From e56ffe846522e78622ca777b1753ff0f811b74c5 Mon Sep 17 00:00:00 2001 From: dingo Date: Sun, 9 Aug 2026 22:52:23 +0700 Subject: [PATCH] cosmic-conf: watch + theme asset installation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit watch: source-include expansion by textual splicing so diagnostic spans stay correct across merged files; debounced inotify; a bad edit prints diagnostics and keeps watching rather than killing the daemon. assets: plan/apply split mirroring emit.rs. Tarball entries and symlink/ hardlink targets are validated before extraction, sharing one routine between plan and apply so the check cannot drift. Added tests/archive_escape.rs as independent verification of that boundary. The tar crate refuses to build hostile archives through its safe API, so the fixtures write GNU header name/linkname bytes directly — the same thing a malicious archiver does. Asserts on the filesystem afterwards rather than on returned errors, and covers symlink indirection, where neither entry path contains '..' yet a later write still escapes. 99 tests. --- .gitignore | 3 + cosmic-conf/Cargo.lock | 283 ++++++++- cosmic-conf/Cargo.toml | 3 + cosmic-conf/src/assets.rs | 862 ++++++++++++++++++++++++++++ cosmic-conf/src/lib.rs | 2 + cosmic-conf/src/watch.rs | 661 +++++++++++++++++++++ cosmic-conf/tests/archive_escape.rs | 178 ++++++ 7 files changed, 1989 insertions(+), 3 deletions(-) create mode 100644 cosmic-conf/src/assets.rs create mode 100644 cosmic-conf/src/watch.rs create mode 100644 cosmic-conf/tests/archive_escape.rs diff --git a/.gitignore b/.gitignore index cd0766b..2c1f958 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,6 @@ target/ **/*.rs.bk vendor/ + +# Fork; becomes a submodule per spec repo topology +cosmic-comp/ diff --git a/cosmic-conf/Cargo.lock b/cosmic-conf/Cargo.lock index f963fd5..bdd7e05 100644 --- a/cosmic-conf/Cargo.lock +++ b/cosmic-conf/Cargo.lock @@ -2,6 +2,12 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + [[package]] name = "bitflags" version = "2.13.1" @@ -21,11 +27,23 @@ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" name = "cosmic-conf" version = "0.1.0" dependencies = [ + "flate2", + "notify", "ron", "serde", + "tar", "tempfile", ] +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + [[package]] name = "errno" version = "0.3.14" @@ -33,7 +51,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -42,6 +60,35 @@ version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" +[[package]] +name = "filetime" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" +dependencies = [ + "cfg-if", + "libc", +] + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "fsevent-sys" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2" +dependencies = [ + "libc", +] + [[package]] name = "getrandom" version = "0.4.3" @@ -53,6 +100,46 @@ dependencies = [ "r-efi", ] +[[package]] +name = "inotify" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "153be1941a183ec9ccd095ddbe17a8b8d435ef6c76e9e02451b933c3999af2c8" +dependencies = [ + "bitflags", + "inotify-sys", + "libc", +] + +[[package]] +name = "inotify-sys" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c033f80b2c113cdf91ab7a33faa9cbc014726dcad99880c8609af2a370edf37d" +dependencies = [ + "libc", +] + +[[package]] +name = "kqueue" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d763e5b24120b4ddf50de6c92308156765aabfbbccebf401da7cff2d70a41ea" +dependencies = [ + "kqueue-sys", + "libc", +] + +[[package]] +name = "kqueue-sys" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07293a4e297ac234359b510362495713f75ea345d5307140414f20c69ffeb087" +dependencies = [ + "bitflags", + "libc", +] + [[package]] name = "libc" version = "0.2.189" @@ -65,6 +152,61 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "notify" +version = "8.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d3d07927151ff8575b7087f245456e549fea62edf0ec4e565a5ee50c8402bc3" +dependencies = [ + "bitflags", + "fsevent-sys", + "inotify", + "kqueue", + "libc", + "log", + "mio", + "notify-types", + "walkdir", + "windows-sys 0.60.2", +] + +[[package]] +name = "notify-types" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42b8cfee0e339a0337359f3c88165702ac6e600dc01c0cc9579a92d62b08477a" +dependencies = [ + "bitflags", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -119,7 +261,16 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", ] [[package]] @@ -152,6 +303,12 @@ dependencies = [ "syn", ] +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + [[package]] name = "syn" version = "3.0.3" @@ -163,6 +320,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "tar" +version = "0.4.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" +dependencies = [ + "filetime", + "libc", + "xattr", +] + [[package]] name = "tempfile" version = "3.27.0" @@ -173,7 +341,7 @@ dependencies = [ "getrandom", "once_cell", "rustix", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -188,12 +356,46 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets", +] + [[package]] name = "windows-sys" version = "0.61.2" @@ -202,3 +404,78 @@ checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" dependencies = [ "windows-link", ] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix", +] diff --git a/cosmic-conf/Cargo.toml b/cosmic-conf/Cargo.toml index 8513875..bf9b1a9 100644 --- a/cosmic-conf/Cargo.toml +++ b/cosmic-conf/Cargo.toml @@ -6,8 +6,11 @@ license = "GPL-3.0-only" description = "Compiles a single Hyprland-idiom config file into the cosmic-config tree" [dependencies] +flate2 = "1.1.9" +notify = "8.2.0" ron = "0.12" serde = { version = "1.0.229", features = ["derive"] } +tar = "0.4.46" [features] # `emit` links cosmic-config and the component crates. Off by default so the diff --git a/cosmic-conf/src/assets.rs b/cosmic-conf/src/assets.rs new file mode 100644 index 0000000..bec0680 --- /dev/null +++ b/cosmic-conf/src/assets.rs @@ -0,0 +1,862 @@ +//! Install the non-config parts of a HyDE theme: GTK/icon themes, wallpapers, +//! and the upstream `.theme` files that belong to waybar/rofi/kitty. +//! +//! `import.rs` handles `hypr.theme` -> `cosmic.conf`. Everything else in a +//! HyDE theme directory is either a foreign binary blob (a tarball) or a +//! config file for a program cosmic-conf does not own, so there is nothing to +//! translate — only to place correctly. As with `emit.rs`, installation is +//! two-stage: `plan` discovers what would happen without touching disk, +//! `apply` does the writing. A theme directory is untrusted input (it may +//! have been downloaded from anywhere), so the tarball extraction path is +//! hardened against path traversal. +//! +//! Real layout, verified against `HyDE-Project/hyde-themes`, branch +//! `Catppuccin-Mocha`: +//! +//! ```text +//! Configs/.config/hyde/themes//hypr.theme +//! Configs/.config/hyde/themes//waybar.theme +//! Configs/.config/hyde/themes//rofi.theme +//! Configs/.config/hyde/themes//kitty.theme +//! Configs/.config/hyde/themes//wallpapers/* +//! Source/Gtk_.tar.gz +//! Source/Icon_.tar.gz +//! ``` +//! +//! The GTK/icon tarballs live in a `Source/` directory that is a sibling of +//! `Configs/`, not inside the per-theme folder, so `plan` takes it as a +//! separate optional argument rather than assuming it is nested under +//! `theme_dir`. + +use std::collections::BTreeSet; +use std::fmt; +use std::fs; +use std::io; +use std::path::{Component, Path, PathBuf}; + +use flate2::read::GzDecoder; + +#[derive(Debug)] +pub enum AssetError { + Io(io::Error), + /// A tarball entry (or, for a symlink/hardlink, its link target) with an + /// absolute path or a `..` component. Both would let extraction write + /// outside `dest_root`, so this is refused unconditionally rather than + /// sanitised — a theme directory is untrusted input. + UnsafeArchiveEntry { archive: PathBuf, entry: PathBuf }, + NoHomeDirectory, +} + +impl fmt::Display for AssetError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + AssetError::Io(e) => write!(f, "io error: {e}"), + AssetError::UnsafeArchiveEntry { archive, entry } => write!( + f, + "refusing to extract `{}`: entry `{}` escapes the destination directory", + archive.display(), + entry.display() + ), + AssetError::NoHomeDirectory => write!(f, "no home directory available"), + } + } +} + +impl std::error::Error for AssetError {} + +impl From for AssetError { + fn from(e: io::Error) -> Self { + AssetError::Io(e) + } +} + +/// What kind of asset a `Note` or `Action` concerns, for report labelling. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AssetKind { + Gtk, + Icon, + Wallpaper, + Waybar, + Rofi, + Kitty, +} + +impl AssetKind { + fn label(self) -> &'static str { + match self { + AssetKind::Gtk => "GTK theme", + AssetKind::Icon => "icon theme", + AssetKind::Wallpaper => "wallpaper", + AssetKind::Waybar => "waybar config", + AssetKind::Rofi => "rofi config", + AssetKind::Kitty => "kitty config", + } + } +} + +/// Why an asset was left out of the plan. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SkipReason { + /// The destination already exists and `overwrite` was not requested. + /// Never overwriting silently is the point — a stale install must be + /// explicit, not a side effect of importing a new theme. + AlreadyInstalled, + /// The `.theme` file did not open with a `$HOME` destination header (see + /// `split_hyde_header`), so there is no known upstream location to copy + /// it to. + NoDestinationHeader, +} + +impl SkipReason { + pub fn describe(&self) -> &'static str { + match self { + SkipReason::AlreadyInstalled => "already installed; pass --overwrite to replace it", + SkipReason::NoDestinationHeader => { + "no $HOME destination header; cannot determine an install path" + } + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Note { + pub kind: AssetKind, + pub path: PathBuf, + pub reason: SkipReason, +} + +/// One pending install step. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Action { + /// Extract a GTK/icon tarball into `dest` (`~/.themes` or `~/.icons`). + /// The tarball supplies its own top-level directory name. + ExtractArchive { + kind: AssetKind, + archive: PathBuf, + dest: PathBuf, + }, + /// A wallpaper image, copied byte-for-byte. + CopyWallpaper { src: PathBuf, dest: PathBuf }, + /// A waybar/rofi/kitty `.theme` file, copied with its HyDE destination + /// header stripped (see `split_hyde_header`) but its body untouched. + CopyVerbatim { + kind: AssetKind, + src: PathBuf, + dest: PathBuf, + contents: String, + }, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct Plan { + pub actions: Vec, + pub skipped: Vec, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct Report { + pub installed: Vec, +} + +/// Human-readable summary, in the same spirit as `import::render_report`: +/// nothing that was skipped is left unmentioned. +pub fn render_report(plan: &Plan, report: &Report) -> String { + let mut out = String::new(); + if report.installed.is_empty() { + out.push_str("Nothing was installed.\n"); + } else { + out.push_str("Installed:\n"); + for p in &report.installed { + out.push_str(&format!(" {}\n", p.display())); + } + } + if !plan.skipped.is_empty() { + out.push_str("\nSkipped:\n"); + for n in &plan.skipped { + out.push_str(&format!( + " {} ({}): {}\n", + n.path.display(), + n.kind.label(), + n.reason.describe() + )); + } + } + out +} + +pub struct Installer { + /// `$XDG_DATA_HOME`, falling back to `$HOME/.local/share` — same + /// fallback shape as `Emitter::from_env` (emit.rs:126-137). Wallpapers + /// live under here. + data_home: PathBuf, + /// `$HOME` itself: GTK/icon themes go to the legacy `~/.themes` / + /// `~/.icons` locations rather than anywhere under `data_home`, and it is + /// also what a `.theme` file's `$HOME`-relative destination header + /// expands against. + home: PathBuf, +} + +impl Installer { + pub fn from_env() -> Result { + let home = std::env::var_os("HOME") + .map(PathBuf::from) + .ok_or(AssetError::NoHomeDirectory)?; + let data_home = match std::env::var_os("XDG_DATA_HOME") { + Some(x) if !x.is_empty() => PathBuf::from(x), + _ => home.join(".local").join("share"), + }; + Ok(Self { data_home, home }) + } + + pub fn with_paths(data_home: impl Into, home: impl Into) -> Self { + Self { + data_home: data_home.into(), + home: home.into(), + } + } + + /// Discover every installable asset in a theme and decide where it would + /// go, without writing anything. Mirrors `Emitter::plan` / `plan_one`: + /// return every error rather than the first, and never touch the + /// destination tree. + /// + /// `overwrite` controls what happens when a destination already exists — + /// see `SkipReason::AlreadyInstalled`. `theme_name` names the wallpaper + /// subdirectory (`$XDG_DATA_HOME/wallpapers/hyprcosmic//`). + pub fn plan( + &self, + theme_dir: &Path, + source_dir: Option<&Path>, + theme_name: &str, + overwrite: bool, + ) -> Result> { + let mut actions = Vec::new(); + let mut skipped = Vec::new(); + let mut errors = Vec::new(); + + if let Some(source_dir) = source_dir { + for (kind, prefix, dest_root) in [ + (AssetKind::Gtk, "Gtk_", self.home.join(".themes")), + (AssetKind::Icon, "Icon_", self.home.join(".icons")), + ] { + match find_tarball(source_dir, prefix) { + Ok(Some(archive)) => { + match self.plan_archive(kind, &archive, &dest_root, overwrite) { + Ok(Some(action)) => actions.push(action), + Ok(None) => skipped.push(Note { + kind, + path: archive, + reason: SkipReason::AlreadyInstalled, + }), + Err(e) => errors.push(e), + } + } + Ok(None) => {} // No tarball of this kind — not every theme ships both. + Err(e) => errors.push(e), + } + } + } + + self.plan_wallpapers(theme_dir, theme_name, overwrite, &mut actions, &mut skipped, &mut errors); + + for (kind, filename) in [ + (AssetKind::Waybar, "waybar.theme"), + (AssetKind::Rofi, "rofi.theme"), + (AssetKind::Kitty, "kitty.theme"), + ] { + self.plan_verbatim(theme_dir, kind, filename, overwrite, &mut actions, &mut skipped, &mut errors); + } + + if errors.is_empty() { + Ok(Plan { actions, skipped }) + } else { + Err(errors) + } + } + + fn plan_archive( + &self, + kind: AssetKind, + archive: &Path, + dest_root: &Path, + overwrite: bool, + ) -> Result, AssetError> { + // `write: false` — this only reads the tarball's headers to validate + // and name-check it; extraction happens in `apply`. + let entries = walk_archive(archive, dest_root, false)?; + if !overwrite { + let already_present = top_level_names(&entries) + .into_iter() + .any(|name| dest_root.join(name).exists()); + if already_present { + return Ok(None); + } + } + Ok(Some(Action::ExtractArchive { + kind, + archive: archive.to_path_buf(), + dest: dest_root.to_path_buf(), + })) + } + + fn plan_wallpapers( + &self, + theme_dir: &Path, + theme_name: &str, + overwrite: bool, + actions: &mut Vec, + skipped: &mut Vec, + errors: &mut Vec, + ) { + let wallpapers_dir = theme_dir.join("wallpapers"); + if !wallpapers_dir.is_dir() { + return; + } + let dest_dir = self + .data_home + .join("wallpapers") + .join("hyprcosmic") + .join(theme_name); + + let entries = match fs::read_dir(&wallpapers_dir) { + Ok(e) => e, + Err(e) => { + errors.push(e.into()); + return; + } + }; + for entry in entries { + let entry = match entry { + Ok(e) => e, + Err(e) => { + errors.push(e.into()); + continue; + } + }; + let src = entry.path(); + if !src.is_file() { + continue; + } + let dest = dest_dir.join(entry.file_name()); + if !overwrite && dest.exists() { + skipped.push(Note { + kind: AssetKind::Wallpaper, + path: src, + reason: SkipReason::AlreadyInstalled, + }); + continue; + } + actions.push(Action::CopyWallpaper { src, dest }); + } + } + + fn plan_verbatim( + &self, + theme_dir: &Path, + kind: AssetKind, + filename: &str, + overwrite: bool, + actions: &mut Vec, + skipped: &mut Vec, + errors: &mut Vec, + ) { + let src = theme_dir.join(filename); + if !src.is_file() { + return; // Optional — not every theme carries all three. + } + let text = match fs::read_to_string(&src) { + Ok(t) => t, + Err(e) => { + errors.push(e.into()); + return; + } + }; + match split_hyde_header(&text, &self.home) { + Some((dest, body)) => { + if !overwrite && dest.exists() { + skipped.push(Note { + kind, + path: src, + reason: SkipReason::AlreadyInstalled, + }); + } else { + actions.push(Action::CopyVerbatim { + kind, + src, + dest, + contents: body.to_string(), + }); + } + } + None => skipped.push(Note { + kind, + path: src, + reason: SkipReason::NoDestinationHeader, + }), + } + } + + /// Write the plan. Callers should `plan` first so failures — in + /// particular an unsafe archive entry — surface before anything is + /// written, matching `Emitter::apply`'s contract. + pub fn apply(&self, plan: &Plan) -> Result { + let mut installed = Vec::new(); + for action in &plan.actions { + match action { + Action::ExtractArchive { archive, dest, .. } => { + walk_archive(archive, dest, true)?; + installed.push(dest.clone()); + } + Action::CopyWallpaper { src, dest } => { + if let Some(parent) = dest.parent() { + fs::create_dir_all(parent)?; + } + fs::copy(src, dest)?; + installed.push(dest.clone()); + } + Action::CopyVerbatim { dest, contents, .. } => { + if let Some(parent) = dest.parent() { + fs::create_dir_all(parent)?; + } + fs::write(dest, contents)?; + installed.push(dest.clone()); + } + } + } + Ok(Report { installed }) + } +} + +/// Every HyDE `.theme` file — not just `hypr.theme` — opens with a +/// destination line for HyDE's own installer. Verified against +/// `HyDE-Project/hyde-themes`, branch `Catppuccin-Mocha`: +/// +/// - `waybar.theme`: `$HOME/.config/waybar/theme.css|${scrDir}/wbarconfgen.sh` +/// - `rofi.theme`: `$HOME/.config/rofi/theme.rasi` (no pipe at all) +/// - `kitty.theme`: `$HOME/.config/kitty/theme.conf|killall -SIGUSR1 kitty` +/// +/// Only the path before any `|` is a destination; the remainder is a +/// post-install hook (a script path or a shell command) that this crate does +/// not execute — running a command sourced from an untrusted theme would be +/// a code-execution hole, not a config install. +/// +/// The header line itself is HyDE installer metadata, not part of the file +/// the target program reads (a bare path as the first line of `theme.rasi` +/// is not valid rofi syntax), so it is stripped from the returned body — +/// same call `import.rs::strip_hyde_header` makes for `hypr.theme`, just +/// exposed here because the destination is also needed, not only the body. +fn split_hyde_header<'a>(src: &'a str, home: &Path) -> Option<(PathBuf, &'a str)> { + let mut lines = src.splitn(2, '\n'); + let first = lines.next()?; + let path_part = first.split('|').next().unwrap_or(first).trim(); + let rest = path_part.strip_prefix("$HOME")?; + let dest = home.join(rest.trim_start_matches('/')); + Some((dest, lines.next().unwrap_or(""))) +} + +fn find_tarball(dir: &Path, prefix: &str) -> Result, AssetError> { + for entry in fs::read_dir(dir)? { + let entry = entry?; + let name = entry.file_name(); + let name = name.to_string_lossy(); + if name.starts_with(prefix) && name.ends_with(".tar.gz") { + return Ok(Some(entry.path())); + } + } + Ok(None) +} + +/// Read (and, when `write` is true, extract) a `.tar.gz`, validating every +/// entry first. Shared between `plan` (`write: false`, a pure read used only +/// to name-check and reject unsafe archives early) and `apply` +/// (`write: true`), so the safety check cannot drift between the two paths. +fn walk_archive(archive_path: &Path, dest_root: &Path, write: bool) -> Result, AssetError> { + let file = fs::File::open(archive_path)?; + let mut ar = tar::Archive::new(GzDecoder::new(file)); + let mut entries = Vec::new(); + + for entry in ar.entries()? { + let mut entry = entry?; + let rel = entry.path()?.into_owned(); + reject_unsafe_path(archive_path, &rel)?; + + // A symlink/hardlink's own entry path can be safe while its target + // still points outside `dest_root`; a later entry written "through" + // that link would then land wherever the link points. Same rule, + // applied to the target. + if matches!(entry.header().entry_type(), tar::EntryType::Symlink | tar::EntryType::Link) { + if let Some(target) = entry.link_name()? { + reject_unsafe_path(archive_path, &target)?; + } + } + + if write { + let dest = dest_root.join(&rel); + if let Some(parent) = dest.parent() { + fs::create_dir_all(parent)?; + } + entry.unpack(&dest)?; + } + + entries.push(rel); + } + + Ok(entries) +} + +/// Refuse anything that could escape `dest_root` once joined onto it: an +/// absolute path replaces the join outright, and a `..` component walks back +/// out of it. This is the hard security boundary — a theme directory is +/// untrusted input. +fn reject_unsafe_path(archive: &Path, entry: &Path) -> Result<(), AssetError> { + let escapes = entry.is_absolute() || entry.components().any(|c| matches!(c, Component::ParentDir)); + if escapes { + return Err(AssetError::UnsafeArchiveEntry { + archive: archive.to_path_buf(), + entry: entry.to_path_buf(), + }); + } + Ok(()) +} + +/// The first path component of every entry, skipping a leading `./` — used +/// only as a best-effort "is this archive already installed?" heuristic +/// (real GTK/icon tarballs unpack into a single named directory), not as a +/// security boundary. +fn top_level_names(entries: &[PathBuf]) -> BTreeSet { + entries + .iter() + .filter_map(|p| { + p.components() + .find(|c| !matches!(c, Component::CurDir)) + .map(|c| PathBuf::from(c.as_os_str())) + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use flate2::write::GzEncoder; + use flate2::Compression; + use tempfile::TempDir; + + /// Build a `.tar.gz` fixture programmatically so tests do not depend on + /// binary blobs checked into the repo. + fn make_tarball(dir: &Path, name: &str, entries: &[(&str, &[u8])]) -> PathBuf { + let path = dir.join(name); + let file = fs::File::create(&path).unwrap(); + let enc = GzEncoder::new(file, Compression::default()); + let mut builder = tar::Builder::new(enc); + for (entry_path, data) in entries { + let mut header = tar::Header::new_gnu(); + header.set_size(data.len() as u64); + header.set_mode(0o644); + header.set_cksum(); + builder.append_data(&mut header, entry_path, *data).unwrap(); + } + builder.into_inner().unwrap().finish().unwrap(); + path + } + + /// Like `make_tarball`, but writes the entry path straight into the + /// header bytes instead of going through `tar::Header::set_path`, which + /// already refuses absolute paths and `..` *at write time* — exactly the + /// gap `assets.rs` itself must close, since a hostile tarball is not + /// necessarily built with this crate's own tooling. + fn make_malicious_tarball(dir: &Path, name: &str, entry_path: &str, data: &[u8]) -> PathBuf { + let path = dir.join(name); + let file = fs::File::create(&path).unwrap(); + let enc = GzEncoder::new(file, Compression::default()); + let mut builder = tar::Builder::new(enc); + + let mut header = tar::Header::new_gnu(); + header.set_size(data.len() as u64); + header.set_mode(0o644); + header.set_entry_type(tar::EntryType::Regular); + let name_bytes = entry_path.as_bytes(); + header.as_mut_bytes()[..name_bytes.len()].copy_from_slice(name_bytes); + header.set_cksum(); + builder.append(&mut header, data).unwrap(); + + builder.into_inner().unwrap().finish().unwrap(); + path + } + + fn write(path: &Path, contents: &str) { + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(path, contents).unwrap(); + } + + #[test] + fn path_traversal_tarball_is_rejected() { + let tmp = TempDir::new().unwrap(); + let source_dir = tmp.path().join("Source"); + fs::create_dir_all(&source_dir).unwrap(); + // A GTK tarball whose entry escapes via `..` instead of staying under + // its own top-level directory. + make_malicious_tarball(&source_dir, "Gtk_Evil.tar.gz", "../evil.txt", b"pwned"); + + let theme_dir = tmp.path().join("theme"); + fs::create_dir_all(&theme_dir).unwrap(); + + let home = tmp.path().join("home"); + let installer = Installer::with_paths(home.join(".local/share"), &home); + let err = installer + .plan(&theme_dir, Some(&source_dir), "Evil", false) + .expect_err("a path-traversal entry must be rejected, not silently extracted"); + assert!( + err.iter() + .any(|e| matches!(e, AssetError::UnsafeArchiveEntry { .. })), + "{err:?}" + ); + + // Nothing must have been written, in `~/.themes` or anywhere else. + assert!(!home.join(".themes").exists()); + } + + #[test] + fn absolute_path_entry_is_also_rejected() { + let tmp = TempDir::new().unwrap(); + let source_dir = tmp.path().join("Source"); + fs::create_dir_all(&source_dir).unwrap(); + make_malicious_tarball(&source_dir, "Icon_Evil.tar.gz", "/etc/passwd", b"pwned"); + + let theme_dir = tmp.path().join("theme"); + fs::create_dir_all(&theme_dir).unwrap(); + let home = tmp.path().join("home"); + let installer = Installer::with_paths(home.join(".local/share"), &home); + + let err = installer + .plan(&theme_dir, Some(&source_dir), "Evil", false) + .unwrap_err(); + assert!( + err.iter() + .any(|e| matches!(e, AssetError::UnsafeArchiveEntry { .. })), + "{err:?}" + ); + } + + #[test] + fn plan_does_not_touch_disk() { + let tmp = TempDir::new().unwrap(); + let theme_dir = tmp.path().join("theme"); + write(&theme_dir.join("wallpapers/bg.png"), "not really a png"); + write(&theme_dir.join("rofi.theme"), "$HOME/.config/rofi/theme.rasi\n* { main-bg: #000; }\n"); + + let source_dir = tmp.path().join("Source"); + fs::create_dir_all(&source_dir).unwrap(); + make_tarball(&source_dir, "Gtk_Mocha.tar.gz", &[("Mocha/gtk.css", b"* {}")]); + + let home = tmp.path().join("home"); + let installer = Installer::with_paths(home.join(".local/share"), &home); + let plan = installer + .plan(&theme_dir, Some(&source_dir), "Mocha", false) + .expect("a well-formed theme must plan cleanly"); + + assert!(!plan.actions.is_empty()); + assert!(!home.exists(), "planning must not create anything under $HOME"); + } + + #[test] + fn wallpapers_are_copied() { + let tmp = TempDir::new().unwrap(); + let theme_dir = tmp.path().join("theme"); + write(&theme_dir.join("wallpapers/bg.png"), "fake wallpaper bytes"); + + let home = tmp.path().join("home"); + let data_home = home.join(".local/share"); + let installer = Installer::with_paths(&data_home, &home); + let plan = installer.plan(&theme_dir, None, "Mocha", false).unwrap(); + let report = installer.apply(&plan).unwrap(); + + let dest = data_home.join("wallpapers/hyprcosmic/Mocha/bg.png"); + assert!(report.installed.contains(&dest)); + assert_eq!(fs::read_to_string(&dest).unwrap(), "fake wallpaper bytes"); + } + + #[test] + fn verbatim_body_is_copied_unchanged() { + // Real rofi.theme header + body, verified against + // HyDE-Project/hyde-themes (Catppuccin-Mocha branch). + let tmp = TempDir::new().unwrap(); + let theme_dir = tmp.path().join("theme"); + let body = "* {\n main-bg: #11111be6;\n main-fg: #cdd6f4ff;\n}\n"; + write(&theme_dir.join("rofi.theme"), &format!("$HOME/.config/rofi/theme.rasi\n{body}")); + + let home = tmp.path().join("home"); + let installer = Installer::with_paths(home.join(".local/share"), &home); + let plan = installer.plan(&theme_dir, None, "Mocha", false).unwrap(); + let report = installer.apply(&plan).unwrap(); + + let dest = home.join(".config/rofi/theme.rasi"); + assert!(report.installed.contains(&dest)); + // Byte-identical to the source *body* — only the HyDE installer + // header line (not valid rofi syntax) is removed. + assert_eq!(fs::read_to_string(&dest).unwrap(), body); + } + + #[test] + fn waybar_and_kitty_headers_with_pipes_still_resolve() { + // waybar.theme and kitty.theme carry a post-install hook after `|` + // that must be ignored, not executed. + let tmp = TempDir::new().unwrap(); + let theme_dir = tmp.path().join("theme"); + write( + &theme_dir.join("waybar.theme"), + "$HOME/.config/waybar/theme.css|${scrDir}/wbarconfgen.sh\n@define-color bar-bg #000;\n", + ); + write( + &theme_dir.join("kitty.theme"), + "$HOME/.config/kitty/theme.conf|killall -SIGUSR1 kitty\n## name: Mocha\n", + ); + + let home = tmp.path().join("home"); + let installer = Installer::with_paths(home.join(".local/share"), &home); + let plan = installer.plan(&theme_dir, None, "Mocha", false).unwrap(); + installer.apply(&plan).unwrap(); + + assert_eq!( + fs::read_to_string(home.join(".config/waybar/theme.css")).unwrap(), + "@define-color bar-bg #000;\n" + ); + assert_eq!( + fs::read_to_string(home.join(".config/kitty/theme.conf")).unwrap(), + "## name: Mocha\n" + ); + } + + #[test] + fn gtk_and_icon_tarballs_extract_to_the_legacy_directories() { + let tmp = TempDir::new().unwrap(); + let theme_dir = tmp.path().join("theme"); + fs::create_dir_all(&theme_dir).unwrap(); + let source_dir = tmp.path().join("Source"); + fs::create_dir_all(&source_dir).unwrap(); + make_tarball(&source_dir, "Gtk_Mocha.tar.gz", &[("Mocha/gtk-3.0/gtk.css", b"* {}")]); + make_tarball(&source_dir, "Icon_Tela.tar.gz", &[("Tela/index.theme", b"[Icon Theme]")]); + + let home = tmp.path().join("home"); + let installer = Installer::with_paths(home.join(".local/share"), &home); + let plan = installer.plan(&theme_dir, Some(&source_dir), "Mocha", false).unwrap(); + installer.apply(&plan).unwrap(); + + assert_eq!( + fs::read_to_string(home.join(".themes/Mocha/gtk-3.0/gtk.css")).unwrap(), + "* {}" + ); + assert_eq!( + fs::read_to_string(home.join(".icons/Tela/index.theme")).unwrap(), + "[Icon Theme]" + ); + } + + #[test] + fn existing_theme_is_skipped_without_overwrite() { + let tmp = TempDir::new().unwrap(); + let theme_dir = tmp.path().join("theme"); + fs::create_dir_all(&theme_dir).unwrap(); + let source_dir = tmp.path().join("Source"); + fs::create_dir_all(&source_dir).unwrap(); + make_tarball(&source_dir, "Gtk_Mocha.tar.gz", &[("Mocha/gtk.css", b"new")]); + + let home = tmp.path().join("home"); + // Simulate a theme already installed under the name the tarball uses. + write(&home.join(".themes/Mocha/gtk.css"), "old"); + + let installer = Installer::with_paths(home.join(".local/share"), &home); + let plan = installer.plan(&theme_dir, Some(&source_dir), "Mocha", false).unwrap(); + + assert!(plan.actions.is_empty(), "already-installed theme must not be re-planned"); + assert_eq!(plan.skipped.len(), 1); + assert_eq!(plan.skipped[0].reason, SkipReason::AlreadyInstalled); + + // Confirm the skip is honoured all the way through apply, and the + // existing file is left untouched. + installer.apply(&plan).unwrap(); + assert_eq!(fs::read_to_string(home.join(".themes/Mocha/gtk.css")).unwrap(), "old"); + } + + #[test] + fn overwrite_flag_forces_reinstall() { + let tmp = TempDir::new().unwrap(); + let theme_dir = tmp.path().join("theme"); + fs::create_dir_all(&theme_dir).unwrap(); + let source_dir = tmp.path().join("Source"); + fs::create_dir_all(&source_dir).unwrap(); + make_tarball(&source_dir, "Gtk_Mocha.tar.gz", &[("Mocha/gtk.css", b"new")]); + + let home = tmp.path().join("home"); + write(&home.join(".themes/Mocha/gtk.css"), "old"); + + let installer = Installer::with_paths(home.join(".local/share"), &home); + let plan = installer.plan(&theme_dir, Some(&source_dir), "Mocha", true).unwrap(); + assert_eq!(plan.actions.len(), 1); + + installer.apply(&plan).unwrap(); + assert_eq!(fs::read_to_string(home.join(".themes/Mocha/gtk.css")).unwrap(), "new"); + } + + #[test] + fn theme_file_without_a_destination_header_is_reported_not_dropped() { + let tmp = TempDir::new().unwrap(); + let theme_dir = tmp.path().join("theme"); + write(&theme_dir.join("rofi.theme"), "* { main-bg: #000; }\n"); + + let home = tmp.path().join("home"); + let installer = Installer::with_paths(home.join(".local/share"), &home); + let plan = installer.plan(&theme_dir, None, "Mocha", false).unwrap(); + + assert!(plan.actions.is_empty()); + assert_eq!(plan.skipped.len(), 1); + assert_eq!(plan.skipped[0].reason, SkipReason::NoDestinationHeader); + } + + #[test] + fn missing_optional_files_are_not_errors() { + // A theme dir with only wallpapers and no waybar/rofi/kitty/tarballs + // at all must still plan cleanly. + let tmp = TempDir::new().unwrap(); + let theme_dir = tmp.path().join("theme"); + write(&theme_dir.join("wallpapers/bg.png"), "x"); + + let home = tmp.path().join("home"); + let installer = Installer::with_paths(home.join(".local/share"), &home); + let plan = installer.plan(&theme_dir, None, "Mocha", false).unwrap(); + assert_eq!(plan.actions.len(), 1); + assert!(plan.skipped.is_empty()); + } + + #[test] + fn report_lists_installed_and_skipped() { + let tmp = TempDir::new().unwrap(); + let theme_dir = tmp.path().join("theme"); + write(&theme_dir.join("wallpapers/bg.png"), "x"); + write(&theme_dir.join("rofi.theme"), "not a header\n* {}\n"); + + let home = tmp.path().join("home"); + let installer = Installer::with_paths(home.join(".local/share"), &home); + let plan = installer.plan(&theme_dir, None, "Mocha", false).unwrap(); + let report = installer.apply(&plan).unwrap(); + + let text = render_report(&plan, &report); + assert!(text.contains("Installed:"), "{text}"); + assert!(text.contains("bg.png"), "{text}"); + assert!(text.contains("Skipped:"), "{text}"); + assert!(text.contains("no $HOME destination header"), "{text}"); + } + + #[test] + fn from_env_honours_xdg_data_home() { + // Same fallback chain as `Emitter::from_env` (emit.rs), applied to + // XDG_DATA_HOME instead of XDG_CONFIG_HOME. + let prev = std::env::var_os("XDG_DATA_HOME"); + std::env::set_var("XDG_DATA_HOME", "/tmp/xdg-data-probe"); + let installer = Installer::from_env().unwrap(); + assert_eq!(installer.data_home, Path::new("/tmp/xdg-data-probe")); + match prev { + Some(v) => std::env::set_var("XDG_DATA_HOME", v), + None => std::env::remove_var("XDG_DATA_HOME"), + } + } +} diff --git a/cosmic-conf/src/lib.rs b/cosmic-conf/src/lib.rs index 046ae44..7e6f570 100644 --- a/cosmic-conf/src/lib.rs +++ b/cosmic-conf/src/lib.rs @@ -8,11 +8,13 @@ //! COSMIC-specific, which keeps the hard logic testable without a compositor //! running. Only `emit` binds to cosmic-config, behind the `emit` feature. +pub mod assets; pub mod emit; pub mod import; pub mod parser; pub mod resolve; pub mod schema; +pub mod watch; pub use emit::{EmitError, Emitter, Planned}; pub use import::{import_hypr_theme, render_report, Import}; diff --git a/cosmic-conf/src/watch.rs b/cosmic-conf/src/watch.rs new file mode 100644 index 0000000..dec016b --- /dev/null +++ b/cosmic-conf/src/watch.rs @@ -0,0 +1,661 @@ +//! Filesystem watch: re-apply `cosmic.conf` on every edit. +//! +//! Two problems make this more than "call `notify` and re-run `main`'s +//! pipeline": +//! +//! 1. **`source` fans out the watch set.** `parser::Item::Source` lets a +//! config pull in other files (`parser.rs:66`), but `resolve` treats +//! `Source` as inert (`resolve.rs:87`) — nothing upstream actually expands +//! it yet, even though `resolve.rs:66` already assumes an "include +//! expansion" pass ran first. This module is that pass: `merge_text` +//! textually splices a sourced file's contents in place of its `source` +//! line, the same way Hyprland treats `source` as literal inclusion. Doing +//! it as text rather than AST-splicing means the merged string is one +//! coherent document, so `Span`s (which are just line/col, with no file +//! identity — `parser.rs:24`) stay correct for `render_diagnostic` +//! regardless of which physical file a line came from. It also means the +//! watch set has to be recomputed after every successful compile, since +//! editing a `source` line can add or remove files from it. +//! +//! 2. **A bad edit must not kill the daemon or half-apply.** `Emitter::plan` +//! already keeps `apply` transactional (`emit.rs:11-14`); this module's +//! job is to keep that guarantee across an unbounded stream of edits by +//! treating every compile failure as "log it and keep watching" rather +//! than propagating it out of the loop. +//! +//! The event loop itself (`watch`) is intentionally thin. Everything with +//! interesting logic — merging sources, debouncing — is a free function +//! usable without a real inotify watcher, per the module's tests. + +use std::collections::HashSet; +use std::fmt; +use std::fs; +use std::io; +use std::path::{Path, PathBuf}; +use std::sync::mpsc; +use std::time::Duration; + +use notify::{Event, RecommendedWatcher, RecursiveMode, Watcher}; + +use crate::emit::{EmitError, Emitter, Planned}; +use crate::parser::{self, Item, ParseError}; +use crate::render_diagnostic; +use crate::resolve::{self, Diagnostic}; + +/// Editors commonly write a save as several syscalls (truncate, write, +/// rename); this is long enough to collapse those into one recompile without +/// making a real edit feel laggy. +const DEBOUNCE: Duration = Duration::from_millis(250); + +/// Everything that can go wrong compiling `config` (and whatever it sources) +/// into a plan. Every variant renders a complete, human-readable report — +/// `watch` just prints `Display` and moves on. +#[derive(Debug)] +pub enum CompileError { + /// `config`, or something it `source`s, could not be read. + Read { path: PathBuf, error: io::Error }, + /// A `source` chain refers back to a file already being expanded. + /// Splicing it would recurse forever, so this is reported instead. + Cycle { path: PathBuf }, + /// A single file failed to parse on its own, before merging — `source` + /// and `error` are both that file's, so the line number is exact. + Parse { + path: PathBuf, + source: String, + error: ParseError, + }, + /// The merged document failed to resolve. `source` is the full merged + /// text, so `diagnostics`' spans point at the right physical line no + /// matter which file contributed it. + Resolve { + source: String, + diagnostics: Vec, + }, + /// Resolved cleanly but could not be turned into file contents. + Emit(Vec), +} + +impl fmt::Display for CompileError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + CompileError::Read { path, error } => { + write!(f, "error: cannot read {}: {error}\n", path.display()) + } + CompileError::Cycle { path } => { + write!( + f, + "error: `source` cycle detected while expanding {}\n", + path.display() + ) + } + CompileError::Parse { path, source, error } => { + write!( + f, + "in {}:\n{}", + path.display(), + render_diagnostic(source, error.span, &error.message, None) + ) + } + CompileError::Resolve { source, diagnostics } => { + let mut out = String::new(); + for d in diagnostics { + out.push_str(&render_diagnostic(source, d.span, &d.message, d.help.as_deref())); + out.push('\n'); + } + out.push_str(&format!( + "error: {} problem(s) found; nothing was written\n", + diagnostics.len() + )); + write!(f, "{out}") + } + CompileError::Emit(errs) => { + let mut out = String::new(); + for e in errs { + out.push_str(&format!("error: {e}\n")); + } + out.push_str("error: nothing was written\n"); + write!(f, "{out}") + } + } + } +} + +impl std::error::Error for CompileError {} + +/// A completed compile: what to write, and what to watch. +#[derive(Debug)] +pub struct Compiled { + pub planned: Vec, + /// Every file that contributed content, `config` first. This is exactly + /// the set `watch` needs to be subscribed to for the *next* edit to be + /// noticed, and it can change from one compile to the next as `source` + /// lines are added, removed, or edited. + pub sources: Vec, +} + +/// Parse `config` — following `source` directives — resolve, and plan writes +/// against `emitter`, without touching disk. +/// +/// Pulled out of `watch` so the compile pipeline is unit-testable without a +/// filesystem watcher: every test in this module drives `compile` directly. +pub fn compile(config: &Path, emitter: &Emitter) -> Result { + let mut ancestors = Vec::new(); + let mut sources = Vec::new(); + let merged = merge_text(config, &mut ancestors, &mut sources)?; + + let ast = parser::parse(&merged).map_err(|error| CompileError::Parse { + path: config.to_path_buf(), + source: merged.clone(), + error, + })?; + + let resolved = resolve::resolve(&ast).map_err(|diagnostics| CompileError::Resolve { + source: merged, + diagnostics, + })?; + + let planned = emitter.plan(&resolved).map_err(CompileError::Emit)?; + + Ok(Compiled { planned, sources }) +} + +/// Read `path`, then replace every `source = ` line with the +/// (recursively expanded) text of the sourced file, so the result is one +/// document `parser::parse` can consume in a single pass — see the module +/// doc for why textual splicing rather than AST splicing. +/// +/// `ancestors` is the current inclusion chain (for cycle detection); +/// `watched` accumulates every file visited, in the order first seen. +fn merge_text( + path: &Path, + ancestors: &mut Vec, + watched: &mut Vec, +) -> Result { + let key = path.to_path_buf(); + if ancestors.contains(&key) { + return Err(CompileError::Cycle { path: key }); + } + + let raw = fs::read_to_string(path).map_err(|error| CompileError::Read { + path: key.clone(), + error, + })?; + watched.push(key.clone()); + + // Parsing here (rather than scanning text for `source =` ourselves) means + // we inherit the grammar's exact rules for comments and whitespace, so + // the line we splice at is always the one the real parser would call a + // `Source` item. + let ast = parser::parse(&raw).map_err(|error| CompileError::Parse { + path: key.clone(), + source: raw.clone(), + error, + })?; + + let mut targets = Vec::new(); + collect_sources(&ast.items, &mut targets); + if targets.is_empty() { + return Ok(raw); + } + + // Splicing changes line counts, so process bottom-up: replacing a later + // line first leaves every earlier line number still valid. + targets.sort_by(|a, b| b.0.cmp(&a.0)); + + ancestors.push(key); + let mut lines: Vec = raw.lines().map(String::from).collect(); + for (line_no, raw_path) in targets { + let target_path = resolve_source_path(path, &raw_path); + let included = merge_text(&target_path, ancestors, watched)?; + lines.splice(line_no - 1..line_no, included.lines().map(String::from)); + } + ancestors.pop(); + + let mut out = lines.join("\n"); + out.push('\n'); + Ok(out) +} + +/// Depth-first walk collecting every `source` item's `(line, raw path)`. +/// Sections are recursed into: a `source` nested inside `general { .. }` +/// splices its contents into that section, matching Hyprland's textual +/// `source` semantics rather than only supporting top-level includes. +fn collect_sources(items: &[Item], out: &mut Vec<(usize, String)>) { + for item in items { + match item { + Item::Source { path } => out.push((path.span.line, path.value.clone())), + Item::Section { items, .. } => collect_sources(items, out), + _ => {} + } + } +} + +/// Resolve a `source` value the way a shell prompt would: `~/` against +/// `$HOME`, everything else relative to the directory of the file doing the +/// sourcing (not the process's cwd), so a config tree keeps working wherever +/// it is checked out. +fn resolve_source_path(containing_file: &Path, raw: &str) -> PathBuf { + let expanded = if raw == "~" { + std::env::var_os("HOME").map(PathBuf::from).unwrap_or_else(|| PathBuf::from(raw)) + } else if let Some(rest) = raw.strip_prefix("~/") { + match std::env::var_os("HOME") { + Some(home) => PathBuf::from(home).join(rest), + None => PathBuf::from(raw), + } + } else { + PathBuf::from(raw) + }; + + if expanded.is_absolute() { + expanded + } else { + containing_file + .parent() + .unwrap_or_else(|| Path::new(".")) + .join(expanded) + } +} + +/// Block for the first item on `rx`, then keep draining anything that +/// arrives within `window` of the previous one. Returns `None` once the +/// sender side has been dropped and nothing more will ever come. +/// +/// This is the whole debounce policy, factored out of `watch`'s loop so it +/// can be tested against a plain channel instead of real filesystem events — +/// editors write a save as several syscalls, and without this a single save +/// would trigger several redundant recompiles. +fn collect_batch(rx: &mpsc::Receiver, window: Duration) -> Option> { + let first = rx.recv().ok()?; + let mut batch = vec![first]; + while let Ok(next) = rx.recv_timeout(window) { + batch.push(next); + } + Some(batch) +} + +/// Bring `watcher`'s subscriptions in line with `wanted`, diffing against +/// `current` so files that stopped being sourced are actually unwatched +/// (otherwise the watch set only ever grows). +/// +/// Best-effort: a `watch`/`unwatch` failure (e.g. a sourced file that does +/// not exist yet) is not fatal — the next successful compile will retry with +/// whatever the config asks for at that point. +fn sync_watches(watcher: &mut RecommendedWatcher, current: &mut HashSet, wanted: &[PathBuf]) { + let wanted: HashSet = wanted.iter().cloned().collect(); + + for stale in current.difference(&wanted) { + let _ = watcher.unwatch(stale); + } + for fresh in wanted.difference(current) { + let _ = watcher.watch(fresh, RecursiveMode::NonRecursive); + } + + *current = wanted; +} + +/// Anything that stops the daemon outright. Deliberately small: a broken +/// `cosmic.conf` is *not* one of these — see the module doc — so this is +/// only the notify plumbing itself failing to start. +#[derive(Debug)] +pub enum WatchError { + Notify(notify::Error), +} + +impl fmt::Display for WatchError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + WatchError::Notify(e) => write!(f, "watch error: {e}"), + } + } +} + +impl std::error::Error for WatchError {} + +impl From for WatchError { + fn from(e: notify::Error) -> Self { + WatchError::Notify(e) + } +} + +/// Watch `config` — and everything it currently `source`s — reapplying on +/// every change until the watcher itself fails to start or stops delivering +/// events. A malformed edit is reported to stderr and waited past: see the +/// module doc for why that, not propagating the error, is the contract here. +pub fn watch(config: &Path, emitter: &Emitter) -> Result<(), WatchError> { + let (tx, rx) = mpsc::channel::>(); + let mut watcher: RecommendedWatcher = notify::recommended_watcher(tx)?; + let mut watched: HashSet = HashSet::new(); + + // Compile once up front: the desktop should reflect the config the + // moment the daemon starts, and this also tells us the initial watch + // set. If it fails, fall back to watching just `config` — that is the + // one file guaranteed to exist, and a later successful compile will + // widen the watch set to whatever it actually sources. + match compile(config, emitter) { + Ok(compiled) => { + if let Err(e) = emitter.apply(&compiled.planned) { + eprintln!("{}", CompileError::Emit(vec![e])); + } + sync_watches(&mut watcher, &mut watched, &compiled.sources); + } + Err(e) => { + eprintln!("{e}"); + sync_watches(&mut watcher, &mut watched, std::slice::from_ref(&config.to_path_buf())); + } + } + + loop { + let Some(batch) = collect_batch(&rx, DEBOUNCE) else { + // The sender was dropped, which only happens if `watcher` itself + // was torn down — nothing more will ever arrive. + return Ok(()); + }; + + for event in &batch { + if let Err(e) = event { + eprintln!("watch error: {e}"); + } + } + + match compile(config, emitter) { + Ok(compiled) => { + if let Err(e) = emitter.apply(&compiled.planned) { + eprintln!("{}", CompileError::Emit(vec![e])); + } + sync_watches(&mut watcher, &mut watched, &compiled.sources); + } + Err(e) => { + // Leave `watched` alone: the fix for a bad edit might land in + // an already-sourced file, and dropping back to watching + // only `config` would miss that. + eprintln!("{e}"); + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + fn write(dir: &Path, name: &str, contents: &str) -> PathBuf { + let path = dir.join(name); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).unwrap(); + } + fs::write(&path, contents).unwrap(); + path + } + + // ---- compile ---------------------------------------------------- + + #[test] + fn compile_with_no_source_directives_watches_just_the_config() { + let conf_dir = TempDir::new().unwrap(); + let root_dir = TempDir::new().unwrap(); + let config = write(conf_dir.path(), "cosmic.conf", "general {\n autotile = true\n}\n"); + + let compiled = compile(&config, &Emitter::with_root(root_dir.path())).unwrap(); + + assert_eq!(compiled.sources, vec![config]); + assert_eq!(compiled.planned.len(), 1); + } + + #[test] + fn compile_follows_a_source_directive_and_lists_it_as_a_watch_target() { + let conf_dir = TempDir::new().unwrap(); + let root_dir = TempDir::new().unwrap(); + let included = write(conf_dir.path(), "extra.conf", "general {\n autotile = true\n}\n"); + let config = write(conf_dir.path(), "cosmic.conf", "source = extra.conf\n"); + + let compiled = compile(&config, &Emitter::with_root(root_dir.path())).unwrap(); + + assert_eq!(compiled.sources, vec![config, included]); + assert_eq!(compiled.planned.len(), 1); + } + + #[test] + fn compile_expands_a_source_nested_inside_a_section() { + // The sourced file's contents become part of the enclosing section, + // the same way Hyprland's `source` is a literal text substitution. + let conf_dir = TempDir::new().unwrap(); + let root_dir = TempDir::new().unwrap(); + write(conf_dir.path(), "gaps.conf", "gaps_in = 5\ngaps_out = 10\n"); + let config = write( + conf_dir.path(), + "cosmic.conf", + "general {\n source = gaps.conf\n autotile = true\n}\n", + ); + + let planned = compile(&config, &Emitter::with_root(root_dir.path())) + .unwrap() + .planned; + let gaps = planned.iter().find(|p| p.path.ends_with("gaps")).expect("gaps planned"); + assert_eq!(gaps.contents, "(10, 5)"); + } + + #[test] + fn compile_resolves_relative_sources_against_the_including_files_directory() { + // The including file lives in a subdirectory; `nested.conf` must be + // found relative to it, not relative to the process's cwd. + let conf_dir = TempDir::new().unwrap(); + let sub = conf_dir.path().join("sub"); + fs::create_dir_all(&sub).unwrap(); + write(&sub, "nested.conf", "general {\n autotile = true\n}\n"); + let config = write(&sub, "cosmic.conf", "source = nested.conf\n"); + + let root_dir = TempDir::new().unwrap(); + let compiled = compile(&config, &Emitter::with_root(root_dir.path())).unwrap(); + assert_eq!(compiled.sources.len(), 2); + } + + #[test] + fn compile_expands_tilde_against_home() { + let home = TempDir::new().unwrap(); + write(home.path(), "shared.conf", "general {\n autotile = true\n}\n"); + let conf_dir = TempDir::new().unwrap(); + let config = write(conf_dir.path(), "cosmic.conf", "source = ~/shared.conf\n"); + + let prev = std::env::var_os("HOME"); + std::env::set_var("HOME", home.path()); + let result = compile(&config, &Emitter::with_root(TempDir::new().unwrap().path())); + match prev { + Some(v) => std::env::set_var("HOME", v), + None => std::env::remove_var("HOME"), + } + + let compiled = result.unwrap(); + assert!(compiled.sources.contains(&home.path().join("shared.conf"))); + } + + #[test] + fn compile_follows_a_chain_of_nested_sources() { + let conf_dir = TempDir::new().unwrap(); + write(conf_dir.path(), "c.conf", "autotile = true\n"); + write(conf_dir.path(), "b.conf", "general {\n source = c.conf\n}\n"); + let config = write(conf_dir.path(), "a.conf", "source = b.conf\n"); + + let compiled = compile(&config, &Emitter::with_root(TempDir::new().unwrap().path())).unwrap(); + assert_eq!(compiled.sources.len(), 3); + assert_eq!(compiled.planned.len(), 1); + } + + #[test] + fn compile_detects_a_source_cycle() { + let conf_dir = TempDir::new().unwrap(); + let a = conf_dir.path().join("a.conf"); + let b = conf_dir.path().join("b.conf"); + fs::write(&a, "source = b.conf\n").unwrap(); + fs::write(&b, "source = a.conf\n").unwrap(); + + let err = compile(&a, &Emitter::with_root(TempDir::new().unwrap().path())).unwrap_err(); + assert!(matches!(err, CompileError::Cycle { .. }), "{err}"); + } + + #[test] + fn compile_reports_a_missing_source_file_without_panicking() { + let conf_dir = TempDir::new().unwrap(); + let config = write(conf_dir.path(), "cosmic.conf", "source = does-not-exist.conf\n"); + + let err = compile(&config, &Emitter::with_root(TempDir::new().unwrap().path())).unwrap_err(); + assert!(matches!(err, CompileError::Read { .. }), "{err}"); + } + + #[test] + fn compile_surfaces_a_syntax_error_in_a_sourced_file() { + let conf_dir = TempDir::new().unwrap(); + write(conf_dir.path(), "broken.conf", "this is not valid\n"); + let config = write(conf_dir.path(), "cosmic.conf", "source = broken.conf\n"); + + let err = compile(&config, &Emitter::with_root(TempDir::new().unwrap().path())).unwrap_err(); + match err { + CompileError::Parse { path, .. } => assert_eq!(path, conf_dir.path().join("broken.conf")), + other => panic!("expected Parse, got {other}"), + } + } + + #[test] + fn compile_diagnostic_line_number_points_at_the_merged_document_not_the_fragment() { + // The offending line is line 1 of `bad.conf`, but after splicing it + // sits at line 2 of the merged document — the diagnostic must report + // the merged position so the caret lands on the right physical line. + let conf_dir = TempDir::new().unwrap(); + write(conf_dir.path(), "bad.conf", "gaps_inn = 8\n"); + let config = write( + conf_dir.path(), + "cosmic.conf", + "general {\n source = bad.conf\n}\n", + ); + + let err = compile(&config, &Emitter::with_root(TempDir::new().unwrap().path())).unwrap_err(); + match err { + CompileError::Resolve { diagnostics, .. } => { + assert_eq!(diagnostics[0].span.line, 2); + } + other => panic!("expected Resolve, got {other}"), + } + } + + #[test] + fn compile_surfaces_resolve_diagnostics_for_an_unknown_key() { + let conf_dir = TempDir::new().unwrap(); + let config = write(conf_dir.path(), "cosmic.conf", "general {\n gaps_inn = 8\n}\n"); + + let err = compile(&config, &Emitter::with_root(TempDir::new().unwrap().path())).unwrap_err(); + assert!(matches!(err, CompileError::Resolve { .. }), "{err}"); + assert!(err.to_string().contains("unknown key"), "{err}"); + } + + /// Mirrors `emit.rs`'s `plan_does_not_write`: `compile` only plans, so it + /// must leave the cosmic-config tree untouched. + #[test] + fn compile_does_not_write_to_the_config_root() { + let conf_dir = TempDir::new().unwrap(); + let config = write(conf_dir.path(), "cosmic.conf", "general {\n autotile = true\n}\n"); + let root_dir = TempDir::new().unwrap(); + + let _ = compile(&config, &Emitter::with_root(root_dir.path())).unwrap(); + + assert!( + fs::read_dir(root_dir.path()).unwrap().next().is_none(), + "compile must leave the tree untouched" + ); + } + + // ---- resolve_source_path ----------------------------------------- + + #[test] + fn resolve_source_path_is_relative_to_the_including_file_not_the_cwd() { + let including = Path::new("/somewhere/deep/cosmic.conf"); + assert_eq!( + resolve_source_path(including, "extra.conf"), + Path::new("/somewhere/deep/extra.conf") + ); + } + + #[test] + fn resolve_source_path_leaves_absolute_paths_alone() { + let including = Path::new("/somewhere/deep/cosmic.conf"); + assert_eq!( + resolve_source_path(including, "/etc/other.conf"), + Path::new("/etc/other.conf") + ); + } + + // Tilde expansion against `$HOME` is covered end-to-end by + // `compile_expands_tilde_against_home` below rather than here too: + // `std::env::set_var` mutates process-global state, and the default + // test runner is multi-threaded, so two tests racing to set `HOME` + // would be a real source of flakiness rather than a hypothetical one. + + // ---- collect_batch (debounce) ------------------------------------- + // + // These exercise the debounce policy directly against a plain channel, + // with no filesystem or notify involvement at all, so they are fast and + // cannot flake on OS-level event timing. + + #[test] + fn collect_batch_drains_everything_already_sent_before_it_was_called() { + let (tx, rx) = mpsc::channel(); + for i in 0..5 { + tx.send(i).unwrap(); + } + let batch = collect_batch(&rx, Duration::from_millis(30)).unwrap(); + assert_eq!(batch, vec![0, 1, 2, 3, 4]); + } + + #[test] + fn collect_batch_returns_none_once_the_sender_is_dropped() { + let (tx, rx) = mpsc::channel::(); + drop(tx); + assert!(collect_batch(&rx, Duration::from_millis(30)).is_none()); + } + + #[test] + fn collect_batch_starts_a_fresh_batch_after_the_quiet_window_elapses() { + use std::thread; + + let (tx, rx) = mpsc::channel(); + let window = Duration::from_millis(20); + + tx.send(1).unwrap(); + let first = collect_batch(&rx, window).unwrap(); + assert_eq!(first, vec![1]); + + // Send the second burst from another thread after the window has + // safely elapsed (10x margin), so the main thread's blocking `recv` + // in the next `collect_batch` call has something to wake it up. + thread::spawn(move || { + thread::sleep(window * 10); + tx.send(2).unwrap(); + }); + let second = collect_batch(&rx, window).unwrap(); + assert_eq!(second, vec![2]); + } + + // ---- sync_watches --------------------------------------------------- + // + // Exercises the real notify watch/unwatch bookkeeping — but only ever + // registers watches on files that already exist; no event is triggered + // or waited for, so this cannot flake on inotify timing. + + #[test] + fn sync_watches_adds_and_then_removes_a_watch() { + let dir = TempDir::new().unwrap(); + let a = write(dir.path(), "a.conf", ""); + let b = write(dir.path(), "b.conf", ""); + + let (tx, _rx) = mpsc::channel::>(); + let mut watcher = notify::recommended_watcher(tx).unwrap(); + let mut current = HashSet::new(); + + sync_watches(&mut watcher, &mut current, &[a.clone(), b.clone()]); + assert_eq!(current, HashSet::from([a.clone(), b.clone()])); + + // Dropping `b` from the wanted set must unwatch it, not just stop + // tracking it, or the watch set would only ever grow. + sync_watches(&mut watcher, &mut current, &[a.clone()]); + assert_eq!(current, HashSet::from([a])); + } +} diff --git a/cosmic-conf/tests/archive_escape.rs b/cosmic-conf/tests/archive_escape.rs new file mode 100644 index 0000000..ba2e613 --- /dev/null +++ b/cosmic-conf/tests/archive_escape.rs @@ -0,0 +1,178 @@ +//! Adversarial checks on theme-archive extraction. +//! +//! These are deliberately independent of `assets.rs`'s own unit tests. Theme +//! tarballs are downloaded from third-party repositories and extracted into the +//! user's home directory, so "a test named `path_traversal_is_rejected` passes" +//! is not sufficient evidence — these assert on the *filesystem* afterwards, +//! proving nothing escaped rather than trusting a returned error. + +use std::fs; +use std::path::{Path, PathBuf}; + +use cosmic_conf::assets::Installer; +use flate2::write::GzEncoder; +use flate2::Compression; +use tempfile::TempDir; + +/// Build a `.tar.gz` containing arbitrary entries, including hostile ones a +/// well-behaved archiver would refuse to produce. +fn hostile_tarball(path: &Path, entries: &[(&str, tar::EntryType, &[u8], Option<&str>)]) { + let file = fs::File::create(path).unwrap(); + let mut builder = tar::Builder::new(GzEncoder::new(file, Compression::default())); + + for (name, kind, data, link_target) in entries { + let mut header = tar::Header::new_gnu(); + header.set_entry_type(*kind); + header.set_mode(0o644); + header.set_size(if link_target.is_some() { + 0 + } else { + data.len() as u64 + }); + + // `append_data`/`set_path` reject `..` and absolute paths, so a hostile + // archive cannot be produced through the safe API. Write the raw name + // bytes into the GNU header directly — this is precisely what a + // malicious archiver does, and the only way to test the guard honestly. + write_raw_name(&mut header, name); + if let Some(target) = link_target { + write_raw_link(&mut header, target); + } + header.set_cksum(); + + builder.append(&header, *data).unwrap(); + } + builder.into_inner().unwrap().finish().unwrap(); +} + +/// Overwrite the GNU header's `name` field with arbitrary bytes, bypassing the +/// validation `Header::set_path` performs. +fn write_raw_name(header: &mut tar::Header, name: &str) { + let gnu = header.as_gnu_mut().expect("new_gnu produces a GNU header"); + gnu.name = [0u8; 100]; + let bytes = name.as_bytes(); + assert!(bytes.len() < 100, "fixture name too long for a GNU header"); + gnu.name[..bytes.len()].copy_from_slice(bytes); +} + +/// Same, for the `linkname` field. +fn write_raw_link(header: &mut tar::Header, target: &str) { + let gnu = header.as_gnu_mut().expect("new_gnu produces a GNU header"); + gnu.linkname = [0u8; 100]; + let bytes = target.as_bytes(); + assert!(bytes.len() < 100, "fixture link target too long"); + gnu.linkname[..bytes.len()].copy_from_slice(bytes); +} + +/// A theme directory just complete enough for `plan` to consider the archive. +fn theme_with_archive(entries: &[(&str, tar::EntryType, &[u8], Option<&str>)]) -> (TempDir, PathBuf, PathBuf) { + let tmp = TempDir::new().unwrap(); + let theme_dir = tmp.path().join("Configs/.config/hyde/themes/Evil"); + let source_dir = tmp.path().join("Source"); + fs::create_dir_all(&theme_dir).unwrap(); + fs::create_dir_all(&source_dir).unwrap(); + fs::write(theme_dir.join("hypr.theme"), "general {\n gaps_in = 3\n}\n").unwrap(); + + hostile_tarball(&source_dir.join("Gtk_Evil.tar.gz"), entries); + (tmp, theme_dir, source_dir) +} + +/// Anything created outside the sandbox root is an escape. +fn assert_nothing_outside(canary: &Path) { + assert!( + !canary.exists(), + "archive extraction escaped its destination and wrote {}", + canary.display() + ); +} + +#[test] +fn parent_dir_traversal_never_writes_outside_destination() { + let (tmp, theme_dir, source_dir) = theme_with_archive(&[( + "../../../../../../tmp/cosmic_conf_escape_canary", + tar::EntryType::Regular, + b"pwned", + None, + )]); + + let home = tmp.path().join("home"); + let data = home.join(".local/share"); + let installer = Installer::with_paths(&data, &home); + + let result = installer.plan(&theme_dir, Some(&source_dir), "Evil", true); + + // Whether it is rejected at plan time or apply time, the invariant is the + // same: nothing lands outside the destination. + if let Ok(plan) = result { + let _ = installer.apply(&plan); + } + assert_nothing_outside(Path::new("/tmp/cosmic_conf_escape_canary")); +} + +#[test] +fn absolute_path_entry_never_writes_outside_destination() { + let (tmp, theme_dir, source_dir) = theme_with_archive(&[( + "/tmp/cosmic_conf_abs_canary", + tar::EntryType::Regular, + b"pwned", + None, + )]); + + let home = tmp.path().join("home"); + let data = home.join(".local/share"); + let installer = Installer::with_paths(&data, &home); + + if let Ok(plan) = installer.plan(&theme_dir, Some(&source_dir), "Evil", true) { + let _ = installer.apply(&plan); + } + assert_nothing_outside(Path::new("/tmp/cosmic_conf_abs_canary")); +} + +/// The subtle one: neither entry path contains `..`, so a naive check passes. +/// The symlink redirects a later, innocent-looking write outside the tree. +#[test] +fn symlink_indirection_never_writes_outside_destination() { + let (tmp, theme_dir, source_dir) = theme_with_archive(&[ + ("escape", tar::EntryType::Symlink, b"", Some("/tmp")), + ( + "escape/cosmic_conf_symlink_canary", + tar::EntryType::Regular, + b"pwned", + None, + ), + ]); + + let home = tmp.path().join("home"); + let data = home.join(".local/share"); + let installer = Installer::with_paths(&data, &home); + + if let Ok(plan) = installer.plan(&theme_dir, Some(&source_dir), "Evil", true) { + let _ = installer.apply(&plan); + } + assert_nothing_outside(Path::new("/tmp/cosmic_conf_symlink_canary")); +} + +/// A benign archive must still install, or the guard is uselessly strict. +#[test] +fn well_formed_archive_still_installs() { + let (tmp, theme_dir, source_dir) = theme_with_archive(&[( + "Evil-Theme/index.theme", + tar::EntryType::Regular, + b"[Desktop Entry]\n", + None, + )]); + + let home = tmp.path().join("home"); + let data = home.join(".local/share"); + let installer = Installer::with_paths(&data, &home); + + let plan = installer + .plan(&theme_dir, Some(&source_dir), "Evil", true) + .expect("a well-formed archive must plan cleanly"); + installer.apply(&plan).expect("and must apply"); + + assert!( + home.join(".themes/Evil-Theme/index.theme").exists(), + "benign archive did not install; guard is too strict" + ); +}