Skip to main content

soma_self_update/
lib.rs

1//! A transport-neutral, standalone binary self-update transaction.
2//!
3//! The caller must authenticate every [`UpdateDirective`] independently (or
4//! verify a detached signature). A SHA-256 digest received from the same
5//! server as the artifact proves integrity in transit, not publisher identity.
6
7#![forbid(unsafe_code)]
8
9mod directive;
10mod error;
11mod staging;
12#[cfg(unix)]
13mod transaction;
14#[cfg(not(unix))]
15#[path = "transaction_non_unix.rs"]
16mod transaction;
17mod unix;
18mod validation;
19
20use std::path::{Path, PathBuf};
21use std::time::Duration;
22
23pub use error::{Result, UpdateError};
24pub use staging::StagedArtifact;
25pub use transaction::{ConfirmationOutcome, InstallOutcome};
26#[cfg(unix)]
27pub use unix::reexec;
28pub use unix::restart_command;
29pub use validation::ValidatedArtifact;
30
31/// Network transports permitted for an artifact URL.
32#[derive(Clone, Copy, Debug, Eq, PartialEq)]
33pub enum ArtifactTransportPolicy {
34    /// Require HTTPS for every artifact.
35    HttpsOnly,
36    /// Permit HTTP only when the host is the local machine.
37    HttpsOrLoopbackHttp,
38}
39
40/// Strategy used to retain the last-confirmed executable for rollback.
41#[derive(Clone, Copy, Debug, Eq, PartialEq)]
42pub enum BackupStrategy {
43    /// Prefer a hard link and copy bytes when the filesystem rejects links.
44    HardLinkOrCopy,
45    /// Always copy bytes and preserve the executable permission mode.
46    Copy,
47}
48
49/// An authenticated update instruction supplied by the adopting service.
50#[derive(Clone, Debug, Eq, PartialEq)]
51pub struct UpdateDirective {
52    version: String,
53    artifact_url: String,
54    sha256: String,
55}
56
57impl UpdateDirective {
58    /// Constructs and validates an update directive.
59    pub fn new(
60        version: impl Into<String>,
61        artifact_url: impl Into<String>,
62        sha256: impl Into<String>,
63    ) -> Result<Self> {
64        let version = version.into();
65        let artifact_url = artifact_url.into();
66        let sha256 = sha256.into();
67        if version.trim().is_empty() {
68            return Err(UpdateError::InvalidDirective("version must not be empty"));
69        }
70        if artifact_url.trim().is_empty() {
71            return Err(UpdateError::InvalidDirective(
72                "artifact URL must not be empty",
73            ));
74        }
75        if sha256.len() != 64 || !sha256.bytes().all(|byte| byte.is_ascii_hexdigit()) {
76            return Err(UpdateError::InvalidDigest(sha256));
77        }
78        Ok(Self {
79            version,
80            artifact_url,
81            sha256: sha256.to_ascii_lowercase(),
82        })
83    }
84
85    /// Target version reported by the authenticated directive.
86    pub fn version(&self) -> &str {
87        &self.version
88    }
89
90    /// Artifact reference exactly as supplied by the directive.
91    pub fn artifact_url(&self) -> &str {
92        &self.artifact_url
93    }
94
95    /// Normalized lowercase SHA-256 digest.
96    pub fn sha256(&self) -> &str {
97        &self.sha256
98    }
99}
100
101/// Caller-controlled paths used by an update transaction.
102#[derive(Clone, Debug, Eq, PartialEq)]
103pub struct UpdateLayout {
104    executable: PathBuf,
105    state_file: PathBuf,
106}
107
108impl UpdateLayout {
109    /// Creates a layout. Paths are never derived from directive content.
110    pub fn new(executable: impl Into<PathBuf>, state_file: impl Into<PathBuf>) -> Self {
111        Self {
112            executable: executable.into(),
113            state_file: state_file.into(),
114        }
115    }
116
117    /// Executable replaced by a successful install.
118    pub fn executable(&self) -> &Path {
119        &self.executable
120    }
121
122    /// Durable transaction marker path.
123    pub fn state_file(&self) -> &Path {
124        &self.state_file
125    }
126}
127
128/// Resource and lifecycle policy for an updater.
129#[derive(Clone, Debug, Eq, PartialEq)]
130pub struct UpdatePolicy {
131    transport: ArtifactTransportPolicy,
132    max_artifact_bytes: u64,
133    validation_timeout: Duration,
134    max_unconfirmed_restarts: u32,
135    backup_strategy: BackupStrategy,
136}
137
138impl Default for UpdatePolicy {
139    fn default() -> Self {
140        Self {
141            transport: ArtifactTransportPolicy::HttpsOnly,
142            max_artifact_bytes: 128 * 1024 * 1024,
143            validation_timeout: Duration::from_secs(10),
144            max_unconfirmed_restarts: 3,
145            backup_strategy: BackupStrategy::HardLinkOrCopy,
146        }
147    }
148}
149
150impl UpdatePolicy {
151    /// Replaces the artifact transport policy.
152    pub fn with_transport(mut self, transport: ArtifactTransportPolicy) -> Self {
153        self.transport = transport;
154        self
155    }
156
157    /// Replaces the maximum artifact size.
158    pub fn with_max_artifact_bytes(mut self, bytes: u64) -> Result<Self> {
159        if bytes == 0 {
160            return Err(UpdateError::InvalidPolicy(
161                "maximum artifact size must be greater than zero",
162            ));
163        }
164        self.max_artifact_bytes = bytes;
165        Ok(self)
166    }
167
168    /// Replaces the validation timeout.
169    pub fn with_validation_timeout(mut self, timeout: Duration) -> Result<Self> {
170        if timeout.is_zero() {
171            return Err(UpdateError::InvalidPolicy(
172                "validation timeout must be greater than zero",
173            ));
174        }
175        self.validation_timeout = timeout;
176        Ok(self)
177    }
178
179    /// Replaces the number of unconfirmed restarts allowed before rollback.
180    pub fn with_max_unconfirmed_restarts(mut self, attempts: u32) -> Result<Self> {
181        if attempts == 0 {
182            return Err(UpdateError::InvalidPolicy(
183                "restart limit must be greater than zero",
184            ));
185        }
186        self.max_unconfirmed_restarts = attempts;
187        Ok(self)
188    }
189
190    pub fn with_backup_strategy(mut self, strategy: BackupStrategy) -> Self {
191        self.backup_strategy = strategy;
192        self
193    }
194
195    pub fn transport(&self) -> ArtifactTransportPolicy {
196        self.transport
197    }
198
199    pub fn max_artifact_bytes(&self) -> u64 {
200        self.max_artifact_bytes
201    }
202
203    pub fn validation_timeout(&self) -> Duration {
204        self.validation_timeout
205    }
206
207    pub fn max_unconfirmed_restarts(&self) -> u32 {
208        self.max_unconfirmed_restarts
209    }
210
211    pub fn backup_strategy(&self) -> BackupStrategy {
212        self.backup_strategy
213    }
214}
215
216/// Reusable update coordinator.
217#[derive(Clone, Debug)]
218pub struct Updater {
219    layout: UpdateLayout,
220    policy: UpdatePolicy,
221    layout_resolution_error: Option<LayoutBindingError>,
222    #[cfg(all(test, unix))]
223    test_failpoint: std::sync::Arc<std::sync::atomic::AtomicU8>,
224}
225
226/// Result of intentionally moving an executable's durable state authority.
227#[derive(Clone, Debug)]
228pub enum MigrationOutcome {
229    /// The authority rename and parent-directory sync both completed.
230    Migrated { updater: Updater },
231    /// The authority rename completed, but its parent-directory sync failed.
232    ///
233    /// The caller must retain and use `updater`: returning to the old updater
234    /// after the authority has changed would select a state path that is no
235    /// longer authoritative. Log `diagnostic` and retry the migration later if
236    /// the durability boundary must be confirmed.
237    MigratedIndeterminate {
238        updater: Updater,
239        diagnostic: String,
240    },
241}
242
243impl MigrationOutcome {
244    /// Returns the updater bound to the new authoritative state path.
245    pub fn into_updater(self) -> Updater {
246        match self {
247            Self::Migrated { updater } | Self::MigratedIndeterminate { updater, .. } => updater,
248        }
249    }
250
251    /// Borrows the updater bound to the new authoritative state path.
252    pub fn updater(&self) -> &Updater {
253        match self {
254            Self::Migrated { updater } | Self::MigratedIndeterminate { updater, .. } => updater,
255        }
256    }
257}
258
259impl Updater {
260    pub fn new(layout: UpdateLayout, policy: UpdatePolicy) -> Self {
261        let (layout, layout_resolution_error) = bind_layout_to_current_dir(layout);
262        Self {
263            layout,
264            policy,
265            layout_resolution_error,
266            #[cfg(all(test, unix))]
267            test_failpoint: std::sync::Arc::new(std::sync::atomic::AtomicU8::new(0)),
268        }
269    }
270
271    pub fn layout(&self) -> &UpdateLayout {
272        &self.layout
273    }
274
275    pub fn policy(&self) -> &UpdatePolicy {
276        &self.policy
277    }
278
279    pub(crate) fn ensure_layout_bound(&self) -> Result<()> {
280        match &self.layout_resolution_error {
281            Some(error) => Err(UpdateError::io(
282                &error.path,
283                std::io::Error::new(error.kind, error.message.clone()),
284            )),
285            None => Ok(()),
286        }
287    }
288}
289
290#[derive(Clone, Debug)]
291struct LayoutBindingError {
292    path: PathBuf,
293    kind: std::io::ErrorKind,
294    message: String,
295}
296
297fn bind_layout_to_current_dir(layout: UpdateLayout) -> (UpdateLayout, Option<LayoutBindingError>) {
298    let base = if layout.executable.is_absolute() && layout.state_file.is_absolute() {
299        None
300    } else {
301        match std::env::current_dir() {
302            Ok(base) => Some(base),
303            Err(error) => {
304                return (
305                    layout,
306                    Some(LayoutBindingError {
307                        path: PathBuf::from("."),
308                        kind: error.kind(),
309                        message: error.to_string(),
310                    }),
311                );
312            }
313        }
314    };
315    let executable = if layout.executable.is_absolute() {
316        layout.executable
317    } else {
318        base.as_ref().unwrap().join(layout.executable)
319    };
320    let state_file = if layout.state_file.is_absolute() {
321        layout.state_file
322    } else {
323        base.as_ref().unwrap().join(layout.state_file)
324    };
325    #[cfg(unix)]
326    let state_file = match bind_state_identity(&state_file) {
327        Ok(state_file) => state_file,
328        Err(error) => {
329            let diagnostic = LayoutBindingError {
330                path: state_file.clone(),
331                kind: error.kind(),
332                message: error.to_string(),
333            };
334            return (
335                UpdateLayout {
336                    executable,
337                    state_file,
338                },
339                Some(diagnostic),
340            );
341        }
342    };
343    (
344        UpdateLayout {
345            executable,
346            state_file,
347        },
348        None,
349    )
350}
351
352#[cfg(unix)]
353pub(crate) fn bind_state_identity(path: &Path) -> std::io::Result<PathBuf> {
354    let mut component_path = PathBuf::new();
355    for component in path.components() {
356        component_path.push(component);
357        match std::fs::symlink_metadata(&component_path) {
358            Ok(metadata) if metadata.file_type().is_symlink() => {
359                return Err(std::io::Error::new(
360                    std::io::ErrorKind::InvalidInput,
361                    "state path must not contain symlinked components",
362                ));
363            }
364            Ok(_) => {}
365            Err(error) if error.kind() == std::io::ErrorKind::NotFound => break,
366            Err(error) => return Err(error),
367        }
368    }
369    let identity = match std::fs::canonicalize(path) {
370        Ok(path) => Ok(path),
371        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
372            let parent = path.parent().ok_or_else(|| {
373                std::io::Error::new(
374                    std::io::ErrorKind::InvalidInput,
375                    "state file must have a parent",
376                )
377            })?;
378            let parent = std::fs::canonicalize(parent)?;
379            let name = path.file_name().ok_or_else(|| {
380                std::io::Error::new(
381                    std::io::ErrorKind::InvalidInput,
382                    "state file must have a name",
383                )
384            })?;
385            Ok(parent.join(name))
386        }
387        Err(error) => Err(error),
388    }?;
389    if identity != path {
390        return Err(std::io::Error::new(
391            std::io::ErrorKind::InvalidInput,
392            "state path must be canonical and contain no symlinked components",
393        ));
394    }
395    Ok(identity)
396}
397
398pub(crate) fn reject_executable_leaf_symlink(path: &Path) -> Result<()> {
399    match std::fs::symlink_metadata(path) {
400        Ok(metadata) if metadata.file_type().is_symlink() => Err(UpdateError::InvalidPolicy(
401            "executable path must not be a symlink",
402        )),
403        Ok(_) => Ok(()),
404        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
405        Err(error) => Err(UpdateError::io(path, error)),
406    }
407}
408
409/// Work required while starting a service with possible pending update state.
410#[derive(Clone, Debug, Eq, PartialEq)]
411pub enum RecoveryAction {
412    NoPendingUpdate,
413    PendingUpdate {
414        target: String,
415        attempts: u32,
416        max_attempts: u32,
417    },
418    RollbackInstalled {
419        executable: PathBuf,
420        restored_version: String,
421    },
422}