collective

Browse / github.com/storytold/craft-libs / patch

fix(update): a standalone self-update replaces the whole tarball install

fix(update): a standalone self-update replaces the whole tarball install On Linux, a copy installed from the release tarball (`bin/` and `share/` unpacked into a prefix such as `~/.local`) is detected as `Install::Standalone`. `replace_standalone` swapped only `bin/<app>`, so after an update the CLI beside it (`pdfcraft-cli`) and the app's data, desktop entry, icons and metainfo all stayed on the old release. ## Change `replace_standalone` now unpacks the tarball with the existing `install::tree::unpack` (bounded entry count and size, contained in staging; the module becomes `pub(crate)`). Besides the executable it replaces: - **the release's other programs that

Status
open
Score
0.00
Adopters
0 other operators
Author
Tomcatt (imported from pull request #392, open)
Base
bd97cb760c36c4398def923817702a7e7f3096af
Size
+184 −32 in 2 files
Review
pending
Shared
2026-10-28
Id
a6add47b8cc095d68fa7b360

How it went

No other operator has reported using it yet.

Diff

raw · crates/update/src/install.rs crates/update/src/self_update.rs

diff --git a/crates/update/src/install.rs b/crates/update/src/install.rs--- a/crates/update/src/install.rs+++ b/crates/update/src/install.rs@@ -1424,7 +1424,7 @@ pub mod macos { /// out the same way first. #[cfg(unix)] #[allow(dead_code)]-mod tree {+pub(crate) mod tree {     use super::{App, Path, PathBuf, single_top_folder};      /// Unpacks `file` and returns a staging folder laid out like `/usr/local`.diff --git a/crates/update/src/self_update.rs b/crates/update/src/self_update.rs--- a/crates/update/src/self_update.rs+++ b/crates/update/src/self_update.rs@@ -368,7 +368,7 @@ pub fn update(             }             #[cfg(unix)]             Install::Standalone(exe) => {-                let result = replace_standalone(app, &file, exe);+                let result = replace_standalone(app, paths, &file, exe);                 let _ = std::fs::remove_file(&file);                 result?;                 relaunch_after_exit(pid, &[exe.as_os_str()])@@ -415,42 +415,94 @@ fn replace_appimage(new: &Path, path: &Path) -> Result<(), String> {     }) } -/// Extracts the app's binary from a release tarball and atomically renames it over the running-/// standalone executable.+/// Replaces a standalone copy from a release tarball, along with what the tarball install put+/// beside it: the release's other programs that are already in the executable's folder, and,+/// when that folder is a prefix's `bin` with the app's `share/<id>` folder next to it, everything+/// under the tarball's `share` (data, desktop entry, icons). A copy on its own in some folder gets+/// only its executable. Each file is staged next to its destination and renamed over it; the+/// executable goes last, so a failure before it leaves the running version in charge. #[cfg(unix)]-fn replace_standalone(app: &App, tarball: &Path, exe: &Path) -> Result<(), String> {-    use std::os::unix::fs::PermissionsExt;-    let dir = exe.parent().ok_or_else(|| "executable isn't in a folder".to_string())?;-    let f = std::fs::File::open(tarball).map_err(|e| format!("could not open {}: {e}", tarball.display()))?;-    let gz = flate2::read::GzDecoder::new(f);-    let mut archive = tar::Archive::new(gz);-    let staged = dir.join(format!(".{}.update", exe.file_name().and_then(|n| n.to_str()).unwrap_or("app")));-    let bin_suffix = format!("/{}", app.id);-    let bin_rel = format!("bin/{}", app.id);-    let mut found = false;-    for entry in archive.entries().map_err(|e| format!("could not read archive entries: {e}"))? {-        let mut entry = entry.map_err(|e| format!("archive entry error: {e}"))?;-        if !entry.header().entry_type().is_file() {-            continue;+fn replace_standalone(app: &App, paths: &Paths, tarball: &Path, exe: &Path) -> Result<(), String> {+    let staging = paths.cache.join(format!("{}.standalone", app.id));+    let result = install::tree::unpack(app, tarball, &staging).and_then(|tree| replace_from_tree(app, &tree, exe));+    let _ = std::fs::remove_dir_all(&staging);+    result+}++#[cfg(unix)]+fn replace_from_tree(app: &App, tree: &Path, exe: &Path) -> Result<(), String> {+    let new_exe = tree.join("bin").join(app.id);+    if !is_plain_file(&new_exe) {+        return Err(format!("release archive did not contain binary for {}", app.id));+    }+    let bin = exe.parent().ok_or_else(|| "executable isn't in a folder".to_string())?;+    let mut moves = Vec::new();+    for entry in std::fs::read_dir(tree.join("bin")).map_err(|e| format!("couldn't read the release's programs ({e})"))?.flatten() {+        let name = entry.file_name();+        if name != app.id && is_plain_file(&entry.path()) && bin.join(&name).is_file() {+            moves.push((entry.path(), bin.join(&name)));         }-        let path = entry.path().map_err(|e| format!("archive path error: {e}"))?;-        let path_str = path.to_string_lossy();-        if path_str == bin_rel || path_str.ends_with(&bin_suffix) || path.file_name().is_some_and(|n| n == app.id) {-            let mut out = std::fs::File::create(&staged).map_err(|e| format!("could not create {}: {e}", staged.display()))?;-            std::io::copy(&mut entry, &mut out).map_err(|e| format!("could not extract binary: {e}"))?;-            found = true;-            break;+    }+    let prefix = bin.parent().filter(|_| bin.file_name().is_some_and(|n| n == "bin"));+    if let Some(prefix) = prefix+        && prefix.join("share").join(app.id).is_dir()+    {+        for file in plain_files(&tree.join("share"))? {+            let relative = file.strip_prefix(tree).map_err(|_| "staged file escapes the release".to_string())?;+            moves.push((file.clone(), prefix.join(relative)));         }     }-    if !found {-        let _ = std::fs::remove_file(&staged);-        return Err(format!("release archive did not contain binary for {}", app.id));+    for (from, to) in &moves {+        place(from, to, false)?;     }-    std::fs::set_permissions(&staged, std::fs::Permissions::from_mode(0o755)).map_err(|e| e.to_string())?;-    std::fs::rename(&staged, exe).map_err(|e| {+    place(&new_exe, exe, true)+}++/// Copies `from` next to `to` and renames it over `to`. Group and world write and the set-id+/// bits are never carried over from the archive; the executable is always 0755.+#[cfg(unix)]+fn place(from: &Path, to: &Path, executable: bool) -> Result<(), String> {+    use std::os::unix::fs::PermissionsExt;+    let dir = to.parent().ok_or_else(|| format!("{} isn't in a folder", to.display()))?;+    std::fs::create_dir_all(dir).map_err(|e| format!("couldn't create {} ({e})", dir.display()))?;+    let name = to.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();+    let staged = dir.join(format!(".{name}.update"));+    let done = std::fs::copy(from, &staged).map_err(|e| format!("couldn't write next to {} ({e})", to.display())).and_then(|_| {+        let mode = std::fs::metadata(from).map(|m| m.permissions().mode()).unwrap_or(0o644);+        let mode = if executable { 0o755 } else { mode & 0o755 };+        std::fs::set_permissions(&staged, std::fs::Permissions::from_mode(mode)).map_err(|e| e.to_string())?;+        std::fs::rename(&staged, to).map_err(|e| format!("couldn't replace {} ({e})", to.display()))+    });+    if done.is_err() {         let _ = std::fs::remove_file(&staged);-        format!("could not replace {}: {e}", exe.display())-    })+    }+    done+}++/// A regular file, not a link to one.+#[cfg(unix)]+fn is_plain_file(path: &Path) -> bool {+    std::fs::symlink_metadata(path).is_ok_and(|m| m.file_type().is_file())+}++/// The regular files under `dir`, without following links; none when `dir` doesn't exist.+#[cfg(unix)]+fn plain_files(dir: &Path) -> Result<Vec<PathBuf>, String> {+    let mut files = Vec::new();+    let mut pending = vec![dir.to_path_buf()];+    while let Some(dir) = pending.pop() {+        let Ok(entries) = std::fs::read_dir(&dir) else { continue };+        for entry in entries {+            let entry = entry.map_err(|e| format!("couldn't read {} ({e})", dir.display()))?;+            let kind = entry.file_type().map_err(|e| e.to_string())?;+            if kind.is_dir() {+                pending.push(entry.path());+            } else if kind.is_file() {+                files.push(entry.path());+            }+        }+    }+    Ok(files) }  /// Starts `sh`, which waits for `pid` to exit and then runs `command`. Detached, so it outlives us.@@ -1778,4 +1830,104 @@ mod tests {         assert!(is_bundle_origin(&crate::suite::PHOTOCRAFT, "photocraft-origin"));         assert!(!is_bundle_origin(&crate::suite::LAUNCHER, ""));     }++    /// A release tarball like `package.sh` makes: everything under one `<app>-<ver>-<platform>/`.+    #[cfg(unix)]+    fn release_tarball(path: &Path, files: &[(&str, &[u8])]) {+        let f = std::fs::File::create(path).unwrap();+        let mut b = tar::Builder::new(flate2::write::GzEncoder::new(f, flate2::Compression::fast()));+        for (name, bytes) in files {+            let mut h = tar::Header::new_gnu();+            h.set_size(bytes.len() as u64);+            h.set_mode(0o755);+            h.set_cksum();+            b.append_data(&mut h, format!("pdfcraft-9.9.9-linux-x86_64/{name}"), *bytes).unwrap();+        }+        b.into_inner().unwrap().finish().unwrap();+    }++    #[cfg(unix)]+    #[test]+    fn a_standalone_update_in_a_prefix_replaces_the_cli_and_shared_files_too() {+        let dir = tempfile::tempdir().unwrap();+        let prefix = dir.path().join("local");+        std::fs::create_dir_all(prefix.join("bin")).unwrap();+        std::fs::create_dir_all(prefix.join("share/pdfcraft/models")).unwrap();+        let exe = prefix.join("bin/pdfcraft");+        std::fs::write(&exe, b"old app").unwrap();+        std::fs::write(prefix.join("bin/pdfcraft-cli"), b"old cli").unwrap();+        std::fs::write(prefix.join("bin/unrelated"), b"mine").unwrap();+        std::fs::write(prefix.join("share/pdfcraft/models/a.rten"), b"old model").unwrap();+        let tarball = dir.path().join("new.tar.gz");+        release_tarball(+            &tarball,+            &[+                ("bin/pdfcraft", b"new app"),+                ("bin/pdfcraft-cli", b"new cli"),+                ("share/pdfcraft/models/a.rten", b"new model"),+                ("share/pdfcraft/models/b.rten", b"added model"),+                ("share/applications/ai.storyteller.pdfcraft.desktop", b"desktop"),+            ],+        );+        let paths = Paths::under(&dir.path().join("data"));+        replace_standalone(&crate::suite::PDFCRAFT, &paths, &tarball, &exe).unwrap();+        let read = |p: &str| std::fs::read(prefix.join(p)).unwrap();+        assert_eq!(read("bin/pdfcraft"), b"new app");+        assert_eq!(read("bin/pdfcraft-cli"), b"new cli");+        assert_eq!(read("bin/unrelated"), b"mine", "files the release doesn't ship are left alone");+        assert_eq!(read("share/pdfcraft/models/a.rten"), b"new model");+        assert_eq!(read("share/pdfcraft/models/b.rten"), b"added model");+        assert_eq!(read("share/applications/ai.storyteller.pdfcraft.desktop"), b"desktop");+        let leftovers: Vec<_> = walk(&prefix).into_iter().filter(|p| p.to_string_lossy().contains(".update")).collect();+        assert!(leftovers.is_empty(), "no staged files left behind: {leftovers:?}");+    }++    #[cfg(unix)]+    #[test]+    fn a_lone_standalone_binary_gets_only_what_sits_beside_it() {+        let dir = tempfile::tempdir().unwrap();+        let tools = dir.path().join("tools");+        std::fs::create_dir_all(&tools).unwrap();+        let exe = tools.join("pdfcraft");+        std::fs::write(&exe, b"old app").unwrap();+        let tarball = dir.path().join("new.tar.gz");+        release_tarball(&tarball, &[("bin/pdfcraft", b"new app"), ("bin/pdfcraft-cli", b"new cli"), ("share/pdfcraft/models/a.rten", b"model")]);+        let paths = Paths::under(&dir.path().join("data"));+        replace_standalone(&crate::suite::PDFCRAFT, &paths, &tarball, &exe).unwrap();+        assert_eq!(std::fs::read(&exe).unwrap(), b"new app");+        assert!(!tools.join("pdfcraft-cli").exists(), "a program that wasn't installed isn't added");+        assert!(!dir.path().join("share").exists(), "no prefix, so no shared files");+    }++    #[cfg(unix)]+    #[test]+    fn a_standalone_update_without_the_app_binary_changes_nothing() {+        let dir = tempfile::tempdir().unwrap();+        let prefix = dir.path().join("local");+        std::fs::create_dir_all(prefix.join("bin")).unwrap();+        std::fs::create_dir_all(prefix.join("share/pdfcraft")).unwrap();+        let exe = prefix.join("bin/pdfcraft");+        std::fs::write(&exe, b"old app").unwrap();+        std::fs::write(prefix.join("bin/pdfcraft-cli"), b"old cli").unwrap();+        let tarball = dir.path().join("new.tar.gz");+        release_tarball(&tarball, &[("bin/pdfcraft-cli", b"new cli")]);+        let paths = Paths::under(&dir.path().join("data"));+        assert!(replace_standalone(&crate::suite::PDFCRAFT, &paths, &tarball, &exe).is_err());+        assert_eq!(std::fs::read(&exe).unwrap(), b"old app");+        assert_eq!(std::fs::read(prefix.join("bin/pdfcraft-cli")).unwrap(), b"old cli");+    }++    #[cfg(unix)]+    fn walk(dir: &Path) -> Vec<PathBuf> {+        let mut out = Vec::new();+        for e in std::fs::read_dir(dir).unwrap().flatten() {+            let p = e.path();+            if p.is_dir() {+                out.extend(walk(&p));+            } else {+                out.push(p);+            }+        }+        out+    } }