Skip to main content

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, Default, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
27pub enum UpdatePolicy {
28    /// Automatically download and apply updates (DEFAULT).
29    #[default]
30    Silent,
31
32    /// Download updates but notify user before applying.
33    DownloadAndNotify,
34
35    /// Only notify about available updates, don't download.
36    NotifyOnly,
37
38    /// Never automatically update - manual only.
39    Manual,
40
41    /// Only force updates for critical security patches.
42    CriticalOnly,
43}
44
45impl UpdatePolicy {
46    /// Returns whether this policy allows automatic downloads.
47    #[must_use]
48    pub fn allows_auto_download(&self) -> bool {
49        matches!(self, Self::Silent | Self::DownloadAndNotify)
50    }
51
52    /// Returns whether this policy allows automatic application.
53    #[must_use]
54    pub fn allows_auto_apply(&self) -> bool {
55        matches!(self, Self::Silent)
56    }
57}
58
59/// Release channel for updates.
60#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
61#[serde(rename_all = "lowercase")]
62pub enum ReleaseChannel {
63    /// Stable releases - thoroughly tested.
64    #[default]
65    Stable,
66
67    /// Beta releases - feature complete but still testing.
68    Beta,
69
70    /// Nightly releases - latest development builds.
71    Nightly,
72}
73
74impl ReleaseChannel {
75    /// Convert to string identifier.
76    #[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    /// Parse from string name.
86    #[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/// A pinned signing key for update verification.
98#[derive(Debug, Clone, Serialize, Deserialize)]
99pub struct PinnedKey {
100    /// Key identifier.
101    pub key_id: String,
102
103    /// ML-DSA-65 public key bytes (base64 encoded).
104    pub public_key: String,
105
106    /// When this key becomes valid (Unix timestamp).
107    pub valid_from: u64,
108
109    /// When this key expires (Unix timestamp, 0 = no expiry).
110    pub valid_until: u64,
111}
112
113impl PinnedKey {
114    /// Create a new pinned key.
115    #[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    /// Check if this key is currently valid.
126    #[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/// Configuration for the update system.
138#[derive(Debug, Clone, Serialize, Deserialize)]
139pub struct UpdateConfig {
140    /// URL to the update manifest.
141    pub manifest_url: String,
142
143    /// How often to check for updates.
144    pub check_interval: Duration,
145
146    /// Update policy.
147    pub policy: UpdatePolicy,
148
149    /// Release channel to follow.
150    pub channel: ReleaseChannel,
151
152    /// Directory for staging downloaded updates.
153    pub staging_dir: PathBuf,
154
155    /// Directory for backup of current binary.
156    pub backup_dir: PathBuf,
157
158    /// Pinned signing keys for verification.
159    pub signing_keys: Vec<PinnedKey>,
160
161    /// Maximum download size in bytes (default: 500MB).
162    pub max_download_size: u64,
163
164    /// Connection timeout for downloads.
165    pub download_timeout: Duration,
166
167    /// Whether to verify signatures (should always be true in production).
168    pub verify_signatures: bool,
169
170    /// Number of retry attempts for downloads.
171    pub max_retries: u32,
172
173    /// Delay between retries.
174    pub retry_delay: Duration,
175
176    /// User-Agent header for HTTP requests.
177    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), // 6 hours
193            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,       // 500 MB
199            download_timeout: Duration::from_secs(300), // 5 minutes
200            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    /// Create a new config with the given manifest URL.
210    #[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    /// Set the update policy.
217    #[must_use]
218    pub fn with_policy(mut self, policy: UpdatePolicy) -> Self {
219        self.policy = policy;
220        self
221    }
222
223    /// Set the release channel.
224    #[must_use]
225    pub fn with_channel(mut self, channel: ReleaseChannel) -> Self {
226        self.channel = channel;
227        self
228    }
229
230    /// Set the check interval.
231    #[must_use]
232    pub fn with_check_interval(mut self, interval: Duration) -> Self {
233        self.check_interval = interval;
234        self
235    }
236
237    /// Add a pinned signing key.
238    #[must_use]
239    pub fn with_signing_key(mut self, key: PinnedKey) -> Self {
240        self.signing_keys.push(key);
241        self
242    }
243
244    /// Set the staging directory.
245    #[must_use]
246    pub fn with_staging_dir(mut self, dir: PathBuf) -> Self {
247        self.staging_dir = dir;
248        self
249    }
250
251    /// Set the backup directory.
252    #[must_use]
253    pub fn with_backup_dir(mut self, dir: PathBuf) -> Self {
254        self.backup_dir = dir;
255        self
256    }
257
258    /// Disable signature verification (DANGEROUS - only for testing).
259    #[must_use]
260    pub fn without_signature_verification(mut self) -> Self {
261        self.verify_signatures = false;
262        self
263    }
264}
265
266/// Builder for UpdateConfig.
267pub struct UpdateConfigBuilder {
268    config: UpdateConfig,
269}
270
271impl UpdateConfigBuilder {
272    /// Create a new builder with default config.
273    #[must_use]
274    pub fn new() -> Self {
275        Self {
276            config: UpdateConfig::default(),
277        }
278    }
279
280    /// Set manifest URL.
281    #[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    /// Set update policy.
288    #[must_use]
289    pub fn policy(mut self, policy: UpdatePolicy) -> Self {
290        self.config.policy = policy;
291        self
292    }
293
294    /// Set release channel.
295    #[must_use]
296    pub fn channel(mut self, channel: ReleaseChannel) -> Self {
297        self.config.channel = channel;
298        self
299    }
300
301    /// Set check interval.
302    #[must_use]
303    pub fn check_interval(mut self, interval: Duration) -> Self {
304        self.config.check_interval = interval;
305        self
306    }
307
308    /// Add signing key.
309    #[must_use]
310    pub fn signing_key(mut self, key: PinnedKey) -> Self {
311        self.config.signing_keys.push(key);
312        self
313    }
314
315    /// Set staging directory.
316    #[must_use]
317    pub fn staging_dir(mut self, dir: PathBuf) -> Self {
318        self.config.staging_dir = dir;
319        self
320    }
321
322    /// Build the config.
323    #[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()); // No time bounds = always valid
377
378        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}