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