zc2 0.0.29

P2P compute broker with credit-based billing, WAL, and broker mesh support
use std::collections::HashMap;
use std::io::Write;
use std::path::PathBuf;

/// Serialises the remaining tests that redirect `HOME` / `ZAKURO_HOME`, or
/// `ZAKURO_STATE_DIR` (the vpn connection-state dir).
///
/// Environment variables are process-global and `cargo test` is multi-threaded
/// by default, so without this two such tests interleave and the failure
/// surfaces in whichever one lost the race rather than the one that moved the
/// variable.
///
/// **This lock is a mitigation, not a guarantee.** It only orders tests that
/// take it, and `envs::update()` calls `set_var` over whatever keys a config
/// file carries -- so any test that boots a broker can still move these
/// variables without ever touching the lock. That is why serialising alone
/// did not fix CI. The durable answer is not to depend on the environment:
/// see [`dir_from`] and `NodeKey::load_or_create_in`, which take their inputs
/// as arguments. Prefer those in new tests; this remains for the ones that
/// genuinely exercise the env-reading path.
///
/// Poisoning is ignored on purpose -- a panic in one test must not cascade
/// into unrelated failures in every other test that wants the lock.
#[cfg(test)]
pub(crate) static HOME_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());

/// Where zc keeps per-node state: the Ed25519 node key, the roster cache, the
/// peer list.
///
/// `ZAKURO_HOME` wins when set, and is the state directory ITSELF -- the same
/// meaning `common::create_directories` already gives it when it makes
/// `config/`, `lib/`, `logs/` directly underneath. Honouring it here is not a
/// convenience: a containerised broker has no persistent `$HOME`, so mounting
/// a volume and pointing `ZAKURO_HOME` at it was the documented way to keep a
/// node's identity across restarts -- and this function ignoring the variable
/// silently defeated it. The k8s fleet ran that way for weeks: the volume was
/// mounted and empty, every restart minted a fresh keypair, re-registered as a
/// brand-new node, and orphaned the previous roster entry. Three accounts had
/// accumulated 25+ dead keys each by the time anyone looked.
///
/// Falls back to `$HOME/.zakuro`, which is what every non-container install
/// uses and what the LXC fleet pins via a systemd `Environment=HOME=/root`.
/// In the unit-test binary the fallback is a scratch directory instead; see
/// [`fallback_home`].
pub fn dir() -> Option<PathBuf> {
    dir_from(std::env::var_os("ZAKURO_HOME"), fallback_home())
}

/// The home directory state falls back to when no override is set: `$HOME`.
#[cfg(not(test))]
pub(crate) fn fallback_home() -> Option<std::ffi::OsString> {
    std::env::var_os("HOME")
}

/// In the unit-test binary, only a HOME that a test pointed into the temp dir
/// is kept. Any other one -- the developer's, or a broker host's real home --
/// is swapped for a per-process scratch directory, so a test that saves node
/// state without redirecting anything never writes the real `~/.zakuro`
/// (zc#211: the deploy drive tests rewrote a live broker's `deployments.json`).
#[cfg(test)]
pub(crate) fn fallback_home() -> Option<std::ffi::OsString> {
    Some(test_fallback_home(std::env::var_os("HOME"), &std::env::temp_dir()).into_os_string())
}

/// The unit-test rule of [`fallback_home`], as a pure function of its inputs.
#[cfg(test)]
pub(crate) fn test_fallback_home(
    home: Option<std::ffi::OsString>,
    tmp: &std::path::Path,
) -> PathBuf {
    match home {
        Some(h) if std::path::Path::new(&h).starts_with(tmp) => PathBuf::from(h),
        _ => tmp.join(format!("zc-test-home-{}", std::process::id())),
    }
}

/// The precedence rule itself, as a pure function of the two values.
///
/// Split out so it can be tested without touching process-global environment
/// variables. That is not tidiness: `envs::update()` calls `set_var` over
/// whatever keys a config file carries, so ANY concurrently running test that
/// boots a broker can move `ZAKURO_HOME` out from under a test that set it.
/// A test that mutates the environment cannot be made reliable by locking,
/// because the writers it races with do not take the lock.
pub fn dir_from(
    zakuro_home: Option<std::ffi::OsString>,
    home: Option<std::ffi::OsString>,
) -> Option<PathBuf> {
    if let Some(h) = zakuro_home {
        if !h.is_empty() {
            return Some(PathBuf::from(h));
        }
    }
    home.map(|h| PathBuf::from(h).join(".zakuro"))
}

/// Production dashboard API base URL.
///
/// hub, not `my`: `my.zakuro-ai.com` has no DNS record at all (NXDOMAIN,
/// checked against 1.1.1.1 on 2026-09-06 — no A, AAAA or CNAME, only the zone
/// SOA). Every zc command without `ZAKURO_ENV=staging` therefore failed to
/// *resolve* rather than failing to authenticate, which is why it read as a
/// network problem instead of a wrong constant.
///
/// `hub.zakuro-ai.com` is the live prod host: a Cloudflare-proxied CNAME onto
/// the tunnel, publicly reachable, serving `/api/`. Prod has no `api.` split
/// yet — that needs a tunnel ingress rule, not just DNS — so unlike staging
/// below, the browser host and the machine host are the same name here. When
/// `api.hub.zakuro-ai.com` exists, this moves to it and the two match again.
pub const PROD_API_URL: &str = "https://hub.zakuro-ai.com";
/// Staging dashboard API base URL.
///
/// stg.api is the canonical MACHINE host, following the `stg.api.<service>`
/// convention. It is the same hub Service and the same paths as stg.hub --
/// the hub serves the whole dashboard API in-process, including
/// `/api/auth/cli/token` and `/api/auth/cli/join`, which `enroll` needs.
///
/// stg.hub remains the canonical BROWSER host and keeps the device-flow
/// `/activate` page and the Google OAuth callback, so those URLs are built
/// from the server's own DASHBOARD_BASE_URL and are unaffected by this.
/// The retired stg-my / stg.my-api are gone entirely.
pub const STAGING_API_URL: &str = "https://stg.api.zakuro-ai.com";

/// Resolve the dashboard API base URL, in precedence order:
///   1. `ZAKURO_API_URL` (explicit override) — used verbatim when non-empty.
///   2. `ZAKURO_ENV=staging|stg|stage` → the staging endpoint.
///   3. otherwise → production.
///
/// This is the single place the `hub.zakuro-ai.com` / `stg.api.zakuro-ai.com`
/// switch lives, so `ZAKURO_ENV=staging` flips every zc command (init, broker,
/// enroll, info, …) to staging at once.
pub fn default_api_url() -> String {
    if let Ok(u) = std::env::var("ZAKURO_API_URL") {
        if !u.trim().is_empty() {
            return u;
        }
    }
    match std::env::var("ZAKURO_ENV")
        .unwrap_or_default()
        .to_lowercase()
        .as_str()
    {
        "staging" | "stg" | "stage" => STAGING_API_URL.to_string(),
        _ => PROD_API_URL.to_string(),
    }
}

pub fn path() -> Option<PathBuf> {
    dir().map(|d| d.join("credentials"))
}

pub fn parse(text: &str) -> HashMap<String, String> {
    let mut m = HashMap::new();
    for line in text.lines() {
        let line = line.trim();
        if line.is_empty() || line.starts_with('#') {
            continue;
        }
        let line = line.strip_prefix("export ").unwrap_or(line);
        if let Some((k, v)) = line.split_once('=') {
            m.insert(k.trim().to_string(), v.trim().to_string());
        }
    }
    m
}

pub fn save(api_key: &str, api_url: Option<&str>) -> std::io::Result<()> {
    let d = dir().ok_or_else(|| std::io::Error::new(std::io::ErrorKind::NotFound, "no HOME"))?;
    std::fs::create_dir_all(&d)?;
    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt;
        std::fs::set_permissions(&d, std::fs::Permissions::from_mode(0o700)).ok();
    }
    let p = d.join("credentials");
    let mut body = format!("api_key={}\n", api_key);
    if let Some(u) = api_url {
        body.push_str(&format!("api_url={}\n", u));
    }
    let mut f = std::fs::File::create(&p)?;
    f.write_all(body.as_bytes())?;
    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt;
        std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o600)).ok();
    }
    Ok(())
}

/// Fill ZAKURO_API_KEY / ZAKURO_API_URL from stored config when unset.
/// Precedence: process env > ~/.zakuro/credentials > ~/.zakuro/env.
pub fn load_into_env() {
    if let Some(p) = path() {
        if let Ok(text) = std::fs::read_to_string(&p) {
            let m = parse(&text);
            set_if_unset("ZAKURO_API_KEY", m.get("api_key"));
            set_if_unset("ZAKURO_API_URL", m.get("api_url"));
        }
    }
    if let Some(d) = dir() {
        if let Ok(text) = std::fs::read_to_string(d.join("env")) {
            let m = parse(&text);
            set_if_unset("ZAKURO_API_KEY", m.get("ZAKURO_API_KEY"));
            set_if_unset("ZAKURO_API_URL", m.get("ZAKURO_API_URL"));
        }
    }
}

fn set_if_unset(var: &str, value: Option<&String>) {
    if std::env::var(var).is_err() {
        if let Some(v) = value {
            if !v.is_empty() {
                std::env::set_var(var, v);
            }
        }
    }
}

/// Where `zc connect` stores the mesh's shared peer key (`ZAKURO_PEER_KEY`)
/// handed out by the hub, so a broker started on this machine can join the
/// mesh as a PEER (QUIC transport, `/peer/*`) without the operator pasting the
/// secret by hand. Sibling of `credentials` (0600 in a 0700 dir).
pub fn mesh_peer_key_path() -> Option<PathBuf> {
    dir().map(|d| d.join("mesh_peer_key"))
}

/// The stored mesh peer key, if `zc connect` saved one.
pub fn load_mesh_peer_key() -> Option<String> {
    let p = mesh_peer_key_path()?;
    let text = std::fs::read_to_string(p).ok()?;
    let k = text.trim();
    if k.is_empty() {
        None
    } else {
        Some(k.to_string())
    }
}

pub fn save_mesh_peer_key(key: &str) -> std::io::Result<()> {
    let d = dir().ok_or_else(|| std::io::Error::new(std::io::ErrorKind::NotFound, "no HOME"))?;
    std::fs::create_dir_all(&d)?;
    let p = d.join("mesh_peer_key");
    let mut f = std::fs::File::create(&p)?;
    f.write_all(key.trim().as_bytes())?;
    f.write_all(b"\n")?;
    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt;
        std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o600)).ok();
    }
    Ok(())
}

#[cfg(test)]
mod tests {
    #[test]
    fn parse_prefers_env_else_file() {
        let txt = "api_key=zk_1_abc\napi_url=http://x\n";
        let m = super::parse(txt);
        assert_eq!(m.get("api_key").unwrap(), "zk_1_abc");
        assert_eq!(m.get("api_url").unwrap(), "http://x");
    }

    #[test]
    fn parse_strips_export_prefix() {
        let m = super::parse("export ZAKURO_API_URL=https://stg\n# c\nZAKURO_API_KEY=zk_1_a\n");
        assert_eq!(m.get("ZAKURO_API_URL").unwrap(), "https://stg");
        assert_eq!(m.get("ZAKURO_API_KEY").unwrap(), "zk_1_a");
    }

    /// stg-my is retired; staging machine traffic addresses stg.api. This constant is
    /// compiled into every installed binary, so it is pinned by value here --
    /// the pre-existing `envs_are_prod_then_staging` test compares against the
    /// constant itself and so would pass no matter what host it names.
    #[test]
    fn staging_points_at_stg_api_not_the_retired_stg_my() {
        assert_eq!(
            super::STAGING_API_URL,
            "https://stg.api.zakuro-ai.com",
            "staging must address stg.api, the machine host; stg-my is decommissioned"
        );
        assert_eq!(
            super::PROD_API_URL,
            "https://hub.zakuro-ai.com",
            "prod must name a host that resolves; my.zakuro-ai.com is NXDOMAIN"
        );
    }

    /// zc#211: a unit test that saves node state without redirecting anything
    /// must land in a scratch directory, never the real `~/.zakuro` (the
    /// deploy drive tests rewrote a live broker's `deployments.json`). A HOME
    /// that a test pointed into the temp dir is kept, which is what the
    /// `enroll` and `roster_cache` tests rely on.
    #[test]
    fn unit_tests_swap_a_real_home_for_a_scratch_one() {
        let tmp = std::path::Path::new("/scratch");
        let scratch = super::test_fallback_home(Some("/home/foo".into()), tmp);
        assert!(scratch.starts_with(tmp), "{}", scratch.display());
        assert_eq!(super::test_fallback_home(None, tmp), scratch);
        assert_eq!(
            super::test_fallback_home(Some("/scratch/zc-enroll-test-1".into()), tmp),
            std::path::PathBuf::from("/scratch/zc-enroll-test-1")
        );
    }

    #[test]
    fn dir_never_falls_back_outside_the_temp_dir_in_unit_tests() {
        let home = std::path::PathBuf::from(super::fallback_home().expect("always set in tests"));
        assert!(home.starts_with(std::env::temp_dir()), "{}", home.display());
        assert_eq!(
            super::dir_from(None, Some(home.clone().into_os_string())),
            Some(home.join(".zakuro"))
        );
    }
}