Files
hyprcosmic/cosmic-conf/tests/archive_escape.rs
T
gitops 362f324755 import-theme: write rofi's entry point and per-machine overrides
The four-layer rofi chain needs config.rasi and local.rasi to exist, and
neither has an upstream file to copy: a HyDE theme has no equivalent of
either. They were hand-written for this machine, which meant a second machine
got a launcher that reported a missing @import instead of opening.

config.rasi is `include_str!`d from config/rofi/ rather than kept as a string
literal, so it stays a real .rasi file -- highlightable, diffable, editable
without a rebuild to see the result -- and there is one copy of it rather than
two that can disagree.

local.rasi is composed per machine from two things the repo cannot know: the
theme's $ICON_THEME, and a wallpaper path. Each half is omitted entirely when
there is nothing to say, because an empty `icon-theme:` list is something rofi
would honour.

$ICON_THEME comes back as a field on Import rather than being re-parsed out of
the conf text that the same function just rendered.

The wallpaper needed a stable name. local.rasi cannot hardcode a filename
without going stale at the next theme import, so plan_wallpapers now also
maintains ~/.local/share/wallpapers/hyprcosmic/current as a symlink to one of
the copies it made. HyDE has this problem too and solves it the same way, with
~/.cache/hyde/wall.thmb. The launcher sidebar and the autostart's `awww img`
line both name the link, so they cannot drift apart.

Which wallpaper it points at is the first in *sorted* order. read_dir returns
whatever the filesystem feels like, and an arbitrary choice is fine where an
unrepeatable one is not: re-running the import would otherwise change the
wallpaper at random. The link is repointed even when every wallpaper was
skipped as already installed -- the copies are theme-specific and unchanged,
but the link is global and has to follow the theme just imported.

Repointing has to handle a *dangling* link, which is exactly what a previous
import leaves behind once its theme directory is gone: symlink(2) fails with
EEXIST rather than replacing, and Path::exists follows the link, so it answers
false for the one case that needs removing. symlink_metadata asks about the
link itself.

Values reaching a generated config come from a theme directory that may have
been downloaded from anywhere, so quote_rasi_string strips quotes, backslashes
and control characters -- .rasi has no escape syntax worth relying on.

Four existing tests counted actions and broke once every plan carried two more.
Fixed with a theme_assets() filter rather than by bumping the numbers, so what
they are actually asserting stays visible and the next generated file does not
break them again.

125 unit, 5 bin and 4 integration tests pass; clippy --all-targets and
`cargo fmt --check` are clean. A real import of Tokyo Night installed all 13
paths, and `cosmic-conf apply --diff` reports no changes afterwards.
2026-08-10 11:48:02 +07:00

185 lines
6.4 KiB
Rust

//! 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", None, 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", None, 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", None, 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", None, 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"
);
}