Make the update check optional and harden the legacy paths

The Gitea update check is now the default feature update-check of
ahfail-ui; distro builds drop it, and the HTTP client with it.
ahfail-display refuses to run as root and keeps its lock file in
XDG_RUNTIME_DIR instead of a fixed name in the shared /tmp, and the PAM
module no longer spawns it from a root context such as a greeter.
Removes pam_tests.rs, which tested helpers dropped in v0.9.0 and has not
compiled since.

Claude-Session: https://claude.ai/code/session_012LCTmU2ewo8tnM1dByYAoG
This commit is contained in:
Asger Geel Weirsøe
2026-09-30 19:18:21 +02:00
parent 37e6c36aee
commit a4b6297156
7 changed files with 70 additions and 27 deletions
+5 -1
View File
@@ -7,7 +7,7 @@ edition = "2021"
name = "ahfail-display"
[dependencies]
ahfail-ui = { path = "../ahfail-ui" }
ahfail-ui = { path = "../ahfail-ui", default-features = false }
gtk = { version = "0.15", package = "gtk", features = ["v3_24"] }
gdk = { version = "0.15", package = "gdk", features = ["v3_24"] }
gstreamer = { version = "0.18", package = "gstreamer", features = ["v1_18"] }
@@ -15,6 +15,10 @@ glib = { version = "0.15", package = "glib" }
cairo = { version = "0.15", package = "cairo-rs" }
libc = "0.2"
[features]
default = ["update-check"]
update-check = ["ahfail-ui/update-check"]
[build-dependencies]
cc = "1"
pkg-config = "0.3"
+17 -2
View File
@@ -78,15 +78,30 @@ mod x11_grab {
}
}
fn display_lock_path() -> std::path::PathBuf {
match std::env::var("XDG_RUNTIME_DIR") {
Ok(dir) if !dir.is_empty() => std::path::Path::new(&dir).join("ahfail-display.lock"),
_ => std::path::PathBuf::from(format!("/tmp/ahfail-display-{}.lock", unsafe { libc::getuid() })),
}
}
fn main() {
unsafe {
libc::signal(libc::SIGTERM, handle_sigterm as *const () as libc::sighandler_t);
}
// Single-instance guard
// Never run as root (the PAM module refuses to spawn us there, but the
// binary can also be started by hand).
if unsafe { libc::getuid() == 0 || libc::geteuid() == 0 } {
eprintln!("[ahfail] ahfail-display refuses to run as root");
return;
}
// Single-instance guard, per user: a fixed name in the shared /tmp could be
// pre-created by another user to block us.
let lock_file = std::fs::OpenOptions::new()
.create(true).write(true)
.open("/tmp/ahfail-display.lock");
.open(display_lock_path());
let lock_file = match lock_file {
Ok(f) => f,
Err(_) => return,
+5 -1
View File
@@ -8,7 +8,7 @@ name = "ahfail_module"
crate-type = ["staticlib", "rlib"]
[dependencies]
ahfail-ui = { path = "../ahfail-ui" }
ahfail-ui = { path = "../ahfail-ui", default-features = false }
gtk = { version = "0.15", package = "gtk", features = ["v3_24"] }
gdk = { version = "0.15", package = "gdk", features = ["v3_24"] }
gstreamer = { version = "0.18", package = "gstreamer", features = ["v1_18"] }
@@ -17,5 +17,9 @@ glib = { version = "0.15", package = "glib" }
gdk-pixbuf = "0.15"
rand = "0.8"
[features]
default = ["update-check"]
update-check = ["ahfail-ui/update-check"]
[build-dependencies]
pkg-config = "0.3"
+11
View File
@@ -56,7 +56,13 @@ pub unsafe extern "C" fn pam_sm_authenticate(
.map(|s| s.trim().to_string())
.unwrap_or_default();
// Never start a GTK program as root. Inside a greeter or display manager
// (lightdm and friends) this module runs in a root process; spawning the
// display there would run a whole GUI stack with root rights. Skip the effect
// and let authentication carry on untouched.
if !running_as_root() {
spawn_display(display_path.clone(), &locker_name);
}
let key = match CString::new("dk.weircon.ahfail") {
Ok(k) => k,
@@ -98,6 +104,11 @@ pub unsafe extern "C" fn pam_sm_chauthtok(
_pamh: *mut PamHandle, _flags: c_int, _argc: c_int, _argv: *const *const c_char,
) -> c_int { PAM_IGNORE }
/// True if either the real or the effective uid is root.
fn running_as_root() -> bool {
unsafe { libc::getuid() == 0 || libc::geteuid() == 0 }
}
unsafe fn argc_argv<'a>(argc: c_int, argv: *const *const c_char) -> &'a [*const c_char] {
if argv.is_null() || argc <= 0 { return &[]; }
std::slice::from_raw_parts(argv, argc as usize)
-21
View File
@@ -1,21 +0,0 @@
use ahfail_pam::{is_failure, is_success, is_replace, PAM_AUTH_ERR, PAM_SUCCESS, PAM_DATA_REPLACE};
#[test]
fn status_classification() {
assert!(is_failure(PAM_AUTH_ERR));
assert!(is_success(PAM_SUCCESS));
assert!(!is_failure(PAM_SUCCESS));
assert!(!is_success(PAM_AUTH_ERR));
}
#[test]
fn replace_flag_detected() {
let replace_status = PAM_AUTH_ERR | PAM_DATA_REPLACE;
assert!(is_replace(replace_status));
assert!(!is_replace(PAM_SUCCESS));
}
#[test]
fn display_path_default_is_set() {
assert!(!ahfail_pam::default_display_path().is_empty());
}
+8 -1
View File
@@ -16,8 +16,15 @@ glib = { version = "0.15", package = "glib" }
gio = { version = "0.15", package = "gio" }
gdk-pixbuf = "0.15"
rand = "0.8"
ureq = "2"
ureq = { version = "2", optional = true }
libc = "0.2"
[features]
# Checks Gitea for a newer release once a day and shows a desktop notification.
# Distro packages build without it (--no-default-features): there the package
# manager handles updates, and a locker should not phone home.
default = ["update-check"]
update-check = ["dep:ureq"]
[dev-dependencies]
tempfile = "3"
+23
View File
@@ -1,16 +1,29 @@
//! Optional update check (cargo feature `update-check`, on by default).
//! Without the feature `check_for_update` is a no-op and nothing touches the
//! network, which is how distro packages are built.
#[cfg(feature = "update-check")]
use std::path::PathBuf;
#[cfg(feature = "update-check")]
use std::process::Command;
#[cfg(feature = "update-check")]
use std::sync::atomic::{AtomicBool, Ordering};
#[cfg(feature = "update-check")]
use std::time::SystemTime;
#[cfg(feature = "update-check")]
use std::fs;
#[cfg(feature = "update-check")]
const GITEA_API: &str =
"https://gitea.weircon.dk/api/v1/repos/agw/gtk-ahfail/releases/latest";
#[cfg(feature = "update-check")]
const UPDATE_NOTIFY_MSG: &str =
"Update available — visit https://gitea.weircon.dk/agw/gtk-ahfail/releases";
#[cfg(feature = "update-check")]
const CHECK_INTERVAL_SECS: u64 = 86_400;
// Guards against multiple windows spawning duplicate checks on the same failure.
#[cfg(feature = "update-check")]
static CHECK_IN_FLIGHT: AtomicBool = AtomicBool::new(false);
pub fn is_newer(latest: &str, current: &str) -> bool {
@@ -26,6 +39,7 @@ pub fn is_newer(latest: &str, current: &str) -> bool {
}
}
#[cfg(feature = "update-check")]
fn cache_file() -> PathBuf {
let dir = std::env::var("HOME")
.map(|h| PathBuf::from(h).join(".cache/ahfail"))
@@ -37,6 +51,7 @@ fn cache_file() -> PathBuf {
dir.join("last_update_check")
}
#[cfg(feature = "update-check")]
fn rate_limited() -> bool {
let path = cache_file();
if let Ok(meta) = fs::metadata(&path) {
@@ -50,10 +65,12 @@ fn rate_limited() -> bool {
false
}
#[cfg(feature = "update-check")]
fn touch_cache() {
let _ = fs::write(cache_file(), b"");
}
#[cfg(feature = "update-check")]
fn send_notification() {
#[cfg(target_os = "linux")]
let _ = Command::new("notify-send")
@@ -69,6 +86,7 @@ fn send_notification() {
.status();
}
#[cfg(feature = "update-check")]
/// Run in a background thread. Checks Gitea for a newer release and sends a
/// desktop notification if one exists. Rate-limited to once per 24 hours.
/// Fails silently on any error.
@@ -104,6 +122,7 @@ pub fn check_for_update(current_version: &str) {
}
}
#[cfg(feature = "update-check")]
fn extract_tag_name(json: &str) -> Option<String> {
// Handle both compact ("tag_name":"v1.0") and spaced ("tag_name": "v1.0") JSON.
let key = "\"tag_name\"";
@@ -113,3 +132,7 @@ fn extract_tag_name(json: &str) -> Option<String> {
let end = rest.find('"')?;
Some(rest[..end].to_string())
}
/// Built without the `update-check` feature: never contacts the network.
#[cfg(not(feature = "update-check"))]
pub fn check_for_update(_current_version: &str) {}