use crate::identity::{validate_name, ContainerIdentity, ContainerRef};
use crate::ContainerError;
use std::process::Command;
use std::time::Duration;
use strop_core::process::{capture_with, CaptureError, CapturePolicy, StdinPolicy};
use strop_core::worker::CancelToken;
const INFO_DEADLINE: Duration = Duration::from_secs(10);
const META_DEADLINE: Duration = Duration::from_secs(15);
pub(crate) const READ_DEADLINE: Duration = Duration::from_secs(30);
const STDERR_LIMIT: u64 = 64 * 1024;
const META_LIMIT: u64 = 4 * 1024 * 1024;
pub(crate) const LIST_LIMIT: u64 = 16 * 1024 * 1024;
#[derive(Debug, Clone)]
pub struct EngineRef {
server_version: String,
}
impl EngineRef {
pub fn server_version(&self) -> &str {
&self.server_version
}
}
pub(crate) struct Captured {
pub code: Option<i32>,
pub stdout: Vec<u8>,
pub stderr: Vec<u8>,
pub stdout_dropped: u64,
}
pub(crate) fn capture(
args: &[&str],
stdout_limit: u64,
deadline: Duration,
token: &CancelToken,
) -> Result<Captured, ContainerError> {
let mut command = Command::new("docker");
command.args(args);
let policy = CapturePolicy {
stdout_limit,
stderr_limit: STDERR_LIMIT,
stderr_tail: 0,
deadline,
stdin: StdinPolicy::Null,
};
let output = capture_with(&mut command, token, &policy).map_err(|error| match error {
CaptureError::Spawn(detail) => ContainerError::EngineUnavailable { detail },
CaptureError::Cancelled => ContainerError::Cancelled,
CaptureError::TimedOut(deadline) => ContainerError::Io {
detail: format!(
"docker {} timed out after {}s",
args.first().copied().unwrap_or("<none>"),
deadline.as_secs()
),
},
CaptureError::Failure(failure) => ContainerError::Io {
detail: failure.message,
},
})?;
Ok(Captured {
code: output.status.code(),
stdout: output.stdout,
stderr: output.stderr,
stdout_dropped: output.stdout_dropped,
})
}
pub(crate) fn stderr_tail(stderr: &[u8]) -> String {
let text = String::from_utf8_lossy(stderr);
let tail: String = text.chars().rev().take(300).collect::<String>();
tail.chars().rev().collect::<String>().replace('\n', " ")
}
pub fn engine(token: &CancelToken) -> Result<EngineRef, ContainerError> {
let output = capture(
&["info", "--format", "{{json .ServerVersion}}"],
META_LIMIT,
INFO_DEADLINE,
token,
)?;
if output.code != Some(0) {
return Err(ContainerError::EngineUnavailable {
detail: stderr_tail(&output.stderr),
});
}
let server_version: String =
serde_json::from_slice(&output.stdout).map_err(|error| ContainerError::Protocol {
detail: format!("docker info reported no server version: {error}"),
})?;
if server_version.is_empty() {
return Err(ContainerError::Protocol {
detail: "docker info reported an empty server version".into(),
});
}
Ok(EngineRef { server_version })
}
pub fn list_running(
engine: &EngineRef,
token: &CancelToken,
) -> Result<Vec<ContainerIdentity>, ContainerError> {
let _ = engine;
let output = capture(
&["ps", "--quiet", "--no-trunc"],
META_LIMIT,
META_DEADLINE,
token,
)?;
if output.code != Some(0) {
return Err(ContainerError::EngineUnavailable {
detail: stderr_tail(&output.stderr),
});
}
let text = String::from_utf8(output.stdout).map_err(|_| ContainerError::Protocol {
detail: "docker ps answered in non-UTF-8".into(),
})?;
let ids: Vec<&str> = text.lines().filter(|line| !line.is_empty()).collect();
for id in &ids {
if id.len() != 64 || !id.bytes().all(|b| b.is_ascii_hexdigit()) {
return Err(ContainerError::Protocol {
detail: format!("docker ps reported a non-canonical id {id:?}"),
});
}
}
if ids.is_empty() {
return Ok(Vec::new());
}
let mut args = vec!["inspect"];
args.extend(ids);
let output = capture(&args, META_LIMIT, META_DEADLINE, token)?;
inspect_records("docker ps ids", &output)?
.into_iter()
.filter(|record| record.state.running)
.map(identity)
.collect()
}
pub fn inspect(
engine: &EngineRef,
name_or_id: &str,
token: &CancelToken,
) -> Result<ContainerIdentity, ContainerError> {
let _ = engine;
validate_name(name_or_id)?;
let output = capture(&["inspect", name_or_id], META_LIMIT, META_DEADLINE, token)?;
identity(inspect_record(name_or_id, &output)?)
}
pub fn revalidate(
engine: &EngineRef,
held: &ContainerIdentity,
token: &CancelToken,
) -> Result<ContainerRef, ContainerError> {
let current = inspect(engine, &held.name, token)?;
if current.id != held.id || current.started_at != held.started_at {
return Err(ContainerError::StaleIdentity {
name: held.name.clone(),
expected: format!("{}@{}", held.id, held.started_at),
found: format!("{}@{}", current.id, current.started_at),
});
}
ContainerRef::of(¤t)
}
pub(crate) fn refresh(
engine: &EngineRef,
reference: &ContainerRef,
token: &CancelToken,
) -> Result<(), ContainerError> {
let _ = engine;
let id = reference.id().as_str();
let output = capture(&["inspect", id], META_LIMIT, META_DEADLINE, token)?;
let record = inspect_record(id, &output)?;
if record.state.started_at != reference.started_at() {
return Err(ContainerError::StaleIdentity {
name: id.to_string(),
expected: reference.incarnation(),
found: format!("{id}@{}", record.state.started_at),
});
}
if !record.state.running {
return Err(ContainerError::NotRunning { id: id.to_string() });
}
Ok(())
}
#[derive(serde::Deserialize)]
struct InspectRecord {
#[serde(rename = "Id")]
id: String,
#[serde(rename = "Name", default)]
name: String,
#[serde(rename = "Config", default)]
config: InspectConfig,
#[serde(rename = "State", default)]
state: InspectState,
}
#[derive(Default, serde::Deserialize)]
struct InspectConfig {
#[serde(rename = "Image", default)]
image: String,
#[serde(rename = "User", default)]
user: String,
}
#[derive(Default, serde::Deserialize)]
struct InspectState {
#[serde(rename = "Running", default)]
running: bool,
#[serde(rename = "StartedAt", default)]
started_at: String,
}
fn inspect_record(name_or_id: &str, output: &Captured) -> Result<InspectRecord, ContainerError> {
Ok(inspect_records(name_or_id, output)?.remove(0))
}
fn inspect_records(what: &str, output: &Captured) -> Result<Vec<InspectRecord>, ContainerError> {
if output.code != Some(0) {
let tail = stderr_tail(&output.stderr);
if tail.to_ascii_lowercase().contains("no such object") {
return Err(ContainerError::NoSuchContainer {
name: what.to_string(),
});
}
return Err(ContainerError::Io {
detail: format!("docker inspect failed: {tail}"),
});
}
if output.stdout_dropped > 0 {
return Err(ContainerError::OutputTooLarge {
what: "docker inspect output".into(),
});
}
let records: Vec<InspectRecord> =
serde_json::from_slice(&output.stdout).map_err(|error| ContainerError::Protocol {
detail: format!("docker inspect answered malformed JSON: {error}"),
})?;
if records.is_empty() {
return Err(ContainerError::Protocol {
detail: format!("docker inspect of {what} returned no record"),
});
}
Ok(records)
}
fn identity(record: InspectRecord) -> Result<ContainerIdentity, ContainerError> {
strop_workspace::ContainerId::canonical(record.id.clone()).map_err(|_| {
ContainerError::Protocol {
detail: format!("inspect id {:?} is not the canonical 64-hex id", record.id),
}
})?;
Ok(ContainerIdentity {
id: record.id,
name: record.name.trim_start_matches('/').to_string(),
image: record.config.image,
started_at: record.state.started_at,
user: record.config.user,
})
}
#[cfg(test)]
mod tests {
use super::*;
fn captured(code: Option<i32>, stdout: &[u8], stderr: &[u8]) -> Captured {
Captured {
code,
stdout: stdout.to_vec(),
stderr: stderr.to_vec(),
stdout_dropped: 0,
}
}
const INSPECT_JSON: &str = r#"[{
"Id": "5b04229f99d2c4b8ae3c4e38b7a887cf1c5c1ea51f2a1b2c3d4e5f60718293a4",
"Name": "/fixture",
"Config": {"Image": "busybox:latest", "User": "root"},
"State": {"Running": true, "StartedAt": "2026-09-10T08:00:00.123Z"}
}]"#;
#[test]
fn inspect_json_decodes_to_identity() {
let output = captured(Some(0), INSPECT_JSON.as_bytes(), b"");
let identity = identity(inspect_record("fixture", &output).unwrap()).unwrap();
assert_eq!(identity.id.len(), 64);
assert_eq!(identity.name, "fixture", "leading slash stripped");
assert_eq!(identity.image, "busybox:latest");
assert_eq!(identity.started_at, "2026-09-10T08:00:00.123Z");
assert_eq!(identity.user, "root");
}
#[test]
fn missing_object_and_daemon_errors_classify() {
let missing = captured(
Some(1),
b"[]",
b"Error response from daemon: No such object: ghost\n",
);
assert!(matches!(
inspect_record("ghost", &missing),
Err(ContainerError::NoSuchContainer { .. })
));
let lowercase = captured(Some(1), b"[]", b"error: no such object: ghost\n");
assert!(matches!(
inspect_record("ghost", &lowercase),
Err(ContainerError::NoSuchContainer { .. })
));
let down = captured(
Some(1),
b"",
b"Cannot connect to the Docker daemon at unix:///var/run/docker.sock",
);
assert!(matches!(
inspect_record("ghost", &down),
Err(ContainerError::Io { .. })
));
let malformed = captured(Some(0), b"[{]", b"");
assert!(matches!(
inspect_record("ghost", &malformed),
Err(ContainerError::Protocol { .. })
));
}
#[test]
fn non_canonical_ids_are_protocol_violations() {
let json = INSPECT_JSON.replace(
"5b04229f99d2c4b8ae3c4e38b7a887cf1c5c1ea51f2a1b2c3d4e5f60718293a4",
"5b04229f99d2",
);
let output = captured(Some(0), json.as_bytes(), b"");
assert!(matches!(
inspect_record("x", &output).and_then(identity),
Err(ContainerError::Protocol { .. })
));
}
#[test]
fn stderr_tail_is_bounded_and_single_line() {
let noisy = format!("{}\nfinal line", "x".repeat(1000));
let tail = stderr_tail(noisy.as_bytes());
assert!(tail.len() <= 300);
assert!(tail.ends_with("final line"));
assert!(!tail.contains('\n'));
}
}