Skip to main content

rightsize_docker/
backend.rs

1//! `DockerBackend`: maps `SandboxBackend` onto the daemon HTTP endpoints this
2//! backend needs, over the hand-rolled unix-socket client and the frame demuxer —
3//! with one deliberate exception: runtime file copy shells out to the `docker` CLI
4//! (see [`DockerBackend::copy_to_container`]'s doc) rather than hand-rolling the
5//! daemon's tar-archive copy endpoints. This is also the correctness oracle other
6//! backends are checked against, since Docker enforces semantics (read-only mounts,
7//! native networks) microsandbox only emulates.
8//!
9//! **`Handle` has no per-container mutable state** (contrast microsandbox's
10//! `HandleState` map): every operation here is a stateless HTTP call keyed by the
11//! daemon-assigned container id already carried on the handle, so there's nothing to
12//! look up in a side table.
13
14use std::collections::{BTreeMap, HashMap};
15use std::path::Path;
16use std::sync::Mutex;
17use std::time::Duration;
18
19use rightsize::backend::{Capabilities, FollowHandle, SandboxBackend, SandboxHandle};
20use rightsize::error::{Result, RightsizeError};
21use rightsize::model::{ContainerSpec, ExecResult};
22
23use crate::client::DockerClient;
24use crate::frames::{BodyReader, LineAssembler, StreamType, demux_into, read_frame};
25use crate::json::{
26    ConnectNetworkBody, CreateContainerBody, CreateExecBody, CreateNetworkBody, EmptyObject,
27    EndpointConfig, ExecInspect, HostConfig, IdResponse, LabelFilter, ListedEntry, NameFilter,
28    PortBinding as JsonPortBinding, StartExecBody,
29};
30
31/// How long `POST /containers/{id}/stop` gives the entrypoint to exit before the
32/// daemon SIGKILLs it.
33const STOP_TIMEOUT_SECS: u64 = 10;
34
35/// The label every non-`keep_alive` container this backend creates carries, so
36/// `close()`/the reaper can find exactly this run's containers without touching
37/// anyone else's. This is a cross-process wire format, not Rust API surface, so it
38/// doesn't need to match this crate's own naming conventions.
39const RUN_ID_LABEL_KEY: &str = "dev.rightsize.run_id";
40
41/// The label a `keep_alive` (reuse) container carries INSTEAD of
42/// [`RUN_ID_LABEL_KEY`] — deliberately a different key, so `close()`'s run-id label
43/// filter (and any run-id-scoped listing) structurally never matches a reuse
44/// container. See `ContainerSpec::keep_alive`'s doc.
45const REUSE_LABEL_KEY: &str = "dev.rightsize.reuse";
46
47/// A Docker container reference: the daemon-assigned id plus the spec that created
48/// it. No mutable per-container state (see the module docs) — every operation is a
49/// stateless call keyed by `id`.
50struct Handle {
51    id: String,
52    spec: ContainerSpec,
53}
54
55impl SandboxHandle for Handle {
56    fn id(&self) -> &str {
57        &self.id
58    }
59    fn spec(&self) -> &ContainerSpec {
60        &self.spec
61    }
62}
63
64/// Drives the Docker daemon over `DockerClient`. See the module docs for the shape.
65pub struct DockerBackend {
66    client: DockerClient,
67    /// Caches network-id lookups so `ensure_network`/`install`/`remove_network` agree
68    /// on which daemon-side network a given `rightsize` network id maps to, without a
69    /// list-by-name round trip on every call.
70    network_ids: Mutex<HashMap<String, String>>,
71}
72
73impl DockerBackend {
74    /// Builds a backend talking to the daemon through `client`. Crate-internal:
75    /// [`crate::DockerBackendProvider`] (via `rightsize::backends::resolve`) is the
76    /// usual way to get one; [`Self::connecting_to_env`] is the public escape hatch
77    /// for callers (integration tests, mainly) that want a `DockerBackend` directly
78    /// without going through backend resolution.
79    pub(crate) fn new(client: DockerClient) -> Self {
80        DockerBackend {
81            client,
82            network_ids: Mutex::new(HashMap::new()),
83        }
84    }
85
86    /// Builds a backend pointed at the daemon `DOCKER_HOST` (or the default socket)
87    /// names — the same resolution `crate::DockerBackendProvider`'s `create` uses,
88    /// exposed directly for callers that want a concrete `DockerBackend` without
89    /// going through `rightsize::backends::resolve` (integration tests, mainly).
90    pub fn connecting_to_env() -> Self {
91        Self::new(DockerClient::from_env())
92    }
93
94    /// Exposed for the transport regression test: the socket path this
95    /// backend's client actually dials, so a test can assert it's a unix path and
96    /// never `localhost:2375` or similar.
97    #[cfg(test)]
98    pub(crate) fn socket_path_for_test(&self) -> std::path::PathBuf {
99        self.client.socket_path().to_path_buf()
100    }
101
102    /// `GET /images/{name}/json` to check presence, `POST /images/create` to pull if
103    /// a 404 says it's missing. The pull response is a stream of progress JSON lines
104    /// this backend doesn't parse — it drains the body to completion (the daemon
105    /// itself decides when the pull is done; a stream that closes IS "pull finished
106    /// or failed") and doesn't fail loudly on inspect trouble, only reacts to
107    /// affirmatively-absent.
108    async fn pull_if_missing(&self, image: &str) -> Result<()> {
109        let inspect_path = format!("/images/{}/json", encode_path_segment(image));
110        let inspect = self.client.request("GET", &inspect_path, None).await?;
111        if inspect.status == 200 {
112            return Ok(());
113        }
114        let (repo, tag) = split_repo_tag(image);
115        let pull_path = format!(
116            "/images/create?fromImage={}&tag={}",
117            encode_query_value(&repo),
118            encode_query_value(&tag)
119        );
120        let resp = self.client.request("POST", &pull_path, None).await?;
121        if resp.status >= 400 {
122            return Err(RightsizeError::Backend(format!(
123                "docker could not pull image '{image}' (HTTP {}): {}",
124                resp.status,
125                String::from_utf8_lossy(&resp.body)
126            )));
127        }
128        Ok(())
129    }
130
131    /// `POST /networks/{id}/connect` with the aliases this container should be
132    /// reachable as.
133    async fn connect_network(
134        &self,
135        container_id: &str,
136        network_id: &str,
137        aliases: &[String],
138    ) -> Result<()> {
139        let body = ConnectNetworkBody {
140            container: container_id.to_string(),
141            endpoint_config: EndpointConfig {
142                aliases: aliases.to_vec(),
143            },
144        };
145        let body = serde_json::to_string(&body)
146            .expect("ConnectNetworkBody has no non-serializable fields");
147        let path = format!("/networks/{network_id}/connect");
148        let resp = self.client.request("POST", &path, Some(&body)).await?;
149        if resp.status >= 400 {
150            return Err(RightsizeError::Backend(format!(
151                "docker could not connect container {container_id} to network {network_id} \
152                 (HTTP {}): {}",
153                resp.status,
154                String::from_utf8_lossy(&resp.body)
155            )));
156        }
157        Ok(())
158    }
159
160    /// `GET /networks?filters=...` then `POST /networks/create` — cached by
161    /// `network_id` so repeat calls (e.g. a second container joining the same
162    /// `rightsize` network) don't re-list/re-create.
163    async fn ensure_network_get_id(&self, network_id: &str) -> Result<String> {
164        if let Some(id) = self
165            .network_ids
166            .lock()
167            .expect("network_ids mutex poisoned")
168            .get(network_id)
169        {
170            return Ok(id.clone());
171        }
172
173        let filters = NameFilter {
174            name: vec![network_id.to_string()],
175        };
176        let filters =
177            serde_json::to_string(&filters).expect("NameFilter has no non-serializable fields");
178        let list_path = format!("/networks?filters={}", encode_query_value(&filters));
179        let list = self.client.request("GET", &list_path, None).await?;
180        if list.status == 200 {
181            let entries: Vec<ListedEntry> = serde_json::from_slice(&list.body).unwrap_or_default();
182            if let Some(entry) = entries.into_iter().next() {
183                self.network_ids
184                    .lock()
185                    .expect("network_ids mutex poisoned")
186                    .insert(network_id.to_string(), entry.id.clone());
187                return Ok(entry.id);
188            }
189        }
190
191        let create_body = CreateNetworkBody {
192            name: network_id.to_string(),
193        };
194        let create_body = serde_json::to_string(&create_body)
195            .expect("CreateNetworkBody has no non-serializable fields");
196        let created = self
197            .client
198            .request("POST", "/networks/create", Some(&create_body))
199            .await?;
200        if created.status >= 400 {
201            return Err(RightsizeError::Backend(format!(
202                "docker could not create network '{network_id}' (HTTP {}): {}",
203                created.status,
204                String::from_utf8_lossy(&created.body)
205            )));
206        }
207        let id = serde_json::from_slice::<IdResponse>(&created.body)
208            .map_err(|e| {
209                RightsizeError::Backend(format!(
210                    "docker's network-create response for '{network_id}' had no Id field: {e} \
211                     (body: {})",
212                    String::from_utf8_lossy(&created.body)
213                ))
214            })?
215            .id;
216        self.network_ids
217            .lock()
218            .expect("network_ids mutex poisoned")
219            .insert(network_id.to_string(), id.clone());
220        Ok(id)
221    }
222
223    /// A live `GET /networks?filters={"name":[name]}` lookup, independent of the
224    /// in-memory `network_ids` cache — [`Self::remove_network`]'s cache-miss
225    /// fallback, so removing a network created by a DIFFERENT process (the reaping
226    /// sweep's whole purpose) doesn't silently no-op just because THIS backend
227    /// instance never itself called [`Self::ensure_network_get_id`] for it.
228    /// Best-effort: `None` on any failure (request error, non-200, no match,
229    /// unparseable body), matching `remove_network`'s own best-effort contract.
230    async fn lookup_network_id_by_name(&self, name: &str) -> Option<String> {
231        let filters = NameFilter {
232            name: vec![name.to_string()],
233        };
234        let filters = serde_json::to_string(&filters).ok()?;
235        let list_path = format!("/networks?filters={}", encode_query_value(&filters));
236        let list = self.client.request("GET", &list_path, None).await.ok()?;
237        if list.status != 200 {
238            return None;
239        }
240        let entries: Vec<ListedEntry> = serde_json::from_slice(&list.body).ok()?;
241        entries.into_iter().next().map(|e| e.id)
242    }
243}
244
245/// Builds the `POST /containers/create` JSON body: `Image`, `Env[]`, `Cmd[]?`,
246/// `ExposedPorts{}`, `Labels`, and a `HostConfig` carrying port bindings (bound to
247/// `127.0.0.1` — never the wildcard address), read-only/read-write binds, the
248/// `host.docker.internal:host-gateway` extra host (lets a container reach services
249/// running on the host, e.g. a test's own HTTP server, the same way it would under a
250/// real Docker Desktop install), and an optional memory cap.
251fn build_create_body(spec: &ContainerSpec) -> CreateContainerBody {
252    let env = spec.env.iter().map(|(k, v)| format!("{k}={v}")).collect();
253
254    let exposed_ports = spec
255        .ports
256        .iter()
257        .map(|p| (format!("{}/tcp", p.guest_port), EmptyObject {}))
258        .collect();
259
260    let port_bindings = spec
261        .ports
262        .iter()
263        .map(|p| {
264            (
265                format!("{}/tcp", p.guest_port),
266                vec![JsonPortBinding {
267                    host_ip: "127.0.0.1".to_string(),
268                    host_port: p.host_port.to_string(),
269                }],
270            )
271        })
272        .collect();
273
274    let binds = spec
275        .mounts
276        .iter()
277        .map(|m| {
278            let mode = if m.read_only { "ro" } else { "rw" };
279            format!("{}:{}:{}", m.host_path.display(), m.guest_path, mode)
280        })
281        .collect();
282
283    let labels = if spec.keep_alive {
284        BTreeMap::from([(REUSE_LABEL_KEY.to_string(), reuse_label_value(spec))])
285    } else {
286        BTreeMap::from([(RUN_ID_LABEL_KEY.to_string(), spec.run_id.clone())])
287    };
288
289    CreateContainerBody {
290        image: spec.image.clone(),
291        env,
292        cmd: spec.command.clone(),
293        exposed_ports,
294        labels,
295        host_config: HostConfig {
296            port_bindings,
297            binds,
298            extra_hosts: vec!["host.docker.internal:host-gateway".to_string()],
299            memory: spec.memory_limit_mb.map(|mb| mb * 1024 * 1024),
300        },
301    }
302}
303
304/// The 12-hex-char value a `keep_alive` container's [`REUSE_LABEL_KEY`] label
305/// carries. Every real reuse container `rightsize::container`'s reuse flow builds
306/// always carries the exact `rz-reuse-<12hex>` name that hash is derived from (see
307/// `rightsize::reuse::compute_identity`) — this extracts that suffix directly,
308/// rather than re-deriving the hash itself, so the label always agrees with the
309/// name regardless of which language built the spec. Falls back to a cheap,
310/// dependency-free FNV-1a-style hash of `spec.name` for a `keep_alive` spec built
311/// directly with some OTHER name shape (only ever a unit test constructing
312/// `ContainerSpec` by hand) — deterministic, just not meaningfully tied to any
313/// cross-language identity.
314fn reuse_label_value(spec: &ContainerSpec) -> String {
315    if let Some(hex) = spec.name.strip_prefix("rz-reuse-") {
316        if hex.len() == 12 && hex.chars().all(|c| c.is_ascii_hexdigit()) {
317            return hex.to_string();
318        }
319    }
320    let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
321    for byte in spec.name.as_bytes() {
322        hash ^= *byte as u64;
323        hash = hash.wrapping_mul(0x0000_0100_0000_01B3);
324    }
325    format!("{:012x}", hash & 0xFFFF_FFFF_FFFF)
326}
327
328/// True if `message` (a daemon error body/exception message) names a host-port bind
329/// conflict — the daemon has no distinct exception type for this, only free-text like
330/// "driver failed programming external connectivity ... address already in use" or
331/// "Bind for 0.0.0.0:PORT failed: port is already allocated".
332fn is_port_bind_conflict_message(message: &str) -> bool {
333    let m = message.to_lowercase();
334    m.contains("already in use") || m.contains("already allocated")
335}
336
337/// Percent-encodes a path segment (just enough for image references, which can
338/// contain `/` that must stay literal in this position but nothing else exotic in
339/// practice) — used for `GET /images/{name}/json`.
340fn encode_path_segment(s: &str) -> String {
341    // Image names/tags are `[a-zA-Z0-9_.\-/:@]`-shaped in practice; none of those need
342    // percent-encoding in a URL path segment, so this is intentionally a pass-through
343    // rather than a full RFC 3986 encoder — there is nothing in this crate's own
344    // input space that would need escaping here.
345    s.to_string()
346}
347
348/// Percent-encodes a query string value (`fromImage`, `tag`, the JSON `filters` blob)
349/// — covers exactly the characters that show up in image references and the small
350/// hand-built JSON filter strings this backend sends, not a general URL encoder.
351fn encode_query_value(s: &str) -> String {
352    let mut out = String::with_capacity(s.len());
353    for b in s.bytes() {
354        match b {
355            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
356                out.push(b as char)
357            }
358            _ => out.push_str(&format!("%{b:02X}")),
359        }
360    }
361    out
362}
363
364/// Splits `image` into `(repository, tag)` for `POST /images/create?fromImage=&tag=`.
365/// A tag-less reference (`"redis"`) defaults to `"latest"`, matching Docker's own
366/// convention; a digest reference (`repo@sha256:...`) is passed through as the
367/// "repository" half with an empty tag, since `fromImage` accepts the whole
368/// `repo@digest` form and an empty `tag` is simply omitted from the query by the
369/// caller not being asked to add one — see the call site, which always sends both
370/// params, so this returns `""` rather than `"latest"` for a digest reference,
371/// avoiding a nonsensical `tag=latest` alongside an explicit digest.
372fn split_repo_tag(image: &str) -> (String, String) {
373    if image.contains('@') {
374        return (image.to_string(), String::new());
375    }
376    // A tag separator is the LAST colon that appears after the last slash (so a
377    // registry host:port prefix like `localhost:5000/redis` isn't mistaken for a
378    // tag separator).
379    let slash_idx = image.rfind('/').map(|i| i + 1).unwrap_or(0);
380    match image[slash_idx..].rfind(':') {
381        Some(rel_colon) => {
382            let colon = slash_idx + rel_colon;
383            (image[..colon].to_string(), image[colon + 1..].to_string())
384        }
385        None => (image.to_string(), "latest".to_string()),
386    }
387}
388
389#[async_trait::async_trait]
390impl SandboxBackend for DockerBackend {
391    fn name(&self) -> &str {
392        "docker"
393    }
394
395    fn supports_native_networks(&self) -> bool {
396        true
397    }
398
399    fn capabilities(&self) -> Capabilities {
400        Capabilities {
401            // Containers share the host kernel — no per-sandbox microVM boundary.
402            hardware_isolated: false,
403            checkpoint: true,
404            // An image commit touches nothing running — the container is left
405            // undisturbed, unlike microsandbox's stop/snapshot/start cycle.
406            checkpoint_restarts_workload: false,
407        }
408    }
409
410    async fn create(&self, spec: ContainerSpec) -> Result<Box<dyn SandboxHandle>> {
411        self.pull_if_missing(&spec.image).await?;
412
413        let body = build_create_body(&spec);
414        let body = serde_json::to_string(&body)
415            .expect("CreateContainerBody has no non-serializable fields");
416        let path = format!("/containers/create?name={}", encode_query_value(&spec.name));
417        let resp = self.client.request("POST", &path, Some(&body)).await?;
418        if resp.status == 409 {
419            let message = String::from_utf8_lossy(&resp.body).into_owned();
420            return Err(RightsizeError::NameConflict {
421                message: format!(
422                    "docker container name '{}' is already in use (HTTP 409): {message}",
423                    spec.name
424                ),
425                source: None,
426            });
427        }
428        if resp.status >= 400 {
429            let message = String::from_utf8_lossy(&resp.body).into_owned();
430            return Err(RightsizeError::Backend(format!(
431                "docker could not create container '{}' (HTTP {}): {message}",
432                spec.name, resp.status
433            )));
434        }
435        let id = serde_json::from_slice::<IdResponse>(&resp.body)
436            .map_err(|e| {
437                RightsizeError::Backend(format!(
438                    "docker's container-create response for '{}' had no Id field: {e} (body: {})",
439                    spec.name,
440                    String::from_utf8_lossy(&resp.body)
441                ))
442            })?
443            .id;
444
445        if let Some(network_id) = &spec.network_id {
446            let daemon_network_id = self.ensure_network_get_id(network_id).await?;
447            self.connect_network(&id, &daemon_network_id, &spec.aliases)
448                .await?;
449        }
450
451        Ok(Box::new(Handle { id, spec }))
452    }
453
454    async fn start(&self, handle: &dyn SandboxHandle) -> Result<()> {
455        let path = format!("/containers/{}/start", handle.id());
456        let resp = self.client.request("POST", &path, None).await?;
457        if resp.status == 204 || resp.status == 304 {
458            return Ok(()); // 304 = already started, treated as success like the daemon intends.
459        }
460        let message = String::from_utf8_lossy(&resp.body).into_owned();
461        if resp.status == 500 && is_port_bind_conflict_message(&message) {
462            return Err(RightsizeError::PortBindConflict {
463                message: format!(
464                    "docker could not bind a host port for {}: {message}",
465                    handle.id()
466                ),
467                source: None,
468            });
469        }
470        Err(RightsizeError::Backend(format!(
471            "docker could not start container {} (HTTP {}): {message}",
472            handle.id(),
473            resp.status
474        )))
475    }
476
477    async fn stop(&self, handle: &dyn SandboxHandle) -> Result<()> {
478        let path = format!("/containers/{}/stop?t={STOP_TIMEOUT_SECS}", handle.id());
479        let _ = self.client.request("POST", &path, None).await; // best-effort
480        Ok(())
481    }
482
483    async fn remove(&self, handle: &dyn SandboxHandle) -> Result<()> {
484        let path = format!("/containers/{}?force=true", handle.id());
485        let _ = self.client.request("DELETE", &path, None).await; // best-effort
486        Ok(())
487    }
488
489    async fn exec(&self, handle: &dyn SandboxHandle, cmd: &[String]) -> Result<ExecResult> {
490        let create_body = CreateExecBody {
491            attach_stdout: true,
492            attach_stderr: true,
493            cmd: cmd.to_vec(),
494        };
495        let create_body = serde_json::to_string(&create_body)
496            .expect("CreateExecBody has no non-serializable fields");
497        let create_path = format!("/containers/{}/exec", handle.id());
498        let created = self
499            .client
500            .request("POST", &create_path, Some(&create_body))
501            .await?;
502        if created.status >= 400 {
503            return Err(RightsizeError::Backend(format!(
504                "docker could not create an exec for container {} (HTTP {}): {}",
505                handle.id(),
506                created.status,
507                String::from_utf8_lossy(&created.body)
508            )));
509        }
510        let exec_id = serde_json::from_slice::<IdResponse>(&created.body)
511            .map_err(|e| {
512                RightsizeError::Backend(format!(
513                    "docker's exec-create response for container {} had no Id field: {e} \
514                     (body: {})",
515                    handle.id(),
516                    String::from_utf8_lossy(&created.body)
517                ))
518            })?
519            .id;
520
521        let start_path = format!("/exec/{exec_id}/start");
522        let start_body = serde_json::to_string(&StartExecBody { detach: false })
523            .expect("StartExecBody has no non-serializable fields");
524        let (headers, mut stream) = self
525            .client
526            .request_stream("POST", &start_path, Some(&start_body))
527            .await?;
528        if headers.status >= 400 {
529            return Err(RightsizeError::Backend(format!(
530                "docker could not start exec {exec_id} for container {} (HTTP {})",
531                handle.id(),
532                headers.status
533            )));
534        }
535        let mut body = BodyReader::new(&mut stream, &headers);
536        let mut stdout = Vec::new();
537        let mut stderr = Vec::new();
538        demux_into(
539            &mut body,
540            |b| stdout.extend_from_slice(b),
541            |b| stderr.extend_from_slice(b),
542        )
543        .await?;
544
545        let inspect_path = format!("/exec/{exec_id}/json");
546        let inspected = self.client.request("GET", &inspect_path, None).await?;
547        let exit_code = serde_json::from_slice::<ExecInspect>(&inspected.body)
548            .ok()
549            .and_then(|r| r.exit_code)
550            .unwrap_or(-1);
551
552        Ok(ExecResult {
553            exit_code: exit_code as i32,
554            stdout: String::from_utf8_lossy(&stdout).into_owned(),
555            stderr: String::from_utf8_lossy(&stderr).into_owned(),
556        })
557    }
558
559    async fn logs(&self, handle: &dyn SandboxHandle) -> Result<String> {
560        let path = format!(
561            "/containers/{}/logs?stdout=1&stderr=1&tail=1000",
562            handle.id()
563        );
564        let (headers, mut stream) = self.client.request_stream("GET", &path, None).await?;
565        if headers.status >= 400 {
566            return Err(RightsizeError::Backend(format!(
567                "docker could not fetch logs for container {} (HTTP {})",
568                handle.id(),
569                headers.status
570            )));
571        }
572        let mut body = BodyReader::new(&mut stream, &headers);
573        let mut assembler = LineAssembler::new();
574        let mut out = String::new();
575        while let Some(frame) = read_frame(&mut body).await? {
576            if !matches!(frame.stream, StreamType::Stdout | StreamType::Stderr) {
577                continue;
578            }
579            let text = String::from_utf8_lossy(&frame.payload).into_owned();
580            for line in assembler.feed(&text) {
581                out.push_str(&line);
582                out.push('\n');
583            }
584        }
585        if let Some(tail) = assembler.flush() {
586            out.push_str(&tail);
587            out.push('\n');
588        }
589        Ok(out)
590    }
591
592    async fn follow_logs(
593        &self,
594        handle: &dyn SandboxHandle,
595        consumer: Box<dyn Fn(String) + Send + Sync>,
596    ) -> Result<FollowHandle> {
597        let path = format!(
598            "/containers/{}/logs?stdout=1&stderr=1&follow=1&tail=all",
599            handle.id()
600        );
601        let (headers, mut stream) = self.client.request_stream("GET", &path, None).await?;
602        if headers.status >= 400 {
603            return Err(RightsizeError::Backend(format!(
604                "docker could not follow logs for container {} (HTTP {})",
605                handle.id(),
606                headers.status
607            )));
608        }
609
610        let close_requested = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
611        let task_close = close_requested.clone();
612        let task = tokio::spawn(async move {
613            let mut body = BodyReader::new(&mut stream, &headers);
614            let mut assembler = LineAssembler::new();
615            loop {
616                // Checked BEFORE each delivery (not just once per loop) — the
617                // resolved P1 forward flag: `abort()` alone can't guarantee no
618                // post-close callback, since it only cancels at the next `.await`
619                // point. Checking this flag right before every consumer call means
620                // the flag and the abort jointly rule that out — see
621                // `rightsize::backend::FollowHandle`'s doc for the full contract.
622                if task_close.load(std::sync::atomic::Ordering::SeqCst) {
623                    return;
624                }
625                let frame = match read_frame(&mut body).await {
626                    Ok(Some(f)) => f,
627                    Ok(None) => break, // clean stream end — the normal way this loop exits.
628                    Err(_) => break,   // best-effort: a stream error just ends delivery.
629                };
630                if !matches!(frame.stream, StreamType::Stdout | StreamType::Stderr) {
631                    continue;
632                }
633                let text = String::from_utf8_lossy(&frame.payload).into_owned();
634                for line in assembler.feed(&text) {
635                    if task_close.load(std::sync::atomic::Ordering::SeqCst) {
636                        return;
637                    }
638                    consumer(line);
639                }
640            }
641            if task_close.load(std::sync::atomic::Ordering::SeqCst) {
642                return; // stop delivery, never flush — an explicit close beat the stream to the end.
643            }
644            if let Some(tail) = assembler.flush() {
645                consumer(tail);
646            }
647        });
648
649        Ok(FollowHandle::from_task(close_requested, task))
650    }
651
652    async fn ensure_network(&self, network_id: &str) -> Result<()> {
653        self.ensure_network_get_id(network_id).await?;
654        Ok(())
655    }
656
657    async fn remove_network(&self, network_id: &str) -> Result<()> {
658        let cached = self
659            .network_ids
660            .lock()
661            .expect("network_ids mutex poisoned")
662            .remove(network_id);
663        // Cache miss: this backend instance never itself called `ensure_network` for
664        // `network_id` — the reaping sweep's whole point is removing resources a
665        // DIFFERENT (dead) process created, so `network_ids` (populated only by THIS
666        // instance's own `ensure_network_get_id` calls) is empty for it. Fall back to
667        // a live daemon lookup by name — the same "must not depend on in-process
668        // state" requirement `remove_by_name`'s container-level lookup already
669        // satisfies (`blocking_find_container_id_by_name`).
670        let daemon_id = match cached {
671            Some(id) => Some(id),
672            None => self.lookup_network_id_by_name(network_id).await,
673        };
674        if let Some(id) = daemon_id {
675            let path = format!("/networks/{id}");
676            let _ = self.client.request("DELETE", &path, None).await; // best-effort
677        }
678        Ok(())
679    }
680
681    async fn close(&self) -> Result<()> {
682        let run_id = rightsize::RunId::value();
683        let filters = LabelFilter {
684            label: vec![format!("{RUN_ID_LABEL_KEY}={run_id}")],
685        };
686        let filters =
687            serde_json::to_string(&filters).expect("LabelFilter has no non-serializable fields");
688        let path = format!(
689            "/containers/json?all=true&filters={}",
690            encode_query_value(&filters)
691        );
692        let listed = self.client.request("GET", &path, None).await?;
693        if listed.status != 200 {
694            return Ok(()); // best-effort: nothing more to do if even listing fails.
695        }
696        let entries: Vec<ListedEntry> = serde_json::from_slice(&listed.body).unwrap_or_default();
697        for entry in entries {
698            let remove_path = format!("/containers/{}?force=true", entry.id);
699            let _ = self.client.request("DELETE", &remove_path, None).await;
700        }
701        Ok(())
702    }
703
704    fn cleanup_sync(&self, container_id: &str) {
705        // Blocking std I/O only, no Tokio — this runs on the dedicated cleanup thread
706        // (see rightsize::cleanup), never in async context (decision 1).
707        let _ = blocking_force_remove(self.client.socket_path(), container_id, STOP_TIMEOUT_SECS);
708    }
709
710    fn remove_by_name(&self, name: &str) {
711        // The reaping ledger persists NAMES, but every other call on this backend is
712        // keyed by the daemon-assigned id already carried on a `Handle` — so this
713        // resolves name -> id first (a list-by-name-filter GET), then force-removes
714        // that id. SYNCHRONOUS, blocking std I/O only (see
715        // `SandboxBackend::remove_by_name`'s doc for why) — the same transport shape
716        // `cleanup_sync`/`blocking_force_remove` already use, extended with a body
717        // read since this call needs the list response, not just a status code.
718        let socket = self.client.socket_path();
719        let Some(id) = blocking_find_container_id_by_name(socket, name) else {
720            return; // not found (or the lookup itself failed): silently fine.
721        };
722        let _ = blocking_force_remove(socket, &id, STOP_TIMEOUT_SECS);
723    }
724
725    /// `GET /containers/json?filters={"name":["^/<name>$"],"status":["running"]}` —
726    /// the reuse adopt path's own query (`rightsize::reuse`). The `name` filter is
727    /// anchored the same way `blocking_find_container_id_by_name` anchors it
728    /// (Docker's `name` filter is substring-by-default); `status` is explicit
729    /// rather than relying on the daemon's own "no `all=true`" default meaning
730    /// running-only, so the intent reads directly off the request. `Ok(None)` for
731    /// no match OR a non-200 response — matching this backend's other best-effort
732    /// list-then-not-found reads (e.g. `lookup_network_id_by_name`).
733    async fn find_running(&self, spec: &ContainerSpec) -> Result<Option<Box<dyn SandboxHandle>>> {
734        let filters = format!(r#"{{"name":["^/{}$"],"status":["running"]}}"#, spec.name);
735        let path = format!("/containers/json?filters={}", encode_query_value(&filters));
736        let resp = self.client.request("GET", &path, None).await?;
737        if resp.status != 200 {
738            return Ok(None);
739        }
740        let entries: Vec<ListedEntry> = serde_json::from_slice(&resp.body).unwrap_or_default();
741        Ok(entries.into_iter().next().map(|entry| {
742            Box::new(Handle {
743                id: entry.id,
744                spec: spec.clone(),
745            }) as Box<dyn SandboxHandle>
746        }))
747    }
748
749    /// `POST /commit?container=&repo=&tag=` — the checkpoint feature's own backend
750    /// primitive (`rightsize::ContainerGuard::checkpoint`). Formats `nonce` (a
751    /// random 12-lowercase-hex string the generic layer generates fresh per call)
752    /// into this backend's own ref shape, `rightsize/checkpoint:<nonce>`, splitting
753    /// it into the `repo`/`tag` pair the endpoint wants (the same split
754    /// `split_repo_tag` already does for image pulls), and returns the ref.
755    async fn create_checkpoint(&self, handle: &dyn SandboxHandle, nonce: &str) -> Result<String> {
756        let checkpoint_ref = format!("rightsize/checkpoint:{nonce}");
757        let (repo, tag) = split_repo_tag(&checkpoint_ref);
758        let path = format!(
759            "/commit?container={}&repo={}&tag={}",
760            encode_query_value(handle.id()),
761            encode_query_value(&repo),
762            encode_query_value(&tag)
763        );
764        let resp = self.client.request("POST", &path, None).await?;
765        if resp.status >= 400 {
766            return Err(RightsizeError::Backend(format!(
767                "docker could not commit container {} to image '{checkpoint_ref}' (HTTP {}): {}",
768                handle.id(),
769                resp.status,
770                String::from_utf8_lossy(&resp.body)
771            )));
772        }
773        Ok(checkpoint_ref)
774    }
775
776    /// `DELETE /images/{ref}?force=true` — best-effort; a 404 (already gone) is
777    /// success, matching [`Self::remove_by_name`]'s own "not found is fine"
778    /// contract.
779    async fn remove_checkpoint(&self, checkpoint_ref: &str) -> Result<()> {
780        let path = format!("/images/{}?force=true", encode_path_segment(checkpoint_ref));
781        let resp = self.client.request("DELETE", &path, None).await?;
782        if resp.status >= 400 && resp.status != 404 {
783            return Err(RightsizeError::Backend(format!(
784                "docker could not remove checkpoint image '{checkpoint_ref}' (HTTP {}): {}",
785                resp.status,
786                String::from_utf8_lossy(&resp.body)
787            )));
788        }
789        Ok(())
790    }
791
792    /// `GET /images/{ref}/json` — the named-checkpoint existence probe
793    /// (`SandboxBackend::has_checkpoint`, `Checkpoint::find`'s staleness check).
794    /// A 200 means the tagged image still exists; a 404 is a definite "not
795    /// there." Any OTHER status (or a transport-level request failure, via the
796    /// `?` above) surfaces as an error — this SPI forbids a probe failure from
797    /// resolving to "absent," unlike [`Self::pull_if_missing`]'s own inspect,
798    /// which only reacts to affirmatively-404 and doesn't fail loudly on
799    /// inspect trouble.
800    async fn has_checkpoint(&self, checkpoint_ref: &str) -> Result<bool> {
801        let path = format!("/images/{}/json", encode_path_segment(checkpoint_ref));
802        let resp = self.client.request("GET", &path, None).await?;
803        match resp.status {
804            200 => Ok(true),
805            404 => Ok(false),
806            status => Err(RightsizeError::Backend(format!(
807                "docker could not inspect checkpoint image '{checkpoint_ref}' (HTTP {status}): {}",
808                String::from_utf8_lossy(&resp.body)
809            ))),
810        }
811    }
812
813    /// Shells out to the `docker` CLI (`docker cp <host_path> <id>:<container_path>`)
814    /// rather than the daemon HTTP API this backend otherwise speaks exclusively —
815    /// the daemon's copy endpoints are tar-archive-in/tar-archive-out, and adding
816    /// tar encode/decode here would mean either a new third-party dependency or a
817    /// hand-rolled tar implementation, both worse than shelling out. The `docker`
818    /// CLI is already a hard requirement for this backend regardless (the reaping
819    /// watchdog's own kill command is a plain `docker rm -f`), so this adds no new
820    /// external dependency.
821    async fn copy_to_container(
822        &self,
823        handle: &dyn SandboxHandle,
824        host_path: &Path,
825        container_path: &str,
826    ) -> Result<()> {
827        let args = docker_cp_in_args(handle.id(), host_path, container_path);
828        let result = run_docker_cli(&args).await?;
829        if result.exit_code != 0 {
830            return Err(RightsizeError::Backend(format!(
831                "docker cp into container {} failed (exit {}): {}",
832                handle.id(),
833                result.exit_code,
834                result.stderr.trim()
835            )));
836        }
837        Ok(())
838    }
839
840    /// The reverse direction of [`Self::copy_to_container`], same `docker cp`
841    /// shape and error-surfacing.
842    async fn copy_from_container(
843        &self,
844        handle: &dyn SandboxHandle,
845        container_path: &str,
846        host_path: &Path,
847    ) -> Result<()> {
848        let args = docker_cp_out_args(handle.id(), container_path, host_path);
849        let result = run_docker_cli(&args).await?;
850        if result.exit_code != 0 {
851            return Err(RightsizeError::Backend(format!(
852                "docker cp out of container {} failed (exit {}): {}",
853                handle.id(),
854                result.exit_code,
855                result.stderr.trim()
856            )));
857        }
858        Ok(())
859    }
860
861    fn watchdog_kill_command(&self) -> Vec<String> {
862        vec!["docker".to_string(), "rm".to_string(), "-f".to_string()]
863    }
864
865    fn watchdog_network_kill_command(&self) -> Vec<String> {
866        vec![
867            "docker".to_string(),
868            "network".to_string(),
869            "rm".to_string(),
870        ]
871    }
872}
873
874/// Builds the argv for `docker cp <host_path> <container_id>:<container_path>` —
875/// pure argv construction, kept separate from [`run_docker_cli`] so the shape is a
876/// plain data-in/data-out unit test, independent of a real `docker` binary (the
877/// same split `rightsize-msb`'s own `commands` module uses for its `msb` argv).
878fn docker_cp_in_args(container_id: &str, host_path: &Path, container_path: &str) -> Vec<String> {
879    vec![
880        "cp".to_string(),
881        host_path.display().to_string(),
882        format!("{container_id}:{container_path}"),
883    ]
884}
885
886/// Builds the argv for `docker cp <container_id>:<container_path> <host_path>` —
887/// the reverse direction of [`docker_cp_in_args`].
888fn docker_cp_out_args(container_id: &str, container_path: &str, host_path: &Path) -> Vec<String> {
889    vec![
890        "cp".to_string(),
891        format!("{container_id}:{container_path}"),
892        host_path.display().to_string(),
893    ]
894}
895
896/// Runs the `docker` CLI (never the daemon HTTP API this backend otherwise speaks
897/// exclusively — see [`DockerBackend::copy_to_container`]'s doc for why copy is the
898/// one exception) with a closed/null stdin, matching every other child process this
899/// workspace spawns under the hood (see `rightsize-msb`'s own convention), and
900/// captures its exit code plus stdout/stderr.
901async fn run_docker_cli(args: &[String]) -> Result<ExecResult> {
902    let output = tokio::process::Command::new("docker")
903        .args(args)
904        .stdin(std::process::Stdio::null())
905        .output()
906        .await
907        .map_err(|e| {
908            RightsizeError::Backend(format!("failed to spawn docker {}: {e}", args.join(" ")))
909        })?;
910    Ok(ExecResult {
911        exit_code: output.status.code().unwrap_or(-1),
912        stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
913        stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
914    })
915}
916
917/// The `cleanup_sync` path's blocking counterpart to `stop`+`remove`: issues a plain
918/// `POST /containers/{id}/stop?t=` then `DELETE /containers/{id}?force=true` over a
919/// blocking `std::os::unix::net::UnixStream` — no Tokio, since this runs on the
920/// dedicated `Drop`-path cleanup thread with no async runtime in context. Best-effort:
921/// errors are swallowed by the caller, matching this backend's own async `stop`/
922/// `remove`.
923fn blocking_force_remove(
924    socket_path: &std::path::Path,
925    container_id: &str,
926    stop_timeout_secs: u64,
927) -> std::io::Result<()> {
928    let _ = blocking_request(
929        socket_path,
930        "POST",
931        &format!("/containers/{container_id}/stop?t={stop_timeout_secs}"),
932    );
933    blocking_request(
934        socket_path,
935        "DELETE",
936        &format!("/containers/{container_id}?force=true"),
937    )
938    .map(|_| ())
939}
940
941/// Resolves `name` to a daemon-assigned container id via a blocking
942/// `GET /containers/json?all=true&filters={"name":["^/<name>$"]}` — the `^/...$`
943/// anchoring matches Docker's own convention for an exact-name filter (Docker's
944/// `name` filter is substring-by-default; anchoring is what makes it exact). Returns
945/// `None` on any failure (lookup error, no match, unparseable body) — the caller
946/// treats that as "nothing to remove", matching `remove_by_name`'s best-effort,
947/// not-found-is-fine contract.
948fn blocking_find_container_id_by_name(socket_path: &std::path::Path, name: &str) -> Option<String> {
949    let filters = format!(r#"{{"name":["^/{name}$"]}}"#);
950    let path = format!(
951        "/containers/json?all=true&filters={}",
952        encode_query_value(&filters)
953    );
954    let body = blocking_get_body(socket_path, &path).ok()?;
955    let entries: Vec<ListedEntry> = serde_json::from_slice(&body).ok()?;
956    entries.into_iter().next().map(|e| e.id)
957}
958
959/// A minimal blocking HTTP GET over a blocking unix socket, returning the response
960/// body — the read-the-body counterpart to [`blocking_request`] (which discards it),
961/// needed by [`blocking_find_container_id_by_name`]. Honors `Content-Length` when
962/// present, dechunks a `Transfer-Encoding: chunked` response (`GET
963/// /containers/json` — this call's only caller — comes back chunked on every
964/// dockerd/Docker Desktop build observed; empirically NOT the "Content-Length in
965/// practice" case an earlier version of this function assumed, which made
966/// `blocking_find_container_id_by_name` silently find nothing on every real
967/// daemon), and otherwise falls back to reading until the peer closes (this client
968/// always sends `Connection: close`, matching [`blocking_request`]'s own
969/// assumption).
970fn blocking_get_body(socket_path: &std::path::Path, path: &str) -> std::io::Result<Vec<u8>> {
971    use std::io::{BufRead, BufReader, Read, Write};
972    use std::os::unix::net::UnixStream;
973
974    let mut stream = UnixStream::connect(socket_path)?;
975    stream.set_read_timeout(Some(Duration::from_secs(stop_timeout_budget())))?;
976    let request = format!("GET {path} HTTP/1.1\r\nHost: docker\r\nConnection: close\r\n\r\n");
977    stream.write_all(request.as_bytes())?;
978
979    let mut reader = BufReader::new(stream);
980    let mut status_line = String::new();
981    reader.read_line(&mut status_line)?;
982
983    let mut content_length: Option<usize> = None;
984    let mut chunked = false;
985    loop {
986        let mut line = String::new();
987        let n = reader.read_line(&mut line)?;
988        if n == 0 || line == "\r\n" || line == "\n" {
989            break;
990        }
991        if let Some((name, value)) = line.split_once(':') {
992            let name = name.trim();
993            let value = value.trim();
994            if name.eq_ignore_ascii_case("content-length") {
995                content_length = value.parse::<usize>().ok();
996            } else if name.eq_ignore_ascii_case("transfer-encoding") {
997                chunked = value.to_ascii_lowercase().contains("chunked");
998            }
999        }
1000    }
1001
1002    if chunked {
1003        return blocking_read_chunked_body(&mut reader);
1004    }
1005
1006    let mut body = Vec::new();
1007    match content_length {
1008        Some(len) => {
1009            body.resize(len, 0);
1010            reader.read_exact(&mut body)?;
1011        }
1012        None => {
1013            reader.read_to_end(&mut body)?;
1014        }
1015    }
1016    Ok(body)
1017}
1018
1019/// Dechunks an HTTP `Transfer-Encoding: chunked` body from `reader` — the blocking
1020/// counterpart to `client::read_chunked_body`, needed because [`blocking_get_body`]
1021/// runs on a plain OS thread with no Tokio runtime available. Same framing: a hex
1022/// chunk-size line, that many bytes, a trailing `\r\n`, repeated until a zero-size
1023/// chunk (optionally followed by trailer headers, drained and discarded) ends the
1024/// body.
1025fn blocking_read_chunked_body(
1026    reader: &mut std::io::BufReader<std::os::unix::net::UnixStream>,
1027) -> std::io::Result<Vec<u8>> {
1028    use std::io::{BufRead, Read};
1029
1030    let mut out = Vec::new();
1031    loop {
1032        let mut size_line = String::new();
1033        reader.read_line(&mut size_line)?;
1034        let size_str = size_line.trim().split(';').next().unwrap_or("").trim();
1035        let size = usize::from_str_radix(size_str, 16).map_err(|_| {
1036            std::io::Error::new(
1037                std::io::ErrorKind::InvalidData,
1038                format!(
1039                    "could not parse a chunk size from the Docker daemon's response: \
1040                     {size_line:?}"
1041                ),
1042            )
1043        })?;
1044        if size == 0 {
1045            // Drain trailer headers up to the final blank line, then stop.
1046            loop {
1047                let mut trailer = String::new();
1048                let n = reader.read_line(&mut trailer)?;
1049                if n == 0 || trailer == "\r\n" || trailer == "\n" {
1050                    break;
1051                }
1052            }
1053            break;
1054        }
1055        let mut chunk = vec![0u8; size];
1056        reader.read_exact(&mut chunk)?;
1057        out.extend_from_slice(&chunk);
1058        // Each chunk is followed by a trailing \r\n that isn't part of the payload.
1059        let mut crlf = [0u8; 2];
1060        reader.read_exact(&mut crlf)?;
1061    }
1062    Ok(out)
1063}
1064
1065/// A minimal blocking HTTP/1.1 request over a blocking unix socket — the Drop-path
1066/// analogue of [`DockerClient::request`], deliberately NOT reusing any of that async
1067/// client's code (which is all `tokio::net::UnixStream`-shaped): this runs on a plain
1068/// OS thread with no Tokio runtime available, so it needs its own blocking transport.
1069/// Doesn't need to handle chunked responses — the
1070/// only calls made here (`stop`, `force=true remove`) return small `Content-Length`
1071/// (or empty) bodies in practice — so this reads until the peer closes and returns
1072/// whatever arrived, without trying to interpret framing at all.
1073fn blocking_request(
1074    socket_path: &std::path::Path,
1075    method: &str,
1076    path: &str,
1077) -> std::io::Result<()> {
1078    use std::io::{Read, Write};
1079    use std::os::unix::net::UnixStream;
1080
1081    let mut stream = UnixStream::connect(socket_path)?;
1082    stream.set_read_timeout(Some(Duration::from_secs(stop_timeout_budget())))?;
1083    let request = format!("{method} {path} HTTP/1.1\r\nHost: docker\r\nConnection: close\r\n\r\n");
1084    stream.write_all(request.as_bytes())?;
1085    let mut discard = Vec::new();
1086    let _ = stream.read_to_end(&mut discard);
1087    Ok(())
1088}
1089
1090/// A generous fixed budget for the blocking cleanup-path request's read timeout —
1091/// this path has no caller to propagate a timeout error to (it's best-effort, fired
1092/// from `Drop`), so it just needs to not hang the cleanup thread forever if the
1093/// daemon never responds.
1094fn stop_timeout_budget() -> u64 {
1095    STOP_TIMEOUT_SECS + 20
1096}
1097
1098#[cfg(test)]
1099mod tests {
1100    use super::*;
1101
1102    #[test]
1103    fn capabilities_report_no_hardware_isolation_but_checkpoint_support() {
1104        let backend = DockerBackend::connecting_to_env();
1105        let caps = backend.capabilities();
1106        assert!(!caps.hardware_isolated, "containers share the host kernel");
1107        assert!(caps.checkpoint);
1108        assert!(
1109            !caps.checkpoint_restarts_workload,
1110            "an image commit leaves the running container undisturbed"
1111        );
1112    }
1113
1114    /// A minimal, dependency-free temp-directory helper for the blocking-socket
1115    /// tests below — see the async test module's own `tempdir_shim` for why this
1116    /// crate hand-rolls one per test module rather than sharing it.
1117    mod blocking_tempdir_shim {
1118        use std::path::{Path, PathBuf};
1119
1120        pub(super) struct TempDir(PathBuf);
1121
1122        impl TempDir {
1123            pub(super) fn new() -> Self {
1124                static COUNTER: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0);
1125                let seq = COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1126                let unique = format!("rzdbb-{:x}-{:x}", std::process::id() as u16, seq);
1127                let path = Path::new("/tmp").join(unique);
1128                std::fs::create_dir_all(&path).expect("create temp dir");
1129                TempDir(path)
1130            }
1131
1132            pub(super) fn path(&self) -> &Path {
1133                &self.0
1134            }
1135        }
1136
1137        impl Drop for TempDir {
1138            fn drop(&mut self) {
1139                let _ = std::fs::remove_dir_all(&self.0);
1140            }
1141        }
1142    }
1143
1144    /// `blocking_get_body` must dechunk a `Transfer-Encoding: chunked` response —
1145    /// this is the real shape `GET /containers/json` comes back in on every
1146    /// dockerd/Docker Desktop build observed, not the `Content-Length` shape an
1147    /// earlier version of this function assumed, which made
1148    /// `blocking_find_container_id_by_name` (and therefore `remove_by_name`) find
1149    /// nothing on every real daemon.
1150    #[test]
1151    fn blocking_get_body_dechunks_a_transfer_encoding_chunked_response() {
1152        use std::io::{Read, Write};
1153        use std::os::unix::net::UnixListener;
1154
1155        let dir = blocking_tempdir_shim::TempDir::new();
1156        let sock_path = dir.path().join("docker.sock");
1157        let listener = UnixListener::bind(&sock_path).expect("bind fixture socket");
1158
1159        let server = std::thread::spawn(move || {
1160            let (mut stream, _) = listener.accept().expect("accept fixture connection");
1161            let mut buf = [0u8; 4096];
1162            let _ = stream.read(&mut buf); // drain the request line/headers.
1163            stream
1164                .write_all(
1165                    b"HTTP/1.1 200 OK\r\n\
1166                      Content-Type: application/json\r\n\
1167                      Transfer-Encoding: chunked\r\n\
1168                      \r\n\
1169                      5\r\nhello\r\n\
1170                      6\r\n world\r\n\
1171                      0\r\n\r\n",
1172                )
1173                .expect("write fixture response");
1174        });
1175
1176        let body =
1177            blocking_get_body(&sock_path, "/containers/json").expect("dechunking must succeed");
1178        assert_eq!(body, b"hello world");
1179        server.join().expect("fixture server thread must not panic");
1180    }
1181
1182    /// The `Content-Length`-framed shape must keep working too — the dechunking
1183    /// addition must be additive, not a regression on the framing that already
1184    /// worked.
1185    #[test]
1186    fn blocking_get_body_honors_content_length_when_present() {
1187        use std::io::{Read, Write};
1188        use std::os::unix::net::UnixListener;
1189
1190        let dir = blocking_tempdir_shim::TempDir::new();
1191        let sock_path = dir.path().join("docker.sock");
1192        let listener = UnixListener::bind(&sock_path).expect("bind fixture socket");
1193
1194        let server = std::thread::spawn(move || {
1195            let (mut stream, _) = listener.accept().expect("accept fixture connection");
1196            let mut buf = [0u8; 4096];
1197            let _ = stream.read(&mut buf);
1198            stream
1199                .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 13\r\n\r\nhello world!!")
1200                .expect("write fixture response");
1201        });
1202
1203        let body = blocking_get_body(&sock_path, "/containers/json")
1204            .expect("Content-Length read must succeed");
1205        assert_eq!(body, b"hello world!!");
1206        server.join().expect("fixture server thread must not panic");
1207    }
1208
1209    #[test]
1210    fn is_port_bind_conflict_message_matches_known_phrasings() {
1211        assert!(is_port_bind_conflict_message(
1212            "driver failed programming external connectivity: address already in use"
1213        ));
1214        assert!(is_port_bind_conflict_message(
1215            "Bind for 0.0.0.0:6379 failed: port is already allocated"
1216        ));
1217        assert!(is_port_bind_conflict_message(
1218            "ALREADY ALLOCATED (case-insensitive)"
1219        ));
1220    }
1221
1222    #[test]
1223    fn is_port_bind_conflict_message_negative_cases_do_not_match() {
1224        assert!(!is_port_bind_conflict_message("no such image"));
1225        assert!(!is_port_bind_conflict_message("container already stopped"));
1226        assert!(!is_port_bind_conflict_message(""));
1227    }
1228
1229    #[test]
1230    fn split_repo_tag_defaults_to_latest_when_untagged() {
1231        assert_eq!(
1232            split_repo_tag("redis"),
1233            ("redis".to_string(), "latest".to_string())
1234        );
1235        assert_eq!(
1236            split_repo_tag("redis:8.6-alpine"),
1237            ("redis".to_string(), "8.6-alpine".to_string())
1238        );
1239    }
1240
1241    #[test]
1242    fn split_repo_tag_handles_a_registry_host_with_a_port() {
1243        assert_eq!(
1244            split_repo_tag("localhost:5000/redis"),
1245            ("localhost:5000/redis".to_string(), "latest".to_string())
1246        );
1247        assert_eq!(
1248            split_repo_tag("localhost:5000/redis:8.6-alpine"),
1249            ("localhost:5000/redis".to_string(), "8.6-alpine".to_string())
1250        );
1251    }
1252
1253    #[test]
1254    fn split_repo_tag_passes_through_a_digest_reference_with_no_tag() {
1255        assert_eq!(
1256            split_repo_tag("redis@sha256:abcdef"),
1257            ("redis@sha256:abcdef".to_string(), String::new())
1258        );
1259    }
1260
1261    /// `build_create_body` returns a struct now (see `crate::json::CreateContainerBody`);
1262    /// these tests still assert on the actual bytes sent over the wire, serializing it
1263    /// exactly like `create()` does, so a field-rename typo or a `skip_serializing_if`
1264    /// slip is still caught here rather than only in `crate::json`'s own struct-shape
1265    /// tests.
1266    fn serialize(spec: &ContainerSpec) -> String {
1267        serde_json::to_string(&build_create_body(spec)).unwrap()
1268    }
1269
1270    #[test]
1271    fn build_create_body_includes_port_bindings_on_loopback() {
1272        let mut spec = ContainerSpec::new("rz-x-0", "redis:8.6-alpine", "deadbeef");
1273        spec.ports.push(rightsize::model::PortBinding {
1274            host_port: 32768,
1275            guest_port: 6379,
1276        });
1277        let body = serialize(&spec);
1278        assert!(body.contains("\"HostIp\":\"127.0.0.1\""));
1279        assert!(body.contains("\"HostPort\":\"32768\""));
1280        assert!(body.contains("\"6379/tcp\""));
1281    }
1282
1283    #[test]
1284    fn build_create_body_includes_the_run_id_label_and_extra_host() {
1285        let spec = ContainerSpec::new("rz-x-0", "redis:8.6-alpine", "deadbeef");
1286        let body = serialize(&spec);
1287        assert!(body.contains("\"dev.rightsize.run_id\":\"deadbeef\""));
1288        assert!(body.contains("host.docker.internal:host-gateway"));
1289    }
1290
1291    #[test]
1292    fn build_create_body_honors_mount_read_only_flag() {
1293        let mut spec = ContainerSpec::new("rz-x-0", "redis:8.6-alpine", "deadbeef");
1294        spec.mounts
1295            .push(rightsize::model::FileMount::new("/host/a", "/guest/a"));
1296        spec.mounts
1297            .push(rightsize::model::FileMount::new("/host/b", "/guest/b").read_write());
1298        let body = serialize(&spec);
1299        assert!(body.contains("/host/a:/guest/a:ro"));
1300        assert!(body.contains("/host/b:/guest/b:rw"));
1301    }
1302
1303    #[test]
1304    fn build_create_body_omits_memory_when_unset_and_includes_it_when_set() {
1305        let spec = ContainerSpec::new("rz-x-0", "redis:8.6-alpine", "deadbeef");
1306        assert!(!serialize(&spec).contains("Memory"));
1307
1308        let mut spec = ContainerSpec::new("rz-x-0", "redis:8.6-alpine", "deadbeef");
1309        spec.memory_limit_mb = Some(512);
1310        assert!(serialize(&spec).contains("\"Memory\":536870912"));
1311    }
1312
1313    #[test]
1314    fn build_create_body_omits_cmd_when_command_is_none_and_includes_it_when_some() {
1315        let spec = ContainerSpec::new("rz-x-0", "redis:8.6-alpine", "deadbeef");
1316        assert!(!serialize(&spec).contains("\"Cmd\""));
1317
1318        let mut spec = ContainerSpec::new("rz-x-0", "redis:8.6-alpine", "deadbeef");
1319        spec.command = Some(vec![
1320            "redis-server".to_string(),
1321            "--port".to_string(),
1322            "6379".to_string(),
1323        ]);
1324        let body = serialize(&spec);
1325        assert!(body.contains("\"Cmd\":[\"redis-server\",\"--port\",\"6379\"]"));
1326    }
1327
1328    #[test]
1329    fn docker_backend_dials_a_unix_socket_never_a_tcp_host() {
1330        // Standing regression guard: this backend's transport must always be
1331        // a unix socket path, never a `host:port` TCP endpoint — the exact failure
1332        // mode a shared/bumped HTTP stack can cause if a dependency update
1333        // silently changes the default transport.
1334        let backend = DockerBackend::new(DockerClient::from_docker_host(None));
1335        let path = backend.socket_path_for_test();
1336        assert!(
1337            path.is_absolute(),
1338            "expected an absolute unix socket path, got {path:?}"
1339        );
1340        let path_str = path.to_string_lossy();
1341        assert!(
1342            !path_str.contains(':'),
1343            "a unix socket path must not look like a host:port TCP address — got {path_str}"
1344        );
1345
1346        let backend_env =
1347            DockerBackend::new(DockerClient::from_docker_host(Some("tcp://127.0.0.1:2375")));
1348        let fallback_path = backend_env.socket_path_for_test();
1349        assert!(
1350            !fallback_path.to_string_lossy().contains("2375"),
1351            "a tcp:// DOCKER_HOST must not leak a TCP port into this backend's transport — \
1352             got {fallback_path:?}"
1353        );
1354    }
1355
1356    #[test]
1357    fn keep_alive_spec_gets_the_reuse_label_instead_of_the_run_id_label() {
1358        let mut spec = ContainerSpec::new("rz-x-0", "redis:8.6-alpine", "deadbeef");
1359        spec.keep_alive = true;
1360        let body = serialize(&spec);
1361        assert!(body.contains("\"dev.rightsize.reuse\""), "{body}");
1362        assert!(!body.contains("\"dev.rightsize.run_id\""), "{body}");
1363    }
1364
1365    #[test]
1366    fn non_keep_alive_spec_still_gets_the_run_id_label_only() {
1367        let spec = ContainerSpec::new("rz-x-0", "redis:8.6-alpine", "deadbeef");
1368        let body = serialize(&spec);
1369        assert!(
1370            body.contains("\"dev.rightsize.run_id\":\"deadbeef\""),
1371            "{body}"
1372        );
1373        assert!(!body.contains("\"dev.rightsize.reuse\""), "{body}");
1374    }
1375
1376    #[test]
1377    fn watchdog_kill_command_is_a_plain_docker_rm_dash_f() {
1378        let backend = DockerBackend::new(DockerClient::from_docker_host(None));
1379        assert_eq!(
1380            backend.watchdog_kill_command(),
1381            vec!["docker".to_string(), "rm".to_string(), "-f".to_string()]
1382        );
1383    }
1384
1385    #[test]
1386    fn watchdog_network_kill_command_is_docker_network_rm() {
1387        let backend = DockerBackend::new(DockerClient::from_docker_host(None));
1388        assert_eq!(
1389            backend.watchdog_network_kill_command(),
1390            vec![
1391                "docker".to_string(),
1392                "network".to_string(),
1393                "rm".to_string()
1394            ]
1395        );
1396    }
1397
1398    #[test]
1399    fn reuse_label_value_extracts_the_hash_straight_from_an_rz_reuse_name() {
1400        let spec = ContainerSpec::new("rz-reuse-799aad5a3338", "redis:7-alpine", "deadbeef");
1401        assert_eq!(reuse_label_value(&spec), "799aad5a3338");
1402    }
1403
1404    #[test]
1405    fn reuse_label_value_is_twelve_hex_chars_and_deterministic() {
1406        let spec_a = ContainerSpec::new("rz-x-0", "redis:8.6-alpine", "deadbeef");
1407        let spec_b = ContainerSpec::new("rz-x-0", "redis:8.6-alpine", "deadbeef");
1408        let spec_c = ContainerSpec::new("rz-x-1", "redis:8.6-alpine", "deadbeef");
1409        let a = reuse_label_value(&spec_a);
1410        let b = reuse_label_value(&spec_b);
1411        let c = reuse_label_value(&spec_c);
1412        assert_eq!(a.len(), 12);
1413        assert!(a.chars().all(|ch| ch.is_ascii_hexdigit()));
1414        assert_eq!(a, b, "same spec.name must hash the same every time");
1415        assert_ne!(a, c, "a different spec.name must hash differently");
1416    }
1417
1418    // --- remove_network's cache-miss fallback -------------------------------------
1419    //
1420    // The reaping sweep's whole point is removing resources a DIFFERENT (dead)
1421    // process created — a network this backend INSTANCE never itself called
1422    // `ensure_network` for, so `network_ids` (populated only by THIS instance's own
1423    // `ensure_network_get_id` calls) has nothing cached for it. These tests drive
1424    // `DockerBackend` against a real (fixture) unix socket rather than a fake
1425    // `SandboxBackend`, since the bug lives in the daemon-call sequence itself, not
1426    // in anything a fake could stand in for.
1427
1428    use std::sync::Arc;
1429    use tokio::net::UnixListener;
1430
1431    /// A minimal, dependency-free temp-directory helper — duplicated from
1432    /// `crate::client`'s own test-only `tempdir_shim` rather than shared, since that
1433    /// one is nested inside a private `#[cfg(test)] mod tests` in a different file
1434    /// and this crate's own convention is small hand-rolled scaffolding per test
1435    /// module over an extra dependency or cross-module test plumbing.
1436    mod tempdir_shim {
1437        use std::path::{Path, PathBuf};
1438
1439        pub(super) struct TempDir(PathBuf);
1440
1441        impl TempDir {
1442            /// Rooted at `/tmp`, not `std::env::temp_dir()` — see
1443            /// `crate::client`'s `tempdir_shim` for why (`AF_UNIX`'s `SUN_LEN` cap).
1444            pub(super) fn new() -> Self {
1445                static COUNTER: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0);
1446                let seq = COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1447                let unique = format!("rzdb-{:x}-{:x}", std::process::id() as u16, seq);
1448                let path = Path::new("/tmp").join(unique);
1449                std::fs::create_dir_all(&path).expect("create temp dir");
1450                TempDir(path)
1451            }
1452
1453            pub(super) fn path(&self) -> &Path {
1454                &self.0
1455            }
1456        }
1457
1458        impl Drop for TempDir {
1459            fn drop(&mut self) {
1460                let _ = std::fs::remove_dir_all(&self.0);
1461            }
1462        }
1463    }
1464
1465    /// Spawns a fixture unix-socket "server" that accepts connections IN SEQUENCE —
1466    /// one per entry in `responses` — replying with the given canned bytes and
1467    /// recording each request's status line, so a test can drive (and assert on) a
1468    /// `DockerBackend` method that makes more than one sequential HTTP call. Unlike
1469    /// `crate::client`'s own single-request fixture, this one is needed because
1470    /// `remove_network`'s cache-miss fallback issues a `GET` lookup THEN a `DELETE`
1471    /// — two separate connections, since this client never keeps one alive across
1472    /// calls (see `crate::client`'s module doc).
1473    async fn multi_request_fixture(
1474        responses: Vec<Vec<u8>>,
1475    ) -> (DockerClient, tempdir_shim::TempDir, Arc<Mutex<Vec<String>>>) {
1476        let dir = tempdir_shim::TempDir::new();
1477        let sock_path = dir.path().join("docker.sock");
1478        let listener = UnixListener::bind(&sock_path).expect("bind fixture socket");
1479        let received: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
1480        let received_clone = received.clone();
1481        tokio::spawn(async move {
1482            for response in responses {
1483                if let Ok((mut conn, _)) = listener.accept().await {
1484                    use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
1485                    let mut buf = vec![0u8; 4096];
1486                    let n = tokio::time::timeout(
1487                        std::time::Duration::from_millis(200),
1488                        conn.read(&mut buf),
1489                    )
1490                    .await
1491                    .ok()
1492                    .and_then(|r| r.ok())
1493                    .unwrap_or(0);
1494                    let request_line = String::from_utf8_lossy(&buf[..n])
1495                        .lines()
1496                        .next()
1497                        .unwrap_or_default()
1498                        .to_string();
1499                    received_clone.lock().unwrap().push(request_line);
1500                    let _ = conn.write_all(&response).await;
1501                    let _ = conn.shutdown().await;
1502                }
1503            }
1504        });
1505        (DockerClient::at_socket(sock_path), dir, received)
1506    }
1507
1508    /// A canned `200 OK` response listing exactly one network whose daemon id is
1509    /// `id` — the shape `GET /networks?filters=...` returns.
1510    fn network_list_response(id: &str) -> Vec<u8> {
1511        let payload = format!(r#"[{{"Id":"{id}"}}]"#);
1512        format!(
1513            "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
1514            payload.len(),
1515            payload
1516        )
1517        .into_bytes()
1518    }
1519
1520    /// A canned `200 OK` response listing exactly one container whose daemon id is
1521    /// `id` — the shape `GET /containers/json?filters=...` returns (same `[{"Id":
1522    /// "..."}]` shape as [`network_list_response`], reused as-is by
1523    /// [`ListedEntry`], but named separately since it stands for a different
1524    /// daemon resource at each call site).
1525    fn container_list_response(id: &str) -> Vec<u8> {
1526        network_list_response(id)
1527    }
1528
1529    /// A canned `200 OK` empty-array response — "nothing matched this filter."
1530    fn empty_list_response() -> Vec<u8> {
1531        let payload = "[]";
1532        format!(
1533            "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
1534            payload.len(),
1535            payload
1536        )
1537        .into_bytes()
1538    }
1539
1540    #[tokio::test]
1541    async fn find_running_returns_a_handle_when_the_daemon_lists_a_running_match() {
1542        let (client, _dir, received) =
1543            multi_request_fixture(vec![container_list_response("daemon-c-1")]).await;
1544        let backend = DockerBackend::new(client);
1545        let spec = ContainerSpec::new("rz-reuse-799aad5a3338", "redis:7-alpine", "deadbeef");
1546
1547        let found = backend
1548            .find_running(&spec)
1549            .await
1550            .unwrap()
1551            .expect("must find the running container");
1552        assert_eq!(found.id(), "daemon-c-1");
1553
1554        let requests = received.lock().unwrap().clone();
1555        assert_eq!(requests.len(), 1);
1556        assert!(
1557            requests[0].starts_with("GET /containers/json?filters="),
1558            "{requests:?}"
1559        );
1560    }
1561
1562    #[tokio::test]
1563    async fn find_running_returns_none_when_nothing_matches() {
1564        let (client, _dir, _received) = multi_request_fixture(vec![empty_list_response()]).await;
1565        let backend = DockerBackend::new(client);
1566        let spec = ContainerSpec::new("rz-reuse-799aad5a3338", "redis:7-alpine", "deadbeef");
1567
1568        assert!(backend.find_running(&spec).await.unwrap().is_none());
1569    }
1570
1571    #[tokio::test]
1572    async fn create_maps_a_409_conflict_to_the_typed_name_conflict_error() {
1573        // Two canned responses: `create()` first checks the image is present
1574        // (`pull_if_missing`'s `GET /images/{name}/json`, answered `200 OK` so it
1575        // never attempts a pull), THEN issues the actual `POST /containers/create`
1576        // that this test cares about, answered with the 409 conflict.
1577        let (client, _dir, _received) = multi_request_fixture(vec![
1578            b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\n{}".to_vec(),
1579            b"HTTP/1.1 409 Conflict\r\nContent-Length: 44\r\n\r\n{\"message\":\"Conflict. Name already in use.\"}".to_vec(),
1580        ])
1581        .await;
1582        let backend = DockerBackend::new(client);
1583        let spec = ContainerSpec::new("rz-reuse-799aad5a3338", "redis:7-alpine", "deadbeef");
1584
1585        let err = match backend.create(spec).await {
1586            Ok(_) => panic!("a 409 must surface as an error"),
1587            Err(e) => e,
1588        };
1589        assert!(
1590            matches!(err, rightsize::error::RightsizeError::NameConflict { .. }),
1591            "{err}"
1592        );
1593    }
1594
1595    #[tokio::test]
1596    async fn remove_network_falls_back_to_a_live_lookup_when_the_in_memory_cache_never_saw_it() {
1597        let (client, _dir, received) = multi_request_fixture(vec![
1598            network_list_response("daemon-net-9"),
1599            b"HTTP/1.1 204 No Content\r\n\r\n".to_vec(),
1600        ])
1601        .await;
1602        let backend = DockerBackend::new(client);
1603
1604        // No prior `ensure_network` call on THIS instance — `network_ids` starts
1605        // empty, exactly as it would for a process reaping a DIFFERENT (dead)
1606        // process's leftover network.
1607        backend.remove_network("rz-net-deadrun").await.unwrap();
1608
1609        let requests = received.lock().unwrap().clone();
1610        assert_eq!(
1611            requests.len(),
1612            2,
1613            "a cache miss must fall back to a live GET lookup, then DELETE the id it \
1614             finds — got: {requests:?}"
1615        );
1616        assert!(
1617            requests[0].starts_with("GET /networks?filters="),
1618            "{requests:?}"
1619        );
1620        assert!(
1621            requests[1].starts_with("DELETE /networks/daemon-net-9 "),
1622            "the DELETE must target the id the live lookup resolved, not the \
1623             rightsize-side network name: {requests:?}"
1624        );
1625    }
1626
1627    #[tokio::test]
1628    async fn remove_network_uses_the_cached_id_without_a_lookup_when_this_instance_created_it() {
1629        // Only ONE canned response: if this test needed a second connection (a GET
1630        // lookup it shouldn't be making), the fixture would have nothing left to
1631        // accept and the DELETE call would hang until the client's own timeout —
1632        // a real failure signal, not a false pass.
1633        let (client, _dir, received) =
1634            multi_request_fixture(vec![b"HTTP/1.1 204 No Content\r\n\r\n".to_vec()]).await;
1635        let backend = DockerBackend::new(client);
1636        backend
1637            .network_ids
1638            .lock()
1639            .unwrap()
1640            .insert("rz-net-ownrun".to_string(), "daemon-net-owned".to_string());
1641
1642        backend.remove_network("rz-net-ownrun").await.unwrap();
1643
1644        let requests = received.lock().unwrap().clone();
1645        assert_eq!(requests.len(), 1, "{requests:?}");
1646        assert!(
1647            requests[0].starts_with("DELETE /networks/daemon-net-owned "),
1648            "{requests:?}"
1649        );
1650    }
1651
1652    // --- create_checkpoint (checkpoint) ---------------------------------------
1653
1654    #[tokio::test]
1655    async fn create_checkpoint_posts_commit_with_the_split_repo_and_tag_and_returns_the_ref() {
1656        let (client, _dir, received) = multi_request_fixture(vec![
1657            b"HTTP/1.1 201 Created\r\nContent-Length: 2\r\n\r\n{}".to_vec(),
1658        ])
1659        .await;
1660        let backend = DockerBackend::new(client);
1661        let handle = Handle {
1662            id: "daemon-c-checkpoint".to_string(),
1663            spec: ContainerSpec::new("rz-x-0", "redis:8.6-alpine", "deadbeef"),
1664        };
1665
1666        let checkpoint_ref = backend
1667            .create_checkpoint(&handle, "abc123def456")
1668            .await
1669            .expect("commit must succeed on a 201");
1670        assert_eq!(checkpoint_ref, "rightsize/checkpoint:abc123def456");
1671
1672        let requests = received.lock().unwrap().clone();
1673        assert_eq!(requests.len(), 1, "{requests:?}");
1674        assert!(
1675            requests[0].starts_with("POST /commit?container=daemon-c-checkpoint"),
1676            "{requests:?}"
1677        );
1678        assert!(
1679            requests[0].contains("repo=rightsize%2Fcheckpoint"),
1680            "{requests:?}"
1681        );
1682        assert!(requests[0].contains("tag=abc123def456"), "{requests:?}");
1683    }
1684
1685    #[tokio::test]
1686    async fn create_checkpoint_surfaces_a_daemon_error_as_a_backend_error() {
1687        let (client, _dir, _received) = multi_request_fixture(vec![
1688            b"HTTP/1.1 404 Not Found\r\nContent-Length: 23\r\n\r\n{\"message\":\"no such\"}"
1689                .to_vec(),
1690        ])
1691        .await;
1692        let backend = DockerBackend::new(client);
1693        let handle = Handle {
1694            id: "missing-container".to_string(),
1695            spec: ContainerSpec::new("rz-x-0", "redis:8.6-alpine", "deadbeef"),
1696        };
1697
1698        let err = backend
1699            .create_checkpoint(&handle, "abc123def456")
1700            .await
1701            .expect_err("a 404 must surface as an error");
1702        assert!(
1703            matches!(err, rightsize::error::RightsizeError::Backend(_)),
1704            "{err}"
1705        );
1706    }
1707
1708    #[tokio::test]
1709    async fn remove_checkpoint_deletes_the_image_and_treats_not_found_as_success() {
1710        let (client, _dir, received) = multi_request_fixture(vec![
1711            b"HTTP/1.1 404 Not Found\r\nContent-Length: 2\r\n\r\n{}".to_vec(),
1712        ])
1713        .await;
1714        let backend = DockerBackend::new(client);
1715
1716        backend
1717            .remove_checkpoint("rightsize/checkpoint:abc123def456")
1718            .await
1719            .expect("a 404 (already gone) must be treated as success");
1720
1721        let requests = received.lock().unwrap().clone();
1722        assert_eq!(requests.len(), 1, "{requests:?}");
1723        assert!(
1724            requests[0].starts_with("DELETE /images/rightsize/checkpoint:abc123def456"),
1725            "{requests:?}"
1726        );
1727    }
1728
1729    // --- has_checkpoint (named checkpoints' existence probe) --------------------
1730
1731    #[tokio::test]
1732    async fn has_checkpoint_returns_true_on_a_200() {
1733        let (client, _dir, received) = multi_request_fixture(vec![
1734            b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\n{}".to_vec(),
1735        ])
1736        .await;
1737        let backend = DockerBackend::new(client);
1738
1739        let exists = backend
1740            .has_checkpoint("rightsize/checkpoint:abc123def456")
1741            .await
1742            .expect("a 200 must resolve to Ok(true)");
1743        assert!(exists);
1744
1745        let requests = received.lock().unwrap().clone();
1746        assert_eq!(requests.len(), 1, "{requests:?}");
1747        assert!(
1748            requests[0].starts_with("GET /images/rightsize/checkpoint:abc123def456/json"),
1749            "{requests:?}"
1750        );
1751    }
1752
1753    #[tokio::test]
1754    async fn has_checkpoint_returns_false_on_a_404() {
1755        let (client, _dir, _received) = multi_request_fixture(vec![
1756            b"HTTP/1.1 404 Not Found\r\nContent-Length: 2\r\n\r\n{}".to_vec(),
1757        ])
1758        .await;
1759        let backend = DockerBackend::new(client);
1760
1761        let exists = backend
1762            .has_checkpoint("rightsize/checkpoint:gone")
1763            .await
1764            .expect("a 404 must resolve to Ok(false), not an error");
1765        assert!(!exists);
1766    }
1767
1768    #[tokio::test]
1769    async fn has_checkpoint_surfaces_any_other_status_as_an_error() {
1770        let (client, _dir, _received) = multi_request_fixture(vec![
1771            b"HTTP/1.1 500 Internal Server Error\r\nContent-Length: 15\r\n\r\n{\"message\":\"x\"}"
1772                .to_vec(),
1773        ])
1774        .await;
1775        let backend = DockerBackend::new(client);
1776
1777        let err = backend
1778            .has_checkpoint("rightsize/checkpoint:abc123def456")
1779            .await
1780            .expect_err("a probe failure must surface, never resolve to Ok(false)");
1781        assert!(
1782            matches!(err, rightsize::error::RightsizeError::Backend(_)),
1783            "{err}"
1784        );
1785    }
1786
1787    // --- docker cp argv construction -------------------------------------------
1788
1789    #[test]
1790    fn docker_cp_in_args_puts_the_host_path_first_and_container_id_colon_path_second() {
1791        assert_eq!(
1792            docker_cp_in_args("daemon-id-1", Path::new("/host/src.txt"), "/guest/dst.txt"),
1793            vec!["cp", "/host/src.txt", "daemon-id-1:/guest/dst.txt"]
1794        );
1795    }
1796
1797    #[test]
1798    fn docker_cp_out_args_puts_the_container_id_colon_path_first_and_host_path_second() {
1799        assert_eq!(
1800            docker_cp_out_args("daemon-id-1", "/guest/src.txt", Path::new("/host/dst.txt")),
1801            vec!["cp", "daemon-id-1:/guest/src.txt", "/host/dst.txt"]
1802        );
1803    }
1804}