cargo_dist_schema/
lib.rs

1#![deny(missing_docs)]
2
3//! # cargo-dist-schema
4//!
5//! This crate exists to serialize and deserialize the dist-manifest.json produced
6//! by dist. Ideally it should be reasonably forward and backward compatible
7//! with different versions of this format.
8//!
9//! The root type of the schema is [`DistManifest`][].
10
11pub mod macros;
12pub use target_lexicon;
13
14use std::{collections::BTreeMap, str::FromStr};
15
16use schemars::JsonSchema;
17use semver::Version;
18use serde::{Deserialize, Serialize};
19use target_lexicon::Triple;
20
21declare_strongly_typed_string! {
22    /// A rustc-like target triple/tuple (e.g. "x86_64-pc-windows-msvc")
23    pub struct TripleName => &TripleNameRef;
24}
25
26impl TripleNameRef {
27    /// Parse as a [`Triple`]
28    pub fn parse(&self) -> Result<Triple, <Triple as FromStr>::Err> {
29        Triple::from_str(self.as_str())
30    }
31
32    /// Returns true if this target triple contains the word "musl"
33    pub fn is_musl(&self) -> bool {
34        self.0.contains("musl")
35    }
36
37    /// Returns true if this target triple contains the word "linux"
38    pub fn is_linux(&self) -> bool {
39        self.0.contains("linux")
40    }
41
42    /// Returns true if this target triple contains the word "apple"
43    pub fn is_apple(&self) -> bool {
44        self.0.contains("apple")
45    }
46
47    /// Returns true if this target triple contains the word "darwin"
48    pub fn is_darwin(&self) -> bool {
49        self.0.contains("darwin")
50    }
51
52    /// Returns true if this target triple contains the word "windows"
53    pub fn is_windows(&self) -> bool {
54        self.0.contains("windows")
55    }
56
57    /// Returns true if this target triple contains the word "x86_64"
58    pub fn is_x86_64(&self) -> bool {
59        self.0.contains("x86_64")
60    }
61
62    /// Returns true if this target triple contains the word "aarch64"
63    pub fn is_aarch64(&self) -> bool {
64        self.0.contains("aarch64")
65    }
66
67    //---------------------------
68    // common combinations
69
70    /// Returns true if this target triple contains the string "linux-musl"
71    pub fn is_linux_musl(&self) -> bool {
72        self.0.contains("linux-musl")
73    }
74
75    /// Returns true if this target triple contains the string "windows-msvc"
76    pub fn is_windows_msvc(&self) -> bool {
77        self.0.contains("windows-msvc")
78    }
79}
80declare_strongly_typed_string! {
81    /// The name of a Github Actions Runner, like `ubuntu-22.04` or `macos-13`
82    pub struct GithubRunner => &GithubRunnerRef;
83
84    /// A container image, like `quay.io/pypa/manylinux_2_28_x86_64`
85    pub struct ContainerImage => &ContainerImageRef;
86}
87
88/// Github runners configuration (which github image/container should be used
89/// to build which target).
90pub type GithubRunners = BTreeMap<TripleName, GithubRunnerConfig>;
91
92impl GithubRunnerRef {
93    /// Does the runner name contain the word "buildjet"?
94    pub fn is_buildjet(&self) -> bool {
95        self.as_str().contains("buildjet")
96    }
97}
98
99/// A value or just a string
100///
101/// This allows us to have a simple string-based version of a config while still
102/// allowing for a more advanced version to exist.
103#[derive(Serialize, Deserialize, Debug, Clone, JsonSchema)]
104#[serde(untagged)]
105pub enum StringLikeOr<S, T> {
106    /// They gave the simple string-like value (see `declare_strongly_typed_string!`)
107    StringLike(S),
108    /// They gave a more interesting value
109    Val(T),
110}
111
112impl<S, T> StringLikeOr<S, T> {
113    /// Constructs a new `StringLikeOr` from the string-like value `s`
114    pub fn from_s(s: S) -> Self {
115        Self::StringLike(s)
116    }
117
118    /// Constructs a new `StringLikeOr` from the more interesting value `t`
119    pub fn from_t(t: T) -> Self {
120        Self::Val(t)
121    }
122}
123
124/// A local system path on the machine dist was run.
125///
126/// This is a String because when deserializing this may be a path format from a different OS!
127pub type LocalPath = String;
128/// A relative path inside an artifact
129///
130/// This is a String because when deserializing this may be a path format from a different OS!
131///
132/// (Should we normalize this one?)
133pub type RelPath = String;
134
135declare_strongly_typed_string! {
136    /// The unique ID of an Artifact
137    pub struct ArtifactId => &ArtifactIdRef;
138}
139
140/// The unique ID of a System
141pub type SystemId = String;
142/// The unique ID of an Asset
143pub type AssetId = String;
144/// A sorted set of values
145pub type SortedSet<T> = std::collections::BTreeSet<T>;
146
147/// A report of the releases and artifacts that dist generated
148#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
149pub struct DistManifest {
150    /// The version of dist that generated this
151    #[serde(default)]
152    #[serde(skip_serializing_if = "Option::is_none")]
153    pub dist_version: Option<String>,
154    /// The (git) tag associated with this announcement
155    #[serde(default)]
156    #[serde(skip_serializing_if = "Option::is_none")]
157    pub announcement_tag: Option<String>,
158    /// True if --tag wasn't explicitly passed to dist. This usually indicates
159    /// some kind of dry-run state like pr-run-mode=upload. Some third-party tools
160    /// may use this as a proxy for "is dry run"
161    #[serde(default)]
162    pub announcement_tag_is_implicit: bool,
163    /// Whether this announcement appears to be a prerelease
164    #[serde(default)]
165    pub announcement_is_prerelease: bool,
166    /// A title for the announcement
167    #[serde(default)]
168    #[serde(skip_serializing_if = "Option::is_none")]
169    pub announcement_title: Option<String>,
170    /// A changelog for the announcement
171    #[serde(default)]
172    #[serde(skip_serializing_if = "Option::is_none")]
173    pub announcement_changelog: Option<String>,
174    /// A Github Releases body for the announcement
175    #[serde(default)]
176    #[serde(skip_serializing_if = "Option::is_none")]
177    pub announcement_github_body: Option<String>,
178    /// Info about the toolchain used to build this announcement
179    ///
180    /// DEPRECATED: never appears anymore
181    #[serde(default)]
182    #[serde(skip_serializing_if = "Option::is_none")]
183    pub system_info: Option<SystemInfo>,
184    /// App releases we're distributing
185    #[serde(default)]
186    #[serde(skip_serializing_if = "Vec::is_empty")]
187    pub releases: Vec<Release>,
188    /// The artifacts included in this Announcement, referenced by releases.
189    #[serde(default)]
190    #[serde(skip_serializing_if = "BTreeMap::is_empty")]
191    pub artifacts: BTreeMap<ArtifactId, Artifact>,
192    /// The systems that artifacts were built on
193    #[serde(default)]
194    #[serde(skip_serializing_if = "BTreeMap::is_empty")]
195    pub systems: BTreeMap<SystemId, SystemInfo>,
196    /// The assets contained within artifacts (binaries)
197    #[serde(default)]
198    #[serde(skip_serializing_if = "BTreeMap::is_empty")]
199    pub assets: BTreeMap<AssetId, AssetInfo>,
200    /// Whether to publish prereleases to package managers
201    #[serde(default)]
202    pub publish_prereleases: bool,
203    /// Where possible, announce/publish a release as "latest" regardless of semver version
204    #[serde(default)]
205    pub force_latest: bool,
206    /// ci backend info
207    #[serde(default)]
208    #[serde(skip_serializing_if = "Option::is_none")]
209    pub ci: Option<CiInfo>,
210    /// Data about dynamic linkage in the built libraries
211    #[serde(default)]
212    // FIXME: turn on this skip_serializing_if at some point.
213    // old dist-manifest consumers don't think this field can
214    // be missing, so it's unsafe to stop emitting it, but
215    // we want to deprecate it at some point.
216    // #[serde(skip_serializing_if = "Vec::is_empty")]
217    pub linkage: Vec<Linkage>,
218    /// Files to upload
219    #[serde(default)]
220    // We need to make sure we always serialize this when it's empty,
221    // because we index into this array unconditionally during upload.
222    pub upload_files: Vec<String>,
223    /// Whether Artifact Attestations should be found in the GitHub Release
224    ///
225    /// <https://github.blog/2024-05-02-introducing-artifact-attestations-now-in-public-beta/>
226    #[serde(default)]
227    #[serde(skip_serializing_if = "std::ops::Not::not")]
228    pub github_attestations: bool,
229    /// Patterns to attest when creating Artifact Attestations
230    #[serde(default)]
231    #[serde(skip_serializing_if = "GithubAttestationsFilters::is_default")]
232    pub github_attestations_filters: GithubAttestationsFilters,
233    /// When to generate Artifact Attestations
234    ///
235    /// Defaults to "build-local-artifacts" for backwards compatibility
236    #[serde(default)]
237    #[serde(skip_serializing_if = "GithubAttestationsPhase::is_default")]
238    pub github_attestations_phase: GithubAttestationsPhase,
239}
240
241/// Information about the build environment on this system
242#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
243pub enum BuildEnvironment {
244    /// Linux-specific information
245    #[serde(rename = "linux")]
246    Linux {
247        /// The builder's glibc version, relevant to glibc-based
248        /// builds.
249        glibc_version: Option<GlibcVersion>,
250    },
251    /// macOS-specific information
252    #[serde(rename = "macos")]
253    MacOS {
254        /// The version of macOS used by the builder
255        os_version: String,
256    },
257    /// Windows-specific information
258    #[serde(rename = "windows")]
259    Windows,
260    /// Unable to determine what the host OS was - error?
261    #[serde(rename = "indeterminate")]
262    Indeterminate,
263}
264
265/// Minimum glibc version required to run software
266#[derive(
267    Debug, Clone, Serialize, Deserialize, JsonSchema, Hash, PartialEq, Eq, PartialOrd, Ord,
268)]
269pub struct GlibcVersion {
270    /// Major version
271    pub major: u64,
272    /// Series (minor) version
273    pub series: u64,
274}
275
276impl Default for GlibcVersion {
277    fn default() -> Self {
278        Self {
279            // Values from the default Ubuntu runner
280            major: 2,
281            series: 31,
282        }
283    }
284}
285
286/// Info about an Asset (binary)
287#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
288pub struct AssetInfo {
289    /// unique id of the Asset
290    pub id: AssetId,
291    /// filename of the Asset
292    pub name: String,
293    /// the system it was built on
294    pub system: SystemId,
295    /// rust-style target triples the Asset natively supports
296    ///
297    /// * length 0: not a meaningful question, maybe some static file
298    /// * length 1: typical of binaries
299    /// * length 2+: some kind of universal binary
300    pub target_triples: Vec<TripleName>,
301    /// the linkage of this Asset
302    pub linkage: Option<Linkage>,
303}
304
305/// CI backend info
306#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
307pub struct CiInfo {
308    /// GitHub CI backend
309    #[serde(skip_serializing_if = "Option::is_none")]
310    pub github: Option<GithubCiInfo>,
311}
312
313/// Github CI backend
314#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
315pub struct GithubCiInfo {
316    /// Github CI Matrix for upload-artifacts
317    #[serde(skip_serializing_if = "Option::is_none")]
318    pub artifacts_matrix: Option<GithubMatrix>,
319
320    /// What kind of job to run on pull request
321    #[serde(skip_serializing_if = "Option::is_none")]
322    pub pr_run_mode: Option<PrRunMode>,
323
324    /// A specific commit to tag in an external repository
325    #[serde(skip_serializing_if = "Option::is_none")]
326    pub external_repo_commit: Option<String>,
327}
328
329/// Github CI Matrix
330#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
331pub struct GithubMatrix {
332    /// define each task manually rather than doing cross-product stuff
333    #[serde(default)]
334    #[serde(skip_serializing_if = "Vec::is_empty")]
335    pub include: Vec<GithubLocalJobConfig>,
336}
337
338impl GithubMatrix {
339    /// Gets if the matrix has no entries
340    ///
341    /// this is useful for checking if there should be No matrix
342    pub fn is_empty(&self) -> bool {
343        self.include.is_empty()
344    }
345}
346
347declare_strongly_typed_string! {
348    /// A bit of shell script to install brew/apt/chocolatey/etc. packages
349    pub struct PackageInstallScript => &PackageInstallScriptRef;
350}
351
352/// The version of `GithubRunnerConfig` that's deserialized from the config file: it
353/// has optional fields that are computed later.
354#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
355pub struct GithubRunnerConfigInput {
356    /// GHA's `runs-on` key: Github Runner image to use, see <https://github.com/actions/runner-images>
357    /// and <https://docs.github.com/en/actions/writing-workflows/choosing-where-your-workflow-runs/choosing-the-runner-for-a-job>
358    ///
359    /// This is not necessarily a well-known runner, it could be something self-hosted, it
360    /// could be from BuildJet, Namespace, etc.
361    ///
362    /// If not specified, `container` has to be set.
363    pub runner: Option<GithubRunner>,
364
365    /// Host triple of the runner (well-known, custom, or best guess).
366    /// If the runner is one of GitHub's official runner images, the platform
367    /// is hardcoded. If it's custom, then we have a `target_triple => runner` in the config
368    pub host: Option<TripleName>,
369
370    /// Container image to run the job in, using GitHub's builtin
371    /// container support, see <https://docs.github.com/en/actions/writing-workflows/choosing-where-your-workflow-runs/running-jobs-in-a-container>
372    ///
373    /// This doesn't allow mounting volumes, or anything, because we're only able
374    /// to set the `container` key to something stringy
375    ///
376    /// If not specified, `runner` has to be set.
377    pub container: Option<StringLikeOr<ContainerImage, ContainerConfigInput>>,
378}
379
380/// GitHub config that's common between different kinds of jobs (global, local)
381#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq, PartialOrd, Ord)]
382pub struct GithubRunnerConfig {
383    /// GHA's `runs-on` key: Github Runner image to use, see <https://github.com/actions/runner-images>
384    /// and <https://docs.github.com/en/actions/writing-workflows/choosing-where-your-workflow-runs/choosing-the-runner-for-a-job>
385    ///
386    /// This is not necessarily a well-known runner, it could be something self-hosted, it
387    /// could be from BuildJet, Namespace, etc.
388    pub runner: GithubRunner,
389
390    /// Host triple of the runner (well-known, custom, or best guess).
391    /// If the runner is one of GitHub's official runner images, the platform
392    /// is hardcoded. If it's custom, then we have a `target_triple => runner` in the config
393    pub host: TripleName,
394
395    /// Container image to run the job in, using GitHub's builtin
396    /// container support, see <https://docs.github.com/en/actions/writing-workflows/choosing-where-your-workflow-runs/running-jobs-in-a-container>
397    ///
398    /// This doesn't allow mounting volumes, or anything, because we're only able
399    /// to set the `container` key to something stringy
400    #[serde(skip_serializing_if = "Option::is_none")]
401    pub container: Option<ContainerConfig>,
402}
403
404impl GithubRunnerConfig {
405    /// If the container runs through a container, that container might have a different
406    /// architecture than the outer VM — this returns the container's triple if any,
407    /// and falls back to the "machine"'s triple if not.
408    pub fn real_triple_name(&self) -> &TripleNameRef {
409        if let Some(container) = &self.container {
410            &container.host
411        } else {
412            &self.host
413        }
414    }
415
416    /// cf. [`Self::real_triple_name`], but parsed
417    pub fn real_triple(&self) -> Triple {
418        self.real_triple_name().parse().unwrap()
419    }
420}
421
422/// GitHub config that's common between different kinds of jobs (global, local)
423#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
424pub struct ContainerConfigInput {
425    /// The container image to run, something like `ubuntu:22.04` or
426    /// `quay.io/pypa/manylinux_2_28_x86_64`
427    pub image: ContainerImage,
428
429    /// The host triple of the container, something like `x86_64-unknown-linux-gnu`
430    /// or `aarch64-unknown-linux-musl` or whatever.
431    pub host: Option<TripleName>,
432
433    /// The package manager to use within the container, like `apt`.
434    #[serde(rename = "package-manager")]
435    pub package_manager: Option<PackageManager>,
436}
437
438/// GitHub config that's common between different kinds of jobs (global, local)
439#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq, PartialOrd, Ord)]
440pub struct ContainerConfig {
441    /// The container image to run, something like `ubuntu:22.04` or
442    /// `quay.io/pypa/manylinux_2_28_x86_64`
443    pub image: ContainerImage,
444
445    /// The host triple of the container, something like `x86_64-unknown-linux-gnu`
446    /// or `aarch64-unknown-linux-musl` or whatever.
447    pub host: TripleName,
448
449    /// The package manager to use within the container, like `apt`.
450    pub package_manager: Option<PackageManager>,
451}
452
453/// Used in `github/release.yml.j2` to template out "global" build jobs
454/// (plan, global assets, announce, etc)
455#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
456pub struct GithubGlobalJobConfig {
457    /// Where to run this job?
458    #[serde(flatten)]
459    pub runner: GithubRunnerConfig,
460
461    /// Expression to execute to install dist
462    pub install_dist: GhaRunStep,
463
464    /// Arguments to pass to dist
465    pub dist_args: String,
466
467    /// Expression to execute to install cargo-cyclonedx
468    #[serde(skip_serializing_if = "Option::is_none")]
469    pub install_cargo_cyclonedx: Option<GhaRunStep>,
470
471    #[serde(skip_serializing_if = "Option::is_none")]
472    /// Expression to execute to install omnibor-cli
473    pub install_omnibor: Option<GhaRunStep>,
474}
475
476/// Used in `github/release.yml.j2` to template out "local" build jobs
477#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
478pub struct GithubLocalJobConfig {
479    /// Where to run this job?
480    #[serde(flatten)]
481    pub runner: GithubRunnerConfig,
482
483    /// Expression to execute to install dist
484    pub install_dist: GhaRunStep,
485
486    /// Arguments to pass to dist
487    pub dist_args: String,
488
489    /// Target triples to build for
490    #[serde(skip_serializing_if = "Option::is_none")]
491    pub targets: Option<Vec<TripleName>>,
492
493    /// Expression to execute to install cargo-auditable
494    #[serde(skip_serializing_if = "Option::is_none")]
495    pub install_cargo_auditable: Option<GhaRunStep>,
496
497    /// Expression to execute to install omnibor-cli
498    #[serde(skip_serializing_if = "Option::is_none")]
499    pub install_omnibor: Option<GhaRunStep>,
500
501    /// Command to run to install dependencies
502    #[serde(skip_serializing_if = "Option::is_none")]
503    pub packages_install: Option<PackageInstallScript>,
504
505    /// What cache provider to use
506    #[serde(skip_serializing_if = "Option::is_none")]
507    pub cache_provider: Option<String>,
508}
509
510/// Used to capture GitHub Attestations filters
511#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
512pub struct GithubAttestationsFilters(Vec<String>);
513
514impl Default for GithubAttestationsFilters {
515    fn default() -> Self {
516        Self(vec!["*".to_string()])
517    }
518}
519
520impl<'a> IntoIterator for &'a GithubAttestationsFilters {
521    type Item = &'a String;
522    type IntoIter = std::slice::Iter<'a, String>;
523
524    fn into_iter(self) -> Self::IntoIter {
525        self.0.iter()
526    }
527}
528
529impl GithubAttestationsFilters {
530    fn is_default(&self) -> bool {
531        *self == Default::default()
532    }
533}
534
535/// Phase in which to generate GitHub attestations
536#[derive(Debug, Copy, Clone, Serialize, Deserialize, JsonSchema, Default)]
537pub enum GithubAttestationsPhase {
538    /// Generate attestations during the `announce` phase
539    #[serde(rename = "announce")]
540    Announce,
541    /// Generate attestations during the `host` phase
542    #[serde(rename = "host")]
543    Host,
544    /// Generate attestations during `build-local-artifacts` (default for backwards compatibility)
545    #[default]
546    #[serde(rename = "build-local-artifacts")]
547    BuildLocalArtifacts,
548}
549
550impl GithubAttestationsPhase {
551    fn is_default(&self) -> bool {
552        matches!(self, GithubAttestationsPhase::BuildLocalArtifacts)
553    }
554}
555
556impl std::fmt::Display for GithubAttestationsPhase {
557    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
558        match self {
559            GithubAttestationsPhase::Announce => write!(f, "announce"),
560            GithubAttestationsPhase::Host => write!(f, "host"),
561            GithubAttestationsPhase::BuildLocalArtifacts => write!(f, "build-local-artifacts"),
562        }
563    }
564}
565
566/// A GitHub Actions "run" step, either bash or powershell
567#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
568// this mirrors GHA's structure, see
569//   * <https://serde.rs/enum-representations.html>
570//   * <https://docs.github.com/en/actions/writing-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsshell>
571#[serde(tag = "shell", content = "run")]
572pub enum GhaRunStep {
573    /// see [`DashScript`]
574    #[serde(rename = "sh")]
575    Dash(DashScript),
576    /// see [`PowershellScript`]
577    #[serde(rename = "pwsh")]
578    Powershell(PowershellScript),
579}
580
581impl From<DashScript> for GhaRunStep {
582    fn from(bash: DashScript) -> Self {
583        Self::Dash(bash)
584    }
585}
586
587impl From<PowershellScript> for GhaRunStep {
588    fn from(powershell: PowershellScript) -> Self {
589        Self::Powershell(powershell)
590    }
591}
592
593declare_strongly_typed_string! {
594    /// A bit of shell script (that can run with `/bin/sh`), ran on CI runners. Can be multi-line.
595    pub struct DashScript => &DashScriptRef;
596
597    /// A bit of powershell script, ran on CI runners. Can be multi-line.
598    pub struct PowershellScript => &PowershellScriptRef;
599}
600
601/// Type of job to run on pull request
602#[derive(
603    Debug, Copy, Clone, Serialize, Deserialize, JsonSchema, Default, PartialEq, Eq, PartialOrd, Ord,
604)]
605pub enum PrRunMode {
606    /// Do not run on pull requests at all
607    #[serde(rename = "skip")]
608    Skip,
609    /// Only run the plan step
610    #[default]
611    #[serde(rename = "plan")]
612    Plan,
613    /// Build and upload artifacts
614    #[serde(rename = "upload")]
615    Upload,
616}
617
618impl std::fmt::Display for PrRunMode {
619    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
620        match self {
621            PrRunMode::Skip => write!(f, "skip"),
622            PrRunMode::Plan => write!(f, "plan"),
623            PrRunMode::Upload => write!(f, "upload"),
624        }
625    }
626}
627
628/// Info about a system used to build this announcement.
629#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
630pub struct SystemInfo {
631    /// The unique id of the System
632    pub id: SystemId,
633    /// The version of Cargo used (first line of cargo -vV)
634    pub cargo_version_line: Option<String>,
635    /// Environment of the System
636    pub build_environment: BuildEnvironment,
637}
638
639/// Release-specific environment variables
640#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
641pub struct EnvironmentVariables {
642    /// Environment variable to force an install location
643    pub install_dir_env_var: String,
644    /// Environment variable to force an unmanaged install location
645    pub unmanaged_dir_env_var: String,
646    /// Environment variable to disable updater features
647    pub disable_update_env_var: String,
648    /// Environment variable to disable modifying the path
649    pub no_modify_path_env_var: String,
650    /// Environment variable to make the installer more quiet
651    pub print_quiet_env_var: String,
652    /// Environment variable to make the installer more verbose
653    pub print_verbose_env_var: String,
654    /// Environment variable to override the URL to download from
655    ///
656    /// This trumps the base_url env vars below.
657    pub download_url_env_var: String,
658    /// Environment variable to set the GitHub base URL
659    ///
660    /// `{owner}/{repo}` will be added to the end of this value to
661    /// construct the installer_download_url.
662    pub github_base_url_env_var: String,
663    /// Environment variable to set the GitHub Enterprise base URL
664    ///
665    /// `{owner}/{repo}` will be added to the end of this value to
666    /// construct the installer_download_url.
667    pub ghe_base_url_env_var: String,
668    /// Environment variable to set the GitHub BEARER token when fetching archives
669    pub github_token_env_var: String,
670}
671
672/// A Release of an Application
673#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
674pub struct Release {
675    /// The name of the app
676    pub app_name: String,
677    /// The version of the app
678    // FIXME: should be a Version but JsonSchema doesn't support (yet?)
679    pub app_version: String,
680    /// Environment variables which control this release's installer's behaviour
681    #[serde(default)]
682    #[serde(skip_serializing_if = "Option::is_none")]
683    pub env: Option<EnvironmentVariables>,
684    /// Alternative display name that can be prettier
685    #[serde(default)]
686    #[serde(skip_serializing_if = "Option::is_none")]
687    pub display_name: Option<String>,
688    /// Whether to advertise this app's installers/artifacts in announcements
689    #[serde(default)]
690    #[serde(skip_serializing_if = "Option::is_none")]
691    pub display: Option<bool>,
692    /// The artifacts for this release (zips, debuginfo, metadata...)
693    #[serde(default)]
694    #[serde(skip_serializing_if = "Vec::is_empty")]
695    pub artifacts: Vec<ArtifactId>,
696    /// Hosting info
697    #[serde(default)]
698    #[serde(skip_serializing_if = "Hosting::is_empty")]
699    pub hosting: Hosting,
700}
701
702declare_strongly_typed_string! {
703    /// A lowercase descriptor for a checksum algorithm, like "sha256"
704    /// or "blake2b".
705    ///
706    /// TODO(amos): Honestly this type should not exist, it's just what
707    /// `ChecksumStyle` serializes to. `ChecksumsStyle` should just
708    /// be serializable, that's it.
709    pub struct ChecksumExtension => &ChecksumExtensionRef;
710
711    /// A checksum value, usually the lower-cased hex string of the checksum
712    pub struct ChecksumValue => &ChecksumValueRef;
713}
714
715/// A distributable artifact that's part of a Release
716///
717/// i.e. a zip or installer
718#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
719pub struct Artifact {
720    /// The unique name of the artifact (e.g. `myapp-v1.0.0-x86_64-pc-windows-msvc.zip`)
721    ///
722    /// If this is missing then that indicates the artifact is purely informative and has
723    /// no physical files associated with it. This may be used (in the future) to e.g.
724    /// indicate you can install the application with `cargo install` or `npm install`.
725    #[serde(skip_serializing_if = "Option::is_none")]
726    #[serde(default)]
727    pub name: Option<ArtifactId>,
728    /// The kind of artifact this is (e.g. "executable-zip")
729    #[serde(flatten)]
730    pub kind: ArtifactKind,
731    /// The target triple of the bundle
732    #[serde(skip_serializing_if = "Vec::is_empty")]
733    #[serde(default)]
734    pub target_triples: Vec<TripleName>,
735    /// The location of the artifact on the local system
736    #[serde(skip_serializing_if = "Option::is_none")]
737    #[serde(default)]
738    pub path: Option<LocalPath>,
739    /// Assets included in the bundle (like executables and READMEs)
740    #[serde(skip_serializing_if = "Vec::is_empty")]
741    #[serde(default)]
742    pub assets: Vec<Asset>,
743    /// A string describing how to install this
744    #[serde(skip_serializing_if = "Option::is_none")]
745    #[serde(default)]
746    pub install_hint: Option<String>,
747    /// A brief description of what this artifact is
748    #[serde(skip_serializing_if = "Option::is_none")]
749    #[serde(default)]
750    pub description: Option<String>,
751    /// id of an Artifact that contains the checksum for this Artifact
752    #[serde(skip_serializing_if = "Option::is_none")]
753    #[serde(default)]
754    pub checksum: Option<ArtifactId>,
755    /// checksums for this artifact
756    ///
757    /// keys are the name of an algorithm like "sha256" or "sha512"
758    /// values are the actual hex string of the checksum
759    #[serde(default)]
760    #[serde(skip_serializing_if = "BTreeMap::is_empty")]
761    pub checksums: BTreeMap<ChecksumExtension, ChecksumValue>,
762}
763
764/// An asset contained in an artifact (executable, license, etc.)
765#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
766pub struct Asset {
767    /// A unique opaque id for an Asset
768    #[serde(default)]
769    #[serde(skip_serializing_if = "Option::is_none")]
770    pub id: Option<String>,
771    /// The high-level name of the asset
772    #[serde(default)]
773    #[serde(skip_serializing_if = "Option::is_none")]
774    pub name: Option<String>,
775    /// The path of the asset relative to the root of the artifact
776    #[serde(default)]
777    #[serde(skip_serializing_if = "Option::is_none")]
778    pub path: Option<RelPath>,
779    /// The kind of asset this is
780    #[serde(flatten)]
781    pub kind: AssetKind,
782}
783
784/// An artifact included in a Distributable
785#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
786#[serde(tag = "kind")]
787#[non_exhaustive]
788pub enum AssetKind {
789    /// An executable artifact
790    #[serde(rename = "executable")]
791    Executable(ExecutableAsset),
792    /// A C dynamic library
793    #[serde(rename = "c_dynamic_library")]
794    CDynamicLibrary(DynamicLibraryAsset),
795    /// A C static library
796    #[serde(rename = "c_static_library")]
797    CStaticLibrary(StaticLibraryAsset),
798    /// A README file
799    #[serde(rename = "readme")]
800    Readme,
801    /// A LICENSE file
802    #[serde(rename = "license")]
803    License,
804    /// A CHANGELOG or RELEASES file
805    #[serde(rename = "changelog")]
806    Changelog,
807    /// Unknown to this version of cargo-dist-schema
808    ///
809    /// This is a fallback for forward/backward-compat
810    #[serde(other)]
811    #[serde(rename = "unknown")]
812    Unknown,
813}
814
815/// A kind of Artifact
816#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
817#[serde(tag = "kind")]
818#[non_exhaustive]
819pub enum ArtifactKind {
820    /// A zip or a tarball
821    #[serde(rename = "executable-zip")]
822    ExecutableZip,
823    /// Standalone Symbols/Debuginfo for a build
824    #[serde(rename = "symbols")]
825    Symbols,
826    /// Installer
827    #[serde(rename = "installer")]
828    Installer,
829    /// A checksum of another artifact
830    #[serde(rename = "checksum")]
831    Checksum,
832    /// The checksums of many artifacts
833    #[serde(rename = "unified-checksum")]
834    UnifiedChecksum,
835    /// A tarball containing the source code
836    #[serde(rename = "source-tarball")]
837    SourceTarball,
838    /// Some form of extra artifact produced by a sidecar build
839    #[serde(rename = "extra-artifact")]
840    ExtraArtifact,
841    /// An updater executable
842    #[serde(rename = "updater")]
843    Updater,
844    /// A file that already exists
845    #[serde(rename = "sbom")]
846    SBOM,
847    /// An OmniBOR Artifact ID
848    #[serde(rename = "omnibor-artifact-id")]
849    OmniborArtifactId,
850    /// Unknown to this version of cargo-dist-schema
851    ///
852    /// This is a fallback for forward/backward-compat
853    #[serde(other)]
854    #[serde(rename = "unknown")]
855    Unknown,
856}
857
858/// An executable artifact (exe/binary)
859#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
860pub struct ExecutableAsset {
861    /// The name of the Artifact containing symbols for this executable
862    #[serde(skip_serializing_if = "Option::is_none")]
863    #[serde(default)]
864    pub symbols_artifact: Option<ArtifactId>,
865}
866
867/// A C dynamic library artifact (so/dylib/dll)
868#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
869pub struct DynamicLibraryAsset {
870    /// The name of the Artifact containing symbols for this library
871    #[serde(skip_serializing_if = "Option::is_none")]
872    #[serde(default)]
873    pub symbols_artifact: Option<ArtifactId>,
874}
875
876/// A C static library artifact (a/lib)
877#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
878pub struct StaticLibraryAsset {
879    /// The name of the Artifact containing symbols for this library
880    #[serde(skip_serializing_if = "Option::is_none")]
881    #[serde(default)]
882    pub symbols_artifact: Option<ArtifactId>,
883}
884
885/// Info about a manifest version
886pub struct VersionInfo {
887    /// The version
888    pub version: Version,
889    /// The rough epoch of the format
890    pub format: Format,
891}
892
893/// The current version of cargo-dist-schema
894pub const SELF_VERSION: &str = env!("CARGO_PKG_VERSION");
895/// The first epoch of cargo-dist, after this version a bunch of things changed
896/// and we don't support that design anymore!
897pub const DIST_EPOCH_1_MAX: &str = "0.0.3-prerelease8";
898/// Second epoch of cargo-dist, after this we stopped putting versions in artifact ids.
899/// This changes the download URL, but everything else works the same.
900pub const DIST_EPOCH_2_MAX: &str = "0.0.6-prerelease6";
901
902/// More coarse-grained version info, indicating periods when significant changes were made
903#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
904pub enum Format {
905    /// THE BEFORE TIMES -- Unsupported
906    Epoch1,
907    /// First stable versions; during this epoch artifact names/ids contained their version numbers.
908    Epoch2,
909    /// Same as Epoch2, but now artifact names/ids don't include the version number,
910    /// making /latest/ a stable path/url you can perma-link. This only affects download URLs.
911    Epoch3,
912    /// The version is newer than this version of cargo-dist-schema, so we don't know. Most
913    /// likely it's compatible/readable, but maybe a breaking change was made?
914    Future,
915}
916
917impl Format {
918    /// Whether this format is too old to be supported
919    pub fn unsupported(&self) -> bool {
920        self <= &Format::Epoch1
921    }
922    /// Whether this format has version numbers in artifact names
923    pub fn artifact_names_contain_versions(&self) -> bool {
924        self <= &Format::Epoch2
925    }
926}
927
928impl DistManifest {
929    /// Create a new DistManifest
930    pub fn new(releases: Vec<Release>, artifacts: BTreeMap<ArtifactId, Artifact>) -> Self {
931        Self {
932            dist_version: None,
933            announcement_tag: None,
934            announcement_tag_is_implicit: false,
935            announcement_is_prerelease: false,
936            announcement_title: None,
937            announcement_changelog: None,
938            announcement_github_body: None,
939            github_attestations: false,
940            github_attestations_filters: Default::default(),
941            github_attestations_phase: Default::default(),
942            system_info: None,
943            releases,
944            artifacts,
945            systems: Default::default(),
946            assets: Default::default(),
947            publish_prereleases: false,
948            force_latest: false,
949            ci: None,
950            linkage: vec![],
951            upload_files: vec![],
952        }
953    }
954
955    /// Get the JSON Schema for a DistManifest
956    pub fn json_schema() -> schemars::Schema {
957        schemars::schema_for!(DistManifest)
958    }
959
960    /// Get the format of the manifest
961    ///
962    /// If anything goes wrong we'll default to Format::Future
963    pub fn format(&self) -> Format {
964        self.dist_version
965            .as_ref()
966            .and_then(|v| v.parse().ok())
967            .map(|v| format_of_version(&v))
968            .unwrap_or(Format::Future)
969    }
970
971    /// Convenience for iterating artifacts
972    pub fn artifacts_for_release<'a>(
973        &'a self,
974        release: &'a Release,
975    ) -> impl Iterator<Item = (&'a ArtifactIdRef, &'a Artifact)> {
976        release
977            .artifacts
978            .iter()
979            .filter_map(|k| Some((&**k, self.artifacts.get(k)?)))
980    }
981
982    /// Look up a release by its name
983    pub fn release_by_name(&self, name: &str) -> Option<&Release> {
984        self.releases.iter().find(|r| r.app_name == name)
985    }
986
987    /// Either get the release with the given name, or make a minimal one
988    /// with no hosting/artifacts (to be populated)
989    pub fn ensure_release(&mut self, name: String, version: String) -> &mut Release {
990        // Written slightly awkwardly to make the borrowchecker happy :/
991        if let Some(position) = self.releases.iter().position(|r| r.app_name == name) {
992            &mut self.releases[position]
993        } else {
994            let env_app_name = name.to_ascii_uppercase().replace('-', "_");
995            let install_dir_env_var = format!("{env_app_name}_INSTALL_DIR");
996            let download_url_env_var = format!("{env_app_name}_DOWNLOAD_URL");
997            let unmanaged_dir_env_var = format!("{env_app_name}_UNMANAGED_INSTALL");
998            let disable_update_env_var = format!("{env_app_name}_DISABLE_UPDATE");
999            let print_quiet_env_var = format!("{env_app_name}_PRINT_QUIET");
1000            let print_verbose_env_var = format!("{env_app_name}_PRINT_VERBOSE");
1001            let no_modify_path_env_var = format!("{env_app_name}_NO_MODIFY_PATH");
1002            let github_base_url_env_var = format!("{env_app_name}_INSTALLER_GITHUB_BASE_URL");
1003            let ghe_base_url_env_var = format!("{env_app_name}_INSTALLER_GHE_BASE_URL");
1004            let github_token_env_var = format!("{env_app_name}_GITHUB_TOKEN");
1005
1006            let environment_variables = EnvironmentVariables {
1007                install_dir_env_var,
1008                download_url_env_var,
1009                unmanaged_dir_env_var,
1010                disable_update_env_var,
1011                print_quiet_env_var,
1012                print_verbose_env_var,
1013                no_modify_path_env_var,
1014                github_base_url_env_var,
1015                ghe_base_url_env_var,
1016                github_token_env_var,
1017            };
1018
1019            self.releases.push(Release {
1020                app_name: name,
1021                app_version: version,
1022                env: Some(environment_variables),
1023                artifacts: vec![],
1024                hosting: Hosting::default(),
1025                display: None,
1026                display_name: None,
1027            });
1028            self.releases.last_mut().unwrap()
1029        }
1030    }
1031
1032    /// Get the merged linkage for an artifact
1033    ///
1034    /// This lets you know what system dependencies an entire archive of binaries requires
1035    pub fn linkage_for_artifact(&self, artifact_id: &ArtifactId) -> Linkage {
1036        let mut output = Linkage::default();
1037
1038        let Some(artifact) = self.artifacts.get(artifact_id) else {
1039            return output;
1040        };
1041        for base_asset in &artifact.assets {
1042            let Some(asset_id) = &base_asset.id else {
1043                continue;
1044            };
1045            let Some(true_asset) = self.assets.get(asset_id) else {
1046                continue;
1047            };
1048            let Some(linkage) = &true_asset.linkage else {
1049                continue;
1050            };
1051            output.extend(linkage);
1052        }
1053
1054        output
1055    }
1056}
1057
1058impl Release {
1059    /// Get the base URL that artifacts should be downloaded from (append the artifact name to the URL)
1060    pub fn artifact_download_url(&self) -> Option<String> {
1061        self.hosting.artifact_download_url()
1062    }
1063}
1064
1065/// Possible hosting providers
1066#[derive(Clone, Debug, Default, Deserialize, Serialize, JsonSchema)]
1067#[serde(rename_all = "kebab-case")]
1068pub struct Hosting {
1069    /// Hosted on Github Releases
1070    #[serde(default)]
1071    #[serde(skip_serializing_if = "Option::is_none")]
1072    pub github: Option<GithubHosting>,
1073}
1074
1075/// Github Hosting
1076#[derive(Clone, Debug, Deserialize, Serialize, JsonSchema)]
1077pub struct GithubHosting {
1078    /// The URL of the host for GitHub, usually `"https://github.com"`
1079    /// (This can vary for GitHub Enterprise)
1080    pub artifact_base_url: String,
1081    /// The path of the release without the base URL
1082    ///
1083    /// e.g. `/myowner/myrepo/releases/download/v1.0.0/`
1084    pub artifact_download_path: String,
1085    /// The owner of the repo
1086    pub owner: String,
1087    /// The name of the repo
1088    pub repo: String,
1089}
1090
1091impl Hosting {
1092    /// Get the base URL that artifacts should be downloaded from (append the artifact name to the URL)
1093    pub fn artifact_download_url(&self) -> Option<String> {
1094        let Hosting { github } = &self;
1095        if let Some(host) = &github {
1096            return Some(format!(
1097                "{}{}",
1098                host.artifact_base_url, host.artifact_download_path
1099            ));
1100        }
1101        None
1102    }
1103    /// Gets whether there's no hosting
1104    pub fn is_empty(&self) -> bool {
1105        let Hosting { github } = &self;
1106        github.is_none()
1107    }
1108}
1109
1110/// Information about dynamic libraries used by a binary
1111#[derive(Clone, Default, Debug, Deserialize, Serialize, JsonSchema)]
1112pub struct Linkage {
1113    /// Libraries included with the operating system
1114    #[serde(default)]
1115    #[serde(skip_serializing_if = "SortedSet::is_empty")]
1116    pub system: SortedSet<Library>,
1117    /// Libraries provided by the Homebrew package manager
1118    #[serde(default)]
1119    #[serde(skip_serializing_if = "SortedSet::is_empty")]
1120    pub homebrew: SortedSet<Library>,
1121    /// Public libraries not provided by the system and not managed by any package manager
1122    #[serde(default)]
1123    #[serde(skip_serializing_if = "SortedSet::is_empty")]
1124    pub public_unmanaged: SortedSet<Library>,
1125    /// Libraries which don't fall into any other categories
1126    #[serde(default)]
1127    #[serde(skip_serializing_if = "SortedSet::is_empty")]
1128    pub other: SortedSet<Library>,
1129    /// Frameworks, only used on macOS
1130    #[serde(default)]
1131    #[serde(skip_serializing_if = "SortedSet::is_empty")]
1132    pub frameworks: SortedSet<Library>,
1133}
1134
1135/// Represents the package manager a library was installed by
1136#[derive(
1137    Clone, Copy, Debug, Deserialize, Serialize, JsonSchema, PartialEq, Eq, PartialOrd, Ord,
1138)]
1139#[serde(rename_all = "lowercase")]
1140pub enum PackageManager {
1141    /// Homebrew (usually for Mac)
1142    Homebrew,
1143    /// Apt (Debian, Ubuntu, etc)
1144    Apt,
1145}
1146
1147declare_strongly_typed_string! {
1148    /// A homebrew package name, cf. <https://formulae.brew.sh/>
1149    pub struct HomebrewPackageName => &HomebrewPackageNameRef;
1150
1151    /// An APT package name, cf. <https://en.wikipedia.org/wiki/APT_(software)>
1152    pub struct AptPackageName => &AptPackageNameRef;
1153
1154    /// A chocolatey package name, cf. <https://community.chocolatey.org/packages>
1155    pub struct ChocolateyPackageName => &ChocolateyPackageNameRef;
1156
1157    /// A pip package name
1158    pub struct PipPackageName => &PipPackageNameRef;
1159
1160    /// A package version
1161    pub struct PackageVersion => &PackageVersionRef;
1162}
1163
1164/// Represents a dynamic library located somewhere on the system
1165#[derive(Clone, Debug, Deserialize, Serialize, JsonSchema, PartialEq, Eq, PartialOrd, Ord)]
1166pub struct Library {
1167    /// The path to the library; on platforms without that information, it will be a basename instead
1168    pub path: String,
1169    /// The package from which a library comes, if relevant
1170    #[serde(skip_serializing_if = "Option::is_none")]
1171    pub source: Option<String>,
1172    /// Which package manager provided this library
1173    pub package_manager: Option<PackageManager>,
1174    // FIXME: `HomebrewPackageName` and others are now strongly-typed, which makes having this
1175    // source/packagemanager thingy problematic. Maybe we could just have an enum, with Apt,
1176    // Homebrew, and Chocolatey variants? That would change the schema though.
1177}
1178
1179impl Linkage {
1180    /// merge another linkage into this one
1181    pub fn extend(&mut self, val: &Linkage) {
1182        let Linkage {
1183            system,
1184            homebrew,
1185            public_unmanaged,
1186            other,
1187            frameworks,
1188        } = val;
1189        self.system.extend(system.iter().cloned());
1190        self.homebrew.extend(homebrew.iter().cloned());
1191        self.public_unmanaged
1192            .extend(public_unmanaged.iter().cloned());
1193        self.other.extend(other.iter().cloned());
1194        self.frameworks.extend(frameworks.iter().cloned());
1195    }
1196}
1197
1198impl Library {
1199    /// Make a new Library with the given path and no source
1200    pub fn new(path: String) -> Self {
1201        Self {
1202            path,
1203            source: None,
1204            package_manager: None,
1205        }
1206    }
1207
1208    /// Attempts to guess whether this specific library is glibc or not
1209    pub fn is_glibc(&self) -> bool {
1210        // If we were able to parse the source, we can be pretty precise
1211        if let Some(source) = &self.source {
1212            source == "libc6"
1213        } else {
1214            // Both patterns seen on Ubuntu (on the same system!)
1215            self.path.contains("libc.so.6") ||
1216            // This one will also contain the series version but
1217            // we don't want to be too precise here to avoid
1218            // filtering out later OS releases
1219            // Specifically we want to avoid `libc-musl` or `libc.musl`
1220            self.path.contains("libc-2")
1221        }
1222    }
1223}
1224
1225impl std::fmt::Display for Library {
1226    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1227        if let Some(package) = &self.source {
1228            write!(f, "{} ({package})", self.path)
1229        } else {
1230            write!(f, "{}", self.path)
1231        }
1232    }
1233}
1234
1235/// Helper to read the raw version from serialized json
1236fn dist_version(input: &str) -> Option<Version> {
1237    #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
1238    struct PartialDistManifest {
1239        /// The version of dist that generated this
1240        #[serde(default)]
1241        #[serde(skip_serializing_if = "Option::is_none")]
1242        pub dist_version: Option<String>,
1243    }
1244
1245    let manifest: PartialDistManifest = serde_json::from_str(input).ok()?;
1246    let version: Version = manifest.dist_version?.parse().ok()?;
1247    Some(version)
1248}
1249
1250/// Take serialized json and minimally parse out version info
1251pub fn check_version(input: &str) -> Option<VersionInfo> {
1252    let version = dist_version(input)?;
1253    let format = format_of_version(&version);
1254    Some(VersionInfo { version, format })
1255}
1256
1257/// Get the format for a given version
1258pub fn format_of_version(version: &Version) -> Format {
1259    let epoch1 = Version::parse(DIST_EPOCH_1_MAX).unwrap();
1260    let epoch2 = Version::parse(DIST_EPOCH_2_MAX).unwrap();
1261    let self_ver = Version::parse(SELF_VERSION).unwrap();
1262    if version > &self_ver {
1263        Format::Future
1264    } else if version > &epoch2 {
1265        Format::Epoch3
1266    } else if version > &epoch1 {
1267        Format::Epoch2
1268    } else {
1269        Format::Epoch1
1270    }
1271}
1272
1273#[test]
1274fn emit() {
1275    let schema = DistManifest::json_schema();
1276    let json_schema = serde_json::to_string_pretty(&schema).unwrap();
1277    insta::assert_snapshot!(json_schema);
1278}