Skip to main content

origin_manifest/
distribution.rs

1//! How a product is released (ADR-0030).
2//!
3//! Everything here is *declaration*. Signing identities and notarisation credentials
4//! are never in the manifest — they are CI secrets, and a product that has none builds
5//! unsigned artifacts and says so.
6
7use serde::{Deserialize, Serialize};
8
9/// Which audience a build is for.
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
11#[serde(rename_all = "kebab-case")]
12pub enum Channel {
13    #[default]
14    Stable,
15    Beta,
16    Nightly,
17}
18
19impl Channel {
20    pub fn as_str(self) -> &'static str {
21        match self {
22            Self::Stable => "stable",
23            Self::Beta => "beta",
24            Self::Nightly => "nightly",
25        }
26    }
27}
28
29/// Platforms a release builds for.
30///
31/// The names are written out rather than derived: `rename_all` turns `MacosX86_64` into
32/// `macos-x86-64`, which is not what anyone writes in a manifest and not what
33/// [`Target::as_str`] returns. Two spellings for one target is a bug waiting for a
34/// release day.
35#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
36pub enum Target {
37    #[serde(rename = "macos-aarch64")]
38    MacosAarch64,
39    #[serde(rename = "macos-x86_64")]
40    MacosX86_64,
41    #[serde(rename = "windows-x86_64")]
42    WindowsX86_64,
43    #[serde(rename = "linux-x86_64")]
44    LinuxX86_64,
45}
46
47impl Target {
48    pub fn as_str(self) -> &'static str {
49        match self {
50            Self::MacosAarch64 => "macos-aarch64",
51            Self::MacosX86_64 => "macos-x86_64",
52            Self::WindowsX86_64 => "windows-x86_64",
53            Self::LinuxX86_64 => "linux-x86_64",
54        }
55    }
56
57    /// Whether this target needs code signing to be installable without warnings.
58    pub fn needs_signing(self) -> bool {
59        !matches!(self, Self::LinuxX86_64)
60    }
61}
62
63#[derive(Debug, Clone, Serialize, Deserialize)]
64#[serde(default)]
65pub struct DistributionSection {
66    pub channel: Channel,
67    pub targets: Vec<Target>,
68    pub updater: UpdaterSection,
69}
70
71impl Default for DistributionSection {
72    fn default() -> Self {
73        Self {
74            channel: Channel::default(),
75            targets: vec![
76                Target::MacosAarch64,
77                Target::MacosX86_64,
78                Target::WindowsX86_64,
79                Target::LinuxX86_64,
80            ],
81            updater: UpdaterSection::default(),
82        }
83    }
84}
85
86/// In-app updates.
87///
88/// Off by default, and deliberately so: an updater that cannot verify a signature is
89/// worse than no updater, because it turns a compromised endpoint into arbitrary code
90/// execution on every installation.
91#[derive(Debug, Clone, Default, Serialize, Deserialize)]
92#[serde(default)]
93pub struct UpdaterSection {
94    pub enabled: bool,
95    /// Where the update manifest is published. One per channel is the usual shape.
96    pub endpoints: Vec<String>,
97}
98
99impl DistributionSection {
100    /// Targets that would ship unsigned unless CI has an identity for them.
101    pub fn targets_needing_signing(&self) -> Vec<Target> {
102        self.targets
103            .iter()
104            .copied()
105            .filter(|target| target.needs_signing())
106            .collect()
107    }
108}
109
110#[cfg(test)]
111mod tests {
112    use super::*;
113
114    #[test]
115    fn the_updater_is_off_until_someone_turns_it_on() {
116        let distribution = DistributionSection::default();
117
118        assert!(!distribution.updater.enabled);
119        assert!(distribution.updater.endpoints.is_empty());
120    }
121
122    #[test]
123    fn linux_is_the_one_target_that_ships_without_signing() {
124        let distribution = DistributionSection::default();
125
126        let needing = distribution.targets_needing_signing();
127
128        assert_eq!(needing.len(), 3);
129        assert!(!needing.contains(&Target::LinuxX86_64));
130    }
131
132    /// Guards the mismatch this file's comment describes: the serialised name and the
133    /// displayed name must be the same string, for every target.
134    #[test]
135    fn every_target_serialises_exactly_as_it_displays() {
136        for target in [
137            Target::MacosAarch64,
138            Target::MacosX86_64,
139            Target::WindowsX86_64,
140            Target::LinuxX86_64,
141        ] {
142            let serialised = serde_json::to_string(&target).expect("serialise");
143            assert_eq!(
144                serialised.trim_matches('"'),
145                target.as_str(),
146                "a manifest written with `{}` must parse",
147                target.as_str()
148            );
149
150            let parsed: Target = serde_json::from_str(&serialised).expect("round trip");
151            assert_eq!(parsed, target);
152        }
153    }
154
155    #[test]
156    fn a_channel_round_trips_through_the_manifest_format() {
157        let parsed: Channel = toml::from_str::<toml::Table>("value = \"beta\"")
158            .map(|table| table["value"].clone().try_into().unwrap())
159            .unwrap();
160
161        assert_eq!(parsed, Channel::Beta);
162        assert_eq!(parsed.as_str(), "beta");
163    }
164}