use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use serde::{Deserialize, Serialize};
use serde_json::json;
use sha2::{Digest, Sha256};
use axum::response::IntoResponse;
use axum::Json;
pub fn runtime_key(advertise_url: &str) -> String {
let mut h = Sha256::new();
h.update(advertise_url.as_bytes());
hex(&h.finalize())
}
fn hex(bytes: &[u8]) -> String {
bytes.iter().map(|b| format!("{b:02x}")).collect()
}
pub fn redact_payload(payload: &serde_json::Value) -> serde_json::Value {
let mut out = payload.clone();
if let Some(profiles) = out.get_mut("profiles").and_then(|v| v.as_array_mut()) {
for p in profiles.iter_mut() {
if let Some(secret) = p.get("secrets").and_then(|v| v.as_str()) {
let len = secret.len();
p["secrets"] = serde_json::json!(if len > 0 {
format!("<redacted:{len} chars>")
} else {
String::new()
});
}
}
}
out
}
fn bound_signature(profiles: &[serde_json::Value]) -> String {
let mut parts: Vec<String> = profiles
.iter()
.filter_map(|p| {
let pid = p.get("profile_id").and_then(|v| v.as_str())?;
let ds = p
.get("desired_state")
.and_then(|v| v.as_str())
.unwrap_or("?");
Some(format!("{pid}:{ds}"))
})
.collect();
parts.sort();
parts.join(",")
}
fn origin_of(url: &str) -> Option<String> {
let u = url::Url::parse(url.trim()).ok()?;
let scheme = u.scheme();
if scheme != "http" && scheme != "https" {
return None;
}
let host = u.host_str()?;
let host = if host.contains(':') {
format!("[{host}]")
} else {
host.to_string()
};
let port = u.port().map(|p| format!(":{p}")).unwrap_or_default();
Some(format!("{scheme}://{host}{port}"))
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct RuntimeCredential {
pub runtime_key: String,
pub secret: String,
}
pub fn default_credential_path() -> PathBuf {
dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join(".trustee")
.join("thq")
.join("runtime.json")
}
fn write_private(path: &std::path::Path, bytes: &[u8]) -> std::io::Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(parent, std::fs::Permissions::from_mode(0o700));
}
}
std::fs::write(path, bytes)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))?;
}
Ok(())
}
#[derive(Debug, Clone)]
pub struct ThqConfig {
pub thq_url: String,
pub instance_id: String,
pub advertise_url: String,
pub runtime_name: String,
pub heartbeat_interval: u64,
}
const KNOWN_KEYS: [&str; 5] = [
"thq_url",
"instance_id",
"advertise_url",
"runtime_name",
"heartbeat_interval",
];
impl ThqConfig {
pub fn from_toml(config_toml: &str) -> Result<Option<Self>, String> {
let table: toml::Table =
toml::from_str(config_toml).map_err(|e| format!("config is not valid TOML: {e}"))?;
let Some(thq) = table.get("thq").and_then(|v| v.as_table()) else {
return Ok(None); };
for k in thq.keys() {
if !KNOWN_KEYS.contains(&k.as_str()) {
return Err(format!(
"unknown key [thq].{k} — the enrollment config is exactly \
thq_url / instance_id / advertise_url / runtime_name / \
heartbeat_interval (the torpi-era registration keys were \
REMOVED in 0.15.0; migrate the config)"
));
}
}
let req = |key: &str| -> Result<String, String> {
let v = thq
.get(key)
.and_then(|v| v.as_str())
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.ok_or_else(|| format!("[thq].{key} is required (string, non-empty)"))?;
Ok(v)
};
let raw_thq_url = req("thq_url")?;
let trimmed = raw_thq_url.trim_end_matches('/');
let normalized = origin_of(trimmed).ok_or_else(|| {
format!(
"[thq].thq_url must be an http(s) ORIGIN (scheme://host[:port]) — got \
{raw_thq_url:?}: no path, no query, no fragment (a ?instance= here \
is the 405 bug; the leaf goes in [thq].instance_id)"
)
})?;
let parsed = url::Url::parse(trimmed).map_err(|e| format!("[thq].thq_url: {e}"))?;
if parsed.path() != "/" || parsed.query().is_some() || parsed.fragment().is_some() {
return Err(format!(
"[thq].thq_url must be an ORIGIN — got {raw_thq_url:?}: no path/query/fragment \
allowed (the THQ leaf belongs in [thq].instance_id)"
));
}
let instance_id = req("instance_id")?;
let advertise_url_raw = req("advertise_url")?;
if !advertise_url_raw.starts_with("http") {
return Err(format!(
"[thq].advertise_url must be an http(s) URL (THQ calls back to it) — got \
{advertise_url_raw:?}"
));
}
let advertise_url = advertise_url_raw.trim_end_matches('/').to_string();
let runtime_name = req("runtime_name")?;
let heartbeat_interval = match thq.get("heartbeat_interval") {
None => 30,
Some(v) => {
let n = v
.as_integer()
.ok_or("[thq].heartbeat_interval must be an integer (seconds)")?;
if n < 1 {
return Err("[thq].heartbeat_interval must be >= 1 second".to_string());
}
n as u64
}
};
Ok(Some(Self {
thq_url: normalized,
instance_id,
advertise_url,
runtime_name,
heartbeat_interval,
}))
}
}
#[derive(Clone)]
pub struct Enrollment(Arc<EnrollmentInner>);
struct EnrollmentInner {
config: ThqConfig,
cred_path: PathBuf,
home: PathBuf,
notify: tokio::sync::Notify,
}
impl Enrollment {
pub fn new(config: ThqConfig) -> Self {
Self::with_cred_path(config, default_credential_path())
}
pub fn with_cred_path(config: ThqConfig, cred_path: PathBuf) -> Self {
let home = cred_path
.parent()
.and_then(|p| p.parent())
.map(|p| p.to_path_buf())
.unwrap_or_else(|| PathBuf::from("."));
Self(Arc::new(EnrollmentInner {
config,
cred_path,
home,
notify: tokio::sync::Notify::new(),
}))
}
fn key(&self) -> String {
runtime_key(&self.0.config.advertise_url)
}
fn load_credential(&self) -> Option<RuntimeCredential> {
let raw = std::fs::read_to_string(&self.0.cred_path).ok()?;
match serde_json::from_str(&raw) {
Ok(c) => Some(c),
Err(e) => {
tracing::warn!(
"THQ credential {} is unreadable ({e}) — will re-register",
self.0.cred_path.display()
);
None
}
}
}
fn valid_credential(&self) -> Option<RuntimeCredential> {
let c = self.load_credential()?;
if c.runtime_key == self.key() {
Some(c)
} else {
tracing::warn!(
"THQ credential at {} was minted for a different advertise_url \
(key mismatch) — re-registering",
self.0.cred_path.display()
);
None
}
}
fn forget_credential(&self) {
if let Err(e) = std::fs::remove_file(&self.0.cred_path) {
if e.kind() != std::io::ErrorKind::NotFound {
tracing::warn!(
"THQ: cannot drop credential {}: {e}",
self.0.cred_path.display()
);
}
}
}
fn save_credential(&self, cred: &RuntimeCredential) -> std::io::Result<()> {
let bytes = serde_json::to_vec_pretty(cred).expect("credential serializes");
write_private(&self.0.cred_path, &bytes)
}
pub fn spawn(self, state: crate::state::ServerState) {
let enrollment = self.clone();
let client = build_http_client();
tokio::spawn(async move {
let cfg = enrollment.0.config.clone();
let key = enrollment.key();
tracing::info!(
"THQ enrollment lane: runtime \"{}\" (key {}…) -> {} instance {} — \
credential at {}",
cfg.runtime_name,
&key[..16],
cfg.thq_url,
cfg.instance_id,
enrollment.0.cred_path.display()
);
let interval = Duration::from_secs(cfg.heartbeat_interval.max(1));
let mut last_bound: Option<String> = None;
let mut consecutive_registers: u32 = 0;
loop {
match enrollment.valid_credential() {
Some(cred) => {
consecutive_registers = 0;
match pull(&client, &cfg, &cred).await {
PullOutcome::Ok(payload) => {
let total = payload
.get("total")
.and_then(|v| v.as_u64())
.unwrap_or_default();
let profiles = payload
.get("profiles")
.and_then(|v| v.as_array())
.cloned()
.unwrap_or_default();
let signature = bound_signature(&profiles);
if last_bound.as_deref() != Some(signature.as_str()) {
tracing::info!(
target: "thq",
"THQ pull: {total} profile(s) bound [{signature}]"
);
last_bound = Some(signature);
}
tracing::debug!(
target: "thq",
"THQ pull payload (secrets REDACTED): {}",
redact_payload(&payload)
);
let report =
crate::materialize::apply_profiles(&enrollment.0.home, &payload);
crate::thq_dispatch::refresh_after_apply(
&state.thq_dispatch,
&enrollment.0.home,
&report,
);
let applied = report.applied().count();
if applied > 0 {
tracing::info!(target: "thq", "THQ apply: {applied} profile(s) materialized");
}
for pr in &report.profiles {
if let crate::materialize::ProfileOutcome::Failed { reason } =
&pr.outcome
{
tracing::warn!(target: "thq", "THQ apply: profile {} FAILED: {reason}", pr.profile_id);
}
}
for p in &profiles {
let Some(pid) = p.get("profile_id").and_then(|v| v.as_str()) else {
continue;
};
let agent_busy = agent_busy(&state, pid).await;
let observed = report.observed_for(pid, agent_busy);
match report_state(&client, &cfg, &cred, pid, &observed).await {
StateOutcome::Ok => {}
StateOutcome::Forbidden => {
tracing::warn!(
"THQ state report 403 on profile {pid} — \
credential rotated; re-registering (self-heal)"
);
enrollment.forget_credential();
break;
}
StateOutcome::Failed(e) => {
tracing::warn!("THQ state report failed ({pid}): {e}")
}
}
}
tokio::time::sleep(interval).await;
}
PullOutcome::Forbidden => {
tracing::warn!(
"THQ pull 403 — secret rotated or revoked; re-registering \
(self-heal)"
);
enrollment.forget_credential();
}
PullOutcome::Failed(e) => {
tracing::warn!("THQ pull failed (will retry): {e}");
tokio::time::sleep(interval).await;
}
}
}
None => {
consecutive_registers = consecutive_registers.saturating_add(1);
let backoff = register_backoff(interval, consecutive_registers);
if backoff > interval {
tracing::warn!(
"THQ: no usable credential — register #{consecutive_registers}, backing off {backoff:?}"
);
}
register_once(&client, &cfg).await;
let notified = enrollment.0.notify.notified();
tokio::select! {
_ = notified => {}
_ = tokio::time::sleep(backoff) => {}
}
}
}
}
});
}
}
fn build_http_client() -> reqwest::Client {
reqwest::Client::builder()
.danger_accept_invalid_certs(true)
.timeout(Duration::from_secs(10))
.build()
.expect("Failed to build HTTP client for THQ enrollment")
}
const GRACE_REGISTERS: u32 = 5;
const MAX_REGISTER_BACKOFF: Duration = Duration::from_secs(600);
fn register_backoff(interval: Duration, consecutive: u32) -> Duration {
if consecutive <= GRACE_REGISTERS {
interval
} else {
let shift = (consecutive - GRACE_REGISTERS).min(4);
interval
.saturating_mul(1u32 << shift)
.min(MAX_REGISTER_BACKOFF)
}
}
#[derive(Debug, Clone, Deserialize)]
pub struct EnrollPayload {
pub thq_url: String,
pub runtime_id: String,
pub instance_id: String,
pub secret: String,
}
async fn handle_enroll(
axum::Extension(enrollment): axum::Extension<Enrollment>,
Json(payload): Json<EnrollPayload>,
) -> impl IntoResponse {
use axum::http::StatusCode;
let cfg = &enrollment.0.config;
let short = payload.runtime_id.chars().take(16).collect::<String>();
if payload.runtime_id != enrollment.key() {
tracing::warn!(
"THQ enroll REJECTED: runtime_id {short}… is not this process \
(advertise_url key mismatch)"
);
return (
StatusCode::FORBIDDEN,
Json(json!({"error": "runtime_id mismatch — not this runtime"})),
);
}
match origin_of(&payload.thq_url) {
Some(o) if o == cfg.thq_url => {}
other => {
tracing::error!(
"THQ enroll REJECTED (rogue-THQ guard): callback origin {other:?} \
is not the configured thq_url {:?}",
cfg.thq_url
);
return (
StatusCode::FORBIDDEN,
Json(json!({"error": "thq_url origin mismatch — not my THQ"})),
);
}
}
if payload.instance_id != cfg.instance_id {
tracing::error!(
"THQ enroll REJECTED: instance {} is not the configured leaf {}",
payload.instance_id,
cfg.instance_id
);
return (
StatusCode::FORBIDDEN,
Json(json!({"error": "instance_id mismatch"})),
);
}
let cred = RuntimeCredential {
runtime_key: payload.runtime_id,
secret: payload.secret,
};
if let Err(e) = enrollment.save_credential(&cred) {
tracing::error!(
"THQ enroll: cannot persist credential to {}: {e}",
enrollment.0.cred_path.display()
);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "credential persistence failed"})),
);
}
enrollment.0.notify.notify_waiters();
tracing::info!(
target: "thq",
"THQ enrollment secret received from {} — runtime {} enrolled (key {short}…)",
cfg.thq_url,
cfg.runtime_name
);
(StatusCode::OK, Json(json!({"status": "enrolled"})))
}
pub fn enroll_route<S: Clone + Send + Sync + 'static>(enrollment: Enrollment) -> axum::Router<S> {
axum::Router::new()
.route("/thq/enroll", axum::routing::post(handle_enroll))
.layer(axum::Extension(enrollment))
}
fn register_payload(cfg: &ThqConfig, version: &str) -> serde_json::Value {
json!({
"name": cfg.runtime_name,
"advertise_url": cfg.advertise_url,
"version": version,
})
}
fn state_payload(observed: &str, version: &str) -> serde_json::Value {
json!({
"observed_state": observed,
"version": version,
})
}
async fn register_once(client: &reqwest::Client, cfg: &ThqConfig) {
let url = format!("{}/api/v1/runtime/register", cfg.thq_url);
let body = register_payload(cfg, crate::bin_version());
match client
.post(&url)
.header("X-Instance-Id", &cfg.instance_id)
.json(&body)
.send()
.await
{
Ok(resp) => {
let status = resp.status();
let text = resp.text().await.unwrap_or_default();
if status.is_success() {
tracing::info!(target: "thq", "THQ register accepted ({status}): {text}");
} else {
tracing::warn!("THQ register rejected ({status}): {}", truncate(&text, 300));
}
}
Err(e) => tracing::warn!("THQ register unreachable (will retry): {e}"),
}
}
enum PullOutcome {
Ok(serde_json::Value),
Forbidden,
Failed(String),
}
async fn pull(client: &reqwest::Client, cfg: &ThqConfig, cred: &RuntimeCredential) -> PullOutcome {
let url = format!("{}/api/v1/runtime/profiles?secrets=1", cfg.thq_url);
match client
.get(&url)
.header("X-Instance-Id", &cfg.instance_id)
.header("X-Runtime-Id", &cred.runtime_key)
.header("X-Runtime-Secret", &cred.secret)
.send()
.await
{
Ok(resp) => {
let status = resp.status();
if status.as_u16() == 403 {
return PullOutcome::Forbidden;
}
if !status.is_success() {
let text = resp.text().await.unwrap_or_default();
return PullOutcome::Failed(format!("{status}: {}", truncate(&text, 300)));
}
match resp.json::<serde_json::Value>().await {
Ok(v) => PullOutcome::Ok(v),
Err(e) => PullOutcome::Failed(format!("payload is not JSON: {e}")),
}
}
Err(e) => PullOutcome::Failed(e.to_string()),
}
}
enum StateOutcome {
Ok,
Forbidden,
Failed(String),
}
async fn report_state(
client: &reqwest::Client,
cfg: &ThqConfig,
cred: &RuntimeCredential,
profile_id: &str,
observed: &str,
) -> StateOutcome {
let url = format!("{}/api/v1/runtime/profiles/{profile_id}/state", cfg.thq_url);
let body = state_payload(observed, crate::bin_version());
match client
.post(&url)
.header("X-Instance-Id", &cfg.instance_id)
.header("X-Runtime-Id", &cred.runtime_key)
.header("X-Runtime-Secret", &cred.secret)
.json(&body)
.send()
.await
{
Ok(resp) => {
let status = resp.status();
if status.as_u16() == 403 {
return StateOutcome::Forbidden;
}
if status.is_success() {
StateOutcome::Ok
} else {
let text = resp.text().await.unwrap_or_default();
StateOutcome::Failed(format!("{status}: {}", truncate(&text, 300)))
}
}
Err(e) => StateOutcome::Failed(e.to_string()),
}
}
fn truncate(s: &str, n: usize) -> String {
s.chars().take(n).collect()
}
async fn agent_busy(state: &crate::state::ServerState, profile_id: &str) -> bool {
let namespace = trustee_core::user_hash(profile_id);
let mut ours = Vec::new();
for user in state.sessions.iter() {
for entry in user.value().sessions.iter() {
let session = entry.value().session.clone();
let is_ours = session
.lock()
.await
.home_dir
.as_ref()
.map(|h| h.to_string_lossy().contains(&namespace))
.unwrap_or(false);
if is_ours {
ours.push(session);
}
}
}
for session in ours {
if session.lock().await.workflow_state == trustee_core::types::WorkflowState::Running {
return true;
}
}
false
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn bound_signature_is_order_independent_and_stable() {
let a = serde_json::json!({"profile_id":"b-id","desired_state":"running"});
let b = serde_json::json!({"profile_id":"a-id","desired_state":"stopped"});
assert_eq!(
bound_signature(&[a.clone(), b.clone()]),
bound_signature(&[b, a]),
"signature must not depend on payload order"
);
}
#[test]
fn bound_signature_changes_when_desired_state_flips() {
let running = serde_json::json!({"profile_id":"p","desired_state":"running"});
let stopped = serde_json::json!({"profile_id":"p","desired_state":"stopped"});
assert_ne!(
bound_signature(&[running]),
bound_signature(&[stopped]),
"a desired_state flip must re-trigger the INFO summary"
);
}
#[test]
fn bound_signature_skips_idless_profiles_and_defaults_unknown_state() {
let no_id = serde_json::json!({"desired_state":"running"});
let no_state = serde_json::json!({"profile_id":"p"});
assert_eq!(bound_signature(&[no_id.clone(), no_state]), "p:?");
assert_eq!(bound_signature(&[]), "");
assert_eq!(bound_signature(&[no_id]), "");
}
fn tmp(tag: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("trustee-thq3-{}-{}", tag, std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
dir
}
fn full_toml() -> String {
r#"
[thq]
thq_url = "https://thq.tanbal.ir"
instance_id = "6f635fe4-4a06-42b0-8a62-276383488a9c"
advertise_url = "https://10.99.0.11:3000"
runtime_name = "nox"
heartbeat_interval = 30
"#
.to_string()
}
#[test]
fn register_payload_carries_the_given_version_not_the_crate_env() {
let cfg = ThqConfig::from_toml(&full_toml())
.expect("parse ok")
.expect("section present");
let body = register_payload(&cfg, "0.23.6-sentinel");
assert_eq!(body["version"], "0.23.6-sentinel");
assert_ne!(
body["version"], env!("CARGO_PKG_VERSION"),
"the payload must never carry this crate's version when the bin's is given"
);
assert_eq!(body["name"], "nox");
assert_eq!(body["advertise_url"], "https://10.99.0.11:3000");
}
#[test]
fn state_payload_carries_the_given_version_not_the_crate_env() {
let body = state_payload("running", "0.23.6-sentinel");
assert_eq!(body["version"], "0.23.6-sentinel");
assert_ne!(
body["version"], env!("CARGO_PKG_VERSION"),
"the state report must never carry this crate's version when the bin's is given"
);
assert_eq!(body["observed_state"], "running");
}
#[test]
fn production_payloads_read_the_bin_version_source() {
let cfg = ThqConfig::from_toml(&full_toml())
.expect("parse ok")
.expect("section present");
assert_eq!(
register_payload(&cfg, crate::bin_version())["version"],
crate::bin_version()
);
assert_eq!(
state_payload("idle", crate::bin_version())["version"],
crate::bin_version()
);
}
#[test]
fn parse_full_config() {
let cfg = ThqConfig::from_toml(&full_toml())
.expect("parse ok")
.expect("section present");
assert_eq!(cfg.thq_url, "https://thq.tanbal.ir");
assert_eq!(cfg.instance_id, "6f635fe4-4a06-42b0-8a62-276383488a9c");
assert_eq!(cfg.advertise_url, "https://10.99.0.11:3000");
assert_eq!(cfg.runtime_name, "nox");
assert_eq!(cfg.heartbeat_interval, 30);
}
#[test]
fn parse_defaults_heartbeat_only() {
let toml = r#"
[thq]
thq_url = "https://thq.example.com"
instance_id = "leaf-uuid"
advertise_url = "https://10.0.0.5:3000"
runtime_name = "edge"
"#;
let cfg = ThqConfig::from_toml(toml).unwrap().unwrap();
assert_eq!(cfg.heartbeat_interval, 30, "default cadence");
}
#[test]
fn no_section_is_lane_off() {
assert!(ThqConfig::from_toml("[oidc]\nissuer_url = \"x\"\n")
.unwrap()
.is_none());
}
#[test]
fn missing_required_keys_are_loud() {
for key in ["thq_url", "instance_id", "advertise_url", "runtime_name"] {
let broken: String = full_toml()
.lines()
.filter(|l| !l.starts_with(&format!("{key} =")))
.collect::<Vec<_>>()
.join("\n");
let err = ThqConfig::from_toml(&broken).expect_err("must be loud");
assert!(
err.contains(key),
"error must name the missing key {key}: {err}"
);
}
}
#[test]
fn thq_url_with_instance_query_is_rejected() {
let toml = full_toml().replacen(
"thq_url = \"https://thq.tanbal.ir\"",
"thq_url = \"https://thq.tanbal.ir/?instance=6f635fe4-4a06-42b0-8a62-276383488a9c\"",
1,
);
let err = ThqConfig::from_toml(&toml).expect_err("query in thq_url must fail loud");
assert!(
err.contains("instance_id"),
"error must redirect to instance_id: {err}"
);
}
#[test]
fn thq_url_with_path_is_rejected() {
let toml = full_toml().replacen(
"thq_url = \"https://thq.tanbal.ir\"",
"thq_url = \"https://thq.tanbal.ir/api/v2\"",
1,
);
assert!(ThqConfig::from_toml(&toml).is_err());
}
#[test]
fn thq_url_trailing_slash_is_fine() {
let toml = full_toml().replacen(
"thq_url = \"https://thq.tanbal.ir\"",
"thq_url = \"https://thq.tanbal.ir/\"",
1,
);
let cfg = ThqConfig::from_toml(&toml).unwrap().unwrap();
assert_eq!(cfg.thq_url, "https://thq.tanbal.ir", "normalized to origin");
}
#[test]
fn registration_era_keys_are_rejected() {
let old_style = r#"
[thq]
torpi_url = "https://torpi.tanbal.ir"
advertise_url = "https://10.0.0.5:3000"
agent_name = "nox"
"#;
let err = ThqConfig::from_toml(old_style).expect_err("must be loud");
assert!(err.contains("thq_url"), "must name thq_url: {err}");
let leftover = format!("{}\ntorpi_url = \"https://x.example\"", full_toml());
let err = ThqConfig::from_toml(&leftover).expect_err("leftovers must fail loud");
assert!(
err.contains("torpi_url"),
"error must name the leftover key: {err}"
);
let leftover_agent = format!("{}\nagent_name = \"nox\"", full_toml());
assert!(ThqConfig::from_toml(&leftover_agent).is_err());
}
#[test]
fn heartbeat_zero_is_rejected() {
let toml = full_toml().replacen("heartbeat_interval = 30", "heartbeat_interval = 0", 1);
assert!(ThqConfig::from_toml(&toml).is_err());
}
#[test]
fn advertise_url_must_be_http() {
let toml = full_toml().replacen(
"advertise_url = \"https://10.99.0.11:3000\"",
"advertise_url = \"tcp://10.99.0.11:3000\"",
1,
);
assert!(ThqConfig::from_toml(&toml).is_err());
}
#[test]
fn runtime_key_matches_sha256() {
assert_eq!(
runtime_key("abc"),
"ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
);
assert_eq!(runtime_key("").len(), 64, "hex sha256");
}
#[test]
fn origin_strips_instance_query_and_path() {
assert_eq!(
origin_of("https://thq.tanbal.ir/?instance=abc").as_deref(),
Some("https://thq.tanbal.ir")
);
assert_eq!(
origin_of("http://localhost:18710").as_deref(),
Some("http://localhost:18710")
);
assert_eq!(
origin_of("https://thq.tanbal.ir:8443/x?y=1").as_deref(),
Some("https://thq.tanbal.ir:8443")
);
assert_eq!(origin_of("not a url").as_deref(), None);
assert_eq!(origin_of("ftp://host/").as_deref(), None);
}
fn cfg() -> ThqConfig {
ThqConfig::from_toml(&full_toml()).unwrap().unwrap()
}
#[test]
fn credential_roundtrip_private() {
let dir = tmp("cred-round");
let path = dir.join("thq").join("runtime.json");
let enr = Enrollment::with_cred_path(cfg(), path.clone());
let cred = RuntimeCredential {
runtime_key: enr.key(),
secret: "s3cret-value".to_string(),
};
enr.save_credential(&cred).unwrap();
let loaded = enr.load_credential().expect("loads");
assert_eq!(loaded, cred);
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let file_mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
assert_eq!(file_mode, 0o600, "secret file is owner-only");
let dir_mode = std::fs::metadata(path.parent().unwrap())
.unwrap()
.permissions()
.mode()
& 0o777;
assert_eq!(dir_mode, 0o700, "thq/ namespace is owner-only");
}
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn corrupt_credential_yields_none() {
let dir = tmp("cred-corrupt");
let path = dir.join("runtime.json");
std::fs::write(&path, "{not json").unwrap();
let enr = Enrollment::with_cred_path(cfg(), path);
assert!(
enr.load_credential().is_none(),
"corruption self-heals via re-register"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn advertise_change_invalidates_stale_credential() {
let dir = tmp("cred-stale");
let path = dir.join("runtime.json");
let enr = Enrollment::with_cred_path(cfg(), path.clone());
let stale = RuntimeCredential {
runtime_key: runtime_key("https://OLD-ADVERTISE:3000"),
secret: "old".to_string(),
};
enr.save_credential(&stale).unwrap();
assert!(
enr.valid_credential().is_none(),
"stale-key credential must be treated as absent"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn forget_credential_is_tolerant() {
let dir = tmp("cred-forget");
let enr = Enrollment::with_cred_path(cfg(), dir.join("runtime.json"));
enr.forget_credential(); let _ = std::fs::remove_dir_all(&dir);
}
use axum::response::Response;
async fn enroll(enr: &Enrollment, payload: &EnrollPayload) -> Response {
handle_enroll(axum::Extension(enr.clone()), Json(payload.clone()))
.await
.into_response()
}
fn enroll_payload(enr: &Enrollment, thq_url: &str, instance: &str) -> EnrollPayload {
EnrollPayload {
thq_url: thq_url.to_string(),
runtime_id: enr.key(),
instance_id: instance.to_string(),
secret: "fresh-secret".to_string(),
}
}
#[tokio::test]
async fn enroll_accepts_configured_thq_and_persists() {
let dir = tmp("enroll-ok");
let path = dir.join("runtime.json");
let enr = Enrollment::with_cred_path(cfg(), path.clone());
let payload = enroll_payload(
&enr,
"https://thq.tanbal.ir/?instance=6f635fe4-4a06-42b0-8a62-276383488a9c",
"6f635fe4-4a06-42b0-8a62-276383488a9c",
);
let resp = enroll(&enr, &payload).await;
assert_eq!(resp.status(), axum::http::StatusCode::OK);
let cred = enr.load_credential().expect("credential persisted");
assert_eq!(cred.runtime_key, enr.key());
assert_eq!(cred.secret, "fresh-secret");
assert!(enr.valid_credential().is_some(), "immediately usable");
let _ = std::fs::remove_dir_all(&dir);
}
#[tokio::test]
async fn enroll_rejects_rogue_thq_origin() {
let dir = tmp("enroll-rogue");
let path = dir.join("runtime.json");
let enr = Enrollment::with_cred_path(cfg(), path.clone());
let payload = enroll_payload(
&enr,
"https://rogue.example/?instance=leaf",
"6f635fe4-4a06-42b0-8a62-276383488a9c",
);
let resp = enroll(&enr, &payload).await;
assert_eq!(resp.status(), axum::http::StatusCode::FORBIDDEN);
assert!(
enr.load_credential().is_none(),
"a rogue THQ must never persist anything"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[tokio::test]
async fn enroll_rejects_foreign_runtime_id() {
let dir = tmp("enroll-wrongid");
let enr = Enrollment::with_cred_path(cfg(), dir.join("runtime.json"));
let mut payload = enroll_payload(
&enr,
"https://thq.tanbal.ir/?instance=6f635fe4-4a06-42b0-8a62-276383488a9c",
"6f635fe4-4a06-42b0-8a62-276383488a9c",
);
payload.runtime_id = runtime_key("https://SOMEONE-ELSE:3000");
let resp = enroll(&enr, &payload).await;
assert_eq!(resp.status(), axum::http::StatusCode::FORBIDDEN);
assert!(enr.load_credential().is_none());
let _ = std::fs::remove_dir_all(&dir);
}
#[tokio::test]
async fn enroll_rejects_wrong_instance() {
let dir = tmp("enroll-wronginst");
let enr = Enrollment::with_cred_path(cfg(), dir.join("runtime.json"));
let payload = enroll_payload(
&enr,
"https://thq.tanbal.ir/?instance=other-leaf",
"other-leaf",
);
let resp = enroll(&enr, &payload).await;
assert_eq!(resp.status(), axum::http::StatusCode::FORBIDDEN);
assert!(enr.load_credential().is_none());
let _ = std::fs::remove_dir_all(&dir);
}
fn open_state() -> crate::state::ServerState {
let (session, _rx) = trustee_core::session::Session::new();
let (ws_tx, _ws_rx) = tokio::sync::broadcast::channel::<String>(16);
crate::state::ServerState::new(session, ws_tx, None)
}
#[tokio::test]
async fn fresh_process_is_not_busy() {
let state = open_state();
assert!(!agent_busy(&state, "prof-1111").await);
}
#[tokio::test]
async fn running_session_in_own_namespace_makes_agent_busy() {
let state = open_state();
let pid = "prof-1111";
let namespace = trustee_core::user_hash(pid);
let session = {
let user = state.sessions.get("default").unwrap();
let entry = user.value().sessions.get("default").unwrap();
let s = entry.value().session.clone();
s.lock().await.home_dir = Some(std::path::PathBuf::from(format!(
"/tmp/x/users/{namespace}"
)));
s
};
session.lock().await.workflow_state = trustee_core::types::WorkflowState::Running;
assert!(agent_busy(&state, pid).await);
}
#[tokio::test]
async fn running_session_in_another_namespace_does_not_make_agent_busy() {
let state = open_state();
let session = {
let user = state.sessions.get("default").unwrap();
let entry = user.value().sessions.get("default").unwrap();
let s = entry.value().session.clone();
s.lock().await.home_dir =
Some(std::path::PathBuf::from("/tmp/x/users/other-namespace"));
s
};
session.lock().await.workflow_state = trustee_core::types::WorkflowState::Running;
assert!(
!agent_busy(&state, "prof-1111").await,
"another agent's live session must NOT report this profile running"
);
}
#[test]
fn register_backoff_keeps_the_heartbeat_through_the_grace_window() {
let interval = Duration::from_secs(30);
for n in 1..=GRACE_REGISTERS {
assert_eq!(
register_backoff(interval, n),
interval,
"register #{n} keeps the heartbeat cadence"
);
}
}
#[test]
fn register_backoff_doubles_then_caps() {
let interval = Duration::from_secs(30);
assert_eq!(register_backoff(interval, GRACE_REGISTERS + 1), Duration::from_secs(60));
assert_eq!(register_backoff(interval, GRACE_REGISTERS + 2), Duration::from_secs(120));
assert_eq!(register_backoff(interval, GRACE_REGISTERS + 3), Duration::from_secs(240));
assert_eq!(register_backoff(interval, GRACE_REGISTERS + 4), Duration::from_secs(480));
assert_eq!(
register_backoff(interval, 20),
Duration::from_secs(480),
"shift-capped at 16x the heartbeat"
);
assert_eq!(
register_backoff(Duration::from_secs(120), 20),
MAX_REGISTER_BACKOFF,
"MAX_REGISTER_BACKOFF caps"
);
}
}