midi-harbor/tests/app_store_mode.rs
2026-09-28 13:59:10 -05:00

309 lines
12 KiB
Rust

//! The App Store mode through the real binary, and the supervisor the App Store app runs its
//! daemon under (feature 014).
//!
//! The mode keys on `APP_SANDBOX_CONTAINER_ID`, which the App Sandbox sets before `main` and
//! nothing else does (research R-094), so setting it here, with `HOME` and `TMPDIR` pointed at a
//! scratch directory as the sandbox points them at the container, is the mode as the binary sees
//! it, without signing anything.
#![cfg(target_os = "macos")]
#![allow(clippy::expect_used, clippy::unwrap_used)]
use std::os::unix::net::UnixStream;
use std::path::{Path, PathBuf};
use std::process::{Command, Output};
use std::time::{Duration, Instant};
/// Returns an empty scratch directory for one test, kept short so a socket inside it stays well
/// within macOS's 103-byte limit.
fn scratch(label: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("mh-as-{label}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(dir.join("home")).unwrap();
std::fs::create_dir_all(dir.join("tmp")).unwrap();
dir
}
/// Builds a command for the binary as the App Sandbox would start it: the variable set, and
/// `HOME` and `TMPDIR` inside a container, here the scratch directory.
fn sandboxed(dir: &Path) -> Command {
let mut command = Command::new(env!("CARGO_BIN_EXE_midi-harbor"));
command
.env("APP_SANDBOX_CONTAINER_ID", "com.mrgeckosmedia.MidiHarbor")
.env("HOME", dir.join("home"))
// The sandbox's TMPDIR ends in a slash, as the container's did (R-094).
.env("TMPDIR", format!("{}/", dir.join("tmp").display()));
command
}
/// Runs a sandboxed command to completion.
fn run(dir: &Path, arguments: &[&str]) -> Output {
sandboxed(dir)
.args(arguments)
.output()
.expect("the binary runs")
}
/// Waits until something answers on `socket`, or `limit` passes.
fn answers_within(socket: &Path, limit: Duration) -> bool {
let start = Instant::now();
while start.elapsed() < limit {
if UnixStream::connect(socket).is_ok() {
return true;
}
std::thread::sleep(Duration::from_millis(100));
}
false
}
/// Proves that in the App Store build every `service` command is refused with exit 4, naming
/// "Start at login", while the direct build still reaches launchd (contracts/cli.md, FR-A03).
///
/// The sandbox refuses launchctl, and an agent plist written inside the container would never be
/// seen by launchd, so installing would appear to work and start nothing.
#[test]
fn the_app_store_build_refuses_service_commands() {
let dir = scratch("service");
for command in [&["service", "install"][..], &["service", "status"]] {
let output = run(&dir, command);
assert_eq!(
output.status.code(),
Some(4),
"{command:?} must exit 4, unavailable, in the App Store build: {output:?}"
);
assert!(
String::from_utf8_lossy(&output.stderr).contains("\"Start at login\""),
"{command:?} must point at Start at login instead: {output:?}"
);
}
let direct = Command::new(env!("CARGO_BIN_EXE_midi-harbor"))
.args(["service", "status"])
.env("HOME", dir.join("home"))
.env_remove("APP_SANDBOX_CONTAINER_ID")
.output()
.expect("the binary runs");
assert_ne!(
direct.status.code(),
Some(4),
"without the sandbox, service status must still ask launchd: {direct:?}"
);
let _ = std::fs::remove_dir_all(&dir);
}
/// Proves that the sandboxed daemon listens on `$TMPDIR/daemon.sock`, where a sandboxed command
/// line finds it with no `--socket`, reports service installation unavailable because this build
/// does not include it, and stops on `service stop`, asked over its socket since no service
/// manager runs it (R-096, R-097, FR-039c).
///
/// With the unsandboxed layout the socket path in a container passes macOS's 103-byte limit for
/// account names over 18 characters; without the directory, over 30. The App Store app's own
/// Quit stops a daemon it found running the same way, so the command line can do what the window
/// does.
#[test]
fn the_sandboxed_daemon_serves_from_the_container_tmp() {
let dir = scratch("daemon");
let socket = dir.join("tmp").join("daemon.sock");
let daemon = sandboxed(&dir)
.arg("daemon")
.spawn()
.expect("the daemon starts");
assert!(
answers_within(&socket, Duration::from_secs(20)),
"the sandboxed daemon must listen at {}",
socket.display()
);
let output = run(&dir, &["capabilities", "--json"]);
let stopped = run(&dir, &["service", "stop"]);
let mut daemon = daemon;
let exited = daemon.wait().expect("the daemon is waited for");
assert!(
stopped.status.success() && exited.success(),
"service stop must stop the sandboxed daemon through its graceful shutdown: \
{stopped:?}, the daemon exited {exited:?}"
);
assert!(
output.status.success(),
"a sandboxed command line must reach the daemon with no --socket: {output:?}"
);
let report: serde_json::Value =
serde_json::from_slice(&output.stdout).expect("capabilities prints JSON");
let service = report
.as_array()
.and_then(|all| {
all.iter()
.find(|capability| capability["name"] == "service installation")
})
.unwrap_or_else(|| panic!("the report must answer for the service manager: {report}"));
assert_eq!(
service["available"], false,
"the App Store build cannot install a service: {service}"
);
assert_eq!(
service["reason"], "this build does not include it",
"the reason must be that this build does not include it: {service}"
);
let _ = std::fs::remove_dir_all(&dir);
}
/// Returns the pids of the daemons serving `socket`, found by the scratch path in their
/// arguments, which no other process has.
fn daemons_on(socket: &Path) -> Vec<u32> {
let listed = Command::new("pgrep")
.args(["-f", &format!("{} daemon", socket.display())])
.output()
.expect("pgrep runs");
String::from_utf8_lossy(&listed.stdout)
.lines()
.filter_map(|line| line.trim().parse().ok())
.collect()
}
/// Waits until `pid` has exited, or `limit` passes.
fn gone_within(pid: u32, limit: Duration) -> bool {
let start = Instant::now();
while start.elapsed() < limit {
let alive = Command::new("kill")
.args(["-0", &pid.to_string()])
.status()
.is_ok_and(|status| status.success());
if !alive {
return true;
}
std::thread::sleep(Duration::from_millis(50));
}
false
}
/// Returns arguments that run the real binary as a daemon on `socket`, through env(1) so it has a
/// home of its own and never reads the user's configuration.
fn daemon_arguments(dir: &Path, socket: &Path) -> Vec<std::ffi::OsString> {
vec![
format!("HOME={}", dir.join("home").display()).into(),
env!("CARGO_BIN_EXE_midi-harbor").into(),
"--socket".into(),
socket.as_os_str().to_owned(),
"daemon".into(),
]
}
/// Proves that a supervisor told to stop ends its daemon through SIGTERM, within the daemon's own
/// grace rather than by the kill after it, and does not start it again (FR-A02, FR-A08, R-095).
///
/// The App Store app stops its daemon this way when the user quits. A supervisor that treated
/// the stopped daemon as failed would start it again two seconds later, and the user's quit
/// would leave MIDI running with no app to stop it.
#[test]
fn a_stopped_supervisor_leaves_no_daemon_running() {
let dir = scratch("supervised");
let socket = dir.join("tmp").join("daemon.sock");
let supervisor = midi_harbor_service::supervisor::Supervisor::start(
Path::new("/usr/bin/env"),
&daemon_arguments(&dir, &socket),
);
assert!(
answers_within(&socket, Duration::from_secs(20)),
"the supervised daemon must serve its socket"
);
let started = daemons_on(&socket);
assert_eq!(started.len(), 1, "one daemon must serve: {started:?}");
// A graceful stop takes about two seconds, the daemon's own grace for open requests; the
// kill after five would also leave no daemon, but would skip releasing held notes.
let stopping = Instant::now();
supervisor.stop(Duration::from_secs(5));
assert!(
stopping.elapsed() < Duration::from_secs(4),
"the daemon must stop on SIGTERM, not be killed after the grace: took {:?}",
stopping.elapsed()
);
std::thread::sleep(Duration::from_secs(3));
assert!(
UnixStream::connect(&socket).is_err() && daemons_on(&socket).is_empty(),
"no daemon may run after the supervisor was stopped, nor two seconds later"
);
let _ = std::fs::remove_dir_all(&dir);
}
/// Proves the App Store app's hold on its daemon: it starts one when none serves, uses one that
/// already serves rather than starting a second, gets a crashed one back, and stops one it found
/// running as surely as one it started (FR-A02, FR-A08, FR-A14).
///
/// A second daemon would open the same ports and sessions beside the first. A window relaunched
/// after a crash finds the daemon it left, and Quit must still stop it, since in the App Store
/// build nothing else will. The sandbox forbids signalling a daemon an earlier instance of the
/// app started, so that one is asked to stop over its socket (R-095).
#[cfg(feature = "gui")]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn the_app_owns_its_daemon_through_crashes_of_either() {
use midi_harbor_gui::app_store::DaemonOwner;
let dir = scratch("owner");
let socket = dir.join("tmp").join("daemon.sock");
let arguments = daemon_arguments(&dir, &socket);
let env = Path::new("/usr/bin/env");
// Nothing serves, so the app starts its daemon.
let first = DaemonOwner::start(env, &arguments, &socket)
.await
.expect("a daemon starts and serves");
assert!(
first.started(),
"with nothing serving, the app must start a daemon"
);
let [started] = daemons_on(&socket)[..] else {
panic!("one daemon must serve: {:?}", daemons_on(&socket));
};
// A daemon that crashes is started again. The killed one answers until it has gone.
let _ = Command::new("kill")
.args(["-9", &started.to_string()])
.status();
assert!(
gone_within(started, Duration::from_secs(5)),
"the killed daemon never exited"
);
assert!(
answers_within(&socket, Duration::from_secs(10)),
"a daemon that crashed must be started again after the supervisor's two seconds"
);
let [restarted] = daemons_on(&socket)[..] else {
panic!(
"one daemon must serve after the restart: {:?}",
daemons_on(&socket)
);
};
assert_ne!(
restarted, started,
"the daemon serving must be a new process"
);
// A second app, as after a window crash, uses the daemon serving and starts none.
let second = DaemonOwner::start(env, &arguments, &socket)
.await
.expect("the running daemon is found");
assert!(
!second.started(),
"a daemon already serving must be used, not a second started"
);
assert_eq!(
daemons_on(&socket),
vec![restarted],
"attaching must leave the same daemon serving, and only it"
);
// Quitting that app stops the daemon it found, which ends the first app's supervision too.
second.stop().await;
assert!(
gone_within(restarted, Duration::from_secs(5)),
"the daemon the app found must stop when it quits"
);
tokio::time::sleep(Duration::from_secs(3)).await;
assert!(
daemons_on(&socket).is_empty(),
"a daemon asked to stop must not be started again by the first app's supervisor"
);
first.stop().await;
let _ = std::fs::remove_dir_all(&dir);
}