Skip to main content

soma_self_update/
staging.rs

1use std::path::{Path, PathBuf};
2use std::sync::atomic::{AtomicU64, Ordering};
3
4use sha2::{Digest, Sha256};
5use tokio::io::{AsyncRead, AsyncReadExt, AsyncWriteExt};
6
7#[cfg(unix)]
8use crate::transaction::path_validation::validate_distinct_paths;
9use crate::{Result, UpdateDirective, UpdateError, Updater, reject_executable_leaf_symlink};
10
11static STAGING_COUNTER: AtomicU64 = AtomicU64::new(0);
12#[cfg(unix)]
13pub(crate) const VALIDATION_MODE: u32 = 0o700;
14#[cfg(unix)]
15const UNSAFE_EXECUTABLE_MODE_BITS: u32 = 0o7022;
16#[cfg(unix)]
17const UNSAFE_EXECUTABLE_MODE_REMEDIATION: &str = "grant owner execute and remove special bits and group/other write permissions before staging an update";
18
19/// A fully downloaded artifact whose digest matches its directive.
20#[derive(Debug)]
21pub struct StagedArtifact {
22    pub(crate) path: PathBuf,
23    pub(crate) target_version: String,
24    pub(crate) sha256: String,
25    bytes_written: u64,
26    cleanup_on_drop: bool,
27    #[cfg(unix)]
28    pub(crate) intended_mode: u32,
29    #[cfg(unix)]
30    source_identity: ExecutableIdentity,
31}
32
33#[cfg(unix)]
34#[derive(Clone, Copy, Debug, Eq, PartialEq)]
35enum ExecutableIdentity {
36    Present { device: u64, inode: u64, mode: u32 },
37    Absent,
38}
39
40impl StagedArtifact {
41    pub fn path(&self) -> &Path {
42        &self.path
43    }
44    pub fn target_version(&self) -> &str {
45        &self.target_version
46    }
47    pub fn sha256(&self) -> &str {
48        &self.sha256
49    }
50    pub fn bytes_written(&self) -> u64 {
51        self.bytes_written
52    }
53
54    #[cfg(unix)]
55    pub(crate) fn source_was_present(&self) -> bool {
56        matches!(self.source_identity, ExecutableIdentity::Present { .. })
57    }
58
59    #[cfg(unix)]
60    pub(crate) fn revalidate_source_executable(&self, path: &Path) -> Result<()> {
61        let (_, current) = executable_mode_and_identity(path)?;
62        if current != self.source_identity {
63            return Err(UpdateError::ExecutableIdentityChanged {
64                path: path.to_path_buf(),
65            });
66        }
67        Ok(())
68    }
69
70    /// Explicitly removes the staged file and reports cleanup failures.
71    ///
72    /// Dropping an artifact remains a best-effort fallback because `Drop`
73    /// cannot return an error.
74    pub fn cleanup(mut self) -> Result<()> {
75        std::fs::remove_file(&self.path).map_err(|error| UpdateError::io(&self.path, error))?;
76        self.cleanup_on_drop = false;
77        Ok(())
78    }
79}
80
81impl Drop for StagedArtifact {
82    fn drop(&mut self) {
83        if self.cleanup_on_drop {
84            let _ = std::fs::remove_file(&self.path);
85        }
86    }
87}
88
89struct PartialArtifact {
90    path: PathBuf,
91    armed: bool,
92}
93
94impl PartialArtifact {
95    fn disarm(&mut self) {
96        self.armed = false;
97    }
98
99    fn report_error(mut self, operation: UpdateError) -> UpdateError {
100        self.armed = false;
101        match std::fs::remove_file(&self.path) {
102            Ok(()) => operation,
103            Err(error) if error.kind() == std::io::ErrorKind::NotFound => operation,
104            Err(cleanup) => UpdateError::ArtifactCleanupFailed {
105                path: self.path.clone(),
106                operation: Box::new(operation),
107                cleanup,
108            },
109        }
110    }
111}
112
113impl Drop for PartialArtifact {
114    fn drop(&mut self) {
115        if self.armed {
116            let _ = std::fs::remove_file(&self.path);
117        }
118    }
119}
120
121async fn create_partial(path: &Path) -> Result<(tokio::fs::File, PartialArtifact)> {
122    let mut open_options = tokio::fs::OpenOptions::new();
123    open_options.create_new(true).write(true);
124    #[cfg(unix)]
125    {
126        open_options.mode(0o600);
127    }
128    let file = open_options
129        .open(path)
130        .await
131        .map_err(|error| UpdateError::io(path, error))?;
132    let cleanup = PartialArtifact {
133        path: path.to_path_buf(),
134        armed: true,
135    };
136    Ok((file, cleanup))
137}
138
139impl Updater {
140    /// Checks whether the installed executable is safe to update before a
141    /// transport starts downloading artifact bytes.
142    ///
143    /// [`Updater::stage`] repeats this check so callers cannot bypass it or
144    /// rely on stale preflight state.
145    pub fn preflight_stage(&self) -> Result<()> {
146        self.ensure_layout_bound()?;
147        reject_executable_leaf_symlink(self.layout().executable())?;
148        #[cfg(unix)]
149        {
150            let layout = self.validated_layout()?;
151            executable_mode_and_identity(&layout.executable)?;
152        }
153        Ok(())
154    }
155
156    pub async fn stage<R>(
157        &self,
158        mut reader: R,
159        directive: &UpdateDirective,
160    ) -> Result<StagedArtifact>
161    where
162        R: AsyncRead + Unpin,
163    {
164        self.preflight_stage()?;
165        #[cfg(unix)]
166        let layout = self.validated_layout()?;
167        #[cfg(unix)]
168        let resolved_executable = &layout.executable;
169        #[cfg(not(unix))]
170        let resolved_executable = self.layout().executable();
171        let directory = resolved_executable
172            .parent()
173            .filter(|parent| !parent.as_os_str().is_empty())
174            .unwrap_or_else(|| Path::new("."));
175        let name = resolved_executable
176            .file_name()
177            .and_then(|name| name.to_str())
178            .unwrap_or("executable");
179        let path = directory.join(format!(
180            ".{name}.update-{}-{}.part",
181            std::process::id(),
182            STAGING_COUNTER.fetch_add(1, Ordering::Relaxed)
183        ));
184        #[cfg(unix)]
185        let (intended_mode, source_identity) = executable_mode_and_identity(resolved_executable)?;
186        #[cfg(unix)]
187        {
188            let mut namespace = layout.protected.clone();
189            namespace.push(path.clone());
190            validate_distinct_paths(&namespace)?;
191        }
192        let (mut file, mut cleanup) = create_partial(&path).await?;
193        let result: Result<(u64, String)> = async {
194            let mut buffer = [0_u8; 64 * 1024];
195            let mut total = 0_u64;
196            let mut hasher = Sha256::new();
197            loop {
198                let read = reader
199                    .read(&mut buffer)
200                    .await
201                    .map_err(|error| UpdateError::io(&path, error))?;
202                if read == 0 {
203                    break;
204                }
205                let next = total.saturating_add(read as u64);
206                if next > self.policy().max_artifact_bytes() {
207                    return Err(UpdateError::ArtifactTooLarge {
208                        limit: self.policy().max_artifact_bytes(),
209                        actual: next,
210                    });
211                }
212                file.write_all(&buffer[..read])
213                    .await
214                    .map_err(|error| UpdateError::io(&path, error))?;
215                hasher.update(&buffer[..read]);
216                total = next;
217            }
218            file.flush()
219                .await
220                .map_err(|error| UpdateError::io(&path, error))?;
221            file.sync_all()
222                .await
223                .map_err(|error| UpdateError::io(&path, error))?;
224            let actual = encode_hex(&hasher.finalize());
225            if actual != directive.sha256() {
226                return Err(UpdateError::DigestMismatch {
227                    expected: directive.sha256().to_owned(),
228                    actual,
229                });
230            }
231            #[cfg(unix)]
232            {
233                use std::os::unix::fs::PermissionsExt;
234                file.set_permissions(std::fs::Permissions::from_mode(VALIDATION_MODE))
235                    .await
236                    .map_err(|error| UpdateError::io(&path, error))?;
237                file.sync_all()
238                    .await
239                    .map_err(|error| UpdateError::io(&path, error))?;
240            }
241            Ok((total, actual))
242        }
243        .await;
244        // Close the writable descriptor before explicit error cleanup or
245        // before callers execute the successful artifact.
246        drop(file.into_std().await);
247        let (total, actual) = match result {
248            Ok(result) => result,
249            Err(operation) => return Err(cleanup.report_error(operation)),
250        };
251        cleanup.disarm();
252        Ok(StagedArtifact {
253            path,
254            target_version: directive.version().to_owned(),
255            sha256: actual,
256            bytes_written: total,
257            cleanup_on_drop: true,
258            #[cfg(unix)]
259            intended_mode,
260            #[cfg(unix)]
261            source_identity,
262        })
263    }
264}
265
266#[cfg(unix)]
267fn executable_mode_and_identity(path: &Path) -> Result<(u32, ExecutableIdentity)> {
268    use std::os::unix::fs::{MetadataExt, OpenOptionsExt, PermissionsExt};
269
270    let file = match std::fs::OpenOptions::new()
271        .read(true)
272        .custom_flags(nix::libc::O_NOFOLLOW | nix::libc::O_NONBLOCK)
273        .open(path)
274    {
275        Ok(file) => file,
276        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
277            return Ok((0o700, ExecutableIdentity::Absent));
278        }
279        Err(error) => return Err(UpdateError::io(path, error)),
280    };
281    let metadata = file
282        .metadata()
283        .map_err(|error| UpdateError::io(path, error))?;
284    if !metadata.file_type().is_file() {
285        return Err(UpdateError::InvalidPolicy(
286            "executable path must be a regular file",
287        ));
288    }
289    let mode = metadata.permissions().mode() & 0o7777;
290    validate_executable_mode(path, mode)?;
291    Ok((
292        mode,
293        ExecutableIdentity::Present {
294            device: metadata.dev(),
295            inode: metadata.ino(),
296            mode,
297        },
298    ))
299}
300
301#[cfg(unix)]
302pub(crate) fn validate_executable_mode(path: &Path, mode: u32) -> Result<()> {
303    let mode = mode & 0o7777;
304    if mode & UNSAFE_EXECUTABLE_MODE_BITS != 0 || mode & 0o100 == 0 {
305        return Err(UpdateError::UnsafeExecutableMode {
306            path: path.to_path_buf(),
307            mode,
308            remediation: UNSAFE_EXECUTABLE_MODE_REMEDIATION,
309        });
310    }
311    Ok(())
312}
313
314fn encode_hex(bytes: &[u8]) -> String {
315    const HEX: &[u8; 16] = b"0123456789abcdef";
316    let mut output = String::with_capacity(bytes.len() * 2);
317    for byte in bytes {
318        output.push(HEX[(byte >> 4) as usize] as char);
319        output.push(HEX[(byte & 0x0f) as usize] as char);
320    }
321    output
322}
323
324#[cfg(test)]
325mod tests {
326    use super::*;
327
328    #[tokio::test]
329    async fn create_partial_collision_preserves_preexisting_sentinel() {
330        let temp = tempfile::tempdir().unwrap();
331        let explicit_path = temp.path().join("explicit-collision.part");
332        let sentinel = b"not owned by the updater";
333        std::fs::write(&explicit_path, sentinel).unwrap();
334
335        assert!(create_partial(&explicit_path).await.is_err());
336        assert_eq!(std::fs::read(&explicit_path).unwrap(), sentinel);
337    }
338}