Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0a3596aab6 | ||
|
|
1f68fc9825 | ||
|
|
61061d3571 | ||
|
|
20041ddec5 | ||
|
|
ec238f3f77 | ||
|
|
9a3afab14d | ||
|
|
81e79a20cd | ||
|
|
e1fb2a10ba | ||
|
|
acd1ff0112 | ||
|
|
1e3e176c9c | ||
|
|
c3de7f5e74 | ||
|
|
d952d96a6c |
18
.github/workflows/release.yml
vendored
18
.github/workflows/release.yml
vendored
@@ -19,12 +19,12 @@ jobs:
|
|||||||
- target: x86_64-apple-darwin
|
- target: x86_64-apple-darwin
|
||||||
os: macos-latest
|
os: macos-latest
|
||||||
name: ectf-tools-x86_64-apple-darwin
|
name: ectf-tools-x86_64-apple-darwin
|
||||||
- target: x86_64-unknown-linux-gnu
|
- target: x86_64-unknown-linux-musl
|
||||||
os: ubuntu-latest
|
os: ubuntu-latest
|
||||||
name: ectf-tools-x86_64-unknown-linux-gnu
|
name: ectf-tools-x86_64-unknown-linux-musl
|
||||||
- target: aarch64-unknown-linux-gnu
|
- target: aarch64-unknown-linux-musl
|
||||||
os: ubuntu-latest
|
os: ubuntu-latest
|
||||||
name: ectf-tools-aarch64-unknown-linux-gnu
|
name: ectf-tools-aarch64-unknown-linux-musl
|
||||||
|
|
||||||
runs-on: ${{ matrix.os }}
|
runs-on: ${{ matrix.os }}
|
||||||
|
|
||||||
@@ -37,14 +37,18 @@ jobs:
|
|||||||
targets: ${{ matrix.target }}
|
targets: ${{ matrix.target }}
|
||||||
|
|
||||||
- name: Install cross-compilation tools
|
- name: Install cross-compilation tools
|
||||||
if: matrix.target == 'aarch64-unknown-linux-gnu'
|
if: contains(matrix.target, 'linux-musl')
|
||||||
run: |
|
run: |
|
||||||
sudo apt-get update
|
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
|
- name: Build
|
||||||
env:
|
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 }}
|
run: cargo build --release --target ${{ matrix.target }}
|
||||||
|
|
||||||
- name: Package
|
- name: Package
|
||||||
|
|||||||
1203
Cargo.lock
generated
1203
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "ectf-tools"
|
name = "ectf-tools"
|
||||||
version = "0.1.0"
|
version = "0.3.1"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
@@ -13,3 +13,6 @@ hmac = "0.12"
|
|||||||
sha2 = "0.10"
|
sha2 = "0.10"
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
|
serde_yaml = "0.9"
|
||||||
|
clap_complete = "4"
|
||||||
|
reqwest = { version = "0.12", default-features = false, features = ["blocking", "multipart", "json", "rustls-tls"] }
|
||||||
|
|||||||
133
README.md
133
README.md
@@ -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.
|
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
|
```bash
|
||||||
cargo build --release
|
cargo build --release
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
### HSM Host Tools
|
### HSM Host Tools
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -30,6 +38,129 @@ ectf-tools tools /dev/tty.usbmodemXXX listen
|
|||||||
|
|
||||||
# Receive a file from another HSM
|
# Receive a file from another HSM
|
||||||
ectf-tools tools /dev/tty.usbmodemXXX receive 1a2b3c 0 1
|
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)
|
### Hardware Bootloader Tools (MSPM0L2228)
|
||||||
|
|||||||
698
src/api.rs
Normal file
698
src/api.rs
Normal file
@@ -0,0 +1,698 @@
|
|||||||
|
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, 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(());
|
||||||
|
}
|
||||||
|
|
||||||
|
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, 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);
|
||||||
|
|
||||||
|
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>, json: bool) -> 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)?;
|
||||||
|
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(())
|
||||||
|
}
|
||||||
|
|
||||||
|
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, json: bool) -> Result<()> {
|
||||||
|
cmd_flow_submit("submit", commit, None, json)
|
||||||
|
}
|
||||||
|
|
||||||
|
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
69
src/config.rs
Normal 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(())
|
||||||
|
}
|
||||||
|
}
|
||||||
972
src/main.rs
972
src/main.rs
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,6 @@
|
|||||||
use std::fs::File;
|
use std::fs::File;
|
||||||
use std::io::{Read, Write};
|
use std::io::{Read, Write};
|
||||||
|
use std::os::fd::AsRawFd;
|
||||||
|
|
||||||
use anyhow::{Result, bail};
|
use anyhow::{Result, bail};
|
||||||
|
|
||||||
@@ -81,7 +82,10 @@ impl HSMIntf {
|
|||||||
fn write_all(&mut self, data: &[u8]) -> Result<()> {
|
fn write_all(&mut self, data: &[u8]) -> Result<()> {
|
||||||
log::trace_hex("TX", data);
|
log::trace_hex("TX", data);
|
||||||
self.file.write_all(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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user