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 save -o <dest> <ref>`) — same
814    /// rationale as [`Self::copy_to_container`]'s own CLI escape hatch: the daemon
815    /// has no single HTTP endpoint for this, and `docker` is already a hard
816    /// requirement for this backend regardless. The checkpoint-archive feature's
817    /// export primitive (`rightsize::Checkpoint::export_to`).
818    async fn export_checkpoint(&self, checkpoint_ref: &str, dest: &Path) -> Result<()> {
819        let args = docker_save_args(checkpoint_ref, dest);
820        let result = run_docker_cli(&args).await?;
821        if result.exit_code != 0 {
822            return Err(RightsizeError::Backend(format!(
823                "docker save -o {} {checkpoint_ref} failed (exit {}): {}",
824                dest.display(),
825                result.exit_code,
826                result.stderr.trim()
827            )));
828        }
829        Ok(())
830    }
831
832    /// `docker load -i <src_file>` — the checkpoint-archive feature's import
833    /// primitive (`rightsize::Checkpoint::import_from`). Unlike microsandbox's
834    /// content-addressed import, `docker load` preserves the tag baked into the
835    /// save file, so the effective ref is simply `ref_hint` (the archive
836    /// manifest's original ref) unchanged — loading over an already-existing tag
837    /// just re-points it.
838    async fn import_checkpoint(&self, src_file: &Path, ref_hint: &str) -> Result<String> {
839        let args = docker_load_args(src_file);
840        let result = run_docker_cli(&args).await?;
841        if result.exit_code != 0 {
842            return Err(RightsizeError::Backend(format!(
843                "docker load -i {} failed (exit {}): {}",
844                src_file.display(),
845                result.exit_code,
846                result.stderr.trim()
847            )));
848        }
849        Ok(ref_hint.to_string())
850    }
851
852    /// Shells out to the `docker` CLI (`docker cp <host_path> <id>:<container_path>`)
853    /// rather than the daemon HTTP API this backend otherwise speaks exclusively —
854    /// the daemon's copy endpoints are tar-archive-in/tar-archive-out, and adding
855    /// tar encode/decode here would mean either a new third-party dependency or a
856    /// hand-rolled tar implementation, both worse than shelling out. The `docker`
857    /// CLI is already a hard requirement for this backend regardless (the reaping
858    /// watchdog's own kill command is a plain `docker rm -f`), so this adds no new
859    /// external dependency.
860    async fn copy_to_container(
861        &self,
862        handle: &dyn SandboxHandle,
863        host_path: &Path,
864        container_path: &str,
865    ) -> Result<()> {
866        let args = docker_cp_in_args(handle.id(), host_path, container_path);
867        let result = run_docker_cli(&args).await?;
868        if result.exit_code != 0 {
869            return Err(RightsizeError::Backend(format!(
870                "docker cp into container {} failed (exit {}): {}",
871                handle.id(),
872                result.exit_code,
873                result.stderr.trim()
874            )));
875        }
876        Ok(())
877    }
878
879    /// The reverse direction of [`Self::copy_to_container`], same `docker cp`
880    /// shape and error-surfacing.
881    async fn copy_from_container(
882        &self,
883        handle: &dyn SandboxHandle,
884        container_path: &str,
885        host_path: &Path,
886    ) -> Result<()> {
887        let args = docker_cp_out_args(handle.id(), container_path, host_path);
888        let result = run_docker_cli(&args).await?;
889        if result.exit_code != 0 {
890            return Err(RightsizeError::Backend(format!(
891                "docker cp out of container {} failed (exit {}): {}",
892                handle.id(),
893                result.exit_code,
894                result.stderr.trim()
895            )));
896        }
897        Ok(())
898    }
899
900    fn watchdog_kill_command(&self) -> Vec<String> {
901        vec!["docker".to_string(), "rm".to_string(), "-f".to_string()]
902    }
903
904    fn watchdog_network_kill_command(&self) -> Vec<String> {
905        vec![
906            "docker".to_string(),
907            "network".to_string(),
908            "rm".to_string(),
909        ]
910    }
911}
912
913/// Builds the argv for `docker cp <host_path> <container_id>:<container_path>` —
914/// pure argv construction, kept separate from [`run_docker_cli`] so the shape is a
915/// plain data-in/data-out unit test, independent of a real `docker` binary (the
916/// same split `rightsize-msb`'s own `commands` module uses for its `msb` argv).
917fn docker_cp_in_args(container_id: &str, host_path: &Path, container_path: &str) -> Vec<String> {
918    vec![
919        "cp".to_string(),
920        host_path.display().to_string(),
921        format!("{container_id}:{container_path}"),
922    ]
923}
924
925/// Builds the argv for `docker cp <container_id>:<container_path> <host_path>` —
926/// the reverse direction of [`docker_cp_in_args`].
927fn docker_cp_out_args(container_id: &str, container_path: &str, host_path: &Path) -> Vec<String> {
928    vec![
929        "cp".to_string(),
930        format!("{container_id}:{container_path}"),
931        host_path.display().to_string(),
932    ]
933}
934
935/// Builds the argv for `docker save -o <dest> <checkpoint_ref>` — the
936/// checkpoint-archive feature's export primitive
937/// ([`DockerBackend::export_checkpoint`]).
938fn docker_save_args(checkpoint_ref: &str, dest: &Path) -> Vec<String> {
939    vec![
940        "save".to_string(),
941        "-o".to_string(),
942        dest.display().to_string(),
943        checkpoint_ref.to_string(),
944    ]
945}
946
947/// Builds the argv for `docker load -i <src_file>` — the checkpoint-archive
948/// feature's import primitive ([`DockerBackend::import_checkpoint`]).
949fn docker_load_args(src_file: &Path) -> Vec<String> {
950    vec![
951        "load".to_string(),
952        "-i".to_string(),
953        src_file.display().to_string(),
954    ]
955}
956
957/// Runs the `docker` CLI (never the daemon HTTP API this backend otherwise speaks
958/// exclusively — see [`DockerBackend::copy_to_container`]'s doc for why copy is the
959/// one exception) with a closed/null stdin, matching every other child process this
960/// workspace spawns under the hood (see `rightsize-msb`'s own convention), and
961/// captures its exit code plus stdout/stderr.
962async fn run_docker_cli(args: &[String]) -> Result<ExecResult> {
963    let output = tokio::process::Command::new("docker")
964        .args(args)
965        .stdin(std::process::Stdio::null())
966        .output()
967        .await
968        .map_err(|e| {
969            RightsizeError::Backend(format!("failed to spawn docker {}: {e}", args.join(" ")))
970        })?;
971    Ok(ExecResult {
972        exit_code: output.status.code().unwrap_or(-1),
973        stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
974        stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
975    })
976}
977
978/// The `cleanup_sync` path's blocking counterpart to `stop`+`remove`: issues a plain
979/// `POST /containers/{id}/stop?t=` then `DELETE /containers/{id}?force=true` over a
980/// blocking `std::os::unix::net::UnixStream` — no Tokio, since this runs on the
981/// dedicated `Drop`-path cleanup thread with no async runtime in context. Best-effort:
982/// errors are swallowed by the caller, matching this backend's own async `stop`/
983/// `remove`.
984fn blocking_force_remove(
985    socket_path: &std::path::Path,
986    container_id: &str,
987    stop_timeout_secs: u64,
988) -> std::io::Result<()> {
989    let _ = blocking_request(
990        socket_path,
991        "POST",
992        &format!("/containers/{container_id}/stop?t={stop_timeout_secs}"),
993    );
994    blocking_request(
995        socket_path,
996        "DELETE",
997        &format!("/containers/{container_id}?force=true"),
998    )
999    .map(|_| ())
1000}
1001
1002/// Resolves `name` to a daemon-assigned container id via a blocking
1003/// `GET /containers/json?all=true&filters={"name":["^/<name>$"]}` — the `^/...$`
1004/// anchoring matches Docker's own convention for an exact-name filter (Docker's
1005/// `name` filter is substring-by-default; anchoring is what makes it exact). Returns
1006/// `None` on any failure (lookup error, no match, unparseable body) — the caller
1007/// treats that as "nothing to remove", matching `remove_by_name`'s best-effort,
1008/// not-found-is-fine contract.
1009fn blocking_find_container_id_by_name(socket_path: &std::path::Path, name: &str) -> Option<String> {
1010    let filters = format!(r#"{{"name":["^/{name}$"]}}"#);
1011    let path = format!(
1012        "/containers/json?all=true&filters={}",
1013        encode_query_value(&filters)
1014    );
1015    let body = blocking_get_body(socket_path, &path).ok()?;
1016    let entries: Vec<ListedEntry> = serde_json::from_slice(&body).ok()?;
1017    entries.into_iter().next().map(|e| e.id)
1018}
1019
1020/// A minimal blocking HTTP GET over a blocking unix socket, returning the response
1021/// body — the read-the-body counterpart to [`blocking_request`] (which discards it),
1022/// needed by [`blocking_find_container_id_by_name`]. Honors `Content-Length` when
1023/// present, dechunks a `Transfer-Encoding: chunked` response (`GET
1024/// /containers/json` — this call's only caller — comes back chunked on every
1025/// dockerd/Docker Desktop build observed; empirically NOT the "Content-Length in
1026/// practice" case an earlier version of this function assumed, which made
1027/// `blocking_find_container_id_by_name` silently find nothing on every real
1028/// daemon), and otherwise falls back to reading until the peer closes (this client
1029/// always sends `Connection: close`, matching [`blocking_request`]'s own
1030/// assumption).
1031fn blocking_get_body(socket_path: &std::path::Path, path: &str) -> std::io::Result<Vec<u8>> {
1032    use std::io::{BufRead, BufReader, Read, Write};
1033    use std::os::unix::net::UnixStream;
1034
1035    let mut stream = UnixStream::connect(socket_path)?;
1036    stream.set_read_timeout(Some(Duration::from_secs(stop_timeout_budget())))?;
1037    let request = format!("GET {path} HTTP/1.1\r\nHost: docker\r\nConnection: close\r\n\r\n");
1038    stream.write_all(request.as_bytes())?;
1039
1040    let mut reader = BufReader::new(stream);
1041    let mut status_line = String::new();
1042    reader.read_line(&mut status_line)?;
1043
1044    let mut content_length: Option<usize> = None;
1045    let mut chunked = false;
1046    loop {
1047        let mut line = String::new();
1048        let n = reader.read_line(&mut line)?;
1049        if n == 0 || line == "\r\n" || line == "\n" {
1050            break;
1051        }
1052        if let Some((name, value)) = line.split_once(':') {
1053            let name = name.trim();
1054            let value = value.trim();
1055            if name.eq_ignore_ascii_case("content-length") {
1056                content_length = value.parse::<usize>().ok();
1057            } else if name.eq_ignore_ascii_case("transfer-encoding") {
1058                chunked = value.to_ascii_lowercase().contains("chunked");
1059            }
1060        }
1061    }
1062
1063    if chunked {
1064        return blocking_read_chunked_body(&mut reader);
1065    }
1066
1067    let mut body = Vec::new();
1068    match content_length {
1069        Some(len) => {
1070            body.resize(len, 0);
1071            reader.read_exact(&mut body)?;
1072        }
1073        None => {
1074            reader.read_to_end(&mut body)?;
1075        }
1076    }
1077    Ok(body)
1078}
1079
1080/// Dechunks an HTTP `Transfer-Encoding: chunked` body from `reader` — the blocking
1081/// counterpart to `client::read_chunked_body`, needed because [`blocking_get_body`]
1082/// runs on a plain OS thread with no Tokio runtime available. Same framing: a hex
1083/// chunk-size line, that many bytes, a trailing `\r\n`, repeated until a zero-size
1084/// chunk (optionally followed by trailer headers, drained and discarded) ends the
1085/// body.
1086fn blocking_read_chunked_body(
1087    reader: &mut std::io::BufReader<std::os::unix::net::UnixStream>,
1088) -> std::io::Result<Vec<u8>> {
1089    use std::io::{BufRead, Read};
1090
1091    let mut out = Vec::new();
1092    loop {
1093        let mut size_line = String::new();
1094        reader.read_line(&mut size_line)?;
1095        let size_str = size_line.trim().split(';').next().unwrap_or("").trim();
1096        let size = usize::from_str_radix(size_str, 16).map_err(|_| {
1097            std::io::Error::new(
1098                std::io::ErrorKind::InvalidData,
1099                format!(
1100                    "could not parse a chunk size from the Docker daemon's response: \
1101                     {size_line:?}"
1102                ),
1103            )
1104        })?;
1105        if size == 0 {
1106            // Drain trailer headers up to the final blank line, then stop.
1107            loop {
1108                let mut trailer = String::new();
1109                let n = reader.read_line(&mut trailer)?;
1110                if n == 0 || trailer == "\r\n" || trailer == "\n" {
1111                    break;
1112                }
1113            }
1114            break;
1115        }
1116        let mut chunk = vec![0u8; size];
1117        reader.read_exact(&mut chunk)?;
1118        out.extend_from_slice(&chunk);
1119        // Each chunk is followed by a trailing \r\n that isn't part of the payload.
1120        let mut crlf = [0u8; 2];
1121        reader.read_exact(&mut crlf)?;
1122    }
1123    Ok(out)
1124}
1125
1126/// A minimal blocking HTTP/1.1 request over a blocking unix socket — the Drop-path
1127/// analogue of [`DockerClient::request`], deliberately NOT reusing any of that async
1128/// client's code (which is all `tokio::net::UnixStream`-shaped): this runs on a plain
1129/// OS thread with no Tokio runtime available, so it needs its own blocking transport.
1130/// Doesn't need to handle chunked responses — the
1131/// only calls made here (`stop`, `force=true remove`) return small `Content-Length`
1132/// (or empty) bodies in practice — so this reads until the peer closes and returns
1133/// whatever arrived, without trying to interpret framing at all.
1134fn blocking_request(
1135    socket_path: &std::path::Path,
1136    method: &str,
1137    path: &str,
1138) -> std::io::Result<()> {
1139    use std::io::{Read, Write};
1140    use std::os::unix::net::UnixStream;
1141
1142    let mut stream = UnixStream::connect(socket_path)?;
1143    stream.set_read_timeout(Some(Duration::from_secs(stop_timeout_budget())))?;
1144    let request = format!("{method} {path} HTTP/1.1\r\nHost: docker\r\nConnection: close\r\n\r\n");
1145    stream.write_all(request.as_bytes())?;
1146    let mut discard = Vec::new();
1147    let _ = stream.read_to_end(&mut discard);
1148    Ok(())
1149}
1150
1151/// A generous fixed budget for the blocking cleanup-path request's read timeout —
1152/// this path has no caller to propagate a timeout error to (it's best-effort, fired
1153/// from `Drop`), so it just needs to not hang the cleanup thread forever if the
1154/// daemon never responds.
1155fn stop_timeout_budget() -> u64 {
1156    STOP_TIMEOUT_SECS + 20
1157}
1158
1159#[cfg(test)]
1160mod tests {
1161    use super::*;
1162
1163    #[test]
1164    fn capabilities_report_no_hardware_isolation_but_checkpoint_support() {
1165        let backend = DockerBackend::connecting_to_env();
1166        let caps = backend.capabilities();
1167        assert!(!caps.hardware_isolated, "containers share the host kernel");
1168        assert!(caps.checkpoint);
1169        assert!(
1170            !caps.checkpoint_restarts_workload,
1171            "an image commit leaves the running container undisturbed"
1172        );
1173    }
1174
1175    /// A minimal, dependency-free temp-directory helper for the blocking-socket
1176    /// tests below — see the async test module's own `tempdir_shim` for why this
1177    /// crate hand-rolls one per test module rather than sharing it.
1178    mod blocking_tempdir_shim {
1179        use std::path::{Path, PathBuf};
1180
1181        pub(super) struct TempDir(PathBuf);
1182
1183        impl TempDir {
1184            pub(super) fn new() -> Self {
1185                static COUNTER: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0);
1186                let seq = COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1187                let unique = format!("rzdbb-{:x}-{:x}", std::process::id() as u16, seq);
1188                let path = Path::new("/tmp").join(unique);
1189                std::fs::create_dir_all(&path).expect("create temp dir");
1190                TempDir(path)
1191            }
1192
1193            pub(super) fn path(&self) -> &Path {
1194                &self.0
1195            }
1196        }
1197
1198        impl Drop for TempDir {
1199            fn drop(&mut self) {
1200                let _ = std::fs::remove_dir_all(&self.0);
1201            }
1202        }
1203    }
1204
1205    /// `blocking_get_body` must dechunk a `Transfer-Encoding: chunked` response —
1206    /// this is the real shape `GET /containers/json` comes back in on every
1207    /// dockerd/Docker Desktop build observed, not the `Content-Length` shape an
1208    /// earlier version of this function assumed, which made
1209    /// `blocking_find_container_id_by_name` (and therefore `remove_by_name`) find
1210    /// nothing on every real daemon.
1211    #[test]
1212    fn blocking_get_body_dechunks_a_transfer_encoding_chunked_response() {
1213        use std::io::{Read, Write};
1214        use std::os::unix::net::UnixListener;
1215
1216        let dir = blocking_tempdir_shim::TempDir::new();
1217        let sock_path = dir.path().join("docker.sock");
1218        let listener = UnixListener::bind(&sock_path).expect("bind fixture socket");
1219
1220        let server = std::thread::spawn(move || {
1221            let (mut stream, _) = listener.accept().expect("accept fixture connection");
1222            let mut buf = [0u8; 4096];
1223            let _ = stream.read(&mut buf); // drain the request line/headers.
1224            stream
1225                .write_all(
1226                    b"HTTP/1.1 200 OK\r\n\
1227                      Content-Type: application/json\r\n\
1228                      Transfer-Encoding: chunked\r\n\
1229                      \r\n\
1230                      5\r\nhello\r\n\
1231                      6\r\n world\r\n\
1232                      0\r\n\r\n",
1233                )
1234                .expect("write fixture response");
1235        });
1236
1237        let body =
1238            blocking_get_body(&sock_path, "/containers/json").expect("dechunking must succeed");
1239        assert_eq!(body, b"hello world");
1240        server.join().expect("fixture server thread must not panic");
1241    }
1242
1243    /// The `Content-Length`-framed shape must keep working too — the dechunking
1244    /// addition must be additive, not a regression on the framing that already
1245    /// worked.
1246    #[test]
1247    fn blocking_get_body_honors_content_length_when_present() {
1248        use std::io::{Read, Write};
1249        use std::os::unix::net::UnixListener;
1250
1251        let dir = blocking_tempdir_shim::TempDir::new();
1252        let sock_path = dir.path().join("docker.sock");
1253        let listener = UnixListener::bind(&sock_path).expect("bind fixture socket");
1254
1255        let server = std::thread::spawn(move || {
1256            let (mut stream, _) = listener.accept().expect("accept fixture connection");
1257            let mut buf = [0u8; 4096];
1258            let _ = stream.read(&mut buf);
1259            stream
1260                .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 13\r\n\r\nhello world!!")
1261                .expect("write fixture response");
1262        });
1263
1264        let body = blocking_get_body(&sock_path, "/containers/json")
1265            .expect("Content-Length read must succeed");
1266        assert_eq!(body, b"hello world!!");
1267        server.join().expect("fixture server thread must not panic");
1268    }
1269
1270    #[test]
1271    fn is_port_bind_conflict_message_matches_known_phrasings() {
1272        assert!(is_port_bind_conflict_message(
1273            "driver failed programming external connectivity: address already in use"
1274        ));
1275        assert!(is_port_bind_conflict_message(
1276            "Bind for 0.0.0.0:6379 failed: port is already allocated"
1277        ));
1278        assert!(is_port_bind_conflict_message(
1279            "ALREADY ALLOCATED (case-insensitive)"
1280        ));
1281    }
1282
1283    #[test]
1284    fn is_port_bind_conflict_message_negative_cases_do_not_match() {
1285        assert!(!is_port_bind_conflict_message("no such image"));
1286        assert!(!is_port_bind_conflict_message("container already stopped"));
1287        assert!(!is_port_bind_conflict_message(""));
1288    }
1289
1290    #[test]
1291    fn split_repo_tag_defaults_to_latest_when_untagged() {
1292        assert_eq!(
1293            split_repo_tag("redis"),
1294            ("redis".to_string(), "latest".to_string())
1295        );
1296        assert_eq!(
1297            split_repo_tag("redis:8.6-alpine"),
1298            ("redis".to_string(), "8.6-alpine".to_string())
1299        );
1300    }
1301
1302    #[test]
1303    fn split_repo_tag_handles_a_registry_host_with_a_port() {
1304        assert_eq!(
1305            split_repo_tag("localhost:5000/redis"),
1306            ("localhost:5000/redis".to_string(), "latest".to_string())
1307        );
1308        assert_eq!(
1309            split_repo_tag("localhost:5000/redis:8.6-alpine"),
1310            ("localhost:5000/redis".to_string(), "8.6-alpine".to_string())
1311        );
1312    }
1313
1314    #[test]
1315    fn split_repo_tag_passes_through_a_digest_reference_with_no_tag() {
1316        assert_eq!(
1317            split_repo_tag("redis@sha256:abcdef"),
1318            ("redis@sha256:abcdef".to_string(), String::new())
1319        );
1320    }
1321
1322    /// `build_create_body` returns a struct now (see `crate::json::CreateContainerBody`);
1323    /// these tests still assert on the actual bytes sent over the wire, serializing it
1324    /// exactly like `create()` does, so a field-rename typo or a `skip_serializing_if`
1325    /// slip is still caught here rather than only in `crate::json`'s own struct-shape
1326    /// tests.
1327    fn serialize(spec: &ContainerSpec) -> String {
1328        serde_json::to_string(&build_create_body(spec)).unwrap()
1329    }
1330
1331    #[test]
1332    fn build_create_body_includes_port_bindings_on_loopback() {
1333        let mut spec = ContainerSpec::new("rz-x-0", "redis:8.6-alpine", "deadbeef");
1334        spec.ports.push(rightsize::model::PortBinding {
1335            host_port: 32768,
1336            guest_port: 6379,
1337        });
1338        let body = serialize(&spec);
1339        assert!(body.contains("\"HostIp\":\"127.0.0.1\""));
1340        assert!(body.contains("\"HostPort\":\"32768\""));
1341        assert!(body.contains("\"6379/tcp\""));
1342    }
1343
1344    #[test]
1345    fn build_create_body_includes_the_run_id_label_and_extra_host() {
1346        let spec = ContainerSpec::new("rz-x-0", "redis:8.6-alpine", "deadbeef");
1347        let body = serialize(&spec);
1348        assert!(body.contains("\"dev.rightsize.run_id\":\"deadbeef\""));
1349        assert!(body.contains("host.docker.internal:host-gateway"));
1350    }
1351
1352    #[test]
1353    fn build_create_body_honors_mount_read_only_flag() {
1354        let mut spec = ContainerSpec::new("rz-x-0", "redis:8.6-alpine", "deadbeef");
1355        spec.mounts
1356            .push(rightsize::model::FileMount::new("/host/a", "/guest/a"));
1357        spec.mounts
1358            .push(rightsize::model::FileMount::new("/host/b", "/guest/b").read_write());
1359        let body = serialize(&spec);
1360        assert!(body.contains("/host/a:/guest/a:ro"));
1361        assert!(body.contains("/host/b:/guest/b:rw"));
1362    }
1363
1364    #[test]
1365    fn build_create_body_omits_memory_when_unset_and_includes_it_when_set() {
1366        let spec = ContainerSpec::new("rz-x-0", "redis:8.6-alpine", "deadbeef");
1367        assert!(!serialize(&spec).contains("Memory"));
1368
1369        let mut spec = ContainerSpec::new("rz-x-0", "redis:8.6-alpine", "deadbeef");
1370        spec.memory_limit_mb = Some(512);
1371        assert!(serialize(&spec).contains("\"Memory\":536870912"));
1372    }
1373
1374    #[test]
1375    fn build_create_body_omits_cmd_when_command_is_none_and_includes_it_when_some() {
1376        let spec = ContainerSpec::new("rz-x-0", "redis:8.6-alpine", "deadbeef");
1377        assert!(!serialize(&spec).contains("\"Cmd\""));
1378
1379        let mut spec = ContainerSpec::new("rz-x-0", "redis:8.6-alpine", "deadbeef");
1380        spec.command = Some(vec![
1381            "redis-server".to_string(),
1382            "--port".to_string(),
1383            "6379".to_string(),
1384        ]);
1385        let body = serialize(&spec);
1386        assert!(body.contains("\"Cmd\":[\"redis-server\",\"--port\",\"6379\"]"));
1387    }
1388
1389    #[test]
1390    fn docker_backend_dials_a_unix_socket_never_a_tcp_host() {
1391        // Standing regression guard: this backend's transport must always be
1392        // a unix socket path, never a `host:port` TCP endpoint — the exact failure
1393        // mode a shared/bumped HTTP stack can cause if a dependency update
1394        // silently changes the default transport.
1395        let backend = DockerBackend::new(DockerClient::from_docker_host(None));
1396        let path = backend.socket_path_for_test();
1397        assert!(
1398            path.is_absolute(),
1399            "expected an absolute unix socket path, got {path:?}"
1400        );
1401        let path_str = path.to_string_lossy();
1402        assert!(
1403            !path_str.contains(':'),
1404            "a unix socket path must not look like a host:port TCP address — got {path_str}"
1405        );
1406
1407        let backend_env =
1408            DockerBackend::new(DockerClient::from_docker_host(Some("tcp://127.0.0.1:2375")));
1409        let fallback_path = backend_env.socket_path_for_test();
1410        assert!(
1411            !fallback_path.to_string_lossy().contains("2375"),
1412            "a tcp:// DOCKER_HOST must not leak a TCP port into this backend's transport — \
1413             got {fallback_path:?}"
1414        );
1415    }
1416
1417    #[test]
1418    fn keep_alive_spec_gets_the_reuse_label_instead_of_the_run_id_label() {
1419        let mut spec = ContainerSpec::new("rz-x-0", "redis:8.6-alpine", "deadbeef");
1420        spec.keep_alive = true;
1421        let body = serialize(&spec);
1422        assert!(body.contains("\"dev.rightsize.reuse\""), "{body}");
1423        assert!(!body.contains("\"dev.rightsize.run_id\""), "{body}");
1424    }
1425
1426    #[test]
1427    fn non_keep_alive_spec_still_gets_the_run_id_label_only() {
1428        let spec = ContainerSpec::new("rz-x-0", "redis:8.6-alpine", "deadbeef");
1429        let body = serialize(&spec);
1430        assert!(
1431            body.contains("\"dev.rightsize.run_id\":\"deadbeef\""),
1432            "{body}"
1433        );
1434        assert!(!body.contains("\"dev.rightsize.reuse\""), "{body}");
1435    }
1436
1437    #[test]
1438    fn watchdog_kill_command_is_a_plain_docker_rm_dash_f() {
1439        let backend = DockerBackend::new(DockerClient::from_docker_host(None));
1440        assert_eq!(
1441            backend.watchdog_kill_command(),
1442            vec!["docker".to_string(), "rm".to_string(), "-f".to_string()]
1443        );
1444    }
1445
1446    #[test]
1447    fn watchdog_network_kill_command_is_docker_network_rm() {
1448        let backend = DockerBackend::new(DockerClient::from_docker_host(None));
1449        assert_eq!(
1450            backend.watchdog_network_kill_command(),
1451            vec![
1452                "docker".to_string(),
1453                "network".to_string(),
1454                "rm".to_string()
1455            ]
1456        );
1457    }
1458
1459    #[test]
1460    fn reuse_label_value_extracts_the_hash_straight_from_an_rz_reuse_name() {
1461        let spec = ContainerSpec::new("rz-reuse-799aad5a3338", "redis:7-alpine", "deadbeef");
1462        assert_eq!(reuse_label_value(&spec), "799aad5a3338");
1463    }
1464
1465    #[test]
1466    fn reuse_label_value_is_twelve_hex_chars_and_deterministic() {
1467        let spec_a = ContainerSpec::new("rz-x-0", "redis:8.6-alpine", "deadbeef");
1468        let spec_b = ContainerSpec::new("rz-x-0", "redis:8.6-alpine", "deadbeef");
1469        let spec_c = ContainerSpec::new("rz-x-1", "redis:8.6-alpine", "deadbeef");
1470        let a = reuse_label_value(&spec_a);
1471        let b = reuse_label_value(&spec_b);
1472        let c = reuse_label_value(&spec_c);
1473        assert_eq!(a.len(), 12);
1474        assert!(a.chars().all(|ch| ch.is_ascii_hexdigit()));
1475        assert_eq!(a, b, "same spec.name must hash the same every time");
1476        assert_ne!(a, c, "a different spec.name must hash differently");
1477    }
1478
1479    // --- remove_network's cache-miss fallback -------------------------------------
1480    //
1481    // The reaping sweep's whole point is removing resources a DIFFERENT (dead)
1482    // process created — a network this backend INSTANCE never itself called
1483    // `ensure_network` for, so `network_ids` (populated only by THIS instance's own
1484    // `ensure_network_get_id` calls) has nothing cached for it. These tests drive
1485    // `DockerBackend` against a real (fixture) unix socket rather than a fake
1486    // `SandboxBackend`, since the bug lives in the daemon-call sequence itself, not
1487    // in anything a fake could stand in for.
1488
1489    use std::sync::Arc;
1490    use tokio::net::UnixListener;
1491
1492    /// A minimal, dependency-free temp-directory helper — duplicated from
1493    /// `crate::client`'s own test-only `tempdir_shim` rather than shared, since that
1494    /// one is nested inside a private `#[cfg(test)] mod tests` in a different file
1495    /// and this crate's own convention is small hand-rolled scaffolding per test
1496    /// module over an extra dependency or cross-module test plumbing.
1497    mod tempdir_shim {
1498        use std::path::{Path, PathBuf};
1499
1500        pub(super) struct TempDir(PathBuf);
1501
1502        impl TempDir {
1503            /// Rooted at `/tmp`, not `std::env::temp_dir()` — see
1504            /// `crate::client`'s `tempdir_shim` for why (`AF_UNIX`'s `SUN_LEN` cap).
1505            pub(super) fn new() -> Self {
1506                static COUNTER: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0);
1507                let seq = COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1508                let unique = format!("rzdb-{:x}-{:x}", std::process::id() as u16, seq);
1509                let path = Path::new("/tmp").join(unique);
1510                std::fs::create_dir_all(&path).expect("create temp dir");
1511                TempDir(path)
1512            }
1513
1514            pub(super) fn path(&self) -> &Path {
1515                &self.0
1516            }
1517        }
1518
1519        impl Drop for TempDir {
1520            fn drop(&mut self) {
1521                let _ = std::fs::remove_dir_all(&self.0);
1522            }
1523        }
1524    }
1525
1526    /// Spawns a fixture unix-socket "server" that accepts connections IN SEQUENCE —
1527    /// one per entry in `responses` — replying with the given canned bytes and
1528    /// recording each request's status line, so a test can drive (and assert on) a
1529    /// `DockerBackend` method that makes more than one sequential HTTP call. Unlike
1530    /// `crate::client`'s own single-request fixture, this one is needed because
1531    /// `remove_network`'s cache-miss fallback issues a `GET` lookup THEN a `DELETE`
1532    /// — two separate connections, since this client never keeps one alive across
1533    /// calls (see `crate::client`'s module doc).
1534    async fn multi_request_fixture(
1535        responses: Vec<Vec<u8>>,
1536    ) -> (DockerClient, tempdir_shim::TempDir, Arc<Mutex<Vec<String>>>) {
1537        let dir = tempdir_shim::TempDir::new();
1538        let sock_path = dir.path().join("docker.sock");
1539        let listener = UnixListener::bind(&sock_path).expect("bind fixture socket");
1540        let received: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
1541        let received_clone = received.clone();
1542        tokio::spawn(async move {
1543            for response in responses {
1544                if let Ok((mut conn, _)) = listener.accept().await {
1545                    use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
1546                    let mut buf = vec![0u8; 4096];
1547                    let n = tokio::time::timeout(
1548                        std::time::Duration::from_millis(200),
1549                        conn.read(&mut buf),
1550                    )
1551                    .await
1552                    .ok()
1553                    .and_then(|r| r.ok())
1554                    .unwrap_or(0);
1555                    let request_line = String::from_utf8_lossy(&buf[..n])
1556                        .lines()
1557                        .next()
1558                        .unwrap_or_default()
1559                        .to_string();
1560                    received_clone.lock().unwrap().push(request_line);
1561                    let _ = conn.write_all(&response).await;
1562                    let _ = conn.shutdown().await;
1563                }
1564            }
1565        });
1566        (DockerClient::at_socket(sock_path), dir, received)
1567    }
1568
1569    /// A canned `200 OK` response listing exactly one network whose daemon id is
1570    /// `id` — the shape `GET /networks?filters=...` returns.
1571    fn network_list_response(id: &str) -> Vec<u8> {
1572        let payload = format!(r#"[{{"Id":"{id}"}}]"#);
1573        format!(
1574            "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
1575            payload.len(),
1576            payload
1577        )
1578        .into_bytes()
1579    }
1580
1581    /// A canned `200 OK` response listing exactly one container whose daemon id is
1582    /// `id` — the shape `GET /containers/json?filters=...` returns (same `[{"Id":
1583    /// "..."}]` shape as [`network_list_response`], reused as-is by
1584    /// [`ListedEntry`], but named separately since it stands for a different
1585    /// daemon resource at each call site).
1586    fn container_list_response(id: &str) -> Vec<u8> {
1587        network_list_response(id)
1588    }
1589
1590    /// A canned `200 OK` empty-array response — "nothing matched this filter."
1591    fn empty_list_response() -> Vec<u8> {
1592        let payload = "[]";
1593        format!(
1594            "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
1595            payload.len(),
1596            payload
1597        )
1598        .into_bytes()
1599    }
1600
1601    #[tokio::test]
1602    async fn find_running_returns_a_handle_when_the_daemon_lists_a_running_match() {
1603        let (client, _dir, received) =
1604            multi_request_fixture(vec![container_list_response("daemon-c-1")]).await;
1605        let backend = DockerBackend::new(client);
1606        let spec = ContainerSpec::new("rz-reuse-799aad5a3338", "redis:7-alpine", "deadbeef");
1607
1608        let found = backend
1609            .find_running(&spec)
1610            .await
1611            .unwrap()
1612            .expect("must find the running container");
1613        assert_eq!(found.id(), "daemon-c-1");
1614
1615        let requests = received.lock().unwrap().clone();
1616        assert_eq!(requests.len(), 1);
1617        assert!(
1618            requests[0].starts_with("GET /containers/json?filters="),
1619            "{requests:?}"
1620        );
1621    }
1622
1623    #[tokio::test]
1624    async fn find_running_returns_none_when_nothing_matches() {
1625        let (client, _dir, _received) = multi_request_fixture(vec![empty_list_response()]).await;
1626        let backend = DockerBackend::new(client);
1627        let spec = ContainerSpec::new("rz-reuse-799aad5a3338", "redis:7-alpine", "deadbeef");
1628
1629        assert!(backend.find_running(&spec).await.unwrap().is_none());
1630    }
1631
1632    #[tokio::test]
1633    async fn create_maps_a_409_conflict_to_the_typed_name_conflict_error() {
1634        // Two canned responses: `create()` first checks the image is present
1635        // (`pull_if_missing`'s `GET /images/{name}/json`, answered `200 OK` so it
1636        // never attempts a pull), THEN issues the actual `POST /containers/create`
1637        // that this test cares about, answered with the 409 conflict.
1638        let (client, _dir, _received) = multi_request_fixture(vec![
1639            b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\n{}".to_vec(),
1640            b"HTTP/1.1 409 Conflict\r\nContent-Length: 44\r\n\r\n{\"message\":\"Conflict. Name already in use.\"}".to_vec(),
1641        ])
1642        .await;
1643        let backend = DockerBackend::new(client);
1644        let spec = ContainerSpec::new("rz-reuse-799aad5a3338", "redis:7-alpine", "deadbeef");
1645
1646        let err = match backend.create(spec).await {
1647            Ok(_) => panic!("a 409 must surface as an error"),
1648            Err(e) => e,
1649        };
1650        assert!(
1651            matches!(err, rightsize::error::RightsizeError::NameConflict { .. }),
1652            "{err}"
1653        );
1654    }
1655
1656    #[tokio::test]
1657    async fn remove_network_falls_back_to_a_live_lookup_when_the_in_memory_cache_never_saw_it() {
1658        let (client, _dir, received) = multi_request_fixture(vec![
1659            network_list_response("daemon-net-9"),
1660            b"HTTP/1.1 204 No Content\r\n\r\n".to_vec(),
1661        ])
1662        .await;
1663        let backend = DockerBackend::new(client);
1664
1665        // No prior `ensure_network` call on THIS instance — `network_ids` starts
1666        // empty, exactly as it would for a process reaping a DIFFERENT (dead)
1667        // process's leftover network.
1668        backend.remove_network("rz-net-deadrun").await.unwrap();
1669
1670        let requests = received.lock().unwrap().clone();
1671        assert_eq!(
1672            requests.len(),
1673            2,
1674            "a cache miss must fall back to a live GET lookup, then DELETE the id it \
1675             finds — got: {requests:?}"
1676        );
1677        assert!(
1678            requests[0].starts_with("GET /networks?filters="),
1679            "{requests:?}"
1680        );
1681        assert!(
1682            requests[1].starts_with("DELETE /networks/daemon-net-9 "),
1683            "the DELETE must target the id the live lookup resolved, not the \
1684             rightsize-side network name: {requests:?}"
1685        );
1686    }
1687
1688    #[tokio::test]
1689    async fn remove_network_uses_the_cached_id_without_a_lookup_when_this_instance_created_it() {
1690        // Only ONE canned response: if this test needed a second connection (a GET
1691        // lookup it shouldn't be making), the fixture would have nothing left to
1692        // accept and the DELETE call would hang until the client's own timeout —
1693        // a real failure signal, not a false pass.
1694        let (client, _dir, received) =
1695            multi_request_fixture(vec![b"HTTP/1.1 204 No Content\r\n\r\n".to_vec()]).await;
1696        let backend = DockerBackend::new(client);
1697        backend
1698            .network_ids
1699            .lock()
1700            .unwrap()
1701            .insert("rz-net-ownrun".to_string(), "daemon-net-owned".to_string());
1702
1703        backend.remove_network("rz-net-ownrun").await.unwrap();
1704
1705        let requests = received.lock().unwrap().clone();
1706        assert_eq!(requests.len(), 1, "{requests:?}");
1707        assert!(
1708            requests[0].starts_with("DELETE /networks/daemon-net-owned "),
1709            "{requests:?}"
1710        );
1711    }
1712
1713    // --- create_checkpoint (checkpoint) ---------------------------------------
1714
1715    #[tokio::test]
1716    async fn create_checkpoint_posts_commit_with_the_split_repo_and_tag_and_returns_the_ref() {
1717        let (client, _dir, received) = multi_request_fixture(vec![
1718            b"HTTP/1.1 201 Created\r\nContent-Length: 2\r\n\r\n{}".to_vec(),
1719        ])
1720        .await;
1721        let backend = DockerBackend::new(client);
1722        let handle = Handle {
1723            id: "daemon-c-checkpoint".to_string(),
1724            spec: ContainerSpec::new("rz-x-0", "redis:8.6-alpine", "deadbeef"),
1725        };
1726
1727        let checkpoint_ref = backend
1728            .create_checkpoint(&handle, "abc123def456")
1729            .await
1730            .expect("commit must succeed on a 201");
1731        assert_eq!(checkpoint_ref, "rightsize/checkpoint:abc123def456");
1732
1733        let requests = received.lock().unwrap().clone();
1734        assert_eq!(requests.len(), 1, "{requests:?}");
1735        assert!(
1736            requests[0].starts_with("POST /commit?container=daemon-c-checkpoint"),
1737            "{requests:?}"
1738        );
1739        assert!(
1740            requests[0].contains("repo=rightsize%2Fcheckpoint"),
1741            "{requests:?}"
1742        );
1743        assert!(requests[0].contains("tag=abc123def456"), "{requests:?}");
1744    }
1745
1746    #[tokio::test]
1747    async fn create_checkpoint_surfaces_a_daemon_error_as_a_backend_error() {
1748        let (client, _dir, _received) = multi_request_fixture(vec![
1749            b"HTTP/1.1 404 Not Found\r\nContent-Length: 23\r\n\r\n{\"message\":\"no such\"}"
1750                .to_vec(),
1751        ])
1752        .await;
1753        let backend = DockerBackend::new(client);
1754        let handle = Handle {
1755            id: "missing-container".to_string(),
1756            spec: ContainerSpec::new("rz-x-0", "redis:8.6-alpine", "deadbeef"),
1757        };
1758
1759        let err = backend
1760            .create_checkpoint(&handle, "abc123def456")
1761            .await
1762            .expect_err("a 404 must surface as an error");
1763        assert!(
1764            matches!(err, rightsize::error::RightsizeError::Backend(_)),
1765            "{err}"
1766        );
1767    }
1768
1769    #[tokio::test]
1770    async fn remove_checkpoint_deletes_the_image_and_treats_not_found_as_success() {
1771        let (client, _dir, received) = multi_request_fixture(vec![
1772            b"HTTP/1.1 404 Not Found\r\nContent-Length: 2\r\n\r\n{}".to_vec(),
1773        ])
1774        .await;
1775        let backend = DockerBackend::new(client);
1776
1777        backend
1778            .remove_checkpoint("rightsize/checkpoint:abc123def456")
1779            .await
1780            .expect("a 404 (already gone) must be treated as success");
1781
1782        let requests = received.lock().unwrap().clone();
1783        assert_eq!(requests.len(), 1, "{requests:?}");
1784        assert!(
1785            requests[0].starts_with("DELETE /images/rightsize/checkpoint:abc123def456"),
1786            "{requests:?}"
1787        );
1788    }
1789
1790    // --- has_checkpoint (named checkpoints' existence probe) --------------------
1791
1792    #[tokio::test]
1793    async fn has_checkpoint_returns_true_on_a_200() {
1794        let (client, _dir, received) = multi_request_fixture(vec![
1795            b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\n{}".to_vec(),
1796        ])
1797        .await;
1798        let backend = DockerBackend::new(client);
1799
1800        let exists = backend
1801            .has_checkpoint("rightsize/checkpoint:abc123def456")
1802            .await
1803            .expect("a 200 must resolve to Ok(true)");
1804        assert!(exists);
1805
1806        let requests = received.lock().unwrap().clone();
1807        assert_eq!(requests.len(), 1, "{requests:?}");
1808        assert!(
1809            requests[0].starts_with("GET /images/rightsize/checkpoint:abc123def456/json"),
1810            "{requests:?}"
1811        );
1812    }
1813
1814    #[tokio::test]
1815    async fn has_checkpoint_returns_false_on_a_404() {
1816        let (client, _dir, _received) = multi_request_fixture(vec![
1817            b"HTTP/1.1 404 Not Found\r\nContent-Length: 2\r\n\r\n{}".to_vec(),
1818        ])
1819        .await;
1820        let backend = DockerBackend::new(client);
1821
1822        let exists = backend
1823            .has_checkpoint("rightsize/checkpoint:gone")
1824            .await
1825            .expect("a 404 must resolve to Ok(false), not an error");
1826        assert!(!exists);
1827    }
1828
1829    #[tokio::test]
1830    async fn has_checkpoint_surfaces_any_other_status_as_an_error() {
1831        let (client, _dir, _received) = multi_request_fixture(vec![
1832            b"HTTP/1.1 500 Internal Server Error\r\nContent-Length: 15\r\n\r\n{\"message\":\"x\"}"
1833                .to_vec(),
1834        ])
1835        .await;
1836        let backend = DockerBackend::new(client);
1837
1838        let err = backend
1839            .has_checkpoint("rightsize/checkpoint:abc123def456")
1840            .await
1841            .expect_err("a probe failure must surface, never resolve to Ok(false)");
1842        assert!(
1843            matches!(err, rightsize::error::RightsizeError::Backend(_)),
1844            "{err}"
1845        );
1846    }
1847
1848    // --- docker cp argv construction -------------------------------------------
1849
1850    #[test]
1851    fn docker_cp_in_args_puts_the_host_path_first_and_container_id_colon_path_second() {
1852        assert_eq!(
1853            docker_cp_in_args("daemon-id-1", Path::new("/host/src.txt"), "/guest/dst.txt"),
1854            vec!["cp", "/host/src.txt", "daemon-id-1:/guest/dst.txt"]
1855        );
1856    }
1857
1858    #[test]
1859    fn docker_cp_out_args_puts_the_container_id_colon_path_first_and_host_path_second() {
1860        assert_eq!(
1861            docker_cp_out_args("daemon-id-1", "/guest/src.txt", Path::new("/host/dst.txt")),
1862            vec!["cp", "daemon-id-1:/guest/src.txt", "/host/dst.txt"]
1863        );
1864    }
1865
1866    // --- checkpoint archive argv construction -----------------------------------
1867
1868    #[test]
1869    fn docker_save_args_spelling() {
1870        assert_eq!(
1871            docker_save_args(
1872                "rightsize/checkpoint:abc123def456",
1873                Path::new("/tmp/cp.archive-payload")
1874            ),
1875            vec![
1876                "save",
1877                "-o",
1878                "/tmp/cp.archive-payload",
1879                "rightsize/checkpoint:abc123def456"
1880            ]
1881        );
1882    }
1883
1884    #[test]
1885    fn docker_load_args_spelling() {
1886        assert_eq!(
1887            docker_load_args(Path::new("/tmp/cp.archive-payload")),
1888            vec!["load", "-i", "/tmp/cp.archive-payload"]
1889        );
1890    }
1891}