#![cfg(all(feature = "embedded", feature = "ipc", feature = "web", unix))]
use std::io::{BufRead, BufReader, Read, Write};
use std::net::{TcpListener, TcpStream};
use std::path::{Path, PathBuf};
use std::process::{Child, Command, Stdio};
use std::sync::mpsc;
use std::time::Duration;
const PATIENCE: Duration = Duration::from_secs(90);
const TRIES: usize = 1800;
const GAP: Duration = Duration::from_millis(50);
struct Door(Child);
impl Drop for Door {
fn drop(&mut self) {
let _ = self.0.kill();
let _ = self.0.wait();
}
}
fn scratch(case: &str, lines: &[&str]) -> PathBuf {
let dir = std::env::temp_dir().join(format!("iki-hp-{}-{case}", std::process::id()));
let config = dir.join("config").join("ikigai");
std::fs::create_dir_all(&config).expect("scratch config home");
let body: String = lines
.iter()
.map(|line| format!("mount = \"{line}\"\n"))
.collect();
std::fs::write(config.join("config.toml"), body).expect("scratch config.toml");
dir
}
fn spawn(home: &Path, args: &[&str]) -> Command {
let mut command = Command::new(env!("CARGO_BIN_EXE_ikigai"));
command
.args(args)
.env("HOME", home)
.env("XDG_CONFIG_HOME", home.join("config"))
.env("IKIGAI_FILES", home.join("files"));
command
}
fn start(home: &Path, args: &[&str], ready: &'static str) -> (Door, String) {
let mut child = spawn(home, args)
.stdout(Stdio::null())
.stderr(Stdio::piped())
.spawn()
.expect("the binary under test starts");
let stderr = child.stderr.take().expect("piped stderr");
let (tx, rx) = mpsc::channel();
std::thread::spawn(move || {
let mut seen = String::new();
for line in BufReader::new(stderr).lines().map_while(Result::ok) {
let done = line.contains(ready);
seen.push_str(&line);
seen.push('\n');
if done {
break;
}
}
let _ = tx.send(seen);
});
let door = Door(child);
let seen = rx
.recv_timeout(PATIENCE)
.unwrap_or_else(|e| panic!("`{args:?}` never printed `{ready}` in {PATIENCE:?}: {e}"));
(door, seen)
}
fn wait_for_socket(path: &str) {
for _ in 0..TRIES {
if Path::new(path).exists() {
return;
}
std::thread::sleep(GAP);
}
panic!("`{path}` was never bound within {PATIENCE:?}");
}
fn ask(home: &Path, connect: &str, command: &str) -> String {
let out = spawn(home, &["--connect", connect, "--plain", "-c", command])
.output()
.expect("the client runs");
assert!(
out.status.success(),
"`{command}` failed: {}{}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
String::from_utf8_lossy(&out.stdout).to_string()
}
fn free_port() -> u16 {
TcpListener::bind("127.0.0.1:0")
.expect("a loopback port")
.local_addr()
.expect("its address")
.port()
}
fn get(port: u16, path: &str) -> (String, String) {
let mut last = None;
let mut connected = None;
for _ in 0..TRIES {
match TcpStream::connect(("127.0.0.1", port)) {
Ok(stream) => {
connected = Some(stream);
break;
}
Err(e) => {
last = Some(e);
std::thread::sleep(GAP);
}
}
}
let mut stream = connected.unwrap_or_else(|| {
panic!("nothing accepted on {port} within {PATIENCE:?}: {last:?}");
});
stream
.write_all(
format!("GET {path} HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n")
.as_bytes(),
)
.expect("the request is written");
let mut raw = String::new();
stream
.read_to_string(&mut raw)
.expect("the response is read");
let (head, body) = raw.split_once("\r\n\r\n").unwrap_or((raw.as_str(), ""));
let status = head.lines().next().unwrap_or_default().to_string();
(status, body.to_string())
}
#[test]
fn a_serving_door_reports_the_mounts_it_composed() {
let home = scratch(
"ipc",
&[
"prefer urn:iki:store:=/tmp/iki-hp-absent-a.sock",
"prefer urn:cal:=/tmp/iki-hp-absent-b.sock",
],
);
let sock = format!("/tmp/iki-hp-{}.sock", std::process::id());
let _ = std::fs::remove_file(&sock);
let (_door, banner) = start(&home, &["serve", &sock], "serving on");
wait_for_socket(&sock);
let text = ask(&home, &sock, "source urn:host:posture");
assert!(text.contains(&format!("door {sock}")), "{text}");
assert!(
text.contains("mount prefer urn:iki:store: -> /tmp/iki-hp-absent-a.sock"),
"the first config line must be named: {text}"
);
assert!(
text.contains("mount prefer urn:cal: -> /tmp/iki-hp-absent-b.sock"),
"the second config line, with its own prefix and target: {text}"
);
assert!(text.contains("as of STARTUP"), "{text}");
assert!(
text.contains("re-read of the config home"),
"the report must say it is the PROCESS's composition, not the file's: {text}"
);
assert!(text.contains("reloads nothing"), "{text}");
for line in banner.lines().filter_map(|l| l.strip_prefix("ikigai: ")) {
if line.starts_with("mount ") {
assert!(
text.contains(line),
"the banner said `{line}` and the resource does not: {text}"
);
}
}
let turtle = ask(&home, &sock, "source urn:host:posture as=text/turtle");
assert!(
turtle.contains("<urn:host:posture> a ik:Posture"),
"{turtle}"
);
assert!(
turtle.contains("ik:mountPrefix \"urn:iki:store:\""),
"{turtle}"
);
assert!(
turtle.contains("ik:mountTarget \"/tmp/iki-hp-absent-b.sock\""),
"{turtle}"
);
assert!(turtle.contains("ik:asOf \"startup\""), "{turtle}");
assert!(!turtle.contains("_:"), "no blank nodes: {turtle}");
}
#[test]
fn a_declined_topology_is_reported_as_a_decline_not_as_emptiness() {
let home = scratch(
"declined",
&["prefer urn:iki:store:=/tmp/iki-hp-absent-c.sock"],
);
let sock = format!("/tmp/iki-hp-d{}.sock", std::process::id());
let _ = std::fs::remove_file(&sock);
let (_door, _) = start(&home, &["serve", &sock, "--no-config-mounts"], "serving on");
wait_for_socket(&sock);
let text = ask(&home, &sock, "source urn:host:posture");
assert!(
text.contains("mount declined (--no-config-mounts)"),
"{text}"
);
assert!(
!text.contains("urn:iki:store:"),
"a declined door composed NOTHING and must name no mount: {text}"
);
let turtle = ask(&home, &sock, "source urn:host:posture as=text/turtle");
assert!(turtle.contains("ik:mountPosture \"declined\""), "{turtle}");
assert!(
!turtle.contains("ik:mount <"),
"nothing to point at: {turtle}"
);
}
#[test]
fn the_public_http_door_refuses_posture_and_a_granted_one_serves_it() {
let home = scratch("http", &["prefer urn:iki:store:=/tmp/iki-hp-absent-d.sock"]);
let public = free_port();
let (_door, _) = start(
&home,
&["serve", "--http", &public.to_string()],
"serving HTTP on",
);
let (status, body) = get(public, "/host/posture");
assert!(
status.contains("403"),
"a public edge must not hand a stranger its topology: {status} {body}"
);
assert!(
body.contains("urn:cap:host:posture"),
"the refusal names the scope that would open it: {body}"
);
assert!(
!body.contains("iki-hp-absent-d.sock"),
"and it leaks no path while refusing: {body}"
);
let granted = free_port();
let (_door, _) = start(
&home,
&[
"serve",
"--http",
&granted.to_string(),
"--cap",
"urn:cap:host:posture",
],
"serving HTTP on",
);
let (status, body) = get(granted, "/host/posture");
assert!(status.contains("200"), "{status} {body}");
assert!(
body.contains(&format!("door http://127.0.0.1:{granted}")),
"{body}"
);
assert!(
body.contains("mount prefer urn:iki:store: -> /tmp/iki-hp-absent-d.sock"),
"{body}"
);
}