mirror of
https://github.com/outbackdingo/hyprcosmic.git
synced 2026-08-25 14:53:21 +00:00
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:
+148
-4
@@ -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
@@ -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))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user