2 Commits
v0.3.0 ... main

Author SHA1 Message Date
Kieran Klukas
0a3596aab6 feat: add --json flag to all API flow commands
Add --json to submit, ls, and info for test/clone/remote flows.
Document all JSON output formats in README.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 15:04:30 -05:00
Kieran Klukas
1f68fc9825 docs: add test suite, API commands, and env var config to README
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 14:51:52 -05:00
5 changed files with 206 additions and 15 deletions

2
Cargo.lock generated
View File

@@ -226,7 +226,7 @@ dependencies = [
[[package]]
name = "ectf-tools"
version = "0.3.0"
version = "0.3.1"
dependencies = [
"anyhow",
"clap",

View File

@@ -1,6 +1,6 @@
[package]
name = "ectf-tools"
version = "0.3.0"
version = "0.3.1"
edition = "2024"
[dependencies]

123
README.md
View File

@@ -38,6 +38,129 @@ ectf-tools tools /dev/tty.usbmodemXXX listen
# Receive a file from another HSM
ectf-tools tools /dev/tty.usbmodemXXX receive 1a2b3c 0 1
# Run the hardware test suite
ectf-tools tools /dev/tty.usbmodemXXX test 1a2b3c 0x4321 /dev/tty.usbmodemYYY
# Run tests without a second HSM
ectf-tools tools /dev/tty.usbmodemXXX test 1a2b3c 0x4321 --no-transfer
# Output test results as JSON (for CI)
ectf-tools tools /dev/tty.usbmodemXXX test 1a2b3c 0x4321 --no-transfer --json
```
### API Commands
```bash
# Configure your API credentials
ectf-tools config --token <TOKEN> --git-url <GIT_URL>
# Submit a design for testing
ectf-tools api test submit <COMMIT_HASH>
# List recent test flows
ectf-tools api test ls
# Get details on a specific flow
ectf-tools api test info <FLOW_ID>
# Download job output
ectf-tools api test get <JOB_ID> output.tar.gz
# Submit to handoff
ectf-tools api submit <COMMIT_HASH>
# List and download attack packages
ectf-tools api list
ectf-tools api get <PACKAGE_NAME>
```
### Environment Variables
For CI environments, you can configure the tool entirely via environment variables instead of the config file:
| Variable | Description | Required |
|---|---|---|
| `ECTF_TOKEN` | API bearer token | Yes (with `ECTF_GIT_URL`) |
| `ECTF_GIT_URL` | Git repository URL | Yes (with `ECTF_TOKEN`) |
| `ECTF_API_URL` | API base URL (default: `https://api.ectf.mitre.org`) | No |
If both `ECTF_TOKEN` and `ECTF_GIT_URL` are set, no config file is needed. If a config file exists, env vars override individual fields.
### JSON Output (`--json`)
Most commands support `--json` for machine-readable output. The process exit code is still non-zero on failure.
#### `tools test --json`
```json
{
"total": 15,
"passed": 14,
"failed": 1,
"tests": [
{
"name": "list_empty",
"passed": true,
"duration_secs": 0.123
},
{
"name": "bad_pin",
"passed": false,
"duration_secs": 1.234,
"error": "Expected bad pin to fail, but it succeeded"
}
]
}
```
#### `api test submit --json` / `api clone submit --json` / `api submit --json`
```json
{
"flow": "test",
"id": "abc12345-1234-1234-1234-123456789abc"
}
```
#### `api test ls --json` / `api clone ls --json` / `api remote ls --json`
```json
{
"flows": [
{
"id": "abc12345-1234-1234-1234-123456789abc",
"submitted": "2026-02-11T22:05:47.000000Z",
"status": "succeeded",
"completed": true
}
]
}
```
#### `api test info --json` / `api clone info --json` / `api remote info --json`
```json
{
"flow": "test",
"id": "abc12345-1234-1234-1234-123456789abc",
"submitted": "2026-02-11T22:05:47.000000Z",
"status": "succeeded",
"completed": true,
"params": {
"git_url": "https://github.com/example/repo.git",
"commit_hash": "abc1234"
},
"jobs": [
{
"name": "build",
"id": "def12345-1234-1234-1234-123456789abc",
"status": "succeeded",
"has_artifacts": true,
"private": false
}
]
}
```
### Hardware Bootloader Tools (MSPM0L2228)

View File

@@ -300,9 +300,26 @@ fn flow_overall_status(f: &Flow) -> String {
}
}
pub fn cmd_flow_list(flow: &str, count: usize) -> Result<()> {
pub fn cmd_flow_list(flow: &str, count: usize, json: bool) -> Result<()> {
let api = ApiClient::new()?;
let flows = api.flow_list(flow, count)?;
if json {
let items: Vec<serde_json::Value> = flows
.iter()
.map(|f| {
serde_json::json!({
"id": f.id,
"submitted": f.submit_time,
"status": flow_overall_status(f).to_lowercase(),
"completed": f.completed,
})
})
.collect();
println!("{}", serde_json::to_string(&serde_json::json!({ "flows": items })).unwrap());
return Ok(());
}
if flows.is_empty() {
log::info("No flows found");
return Ok(());
@@ -348,9 +365,37 @@ pub fn cmd_flow_list(flow: &str, count: usize) -> Result<()> {
Ok(())
}
pub fn cmd_flow_info(flow: &str, id: &str) -> Result<()> {
pub fn cmd_flow_info(flow: &str, id: &str, json: bool) -> Result<()> {
let api = ApiClient::new()?;
let f = api.flow_info(flow, id)?;
if json {
let jobs: Vec<serde_json::Value> = f
.jobs
.iter()
.map(|j| {
serde_json::json!({
"name": j.name,
"id": j.id,
"status": j.status,
"has_artifacts": j.has_artifacts,
"private": j.private,
})
})
.collect();
let output = serde_json::json!({
"flow": flow,
"id": f.id,
"submitted": f.submit_time,
"status": flow_overall_status(&f).to_lowercase(),
"completed": f.completed,
"params": f.params,
"jobs": jobs,
});
println!("{}", serde_json::to_string(&output).unwrap());
return Ok(());
}
let status = flow_overall_status(&f);
let sc = status_color(&status.to_lowercase());
let when = relative_time(&f.submit_time);
@@ -401,7 +446,7 @@ pub fn cmd_flow_info(flow: &str, id: &str) -> Result<()> {
Ok(())
}
pub fn cmd_flow_submit(flow: &str, commit: &str, url: Option<&str>) -> Result<()> {
pub fn cmd_flow_submit(flow: &str, commit: &str, url: Option<&str>, json: bool) -> Result<()> {
let api = ApiClient::new()?;
let git_url = url
.map(|s| s.to_string())
@@ -411,7 +456,12 @@ pub fn cmd_flow_submit(flow: &str, commit: &str, url: Option<&str>) -> Result<()
"commit_hash": commit,
});
let id = api.flow_submit(flow, &body)?;
log::success(&format!("Submitted {flow} flow: {id}"));
if json {
let output = serde_json::json!({ "flow": flow, "id": id.trim() });
println!("{}", serde_json::to_string(&output).unwrap());
} else {
log::success(&format!("Submitted {flow} flow: {id}"));
}
Ok(())
}
@@ -430,8 +480,8 @@ pub fn cmd_flow_get(flow: &str, job_id: &str, out: &PathBuf) -> Result<()> {
Ok(())
}
pub fn cmd_submit(commit: &str) -> Result<()> {
cmd_flow_submit("submit", commit, None)
pub fn cmd_submit(commit: &str, json: bool) -> Result<()> {
cmd_flow_submit("submit", commit, None, json)
}
pub fn cmd_photo(file: &PathBuf) -> Result<()> {

View File

@@ -106,6 +106,9 @@ enum ApiCmd {
Submit {
/// Git commit hash
commit: String,
/// Output result as JSON for CI
#[arg(long)]
json: bool,
},
/// Submit a PNG for the Team Photo flag
Photo {
@@ -164,11 +167,17 @@ enum FlowCmd {
/// Number of flows to show (0 for all)
#[arg(short, long, default_value = "5")]
number: usize,
/// Output result as JSON for CI
#[arg(long)]
json: bool,
},
/// Get flow details
Info {
/// Flow ID
id: String,
/// Output result as JSON for CI
#[arg(long)]
json: bool,
},
/// Submit a commit
Submit {
@@ -177,6 +186,9 @@ enum FlowCmd {
/// Override git URL
#[arg(short, long)]
url: Option<String>,
/// Output result as JSON for CI
#[arg(long)]
json: bool,
},
/// Cancel a flow
Cancel {
@@ -211,11 +223,17 @@ enum RemoteCmd {
/// Number of flows to show (0 for all)
#[arg(short, long, default_value = "5")]
number: usize,
/// Output result as JSON for CI
#[arg(long)]
json: bool,
},
/// Get remote flow details
Info {
/// Flow ID
id: String,
/// Output result as JSON for CI
#[arg(long)]
json: bool,
},
/// Cancel a remote flow
Cancel {
@@ -572,7 +590,7 @@ fn run_config(
fn run_api(cmd: ApiCmd) -> Result<()> {
match cmd {
ApiCmd::Submit { commit } => api::cmd_submit(&commit),
ApiCmd::Submit { commit, json } => api::cmd_submit(&commit, json),
ApiCmd::Photo { file } => api::cmd_photo(&file),
ApiCmd::Design { file } => api::cmd_design(&file),
ApiCmd::Steal { team, digest } => api::cmd_steal(&team, &digest),
@@ -590,10 +608,10 @@ fn run_api(cmd: ApiCmd) -> Result<()> {
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::Ls { number, json } => api::cmd_flow_list(flow, number, json),
FlowCmd::Info { id, json } => api::cmd_flow_info(flow, &id, json),
FlowCmd::Submit { commit, url, json } => {
api::cmd_flow_submit(flow, &commit, url.as_deref(), json)
}
FlowCmd::Cancel { id } => api::cmd_flow_cancel(flow, &id),
FlowCmd::Get { job_id, out } => api::cmd_flow_get(flow, &job_id, &out),
@@ -608,8 +626,8 @@ fn run_remote(cmd: RemoteCmd) -> Result<()> {
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::Ls { number, json } => api::cmd_flow_list("remote", number, json),
RemoteCmd::Info { id, json } => api::cmd_flow_info("remote", &id, json),
RemoteCmd::Cancel { id } => api::cmd_flow_cancel("remote", &id),
RemoteCmd::Get { job_id, out } => api::cmd_flow_get("remote", &job_id, &out),
}