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