Container-native PXE boot server in Rust, designed as a clean-room alternative to iVentoy that never touches the client OS trust store. This is the first commit of the project; it lands the full output of Phases 1, 2, 3, and 4 in one shot. ## Phase 1 — protocol stack - 8-crate workspace (core, dhcp-proxy, tftp, http-api, iso-store, ipxe-assets, webui, pxeforge bin). - DHCP proxy (RFC 4578): replies with boot info only, never leases — sidesteps CAP_NET_RAW. Architecture-aware bootfile selection from option 93 (BIOS, IA32, x64-UEFI alias 0x0007/0x0009, ARM64). - TFTP server with full OACK negotiation: blksize, tsize, windowsize. Without it a 1 MiB iPXE binary takes 2000 packets and unusably long. - Two-stage iPXE chain: firmware PXE -> TFTP iPXE binary -> iPXE re-DHCPs with user-class iPXE -> HTTP /boot.ipxe -> kernel+initrd. - HTTP server (axum) with byte-Range ISO streaming and an in-place ISO9660 lookup so kernel/initrd are served from inside the ISO without ever extracting it to disk. - Linux ISOs boot via kernel+initrd extraction (memdisk/sanboot fail for >1-2 GiB modern distros). Distro-family detection drives the cmdline (Debian/Ubuntu, RHEL/Fedora, openSUSE, Arch, Alpine). ## Phase 2 — UX + Windows - Hierarchical PXE menu (Default / Installers / Tools / Gated Deployment) generated from settings — no hand-written .ipxe paths surface in the UI. Number-key + letter hotkeys, BIOS+UEFI variants for some RHEL ISOs. - Gated Deployment "horse-race" queue: clients join, operator picks one ISO, every gate launches simultaneously via tokio::sync::Notify. - Bootimus-pattern Windows: WimPatcher injects a CRLF startnet.cmd into boot.wim so vanilla WinPE net-uses an SMB share and runs setup.exe. All Microsoft-signed; no test certs, no testsigning, no httpdisk.sys. SmbManager supervises smbd start/stop/SIGHUP. - Netbox-style dark UI, fully offline (no CDN, no external fonts). ## Phase 3 — MVP hardening - TFTP retransmit rewrite with explicit window tracking — UEFI SNP clients no longer hang on files that end mid-window. 4 new tests. - DHCP broadcast-flag honored per RFC 2131 §4.1. - Multi-arch container (linux/amd64 + linux/arm64). Entrypoint chowns bind-mounts as root then drops to uid 10001 via gosu. - /healthz + /readyz split from /api/status — readyz fails if no iPXE binaries are bundled. - pxeforge seed --from <path> CLI: same pipeline as web upload (slug, sha256, introspection, boot-entry). - All timestamps RFC 3339 (browser Date couldn't parse the 9-tuple). - Gate poll retains assignment until operator releases — clients that retry on transient network errors reuse the assignment instead of falling back to the menu. - Custom OpenShift SCC: hostNetwork + NET_BIND_SERVICE only, no NET_RAW. ## Phase 4 — UI restructure + remote storage - Web UI rebuilt around six tabs inspired by the iVentoy layout: Dashboard / Network / Forge Gate / Storage / Terminal / About. Old "Monitoring/Content/Configuration" sidebar groups are gone. - NFS share manager (crates/iso-store/src/nfs.rs): mount NFSv3 or NFSv4.1 shares as ISO sources instead of uploading every file into the PVC. New IsoSource enum on IsoMeta lets the store resolve Local vs NFS lazily. Persisted to <work_dir>/nfs.json; failed mounts surface in the UI rather than blocking startup. - Dockerfile gains nfs-common + iproute2; mounting NFS in-container also requires CAP_SYS_ADMIN. Documented in docs/architecture.md. - LogBus + tracing layer in core: 500-line ring buffer + broadcast channel feed an SSE endpoint at /api/log/stream. - Operator terminal at /api/terminal: whitelisted commands (status, isos, clients, gate, nfs, smb, log) — deliberately not a shell. Output mirrored onto the LogBus so the live tail and the terminal pane share one timeline. - Network tab: read-only nic_name / subnet_mask / gateway probed from `ip` at startup; only DNS server is editable. Editing IP/mask on a hot UI would silently break PXE for every client mid-boot. - Bootimus parity (releases v0.1.55 -> v0.1.62): amber row tint on un-bootable ISOs with inline reasons, dashboard "won't boot" panel. ## Tests 56 tests passing across the workspace: - 16 core (LogBus, gate, settings, arch, client) - 1 dhcp-proxy (raw option-93 extraction) - 8 http-api unit (range parsing, terminal split/format) - 13 http-api integration (gated deployment, range, settings, NFS, terminal, log SSE, network endpoint, ui assets, no-external-urls) - 12 iso-store (introspect, slugify, smb, windows wim, NFS options) - 6 tftp (RRQ parsing, plan_window edges) cargo build --workspace and cargo clippy --workspace --all-targets both finish clean (warnings only, no errors).
525 lines
19 KiB
Rust
525 lines
19 KiB
Rust
//! Operator terminal — typed commands over HTTP.
|
|
//!
|
|
//! The Terminal tab posts a single command line per request. We split it
|
|
//! into argv, dispatch to a whitelisted handler, and return plain-text
|
|
//! output. The handler also pushes the input line and any output onto
|
|
//! the LogBus so commands and their results show up inline in the live
|
|
//! tail (Minecraft-server-style).
|
|
//!
|
|
//! ## Why a whitelist
|
|
//!
|
|
//! Exposing a real shell would be a remote-code-execution endpoint. We
|
|
//! keep the surface tiny and read-mostly; mutations are limited to the
|
|
//! same operations the rest of the UI already exposes.
|
|
|
|
use crate::state::AppState;
|
|
use axum::{extract::State, http::StatusCode, response::IntoResponse, Json};
|
|
use serde::Deserialize;
|
|
use serde_json::json;
|
|
use std::fmt::Write as _;
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
pub struct CommandRequest {
|
|
/// Raw input as typed by the operator. Empty / all-whitespace is OK
|
|
/// (returns the help banner).
|
|
pub command: String,
|
|
}
|
|
|
|
pub async fn run_command(
|
|
State(state): State<AppState>,
|
|
Json(req): Json<CommandRequest>,
|
|
) -> impl IntoResponse {
|
|
let line = req.command.trim();
|
|
if line.is_empty() {
|
|
return (StatusCode::OK, Json(json!({ "output": HELP_TEXT, "ok": true })));
|
|
}
|
|
|
|
// Echo the typed command into the live log so the Terminal tab shows
|
|
// operator activity in-band with server-emitted log lines.
|
|
state.log_bus.push("info", "pxeforge::terminal", format!("> {line}"));
|
|
|
|
let argv = shell_split(line);
|
|
if argv.is_empty() {
|
|
return (
|
|
StatusCode::OK,
|
|
Json(json!({ "output": HELP_TEXT, "ok": true })),
|
|
);
|
|
}
|
|
|
|
let result = dispatch(&state, &argv).await;
|
|
let (ok, output) = match result {
|
|
Ok(s) => (true, s),
|
|
Err(s) => (false, s),
|
|
};
|
|
// Mirror command output to the log bus (truncated for noisy commands)
|
|
// so reading the live tail tells the same story as scrolling the
|
|
// terminal pane.
|
|
let mirror = if output.len() > 1024 {
|
|
format!("{}\n... ({} bytes truncated)", &output[..1024], output.len() - 1024)
|
|
} else {
|
|
output.clone()
|
|
};
|
|
if ok {
|
|
state.log_bus.push("info", "pxeforge::terminal", mirror);
|
|
} else {
|
|
state.log_bus.push("warn", "pxeforge::terminal", mirror);
|
|
}
|
|
|
|
(StatusCode::OK, Json(json!({ "output": output, "ok": ok })))
|
|
}
|
|
|
|
async fn dispatch(state: &AppState, argv: &[String]) -> Result<String, String> {
|
|
let head = argv[0].as_str();
|
|
let tail = &argv[1..];
|
|
match head {
|
|
"help" | "?" => Ok(HELP_TEXT.to_string()),
|
|
"version" => Ok(format!("pxeforge {}", env!("CARGO_PKG_VERSION"))),
|
|
"uptime" => Ok(uptime_string(state)),
|
|
"status" => Ok(status_text(state)),
|
|
"isos" | "images" => Ok(isos_text(state)),
|
|
"clients" => Ok(clients_text(state)),
|
|
"gate" => gate_command(state, tail).await,
|
|
"nfs" => nfs_command(state, tail).await,
|
|
"smb" => smb_command(state, tail).await,
|
|
"log" => log_command(state, tail),
|
|
"whoami" => Ok("operator".to_string()),
|
|
"echo" => Ok(tail.join(" ")),
|
|
"clear" => Ok("\x0c".to_string()), // form feed — frontend clears panel
|
|
other => Err(format!(
|
|
"unknown command: {other}\ntype 'help' for the list"
|
|
)),
|
|
}
|
|
}
|
|
|
|
// ── status / lists ─────────────────────────────────────────────────────
|
|
|
|
fn status_text(s: &AppState) -> String {
|
|
let isos = s.iso_store.list();
|
|
let clients = s.clients.list();
|
|
let gates = s.gates.list();
|
|
let smb = s.smb.as_ref().map(|m| m.snapshot());
|
|
let nfs = s.nfs.list();
|
|
let nfs_active = nfs.iter().filter(|m| m.mounted).count();
|
|
format!(
|
|
"PXEForge {ver}\n\
|
|
base url: {base}\n\
|
|
interface: {nic}\n\
|
|
uptime: {up}\n\
|
|
isos: {n_isos} (local: {n_local}, nfs: {n_nfs})\n\
|
|
clients: {n_clients}\n\
|
|
gates: {n_gates}\n\
|
|
smb: {smb}\n\
|
|
nfs mounts: {n_total} configured ({n_active} active)\n",
|
|
ver = env!("CARGO_PKG_VERSION"),
|
|
base = s.public_base_url,
|
|
nic = if s.nic_name.is_empty() { "?" } else { s.nic_name.as_str() },
|
|
up = uptime_string(s),
|
|
n_isos = isos.len(),
|
|
n_local = isos.iter().filter(|i| matches!(i.source, pxeforge_iso_store::IsoSource::Local)).count(),
|
|
n_nfs = isos.iter().filter(|i| !matches!(i.source, pxeforge_iso_store::IsoSource::Local)).count(),
|
|
n_clients = clients.len(),
|
|
n_gates = gates.len(),
|
|
smb = smb.map_or_else(|| "(disabled)".into(), |s| format!("{s:?}")),
|
|
n_total = nfs.len(),
|
|
n_active = nfs_active,
|
|
)
|
|
}
|
|
|
|
fn isos_text(s: &AppState) -> String {
|
|
let isos = s.iso_store.list();
|
|
if isos.is_empty() {
|
|
return "(no isos)".into();
|
|
}
|
|
let mut out = String::new();
|
|
let _ = writeln!(
|
|
out,
|
|
"{:<32} {:<10} {:<10} {:<8}",
|
|
"ID", "FAMILY", "SIZE", "SOURCE"
|
|
);
|
|
for i in isos {
|
|
let src = match i.source {
|
|
pxeforge_iso_store::IsoSource::Local => "local".to_string(),
|
|
pxeforge_iso_store::IsoSource::Nfs { mount_id, .. } => format!("nfs:{mount_id}"),
|
|
};
|
|
let _ = writeln!(
|
|
out,
|
|
"{:<32} {:<10} {:<10} {:<8}",
|
|
truncate(&i.id, 32),
|
|
format!("{:?}", i.introspection.family),
|
|
human_bytes(i.size_bytes),
|
|
src,
|
|
);
|
|
}
|
|
out
|
|
}
|
|
|
|
fn clients_text(s: &AppState) -> String {
|
|
let clients = s.clients.list();
|
|
if clients.is_empty() {
|
|
return "(no clients yet)".into();
|
|
}
|
|
let mut out = String::new();
|
|
let _ = writeln!(
|
|
out,
|
|
"{:<19} {:<16} {:<8} {}",
|
|
"MAC", "IP", "EVENTS", "LAST SEEN"
|
|
);
|
|
for c in clients {
|
|
let ip = c.last_ip.map_or_else(|| "-".into(), |i| i.to_string());
|
|
let _ = writeln!(
|
|
out,
|
|
"{:<19} {:<16} {:<8} {}",
|
|
c.mac,
|
|
ip,
|
|
c.events.len(),
|
|
c.last_seen
|
|
.format(&time::format_description::well_known::Rfc3339)
|
|
.unwrap_or_default()
|
|
);
|
|
}
|
|
out
|
|
}
|
|
|
|
// ── gate ───────────────────────────────────────────────────────────────
|
|
|
|
// `async` for symmetry with the other dispatch helpers — gate operations
|
|
// are sync today but might grow to await on a database in a future phase.
|
|
#[allow(clippy::unused_async)]
|
|
async fn gate_command(s: &AppState, args: &[String]) -> Result<String, String> {
|
|
match args.first().map(String::as_str) {
|
|
None | Some("list") => {
|
|
let gs = s.gates.list();
|
|
if gs.is_empty() {
|
|
return Ok("(no gates)".into());
|
|
}
|
|
let mut out = String::new();
|
|
for g in gs {
|
|
let _ = writeln!(
|
|
out,
|
|
"#{:<3} {:<19} {:<16} target={}",
|
|
g.position,
|
|
g.mac,
|
|
g.id,
|
|
g.assigned_target.unwrap_or_else(|| "-".into())
|
|
);
|
|
}
|
|
Ok(out)
|
|
}
|
|
Some("assign-all") => {
|
|
let target = args.get(1).ok_or_else(|| {
|
|
"usage: gate assign-all <iso_boot_entry_id>".to_string()
|
|
})?;
|
|
let found = s
|
|
.iso_store
|
|
.list()
|
|
.into_iter()
|
|
.any(|i| i.boot_entries.iter().any(|e| &e.id == target));
|
|
if !found {
|
|
return Err(format!("no such boot entry: {target}"));
|
|
}
|
|
let ids: Vec<_> = s.gates.list().into_iter().map(|g| g.id).collect();
|
|
let n = s.gates.assign(&ids, target);
|
|
Ok(format!("assigned {n} gates -> {target}"))
|
|
}
|
|
Some("assign") => {
|
|
let gate_id = args
|
|
.get(1)
|
|
.ok_or_else(|| "usage: gate assign <gate_id> <iso_boot_entry_id>".to_string())?;
|
|
let target = args
|
|
.get(2)
|
|
.ok_or_else(|| "usage: gate assign <gate_id> <iso_boot_entry_id>".to_string())?;
|
|
let n = s.gates.assign(std::slice::from_ref(gate_id), target);
|
|
if n == 0 {
|
|
return Err(format!("no such gate: {gate_id}"));
|
|
}
|
|
Ok(format!("assigned 1 gate -> {target}"))
|
|
}
|
|
Some("release") => {
|
|
let gate_id = args.get(1).ok_or_else(|| "usage: gate release <gate_id>".to_string())?;
|
|
match s.gates.release(gate_id) {
|
|
Some(_) => Ok(format!("released {gate_id}")),
|
|
None => Err(format!("no such gate: {gate_id}")),
|
|
}
|
|
}
|
|
Some(other) => Err(format!(
|
|
"unknown gate subcommand: {other}\ntry: gate [list|assign-all|assign|release]"
|
|
)),
|
|
}
|
|
}
|
|
|
|
// ── nfs ────────────────────────────────────────────────────────────────
|
|
|
|
async fn nfs_command(s: &AppState, args: &[String]) -> Result<String, String> {
|
|
match args.first().map(String::as_str) {
|
|
None | Some("list") => {
|
|
let mounts = s.nfs.list();
|
|
if mounts.is_empty() {
|
|
return Ok("(no NFS mounts configured)".into());
|
|
}
|
|
let mut out = String::new();
|
|
let _ = writeln!(
|
|
out,
|
|
"{:<24} {:<6} {:<7} {:<6} {}",
|
|
"ID", "VER", "STATUS", "ISOS", "TARGET"
|
|
);
|
|
for m in mounts {
|
|
let status = if m.mounted { "ok" } else { "down" };
|
|
let _ = writeln!(
|
|
out,
|
|
"{:<24} {:<6} {:<7} {:<6} {}:{}",
|
|
truncate(&m.id, 24),
|
|
match m.version {
|
|
pxeforge_iso_store::NfsVersion::V3 => "v3",
|
|
pxeforge_iso_store::NfsVersion::V41 => "v4.1",
|
|
},
|
|
status,
|
|
m.iso_count,
|
|
m.server,
|
|
m.export,
|
|
);
|
|
if let Some(e) = m.last_error {
|
|
let _ = writeln!(out, " error: {e}");
|
|
}
|
|
}
|
|
Ok(out)
|
|
}
|
|
Some("mount") => {
|
|
// nfs mount <server>:<export> [v3|v41] [ro|rw]
|
|
let target = args
|
|
.get(1)
|
|
.ok_or_else(|| "usage: nfs mount <server>:<export> [v3|v41] [ro|rw]".to_string())?;
|
|
let (server, export) = target
|
|
.split_once(':')
|
|
.ok_or_else(|| "target must be 'server:/export'".to_string())?;
|
|
let version = match args.get(2).map(String::as_str) {
|
|
Some("v3") => pxeforge_iso_store::NfsVersion::V3,
|
|
Some("v41") | None => pxeforge_iso_store::NfsVersion::V41,
|
|
Some(other) => return Err(format!("unknown nfs version: {other} (expect v3 or v41)")),
|
|
};
|
|
let read_only = !matches!(args.get(3).map(String::as_str), Some("rw"));
|
|
let req = pxeforge_iso_store::NfsAddRequest {
|
|
server: server.to_string(),
|
|
export: export.to_string(),
|
|
version,
|
|
read_only,
|
|
};
|
|
match s.nfs.add(req).await {
|
|
Ok(m) => Ok(format!("mounted {} ({} isos)", m.id, m.iso_count)),
|
|
Err(e) => Err(format!("mount failed: {e}")),
|
|
}
|
|
}
|
|
Some("unmount") => {
|
|
let id = args.get(1).ok_or_else(|| "usage: nfs unmount <id>".to_string())?;
|
|
match s.nfs.remove(id).await {
|
|
Ok(()) => Ok(format!("unmounted {id}")),
|
|
Err(e) => Err(format!("unmount failed: {e}")),
|
|
}
|
|
}
|
|
Some("scan") => {
|
|
let id = args.get(1).ok_or_else(|| "usage: nfs scan <id>".to_string())?;
|
|
match s.nfs.rescan(id).await {
|
|
Ok(n) => Ok(format!("re-scanned {id}: {n} isos")),
|
|
Err(e) => Err(format!("scan failed: {e}")),
|
|
}
|
|
}
|
|
Some(other) => Err(format!(
|
|
"unknown nfs subcommand: {other}\ntry: nfs [list|mount|unmount|scan]"
|
|
)),
|
|
}
|
|
}
|
|
|
|
// ── smb ────────────────────────────────────────────────────────────────
|
|
|
|
#[allow(clippy::unused_async)]
|
|
async fn smb_command(s: &AppState, args: &[String]) -> Result<String, String> {
|
|
let smb = s
|
|
.smb
|
|
.as_ref()
|
|
.ok_or_else(|| "SMB manager not configured (Windows support disabled)".to_string())?;
|
|
match args.first().map(String::as_str) {
|
|
None | Some("status") => Ok(format!("{:#?}", smb.snapshot())),
|
|
Some("start") => {
|
|
// start/reconcile return the new SmbState — there's no
|
|
// separate Result type. The state itself indicates success
|
|
// or failure via its variant.
|
|
let st = smb.start();
|
|
Ok(format!("smbd start requested -> {st:?}"))
|
|
}
|
|
Some("stop") => {
|
|
smb.stop();
|
|
Ok("smbd stop requested".into())
|
|
}
|
|
Some("reload") => {
|
|
let st = smb.reconcile();
|
|
Ok(format!("smbd reload (SIGHUP) sent -> {st:?}"))
|
|
}
|
|
Some(other) => Err(format!(
|
|
"unknown smb subcommand: {other}\ntry: smb [status|start|stop|reload]"
|
|
)),
|
|
}
|
|
}
|
|
|
|
// ── log ────────────────────────────────────────────────────────────────
|
|
|
|
fn log_command(s: &AppState, args: &[String]) -> Result<String, String> {
|
|
match args.first().map(String::as_str) {
|
|
Some("clear") => {
|
|
s.log_bus.clear();
|
|
Ok("log buffer cleared".into())
|
|
}
|
|
Some("tail") => {
|
|
let n: usize = args
|
|
.get(1)
|
|
.and_then(|v| v.parse().ok())
|
|
.unwrap_or(20);
|
|
let lines = s.log_bus.recent();
|
|
let start = lines.len().saturating_sub(n);
|
|
let mut out = String::new();
|
|
for l in &lines[start..] {
|
|
let _ = writeln!(out, "{}", l.render());
|
|
}
|
|
if out.is_empty() {
|
|
Ok("(empty)".into())
|
|
} else {
|
|
Ok(out)
|
|
}
|
|
}
|
|
_ => Err("usage: log [clear|tail [n]]".into()),
|
|
}
|
|
}
|
|
|
|
// ── helpers ────────────────────────────────────────────────────────────
|
|
|
|
fn uptime_string(s: &AppState) -> String {
|
|
let now = time::OffsetDateTime::now_utc();
|
|
let secs = (now - s.started_at).whole_seconds().max(0);
|
|
let h = secs / 3600;
|
|
let m = (secs % 3600) / 60;
|
|
let s = secs % 60;
|
|
format!("{h}h {m}m {s}s")
|
|
}
|
|
|
|
fn truncate(s: &str, max: usize) -> String {
|
|
if s.len() <= max {
|
|
s.to_string()
|
|
} else {
|
|
format!("{}…", &s[..max.saturating_sub(1)])
|
|
}
|
|
}
|
|
|
|
fn human_bytes(n: u64) -> String {
|
|
const U: &[&str] = &["B", "KB", "MB", "GB", "TB"];
|
|
// Loss-of-precision past 2^52 is academic for ISO file sizes — even
|
|
// a 4 PiB file still rounds to the right unit.
|
|
#[allow(clippy::cast_precision_loss)]
|
|
let mut x = n as f64;
|
|
let mut i = 0;
|
|
while x >= 1024.0 && i < U.len() - 1 {
|
|
x /= 1024.0;
|
|
i += 1;
|
|
}
|
|
if i == 0 || x >= 10.0 {
|
|
format!("{:.0} {}", x, U[i])
|
|
} else {
|
|
format!("{:.1} {}", x, U[i])
|
|
}
|
|
}
|
|
|
|
/// Tiny shell-like splitter — splits on whitespace, honoring `'…'` and
|
|
/// `"…"` quoted segments. We deliberately don't expand `$VAR` or any
|
|
/// other shell metacharacters; this is a parser for our own command
|
|
/// vocabulary, not a real shell.
|
|
pub fn shell_split(input: &str) -> Vec<String> {
|
|
let mut out = Vec::new();
|
|
let mut current = String::new();
|
|
let mut in_single = false;
|
|
let mut in_double = false;
|
|
for ch in input.chars() {
|
|
match ch {
|
|
'\'' if !in_double => in_single = !in_single,
|
|
'"' if !in_single => in_double = !in_double,
|
|
c if c.is_whitespace() && !in_single && !in_double => {
|
|
if !current.is_empty() {
|
|
out.push(std::mem::take(&mut current));
|
|
}
|
|
}
|
|
c => current.push(c),
|
|
}
|
|
}
|
|
if !current.is_empty() {
|
|
out.push(current);
|
|
}
|
|
out
|
|
}
|
|
|
|
const HELP_TEXT: &str = "\
|
|
PXEForge terminal — available commands:
|
|
|
|
help show this help
|
|
version print server version
|
|
status high-level server status
|
|
uptime time since startup
|
|
|
|
isos list registered ISOs
|
|
clients list PXE clients seen this session
|
|
gate list list gated-deployment queue
|
|
gate assign <gate_id> <target> assign one gate to a boot entry
|
|
gate assign-all <target> assign every waiting gate
|
|
gate release <gate_id> release one gate
|
|
|
|
nfs list list NFS mounts
|
|
nfs mount <s>:<e> [v3|v41] [ro|rw] add and mount an NFS share
|
|
nfs unmount <id> unmount and forget a share
|
|
nfs scan <id> re-scan a share for new ISOs
|
|
|
|
smb status SMB (Samba) state
|
|
smb start | stop | reload control smbd
|
|
|
|
log clear drop the in-memory log ring buffer
|
|
log tail [n] show the last n buffered lines (default 20)
|
|
clear clear the terminal pane
|
|
|
|
Tab to autocomplete is not implemented (sorry).\n";
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn shell_split_basic() {
|
|
assert_eq!(shell_split(""), Vec::<String>::new());
|
|
assert_eq!(shell_split("nfs list"), vec!["nfs", "list"]);
|
|
assert_eq!(
|
|
shell_split("nfs mount 10.0.0.5:/srv v41 ro"),
|
|
vec!["nfs", "mount", "10.0.0.5:/srv", "v41", "ro"]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn shell_split_quoted() {
|
|
assert_eq!(
|
|
shell_split("echo 'hello world' done"),
|
|
vec!["echo", "hello world", "done"]
|
|
);
|
|
assert_eq!(
|
|
shell_split(r#"echo "double quotes" 'and singles'"#),
|
|
vec!["echo", "double quotes", "and singles"]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn human_bytes_units() {
|
|
assert_eq!(human_bytes(0), "0 B");
|
|
assert_eq!(human_bytes(1023), "1023 B");
|
|
assert_eq!(human_bytes(1024), "1.0 KB");
|
|
assert_eq!(human_bytes(2 * 1024 * 1024), "2.0 MB");
|
|
assert_eq!(human_bytes(5 * 1024u64.pow(3)), "5.0 GB");
|
|
}
|
|
|
|
#[test]
|
|
fn truncate_keeps_short() {
|
|
assert_eq!(truncate("hi", 10), "hi");
|
|
assert_eq!(truncate("longerthanfive", 5), "long…");
|
|
}
|
|
}
|