Install HyDE themes end to end

Three things stood between `assets.rs` and a themed desktop.

`import-theme` never called it. The module was written, tested and unreachable;
`--assets` now wires it up, with `--source`, `--overwrite` and `--dry-run`, and
finds the theme repo's Source/ directory by searching upward rather than
assuming HyDE's exact nesting depth.

The archive guard rejected every real icon theme. Refusing any `..` in a link
target is right for an entry path but wrong for a symlink: icon themes are
built out of relative links into sibling directories, and Tela ships thousands
of `../devices/network-wireless.svg`. What matters is whether the target
resolves inside the destination, which `stays_within_root` now decides
lexically -- no canonicalize, since the tree does not exist at plan time and
following real links during validation would be a TOCTOU window. Absolute
targets and links that climb past the root are still refused; the existing
escape tests still pass.

`apply` silently ignored `source`. It parsed and resolved inline while `watch`
went through `compile`, and `flatten` drops `Item::Source` -- so an include
that worked under `watch` vanished under `apply`. `apply` now uses `compile`
too. This matters immediately: the generated theme lives in its own
theme.conf, sourced from cosmic.conf, so re-importing a theme cannot clobber
the keybindings.

The waybar stylesheet claimed a theme could be dropped in ahead of it to
recolour the bar. It could not -- HyDE names its colours main-bg/wb-act-bg and
the rules referenced bar-bg/accent. Split into palette + theme + bridge +
rules, imported in that order, so the claim is now true. Verified by loading
the result through GTK's own CSS parser: with Tokyo Night installed main-bg
resolves to #24283b and wb-act-bg to #bb9af7; with an empty theme.css the
defaults stand. Both parse without error.

Two deliberate departures, both commented where they are made: the theme's
near-transparent bar-bg is composited at 0.85 because cosmic-comp has no blur
to put behind it, and theme.css is copied next to style.css rather than
imported from HyDE's own path, because a missing @import is fatal in GTK and
would break the bar on any machine without a theme.
This commit is contained in:
2026-08-10 09:11:08 +07:00
parent 10c5cc73d4
commit e46514cbab
7 changed files with 387 additions and 107 deletions
+148 -4
View File
@@ -158,6 +158,56 @@ pub struct Report {
pub installed: Vec<PathBuf>,
}
impl Action {
pub fn kind(&self) -> AssetKind {
match self {
Action::ExtractArchive { kind, .. } | Action::CopyVerbatim { kind, .. } => *kind,
Action::CopyWallpaper { .. } => AssetKind::Wallpaper,
}
}
pub fn dest(&self) -> &Path {
match self {
Action::ExtractArchive { dest, .. }
| Action::CopyWallpaper { dest, .. }
| Action::CopyVerbatim { dest, .. } => dest,
}
}
}
/// What `apply` would do, for `--dry-run`.
///
/// Deliberately not `render_report` with a synthesised `Report`: saying
/// "Installed:" about files that were never written is the kind of small lie
/// that makes a tool untrustworthy.
pub fn render_plan(plan: &Plan) -> String {
let mut out = String::new();
if plan.actions.is_empty() {
out.push_str("Nothing to install.\n");
} else {
out.push_str("Would install:\n");
for a in &plan.actions {
out.push_str(&format!(
" {} ({})\n",
a.dest().display(),
a.kind().label()
));
}
}
if !plan.skipped.is_empty() {
out.push_str("\nWould skip:\n");
for n in &plan.skipped {
out.push_str(&format!(
" {} ({}): {}\n",
n.path.display(),
n.kind.label(),
n.reason.describe()
));
}
}
out
}
/// 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 {
@@ -482,11 +532,30 @@ fn walk_archive(archive_path: &Path, dest_root: &Path, write: bool) -> Result<Ve
// 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) {
// that link would then land wherever the link points.
//
// The target cannot use the same rule as the entry path, though. Icon
// themes are built almost entirely out of relative symlinks pointing
// at sibling directories -- Tela ships thousands of
// `../devices/network-wireless.svg` -- so rejecting every `..` would
// reject every real icon theme. What matters is not whether the target
// contains `..` but whether it still lands inside `dest_root` once
// resolved, which is what `stays_within_root` decides.
let entry_type = entry.header().entry_type();
if matches!(entry_type, tar::EntryType::Symlink | tar::EntryType::Link) {
if let Some(target) = entry.link_name()? {
reject_unsafe_path(archive_path, &target)?;
// tar resolves a symlink target against the link's own
// directory, but a hardlink target against the archive root.
let base = match entry_type {
tar::EntryType::Link => Path::new(""),
_ => rel.parent().unwrap_or(Path::new("")),
};
if !stays_within_root(base, &target) {
return Err(AssetError::UnsafeArchiveEntry {
archive: archive_path.to_path_buf(),
entry: target.into_owned(),
});
}
}
}
@@ -519,6 +588,35 @@ fn reject_unsafe_path(archive: &Path, entry: &Path) -> Result<(), AssetError> {
Ok(())
}
/// Does `base/target` still land inside the root it started from?
///
/// Resolution is lexical on purpose. At plan time the destination tree does
/// not exist yet, so `canonicalize` has nothing to work with; and following
/// real symlinks during validation would open a TOCTOU window between the
/// check and the extraction. Counting depth over the joined components
/// answers the only question that matters without touching the filesystem.
fn stays_within_root(base: &Path, target: &Path) -> bool {
if target.is_absolute() {
return false;
}
let mut depth: isize = 0;
for c in base.components().chain(target.components()) {
match c {
Component::Normal(_) => depth += 1,
Component::ParentDir => {
depth -= 1;
if depth < 0 {
return false;
}
}
Component::CurDir => {}
// An absolute component anywhere replaces everything before it.
Component::RootDir | Component::Prefix(_) => return false,
}
}
true
}
/// 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
@@ -541,6 +639,52 @@ mod tests {
use flate2::Compression;
use tempfile::TempDir;
#[test]
fn a_relative_symlink_into_a_sibling_directory_is_allowed() {
// Regression: rejecting every `..` in a link target rejected every
// real icon theme. Tela ships thousands of exactly this shape, and
// Tokyo-Night's Icon_TelaPurple.tar.gz would not extract.
assert!(stays_within_root(
Path::new("Tela-purple-dark/16/panel"),
Path::new("../devices/network-wireless.svg")
));
assert!(stays_within_root(
Path::new("Tela/22/apps"),
Path::new("../../16/apps/firefox.svg")
));
}
#[test]
fn a_relative_symlink_that_climbs_past_the_root_is_still_refused() {
// One `..` too many is the whole attack, so the boundary is exact
// rather than approximate.
assert!(!stays_within_root(
Path::new("Tela/16/panel"),
Path::new("../../../../etc/passwd")
));
assert!(!stays_within_root(Path::new(""), Path::new("../escape")));
assert!(!stays_within_root(Path::new("a"), Path::new("../../escape")));
// Exactly back to the root is fine; one further is not.
assert!(stays_within_root(Path::new("a/b"), Path::new("../../c")));
assert!(!stays_within_root(Path::new("a/b"), Path::new("../../../c")));
}
#[test]
fn an_absolute_symlink_target_is_refused_however_it_is_spelled() {
assert!(!stays_within_root(Path::new("a/b"), Path::new("/etc/passwd")));
assert!(!stays_within_root(Path::new("a/b"), Path::new("/")));
}
#[test]
fn detours_that_end_up_back_inside_are_allowed() {
// `a/b/../c` never leaves, so refusing it would be strictness with no
// security value.
assert!(stays_within_root(
Path::new("theme/scalable"),
Path::new("../scalable/./places/../apps/icon.svg")
));
}
/// 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 {
+82 -30
View File
@@ -2,10 +2,10 @@
//!
//! Exit codes: 0 success, 1 config error (nothing written), 2 usage error.
use std::path::PathBuf;
use std::path::{Path, PathBuf};
use std::process::ExitCode;
use cosmic_conf::{emit::Emitter, import, parse, render_diagnostic, resolve};
use cosmic_conf::{assets, emit::Emitter, import, render_diagnostic, watch};
const USAGE: &str = "\
cosmic-conf — compile cosmic.conf into the cosmic-config tree
@@ -13,15 +13,35 @@ cosmic-conf — compile cosmic.conf into the cosmic-config tree
USAGE:
cosmic-conf apply [--diff] [--config <path>]
cosmic-conf import-theme <hypr.theme> [--out <path>] [--report]
[--assets [--source <dir>] [--overwrite] [--dry-run]]
OPTIONS:
--diff Show what would change without writing anything
--config <path> Config file (default: $XDG_CONFIG_HOME/hyprcosmic/cosmic.conf)
--out <path> Write the generated cosmic.conf here (default: stdout)
--report Print everything that did not translate cleanly
--assets Also install wallpapers, GTK/icon themes and the
waybar/rofi/kitty theme files that sit beside hypr.theme
--source <dir> The theme repo's Source/ directory holding the GTK and
icon tarballs (default: found by searching upward)
--overwrite Replace assets that are already installed
--dry-run With --assets, list what would be installed and stop
-h, --help Show this help
";
/// HyDE keeps GTK and icon tarballs in a `Source/` directory at the root of
/// the theme repo, four levels above the theme folder
/// (`Configs/.config/hyde/themes/<Name>/`). Searching upward rather than
/// hardcoding that depth means a theme unpacked at a different depth, or one
/// vendored into another tree, still works.
fn find_source_dir(theme_dir: &std::path::Path) -> Option<PathBuf> {
theme_dir
.ancestors()
.take(6)
.map(|a| a.join("Source"))
.find(|c| c.is_dir())
}
fn default_config_path() -> Option<PathBuf> {
let base = match std::env::var_os("XDG_CONFIG_HOME") {
Some(x) if !x.is_empty() => PathBuf::from(x),
@@ -85,35 +105,16 @@ fn main() -> ExitCode {
}
fn run(config_path: &PathBuf, diff_only: bool) -> Result<String, String> {
let source = std::fs::read_to_string(config_path)
.map_err(|e| format!("error: cannot read {}: {e}\n", config_path.display()))?;
let ast = parse(&source).map_err(|e| {
render_diagnostic(&source, e.span, &e.message, None)
})?;
let resolved = resolve(&ast).map_err(|diags| {
let mut out = String::new();
for d in &diags {
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",
diags.len()
));
out
})?;
let emitter = Emitter::from_env().map_err(|e| format!("error: {e}\n"))?;
let planned = emitter.plan(&resolved).map_err(|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");
out
})?;
// Through `watch::compile` rather than parse/resolve/plan inline, because
// that is the only path that expands `source`. Doing it by hand here meant
// `resolve` never saw the included text -- `flatten` drops `Item::Source`
// -- so a sourced file was silently ignored by `apply` while `watch`
// honoured it. An include that works in one and vanishes in the other is
// worse than one that is unsupported in both.
let compiled = watch::compile(config_path, &emitter).map_err(|e| e.to_string())?;
let planned = compiled.planned;
let changes: Vec<_> = planned.iter().filter(|p| !p.is_noop()).collect();
@@ -193,5 +194,56 @@ fn run_import(args: &[String]) -> Result<String, String> {
"\n{dropped} setting(s) did not translate. Re-run with --report for details.\n"
));
}
if args.iter().any(|a| a == "--assets") {
out.push('\n');
out.push_str(&install_assets(src_path, &name, args)?);
}
Ok(out)
}
/// The half of a theme that is not config: wallpapers, GTK/icon tarballs, and
/// the `.theme` files belonging to waybar, rofi and kitty.
///
/// Separate from the conf translation because it is separate in kind — none of
/// it is translated, only placed — and because it writes outside the
/// cosmic-config tree, which every other path in this tool does not.
fn install_assets(src_path: &str, name: &str, args: &[String]) -> Result<String, String> {
let theme_dir = PathBuf::from(src_path)
.parent()
.map(Path::to_path_buf)
.ok_or_else(|| format!("error: {src_path} has no parent directory\n"))?;
let source_dir = match args.iter().position(|a| a == "--source") {
Some(i) => match args.get(i + 1) {
Some(p) => Some(PathBuf::from(p)),
None => return Err(format!("error: --source needs a path\n\n{USAGE}")),
},
None => find_source_dir(&theme_dir),
};
let installer = assets::Installer::from_env().map_err(|e| format!("error: {e}\n"))?;
let plan = installer
.plan(
&theme_dir,
source_dir.as_deref(),
name,
args.iter().any(|a| a == "--overwrite"),
)
.map_err(|errors| {
errors
.iter()
.map(|e| format!("error: {e}\n"))
.collect::<String>()
})?;
if args.iter().any(|a| a == "--dry-run") {
return Ok(assets::render_plan(&plan));
}
let report = installer
.apply(&plan)
.map_err(|e| format!("error: {e}\n"))?;
Ok(assets::render_report(&plan, &report))
}