zc2 0.0.27

P2P compute broker with credit-based billing, WAL, and broker mesh support
use crate::envs;
use crate::exec;
use std::fs::File;
use std::io::Write;
use std::path::Path;
use std::str;
use std::{env, fs};

pub fn print_debug(s: &str) {
    if cfg!(debug_assertions) {
        println!("DEBUG: {}", s);
    }
}

// The file `built.rs` was placed there by cargo and `build.rs`
mod built_info {
    include!(concat!(env!("OUT_DIR"), "/built.rs"));
}

pub fn dist() -> Result<String, String> {
    if let Ok(dist_value) = exec::run(&["uname", "-m"]) {
        if dist_value == "aarch64" {
            Ok(String::from("aarch64"))
        } else if dist_value == "x86_64" {
            Ok(String::from("amd64"))
        } else {
            Ok(String::from("arm64"))
        }
    } else {
        Err("Couldn't extract dist".to_string())
    }
}

pub fn build(image: Option<Vec<String>>) {
    if let (Some(image_value), Ok(dist_str)) = (image, dist()) {
        let truncated_vector: Vec<String> = image_value.into_iter().skip(2).collect();
        let d = if &dist_str == "arm64" {
            "aarch64"
        } else if &dist_str == "amd64" {
            "amd64"
        } else {
            &dist_str
        };
        // Was: `BUILDARCH=… BUILDARCHI=… docker compose build <images>` via
        // bash -c, with the (CLI-supplied) image names interpolated into the
        // shell string. Run docker directly with the arch as real env vars and
        // the image names as explicit args — no shell interpolation (audit M4b).
        let _ = std::process::Command::new("docker")
            .args(["compose", "build"])
            .args(&truncated_vector)
            .env("BUILDARCH", d)
            .env("BUILDARCHI", &dist_str)
            .status();
    }
}

fn create_directory(path: &str) -> std::io::Result<()> {
    let path = Path::new(path);
    fs::create_dir_all(path)?;
    Ok(())
}

pub fn create_directories() {
    envs::update();
    if let Ok(zakuro_home) = env::var("ZAKURO_HOME") {
        //Create dirs
        for image in vec![
            "config", "network", "storage", "compute", "node", "hub", "lib", "logs", "bin",
        ] {
            if let Err(e) = create_directory(&format!("{}/{}", zakuro_home, image)) {
                eprintln!("Error creating directory: {}", e);
            }
        }
    }
}

/// Fetch `url` and write the body to `path` using ureq (no shell). Returns Err
/// on transport error, non-2xx status, or a write failure.
fn http_download_to_file(agent: &ureq::Agent, url: &str, path: &str) -> Result<(), String> {
    let body = agent
        .get(url)
        .call()
        .map_err(|e| format!("GET {}: {}", url, e))?
        .into_body()
        .read_to_string()
        .map_err(|e| format!("reading {}: {}", url, e))?;
    fs::write(path, body).map_err(|e| format!("writing {}: {}", path, e))
}

pub fn download_conf() {
    envs::update();
    if let Ok(zakuro_home) = env::var("ZAKURO_HOME") {
        create_directories();

        // Fetch config YAMLs over HTTP via ureq instead of shelling out to
        // `wget` with ZAKURO_HOME interpolated into a bash -c string (audit
        // M4b: avoid shell interpolation). URLs are fixed; paths are joined in
        // Rust, never passed to a shell.
        let agent = ureq::Agent::new_with_config(
            ureq::Agent::config_builder()
                .timeout_global(Some(std::time::Duration::from_secs(30)))
                .build(),
        );

        // Default config.
        if let Err(e) = http_download_to_file(
            &agent,
            "http://get.zakuro-ai.com/zk0?config=default",
            &format!("{}/default-zakuro.yaml", zakuro_home),
        ) {
            eprintln!("Error downloading default config: {}", e);
        }

        // Per-service confs + env files.
        for image in ["network", "storage", "compute", "node", "hub"] {
            if let Err(e) = http_download_to_file(
                &agent,
                &format!("http://get.zakuro-ai.com/zk0?config={}", image),
                &format!("{}/{}/{}-zakuro.yaml", zakuro_home, image, image),
            ) {
                eprintln!("Error downloading {} config: {}", image, e);
            }
            if let Err(e) = http_download_to_file(
                &agent,
                &format!("http://get.zakuro-ai.com/zk0?config={}_env", image),
                &format!("{}/{}/.env", zakuro_home, image),
            ) {
                eprintln!("Error downloading {} env: {}", image, e);
            }
        }
    }
}

/// Build the JSON request body for the profile fetch. Using serde_json (not a
/// hand-rolled format! string) guarantees the API key is escaped and can never
/// break out of the JSON / inject — the key is data, never code.
fn profile_request_value(pkey: &str) -> serde_json::Value {
    serde_json::json!({ "pkey": pkey })
}

pub fn download_auth() {
    envs::update();
    if let (Ok(zakuro_home), Ok(zakuro_auth)) =
        (env::var("ZAKURO_HOME"), env::var("ZAKURO_API_KEY"))
    {
        if !zakuro_home.is_empty() && !zakuro_auth.is_empty() {
            // Never log the API key (it was previously printed via print_debug).
            print_debug(&format!(
                "downloading profile for ZAKURO_HOME={}",
                zakuro_home
            ));
            create_directories();

            // POST the API key as a JSON body via ureq — no shell (the old
            // `curl … --data '{"pkey":"<key>"}' > file` interpolated the key
            // into a bash -c string, allowing shell/JSON injection and leaking
            // the key into argv/logs). serde_json escapes the key; ureq carries
            // it as a request body, and a non-2xx is an Err (so error pages are
            // not written as the wg0.conf).
            let agent = ureq::Agent::new_with_config(
                ureq::Agent::config_builder()
                    .timeout_global(Some(std::time::Duration::from_secs(30)))
                    .build(),
            );
            let result = agent
                .post("https://get.zakuro-ai.com/profile")
                .header("Content-Type", "application/json")
                .send_json(profile_request_value(&zakuro_auth))
                .and_then(|resp| resp.into_body().read_to_string());
            match result {
                Ok(body) => {
                    let path = format!("{}/config/wg0.conf", zakuro_home);
                    if let Err(e) = fs::write(&path, body) {
                        eprintln!("Error writing {}: {}", path, e);
                    } else {
                        print_debug(&format!("wrote profile to {}/config/wg0.conf", zakuro_home));
                    }
                }
                Err(e) => eprintln!("Error fetching profile: {}", e),
            }
        }
    } else {
        eprintln!("Missing ZAKURO_CONTEXT or ZAKURO_API_KEY");
    }
}

pub fn context(path: Option<&str>) -> std::io::Result<()> {
    // envs::update();
    // Specify the file path
    let zakuro_env: String = fs::read_to_string(envs::CONFIG_FILE)?;
    if let Some(path_str) = path {
        let output_line = format!("export ZAKURO_CONTEXT={}", path_str);
        let path = Path::new(path_str);
        if path.exists() {
            // let path_env = &format!("{}/.zakuro/env", env::var("HOME").unwrap());
            let mut lines = Vec::new();
            for line in zakuro_env.split("\n") {
                if !line.contains("export ZAKURO_CONTEXT") {
                    lines.push(line);
                }
            }
            lines.push(&output_line);
            // Concatenate the strings into a single string
            let concatenated = lines.join("\n");

            let mut file = File::create(envs::CONFIG_FILE)?;

            // Write the concatenated string to the file
            file.write_all(concatenated.as_bytes())?;
        }
    } else {
        // println!("{:?}", vars);
    }
    Ok(())
}

pub fn version() {
    let built_time = built::util::strptime(built_info::BUILT_TIME_UTC);
    // Include the git commit the binary was built from (short hash + a `-dirty`
    // marker for uncommitted trees) so a build can be traced to a source revision.
    let git = match built_info::GIT_COMMIT_HASH_SHORT {
        Some(h) => {
            let dirty = if matches!(built_info::GIT_DIRTY, Some(true)) {
                "-dirty"
            } else {
                ""
            };
            format!(" ({}{})", h, dirty)
        }
        None => String::new(),
    };
    println!(
        "zc version {}{} built on {}",
        built_info::PKG_VERSION,
        git,
        built_time.with_timezone(&built::chrono::offset::Local)
    );
}

pub fn logs(_alive: bool) {
    // Fetches the Spark-master status page and dumps it as-is. The
    // historical version of this function parsed the HTML with html5ever
    // + soup and built a structured worker/app view; that parser was
    // commented out long before #46 and the deps were retired with it.
    // If we want the structured view back, build it from the broker's
    // JSON `/workers` endpoint rather than re-introducing an HTML parser.
    let html = match exec::stdout("curl -s http://spark-master.zakuro-ai.com:8080") {
        Ok(html) => html,
        Err(why) => {
            eprintln!("Failed to execute the command: {:?}", why);
            return;
        }
    };
    println!("{}", html);
}

#[cfg(test)]
mod panic_fix_tests {
    use super::context;

    #[test]
    fn context_returns_err_when_config_missing() {
        // CONFIG_FILE points at ~/.zakuro/env; in a clean test env it is
        // typically absent. context() must surface an Err, not panic.
        match context(None) {
            Ok(()) => { /* config existed — acceptable */ }
            Err(e) => {
                assert_eq!(e.kind(), std::io::ErrorKind::NotFound);
            }
        }
    }

    /// The profile request body escapes the API key so a key containing JSON /
    /// shell metacharacters is carried as data, never breaking the JSON or
    /// injecting (M4b: replaced the curl `bash -c` string with a ureq body).
    #[test]
    fn profile_request_value_escapes_special_chars() {
        let nasty = "ab\"c\\d\ne$(rm -rf /)`x`";
        let v = super::profile_request_value(nasty);
        let s = serde_json::to_string(&v).unwrap();
        // Serializes to valid JSON that round-trips back to the exact key.
        let back: serde_json::Value = serde_json::from_str(&s).unwrap();
        assert_eq!(back["pkey"], nasty);
        // The raw double-quote in the key is escaped in the serialized form.
        assert!(
            s.contains("\\\""),
            "key quote must be escaped in the JSON body: {}",
            s
        );
    }
}