mirror of
https://github.com/outbackdingo/hyprcosmic.git
synced 2026-08-25 07:10:09 +00:00
cosmic-conf: watch + theme asset installation
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.
This commit is contained in:
@@ -5,3 +5,6 @@
|
||||
target/
|
||||
**/*.rs.bk
|
||||
vendor/
|
||||
|
||||
# Fork; becomes a submodule per spec repo topology
|
||||
cosmic-comp/
|
||||
|
||||
Generated
+280
-3
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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/<Theme Name>/hypr.theme
|
||||
//! Configs/.config/hyde/themes/<Theme Name>/waybar.theme
|
||||
//! Configs/.config/hyde/themes/<Theme Name>/rofi.theme
|
||||
//! Configs/.config/hyde/themes/<Theme Name>/kitty.theme
|
||||
//! Configs/.config/hyde/themes/<Theme Name>/wallpapers/*
|
||||
//! Source/Gtk_<Name>.tar.gz
|
||||
//! Source/Icon_<Name>.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<io::Error> 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<Action>,
|
||||
pub skipped: Vec<Note>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct Report {
|
||||
pub installed: Vec<PathBuf>,
|
||||
}
|
||||
|
||||
/// 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<Self, AssetError> {
|
||||
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<PathBuf>, home: impl Into<PathBuf>) -> 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/<theme_name>/`).
|
||||
pub fn plan(
|
||||
&self,
|
||||
theme_dir: &Path,
|
||||
source_dir: Option<&Path>,
|
||||
theme_name: &str,
|
||||
overwrite: bool,
|
||||
) -> Result<Plan, Vec<AssetError>> {
|
||||
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<Option<Action>, 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<Action>,
|
||||
skipped: &mut Vec<Note>,
|
||||
errors: &mut Vec<AssetError>,
|
||||
) {
|
||||
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<Action>,
|
||||
skipped: &mut Vec<Note>,
|
||||
errors: &mut Vec<AssetError>,
|
||||
) {
|
||||
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<Report, AssetError> {
|
||||
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<Option<PathBuf>, 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<Vec<PathBuf>, 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<PathBuf> {
|
||||
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"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
|
||||
@@ -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<Diagnostic>,
|
||||
},
|
||||
/// Resolved cleanly but could not be turned into file contents.
|
||||
Emit(Vec<EmitError>),
|
||||
}
|
||||
|
||||
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<Planned>,
|
||||
/// 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<PathBuf>,
|
||||
}
|
||||
|
||||
/// 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<Compiled, CompileError> {
|
||||
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 = <path>` 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<PathBuf>,
|
||||
watched: &mut Vec<PathBuf>,
|
||||
) -> Result<String, CompileError> {
|
||||
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<String> = 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<T>(rx: &mpsc::Receiver<T>, window: Duration) -> Option<Vec<T>> {
|
||||
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<PathBuf>, wanted: &[PathBuf]) {
|
||||
let wanted: HashSet<PathBuf> = 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<notify::Error> 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::<notify::Result<Event>>();
|
||||
let mut watcher: RecommendedWatcher = notify::recommended_watcher(tx)?;
|
||||
let mut watched: HashSet<PathBuf> = 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::<i32>();
|
||||
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::<notify::Result<Event>>();
|
||||
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]));
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user