From 30bd69910522e2042cc7021ad60f9f2d07599b81 Mon Sep 17 00:00:00 2001 From: Samueru-sama Date: Fri, 13 Nov 2026 03:12:30 -0400 Subject: [PATCH 1/4] feat(sidecar): add a fixed 128 KiB zstd metadata SquashFS Place a small zstd SquashFS between the runtime and the DwarFS payload containing .DirIcon, the final desktop entry, and a stub AppRun. This lets SquashFS-only thumbnailers and desktop integration tools read AppImage metadata while uruntime still mounts the DwarFS payload. The sidecar is built in Rust with backhand, padded to a fixed 128 KiB slot for stable zsync offsets, and compressed with zstd level 22. The stub AppRun explains that the metadata image is not the real payload. The pinned runtime is the reissued Anylinux-uruntime v0.8.1 release, which scans up to 256 KiB past the metadata image without adding a SquashFS reader to -lite runtimes. --- Cargo.lock | 330 +++++++++++++++++++++++++++++++++++++++++++++++- Cargo.toml | 1 + README.md | 32 ++++- src/appimage.rs | 17 ++- src/dwarfs.rs | 7 +- src/error.rs | 16 +++ src/lib.rs | 2 + src/metadata.rs | 212 +++++++++++++++++++++++++++++++ src/pinned.rs | 27 ++-- 9 files changed, 621 insertions(+), 23 deletions(-) create mode 100644 src/metadata.rs diff --git a/Cargo.lock b/Cargo.lock index ca69b42..e95dba2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -62,6 +62,7 @@ dependencies = [ name = "appimagetool" version = "0.5.6" 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..82b6e9c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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..c7829c2 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 256x256 PNG .DirIcon or rebuild the runtime with a larger metadata scan limit" + )] + 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..65abe0d --- /dev/null +++ b/src/metadata.rs @@ -0,0 +1,212 @@ +//! 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 just `.DirIcon` +//! and the main `.desktop` entry 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. This value must not exceed +/// uruntime's `METADATA_PAYLOAD_SCAN_LIMIT`; the patched runtime uses a larger +/// limit so a 128 KiB slot can always be skipped. +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` and the main +/// desktop entry 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..a375f41 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", + "87a8e8760d9ea8aee8b4269e36816d0a747bbd684a53ed464e762f0dc8ce0a31", ), ( "loongarch64", - "7f149441fbb772477c8748e58c1233e4e0e141d6ca2507c4ddad97b69b4008bc", + "ecb9cb45bf8e1b28aba7e42efcd79d2fbdcb770e01619a34433e299c340e17d4", ), ( "ppc64", - "db7a7834c1cb657c2708eab206d9a140231962d22f776cd0ea4fd9a482be3037", + "a1a3c87da85c67fcf14a6104103f9f999d692b46da42901f7e0f7c46b190f56b", ), ( "ppc64le", - "0fa3983c5c18794e841d2ad20938b87c467cbf24034a0fad608c5fe00a1668a9", + "748d0f37f5b430b93987cf39186ef1f50e14a129ad1d5b39917a8d10e4a64ae5", ), ( "riscv64", - "25d1137942a24fed3d1126686960de5dc5ec44070d5e3a6c3544cca229d230cb", + "1807c93f57fffdc26a9b6555bb73a37f0a5e81cf151c3eeeada4c42d1aa013db", ), ( "x86_64", - "b3c2916153e089d703cee5a7ebc540941d2f1d71baa90eb3092b15eef48345a8", + "3b5c92fb09c14bda9b51fb89c0c7125d84bf05777280abf07a267dbd4f6497a0", ), ]; From 5cc744d27174fcedbf571e464295459218027fa0 Mon Sep 17 00:00:00 2001 From: Samueru-sama Date: Fri, 13 Nov 2026 03:26:17 -0400 Subject: [PATCH 2/4] docs(sidecar): mention the stub AppRun Document that the sidecar contains AppRun in both the module and builder docs, and keep the oversized-metadata hint focused on shrinking the icon or desktop entry instead of suggesting a slot size change. --- src/error.rs | 2 +- src/metadata.rs | 12 ++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/error.rs b/src/error.rs index c7829c2..c86bf16 100644 --- a/src/error.rs +++ b/src/error.rs @@ -91,7 +91,7 @@ pub enum Error { /// 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 256x256 PNG .DirIcon or rebuild the runtime with a larger metadata scan limit" + hint: use a smaller .DirIcon (for example a 256x256 PNG) or reduce the desktop entry size" )] MetadataSidecarTooLarge { /// Size of the built metadata image. diff --git a/src/metadata.rs b/src/metadata.rs index 65abe0d..f0b7e44 100644 --- a/src/metadata.rs +++ b/src/metadata.rs @@ -2,10 +2,10 @@ //! 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 just `.DirIcon` -//! and the main `.desktop` entry so those tools work against a DwarFS -//! AppImage. The runtime skips this image and mounts the following DwarFS -//! payload. +//! 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 @@ -35,8 +35,8 @@ 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` and the main -/// desktop entry from `appdir`. +/// 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 From 18b19c5b2cd2c1b48902613466d7df3c9b71f776 Mon Sep 17 00:00:00 2001 From: Samueru-sama Date: Fri, 13 Nov 2026 03:40:25 -0400 Subject: [PATCH 3/4] fix(sidecar): pin the 128 KiB-scan runtime release The fixed sidecar slot is 128 KiB, so the runtime now scans exactly that window. Update the pinned v0.8.1 assets and checksums to the reissued release, and document that the runtime scans the fixed slot. --- src/metadata.rs | 5 ++--- src/pinned.rs | 12 ++++++------ 2 files changed, 8 insertions(+), 9 deletions(-) diff --git a/src/metadata.rs b/src/metadata.rs index f0b7e44..aa0aeb0 100644 --- a/src/metadata.rs +++ b/src/metadata.rs @@ -24,9 +24,8 @@ 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. This value must not exceed -/// uruntime's `METADATA_PAYLOAD_SCAN_LIMIT`; the patched runtime uses a larger -/// limit so a 128 KiB slot can always be skipped. +/// 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 diff --git a/src/pinned.rs b/src/pinned.rs index a375f41..67dc36e 100644 --- a/src/pinned.rs +++ b/src/pinned.rs @@ -35,27 +35,27 @@ pub const MKDWARFS_URL_TEMPLATE: &str = pub const URUNTIME_CHECKSUMS: &[(&str, &str)] = &[ ( "aarch64", - "87a8e8760d9ea8aee8b4269e36816d0a747bbd684a53ed464e762f0dc8ce0a31", + "3abaff7ab3f060ee0decfae106b08c1a08577ce623b9ff6c0aeba3107c4399fc", ), ( "loongarch64", - "ecb9cb45bf8e1b28aba7e42efcd79d2fbdcb770e01619a34433e299c340e17d4", + "6e10bf259359fd5fb22ac11f4354e8b9aff36976d35077a020ad0681ccd7380a", ), ( "ppc64", - "a1a3c87da85c67fcf14a6104103f9f999d692b46da42901f7e0f7c46b190f56b", + "6798d59314bdc42b756afb05038778706b26a22eb96a27e47fc563d386cbbe10", ), ( "ppc64le", - "748d0f37f5b430b93987cf39186ef1f50e14a129ad1d5b39917a8d10e4a64ae5", + "e8cda02eab73180c241dfd2352426edff28fd7f46524275d44b27aa474f49eee", ), ( "riscv64", - "1807c93f57fffdc26a9b6555bb73a37f0a5e81cf151c3eeeada4c42d1aa013db", + "13dcf97dc0b2a7fd35e719d2b3d26ad33be8df1ddf65b9f3000a013bbbd25b3a", ), ( "x86_64", - "3b5c92fb09c14bda9b51fb89c0c7125d84bf05777280abf07a267dbd4f6497a0", + "b5e361879fdd8d6056f814cfdfcecc02bb74b679cf9d9d81ea6f0d65b521fb18", ), ]; From 2cf38f94609bf8d0c6e1b2aaed123c2a2e616e01 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 13 Nov 2026 07:41:25 +0000 Subject: [PATCH 4/4] chore: release v0.6.0 --- CHANGELOG.md | 18 ++++++++++++++++++ Cargo.lock | 2 +- Cargo.toml | 2 +- 3 files changed, 20 insertions(+), 2 deletions(-) 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 e95dba2..9cdfd82 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -60,7 +60,7 @@ dependencies = [ [[package]] name = "appimagetool" -version = "0.5.6" +version = "0.6.0" dependencies = [ "backhand", "clap", diff --git a/Cargo.toml b/Cargo.toml index 82b6e9c..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"