1use serde::{Deserialize, Serialize};
22use std::path::PathBuf;
23use std::time::Duration;
24
25#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
27pub enum UpdatePolicy {
28 Silent,
30
31 DownloadAndNotify,
33
34 NotifyOnly,
36
37 Manual,
39
40 CriticalOnly,
42}
43
44impl Default for UpdatePolicy {
45 fn default() -> Self {
46 Self::Silent
47 }
48}
49
50impl UpdatePolicy {
51 #[must_use]
53 pub fn allows_auto_download(&self) -> bool {
54 matches!(self, Self::Silent | Self::DownloadAndNotify)
55 }
56
57 #[must_use]
59 pub fn allows_auto_apply(&self) -> bool {
60 matches!(self, Self::Silent)
61 }
62}
63
64#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
66#[serde(rename_all = "lowercase")]
67pub enum ReleaseChannel {
68 Stable,
70
71 Beta,
73
74 Nightly,
76}
77
78impl Default for ReleaseChannel {
79 fn default() -> Self {
80 Self::Stable
81 }
82}
83
84impl ReleaseChannel {
85 #[must_use]
87 pub fn as_str(&self) -> &'static str {
88 match self {
89 Self::Stable => "stable",
90 Self::Beta => "beta",
91 Self::Nightly => "nightly",
92 }
93 }
94
95 #[must_use]
97 pub fn parse_name(s: &str) -> Option<Self> {
98 match s.to_lowercase().as_str() {
99 "stable" => Some(Self::Stable),
100 "beta" => Some(Self::Beta),
101 "nightly" => Some(Self::Nightly),
102 _ => None,
103 }
104 }
105}
106
107#[derive(Debug, Clone, Serialize, Deserialize)]
109pub struct PinnedKey {
110 pub key_id: String,
112
113 pub public_key: String,
115
116 pub valid_from: u64,
118
119 pub valid_until: u64,
121}
122
123impl PinnedKey {
124 #[must_use]
126 pub fn new(key_id: impl Into<String>, public_key: impl Into<String>) -> Self {
127 Self {
128 key_id: key_id.into(),
129 public_key: public_key.into(),
130 valid_from: 0,
131 valid_until: 0,
132 }
133 }
134
135 #[must_use]
137 pub fn is_valid(&self) -> bool {
138 let now = std::time::SystemTime::now()
139 .duration_since(std::time::UNIX_EPOCH)
140 .map(|d| d.as_secs())
141 .unwrap_or(0);
142
143 now >= self.valid_from && (self.valid_until == 0 || now < self.valid_until)
144 }
145}
146
147#[derive(Debug, Clone, Serialize, Deserialize)]
149pub struct UpdateConfig {
150 pub manifest_url: String,
152
153 pub check_interval: Duration,
155
156 pub policy: UpdatePolicy,
158
159 pub channel: ReleaseChannel,
161
162 pub staging_dir: PathBuf,
164
165 pub backup_dir: PathBuf,
167
168 pub signing_keys: Vec<PinnedKey>,
170
171 pub max_download_size: u64,
173
174 pub download_timeout: Duration,
176
177 pub verify_signatures: bool,
179
180 pub max_retries: u32,
182
183 pub retry_delay: Duration,
185
186 pub user_agent: String,
188}
189
190impl Default for UpdateConfig {
191 fn default() -> Self {
192 let staging_dir = dirs::cache_dir()
193 .map(|d| d.join("saorsa").join("updates"))
194 .unwrap_or_else(|| PathBuf::from("updates"));
195
196 let backup_dir = dirs::data_local_dir()
197 .map(|d| d.join("saorsa").join("backup"))
198 .unwrap_or_else(|| PathBuf::from("backup"));
199
200 Self {
201 manifest_url: "https://releases.saorsa.io/manifest.json".to_string(),
202 check_interval: Duration::from_secs(6 * 3600), policy: UpdatePolicy::default(),
204 channel: ReleaseChannel::default(),
205 staging_dir,
206 backup_dir,
207 signing_keys: Vec::new(),
208 max_download_size: 500 * 1024 * 1024, download_timeout: Duration::from_secs(300), verify_signatures: true,
211 max_retries: 3,
212 retry_delay: Duration::from_secs(5),
213 user_agent: format!("saorsa-core/{}", env!("CARGO_PKG_VERSION")),
214 }
215 }
216}
217
218impl UpdateConfig {
219 #[must_use]
221 pub fn with_manifest_url(mut self, url: impl Into<String>) -> Self {
222 self.manifest_url = url.into();
223 self
224 }
225
226 #[must_use]
228 pub fn with_policy(mut self, policy: UpdatePolicy) -> Self {
229 self.policy = policy;
230 self
231 }
232
233 #[must_use]
235 pub fn with_channel(mut self, channel: ReleaseChannel) -> Self {
236 self.channel = channel;
237 self
238 }
239
240 #[must_use]
242 pub fn with_check_interval(mut self, interval: Duration) -> Self {
243 self.check_interval = interval;
244 self
245 }
246
247 #[must_use]
249 pub fn with_signing_key(mut self, key: PinnedKey) -> Self {
250 self.signing_keys.push(key);
251 self
252 }
253
254 #[must_use]
256 pub fn with_staging_dir(mut self, dir: PathBuf) -> Self {
257 self.staging_dir = dir;
258 self
259 }
260
261 #[must_use]
263 pub fn with_backup_dir(mut self, dir: PathBuf) -> Self {
264 self.backup_dir = dir;
265 self
266 }
267
268 #[must_use]
270 pub fn without_signature_verification(mut self) -> Self {
271 self.verify_signatures = false;
272 self
273 }
274}
275
276pub struct UpdateConfigBuilder {
278 config: UpdateConfig,
279}
280
281impl UpdateConfigBuilder {
282 #[must_use]
284 pub fn new() -> Self {
285 Self {
286 config: UpdateConfig::default(),
287 }
288 }
289
290 #[must_use]
292 pub fn manifest_url(mut self, url: impl Into<String>) -> Self {
293 self.config.manifest_url = url.into();
294 self
295 }
296
297 #[must_use]
299 pub fn policy(mut self, policy: UpdatePolicy) -> Self {
300 self.config.policy = policy;
301 self
302 }
303
304 #[must_use]
306 pub fn channel(mut self, channel: ReleaseChannel) -> Self {
307 self.config.channel = channel;
308 self
309 }
310
311 #[must_use]
313 pub fn check_interval(mut self, interval: Duration) -> Self {
314 self.config.check_interval = interval;
315 self
316 }
317
318 #[must_use]
320 pub fn signing_key(mut self, key: PinnedKey) -> Self {
321 self.config.signing_keys.push(key);
322 self
323 }
324
325 #[must_use]
327 pub fn staging_dir(mut self, dir: PathBuf) -> Self {
328 self.config.staging_dir = dir;
329 self
330 }
331
332 #[must_use]
334 pub fn build(self) -> UpdateConfig {
335 self.config
336 }
337}
338
339impl Default for UpdateConfigBuilder {
340 fn default() -> Self {
341 Self::new()
342 }
343}
344
345#[cfg(test)]
346mod tests {
347 use super::*;
348
349 #[test]
350 fn test_update_policy_default() {
351 assert_eq!(UpdatePolicy::default(), UpdatePolicy::Silent);
352 }
353
354 #[test]
355 fn test_update_policy_auto_download() {
356 assert!(UpdatePolicy::Silent.allows_auto_download());
357 assert!(UpdatePolicy::DownloadAndNotify.allows_auto_download());
358 assert!(!UpdatePolicy::NotifyOnly.allows_auto_download());
359 assert!(!UpdatePolicy::Manual.allows_auto_download());
360 assert!(!UpdatePolicy::CriticalOnly.allows_auto_download());
361 }
362
363 #[test]
364 fn test_release_channel_as_str() {
365 assert_eq!(ReleaseChannel::Stable.as_str(), "stable");
366 assert_eq!(ReleaseChannel::Beta.as_str(), "beta");
367 assert_eq!(ReleaseChannel::Nightly.as_str(), "nightly");
368 }
369
370 #[test]
371 fn test_release_channel_parse_name() {
372 assert_eq!(
373 ReleaseChannel::parse_name("stable"),
374 Some(ReleaseChannel::Stable)
375 );
376 assert_eq!(
377 ReleaseChannel::parse_name("BETA"),
378 Some(ReleaseChannel::Beta)
379 );
380 assert_eq!(ReleaseChannel::parse_name("invalid"), None);
381 }
382
383 #[test]
384 fn test_pinned_key_validity() {
385 let key = PinnedKey::new("test-key", "public-key-data");
386 assert!(key.is_valid()); let future_key = PinnedKey {
389 key_id: "future".to_string(),
390 public_key: "key".to_string(),
391 valid_from: u64::MAX,
392 valid_until: 0,
393 };
394 assert!(!future_key.is_valid());
395 }
396
397 #[test]
398 fn test_config_builder() {
399 let config = UpdateConfigBuilder::new()
400 .manifest_url("https://test.com/manifest")
401 .policy(UpdatePolicy::Manual)
402 .channel(ReleaseChannel::Beta)
403 .check_interval(Duration::from_secs(3600))
404 .build();
405
406 assert_eq!(config.manifest_url, "https://test.com/manifest");
407 assert_eq!(config.policy, UpdatePolicy::Manual);
408 assert_eq!(config.channel, ReleaseChannel::Beta);
409 assert_eq!(config.check_interval, Duration::from_secs(3600));
410 }
411
412 #[test]
413 fn test_config_with_methods() {
414 let config = UpdateConfig::default()
415 .with_manifest_url("https://test.com")
416 .with_policy(UpdatePolicy::CriticalOnly)
417 .with_channel(ReleaseChannel::Nightly);
418
419 assert_eq!(config.manifest_url, "https://test.com");
420 assert_eq!(config.policy, UpdatePolicy::CriticalOnly);
421 assert_eq!(config.channel, ReleaseChannel::Nightly);
422 }
423}