diff --git a/CHANGELOG.md b/CHANGELOG.md index 5c81adb..bc48fbb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,22 @@ +## [0.6.0](https://github.com/pkgforge-dev/Anylinux-appimagetool/compare/0.5.6...0.6.0) - 2026-11-13 + +### ⛰️ Features + +- *(sidecar)* Add a fixed 128 KiB zstd metadata SquashFS - ([30bd699](https://github.com/pkgforge-dev/Anylinux-appimagetool/commit/30bd69910522e2042cc7021ad60f9f2d07599b81)) + +### 🐛 Bug Fixes + +- *(sidecar)* Pin the 128 KiB-scan runtime release - ([18b19c5](https://github.com/pkgforge-dev/Anylinux-appimagetool/commit/18b19c5b2cd2c1b48902613466d7df3c9b71f776)) + +### 📚 Documentation + +- *(sidecar)* Mention the stub AppRun - ([5cc744d](https://github.com/pkgforge-dev/Anylinux-appimagetool/commit/5cc744d27174fcedbf571e464295459218027fa0)) + +### ⚡ Performance + +- *(dwarfs)* Use 16/32/64 MiB blocks by AppDir size tier - ([6dadeb2](https://github.com/pkgforge-dev/Anylinux-appimagetool/commit/6dadeb282442d0b509150f4b64118bdf5fa46e9d)) + ## [0.5.6](https://github.com/pkgforge-dev/appimagetool/compare/0.5.5...0.5.6) - 2026-11-02 ### 🐛 Bug Fixes diff --git a/Cargo.lock b/Cargo.lock index ca69b42..9cdfd82 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -60,8 +60,9 @@ dependencies = [ [[package]] name = "appimagetool" -version = "0.5.6" +version = "0.6.0" dependencies = [ + "backhand", "clap", "libc", "sha2", @@ -70,12 +71,39 @@ dependencies = [ "zsync-rs", ] +[[package]] +name = "backhand" +version = "0.25.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "deb8799e91f66c730e3ae50e8a260d63401e9479f236fb74f793d7656f143f0c" +dependencies = [ + "deku", + "no_std_io2", + "solana-nohash-hasher", + "thiserror", + "xxhash-rust", + "zstd", + "zstd-safe", +] + [[package]] name = "base64" version = "0.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" +[[package]] +name = "bitvec" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddcec3d12c579d40898fe0a9a358a803c23e9c52ca3c425707f81c9436211837" +dependencies = [ + "funty", + "radium", + "tap", + "wyz", +] + [[package]] name = "block-buffer" version = "0.10.4" @@ -107,6 +135,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d16d90359e986641506914ba71350897565610e87ce0ad9e6f28569db3dd5c6d" dependencies = [ "find-msvc-tools", + "jobserver", + "libc", "shlex", ] @@ -147,7 +177,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn", + "syn 3.0.9", ] [[package]] @@ -214,6 +244,66 @@ dependencies = [ "hybrid-array", ] +[[package]] +name = "darling" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1247195ecd7e3c85f83c8d2a366e4210d588e802133e1e355180a9870b517ea4" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.119", +] + +[[package]] +name = "darling_macro" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "deku" +version = "0.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebf55291257a2a5c90cf50ae17b6bbaabc3fd13642cf3895a71c412513c19630" +dependencies = [ + "bitvec", + "deku_derive", + "no_std_io2", + "rustversion", +] + +[[package]] +name = "deku_derive" +version = "0.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bec2a42b511fc5efd9183f4f71c17885d627b17e7fd9a61a92089406caa4397e" +dependencies = [ + "darling", + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "digest" version = "0.10.7" @@ -235,6 +325,12 @@ dependencies = [ "crypto-common 0.2.1", ] +[[package]] +name = "equivalent" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826" + [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -251,6 +347,18 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "funty" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" + [[package]] name = "generic-array" version = "0.14.7" @@ -272,6 +380,23 @@ dependencies = [ "wasi", ] +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + [[package]] name = "heck" version = "0.5.0" @@ -303,6 +428,22 @@ dependencies = [ "typenum", ] +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown", +] + [[package]] name = "is_terminal_polyfill" version = "1.70.2" @@ -315,6 +456,16 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom 0.4.3", + "libc", +] + [[package]] name = "libc" version = "0.2.186" @@ -336,6 +487,12 @@ dependencies = [ "digest 0.11.2", ] +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -346,6 +503,15 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "no_std_io2" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "418abd1b6d34fbf6cae440dc874771b0525a604428704c76e48b29a5e67b8003" +dependencies = [ + "memchr", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -364,6 +530,21 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -382,6 +563,18 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "radium" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" + [[package]] name = "ring" version = "0.17.14" @@ -390,7 +583,7 @@ checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" dependencies = [ "cc", "cfg-if", - "getrandom", + "getrandom 0.2.17", "libc", "untrusted", "windows-sys 0.52.0", @@ -431,6 +624,32 @@ dependencies = [ "untrusted", ] +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + [[package]] name = "sha1" version = "0.11.0" @@ -465,6 +684,12 @@ version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" +[[package]] +name = "solana-nohash-hasher" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b8a731ed60e89177c8a7ab05fe0f1511cedd3e70e773f288f9de33a9cfdc21e" + [[package]] name = "strsim" version = "0.11.1" @@ -477,6 +702,17 @@ version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "syn" version = "3.0.9" @@ -488,6 +724,12 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "tap" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" + [[package]] name = "thiserror" version = "2.0.21" @@ -505,7 +747,37 @@ checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.9", +] + +[[package]] +name = "toml_datetime" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "173f25c883cd2176a2d8212155638c0a60d484a9d84546d270866f40f56b6e49" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.25.19+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd43688c4858d7264ae1cf9a9f874a67c3feb74a883b1cd7bebd6a4ea8307218" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.1.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baa693a8032d7e1cada7d0041e96126df243179ff061456783ac7f12bda4744c" +dependencies = [ + "winnow", ] [[package]] @@ -676,12 +948,64 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "winnow" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c3b93199df0ab874cdf0c6a94f0a0dc3423fe41f1cafb62b8bd7b1deb277aef" +dependencies = [ + "memchr", +] + +[[package]] +name = "wyz" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed" +dependencies = [ + "tap", +] + +[[package]] +name = "xxhash-rust" +version = "0.8.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "550a2b930b62486a393c52d5c3b84bff264b28aa437ed64694d31e93b1757af7" + [[package]] name = "zeroize" version = "1.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +[[package]] +name = "zstd" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50adf04cf3ec914b0b9905c9dde90c80fe74bfd701e331fc80a39c512c525278" +dependencies = [ + "zstd-safe", +] + +[[package]] +name = "zstd-safe" +version = "7.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "498d94ad68720c89b4fbe41b9090850651e68cb2dd9210ce7ffbff3a94653819" +dependencies = [ + "zstd-sys", +] + +[[package]] +name = "zstd-sys" +version = "2.1.3+zstd.1.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f729a06d0257428fd9d997ac8db7e1407bd0262ab016b085b36bc52957c0052d" +dependencies = [ + "cc", + "pkg-config", +] + [[package]] name = "zsync-rs" version = "0.3.0" diff --git a/Cargo.toml b/Cargo.toml index d376f28..5cbc6e1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "appimagetool" -version = "0.5.6" +version = "0.6.0" edition = "2024" description = "A tool for creating AppImages" license = "MIT" @@ -18,6 +18,7 @@ embed-uruntime = [] embed-mkdwarfs = [] [dependencies] +backhand = { version = "0.25.5", default-features = false, features = ["zstd"] } clap = { version = "4.6.1", features = ["derive", "env"] } libc = "0.2.186" sha2 = "0.10.9" diff --git a/README.md b/README.md index ae7243e..054cb27 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ A Rust implementation of `appimagetool` for the [Anylinux-AppImages](https://github.com/pkgforge-dev/Anylinux-AppImages) project. -It takes a prepared AppDir and produces a finished `.AppImage` using DWARFS compression and the [uruntime](https://github.com/VHSgunzo/uruntime) AppImage runtime. Single binary, no Python, no C++ deps. +It takes a prepared AppDir and produces a finished `.AppImage` using DWARFS compression and the [Anylinux-uruntime](https://github.com/pkgforge-dev/Anylinux-uruntime) AppImage runtime. Single binary, no Python, no C++ deps. ## Quick start @@ -25,7 +25,8 @@ That's it. The tool will: ## Features - DWARFS compression for small, delta-friendly AppImages. -- Drop-in uruntime integration with automatic download and ELF section patching. +- Drop-in Anylinux-uruntime integration with automatic download and ELF section patching. +- A fixed-size SquashFS metadata sidecar (`.DirIcon`, desktop entry, and a stub `AppRun`) so SquashFS-only thumbnailers and desktop integration tools work with DwarFS AppImages. - Optional profile-guided optimization (DWARFS hotness profiling) for faster launches. - Built-in zsync generation, no `zsyncmake` shell-out. - No central repository, no daemon, no extra runtime deps. @@ -131,6 +132,33 @@ appimagetool --dwarfs-comp 'zstd:level=22 -S26 -B2' ./AppDir An explicit `--dwarfs-comp` is passed to `mkdwarfs` unchanged. +### SquashFS metadata sidecar + +Each AppImage gets a fixed **128 KiB** SquashFS sidecar between the runtime and +the DwarFS payload. It contains the AppDir's `.DirIcon`, main `.desktop` file, +and a stub `AppRun` that tells tools such as `appimage-run` that the image must +be mounted as DwarFS. The sidecar is compressed with zstd level 22. + +The sidecar makes SquashFS-only thumbnailers and desktop-integration tools work +without understanding DwarFS. The stub `AppRun` does not launch the application; +the real payload is the following DwarFS image. + +The sidecar is zero-padded to a constant size. This keeps the DwarFS payload at +a stable offset across releases, which is what preserves zsync delta-update +efficiency. Changing the slot size shifts the payload and costs a large update; +the first release that adopts the sidecar will also have a large update from +pre-sidecar versions. + +The build fails if the metadata image does not fit in the slot. The 128 KiB +slot covers typical 256x256 and 512x512 icons and most SVGs, including +translation-heavy desktop files. + +The pinned runtime is the +[Anylinux-uruntime](https://github.com/pkgforge-dev/Anylinux-uruntime) fork, +which skips the sidecar and mounts the DwarFS payload. A custom `--runtime` +must include the same metadata-image support, otherwise the runtime will try to +mount the metadata SquashFS as the application. + ### AppDir requirements The AppDir must contain: diff --git a/src/appimage.rs b/src/appimage.rs index c432264..c20c918 100644 --- a/src/appimage.rs +++ b/src/appimage.rs @@ -11,6 +11,7 @@ use crate::config::Config; use crate::desktop::{self, DesktopEntry}; use crate::dwarfs; use crate::error::Result; +use crate::metadata; use crate::uruntime; use crate::util; @@ -129,15 +130,23 @@ pub fn build(config: &Config) -> Result<()> { .clone() .unwrap_or_else(|| dwarfs::default_compression(&config.appdir)); - // Build the final AppImage - dwarfs::build_appimage( + // Build the final AppImage. The header is the configured runtime followed + // by a small metadata SquashFS so SquashFS-only thumbnailers and desktop + // integration tools can read .DirIcon and the desktop entry. + crate::log_info!("Building metadata SquashFS sidecar..."); + let sidecar = metadata::build(&config.appdir, &desktop.path)?; + let header_path = metadata::header_path(&config.tmpdir); + metadata::write_header(&header_path, &runtime_path, &sidecar)?; + let build_result = dwarfs::build_appimage( &mkdwarfs, &config.appdir, - &runtime_path, + &header_path, &output_path, &compression, profile.as_deref(), - )?; + ); + let _ = std::fs::remove_file(&header_path); + build_result?; // Make output executable use std::os::unix::fs::PermissionsExt; diff --git a/src/dwarfs.rs b/src/dwarfs.rs index 452ceb9..5d1881e 100644 --- a/src/dwarfs.rs +++ b/src/dwarfs.rs @@ -150,11 +150,12 @@ fn mkdwarfs_source(config: &Config) -> (String, Option<&'static str>) { (url, expected_checksum) } -/// Build a DWARFS AppImage. The runtime is embedded via `--header`. +/// Build a DWARFS AppImage. `header` is prepended via `--header`; it is the +/// configured runtime followed by the metadata SquashFS sidecar. pub fn build_appimage( mkdwarfs: &Path, appdir: &Path, - runtime: &Path, + header: &Path, output: &Path, compression: &str, profile: Option<&Path>, @@ -171,7 +172,7 @@ pub fn build_appimage( .arg("--no-history") .arg("--no-create-timestamp") .arg("--header") - .arg(runtime) + .arg(header) .arg("--input") .arg(appdir); diff --git a/src/error.rs b/src/error.rs index ab941a3..c86bf16 100644 --- a/src/error.rs +++ b/src/error.rs @@ -84,6 +84,22 @@ pub enum Error { #[error("mksquashfs failed: {0}")] SquashfsFailed(String), + /// Failed to build the metadata SquashFS sidecar. + #[error("failed to build metadata SquashFS sidecar: {0}")] + MetadataSidecar(String), + + /// The metadata SquashFS is larger than its fixed slot. + #[error( + "metadata SquashFS is {size} bytes, exceeding the fixed {limit}-byte sidecar slot\n \ + hint: use a smaller .DirIcon (for example a 256x256 PNG) or reduce the desktop entry size" + )] + MetadataSidecarTooLarge { + /// Size of the built metadata image. + size: usize, + /// Fixed slot size available for the sidecar. + limit: usize, + }, + /// Failed to generate the `.zsync` control file. #[error( "zsync generation failed: {0}\n \ diff --git a/src/lib.rs b/src/lib.rs index 4704ace..36027fd 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -14,6 +14,8 @@ pub mod elf; pub mod embed; pub mod error; pub mod log; +/// Builds the metadata SquashFS sidecar placed before the DwarFS payload. +pub mod metadata; /// Pinned upstream artifact URLs and SHA-256 digests, shared with `build.rs`. pub mod pinned; pub mod uruntime; diff --git a/src/metadata.rs b/src/metadata.rs new file mode 100644 index 0000000..aa0aeb0 --- /dev/null +++ b/src/metadata.rs @@ -0,0 +1,211 @@ +//! Build the small SquashFS metadata sidecar placed between the runtime and +//! the DwarFS payload. +//! +//! Many thumbnailers and desktop-integration tools only know how to read a +//! SquashFS at the end of the runtime ELF. The sidecar carries `.DirIcon`, the +//! main `.desktop` entry, and a stub `AppRun` so those tools work against a +//! DwarFS AppImage. The runtime skips this image and mounts the following +//! DwarFS payload. +//! +//! The image is always padded to [`SIDECAR_SLOT_BYTES`]. Keeping the slot size +//! constant keeps the DwarFS payload at a stable offset across releases, which +//! preserves zsync delta-update efficiency. + +use std::io::Cursor; +use std::path::{Path, PathBuf}; + +use backhand::compression::{CompressionOptions, Compressor, Zstd}; +use backhand::{BackhandError, FilesystemCompressor, FilesystemWriter, NodeHeader}; + +use crate::error::{Error, Result}; + +/// zstd level used for the sidecar. +const SIDECAR_ZSTD_LEVEL: u32 = 22; + +/// Fixed size of the metadata slot between the runtime and the DwarFS payload. +/// +/// The image is zero-padded to this size. The patched runtime scans the fixed +/// 128 KiB slot for the following DwarFS payload. +pub const SIDECAR_SLOT_BYTES: usize = 128 * 1024; + +/// Stub `AppRun` for tools that mount or extract the metadata image instead of +/// using uruntime. It must never be mistaken for the real DwarFS payload. +const DUMMY_APPRUN: &[u8] = b"#!/bin/sh\n\ +>&2 echo 'This is not the real AppImage payload, this is a DwarFS AppImage, please use dwarfs to mount it instead'\n\ +exit 1\n"; + +/// Build a padded metadata SquashFS image containing `.DirIcon`, the main +/// desktop entry, and a stub `AppRun` from `appdir`. +/// +/// `desktop_path` must be the final desktop file (after X-AppImage metadata +/// has been added), so the sidecar advertises the same metadata as the real +/// payload. +pub fn build(appdir: &Path, desktop_path: &Path) -> Result> { + let dir_icon = std::fs::read(appdir.join(".DirIcon"))?; + let desktop = std::fs::read(desktop_path)?; + let desktop_name = desktop_path + .file_name() + .ok_or_else(|| Error::MetadataSidecar("desktop entry has no file name".to_string()))? + .to_owned(); + + let mut fs = FilesystemWriter::default(); + // A fixed timestamp and root ownership keep the sidecar reproducible. + fs.set_time(0); + fs.set_only_root_id(); + fs.set_root_mode(0o755); + fs.set_root_uid(0); + fs.set_root_gid(0); + fs.set_no_padding(); + let compressor = FilesystemCompressor::new( + Compressor::Zstd, + Some(CompressionOptions::Zstd(Zstd { + compression_level: SIDECAR_ZSTD_LEVEL, + })), + ) + .map_err(metadata_error)?; + fs.set_compressor(compressor); + // The zstd level is a build-time choice; readers only need the compressor + // id, so don't spend bytes storing the options in the image. + fs.set_emit_compression_options(false); + + let file_header = NodeHeader::new(0o644, 0, 0, 0); + fs.push_file(Cursor::new(dir_icon), Path::new(".DirIcon"), file_header) + .map_err(metadata_error)?; + fs.push_file(Cursor::new(desktop), Path::new(&desktop_name), file_header) + .map_err(metadata_error)?; + fs.push_file( + Cursor::new(DUMMY_APPRUN), + Path::new("AppRun"), + NodeHeader::new(0o755, 0, 0, 0), + ) + .map_err(metadata_error)?; + + let mut output = Cursor::new(Vec::new()); + let (superblock, _) = fs.write(&mut output).map_err(metadata_error)?; + let mut image = output.into_inner(); + let actual_size = usize::try_from(superblock.bytes_used).map_err(|_| { + Error::MetadataSidecar("metadata image size does not fit usize".to_string()) + })?; + + if actual_size > SIDECAR_SLOT_BYTES { + return Err(Error::MetadataSidecarTooLarge { + size: actual_size, + limit: SIDECAR_SLOT_BYTES, + }); + } + + image.resize(SIDECAR_SLOT_BYTES, 0); + crate::log_debug!( + "metadata sidecar: {} bytes, padded to {} bytes", + actual_size, + SIDECAR_SLOT_BYTES + ); + Ok(image) +} + +/// Write `runtime || sidecar` to `dest` for use as `mkdwarfs --header`. +pub fn write_header(dest: &Path, runtime: &Path, sidecar: &[u8]) -> Result<()> { + use std::io::Write; + + let runtime_bytes = std::fs::read(runtime)?; + let mut file = std::fs::File::create(dest)?; + file.write_all(&runtime_bytes)?; + file.write_all(sidecar)?; + file.flush()?; + Ok(()) +} + +/// Temporary header path for a single build. +pub fn header_path(tmpdir: &Path) -> PathBuf { + crate::util::process_unique_path(tmpdir, "appimage-header") +} + +fn metadata_error(error: BackhandError) -> Error { + Error::MetadataSidecar(error.to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn temp_dir(name: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!( + "appimagetool-metadata-{name}-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::create_dir_all(&dir).unwrap(); + dir + } + + #[test] + fn builds_fixed_size_squashfs_with_expected_entries() { + let dir = temp_dir("build"); + std::fs::write(dir.join(".DirIcon"), b"fake png bytes").unwrap(); + std::fs::write(dir.join("TestApp.desktop"), b"[Desktop Entry]\nName=Test\n").unwrap(); + + let image = build(&dir, &dir.join("TestApp.desktop")).unwrap(); + assert_eq!(image.len(), SIDECAR_SLOT_BYTES); + assert_eq!(&image[..4], b"hsqs"); + + let bytes_used = u64::from_le_bytes(image[40..48].try_into().unwrap()); + assert!(bytes_used <= SIDECAR_SLOT_BYTES as u64); + + let reader = + backhand::FilesystemReader::from_reader(Cursor::new(image.as_slice())).unwrap(); + let paths: Vec<_> = reader.files().map(|node| node.fullpath.clone()).collect(); + assert!(paths.iter().any(|path| path == Path::new("/.DirIcon"))); + assert!( + paths + .iter() + .any(|path| path == Path::new("/TestApp.desktop")) + ); + assert!(paths.iter().any(|path| path == Path::new("/AppRun"))); + + let apprun = reader + .files() + .find(|node| node.fullpath == Path::new("/AppRun")) + .unwrap(); + assert_eq!(apprun.header.permissions, 0o755); + + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn is_reproducible() { + let dir = temp_dir("repro"); + std::fs::write(dir.join(".DirIcon"), b"fake png bytes").unwrap(); + std::fs::write(dir.join("TestApp.desktop"), b"[Desktop Entry]\nName=Test\n").unwrap(); + + let first = build(&dir, &dir.join("TestApp.desktop")).unwrap(); + let second = build(&dir, &dir.join("TestApp.desktop")).unwrap(); + assert_eq!(first, second); + + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn rejects_metadata_larger_than_slot() { + let dir = temp_dir("large"); + // Pseudo-random bytes resist zstd better than a repeating pattern. + let mut state = 0x9e37_79b9_u32; + let large: Vec = (0..SIDECAR_SLOT_BYTES * 2) + .map(|_| { + state ^= state << 13; + state ^= state >> 17; + state ^= state << 5; + (state & 0xff) as u8 + }) + .collect(); + std::fs::write(dir.join(".DirIcon"), &large).unwrap(); + std::fs::write(dir.join("TestApp.desktop"), b"[Desktop Entry]\nName=Test\n").unwrap(); + + let error = build(&dir, &dir.join("TestApp.desktop")).unwrap_err(); + assert!(matches!(error, Error::MetadataSidecarTooLarge { .. })); + + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/src/pinned.rs b/src/pinned.rs index 24742bc..67dc36e 100644 --- a/src/pinned.rs +++ b/src/pinned.rs @@ -1,18 +1,23 @@ -// Pinned upstream artifact URLs and SHA-256 digests. +// Pinned artifact URLs and SHA-256 digests. // // Uses line comments rather than `//!` module docs because `build.rs` pulls this // file in with `include!`, which cannot carry inner doc comments. The module // documentation lives on the `pub mod pinned;` declaration in `lib.rs`. -/// Default uruntime download URL. Pinned for reproducible builds; override with -/// `--runtime-url` / `URUNTIME_LINK`. `{arch}` is replaced with the target -/// architecture. +/// Default Anylinux-uruntime download URL. Pinned for reproducible builds; +/// override with `--runtime-url` / `URUNTIME_LINK`. `{arch}` is replaced with +/// the target architecture. +/// +/// This is the pkgforge-dev fork of uruntime. It tracks upstream version 0.8.1 +/// and adds support for skipping a leading metadata SquashFS image, which the +/// AppImage builder uses so SquashFS-only thumbnailers and desktop integration +/// tools can read `.DirIcon` and the desktop entry. /// /// The `-lite` in this asset name is a licensing boundary, not just a size /// choice. The lite variant embeds DwarFS `dwarfs-fuse-extract`, which holds /// only the MIT-licensed reader and extractor. The non-lite variant embeds /// `dwarfs-universal`, which also contains the GPL-3.0 `mkdwarfs` writer. -pub const URUNTIME_URL_TEMPLATE: &str = "https://github.com/VHSgunzo/uruntime/releases/download/v0.8.1/uruntime-appimage-dwarfs-lite-{arch}"; +pub const URUNTIME_URL_TEMPLATE: &str = "https://github.com/pkgforge-dev/Anylinux-uruntime/releases/download/v0.8.1/uruntime-appimage-dwarfs-lite-{arch}"; /// Default mkdwarfs download URL. `{arch}` is the host arch at run time, and /// the build target in `build.rs` when embedding the blob. @@ -30,27 +35,27 @@ pub const MKDWARFS_URL_TEMPLATE: &str = pub const URUNTIME_CHECKSUMS: &[(&str, &str)] = &[ ( "aarch64", - "c1641dfe465f4cb70ae545fbfb9de7a221aa6e9a0b1d7ea600b213a1f10738a0", + "3abaff7ab3f060ee0decfae106b08c1a08577ce623b9ff6c0aeba3107c4399fc", ), ( "loongarch64", - "7f149441fbb772477c8748e58c1233e4e0e141d6ca2507c4ddad97b69b4008bc", + "6e10bf259359fd5fb22ac11f4354e8b9aff36976d35077a020ad0681ccd7380a", ), ( "ppc64", - "db7a7834c1cb657c2708eab206d9a140231962d22f776cd0ea4fd9a482be3037", + "6798d59314bdc42b756afb05038778706b26a22eb96a27e47fc563d386cbbe10", ), ( "ppc64le", - "0fa3983c5c18794e841d2ad20938b87c467cbf24034a0fad608c5fe00a1668a9", + "e8cda02eab73180c241dfd2352426edff28fd7f46524275d44b27aa474f49eee", ), ( "riscv64", - "25d1137942a24fed3d1126686960de5dc5ec44070d5e3a6c3544cca229d230cb", + "13dcf97dc0b2a7fd35e719d2b3d26ad33be8df1ddf65b9f3000a013bbbd25b3a", ), ( "x86_64", - "b3c2916153e089d703cee5a7ebc540941d2f1d71baa90eb3092b15eef48345a8", + "b5e361879fdd8d6056f814cfdfcecc02bb74b679cf9d9d81ea6f0d65b521fb18", ), ];