saorsa_core/upgrade/
config.rs

1// Copyright (c) 2025 Saorsa Labs Limited
2
3// This file is part of the Saorsa P2P network.
4
5// Licensed under the AGPL-3.0 license:
6// <https://www.gnu.org/licenses/agpl-3.0.html>
7
8// This program is distributed in the hope that it will be useful,
9// but WITHOUT ANY WARRANTY; without even the implied warranty of
10// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11// GNU Affero General Public License for more details.
12
13// You should have received a copy of the GNU Affero General Public License
14// along with this program. If not, see <https://www.gnu.org/licenses/>.
15
16// Copyright 2024 P2P Foundation
17// SPDX-License-Identifier: AGPL-3.0-or-later
18
19//! Configuration for the auto-upgrade system.
20
21use serde::{Deserialize, Serialize};
22use std::path::PathBuf;
23use std::time::Duration;
24
25/// Update policy controlling when updates are applied.
26#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
27pub enum UpdatePolicy {
28    /// Automatically download and apply updates (DEFAULT).
29    Silent,
30
31    /// Download updates but notify user before applying.
32    DownloadAndNotify,
33
34    /// Only notify about available updates, don't download.
35    NotifyOnly,
36
37    /// Never automatically update - manual only.
38    Manual,
39
40    /// Only force updates for critical security patches.
41    CriticalOnly,
42}
43
44impl Default for UpdatePolicy {
45    fn default() -> Self {
46        Self::Silent
47    }
48}
49
50impl UpdatePolicy {
51    /// Returns whether this policy allows automatic downloads.
52    #[must_use]
53    pub fn allows_auto_download(&self) -> bool {
54        matches!(self, Self::Silent | Self::DownloadAndNotify)
55    }
56
57    /// Returns whether this policy allows automatic application.
58    #[must_use]
59    pub fn allows_auto_apply(&self) -> bool {
60        matches!(self, Self::Silent)
61    }
62}
63
64/// Release channel for updates.
65#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
66#[serde(rename_all = "lowercase")]
67pub enum ReleaseChannel {
68    /// Stable releases - thoroughly tested.
69    Stable,
70
71    /// Beta releases - feature complete but still testing.
72    Beta,
73
74    /// Nightly releases - latest development builds.
75    Nightly,
76}
77
78impl Default for ReleaseChannel {
79    fn default() -> Self {
80        Self::Stable
81    }
82}
83
84impl ReleaseChannel {
85    /// Convert to string identifier.
86    #[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    /// Parse from string name.
96    #[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/// A pinned signing key for update verification.
108#[derive(Debug, Clone, Serialize, Deserialize)]
109pub struct PinnedKey {
110    /// Key identifier.
111    pub key_id: String,
112
113    /// ML-DSA-65 public key bytes (base64 encoded).
114    pub public_key: String,
115
116    /// When this key becomes valid (Unix timestamp).
117    pub valid_from: u64,
118
119    /// When this key expires (Unix timestamp, 0 = no expiry).
120    pub valid_until: u64,
121}
122
123impl PinnedKey {
124    /// Create a new pinned key.
125    #[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    /// Check if this key is currently valid.
136    #[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/// Configuration for the update system.
148#[derive(Debug, Clone, Serialize, Deserialize)]
149pub struct UpdateConfig {
150    /// URL to the update manifest.
151    pub manifest_url: String,
152
153    /// How often to check for updates.
154    pub check_interval: Duration,
155
156    /// Update policy.
157    pub policy: UpdatePolicy,
158
159    /// Release channel to follow.
160    pub channel: ReleaseChannel,
161
162    /// Directory for staging downloaded updates.
163    pub staging_dir: PathBuf,
164
165    /// Directory for backup of current binary.
166    pub backup_dir: PathBuf,
167
168    /// Pinned signing keys for verification.
169    pub signing_keys: Vec<PinnedKey>,
170
171    /// Maximum download size in bytes (default: 500MB).
172    pub max_download_size: u64,
173
174    /// Connection timeout for downloads.
175    pub download_timeout: Duration,
176
177    /// Whether to verify signatures (should always be true in production).
178    pub verify_signatures: bool,
179
180    /// Number of retry attempts for downloads.
181    pub max_retries: u32,
182
183    /// Delay between retries.
184    pub retry_delay: Duration,
185
186    /// User-Agent header for HTTP requests.
187    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), // 6 hours
203            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,       // 500 MB
209            download_timeout: Duration::from_secs(300), // 5 minutes
210            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    /// Create a new config with the given manifest URL.
220    #[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    /// Set the update policy.
227    #[must_use]
228    pub fn with_policy(mut self, policy: UpdatePolicy) -> Self {
229        self.policy = policy;
230        self
231    }
232
233    /// Set the release channel.
234    #[must_use]
235    pub fn with_channel(mut self, channel: ReleaseChannel) -> Self {
236        self.channel = channel;
237        self
238    }
239
240    /// Set the check interval.
241    #[must_use]
242    pub fn with_check_interval(mut self, interval: Duration) -> Self {
243        self.check_interval = interval;
244        self
245    }
246
247    /// Add a pinned signing key.
248    #[must_use]
249    pub fn with_signing_key(mut self, key: PinnedKey) -> Self {
250        self.signing_keys.push(key);
251        self
252    }
253
254    /// Set the staging directory.
255    #[must_use]
256    pub fn with_staging_dir(mut self, dir: PathBuf) -> Self {
257        self.staging_dir = dir;
258        self
259    }
260
261    /// Set the backup directory.
262    #[must_use]
263    pub fn with_backup_dir(mut self, dir: PathBuf) -> Self {
264        self.backup_dir = dir;
265        self
266    }
267
268    /// Disable signature verification (DANGEROUS - only for testing).
269    #[must_use]
270    pub fn without_signature_verification(mut self) -> Self {
271        self.verify_signatures = false;
272        self
273    }
274}
275
276/// Builder for UpdateConfig.
277pub struct UpdateConfigBuilder {
278    config: UpdateConfig,
279}
280
281impl UpdateConfigBuilder {
282    /// Create a new builder with default config.
283    #[must_use]
284    pub fn new() -> Self {
285        Self {
286            config: UpdateConfig::default(),
287        }
288    }
289
290    /// Set manifest URL.
291    #[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    /// Set update policy.
298    #[must_use]
299    pub fn policy(mut self, policy: UpdatePolicy) -> Self {
300        self.config.policy = policy;
301        self
302    }
303
304    /// Set release channel.
305    #[must_use]
306    pub fn channel(mut self, channel: ReleaseChannel) -> Self {
307        self.config.channel = channel;
308        self
309    }
310
311    /// Set check interval.
312    #[must_use]
313    pub fn check_interval(mut self, interval: Duration) -> Self {
314        self.config.check_interval = interval;
315        self
316    }
317
318    /// Add signing key.
319    #[must_use]
320    pub fn signing_key(mut self, key: PinnedKey) -> Self {
321        self.config.signing_keys.push(key);
322        self
323    }
324
325    /// Set staging directory.
326    #[must_use]
327    pub fn staging_dir(mut self, dir: PathBuf) -> Self {
328        self.config.staging_dir = dir;
329        self
330    }
331
332    /// Build the config.
333    #[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()); // No time bounds = always valid
387
388        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}