#![cfg(unix)]
use std::fs;
use std::io::{Read, Write};
use std::net::TcpListener;
use std::os::fd::FromRawFd;
use std::os::unix::fs::PermissionsExt;
use std::path::PathBuf;
use std::process::{Command, Output, Stdio};
use std::sync::{
atomic::{AtomicBool, AtomicUsize, Ordering},
Arc, Mutex,
};
use std::thread;
use std::time::Duration;
struct Fixture {
root: PathBuf,
}
impl Fixture {
fn new() -> Self {
static NEXT: AtomicUsize = AtomicUsize::new(0);
let root = std::env::temp_dir().join(format!(
"knott-cli-{}-{}",
std::process::id(),
NEXT.fetch_add(1, Ordering::Relaxed)
));
fs::create_dir(&root).unwrap();
fs::create_dir_all(root.join("db/local")).unwrap();
let fixture = Self { root };
fixture.script(
"pacman",
r#"
printf 'pacman %s\n' "$*" >> "$TEST_LOG"
case "$1" in
-Sddp) for last; do :; done; case "$last" in repo-pkg) exit 0;; *) exit 1;; esac ;;
-Q) exit 1 ;;
-Qp) printf 'aur-pkg 1-1\n' ;;
-Qmq) exit 1 ;;
esac
"#,
);
fixture.script(
"sudo",
"printf 'sudo %s\\n' \"$*\" >> \"$TEST_LOG\"\nexec \"$@\"\n",
);
fixture.script("git", "for last; do :; done\nmkdir -p \"$last/.git\"\n");
fixture.script(
"makepkg",
r#"
printf 'makepkg %s\n' "$*" >> "$TEST_LOG"
case "$1" in
--packagelist) printf '%s/aur-pkg.pkg.tar.zst\n' "$PWD" ;;
*) touch aur-pkg.pkg.tar.zst ;;
esac
"#,
);
fixture
}
fn script(&self, name: &str, body: &str) {
let path = self.root.join(name);
fs::write(&path, format!("#!/bin/sh\nset -eu\n{body}\n")).unwrap();
fs::set_permissions(path, fs::Permissions::from_mode(0o700)).unwrap();
}
fn run(&self, args: &[&str], url: &str) -> Output {
self.command(args, url).output().unwrap()
}
fn command(&self, args: &[&str], url: &str) -> Command {
let mut command = Command::new(env!("CARGO_BIN_EXE_knott"));
command
.args(args)
.env("KNOTT_AUR_URL", url)
.env("XDG_STATE_HOME", self.root.join("state"))
.env("XDG_RUNTIME_DIR", self.root.join("runtime"))
.env("KNOTT_DBPATH", self.root.join("db"))
.env("KNOTT_BUILDDIR", self.root.join("build"))
.env("KNOTT_PACMAN", self.root.join("pacman"))
.env("KNOTT_SUDO", self.root.join("sudo"))
.env("KNOTT_GIT", self.root.join("git"))
.env("KNOTT_MAKEPKG", self.root.join("makepkg"))
.env("KNOTT_COLOR", "never")
.env("KNOTT_PROGRESS", "0")
.env("TEST_LOG", self.root.join("calls"));
command
}
fn calls(&self) -> String {
fs::read_to_string(self.root.join("calls")).unwrap_or_default()
}
fn current(&self) -> PathBuf {
self.root.join("state/knott/current.json")
}
fn save_transaction(&self, phase: &str, ready: bool) -> Vec<u8> {
let mut tx = serde_json::json!({
"schema": 1, "id": "fixture", "command": ["-Syu", "--repo"],
"started_at_unix": 1, "updated_at_unix": 1, "phase": phase,
"refresh": true, "sysupgrade": true, "plan_ready": ready,
"options": {"mode": "Repo", "no_confirm": true, "needed": false,
"no_check": false, "prefer_bin": false, "quiet": false, "no_progress": true},
"repo_deps": [], "aur_items": [], "unresolved": [], "last_error": null
});
if ready {
tx["aur_items"] = serde_json::json!([{
"name": "aur-pkg", "base": "aur-pkg", "version": "1-1", "status": "Built",
"build_dir": self.root.join("build/aur-pkg"),
"artifacts": [self.root.join("build/aur-pkg/pkg.tar.zst")],
"installed_as_dependency": false
}]);
}
let bytes = serde_json::to_vec(&tx).unwrap();
fs::create_dir_all(self.current().parent().unwrap()).unwrap();
fs::write(self.current(), &bytes).unwrap();
bytes
}
}
impl Drop for Fixture {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.root);
}
}
struct Rpc {
url: String,
body: Arc<Mutex<String>>,
stop: Arc<AtomicBool>,
thread: Option<thread::JoinHandle<()>>,
}
impl Rpc {
fn new(body: &str) -> Self {
Self::with_source(body, "")
}
fn with_source(body: &str, source: &str) -> Self {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let url = format!("http://{}", listener.local_addr().unwrap());
listener.set_nonblocking(true).unwrap();
let body = Arc::new(Mutex::new(body.to_string()));
let stop = Arc::new(AtomicBool::new(false));
let (body2, stop2) = (body.clone(), stop.clone());
let source = source.to_string();
let thread = thread::spawn(move || {
while !stop2.load(Ordering::Relaxed) {
match listener.accept() {
Ok((mut stream, _)) => {
stream
.set_read_timeout(Some(Duration::from_secs(2)))
.unwrap();
let mut buffer = [0; 8192];
let count = stream.read(&mut buffer).unwrap_or(0);
let body = if buffer[..count].starts_with(b"GET /cgit/") {
source.clone()
} else {
body2.lock().unwrap().clone()
};
let _ = write!(stream, "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", body.len(), body);
}
Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {
thread::sleep(Duration::from_millis(5))
}
Err(e) => panic!("{e}"),
}
}
});
Self {
url,
body,
stop,
thread: Some(thread),
}
}
}
impl Drop for Rpc {
fn drop(&mut self) {
self.stop.store(true, Ordering::Relaxed);
self.thread.take().unwrap().join().unwrap();
}
}
fn assert_success(output: &Output) {
assert!(
output.status.success(),
"stdout: {}\nstderr: {}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
}
fn terminal_output(mut command: Command, columns: u16) -> String {
let (mut master, mut slave) = (-1, -1);
let size = libc::winsize {
ws_row: 24,
ws_col: columns,
ws_xpixel: 0,
ws_ypixel: 0,
};
assert_eq!(
unsafe {
libc::openpty(
&mut master,
&mut slave,
std::ptr::null_mut(),
std::ptr::null(),
&size,
)
},
0
);
let (mut master, slave) =
unsafe { (fs::File::from_raw_fd(master), fs::File::from_raw_fd(slave)) };
command.stderr(Stdio::from(slave.try_clone().unwrap()));
command.stdout(Stdio::from(slave));
let reader = thread::spawn(move || {
let mut result = Vec::new();
let mut buffer = [0; 4096];
loop {
match master.read(&mut buffer) {
Ok(0) => break,
Ok(count) => result.extend_from_slice(&buffer[..count]),
Err(err) if err.raw_os_error() == Some(libc::EIO) => break,
Err(err) if err.kind() == std::io::ErrorKind::Interrupted => continue,
Err(err) => panic!("read PTY: {err}"),
}
}
result
});
let child = command.spawn().unwrap();
drop(command); let status = child.wait_with_output().unwrap();
assert_success(&status);
String::from_utf8(reader.join().unwrap())
.unwrap()
.replace("\r\n", "\n")
}
#[test]
fn search_terminal_colors_and_width_do_not_change_pipe_or_quiet_output() {
let f = Fixture::new();
f.script("pacman", "printf 'extra/demo 1-1 [installed]\\n A useful package with enough words to wrap on a narrow terminal.\\033]52;c;secret\\007\\n'");
let args = ["--repo", "-Ss", "demo"];
let out = f.run(&args, "http://127.0.0.1:1");
assert_success(&out);
let plain = String::from_utf8(out.stdout).unwrap();
assert_eq!(plain.lines().count(), 2);
assert!(!plain.contains('\x1b'));
assert!(!plain.contains("secret"));
assert_eq!(
f.run(&["--repo", "-Ssq", "demo"], "http://127.0.0.1:1")
.stdout,
b"demo\n"
);
for setting in ["auto", "no-color", "dumb", "never"] {
let mut command = f.command(&args, "http://127.0.0.1:1");
command
.env("KNOTT_COLOR", "auto")
.env("TERM", "xterm-256color")
.env_remove("NO_COLOR");
match setting {
"no-color" => {
command.env("NO_COLOR", "1");
}
"dumb" => {
command.env("TERM", "dumb");
}
"never" => {
command.env("KNOTT_COLOR", "never");
}
_ => {}
}
let rendered = terminal_output(command, 40);
assert_eq!(rendered.contains('\x1b'), setting == "auto");
assert!(!rendered.contains("secret"));
if setting == "auto" {
assert!(rendered.contains("\x1b[1mdemo\x1b[0m"));
}
let clean = knott::sanitize_for_terminal(&rendered);
assert!(clean.lines().count() > 2);
assert!(clean.lines().all(|line| line.len() <= 40));
}
}
#[test]
fn pkgbuild_source_is_safe_on_a_terminal_and_preserved_when_redirected() {
let f = Fixture::new();
let source = "# source\x1b]52;c;clipboard\x07\nname='\u{202e}pkg'\n";
let rpc = Rpc::with_source(
r#"{"type":"multiinfo","resultcount":1,"results":[{"Name":"aur-pkg","Version":"1-1"}]}"#,
source,
);
let out = f.run(&["-Gp", "aur-pkg"], &rpc.url);
assert_success(&out);
assert_eq!(out.stdout, source.as_bytes());
let displayed = terminal_output(f.command(&["-Gp", "aur-pkg"], &rpc.url), 80);
assert_eq!(displayed, "# source\nname='\\u{202e}pkg'\n");
}
#[test]
fn progress_respects_terminal_width_and_disable_settings() {
let f = Fixture::new();
let rpc = Rpc::new(
r#"{"type":"multiinfo","resultcount":2,"results":[{"Name":"alpha","Version":"1-1"},{"Name":"beta","Version":"1-1"}]}"#,
);
for (term, columns, progress, no_progress) in [
("xterm", 40, "1", false),
("dumb", 40, "1", false),
("xterm", 20, "1", false),
("xterm", 40, "0", false),
("xterm", 40, "1", true),
] {
let mut command = f.command(&["--aur", "-Si", "alpha", "beta"], &rpc.url);
command.env("TERM", term).env("KNOTT_PROGRESS", progress);
if no_progress {
command.arg("--no-progress");
}
let rendered = terminal_output(command, columns);
let enabled = term == "xterm" && columns >= 24 && progress == "1" && !no_progress;
assert_eq!(rendered.contains('\r'), enabled, "{rendered:?}");
assert_eq!(rendered.contains('\x1b'), enabled, "{rendered:?}");
for frame in rendered.split('\r').skip(1) {
let line = frame.split("\x1b[K").next().unwrap();
assert!(unicode_width::UnicodeWidthStr::width(line) < usize::from(columns));
}
}
}
#[test]
fn diagnostics_and_errors_cannot_inject_terminal_controls_or_extra_lines() {
let f = Fixture::new();
let out = f
.command(&["--diagnostics"], "invalid URL")
.env(
"XDG_STATE_HOME",
f.root.join("state\x1b]52;c;secret\x07\n\u{202e}"),
)
.output()
.unwrap();
assert_success(&out);
let stdout = String::from_utf8(out.stdout).unwrap();
assert!(!stdout.contains('\x1b'));
assert!(!stdout.contains("secret"));
assert!(stdout.contains("state \\u{202e}/knott"));
let out = f.run(&["-S", "foo", "--bad\x1b[31m\n\u{202e}"], "invalid URL");
assert!(!out.status.success());
let stderr = String::from_utf8(out.stderr).unwrap();
assert!(!stderr.contains('\x1b'));
assert!(stderr.contains("\\u{202e}"));
assert_eq!(stderr.lines().count(), 1);
}
#[test]
fn dry_run_quotes_shell_metacharacters_without_executing_tools() {
let f = Fixture::new();
let out = f.run(
&[
"-U",
"--dry-run",
"a b.pkg",
"$(touch injected).pkg",
"a'b.pkg",
],
"invalid URL",
);
assert_success(&out);
let stdout = String::from_utf8(out.stdout).unwrap();
assert!(
stdout.contains("'a b.pkg' '$(touch injected).pkg' 'a'\\''b.pkg'"),
"{stdout}"
);
assert!(f.calls().is_empty());
assert!(!f.root.join("state").exists());
}
#[test]
fn unsupported_install_options_never_invoke_pacman() {
let f = Fixture::new();
for args in [
vec!["-S", "foo", "--root", "/tmp/other"],
vec!["-Sw", "foo"],
vec!["-S", "--downloadonly", "foo"],
] {
assert!(!f.run(&args, "http://127.0.0.1:1").status.success());
}
assert!(f.calls().is_empty());
assert!(!f.root.join("state").exists());
}
#[test]
fn forwarded_dry_run_is_read_only_and_removal_uses_privileges() {
let f = Fixture::new();
assert_success(&f.run(
&["-Rns", "foo", "--dry-run", "--no-progress"],
"invalid URL",
));
assert!(f.calls().is_empty());
assert!(!f.root.join("state").exists());
assert_success(&f.run(
&["-Rns", "foo", "--noconfirm", "--no-progress"],
"invalid URL",
));
assert!(f.calls().contains("pacman -Rns foo --noconfirm"));
if unsafe { libc::geteuid() } != 0 {
assert!(f.calls().contains("sudo "));
}
}
#[test]
fn recovery_dry_runs_leave_state_untouched_and_run_no_commands() {
for (flag, phase, ready) in [
("--resume", "AurBuilt", true),
("--repair", "RepoUpgradeStarted", false),
("--abort", "AurBuilt", true),
] {
let f = Fixture::new();
let saved = f.save_transaction(phase, ready);
assert_success(&f.run(&[flag, "--dry-run", "--noconfirm"], "http://127.0.0.1:1"));
assert_eq!(fs::read(f.current()).unwrap(), saved);
assert!(f.calls().is_empty(), "{}", f.calls());
assert!(!f.current().parent().unwrap().join("knott.lock").exists());
assert!(!f.current().parent().unwrap().join("transactions").exists());
}
}
#[test]
fn mixed_install_resumes_aur_planning_after_metadata_failure() {
if unsafe { libc::geteuid() } == 0 {
return;
} let f = Fixture::new();
let rpc = Rpc::new(r#"{"type":"error","error":"temporary failure"}"#);
let out = f.run(
&[
"-S",
"repo-pkg",
"aur-pkg",
"--no-prefer-bin",
"--noconfirm",
],
&rpc.url,
);
assert!(!out.status.success());
assert!(f.calls().contains("pacman -S --noconfirm -- repo-pkg"));
assert!(f.current().exists());
*rpc.body.lock().unwrap() = r#"{"type":"multiinfo","resultcount":1,"results":[{"Name":"aur-pkg","PackageBase":"aur-pkg","Version":"1-1"}]}"#.into();
assert_success(&f.run(&["--resume", "--noconfirm"], &rpc.url));
assert!(
f.calls().contains("makepkg -s --force --noconfirm"),
"{}",
f.calls()
);
assert!(f.calls().contains("pacman -U --noconfirm -- "));
assert!(!f.current().exists());
}
#[test]
fn malformed_aur_identity_never_reaches_git_or_makepkg() {
let f = Fixture::new();
let rpc = Rpc::new(
r#"{"type":"multiinfo","resultcount":1,"results":[{"Name":"aur-pkg","PackageBase":"../escape","Version":"1-1"}]}"#,
);
let out = f.run(
&["--aur", "-S", "aur-pkg", "--no-prefer-bin", "--noconfirm"],
&rpc.url,
);
assert!(!out.status.success());
assert!(String::from_utf8_lossy(&out.stderr).contains("invalid package identity"));
assert!(f.calls().is_empty());
assert!(!f.root.join("build").exists());
}
#[test]
fn eof_does_not_approve_an_install() {
let f = Fixture::new();
let rpc = Rpc::new(
r#"{"type":"multiinfo","resultcount":1,"results":[{"Name":"aur-pkg","Version":"1-1"}]}"#,
);
assert_success(&f.run(&["--aur", "-S", "aur-pkg", "--no-prefer-bin"], &rpc.url));
assert!(f.calls().is_empty());
assert!(!f.root.join("build").exists());
}
#[test]
fn split_build_installs_only_planned_artifacts() {
if unsafe { libc::geteuid() } == 0 {
return;
}
let f = Fixture::new();
f.script(
"makepkg",
r#"
case "$1" in
--packagelist) printf '%s/aur-pkg.pkg.tar.zst\n%s/unrequested.pkg.tar.zst\n' "$PWD" "$PWD" ;;
*) touch aur-pkg.pkg.tar.zst unrequested.pkg.tar.zst ;;
esac
"#,
);
let script = fs::read_to_string(f.root.join("pacman")).unwrap().replace(
"-Qp) printf 'aur-pkg 1-1\\n' ;;",
"-Qp) case \"$3\" in *unrequested*) printf 'unrequested 1-1\\n';; *) printf 'aur-pkg 1-1\\n';; esac ;;"
);
fs::write(f.root.join("pacman"), script).unwrap();
let rpc = Rpc::new(
r#"{"type":"multiinfo","resultcount":1,"results":[{"Name":"aur-pkg","Version":"1-1"}]}"#,
);
assert_success(&f.run(
&["--aur", "-S", "aur-pkg", "--no-prefer-bin", "--noconfirm"],
&rpc.url,
));
let calls = f.calls();
let install = calls
.lines()
.find(|line| line.starts_with("pacman -U"))
.unwrap();
assert!(install.contains("aur-pkg.pkg.tar.zst"));
assert!(!install.contains("unrequested.pkg.tar.zst"));
}
#[test]
fn sysupgrade_with_explicit_target_also_plans_aur_updates() {
let f = Fixture::new();
let script = fs::read_to_string(f.root.join("pacman"))
.unwrap()
.replace("-Qmq) exit 1 ;;", "-Qmq) printf 'aur-pkg\\n' ;;");
fs::write(f.root.join("pacman"), script).unwrap();
fs::create_dir_all(f.root.join("db/local/aur-pkg-0-1")).unwrap();
fs::write(
f.root.join("db/local/aur-pkg-0-1/desc"),
"%NAME%\naur-pkg\n\n%VERSION%\n0-1\n",
)
.unwrap();
let rpc = Rpc::new(
r#"{"type":"multiinfo","resultcount":1,"results":[{"Name":"aur-pkg","Version":"1-1"}]}"#,
);
let out = f.run(&["-Syu", "repo-pkg", "--dry-run", "--noconfirm"], &rpc.url);
assert_success(&out);
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(stdout.contains("AUR build order"), "{stdout}");
assert!(stdout.contains("aur-pkg"));
assert!(!f.root.join("state").exists());
}