10 Commits

Author SHA1 Message Date
Kieran Klukas
61061d3571 feat: add --json flag for test output, env var config, and --version
- Add --json flag to `tools test` for machine-readable CI output
- Support ECTF_TOKEN, ECTF_GIT_URL, ECTF_API_URL env vars for config
- Add --version flag via clap

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 14:46:33 -05:00
Kieran Klukas
20041ddec5 feat: allow using env phones 2026-03-04 14:45:50 -05:00
Kieran Klukas
ec238f3f77 feat: add version command 2026-03-04 14:41:23 -05:00
Kieran Klukas
9a3afab14d feat: make write_max fill the slots 2026-03-04 11:58:15 -05:00
Kieran Klukas
81e79a20cd feat: add timing 2026-03-04 11:56:07 -05:00
Kieran Klukas
e1fb2a10ba chore: drain uart 2026-03-04 10:13:25 -05:00
Kieran Klukas
acd1ff0112 fix: switch linux builds to musl for glibc compatibility
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 21:45:24 -08:00
Kieran Klukas
1e3e176c9c chore: bump version to 0.2.0 2026-02-25 21:39:16 -08:00
Kieran Klukas
c3de7f5e74 feat: add api tools 2026-02-25 21:32:27 -08:00
Kieran Klukas
d952d96a6c feat: add shell completions 2026-02-25 20:19:26 -05:00
8 changed files with 2877 additions and 15 deletions

View File

@@ -19,12 +19,12 @@ jobs:
- target: x86_64-apple-darwin
os: macos-latest
name: ectf-tools-x86_64-apple-darwin
- target: x86_64-unknown-linux-gnu
- target: x86_64-unknown-linux-musl
os: ubuntu-latest
name: ectf-tools-x86_64-unknown-linux-gnu
- target: aarch64-unknown-linux-gnu
name: ectf-tools-x86_64-unknown-linux-musl
- target: aarch64-unknown-linux-musl
os: ubuntu-latest
name: ectf-tools-aarch64-unknown-linux-gnu
name: ectf-tools-aarch64-unknown-linux-musl
runs-on: ${{ matrix.os }}
@@ -37,14 +37,18 @@ jobs:
targets: ${{ matrix.target }}
- name: Install cross-compilation tools
if: matrix.target == 'aarch64-unknown-linux-gnu'
if: contains(matrix.target, 'linux-musl')
run: |
sudo apt-get update
sudo apt-get install -y gcc-aarch64-linux-gnu
sudo apt-get install -y musl-tools
if [ "${{ matrix.target }}" = "aarch64-unknown-linux-musl" ]; then
sudo apt-get install -y gcc-aarch64-linux-gnu
fi
- name: Build
env:
CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER: aarch64-linux-gnu-gcc
CARGO_TARGET_AARCH64_UNKNOWN_LINUX_MUSL_LINKER: aarch64-linux-gnu-gcc
CC_aarch64_unknown_linux_musl: aarch64-linux-gnu-gcc
run: cargo build --release --target ${{ matrix.target }}
- name: Package

1193
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
[package]
name = "ectf-tools"
version = "0.1.0"
version = "0.3.0"
edition = "2024"
[dependencies]
@@ -13,4 +13,6 @@ hmac = "0.12"
sha2 = "0.10"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
serde_yaml = "0.9"
clap_complete = "4"
reqwest = { version = "0.12", default-features = false, features = ["blocking", "multipart", "json", "rustls-tls"] }

View File

@@ -4,12 +4,20 @@
Drop-in replacement for MITRE's `uvx ectf` CLI, rewritten in Rust with reliable serial I/O. Uses raw termios instead of pyserial to avoid macOS CDC-ACM data corruption bugs.
## Usage
## Install
```bash
brew install taciturnaxolotl/tap/ectf-tools
```
Or build from source:
```bash
cargo build --release
```
## Usage
### HSM Host Tools
```bash

648
src/api.rs Normal file
View File

@@ -0,0 +1,648 @@
use std::io::{Read as _, Write as _};
use std::net::TcpStream;
use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::time::{Duration, Instant};
use anyhow::{Context, Result, bail};
use crate::config::Config;
use crate::log;
use crate::protocol::{HSMIntf, Opcode};
use crate::serial;
// ─── Types ───
#[derive(Debug, serde::Deserialize)]
#[allow(dead_code)]
pub struct Flow {
pub id: String,
pub submit_time: String,
pub name: String,
pub completed: bool,
pub params: serde_json::Value,
pub jobs: Vec<Job>,
}
#[derive(Debug, serde::Deserialize)]
#[allow(dead_code)]
pub struct Job {
pub name: String,
pub id: String,
pub has_artifacts: bool,
pub private: bool,
pub status: String,
}
fn status_color(s: &str) -> &'static str {
match s {
"succeeded" | "completed" => "\x1b[38;5;114m",
"queued" | "running" | "pending" => "\x1b[38;5;221m",
"canceled" => "\x1b[38;5;242m",
"failed" => "\x1b[38;5;203m",
_ => "",
}
}
fn titlecase(s: &str) -> String {
let mut c = s.chars();
match c.next() {
None => String::new(),
Some(f) => f.to_uppercase().collect::<String>() + c.as_str(),
}
}
fn relative_time(iso: &str) -> String {
// Parse ISO 8601 timestamp and compute relative time
let ts = iso
.split('T')
.next()
.and_then(|d| {
let parts: Vec<&str> = d.split('-').collect();
if parts.len() == 3 {
let y: i64 = parts[0].parse().ok()?;
let m: i64 = parts[1].parse().ok()?;
let d: i64 = parts[2].parse().ok()?;
Some(y * 365 + m * 30 + d)
} else {
None
}
});
let now = {
let output = std::process::Command::new("date")
.arg("+%Y-%m-%d")
.output()
.ok();
output.and_then(|o| {
let s = String::from_utf8(o.stdout).ok()?;
let parts: Vec<&str> = s.trim().split('-').collect();
if parts.len() == 3 {
let y: i64 = parts[0].parse().ok()?;
let m: i64 = parts[1].parse().ok()?;
let d: i64 = parts[2].parse().ok()?;
Some(y * 365 + m * 30 + d)
} else {
None
}
})
};
match (ts, now) {
(Some(t), Some(n)) => {
let days = n - t;
if days < 0 {
"in the future".to_string()
} else if days == 0 {
"today".to_string()
} else if days == 1 {
"yesterday".to_string()
} else if days < 7 {
format!("{days} days ago")
} else if days < 14 {
"a week ago".to_string()
} else if days < 30 {
format!("{} weeks ago", days / 7)
} else if days < 60 {
"a month ago".to_string()
} else if days < 365 {
format!("{} months ago", days / 30)
} else {
format!("{} years ago", days / 365)
}
}
_ => iso.to_string(),
}
}
const RESET: &str = "\x1b[0m";
const BOLD: &str = "\x1b[1m";
const UNDERLINE: &str = "\x1b[4m";
const CYAN: &str = "\x1b[36m";
const YELLOW: &str = "\x1b[33m";
const GREEN: &str = "\x1b[32m";
const RED: &str = "\x1b[31m";
const BLUE: &str = "\x1b[34m";
/// Auto-highlight a value like Rich's highlighter:
/// UUIDs/hashes → yellow, booleans → green/red, URLs → blue, else → cyan
fn highlight(val: &str) -> String {
if val.eq_ignore_ascii_case("true") {
format!("{GREEN}{val}{RESET}")
} else if val.eq_ignore_ascii_case("false") {
format!("{RED}{val}{RESET}")
} else if val.starts_with("http://") || val.starts_with("https://") || val.starts_with("ssh://") || val.starts_with("git@") {
format!("{BLUE}{val}{RESET}")
} else if val.contains('-') && val.len() >= 32 && val.chars().all(|c| c.is_ascii_hexdigit() || c == '-') {
// UUIDs → yellow
format!("{YELLOW}{val}{RESET}")
} else if val.len() >= 32 && val.chars().all(|c| c.is_ascii_hexdigit()) {
// Commit hashes → blue
format!("{BLUE}{val}{RESET}")
} else {
format!("{CYAN}{val}{RESET}")
}
}
// ─── Client ───
pub struct ApiClient {
pub config: Config,
client: reqwest::blocking::Client,
}
impl ApiClient {
pub fn new() -> Result<Self> {
let config = Config::load()?;
let client = reqwest::blocking::Client::new();
Ok(Self { config, client })
}
fn url(&self, path: &str) -> String {
// Trailing slash must come before query string
if let Some((base, query)) = path.split_once('?') {
format!("{}/api/{base}/?{query}", self.config.api_url)
} else {
format!("{}/api/{path}/", self.config.api_url)
}
}
fn get(&self, path: &str) -> Result<reqwest::blocking::Response> {
let resp = self
.client
.get(self.url(path))
.bearer_auth(&self.config.token)
.send()?;
check_status(resp)
}
fn post_json(
&self,
path: &str,
body: &serde_json::Value,
) -> Result<reqwest::blocking::Response> {
let resp = self
.client
.post(self.url(path))
.bearer_auth(&self.config.token)
.json(body)
.send()?;
check_status(resp)
}
fn post_file(
&self,
path: &str,
name: &str,
data: Vec<u8>,
) -> Result<reqwest::blocking::Response> {
let part = reqwest::blocking::multipart::Part::bytes(data).file_name(name.to_string());
let form = reqwest::blocking::multipart::Form::new().part("file", part);
let resp = self
.client
.post(self.url(path))
.bearer_auth(&self.config.token)
.multipart(form)
.send()?;
check_status(resp)
}
// ─── Flow operations ───
pub fn flow_list(&self, flow: &str, count: usize) -> Result<Vec<Flow>> {
let resp = self.get(&format!("flow/{flow}?num={count}"))?;
Ok(resp.json()?)
}
pub fn flow_info(&self, flow: &str, id: &str) -> Result<Flow> {
let resp = self.get(&format!("flow/{flow}/{id}"))?;
Ok(resp.json()?)
}
pub fn flow_submit(&self, flow: &str, body: &serde_json::Value) -> Result<String> {
let resp = self.post_json(&format!("flow/{flow}"), body)?;
Ok(resp.text()?)
}
pub fn flow_cancel(&self, flow: &str, id: &str) -> Result<()> {
let resp = self
.client
.post(self.url(&format!("flow/{flow}/{id}/cancel")))
.bearer_auth(&self.config.token)
.send()?;
check_status(resp)?;
Ok(())
}
pub fn flow_pull(&self, flow: &str, job_id: &str) -> Result<Vec<u8>> {
let resp = self.get(&format!("flow/{flow}/job/{job_id}"))?;
Ok(resp.bytes()?.to_vec())
}
// ─── Package operations ───
pub fn list_packages(&self) -> Result<Vec<String>> {
let resp = self.get("package")?;
Ok(resp.json()?)
}
pub fn get_package(&self, package: &str) -> Result<Vec<u8>> {
let resp = self.get(&format!("package/{package}"))?;
Ok(resp.bytes()?.to_vec())
}
// ─── Flag operations ───
pub fn submit_flag(&self, flag: &str, body: &serde_json::Value) -> Result<String> {
let resp = self.post_json(&format!("flag/{flag}"), body)?;
Ok(resp.text()?)
}
pub fn submit_flag_file(&self, flag: &str, name: &str, data: Vec<u8>) -> Result<String> {
let resp = self.post_file(&format!("flag/{flag}"), name, data)?;
Ok(resp.text()?)
}
}
fn check_status(resp: reqwest::blocking::Response) -> Result<reqwest::blocking::Response> {
let status = resp.status();
let url = resp.url().to_string();
if status.is_success() {
return Ok(resp);
}
let code = status.as_u16();
let text = resp.text().unwrap_or_default();
log::debug(&format!("API {code} from {url}: {text}"));
// Try to extract {"detail": "..."} from JSON responses
let detail = serde_json::from_str::<serde_json::Value>(&text)
.ok()
.and_then(|v| v["detail"].as_str().map(|s| s.to_string()))
.unwrap_or(text);
match code {
401 => bail!("Authentication failed. Check your API token."),
_ => bail!("{detail}"),
}
}
// ─── Command implementations ───
fn flow_overall_status(f: &Flow) -> String {
// Derive status from jobs like the Python tool does
if f.jobs.iter().any(|j| j.status == "failed") {
"Failed".to_string()
} else if f.completed {
"Succeeded".to_string()
} else if f.jobs.iter().any(|j| j.status == "running") {
"Running".to_string()
} else {
"Queued".to_string()
}
}
pub fn cmd_flow_list(flow: &str, count: usize) -> Result<()> {
let api = ApiClient::new()?;
let flows = api.flow_list(flow, count)?;
if flows.is_empty() {
log::info("No flows found");
return Ok(());
}
let flow_title = titlecase(flow);
let id_header = format!("{flow_title} ID");
// Calculate column widths
let id_w = flows.iter().map(|f| f.id.len()).max().unwrap_or(36).max(id_header.len());
let time_w = 14; // "When Submitted"
let status_w = 9;
let total = id_w + time_w + status_w + 10; // 10 for borders + padding
let title = format!("Submitted {flow_title} Flows");
let pad = total.saturating_sub(title.len()) / 2;
// Title
println!("{BOLD}{:>pad$}{title}{RESET}", "");
// Top border
println!("{:━<id_w$}━━┳{:━<time_w$}━━┳{:━<status_w$}━━┓", "", "", "");
// Header
println!(
"{BOLD}{:<id_w$}{RESET}{BOLD}{:<time_w$}{RESET}{BOLD}{:<status_w$}{RESET}",
id_header, "When Submitted", "Status"
);
// Header separator
println!("{:━<id_w$}━━╇{:━<time_w$}━━╇{:━<status_w$}━━┩", "", "", "");
for f in &flows {
let status = flow_overall_status(f);
let sc = status_color(&status.to_lowercase());
let when = relative_time(&f.submit_time);
println!(
"{:<id_w$}{:<time_w$}{sc}{:<status_w$}{RESET}",
f.id, when, status
);
}
// Bottom border
println!("{:─<id_w$}──┴{:─<time_w$}──┴{:─<status_w$}──┘", "", "", "");
Ok(())
}
pub fn cmd_flow_info(flow: &str, id: &str) -> Result<()> {
let api = ApiClient::new()?;
let f = api.flow_info(flow, id)?;
let status = flow_overall_status(&f);
let sc = status_color(&status.to_lowercase());
let when = relative_time(&f.submit_time);
println!("{BOLD}{UNDERLINE}Flow {flow}{RESET}");
println!("├── ID: {}", highlight(&f.id));
// Format like Python: "2026-02-11 22:05:47+00:00"
let display_time = f.submit_time
.replace('T', " ")
.split('.')
.next()
.unwrap_or(&f.submit_time)
.to_string()
+ "+00:00";
println!("├── Submitted: {} ({when})", highlight(&display_time));
println!("├── Status: {sc}{status}{RESET}");
// Parameters
if let Some(obj) = f.params.as_object() {
if !obj.is_empty() {
println!("├── Parameters");
let params: Vec<_> = obj.iter().collect();
for (i, (k, v)) in params.iter().enumerate() {
let connector = if i == params.len() - 1 { "" } else { "" };
let fallback = v.to_string();
let val = v.as_str().unwrap_or(&fallback);
println!("{connector}── {k}: {}", highlight(val));
}
}
}
// Jobs
if !f.jobs.is_empty() {
println!("└── Jobs");
for (i, job) in f.jobs.iter().enumerate() {
let is_last = i == f.jobs.len() - 1;
let branch = if is_last { "" } else { "" };
let prefix = if is_last { " " } else { "" };
let jsc = status_color(&job.status);
println!(" {branch}── {BOLD}{}{RESET}", job.name);
println!(" {prefix} ├── ID: {}", highlight(&job.id));
println!(" {prefix} ├── Has Output: {}", highlight(&titlecase(&job.has_artifacts.to_string())));
println!(" {prefix} ├── Private: {}", highlight(&titlecase(&job.private.to_string())));
println!(" {prefix} └── Status: {jsc}{}{RESET}", titlecase(&job.status));
}
}
Ok(())
}
pub fn cmd_flow_submit(flow: &str, commit: &str, url: Option<&str>) -> Result<()> {
let api = ApiClient::new()?;
let git_url = url
.map(|s| s.to_string())
.unwrap_or_else(|| api.config.git_url.clone());
let body = serde_json::json!({
"git_url": git_url,
"commit_hash": commit,
});
let id = api.flow_submit(flow, &body)?;
log::success(&format!("Submitted {flow} flow: {id}"));
Ok(())
}
pub fn cmd_flow_cancel(flow: &str, id: &str) -> Result<()> {
let api = ApiClient::new()?;
api.flow_cancel(flow, id)?;
log::success(&format!("Cancelled flow {id}"));
Ok(())
}
pub fn cmd_flow_get(flow: &str, job_id: &str, out: &PathBuf) -> Result<()> {
let api = ApiClient::new()?;
let data = api.flow_pull(flow, job_id)?;
std::fs::write(out, &data)?;
log::success(&format!("Downloaded to {}", out.display()));
Ok(())
}
pub fn cmd_submit(commit: &str) -> Result<()> {
cmd_flow_submit("submit", commit, None)
}
pub fn cmd_photo(file: &PathBuf) -> Result<()> {
let api = ApiClient::new()?;
let data = std::fs::read(file).context("Failed to read file")?;
let name = file
.file_name()
.context("No filename")?
.to_str()
.context("Invalid filename")?;
let resp = api.submit_flag_file("photo", name, data)?;
log::success(&format!("Photo submitted: {resp}"));
Ok(())
}
pub fn cmd_design(file: &PathBuf) -> Result<()> {
let api = ApiClient::new()?;
let data = std::fs::read(file).context("Failed to read file")?;
let name = file
.file_name()
.context("No filename")?
.to_str()
.context("Invalid filename")?;
let resp = api.submit_flag_file("design", name, data)?;
log::success(&format!("Design doc submitted: {resp}"));
Ok(())
}
pub fn cmd_steal(team: &str, digest: &str) -> Result<()> {
let api = ApiClient::new()?;
let body = serde_json::json!({
"team": team,
"digest": digest,
});
let resp = api.submit_flag("steal", &body)?;
log::success(&format!("Steal submitted: {resp}"));
Ok(())
}
pub fn cmd_list_packages() -> Result<()> {
let api = ApiClient::new()?;
let packages = api.list_packages()?;
if packages.is_empty() {
log::info("No packages available");
} else {
for p in &packages {
log::info(p);
}
}
Ok(())
}
pub fn cmd_get_package(package: &str, out: Option<&PathBuf>, force: bool) -> Result<()> {
let api = ApiClient::new()?;
let data = api.get_package(package)?;
let path = out.cloned().unwrap_or_else(|| PathBuf::from(package));
if !force && path.exists() {
bail!(
"File {} already exists (use --force to overwrite)",
path.display()
);
}
std::fs::write(&path, &data)?;
log::success(&format!("Downloaded {} to {}", package, path.display()));
Ok(())
}
// ─── Remote scenario ───
const REMOTE_HOST: &str = "54.163.176.58";
pub fn cmd_remote_connect(
mgmt_port: &str,
transfer_port: &str,
team: &str,
timeout: u64,
) -> Result<()> {
let api = ApiClient::new()?;
// Submit remote flow
let body = serde_json::json!({"team": team});
let flow_id = api.flow_submit("remote", &body)?;
log::info(&format!("Submitted remote flow: {flow_id}"));
// Poll for get_ports job
log::info("Waiting for port assignment...");
let port: u16 = loop {
std::thread::sleep(Duration::from_secs(3));
let flow = api.flow_info("remote", &flow_id)?;
if let Some(job) = flow.jobs.iter().find(|j| j.name == "get_ports") {
match job.status.as_str() {
"succeeded" => {
let data = api.flow_pull("remote", &job.id)?;
let port_str = String::from_utf8(data)?.trim().to_string();
break port_str.parse().context("Invalid port number")?;
}
"canceled" | "failed" => bail!("Port assignment failed"),
_ => continue,
}
}
};
log::info(&format!("Assigned port {port}, connecting..."));
// Connect to remote server
let tcp = TcpStream::connect((REMOTE_HOST, port)).context("Failed to connect to remote")?;
tcp.set_nodelay(true)?;
log::success("Connected to remote server");
// Open serial ports
let transfer_file = serial::open_serial(transfer_port, None)?;
let mgmt_port_str = mgmt_port.to_string();
let done = Arc::new(AtomicBool::new(false));
// Bridge: TCP → transfer serial
let tcp_r = tcp.try_clone()?;
let mut transfer_w = transfer_file.try_clone()?;
let done1 = done.clone();
let h1 = std::thread::spawn(move || {
let mut reader = tcp_r;
let mut buf = [0u8; 4096];
while !done1.load(Ordering::Relaxed) {
match reader.read(&mut buf) {
Ok(0) => break,
Ok(n) => {
if transfer_w.write_all(&buf[..n]).is_err() {
break;
}
}
Err(_) => break,
}
}
});
// Bridge: transfer serial → TCP
let mut tcp_w = tcp.try_clone()?;
let done2 = done.clone();
let h2 = std::thread::spawn(move || {
let mut reader = transfer_file;
let mut buf = [0u8; 4096];
while !done2.load(Ordering::Relaxed) {
match reader.read(&mut buf) {
Ok(0) => continue,
Ok(n) => {
if tcp_w.write_all(&buf[..n]).is_err() {
break;
}
}
Err(_) => break,
}
}
});
// HSM listen on management port
let done3 = done.clone();
let h3 = std::thread::spawn(move || {
if done3.load(Ordering::Relaxed) {
return;
}
match HSMIntf::open(&mgmt_port_str) {
Ok(mut hsm) => {
let _ = hsm.send_respond(Opcode::Listen, &[]);
}
Err(e) => log::warning(&format!("Management port error: {e}")),
}
});
// Poll flow status with timeout
let start = Instant::now();
let timeout_dur = Duration::from_secs(timeout);
loop {
std::thread::sleep(Duration::from_secs(3));
if start.elapsed() > timeout_dur {
log::warning("Remote scenario timed out");
break;
}
match api.flow_info("remote", &flow_id) {
Ok(flow) => {
if let Some(job) = flow
.jobs
.iter()
.find(|j| j.name == "run_remote_scenario")
{
match job.status.as_str() {
"succeeded" => {
log::success("Remote scenario completed successfully");
break;
}
"canceled" | "failed" => {
log::error("Remote scenario failed");
break;
}
_ => {
log::debug(&format!("Scenario status: {}", job.status));
}
}
}
}
Err(e) => log::warning(&format!("Failed to check status: {e}")),
}
}
done.store(true, Ordering::Relaxed);
// Shutdown TCP to unblock threads
let _ = tcp.shutdown(std::net::Shutdown::Both);
let _ = h1.join();
let _ = h2.join();
let _ = h3.join();
Ok(())
}

69
src/config.rs Normal file
View File

@@ -0,0 +1,69 @@
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
pub const DEFAULT_API_URL: &str = "https://api.ectf.mitre.org";
#[derive(Debug, Serialize, Deserialize)]
pub struct Config {
pub token: String,
pub git_url: String,
#[serde(default = "default_api_url")]
pub api_url: String,
}
fn default_api_url() -> String {
DEFAULT_API_URL.to_string()
}
impl Config {
pub fn path() -> Result<PathBuf> {
let home = std::env::var("HOME").context("HOME not set")?;
Ok(PathBuf::from(home).join(".ectf-config"))
}
pub fn exists() -> bool {
Self::path().map(|p| p.exists()).unwrap_or(false)
}
pub fn load() -> Result<Self> {
// If all required env vars are set, use them directly (for CI)
if let (Ok(token), Ok(git_url)) = (
std::env::var("ECTF_TOKEN"),
std::env::var("ECTF_GIT_URL"),
) {
let api_url = std::env::var("ECTF_API_URL")
.unwrap_or_else(|_| DEFAULT_API_URL.to_string());
return Ok(Self { token, git_url, api_url });
}
let path = Self::path()?;
let contents = std::fs::read_to_string(&path).with_context(|| {
format!(
"Config not found at {}. Run `ectf-tools config` first, or set ECTF_TOKEN and ECTF_GIT_URL env vars.",
path.display()
)
})?;
let mut cfg: Self = serde_yaml::from_str(&contents).context("Failed to parse config file")?;
// Allow env vars to override individual fields from the config file
if let Ok(token) = std::env::var("ECTF_TOKEN") {
cfg.token = token;
}
if let Ok(git_url) = std::env::var("ECTF_GIT_URL") {
cfg.git_url = git_url;
}
if let Ok(api_url) = std::env::var("ECTF_API_URL") {
cfg.api_url = api_url;
}
Ok(cfg)
}
pub fn save(&self) -> Result<()> {
let path = Self::path()?;
let contents = serde_yaml::to_string(self)?;
std::fs::write(&path, contents)?;
Ok(())
}
}

View File

@@ -1,3 +1,5 @@
mod api;
mod config;
mod fthr;
mod log;
mod protocol;
@@ -30,6 +32,7 @@ const UUID_LEN: usize = 16;
#[derive(Parser)]
#[command(
name = "ectf-tools",
version,
about = "eCTF host tools — Rust reimplementation",
long_about = "Drop-in replacement for MITRE's ectf CLI.\n\
Reliable serial I/O using raw termios instead of pyserial.",
@@ -68,6 +71,164 @@ enum TopLevel {
/// Shell to generate completions for
shell: Shell,
},
/// Create or update the configuration file
Config {
/// Team API token
#[arg(long)]
token: Option<String>,
/// Git repository URL
#[arg(long)]
git_url: Option<String>,
/// API URL
#[arg(long)]
api_url: Option<String>,
/// Overwrite existing config
#[arg(short, long)]
force: bool,
},
/// Open the API documentation website
Docs,
/// Open the eCTF rules website
Rules,
/// Interact with the API
#[command(arg_required_else_help = true)]
Api {
#[command(subcommand)]
command: ApiCmd,
},
}
// ─── API subcommands ───
#[derive(Subcommand)]
enum ApiCmd {
/// Submit your design to Handoff
Submit {
/// Git commit hash
commit: String,
},
/// Submit a PNG for the Team Photo flag
Photo {
/// PNG file to submit
file: PathBuf,
},
/// Submit a PDF for the Design Doc flag
Design {
/// PDF file to submit
file: PathBuf,
},
/// Submit a digest for the Steal Design flag
Steal {
/// Target team identifier
team: String,
/// Hex digest string
digest: String,
},
/// Get the list of available packages
List,
/// Download an Attack Package
Get {
/// Package name
package: String,
/// Output path
#[arg(short, long)]
out: Option<PathBuf>,
/// Overwrite if file exists
#[arg(short, long)]
force: bool,
},
/// Test that your design can be cloned by the API
#[command(arg_required_else_help = true)]
Clone {
#[command(subcommand)]
command: FlowCmd,
},
/// Test your design with the API
#[command(arg_required_else_help = true)]
Test {
#[command(subcommand)]
command: FlowCmd,
},
/// Submit to the remote attack scenario
#[command(arg_required_else_help = true)]
Remote {
#[command(subcommand)]
command: RemoteCmd,
},
}
#[derive(Subcommand)]
enum FlowCmd {
/// List recent flows
Ls {
/// Number of flows to show (0 for all)
#[arg(short, long, default_value = "5")]
number: usize,
},
/// Get flow details
Info {
/// Flow ID
id: String,
},
/// Submit a commit
Submit {
/// Git commit hash
commit: String,
/// Override git URL
#[arg(short, long)]
url: Option<String>,
},
/// Cancel a flow
Cancel {
/// Flow ID
id: String,
},
/// Download job output
Get {
/// Job ID
job_id: String,
/// Output file path
out: PathBuf,
},
}
#[derive(Subcommand)]
enum RemoteCmd {
/// Connect to the remote attack scenario
Connect {
/// HSM management serial port
management_port: String,
/// Transfer interface UART port
transfer_port: String,
/// Target team identifier
team: String,
/// Timeout in seconds
#[arg(short, long, default_value = "120")]
timeout: u64,
},
/// List recent remote flows
Ls {
/// Number of flows to show (0 for all)
#[arg(short, long, default_value = "5")]
number: usize,
},
/// Get remote flow details
Info {
/// Flow ID
id: String,
},
/// Cancel a remote flow
Cancel {
/// Flow ID
id: String,
},
/// Download remote job output
Get {
/// Job ID
job_id: String,
/// Output file path
out: PathBuf,
},
}
// ─── Tools subcommands ───
@@ -122,6 +283,21 @@ enum ToolsCmd {
/// Destination slot on this HSM (0-7)
write_slot: u8,
},
/// Run the test suite against real hardware
Test {
/// 6-char hex PIN
pin: String,
/// Group ID (decimal or 0xHEX)
gid: String,
/// Second HSM serial port for transfer tests
transfer_port: Option<String>,
/// Skip tests requiring a second HSM
#[arg(long)]
no_transfer: bool,
/// Output results as JSON for CI
#[arg(long)]
json: bool,
},
}
// ─── HW subcommands ───
@@ -273,14 +449,169 @@ fn main() {
}
}
fn open_url(url: &str) -> Result<()> {
#[cfg(target_os = "macos")]
{
std::process::Command::new("open").arg(url).spawn()?;
}
#[cfg(not(target_os = "macos"))]
{
std::process::Command::new("xdg-open").arg(url).spawn()?;
}
Ok(())
}
fn run(cli: Cli) -> Result<()> {
match cli.command {
TopLevel::Tools { port, command } => run_tools(&port, command),
TopLevel::Hw { port, command } => run_hw(&port, command),
TopLevel::Completions { shell } => {
clap_complete::generate(shell, &mut Cli::command(), "ectf-tools", &mut std::io::stdout());
clap_complete::generate(
shell,
&mut Cli::command(),
"ectf-tools",
&mut std::io::stdout(),
);
Ok(())
}
TopLevel::Config {
token,
git_url,
api_url,
force,
} => run_config(token, git_url, api_url, force),
TopLevel::Docs => {
open_url("https://sb.ectf.mitre.org/")?;
log::success("Opened API documentation");
Ok(())
}
TopLevel::Rules => {
open_url("https://rules.ectf.mitre.org/")?;
log::success("Opened eCTF rules");
Ok(())
}
TopLevel::Api { command } => run_api(command),
}
}
// ─── Config ───
fn prompt(label: &str, default: Option<&str>) -> Result<String> {
use std::io::Write;
match default {
Some(d) => eprint!("{label} [{d}]: "),
None => eprint!("{label}: "),
}
std::io::stderr().flush()?;
let mut input = String::new();
std::io::stdin().read_line(&mut input)?;
let input = input.trim().to_string();
if input.is_empty() {
default
.map(|d| d.to_string())
.context(format!("{label} is required"))
} else {
Ok(input)
}
}
fn run_config(
token: Option<String>,
git_url: Option<String>,
api_url: Option<String>,
force: bool,
) -> Result<()> {
let has_args = token.is_some() || git_url.is_some() || api_url.is_some() || force;
// No args: print existing config or prompt for new one
if !has_args && config::Config::exists() {
let cfg = config::Config::load()?;
log::info(&format!("token: {}", cfg.token));
log::info(&format!("git_url: {}", cfg.git_url));
log::info(&format!("api_url: {}", cfg.api_url));
return Ok(());
}
let existing = if config::Config::exists() && !force {
Some(config::Config::load()?)
} else {
None
};
let token = match token {
Some(t) => t,
None => prompt("Token", existing.as_ref().map(|c| c.token.as_str()))?,
};
let git_url = match git_url {
Some(g) => g,
None => prompt("Git URL", existing.as_ref().map(|c| c.git_url.as_str()))?,
};
let api_url = match api_url {
Some(a) => a,
None => prompt(
"API URL",
Some(
existing
.as_ref()
.map(|c| c.api_url.as_str())
.unwrap_or(config::DEFAULT_API_URL),
),
)?,
};
config::Config { token, git_url, api_url }.save()?;
log::success(&format!(
"Config saved to {}",
config::Config::path()?.display()
));
Ok(())
}
// ─── API commands ───
fn run_api(cmd: ApiCmd) -> Result<()> {
match cmd {
ApiCmd::Submit { commit } => api::cmd_submit(&commit),
ApiCmd::Photo { file } => api::cmd_photo(&file),
ApiCmd::Design { file } => api::cmd_design(&file),
ApiCmd::Steal { team, digest } => api::cmd_steal(&team, &digest),
ApiCmd::List => api::cmd_list_packages(),
ApiCmd::Get {
package,
out,
force,
} => api::cmd_get_package(&package, out.as_ref(), force),
ApiCmd::Clone { command } => run_flow("clone", command),
ApiCmd::Test { command } => run_flow("test", command),
ApiCmd::Remote { command } => run_remote(command),
}
}
fn run_flow(flow: &str, cmd: FlowCmd) -> Result<()> {
match cmd {
FlowCmd::Ls { number } => api::cmd_flow_list(flow, number),
FlowCmd::Info { id } => api::cmd_flow_info(flow, &id),
FlowCmd::Submit { commit, url } => {
api::cmd_flow_submit(flow, &commit, url.as_deref())
}
FlowCmd::Cancel { id } => api::cmd_flow_cancel(flow, &id),
FlowCmd::Get { job_id, out } => api::cmd_flow_get(flow, &job_id, &out),
}
}
fn run_remote(cmd: RemoteCmd) -> Result<()> {
match cmd {
RemoteCmd::Connect {
management_port,
transfer_port,
team,
timeout,
} => api::cmd_remote_connect(&management_port, &transfer_port, &team, timeout),
RemoteCmd::Ls { number } => api::cmd_flow_list("remote", number),
RemoteCmd::Info { id } => api::cmd_flow_info("remote", &id),
RemoteCmd::Cancel { id } => api::cmd_flow_cancel("remote", &id),
RemoteCmd::Get { job_id, out } => api::cmd_flow_get("remote", &job_id, &out),
}
}
@@ -434,11 +765,622 @@ fn run_tools(port: &str, cmd: ToolsCmd) -> Result<()> {
"Receive successful. Wrote file to local slot {write_slot}"
));
}
ToolsCmd::Test {
pin,
gid,
transfer_port,
no_transfer,
json,
} => {
validate_pin(&pin)?;
let gid = parse_gid(&gid)?;
if !no_transfer && transfer_port.is_none() {
bail!("Transfer port required unless --no-transfer is passed");
}
let mut hsm2 = match &transfer_port {
Some(p) if !no_transfer => {
Some(HSMIntf::open(p).context("Failed to open transfer serial port")?)
}
_ => None,
};
return run_test(&mut hsm, hsm2.as_mut(), &pin, gid, no_transfer, json);
}
}
Ok(())
}
// ─── Test runner ───
fn write_frame(pin: &str, slot: u8, gid: u16, filename: &str, content: &[u8]) -> Vec<u8> {
let mut name_buf = [0u8; MAX_NAME_LEN];
let name_bytes = filename.as_bytes();
name_buf[..name_bytes.len().min(MAX_NAME_LEN)]
.copy_from_slice(&name_bytes[..name_bytes.len().min(MAX_NAME_LEN)]);
let uuid_bytes = *uuid::Uuid::new_v4().as_bytes();
let mut frame = Vec::with_capacity(59 + content.len());
frame.extend_from_slice(pin.as_bytes());
frame.push(slot);
frame.extend_from_slice(&gid.to_le_bytes());
frame.extend_from_slice(&name_buf);
frame.extend_from_slice(&uuid_bytes);
frame.extend_from_slice(&(content.len() as u16).to_le_bytes());
frame.extend_from_slice(content);
frame
}
fn read_frame(pin: &str, slot: u8) -> Vec<u8> {
let mut frame = Vec::with_capacity(7);
frame.extend_from_slice(pin.as_bytes());
frame.push(slot);
frame
}
fn recv_frame(pin: &str, read_slot: u8, write_slot: u8) -> Vec<u8> {
let mut frame = Vec::with_capacity(8);
frame.extend_from_slice(pin.as_bytes());
frame.push(read_slot);
frame.push(write_slot);
frame
}
fn run_test(
hsm: &mut HSMIntf,
mut hsm2: Option<&mut HSMIntf>,
pin: &str,
gid: u16,
no_transfer: bool,
json: bool,
) -> Result<()> {
struct TestResult {
name: &'static str,
passed: bool,
duration_secs: f64,
error: Option<String>,
}
let mut results: Vec<TestResult> = Vec::new();
macro_rules! run_test {
($name:expr, $body:expr) => {{
if !json {
log::info(&format!("Running: {}", $name));
}
let start = std::time::Instant::now();
match (|| -> Result<()> { $body })() {
Ok(()) => {
let elapsed = start.elapsed();
if !json {
log::success(&format!("{} passed ({:.1}s)", $name, elapsed.as_secs_f64()));
}
results.push(TestResult {
name: $name,
passed: true,
duration_secs: elapsed.as_secs_f64(),
error: None,
});
}
Err(e) => {
let elapsed = start.elapsed();
if !json {
log::error(&format!("{} FAILED ({:.1}s): {e}", $name, elapsed.as_secs_f64()));
}
results.push(TestResult {
name: $name,
passed: false,
duration_secs: elapsed.as_secs_f64(),
error: Some(e.to_string()),
});
}
}
}};
}
// Timing limits from the eCTF spec (milliseconds)
const TIME_LIST: u128 = 500;
const TIME_READ: u128 = 3000;
const TIME_WRITE: u128 = 3000;
const TIME_RECEIVE: u128 = 3000;
const TIME_INTERROGATE: u128 = 1000;
const TIME_BAD_PIN: u128 = 5000;
macro_rules! timed {
($hsm:expr, $op:expr, $frame:expr, $limit_ms:expr) => {{
let t = std::time::Instant::now();
let res = $hsm.send_respond($op, $frame);
let ms = t.elapsed().as_millis();
if ms > $limit_ms {
bail!(
"{:?} took {}ms, exceeds {}ms limit",
$op, ms, $limit_ms
);
}
res
}};
}
// A different group ID for permission tests
let bad_gid: u16 = if gid == 0xFFFF { gid - 1 } else { gid + 1 };
// Slot usage plan:
// 0: write_1, overwrite, write_max_file_name, write_max_file_size
// 1: write_all_ascii
// 2: (reserved for write_max)
// 3: read_without_perms (bad_gid), write_without_perms
// 4: write_0_byte_file
// 5,6,7: write_max
// ── list_empty: verify HSM starts with no files ──
run_test!("list_empty", {
let resp = timed!(hsm, Opcode::List, pin.as_bytes(), TIME_LIST)?;
let files = unpack_files(&resp.body)?;
if !files.is_empty() {
bail!("Expected 0 files, found {}", files.len());
}
Ok(())
});
// ── write_1: write file to slot 0, read back, verify ──
let write1_content = b"Hi this will be the text inside the file";
run_test!("write_1", {
let frame = write_frame(pin, 0, gid, "test.txt", write1_content);
timed!(hsm, Opcode::Write, &frame, TIME_WRITE)?;
let resp = timed!(hsm, Opcode::Read, &read_frame(pin, 0), TIME_READ)?;
if resp.body.len() < MAX_NAME_LEN {
bail!("Read response too short");
}
let contents = &resp.body[MAX_NAME_LEN..];
if contents != write1_content.as_slice() {
bail!(
"Content mismatch: expected {} bytes, got {} bytes",
write1_content.len(),
contents.len()
);
}
Ok(())
});
// ── interrogate_1 + receive_1: two-HSM file transfer ──
if !no_transfer {
let hsm2 = hsm2.as_deref_mut().expect("transfer HSM required");
run_test!("interrogate_1", {
hsm2.send_respond(Opcode::Listen, &[])?;
let resp = timed!(hsm, Opcode::Interrogate, pin.as_bytes(), TIME_INTERROGATE)?;
let files = unpack_files(&resp.body)?;
if files.is_empty() {
bail!("Interrogation returned no files");
}
Ok(())
});
run_test!("receive_1", {
hsm2.send_respond(Opcode::Listen, &[])?;
timed!(hsm, Opcode::Interrogate, pin.as_bytes(), TIME_INTERROGATE)?;
timed!(hsm2, Opcode::Receive, &recv_frame(pin, 0, 1), TIME_RECEIVE)?;
Ok(())
});
}
// ── overwrite: overwrite slot 0 with new content ──
run_test!("overwrite", {
let content = b"This file will be overwriting an existing file";
let frame = write_frame(pin, 0, gid, "overwriting_file.txt", content);
timed!(hsm, Opcode::Write, &frame, TIME_WRITE)?;
let resp = timed!(hsm, Opcode::Read, &read_frame(pin, 0), TIME_READ)?;
if resp.body.len() < MAX_NAME_LEN {
bail!("Read response too short");
}
let got = &resp.body[MAX_NAME_LEN..];
if got != content.as_slice() {
bail!("Overwrite content mismatch");
}
Ok(())
});
// ── pass_file_back_and_forth: transfer with different gid ──
if !no_transfer {
let hsm2 = hsm2.as_deref_mut().expect("transfer HSM required");
run_test!("pass_file_back_and_forth", {
// Write file with a different gid on hsm
let other_gid: u16 = gid.wrapping_add(0x1000);
let content = b"This file will be passed back and forth";
let frame = write_frame(pin, 0, other_gid, "passed_file.txt", content);
timed!(hsm, Opcode::Write, &frame, TIME_WRITE)?;
// Transfer hsm → hsm2 (slot 0 → slot 0)
hsm2.send_respond(Opcode::Listen, &[])?;
timed!(hsm, Opcode::Interrogate, pin.as_bytes(), TIME_INTERROGATE)?;
timed!(hsm2, Opcode::Receive, &recv_frame(pin, 0, 0), TIME_RECEIVE)?;
// Transfer hsm2 → hsm (slot 0 → slot 0)
hsm.send_respond(Opcode::Listen, &[])?;
timed!(hsm2, Opcode::Interrogate, pin.as_bytes(), TIME_INTERROGATE)?;
timed!(hsm, Opcode::Receive, &recv_frame(pin, 0, 0), TIME_RECEIVE)?;
Ok(())
});
}
// ── write_max_file_name: 32-char filename (max length) ──
run_test!("write_max_file_name", {
// 31 visible chars + null terminator fills 32-byte name buffer
let content = b"Hi this will be the text inside the file";
let frame = write_frame(pin, 0, gid, "this_filename_is_of_max_size_32", content);
timed!(hsm, Opcode::Write, &frame, TIME_WRITE)?;
Ok(())
});
// ── write_max_file_size: 8192-byte file (maximum) ──
// Uses the same Julius Caesar text as the remote test suite.
let max_content = {
const BRUTUS: &[u8] = b" SCENE II. A public place. Flourish. Enter CAESAR; \
ANTONY, for the course; CALPURNIA, PORTIA, DECIUS BRUTUS, CICERO, BRUTUS, CASSIUS, \
and CASCA; a great crowd following, among them a Soothsayer CAESAR Calpurnia! CASCA \
Peace, ho! Caesar speaks. CAESAR Calpurnia! CALPURNIA Here, my lord. CAESAR Stand \
you directly in Antonius' way, When he doth run his course. Antonius! ANTONY \
Caesar, my lord? CAESAR Forget not, in your speed, Antonius, To touch Calpurnia; \
for our elders say, The barren, touched in this holy chase, Shake off their sterile \
curse. ANTONY I shall remember: When Caesar says 'do this,' it is perform'd. CAESAR \
Set on; and leave no ceremony out. Flourish Soothsayer Caesar! CAESAR Ha! who \
calls? CASCA Bid every noise be still: peace yet again! CAESAR Who is it in the \
press that calls on me? I hear a tongue, shriller than all the music, Cry 'Caesar!' \
Speak; Caesar is turn'd to hear. Soothsayer Beware the ides of March. CAESAR What \
man is that? BRUTUS A soothsayer bids you beware the ides of March. CAESAR Set him \
before me; let me see his face. CASSIUS Fellow, come from the throng; look upon \
Caesar. CAESAR What say'st thou to me now? speak once again. Soothsayer Beware the \
ides of March. CAESAR He is a dreamer; let us leave him: pass. Sennet. Exeunt all \
except BRUTUS and CASSIUS CASSIUS Will you go see the order of the course? BRUTUS \
Not I. CASSIUS I pray you, do. BRUTUS I am not gamesome: I do lack some part Of \
that quick spirit that is in Antony. Let me not hinder, Cassius, your desires; \
I'll leave you. CASSIUS Brutus, I do observe you now of late: I have not from your \
eyes that gentleness And show of love as I was wont to have: You bear too stubborn \
and too strange a hand Over your friend that loves you. BRUTUS Cassius, Be not \
deceived: if I have veil'd my look, I turn the trouble of my countenance Merely \
upon myself. Vexed I am Of late with passions of some difference, Conceptions only \
proper to myself, Which give some soil perhaps to my behaviors; But let not \
therefore my good friends be grieved- Among which number, Cassius, be you one- Nor \
construe any further my neglect, Than that poor Brutus, with himself at war, \
Forgets the shows of love to other men. CASSIUS Then, Brutus, I have much mistook \
your passion; By means whereof this breast of mine hath buried Thoughts of great \
value, worthy cogitations. Tell me, good Brutus, can you see your face? BRUTUS No, \
Cassius; for the eye sees not itself, But by reflection, by some other things. \
CASSIUS Tis just: And it is very much lamented, Brutus, That you have no such \
mirrors as will turn Your hidden worthiness into your eye, That you might see your \
shadow. I have heard, Where many of the best respect in Rome, Except immortal \
Caesar, speaking of Brutus And groaning underneath this age's yoke, Have wish'd \
that noble Brutus had his eyes. BRUTUS Into what dangers would you lead me, \
Cassius, That you would have me seek into myself For that which is not in me? \
CASSIUS Therefore, good Brutus, be prepared to hear: And since you know you cannot \
see yourself So well as by reflection, I, your glass, Will modestly discover to \
yourself That of yourself which you yet know not of. And be not jealous on me, \
gentle Brutus: Were I a common laugher, or did use To stale with ordinary oaths my \
love To every new protester; if you know That I do fawn on men and hug them hard \
And after scandal them, or if you know That I profess myself in banqueting To all \
the rout, then hold me dangerous. Flourish, and shout BRUTUS What means this \
shouting? I do fear, the people Choose Caesar for their king. CASSIUS Ay, do you \
fear it? Then must I think you would not have it so. BRUTUS I would not, Cassius; \
yet I love him well. But wherefore do you hold me here so long? What is it that \
you would impart to me? If it be aught toward the general good, Set honour in one \
eye and death i' the other, And I will look on both indifferently, For let the gods \
so speed me as I love The name of honour more than I fear death. CASSIUS I know \
that virtue to be in you, Brutus, As well as I do know your outward favour. Well, \
honour is the subject of my story. I cannot tell what you and other men Think of \
this life; but, for my single self, I had as lief not be as live to be In awe of \
such a thing as I myself. I was born free as Caesar; so were you: We both have fed \
as well, and we can both Endure the winter's cold as well as he: For once, upon a \
raw and gusty day, The troubled Tiber chafing with her shores, Caesar said to me \
'Darest thou, Cassius, now Leap in with me into this angry flood, And swim to \
yonder point?' Upon the word, Accoutred as I was, I plunged in And bade him \
follow; so indeed he did. The torrent roar'd, and we did buffet it With lusty \
sinews, throwing it aside And stemming it with hearts of controversy; But ere we \
could arrive the point proposed, Caesar cried 'Help me, Cassius, or I sink!' I, as \
Aeneas, our great ancestor, Did from the flames of Troy upon his shoulder The old \
Anchises bear, so from the waves of Tiber Did I the tired Caesar. And this man Is \
now become a god, and Cassius is A wretched creature and must bend his body, If \
Caesar carelessly but nod on him. He had a fever when he was in Spain, And when the \
fit was on him, I did mark How he did shake: 'tis true, this god did shake; His \
coward lips did from their colour fly, And that same eye whose bend doth awe the \
world Did lose his lustre: I did hear him groan: Ay, and that tongue of his that \
bade the Romans Mark him and write his speeches in their books, Alas, it cried \
'Give me some drink, Titinius,' As a sick girl. Ye gods, it doth amaze me A man of \
such a feeble temper should So get the start of the majestic world And bear the \
palm alone. Shout. Flourish BRUTUS Another general shout! I do believe that these \
applauses are For some new honours that are heap'd on Caesar. CASSIUS Why, man, he \
doth bestride the narrow world Like a Colossus, and we petty men Walk under his \
huge legs and peep about To find ourselves dishonourable graves. Men at some time \
are masters of their fates: The fault, dear Brutus, is not in our stars, But in \
ourselves, that we are underlings. Brutus and Caesar: what should be in that \
'Caesar'? Why should that name be sounded more than yours? Write them together, \
yours is as fair a name; Sound them, it doth become the mouth as well; Weigh them, \
it is as heavy; conjure with 'em, Brutus will start a spirit as soon as Caesar. \
Now, in the names of all the gods at once, Upon what meat doth this our Caesar \
feed, That he is grown so great? Age, thou art shamed! Rome, thou hast lost the \
breed of noble bloods! When went there by an age, since the great flood, But it was \
famed with more than with one man? When could they say till now, that talk'd of \
Rome, That her wide walls encompass'd but one man? Now is it Rome indeed and room \
enough, When there is in it but one only man. O, you and I have heard our fathers \
say, There was a Brutus once that would have brook'd The eternal devil to keep his \
state in Rome As easily as a king. BRUTUS That you do love me, I am nothing \
jealous; What you would work me to, I have some aim: How I have thought of this and \
of these times, I shall recount hereafter; for this present, I would not, so with \
love I might entreat you, Be any further moved. What you have said I will consider; \
what you have to say I will with patience hear, and find a time Both meet to hear \
and answer such high things. Till then, my noble friend, chew upon this: Brutus had \
rather be a villager Than to repute himself a son of Rome Under these hard \
conditions as this time Is like to lay upon us. CASSIUS I am glad that my weak \
words Have struck but thus much show of fire from Brutus. BRUTUS The games are done \
and Caesar is returning. CASSIUS As they pass by, pluck Casca by the sleeve; And \
he will, after his sour fashion, tell you What hath proceeded worthy note to-day. \
Re-enter CAESAR and his Train BRUTUS I will do so. But, look you, Cassius, The \
angry spot doth glow on Caesar's brow, And all the rest look like a chidden train: \
Calpurnia's cheek is pale; and Cicero Looks with such ferret and such fiery eyes \
As we have seen him in the Capitol, Being cross";
let mut buf = Vec::with_capacity(MAX_FILE_LEN);
while buf.len() < MAX_FILE_LEN {
let remaining = MAX_FILE_LEN - buf.len();
buf.extend_from_slice(&BRUTUS[..remaining.min(BRUTUS.len())]);
}
buf
};
run_test!("write_max_file_size", {
let frame = write_frame(pin, 0, gid, "full_file_0.out", &max_content);
timed!(hsm, Opcode::Write, &frame, TIME_WRITE)?;
let resp = timed!(hsm, Opcode::Read, &read_frame(pin, 0), TIME_READ)?;
if resp.body.len() < MAX_NAME_LEN {
bail!("Read response too short");
}
let got = &resp.body[MAX_NAME_LEN..];
if got.len() != MAX_FILE_LEN {
bail!("Expected {MAX_FILE_LEN} bytes, got {}", got.len());
}
if got != max_content.as_slice() {
bail!("Max-size file content mismatch");
}
Ok(())
});
// ── receive_max_file: transfer the 8192-byte file ──
if !no_transfer {
let hsm2 = hsm2.as_deref_mut().expect("transfer HSM required");
run_test!("receive_max_file", {
hsm2.send_respond(Opcode::Listen, &[])?;
timed!(hsm, Opcode::Interrogate, pin.as_bytes(), TIME_INTERROGATE)?;
timed!(hsm2, Opcode::Receive, &recv_frame(pin, 0, 2), TIME_RECEIVE)?;
Ok(())
});
}
// ── write_all_ascii: write all 256 byte values 0x00-0xFF ──
run_test!("write_all_ascii", {
let content: Vec<u8> = (0..=255u8).collect();
let frame = write_frame(pin, 1, gid, "all_ascii.txt", &content);
timed!(hsm, Opcode::Write, &frame, TIME_WRITE)?;
let resp = timed!(hsm, Opcode::Read, &read_frame(pin, 1), TIME_READ)?;
if resp.body.len() < MAX_NAME_LEN {
bail!("Read response too short");
}
let got = &resp.body[MAX_NAME_LEN..];
if got != content.as_slice() {
bail!("All-ASCII content mismatch");
}
Ok(())
});
// ── bad_pin: wrong pin should error, HSM should still work after ──
run_test!("bad_pin", {
// Send a deliberately wrong (too-short) pin — allowed up to 5s
let bad_result = timed!(hsm, Opcode::List, b"ecd7", TIME_BAD_PIN);
if bad_result.is_ok() {
bail!("Expected bad pin to fail, but it succeeded");
}
// Verify HSM still works with correct pin
timed!(hsm, Opcode::List, pin.as_bytes(), TIME_LIST)?;
Ok(())
});
// ── Permission tests: require two HSMs with different gid/permission sets ──
if !no_transfer {
let hsm2 = hsm2.as_deref_mut().expect("transfer HSM required");
// read_without_perms: HSM B writes file with bad_gid, HSM A tries to read
run_test!("read_without_perms", {
let content = b"This file should not be readable by the HSM that wrote the data";
let frame = write_frame(pin, 3, bad_gid, "non_readable.txt", content);
timed!(hsm2, Opcode::Write, &frame, TIME_WRITE)?;
hsm2.send_respond(Opcode::Listen, &[])?;
timed!(hsm, Opcode::Interrogate, pin.as_bytes(), TIME_INTERROGATE)?;
match timed!(hsm, Opcode::Receive, &recv_frame(pin, 3, 3), TIME_RECEIVE) {
Err(_) => {} // expected: gid mismatch on receive
Ok(_) => bail!("Expected receive to fail with wrong group ID, but it succeeded"),
}
Ok(())
});
// write_without_perms: HSM B writes file with bad_gid, HSM A tries to overwrite
run_test!("write_without_perms", {
let content = b"This file should not be receivable on HSM A";
let frame = write_frame(pin, 3, gid, "replacement.txt", content);
match timed!(hsm, Opcode::Write, &frame, TIME_WRITE) {
Err(_) => {} // expected: gid mismatch on overwrite
Ok(_) => bail!("Expected overwrite to fail with wrong group ID, but it succeeded"),
}
Ok(())
});
// receive_without_perms: HSM B writes file with bad_gid, HSM A tries to receive
run_test!("receive_without_perms", {
let content = b"This file should not be receivable on HSM A";
let frame = write_frame(pin, 3, bad_gid, "non_receivable.txt", content);
timed!(hsm2, Opcode::Write, &frame, TIME_WRITE)?;
hsm.send_respond(Opcode::Listen, &[])?;
timed!(hsm2, Opcode::Interrogate, pin.as_bytes(), TIME_INTERROGATE)?;
match timed!(hsm, Opcode::Receive, &recv_frame(pin, 3, 3), TIME_RECEIVE) {
Err(_) => {} // expected: gid mismatch
Ok(_) => bail!("Expected receive to fail with wrong group ID, but it succeeded"),
}
Ok(())
});
}
// ── write_0_byte_file: write file with 0 bytes content to slot 4 ──
// Remote sends: pin + slot + gid + "no_contents.txt" + uuid + len(0x0000)
// Total frame = 59 bytes, no content appended.
run_test!("write_0_byte_file", {
let frame = write_frame(pin, 4, gid, "no_contents.txt", &[]);
timed!(hsm, Opcode::Write, &frame, TIME_WRITE)?;
Ok(())
});
// ── read_0_byte_file: LIST and verify the 0-byte file appears ──
// Note: the remote test does a LIST here, NOT a READ.
run_test!("read_0_byte_file", {
let resp = timed!(hsm, Opcode::List, pin.as_bytes(), TIME_LIST)?;
let files = unpack_files(&resp.body)?;
let found = files.iter().any(|(slot, _, name)| *slot == 4 && name == "no_contents.txt");
if !found {
bail!("0-byte file not found in file listing");
}
Ok(())
});
// ── read_back_0_byte: READ slot 4, verify empty content ──
// (Extra test not in remote suite — verifies actual read returns 0 bytes)
run_test!("read_back_0_byte", {
let resp = timed!(hsm, Opcode::Read, &read_frame(pin, 4), TIME_READ)?;
if resp.body.len() < MAX_NAME_LEN {
bail!("Read response too short");
}
let contents = &resp.body[MAX_NAME_LEN..];
if !contents.is_empty() {
bail!("Expected empty contents, got {} bytes", contents.len());
}
Ok(())
});
// ── receive_0_byte_file: transfer the 0-byte file ──
if !no_transfer {
let hsm2 = hsm2.as_deref_mut().expect("transfer HSM required");
run_test!("receive_0_byte_file", {
hsm2.send_respond(Opcode::Listen, &[])?;
timed!(hsm, Opcode::Interrogate, pin.as_bytes(), TIME_INTERROGATE)?;
timed!(hsm2, Opcode::Receive, &recv_frame(pin, 4, 4), TIME_RECEIVE)?;
Ok(())
});
}
// ── write_max: fill all remaining empty slots to reach 8 total ──
run_test!("write_max", {
// List current files to find which slots are occupied
let resp = timed!(hsm, Opcode::List, pin.as_bytes(), TIME_LIST)?;
let files = unpack_files(&resp.body)?;
let occupied: std::collections::HashSet<u8> = files.iter().map(|(s, _, _)| *s).collect();
for i in 0u8..8 {
if occupied.contains(&i) {
continue;
}
let name = format!("file_{i}.txt");
let content = format!("This is file number {i}");
let frame = write_frame(pin, i, gid, &name, content.as_bytes());
timed!(hsm, Opcode::Write, &frame, TIME_WRITE)?;
}
let resp = timed!(hsm, Opcode::List, pin.as_bytes(), TIME_LIST)?;
let files = unpack_files(&resp.body)?;
if files.len() != 8 {
bail!("Expected 8 files, found {}", files.len());
}
Ok(())
});
// ── Summary ──
let total = results.len();
let passed = results.iter().filter(|r| r.passed).count();
let failed = total - passed;
if json {
let tests: Vec<serde_json::Value> = results
.iter()
.map(|r| {
let mut obj = serde_json::Map::new();
obj.insert("name".into(), serde_json::Value::String(r.name.into()));
obj.insert("passed".into(), serde_json::Value::Bool(r.passed));
obj.insert(
"duration_secs".into(),
serde_json::Value::Number(
serde_json::Number::from_f64(r.duration_secs).unwrap(),
),
);
if let Some(e) = &r.error {
obj.insert("error".into(), serde_json::Value::String(e.clone()));
}
serde_json::Value::Object(obj)
})
.collect();
let output = serde_json::json!({
"total": total,
"passed": passed,
"failed": failed,
"tests": tests,
});
println!("{}", serde_json::to_string(&output).unwrap());
} else {
println!();
for r in &results {
if r.passed {
log::success(&format!(" PASS {}", r.name));
} else {
log::error(&format!(" FAIL {}", r.name));
}
}
println!();
if failed == 0 {
log::success(&format!("All {total} tests passed"));
} else {
log::error(&format!("{failed}/{total} tests failed"));
}
}
if failed > 0 {
bail!("{failed} test(s) failed");
}
Ok(())
}
// ─── HW commands ───
fn run_hw(port: &str, cmd: HwCmd) -> Result<()> {

View File

@@ -1,5 +1,6 @@
use std::fs::File;
use std::io::{Read, Write};
use std::os::fd::AsRawFd;
use anyhow::{Result, bail};
@@ -81,7 +82,10 @@ impl HSMIntf {
fn write_all(&mut self, data: &[u8]) -> Result<()> {
log::trace_hex("TX", data);
self.file.write_all(data)?;
self.file.flush()?;
// tcdrain ensures all written data is transmitted before returning.
// File::flush() is a no-op for std::fs::File, so without this,
// macOS CDC-ACM may buffer writes and cause protocol desync.
unsafe { libc::tcdrain(self.file.as_raw_fd()); }
Ok(())
}