#![cfg(all(feature = "embedded", feature = "ipc", feature = "web", unix))]
use std::io::{BufRead, BufReader, Read, Write};
use std::net::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);
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 assert_bound(path: &str) {
assert!(
Path::new(path).exists(),
"`{path}` must be bound when the banner prints: the banner is the readiness signal"
);
}
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 bound_port(banner: &str) -> u16 {
let port = banner
.lines()
.find_map(|line| line.split("serving HTTP on ").nth(1))
.and_then(|rest| rest.split_whitespace().next())
.and_then(|addr| addr.parse::<std::net::SocketAddr>().ok())
.map(|addr| addr.port())
.unwrap_or_else(|| panic!("no bound address on the banner: {banner}"));
assert_ne!(
port, 0,
"the banner must name the port the door BOUND, not the one it was asked for: {banner}"
);
port
}
fn get(port: u16, path: &str) -> (String, String) {
let mut stream = TcpStream::connect(("127.0.0.1", port))
.unwrap_or_else(|e| panic!("nothing accepted on {port}: {e}"));
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");
assert_bound(&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");
assert_bound(&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 (_door, banner) = start(&home, &["serve", "--http", "0"], "serving HTTP on");
let public = bound_port(&banner);
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 (_door, banner) = start(
&home,
&["serve", "--http", "0", "--cap", "urn:cap:host:posture"],
"serving HTTP on",
);
let granted = bound_port(&banner);
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}"
);
}
#[test]
fn each_door_names_its_own_nature_in_host_info() {
let home = scratch("nature", &[]);
let sock = format!("/tmp/iki-hn{}.sock", std::process::id());
let _ = std::fs::remove_file(&sock);
let (_door, _) = start(&home, &["serve", &sock], "serving on");
assert_bound(&sock);
let remote = ask(&home, &sock, "source urn:host:info");
assert!(remote.contains("nature Remote (IPC)"), "{remote}");
assert!(!remote.contains("Embedded"), "{remote}");
let out = spawn(&home, &["--plain", "-c", "source urn:host:info"])
.output()
.expect("the binary runs in process");
let local = String::from_utf8_lossy(&out.stdout);
assert!(out.status.success(), "{local}");
assert!(local.contains("nature Embedded (Native)"), "{local}");
let (_http, banner) = start(&home, &["serve", "--http", "0"], "serving HTTP on");
let (status, body) = get(bound_port(&banner), "/host/info");
assert!(status.contains("200"), "{status} {body}");
assert!(body.contains("nature Remote (HTTP)"), "{body}");
let _ = std::fs::remove_file(&sock);
}