Skip to main content

renew/
lib.rs

1#[cfg(any(target_os = "macos", test))]
2mod bundle;
3mod engine;
4mod manifest;
5
6pub use engine::{clean_old_binary, install, restart, staging_path};
7pub use manifest::{AppBundle, Binary, Check, Release, platform_key};
8
9use std::time::Duration;
10
11pub const MANIFEST_TIMEOUT: Duration = Duration::from_secs(3);
12pub const DOWNLOAD_TIMEOUT: Duration = Duration::from_secs(180);
13pub const MAX_BINARY_BYTES: u64 = 512 * 1024 * 1024;
14pub const MAX_APP_BYTES: u64 = 1024 * 1024 * 1024;
15
16#[derive(Debug, thiserror::Error)]
17#[non_exhaustive]
18pub enum RenewError {
19    #[error("the release manifest could not be reached")]
20    ManifestUnreachable,
21    #[error("the release manifest did not parse")]
22    ManifestMalformed,
23    #[error("the public key is not a minisign public key")]
24    BadPublicKey,
25    #[error("the release manifest's signature does not verify")]
26    BadSignature,
27    #[error("no binary is published for this platform")]
28    NoPlatformBinary,
29    #[error("the download did not arrive whole")]
30    DownloadFailed,
31    #[error("a version string did not parse")]
32    BadVersion,
33    #[error("the downloaded binary could not be staged: {source}")]
34    StageFailed { source: std::io::Error },
35    #[error("the downloaded binary failed verification: {detail}")]
36    VerifyFailed { detail: String },
37    #[error("refused a plain http url: only https, or http to this machine, is fetched")]
38    InsecureUrl,
39    #[error("the new binary could not be swapped in: {source}")]
40    SwapFailed { source: std::io::Error },
41    #[error("this app runs from a macOS .app bundle and no bundle is published for it")]
42    NoAppBundle,
43    #[error("{} is not writable by this user, so the app can't update itself", path.display())]
44    NotWritable { path: std::path::PathBuf },
45    #[error("the release manifest was issued before {not_before}, so it is stale or replayed")]
46    StaleManifest {
47        issued: Option<u64>,
48        not_before: u64,
49    },
50}
51
52pub fn check(
53    manifest_url: &str,
54    public_key: &str,
55    current_version: &str,
56) -> Result<Check, RenewError> {
57    let public_key = manifest::public_key(public_key)?;
58    let manifest = manifest::fetch_manifest(manifest_url, &public_key)?;
59    manifest.check_against(current_version)
60}
61
62pub fn check_since(
63    manifest_url: &str,
64    public_key: &str,
65    current_version: &str,
66    not_before: u64,
67) -> Result<Check, RenewError> {
68    let public_key = manifest::public_key(public_key)?;
69    let manifest = manifest::fetch_manifest(manifest_url, &public_key)?;
70    manifest.issued_since(not_before)?;
71    manifest.check_against(current_version)
72}
73
74pub(crate) fn get(
75    url: &str,
76    timeout: Duration,
77    failed: RenewError,
78) -> Result<ureq::http::Response<ureq::Body>, RenewError> {
79    let agent: ureq::Agent = ureq::Agent::config_builder()
80        .timeout_global(Some(timeout))
81        .http_status_as_error(false)
82        .https_only(!loopback(url))
83        .build()
84        .into();
85    match agent.get(url).call() {
86        Ok(response) if response.status().is_success() => Ok(response),
87        Err(ureq::Error::RequireHttpsOnly(_)) => Err(RenewError::InsecureUrl),
88        _ => Err(failed),
89    }
90}
91
92fn loopback(url: &str) -> bool {
93    let Ok(uri) = url.parse::<ureq::http::Uri>() else {
94        return false;
95    };
96    let Some(host) = uri.host() else {
97        return false;
98    };
99    let host = host.trim_start_matches('[').trim_end_matches(']');
100    host.eq_ignore_ascii_case("localhost")
101        || host
102            .parse::<std::net::IpAddr>()
103            .is_ok_and(|ip| ip.is_loopback())
104}
105
106#[cfg(test)]
107mod tests {
108    use std::io::{Read, Write};
109
110    use super::*;
111
112    fn keys() -> minisign::KeyPair {
113        minisign::KeyPair::generate_unencrypted_keypair().unwrap()
114    }
115
116    fn signed_manifest(keys: &minisign::KeyPair) -> (Vec<u8>, Vec<u8>) {
117        let raw = format!(
118            r#"{{"version":"1.1.0","platforms":{{"{key}":{{"url":"https://example.invalid/app","sha256":"{sha256}","size":100}}}}}}"#,
119            key = platform_key(),
120            sha256 = "ab".repeat(32),
121        )
122        .into_bytes();
123        let signature = minisign::sign(Some(&keys.pk), &keys.sk, raw.as_slice(), None, None)
124            .unwrap()
125            .into_string()
126            .into_bytes();
127        (raw, signature)
128    }
129
130    fn serve(routes: Vec<(&'static str, Vec<u8>)>) -> String {
131        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
132        let addr = listener.local_addr().unwrap();
133        std::thread::spawn(move || {
134            for socket in listener.incoming() {
135                let Ok(mut socket) = socket else { continue };
136                let mut head = [0u8; 4096];
137                let got = socket.read(&mut head).unwrap_or(0);
138                let request = String::from_utf8_lossy(&head[..got]);
139                let path = request.split_whitespace().nth(1).unwrap_or("");
140                let (status, body) = routes
141                    .iter()
142                    .find(|(route, _)| *route == path)
143                    .map_or(("404 Not Found", &[][..]), |(_, body)| {
144                        ("200 OK", body.as_slice())
145                    });
146                let _ = write!(
147                    socket,
148                    "HTTP/1.1 {status}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
149                    body.len()
150                );
151                let _ = socket.write_all(body);
152            }
153        });
154        format!("http://{addr}/manifest.json")
155    }
156
157    fn served(raw: Vec<u8>, signature: Vec<u8>) -> String {
158        serve(vec![
159            ("/manifest.json", raw),
160            ("/manifest.json.minisig", signature),
161        ])
162    }
163
164    #[test]
165    fn a_signed_newer_manifest_is_available() {
166        let keys = keys();
167        let (raw, signature) = signed_manifest(&keys);
168        let url = served(raw, signature);
169        let update = check(&url, &keys.pk.to_base64(), "1.0.0")
170            .unwrap()
171            .update
172            .expect("a newer signed version must be an update");
173        assert_eq!(update.version, "1.1.0");
174        assert_eq!(update.binary.size, 100);
175    }
176
177    #[test]
178    fn a_flipped_byte_in_the_served_manifest_is_a_bad_signature() {
179        let keys = keys();
180        let (mut raw, signature) = signed_manifest(&keys);
181        raw[0] ^= 1;
182        let url = served(raw, signature);
183        let outcome = check(&url, &keys.pk.to_base64(), "1.0.0");
184        assert!(matches!(outcome, Err(RenewError::BadSignature)));
185    }
186
187    #[test]
188    fn a_manifest_signed_by_another_key_is_a_bad_signature() {
189        let (raw, signature) = signed_manifest(&keys());
190        let url = served(raw, signature);
191        let outcome = check(&url, &keys().pk.to_base64(), "1.0.0");
192        assert!(matches!(outcome, Err(RenewError::BadSignature)));
193    }
194
195    #[test]
196    fn a_malformed_signature_file_is_a_bad_signature() {
197        let keys = keys();
198        let (raw, _) = signed_manifest(&keys);
199        let url = served(raw, b"not a signature".to_vec());
200        let outcome = check(&url, &keys.pk.to_base64(), "1.0.0");
201        assert!(matches!(outcome, Err(RenewError::BadSignature)));
202    }
203
204    #[test]
205    fn a_missing_signature_is_an_unreachable_manifest() {
206        let keys = keys();
207        let (raw, _) = signed_manifest(&keys);
208        let url = serve(vec![("/manifest.json", raw)]);
209        let outcome = check(&url, &keys.pk.to_base64(), "1.0.0");
210        assert!(matches!(outcome, Err(RenewError::ManifestUnreachable)));
211    }
212
213    #[test]
214    fn a_garbage_public_key_is_refused_before_any_fetch() {
215        let outcome = check("http://127.0.0.1:9/manifest.json", "not a key", "1.0.0");
216        assert!(matches!(outcome, Err(RenewError::BadPublicKey)));
217    }
218
219    #[test]
220    fn plain_http_to_another_host_is_refused() {
221        let outcome = check("http://example.com/m.json", &keys().pk.to_base64(), "1.0.0");
222        assert!(matches!(outcome, Err(RenewError::InsecureUrl)));
223    }
224}