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}