// SPDX-License-Identifier: MIT OR Apache-2.0 //! End-to-end tests of the `bankml` binary: what an operator types, what comes back, which exit code. //! Offline — synthetic GGUFs and a mock llama-server; the real-model oracles are in the modules (see //! testing/README.md). `cargo test --release --test cli` use std::io::{BufRead, BufReader, Read, Write}; use std::net::{TcpListener, TcpStream}; use std::path::{Path, PathBuf}; use std::process::{Command, Output}; fn bankml(args: &[&str]) -> Output { Command::new(env!("CARGO_BIN_EXE_bankml")).args(args).output().expect("run bankml") } fn code(o: &Output) -> i32 { o.status.code().unwrap_or(-1) } fn out(o: &Output) -> String { String::from_utf8_lossy(&o.stdout).into_owned() } /// A scratch directory for this test process. Directories left by earlier runs (their process is gone) are removed /// first: the fixtures are synthetic GGUFs of several MB each, and 234 runs had left 1.2 GB behind. fn dir(tag: &str) -> PathBuf { static SWEPT: std::sync::Once = std::sync::Once::new(); SWEPT.call_once(|| { for e in std::fs::read_dir(std::env::temp_dir()).into_iter().flatten().flatten() { let name = e.file_name().to_string_lossy().into_owned(); let pid = name.strip_prefix("bankml-cli-").and_then(|r| r.rsplit('-').next()).and_then(|p| p.parse::().ok()); if let Some(pid) = pid { if pid != std::process::id() && !std::path::Path::new(&format!("/proc/{pid}")).exists() { let _ = std::fs::remove_dir_all(e.path()); } } } }); let d = std::env::temp_dir().join(format!("bankml-cli-{tag}-{}", std::process::id())); std::fs::create_dir_all(&d).unwrap(); d } fn s(x: &str) -> Vec { let mut v = (x.len() as u64).to_le_bytes().to_vec(); v.extend_from_slice(x.as_bytes()); v } /// A GGUF v3 with the given string keys and tensors (name, elements, type, bytes per block, elems per block). fn gguf(keys: &[(&str, &str)], tensors: &[(&str, u64, u32, u64, u64)]) -> Vec { let mut h = b"GGUF".to_vec(); h.extend(3u32.to_le_bytes()); h.extend((tensors.len() as u64).to_le_bytes()); h.extend((keys.len() as u64).to_le_bytes()); for (k, v) in keys { h.extend(s(k)); h.extend(8u32.to_le_bytes()); h.extend(s(v)); } let mut off = 0u64; for &(n, ne, ty, bb, per) in tensors { h.extend(s(n)); h.extend(1u32.to_le_bytes()); h.extend(ne.to_le_bytes()); h.extend(ty.to_le_bytes()); h.extend(off.to_le_bytes()); off += (ne / per * bb).div_ceil(32) * 32; } h.resize(h.len().div_ceil(32) * 32 + off as usize, 0); h } fn write(d: &Path, name: &str, b: &[u8]) -> String { let p = d.join(name); std::fs::write(&p, b).unwrap(); p.to_string_lossy().into_owned() } fn sha(p: &str) -> String { out(&bankml(&["sha256", p])).split_whitespace().next().unwrap().to_string() } #[test] fn version_and_usage() { let v = bankml(&["version"]); assert_eq!((code(&v), out(&v).trim().to_string()), (0, format!("bankml {}", env!("CARGO_PKG_VERSION")))); assert_eq!(code(&bankml(&[])), 1); assert_eq!(code(&bankml(&["nonsense"])), 1); } #[test] fn guard_verdicts_and_exit_codes() { let d = dir("guard"); let q2 = [("general.architecture", "qwen3"), ("general.name", "Ternary-Bonsai-1.7B")]; let good = write(&d, "good.gguf", &gguf(&q2, &[("a", 4096, 42, 18, 64)])); let legacy = write(&d, "legacy.gguf", &gguf(&q2, &[("a", 4096, 42, 34, 128), ("b", 4096, 42, 34, 128)])); let b2 = write(&d, "b2.gguf", &gguf(&[("general.name", "Ternary Bonsai 2 27B")], &[("a", 4096, 42, 18, 64)])); let trunc = write(&d, "trunc.gguf", &gguf(&q2, &[("a", 4096, 42, 18, 64)])[..40]); let g = bankml(&["guard", &good, "--json"]); assert_eq!(code(&g), 0); assert!(out(&g).contains("\"verdict\": \"play\"") && out(&g).contains("\"Q2_0\": 1"), "{}", out(&g)); let l = bankml(&["guard", &legacy]); assert_eq!(code(&l), 2); assert!(out(&l).contains("legacy group-128"), "{}", out(&l)); assert_eq!(code(&bankml(&["guard", &b2])), 2); assert_eq!(code(&bankml(&["guard", &b2, "--engine", "prism"])), 0); assert_eq!(code(&bankml(&["guard", &trunc])), 3); assert_eq!(code(&bankml(&["guard", &d.join("missing.gguf").to_string_lossy()])), 1); } /// 0.0.1 aborted with a stack overflow on this file (exit 134); it must refuse. #[test] fn hostile_headers_refuse_not_crash() { let d = dir("hostile"); let mut h = b"GGUF".to_vec(); h.extend(3u32.to_le_bytes()); h.extend(0u64.to_le_bytes()); h.extend(1u64.to_le_bytes()); h.extend(s("a")); h.extend(9u32.to_le_bytes()); for _ in 0..2_000_000 { h.extend(9u32.to_le_bytes()); h.extend(1u64.to_le_bytes()); } h.extend(4u32.to_le_bytes()); h.extend(0u64.to_le_bytes()); let nested = write(&d, "nested.gguf", &h); let o = bankml(&["guard", &nested]); assert_eq!(code(&o), 2, "{}", out(&o)); assert!(out(&o).contains("nested deeper")); } #[test] fn pin_and_verify() { let d = dir("verify"); let m = write(&d, "m.gguf", &gguf(&[("general.architecture", "qwen3")], &[("a", 4096, 41, 18, 128)])); let h = sha(&m); let fork = write(&d, "FORK.json", format!("{{\"files\": [{{\"path\": \"m.gguf\", \"sha256\": \"{h}\"}}]}}").as_bytes()); let wrong = write(&d, "WRONG.json", format!("{{\"files\": [{{\"path\": \"m.gguf\", \"sha256\": \"{}\"}}]}}", "0".repeat(64)).as_bytes()); let p = bankml(&["pin", &m, "--fork", &fork]); assert_eq!((code(&p), out(&p).trim().to_string()), (0, format!("pinned {h}"))); assert_eq!(code(&bankml(&["pin", &m, "--fork", &wrong])), 2); assert_eq!(code(&bankml(&["pin", &m, "--fork", &d.join("none.json").to_string_lossy()])), 1); // io, not "unpinned" let v = bankml(&["verify", &m, "--fork", &fork, "--json"]); assert_eq!(code(&v), 0); assert!(out(&v).contains(&format!("\"model_sha256\": \"{h}\"")) && out(&v).contains("\"verdict\": \"play\"")); let w = bankml(&["verify", &m, "--fork", &wrong, "--json"]); assert_eq!(code(&w), 2); assert!(out(&w).contains("\"verdict\": \"refuse\"") && out(&w).contains("!= pinned")); } // ---------------------------------------------------------------- serve, against a mock llama-server /// A mock llama-server: /health, /props naming `model`, and a chat endpoint that streams (chunked) or not. fn mock_upstream(model: String) -> String { let l = TcpListener::bind("127.0.0.1:0").unwrap(); let addr = l.local_addr().unwrap().to_string(); std::thread::spawn(move || { for c in l.incoming().flatten() { let model = model.clone(); std::thread::spawn(move || mock_one(c, &model)); } }); addr } fn mock_one(mut c: TcpStream, model: &str) { let mut r = BufReader::new(c.try_clone().unwrap()); let mut line = String::new(); r.read_line(&mut line).unwrap(); let path = line.split_whitespace().nth(1).unwrap_or("").to_string(); let mut n = 0; loop { let mut h = String::new(); r.read_line(&mut h).unwrap(); if h == "\r\n" || h.is_empty() { break; } if let Some(v) = h.to_ascii_lowercase().strip_prefix("content-length:") { n = v.trim().parse().unwrap(); } } let mut body = vec![0; n]; r.read_exact(&mut body).unwrap(); let json = |b: &str| format!("HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{b}", b.len()); let reply = match path.as_str() { "/health" => json("{\"status\":\"ok\"}"), "/props" => json(&format!("{{\"model_path\":{:?}}}", model)), _ if String::from_utf8_lossy(&body).contains("\"stream\": true") || String::from_utf8_lossy(&body).contains("\"stream\":true") => { let events = [ "data: {\"choices\":[{\"delta\":{\"content\":\"Savante \"}}]}\n\n", "data: {\"choices\":[{\"delta\":{\"content\":\"knows.\"}}]}\n\n", "data: {\"choices\":[],\"usage\":{\"prompt_tokens\":7,\"completion_tokens\":2}}\n\n", "data: [DONE]\n\n", ]; let mut s = "HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nTransfer-Encoding: chunked\r\n\r\n".to_string(); for e in events { s += &format!("{:x}\r\n{e}\r\n", e.len()); } s + "0\r\n\r\n" } _ => json("{\"choices\":[{\"message\":{\"role\":\"assistant\",\"content\":\"Savante knows.\"}}],\"usage\":{\"prompt_tokens\":7,\"completion_tokens\":2}}"), }; let _ = c.write_all(reply.as_bytes()); } fn http(addr: &str, method: &str, path: &str, body: &str) -> String { http_h(addr, method, path, body, "Host: 127.0.0.1\r\nContent-Type: application/json\r\n") } fn http_h(addr: &str, method: &str, path: &str, body: &str, headers: &str) -> String { let mut s = TcpStream::connect(addr).unwrap(); s.set_read_timeout(Some(std::time::Duration::from_secs(20))).unwrap(); write!(s, "{method} {path} HTTP/1.1\r\n{headers}Content-Length: {}\r\nConnection: close\r\n\r\n{body}", body.len()).unwrap(); let mut o = String::new(); s.read_to_string(&mut o).unwrap(); o } fn free_port() -> String { TcpListener::bind("127.0.0.1:0").unwrap().local_addr().unwrap().to_string() } /// The serve under test, killed when the test ends — pass or panic (a leaked child keeps the test's pipes open). struct Serve(std::process::Child); impl Drop for Serve { fn drop(&mut self) { let _ = self.0.kill(); let _ = self.0.wait(); } } fn start_serve(m: &str, fork: &str, upstream: &str) -> (Serve, String) { let listen = free_port(); let mut child = Command::new(env!("CARGO_BIN_EXE_bankml")) .args(["serve", m, "--fork", fork, "--upstream", upstream, "--listen", &listen]) .stderr(std::process::Stdio::null()) .spawn() .unwrap(); for _ in 0..100 { if TcpStream::connect(&listen).is_ok() { return (Serve(child), listen); } std::thread::sleep(std::time::Duration::from_millis(50)); } let _ = child.kill(); let _ = child.wait(); panic!("serve did not start"); } fn sha_hex(b: &[u8]) -> String { let mut h = bankml::sha256::Sha256::default(); h.update(b); bankml::sha256::hex(&h.finish()) } #[test] fn serve_gates_and_signs_answers() { let d = dir("serve"); let m = write(&d, "m.gguf", &gguf(&[("general.architecture", "qwen3")], &[("a", 4096, 41, 18, 128)])); let h = sha(&m); let fork = write(&d, "FORK.json", format!("{{\"files\": [{{\"path\": \"m.gguf\", \"sha256\": \"{h}\"}}]}}").as_bytes()); let canon = Path::new(&m).canonicalize().unwrap().to_string_lossy().into_owned(); let up = mock_upstream(canon); let (child, addr) = start_serve(&m, &fork, &up); let st = http(&addr, "GET", "/bankml", ""); assert!(st.contains(&h) && st.contains("\"verdict\": \"play\""), "{st}"); let one = http(&addr, "POST", "/v1/chat/completions", "{\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}]}"); assert!(one.contains("\"bankml_receipt\"") && one.contains(&format!("\"model_sha256\": \"{h}\"")), "{one}"); assert!(one.contains(&format!("\"response_sha256\": \"{}\"", sha_hex(b"Savante knows."))), "{one}"); assert!(one.contains("\"completion_tokens\": 2")); let st = http(&addr, "POST", "/v1/chat/completions", "{\"stream\": true, \"messages\":[]}"); let receipt = st.lines().find(|l| l.contains("bankml_receipt")).unwrap_or_else(|| panic!("{st}")); assert!(receipt.contains(&format!("\"response_sha256\": \"{}\"", sha_hex(b"Savante knows."))), "{receipt}"); assert!(st.find("bankml_receipt").unwrap() < st.find("[DONE]").unwrap(), "receipt before DONE"); assert!(st.contains("\"content\":\"Savante \""), "the stream itself passes through untouched"); let body = "{\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}]}"; assert!(one.contains(&format!("\"request_sha256\": \"{}\"", sha_hex(body.as_bytes()))), "the receipt names the request: {one}"); // a web page cannot drive it: a foreign Host (DNS rebinding) and a form-style POST are refused let r = http_h(&addr, "POST", "/v1/chat/completions", body, "Host: evil.example\r\nContent-Type: application/json\r\n"); assert!(r.starts_with("HTTP/1.1 403"), "{r}"); let r = http_h(&addr, "POST", "/v1/chat/completions", body, "Host: 127.0.0.1\r\nContent-Type: text/plain\r\n"); assert!(r.starts_with("HTTP/1.1 415"), "{r}"); // the model file changes after verification: no answer, no receipt std::fs::write(&m, b"replaced weights").unwrap(); let r = http(&addr, "POST", "/v1/chat/completions", body); assert!(r.starts_with("HTTP/1.1 503") && !r.contains("bankml_receipt"), "{r}"); drop(child); } #[test] fn serve_refuses_an_unverified_model_or_a_different_upstream_file() { let d = dir("serve-refuse"); let m = write(&d, "m.gguf", &gguf(&[("general.architecture", "qwen3")], &[("a", 4096, 41, 18, 128)])); let other = write(&d, "other.gguf", b"GGUF"); let h = sha(&m); let fork = write(&d, "FORK.json", format!("{{\"files\": [{{\"path\": \"m.gguf\", \"sha256\": \"{h}\"}}]}}").as_bytes()); let wrong = write(&d, "WRONG.json", format!("{{\"files\": [{{\"path\": \"m.gguf\", \"sha256\": \"{}\"}}]}}", "1".repeat(64)).as_bytes()); let up = mock_upstream(Path::new(&other).canonicalize().unwrap().to_string_lossy().into_owned()); let run = |f: &str| { let o = Command::new(env!("CARGO_BIN_EXE_bankml")).args(["serve", &m, "--fork", f, "--upstream", &up, "--listen", &free_port()]).output().unwrap(); (code(&o), String::from_utf8_lossy(&o.stderr).into_owned() + &out(&o)) }; let (c, msg) = run(&wrong); assert_eq!(c, 2, "{msg}"); assert!(msg.contains("!= pinned"), "{msg}"); let (c, msg) = run(&fork); assert_eq!(c, 2, "{msg}"); assert!(msg.contains("not the verified"), "{msg}"); } /// O5: `bankml create` over a pinned (header-only) GGUF, by name and by path; the manifest; the refusals. #[test] fn create_writes_a_layer_over_a_pin() { let d = dir("create"); let reg = d.join("forks"); std::fs::create_dir_all(®).unwrap(); let base = write(®, "base-F16.gguf", &gguf(&[("general.architecture", "llama"), ("tokenizer.chat_template", "{{ chatml }}")], &[])); std::fs::write(reg.join("base-F16.gguf.FORK.json"), format!("{{\"files\": [{{\"path\": \"base-F16.gguf\", \"sha256\": \"{}\"}}]}}", sha(&base))).unwrap(); let mf = d.join("Modelfile"); std::fs::write(&mf, "FROM base-f16\nSYSTEM \"\"\"You are mindX.\"\"\"\nPARAMETER stop <|im_end|>\nPARAMETER num_ctx 2048\n").unwrap(); let r = reg.to_str().unwrap(); let o = bankml(&["create", "persona", "-f", mf.to_str().unwrap(), "--registry", r]); assert_eq!(code(&o), 0, "{}", String::from_utf8_lossy(&o.stderr)); assert!(out(&o).lines().last() == Some("success") && out(&o).contains(&format!("using existing layer sha256:{}", sha(&base))), "{}", out(&o)); let m = std::fs::read_to_string(reg.join("persona.MODEL.json")).unwrap(); assert!(m.contains("\"system\": \"You are mindX.\"") && m.contains("\"parameters\": {\"num_ctx\": 2048}") && m.contains("\"stop\": [\"<|im_end|>\"]"), "{m}"); // by path, relative to the Modelfile std::fs::write(&mf, "FROM ./forks/base-F16.gguf\n").unwrap(); assert_eq!(code(&bankml(&["create", "by-path", "-f", mf.to_str().unwrap(), "--registry", r])), 0); // refusals: exit 2 with the reason for (text, why) in [("FROM base-f16\nADAPTER /x\n", "LoRA"), ("FROM base-f16\nPARAMETER mirostat 2\n", "not reproduced"), ("FROM nothing\n", "no such model"), ("SYSTEM x\n", "no FROM")] { std::fs::write(&mf, text).unwrap(); let o = bankml(&["create", "x", "-f", mf.to_str().unwrap(), "--registry", r]); let e = String::from_utf8_lossy(&o.stderr).into_owned(); assert!(code(&o) == 2 && e.contains(why), "{text:?}: {e}"); } let _ = std::fs::remove_dir_all(&d); } /// O5: `bankml convert` refuses what it would not write as llama.cpp writes it (the byte-identity oracle on real /// weights is `convert::tests::oracle_convert_b11192`). #[test] fn convert_refuses_with_the_reason() { let d = dir("convert"); let m = d.join("tiny-llama"); std::fs::create_dir_all(&m).unwrap(); let o = |args: &[&str]| { let r = bankml(args); (code(&r), String::from_utf8_lossy(&r.stderr).into_owned()) }; let out_gguf = d.join("out.gguf"); let (ms, og) = (m.to_str().unwrap(), out_gguf.to_str().unwrap()); std::fs::write(m.join("config.json"), r#"{"architectures": ["Qwen3ForCausalLM"]}"#).unwrap(); let (c, e) = o(&["convert", ms, "-o", og]); assert!(c == 2 && e.contains("Llama architecture"), "{e}"); // a tiny Llama: one BF16 tensor per role, and a tokenizer no one has measured std::fs::write(m.join("config.json"), r#"{"architectures": ["LlamaForCausalLM"], "hidden_size": 4, "num_attention_heads": 2, "num_key_value_heads": 1, "num_hidden_layers": 1, "intermediate_size": 8, "vocab_size": 3, "tie_word_embeddings": true}"#).unwrap(); let ts = [("model.embed_tokens.weight", vec![3u64, 4]), ("model.layers.0.input_layernorm.weight", vec![4]), ("model.layers.0.self_attn.q_proj.weight", vec![4, 4]), ("model.layers.0.self_attn.k_proj.weight", vec![2, 4]), ("model.norm.weight", vec![4])]; let (mut hdr, mut data, mut off) = (Vec::new(), Vec::new(), 0u64); for (n, sh) in &ts { let len = sh.iter().product::() * 2; hdr.push(format!("\"{n}\": {{\"dtype\": \"BF16\", \"shape\": {sh:?}, \"data_offsets\": [{off}, {}]}}", off + len)); data.extend(std::iter::repeat_n(0x3fu8, len as usize)); off += len; } let h = format!("{{{}}}", hdr.join(", ")); let mut st = (h.len() as u64).to_le_bytes().to_vec(); st.extend(h.as_bytes()); st.extend(data); std::fs::write(m.join("model.safetensors"), st).unwrap(); std::fs::write(m.join("tokenizer.json"), r#"{"model": {"type": "BPE", "vocab": {"a": 0, "b": 1, "ab": 2}, "merges": ["a b"]}, "added_tokens": [], "pre_tokenizer": null}"#).unwrap(); let (c, e) = o(&["convert", ms, "-o", og]); assert!(c == 2 && e.contains("tokenizer not recognised"), "{e}"); assert!(!out_gguf.exists()); std::fs::write(m.join("README.md"), "---\nlicense: mit\n---\n").unwrap(); let (c, e) = o(&["convert", ms, "-o", og]); assert!(c == 2 && e.contains("README.md"), "{e}"); let (c, e) = o(&["convert", ms, "-o", og, "--outtype", "q8_0"]); assert!(c == 2 && e.contains("f16 only"), "{e}"); let _ = std::fs::remove_dir_all(&d); }