Skip to main content

supercov_engine/
docker_proxy.rs

1//! Containers a suite starts, through any Docker client.
2//!
3//! The suite's `DOCKER_HOST` points at a socket of Supercov's that relays the
4//! Docker Engine API to the daemon the user's own client would reach. Every
5//! request passes through unchanged but one: creating a container. A
6//! container whose image holds the copy's instrumented code is given the
7//! run's settings and Supercov's directory, mounted at the path it has
8//! outside, so the code in it writes its evidence where the run reads it.
9//! `docker compose`, `docker run`, testcontainers and the SDKs all create
10//! containers this way, so one place serves them all.
11//!
12//! Which images hold the copy's code:
13//! - a compose service whose build context is in the copy, which the relay
14//!   builds from the copy before the container is created: an image left
15//!   over from an earlier run is never used;
16//! - any image created while the run was going: the suite built it, from the
17//!   copy it runs in;
18//! - an image an earlier run recorded as built from source identical to this
19//!   run's: a build that found everything cached returns it, unchanged.
20//!
21//! An image built from an earlier copy is left alone, and the run says so: its
22//! probes were placed in other source. At the end the run stops the
23//! containers it measured, and the rest of their compose projects, so each
24//! writes its last evidence; it never removes them.
25
26use std::{
27    collections::{BTreeMap, BTreeSet},
28    fs,
29    io::{self, BufRead, BufReader, Read, Write},
30    os::unix::net::{UnixListener, UnixStream},
31    path::{Path, PathBuf},
32    process::{Command, Stdio},
33    sync::{
34        Arc, Mutex,
35        atomic::{AtomicBool, Ordering},
36    },
37    thread::JoinHandle,
38    time::{Duration, SystemTime, UNIX_EPOCH},
39};
40
41use serde_json::{Value, json};
42
43/// Set in each measured container: its runtime runs in an image of the copy.
44pub const CONTAINER_VARIABLE: &str = "SUPERCOV_CONTAINER";
45
46/// What the relay gives the containers it measures.
47#[derive(Debug, Clone)]
48pub struct ProxySettings {
49    /// Supercov's copy: a compose build context in it is the copy's.
50    pub copy: PathBuf,
51    /// Mounted into each measured container at the same path.
52    pub mount: PathBuf,
53    /// The run's variables each measured container is given.
54    pub environment: Vec<(String, String)>,
55    /// The source the run's servers were instrumented from.
56    pub fingerprint: String,
57    /// Images earlier runs recorded, by id, with their source fingerprint.
58    pub registry: PathBuf,
59    /// Where the relay records what it did, one JSON line per container.
60    pub state: PathBuf,
61}
62
63/// The daemon a client would reach without Supercov, as a socket path. None
64/// when it is not a local socket (a TCP or SSH daemon), which the relay does
65/// not stand in for.
66pub fn upstream_socket(environment: &BTreeMap<String, String>) -> Option<PathBuf> {
67    if let Some(host) = environment
68        .get("DOCKER_HOST")
69        .filter(|host| !host.is_empty())
70    {
71        return host.strip_prefix("unix://").map(PathBuf::from);
72    }
73    let mut command = Command::new("docker");
74    command
75        .args([
76            "context",
77            "inspect",
78            "--format",
79            "{{.Endpoints.docker.Host}}",
80        ])
81        .stdin(Stdio::null())
82        .stderr(Stdio::null());
83    if let Some(context) = environment.get("DOCKER_CONTEXT") {
84        command.env("DOCKER_CONTEXT", context);
85    }
86    if let Ok(output) = command.output()
87        && output.status.success()
88    {
89        let host = String::from_utf8_lossy(&output.stdout).trim().to_owned();
90        return host.strip_prefix("unix://").map(PathBuf::from);
91    }
92    let default = PathBuf::from("/var/run/docker.sock");
93    default.exists().then_some(default)
94}
95
96/// The daemon's socket as a container sees it from the daemon's side: what
97/// testcontainers mounts into Ryuk, its reaper. Docker Desktop, Colima and
98/// the like run the daemon in a VM, where it is /var/run/docker.sock whatever
99/// the host calls it; on Linux the daemon shares the host's paths.
100fn daemon_side_socket(upstream: &Path) -> PathBuf {
101    if cfg!(target_os = "linux") {
102        upstream.to_owned()
103    } else {
104        PathBuf::from("/var/run/docker.sock")
105    }
106}
107
108/// Where the relay listens: in the run's own directory when the path fits a
109/// socket address (104 bytes on macOS, 108 on Linux), or else in a private
110/// directory of its own under /tmp.
111pub fn socket_path(state: &Path) -> io::Result<PathBuf> {
112    let preferred = state.join("docker.sock");
113    if preferred.as_os_str().len() < 100 {
114        fs::create_dir_all(state)?;
115        return Ok(preferred);
116    }
117    use std::os::unix::fs::DirBuilderExt;
118    let directory = PathBuf::from("/tmp").join(format!(
119        "supercov-docker-{}-{}",
120        std::process::id(),
121        SystemTime::now()
122            .duration_since(UNIX_EPOCH)
123            .map(|elapsed| elapsed.as_nanos())
124            .unwrap_or(0)
125    ));
126    fs::DirBuilder::new().mode(0o700).create(&directory)?;
127    Ok(directory.join("docker.sock"))
128}
129
130/// Remove the socket, and the private directory it was given when it had
131/// one.
132fn remove_socket(socket: &Path) {
133    let _ = fs::remove_file(socket);
134    if let Some(parent) = socket.parent()
135        && parent.starts_with("/tmp")
136        && parent
137            .file_name()
138            .and_then(|name| name.to_str())
139            .is_some_and(|name| name.starts_with("supercov-docker-"))
140    {
141        let _ = fs::remove_dir(parent);
142    }
143}
144
145/// A running relay.
146pub struct DockerProxy {
147    socket: PathBuf,
148    upstream: PathBuf,
149    shared: Arc<Shared>,
150    stop: Arc<AtomicBool>,
151    thread: Option<JoinHandle<()>>,
152}
153
154/// What every connection's handler reads and records.
155struct Shared {
156    settings: ProxySettings,
157    upstream: PathBuf,
158    socket: PathBuf,
159    started_ms: i64,
160    /// Containers created through the relay, as recorded.
161    created: Mutex<Vec<CreatedContainer>>,
162    /// Compose services already built from the copy in this run.
163    built: Mutex<BTreeSet<(String, String)>>,
164}
165
166#[derive(Debug, Clone)]
167struct CreatedContainer {
168    id: String,
169    measured: bool,
170    project: Option<String>,
171}
172
173impl DockerProxy {
174    /// Listen on `socket` and relay to `upstream`.
175    pub fn start(socket: &Path, upstream: &Path, settings: ProxySettings) -> io::Result<Self> {
176        if let Some(parent) = socket.parent() {
177            fs::create_dir_all(parent)?;
178        }
179        let _ = fs::remove_file(socket);
180        let listener = UnixListener::bind(socket)?;
181        listener.set_nonblocking(true)?;
182        if let Some(parent) = settings.state.parent() {
183            fs::create_dir_all(parent)?;
184        }
185        let shared = Arc::new(Shared {
186            settings,
187            upstream: upstream.to_owned(),
188            socket: socket.to_owned(),
189            started_ms: now_ms(),
190            created: Mutex::new(Vec::new()),
191            built: Mutex::new(BTreeSet::new()),
192        });
193        let stop = Arc::new(AtomicBool::new(false));
194        let thread = {
195            let shared = Arc::clone(&shared);
196            let stop = Arc::clone(&stop);
197            std::thread::Builder::new()
198                .name("supercov-docker-relay".into())
199                .spawn(move || accept(&listener, &shared, &stop))?
200        };
201        Ok(Self {
202            socket: socket.to_owned(),
203            upstream: upstream.to_owned(),
204            shared,
205            stop,
206            thread: Some(thread),
207        })
208    }
209
210    /// What the suite's environment needs: its clients reach the relay, and
211    /// a reaper a library starts mounts the daemon's own socket, which the
212    /// relay's could not stand in for inside a VM.
213    pub fn environment(&self, ambient: &BTreeMap<String, String>) -> BTreeMap<String, String> {
214        let mut environment = BTreeMap::from([(
215            "DOCKER_HOST".to_owned(),
216            format!("unix://{}", self.socket.display()),
217        )]);
218        if !ambient.contains_key("TESTCONTAINERS_DOCKER_SOCKET_OVERRIDE") {
219            environment.insert(
220                "TESTCONTAINERS_DOCKER_SOCKET_OVERRIDE".into(),
221                daemon_side_socket(&self.upstream).display().to_string(),
222            );
223        }
224        environment
225    }
226
227    /// Stop what the run measured, and the rest of their compose projects, so
228    /// each writes its last evidence; then stop relaying. Never removed: the
229    /// containers are the suite's.
230    pub fn finish(mut self) {
231        let created = self
232            .shared
233            .created
234            .lock()
235            .map(|created| created.clone())
236            .unwrap_or_default();
237        let projects = created
238            .iter()
239            .filter(|container| container.measured)
240            .filter_map(|container| container.project.clone())
241            .collect::<BTreeSet<_>>();
242        for container in &created {
243            let ours = container.measured
244                || container
245                    .project
246                    .as_ref()
247                    .is_some_and(|project| projects.contains(project));
248            if ours {
249                let _ = daemon_request(
250                    &self.upstream,
251                    "POST",
252                    &format!("/containers/{}/stop?t=10", container.id),
253                    None,
254                );
255            }
256        }
257        self.stop.store(true, Ordering::Release);
258        if let Some(thread) = self.thread.take() {
259            let _ = thread.join();
260        }
261        remove_socket(&self.socket);
262    }
263}
264
265impl Drop for DockerProxy {
266    fn drop(&mut self) {
267        self.stop.store(true, Ordering::Release);
268        if self.thread.is_some() {
269            remove_socket(&self.socket);
270        }
271    }
272}
273
274fn now_ms() -> i64 {
275    SystemTime::now()
276        .duration_since(UNIX_EPOCH)
277        .map(|elapsed| elapsed.as_millis() as i64)
278        .unwrap_or(0)
279}
280
281fn accept(listener: &UnixListener, shared: &Arc<Shared>, stop: &AtomicBool) {
282    while !stop.load(Ordering::Acquire) {
283        match listener.accept() {
284            Ok((client, _)) => {
285                let _ = client.set_nonblocking(false);
286                let shared = Arc::clone(shared);
287                let _ = std::thread::Builder::new()
288                    .name("supercov-docker-connection".into())
289                    .spawn(move || {
290                        let _ = relay_connection(client, &shared);
291                    });
292            }
293            Err(error) if error.kind() == io::ErrorKind::WouldBlock => {
294                std::thread::sleep(Duration::from_millis(10));
295            }
296            Err(_) => std::thread::sleep(Duration::from_millis(10)),
297        }
298    }
299}
300
301/// An HTTP/1.1 message head: the start line and its headers, as sent.
302#[derive(Debug, Clone, PartialEq)]
303struct Head {
304    start: String,
305    headers: Vec<(String, String)>,
306}
307
308impl Head {
309    fn header(&self, name: &str) -> Option<&str> {
310        self.headers
311            .iter()
312            .find(|(key, _)| key.eq_ignore_ascii_case(name))
313            .map(|(_, value)| value.as_str())
314    }
315
316    fn without(&mut self, name: &str) {
317        self.headers
318            .retain(|(key, _)| !key.eq_ignore_ascii_case(name));
319    }
320
321    fn set(&mut self, name: &str, value: String) {
322        self.without(name);
323        self.headers.push((name.to_owned(), value));
324    }
325
326    fn chunked(&self) -> bool {
327        self.header("transfer-encoding")
328            .is_some_and(|value| value.to_ascii_lowercase().contains("chunked"))
329    }
330
331    fn content_length(&self) -> Option<u64> {
332        self.header("content-length")?.trim().parse().ok()
333    }
334
335    fn write(&self, out: &mut impl Write) -> io::Result<()> {
336        let mut text = String::with_capacity(256);
337        text.push_str(&self.start);
338        text.push_str("\r\n");
339        for (key, value) in &self.headers {
340            text.push_str(key);
341            text.push_str(": ");
342            text.push_str(value);
343            text.push_str("\r\n");
344        }
345        text.push_str("\r\n");
346        out.write_all(text.as_bytes())
347    }
348}
349
350/// The next message head, or None at a clean end of the stream.
351fn read_head(reader: &mut impl BufRead) -> io::Result<Option<Head>> {
352    let mut start = String::new();
353    loop {
354        start.clear();
355        if reader.read_line(&mut start)? == 0 {
356            return Ok(None);
357        }
358        // Blank lines between messages are tolerated, as RFC 9112 allows.
359        if !start.trim().is_empty() {
360            break;
361        }
362    }
363    let mut headers = Vec::new();
364    loop {
365        let mut line = String::new();
366        if reader.read_line(&mut line)? == 0 {
367            return Err(io::Error::new(
368                io::ErrorKind::UnexpectedEof,
369                "head cut short",
370            ));
371        }
372        let line = line.trim_end_matches(['\r', '\n']);
373        if line.is_empty() {
374            break;
375        }
376        let (key, value) = line
377            .split_once(':')
378            .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "malformed header"))?;
379        headers.push((key.trim().to_owned(), value.trim().to_owned()));
380    }
381    Ok(Some(Head {
382        start: start.trim_end_matches(['\r', '\n']).to_owned(),
383        headers,
384    }))
385}
386
387/// Copy a body framed by `head` from `reader` to `out`, flushing as it goes:
388/// a stream (events, logs, build progress) reaches the client as it comes.
389fn relay_body(head: &Head, reader: &mut impl BufRead, out: &mut impl Write) -> io::Result<()> {
390    if head.chunked() {
391        loop {
392            let mut size_line = String::new();
393            if reader.read_line(&mut size_line)? == 0 {
394                return Err(io::Error::new(
395                    io::ErrorKind::UnexpectedEof,
396                    "chunk cut short",
397                ));
398            }
399            out.write_all(size_line.as_bytes())?;
400            let size =
401                usize::from_str_radix(size_line.trim().split(';').next().unwrap_or("").trim(), 16)
402                    .map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "bad chunk size"))?;
403            if size == 0 {
404                // Trailers, then the blank line that ends the body.
405                loop {
406                    let mut trailer = String::new();
407                    if reader.read_line(&mut trailer)? == 0 {
408                        break;
409                    }
410                    out.write_all(trailer.as_bytes())?;
411                    if trailer.trim().is_empty() {
412                        break;
413                    }
414                }
415                out.flush()?;
416                return Ok(());
417            }
418            copy_exact(reader, out, size as u64 + 2)?;
419            out.flush()?;
420        }
421    } else if let Some(length) = head.content_length() {
422        copy_exact(reader, out, length)?;
423        out.flush()
424    } else {
425        Ok(())
426    }
427}
428
429fn copy_exact(reader: &mut impl Read, out: &mut impl Write, mut remaining: u64) -> io::Result<()> {
430    let mut buffer = [0_u8; 64 * 1024];
431    while remaining > 0 {
432        let want = buffer.len().min(remaining as usize);
433        let read = reader.read(&mut buffer[..want])?;
434        if read == 0 {
435            return Err(io::Error::new(
436                io::ErrorKind::UnexpectedEof,
437                "body cut short",
438            ));
439        }
440        out.write_all(&buffer[..read])?;
441        remaining -= read as u64;
442    }
443    Ok(())
444}
445
446/// A whole body, decoded.
447fn read_body(head: &Head, reader: &mut impl BufRead) -> io::Result<Vec<u8>> {
448    let mut encoded = Vec::new();
449    relay_body(head, reader, &mut encoded)?;
450    if !head.chunked() {
451        return Ok(encoded);
452    }
453    let mut decoded = Vec::new();
454    let mut cursor = io::Cursor::new(encoded);
455    loop {
456        let mut size_line = String::new();
457        cursor.read_line(&mut size_line)?;
458        let size =
459            usize::from_str_radix(size_line.trim().split(';').next().unwrap_or("").trim(), 16)
460                .map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "bad chunk size"))?;
461        if size == 0 {
462            return Ok(decoded);
463        }
464        let mut chunk = vec![0; size + 2];
465        cursor.read_exact(&mut chunk)?;
466        decoded.extend_from_slice(&chunk[..size]);
467    }
468}
469
470/// Whether a response ends with the stream handed over to raw bytes both
471/// ways: `101` for a request that asked to upgrade (`attach`, `exec`,
472/// BuildKit's session), or Docker's raw stream types without a length, the
473/// older hijack.
474fn hijacked(request: &Head, response: &Head) -> bool {
475    let status = response.start.split_whitespace().nth(1).unwrap_or("");
476    let asked = request.header("upgrade").is_some()
477        || request
478            .header("connection")
479            .is_some_and(|value| value.to_ascii_lowercase().contains("upgrade"));
480    if status == "101" && asked {
481        return true;
482    }
483    let raw = response.header("content-type").is_some_and(|value| {
484        value.starts_with("application/vnd.docker.raw-stream")
485            || value.starts_with("application/vnd.docker.multiplexed-stream")
486    });
487    raw && status == "200" && !response.chunked() && response.content_length().is_none()
488}
489
490/// Whether a response carries no body whatever its headers say.
491fn bodiless(request: &Head, response: &Head) -> bool {
492    let status = response.start.split_whitespace().nth(1).unwrap_or("");
493    request.start.starts_with("HEAD ")
494        || status.starts_with('1')
495        || status == "204"
496        || status == "304"
497}
498
499/// The path of a request, past an API version prefix and before the query.
500fn endpoint(head: &Head) -> (&str, &str) {
501    let mut parts = head.start.split_whitespace();
502    let method = parts.next().unwrap_or("");
503    let target = parts.next().unwrap_or("");
504    let path = target.split('?').next().unwrap_or("");
505    let path = match path.strip_prefix("/v") {
506        Some(rest) if rest.starts_with(|c: char| c.is_ascii_digit()) => {
507            rest.find('/').map_or(path, |index| &rest[index..])
508        }
509        _ => path,
510    };
511    (method, path)
512}
513
514fn relay_connection(client: UnixStream, shared: &Shared) -> io::Result<()> {
515    let upstream = UnixStream::connect(&shared.upstream)?;
516    let mut client_reader = BufReader::new(client.try_clone()?);
517    let mut client_writer = client;
518    let mut upstream_reader = BufReader::new(upstream.try_clone()?);
519    let mut upstream_writer = upstream;
520    loop {
521        let Some(mut request) = read_head(&mut client_reader)? else {
522            return Ok(());
523        };
524        let (method, path) = endpoint(&request);
525        let creating = method == "POST" && path == "/containers/create";
526        let mut created_context = None;
527        if creating {
528            let body = read_body(&request, &mut client_reader)?;
529            let (body, context) = match serde_json::from_slice::<Value>(&body) {
530                Ok(config) => {
531                    let (config, context) = shared.instrument(config);
532                    (serde_json::to_vec(&config).unwrap_or(body), Some(context))
533                }
534                Err(_) => (body, None),
535            };
536            created_context = context;
537            request.without("transfer-encoding");
538            request.set("Content-Length", body.len().to_string());
539            request.write(&mut upstream_writer)?;
540            upstream_writer.write_all(&body)?;
541            upstream_writer.flush()?;
542        } else {
543            request.write(&mut upstream_writer)?;
544            upstream_writer.flush()?;
545            if request
546                .header("expect")
547                .is_some_and(|value| value.eq_ignore_ascii_case("100-continue"))
548            {
549                // The daemon answers `100 Continue` (or refuses) before the
550                // body is sent.
551                let Some(interim) = read_head(&mut upstream_reader)? else {
552                    return Ok(());
553                };
554                interim.write(&mut client_writer)?;
555                client_writer.flush()?;
556                if !interim.start.contains(" 100") {
557                    if !bodiless(&request, &interim) {
558                        relay_body(&interim, &mut upstream_reader, &mut client_writer)?;
559                    }
560                    continue;
561                }
562            }
563            relay_body(&request, &mut client_reader, &mut upstream_writer)?;
564        }
565        let Some(response) = read_head(&mut upstream_reader)? else {
566            return Ok(());
567        };
568        response.write(&mut client_writer)?;
569        client_writer.flush()?;
570        if hijacked(&request, &response) {
571            return splice(
572                client_reader,
573                client_writer,
574                upstream_reader,
575                upstream_writer,
576            );
577        }
578        if bodiless(&request, &response) {
579            continue;
580        }
581        if let Some(context) = created_context {
582            let body = read_body(&response, &mut upstream_reader)?;
583            // Sent as it came: chunked re-framed as one chunk.
584            if response.chunked() {
585                write!(client_writer, "{:x}\r\n", body.len())?;
586                client_writer.write_all(&body)?;
587                client_writer.write_all(b"\r\n0\r\n\r\n")?;
588            } else {
589                client_writer.write_all(&body)?;
590            }
591            client_writer.flush()?;
592            if let Ok(created) = serde_json::from_slice::<Value>(&body)
593                && let Some(id) = created.get("Id").and_then(Value::as_str)
594            {
595                shared.record(id, context);
596            }
597            continue;
598        }
599        if !response.chunked() && response.content_length().is_none() {
600            // Delimited by the connection closing.
601            io::copy(&mut upstream_reader, &mut client_writer)?;
602            return Ok(());
603        }
604        relay_body(&response, &mut upstream_reader, &mut client_writer)?;
605    }
606}
607
608/// Raw bytes both ways until either side closes: what was already read into
609/// a reader's buffer goes first.
610fn splice(
611    mut client_reader: BufReader<UnixStream>,
612    mut client_writer: UnixStream,
613    mut upstream_reader: BufReader<UnixStream>,
614    mut upstream_writer: UnixStream,
615) -> io::Result<()> {
616    let buffered = client_reader.buffer().to_vec();
617    client_reader.consume(buffered.len());
618    upstream_writer.write_all(&buffered)?;
619    let to_daemon = std::thread::spawn(move || {
620        let _ = io::copy(&mut client_reader, &mut upstream_writer);
621        let _ = upstream_writer.shutdown(std::net::Shutdown::Write);
622    });
623    let _ = io::copy(&mut upstream_reader, &mut client_writer);
624    let _ = client_writer.shutdown(std::net::Shutdown::Write);
625    let _ = to_daemon.join();
626    Ok(())
627}
628
629/// One request to the daemon, made by the relay itself: the body of the
630/// answer when it succeeded.
631fn daemon_request(socket: &Path, method: &str, path: &str, body: Option<&[u8]>) -> Option<Vec<u8>> {
632    let mut stream = UnixStream::connect(socket).ok()?;
633    stream
634        .set_read_timeout(Some(Duration::from_secs(60)))
635        .ok()?;
636    let body = body.unwrap_or_default();
637    write!(
638        stream,
639        "{method} {path} HTTP/1.1\r\nHost: docker\r\nConnection: close\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n",
640        body.len()
641    )
642    .ok()?;
643    stream.write_all(body).ok()?;
644    let mut reader = BufReader::new(stream);
645    let head = read_head(&mut reader).ok()??;
646    let status = head.start.split_whitespace().nth(1).unwrap_or("");
647    if !status.starts_with('2') {
648        return None;
649    }
650    if head.chunked() || head.content_length().is_some() {
651        read_body(&head, &mut reader).ok()
652    } else {
653        let mut rest = Vec::new();
654        reader.read_to_end(&mut rest).ok()?;
655        Some(rest)
656    }
657}
658
659/// What deciding about one container found, kept until it is created.
660#[derive(Debug, Clone, PartialEq)]
661struct CreateContext {
662    measured: bool,
663    image: String,
664    project: Option<String>,
665    service: Option<String>,
666    /// Why it was left alone, when it was.
667    note: Option<String>,
668}
669
670impl Shared {
671    /// A create request's configuration, with the run's settings when its
672    /// image holds the copy's code.
673    fn instrument(&self, mut config: Value) -> (Value, CreateContext) {
674        let image = config
675            .get("Image")
676            .and_then(Value::as_str)
677            .unwrap_or("")
678            .to_owned();
679        let labels = config.get("Labels").cloned().unwrap_or(Value::Null);
680        let label = |name: &str| labels.get(name).and_then(Value::as_str).map(str::to_owned);
681        let project = label("com.docker.compose.project");
682        let service = label("com.docker.compose.service");
683        self.rewrite_socket_mounts(&mut config);
684        let mut context = CreateContext {
685            measured: false,
686            image: image.clone(),
687            project: project.clone(),
688            service: service.clone(),
689            note: None,
690        };
691        // A compose service built from the copy is built from it now, so the
692        // container holds the copy as it is.
693        let compose_built = match (&project, &service) {
694            (Some(project), Some(service)) => self.build_compose_service(
695                project,
696                service,
697                label("com.docker.compose.project.config_files"),
698                label("com.docker.compose.project.working_dir"),
699            ),
700            _ => None,
701        };
702        let Some(inspected) = daemon_request(
703            &self.upstream,
704            "GET",
705            &format!("/images/{}/json", url_path_segment(&image)),
706            None,
707        )
708        .and_then(|body| serde_json::from_slice::<Value>(&body).ok()) else {
709            return (config, context);
710        };
711        let id = inspected
712            .get("Id")
713            .and_then(Value::as_str)
714            .unwrap_or("")
715            .to_owned();
716        let created_ms = inspected
717            .get("Created")
718            .and_then(Value::as_str)
719            .and_then(parse_rfc3339_ms)
720            .unwrap_or(0);
721        let mut registry = read_registry(&self.settings.registry);
722        let decision = if compose_built == Some(true) || created_ms >= self.started_ms {
723            registry.insert(id.clone(), self.settings.fingerprint.clone());
724            write_registry(&self.settings.registry, &registry);
725            Some(true)
726        } else if let Some(fingerprint) = registry.get(&id) {
727            if *fingerprint == self.settings.fingerprint {
728                Some(true)
729            } else {
730                context.note = Some(format!(
731                    "image {image} was built from an earlier copy of the project; build it in the test command to measure it"
732                ));
733                Some(false)
734            }
735        } else {
736            None
737        };
738        if decision != Some(true) {
739            return (config, context);
740        }
741        let image_environment = inspected
742            .get("Config")
743            .and_then(|config| config.get("Env"))
744            .and_then(Value::as_array)
745            .map(|values| {
746                values
747                    .iter()
748                    .filter_map(Value::as_str)
749                    .filter_map(|entry| entry.split_once('='))
750                    .map(|(key, value)| (key.to_owned(), value.to_owned()))
751                    .collect::<BTreeMap<_, _>>()
752            })
753            .unwrap_or_default();
754        add_settings(&mut config, &self.settings, &image_environment);
755        context.measured = true;
756        (config, context)
757    }
758
759    /// A bind of the relay's own socket is a bind of the daemon's: inside a
760    /// VM the relay's path is no socket at all.
761    fn rewrite_socket_mounts(&self, config: &mut Value) {
762        let ours = self.socket.display().to_string();
763        let theirs = daemon_side_socket(&self.upstream).display().to_string();
764        let Some(host) = config.get_mut("HostConfig").and_then(Value::as_object_mut) else {
765            return;
766        };
767        if let Some(binds) = host.get_mut("Binds").and_then(Value::as_array_mut) {
768            for bind in binds.iter_mut() {
769                if let Some(text) = bind.as_str()
770                    && let Some(rest) = text.strip_prefix(&ours)
771                    && rest.starts_with(':')
772                {
773                    *bind = json!(format!("{theirs}{rest}"));
774                }
775            }
776        }
777        if let Some(mounts) = host.get_mut("Mounts").and_then(Value::as_array_mut) {
778            for mount in mounts.iter_mut() {
779                if mount.get("Source").and_then(Value::as_str) == Some(ours.as_str()) {
780                    mount["Source"] = json!(theirs);
781                }
782            }
783        }
784    }
785
786    /// Build a compose service from the copy when its build context is in
787    /// it. Some(true) when it was built (now or earlier in this run), Some
788    /// (false) when it builds from somewhere else, None when compose could
789    /// not say.
790    fn build_compose_service(
791        &self,
792        project: &str,
793        service: &str,
794        config_files: Option<String>,
795        working_dir: Option<String>,
796    ) -> Option<bool> {
797        if self
798            .built
799            .lock()
800            .is_ok_and(|built| built.contains(&(project.to_owned(), service.to_owned())))
801        {
802            return Some(true);
803        }
804        let mut arguments = vec!["compose".to_owned(), "-p".into(), project.to_owned()];
805        for file in config_files.iter().flat_map(|files| files.split(',')) {
806            if !file.is_empty() {
807                arguments.extend(["-f".into(), file.to_owned()]);
808            }
809        }
810        if let Some(directory) = &working_dir {
811            arguments.extend(["--project-directory".into(), directory.clone()]);
812        }
813        let mut config = Command::new("docker");
814        config
815            .args(&arguments)
816            .args(["config", "--format", "json"])
817            .env("DOCKER_HOST", format!("unix://{}", self.upstream.display()))
818            .stdin(Stdio::null())
819            .stderr(Stdio::null());
820        let output = config.output().ok()?;
821        if !output.status.success() {
822            return None;
823        }
824        let resolved = serde_json::from_slice::<Value>(&output.stdout).ok()?;
825        let context = resolved["services"][service]["build"]["context"].as_str()?;
826        if !Path::new(context).starts_with(&self.settings.copy) {
827            return Some(false);
828        }
829        let built = Command::new("docker")
830            .args(&arguments)
831            .args(["build", service])
832            .env("DOCKER_HOST", format!("unix://{}", self.upstream.display()))
833            .stdin(Stdio::null())
834            .stdout(Stdio::null())
835            .stderr(Stdio::null())
836            .status()
837            .ok()?
838            .success();
839        if built && let Ok(mut done) = self.built.lock() {
840            done.insert((project.to_owned(), service.to_owned()));
841        }
842        Some(built)
843    }
844
845    fn record(&self, id: &str, context: CreateContext) {
846        if let Ok(mut created) = self.created.lock() {
847            created.push(CreatedContainer {
848                id: id.to_owned(),
849                measured: context.measured,
850                project: context.project.clone(),
851            });
852        }
853        let line = json!({
854            "id": id,
855            "image": context.image,
856            "measured": context.measured,
857            "project": context.project,
858            "service": context.service,
859            "note": context.note,
860        });
861        if let Ok(mut file) = fs::OpenOptions::new()
862            .create(true)
863            .append(true)
864            .open(&self.settings.state)
865        {
866            let _ = writeln!(file, "{line}");
867        }
868    }
869}
870
871/// A measured container's settings: the run's variables, the mount, and a
872/// Python or Ruby runtime in front of what the image itself sets.
873fn add_settings(
874    config: &mut Value,
875    settings: &ProxySettings,
876    image_environment: &BTreeMap<String, String>,
877) {
878    let mut environment = config
879        .get("Env")
880        .and_then(Value::as_array)
881        .map(|values| {
882            values
883                .iter()
884                .filter_map(Value::as_str)
885                .map(str::to_owned)
886                .collect::<Vec<_>>()
887        })
888        .unwrap_or_default();
889    let own = |environment: &Vec<String>, name: &str| {
890        environment
891            .iter()
892            .any(|entry| entry.split_once('=').is_some_and(|(key, _)| key == name))
893    };
894    for (name, value) in &settings.environment {
895        let value = match name.as_str() {
896            "PYTHONPATH" | "RUBYOPT" => {
897                joined_with_image(name, value, image_environment.get(name).map(String::as_str))
898            }
899            _ => value.clone(),
900        };
901        environment.retain(|entry| entry.split_once('=').is_none_or(|(key, _)| key != name));
902        environment.push(format!("{name}={value}"));
903    }
904    environment.retain(|entry| {
905        entry
906            .split_once('=')
907            .is_none_or(|(key, _)| key != CONTAINER_VARIABLE)
908    });
909    environment.push(format!("{CONTAINER_VARIABLE}=1"));
910    // Python writes the bytecode of Supercov's runtime beside it, in the
911    // mounted directory, as the container's root: the run could not remove
912    // it. A container's own setting stays.
913    if !own(&environment, "PYTHONPYCACHEPREFIX")
914        && !image_environment.contains_key("PYTHONPYCACHEPREFIX")
915    {
916        environment.push("PYTHONPYCACHEPREFIX=/tmp/supercov-pycache".into());
917    }
918    config["Env"] = json!(environment);
919    let mount = settings.mount.display().to_string();
920    if !config.get("HostConfig").is_some_and(Value::is_object) {
921        config["HostConfig"] = json!({});
922    }
923    let host = &mut config["HostConfig"];
924    if !host.get("Binds").is_some_and(Value::is_array) {
925        host["Binds"] = json!([]);
926    }
927    if let Some(binds) = host["Binds"].as_array_mut() {
928        binds.push(json!(format!("{mount}:{mount}")));
929    }
930}
931
932/// What a variable a runtime needs is in a container whose image sets it
933/// too: the runtime's part of ours first, then the image's. Ours was made
934/// for the run's own processes and carries the host's value after it.
935fn joined_with_image(name: &str, ours: &str, image: Option<&str>) -> String {
936    let (runtime, separator) = match name {
937        // `<runtime>:<host's PYTHONPATH>`.
938        "PYTHONPATH" => (ours.split(':').next().unwrap_or(ours).to_owned(), ":"),
939        // `-r<runtime> <host's RUBYOPT>`.
940        _ => (
941            ours.split_whitespace()
942                .filter(|option| option.contains("supercov"))
943                .collect::<Vec<_>>()
944                .join(" "),
945            " ",
946        ),
947    };
948    match image.filter(|value| !value.is_empty()) {
949        Some(image) => format!("{runtime}{separator}{image}"),
950        None => runtime,
951    }
952}
953
954/// An image name as one segment of a request path: `/` stays (the daemon
955/// takes `repo/name:tag` as written), the rest of the reserved set does not.
956fn url_path_segment(name: &str) -> String {
957    let mut out = String::with_capacity(name.len());
958    for byte in name.bytes() {
959        if byte.is_ascii_alphanumeric() || b"-._~/:@".contains(&byte) {
960            out.push(byte as char);
961        } else {
962            out.push_str(&format!("%{byte:02X}"));
963        }
964    }
965    out
966}
967
968/// Milliseconds since the epoch of an RFC 3339 time the daemon prints:
969/// `2026-10-17T21:05:37.497328719Z` or with an offset.
970fn parse_rfc3339_ms(text: &str) -> Option<i64> {
971    let (date, rest) = text.split_once('T')?;
972    let mut date_parts = date.split('-');
973    let year: i64 = date_parts.next()?.parse().ok()?;
974    let month: i64 = date_parts.next()?.parse().ok()?;
975    let day: i64 = date_parts.next()?.parse().ok()?;
976    let (time, offset_seconds) = if let Some(time) = rest.strip_suffix('Z') {
977        (time, 0)
978    } else {
979        let index = rest.rfind(['+', '-'])?;
980        let (time, offset) = rest.split_at(index);
981        let sign = if offset.starts_with('-') { -1 } else { 1 };
982        let mut parts = offset[1..].split(':');
983        let hours: i64 = parts.next()?.parse().ok()?;
984        let minutes: i64 = parts.next().unwrap_or("0").parse().ok()?;
985        (time, sign * (hours * 3600 + minutes * 60))
986    };
987    let (clock, fraction) = time.split_once('.').unwrap_or((time, "0"));
988    let mut clock_parts = clock.split(':');
989    let hour: i64 = clock_parts.next()?.parse().ok()?;
990    let minute: i64 = clock_parts.next()?.parse().ok()?;
991    let second: i64 = clock_parts.next()?.parse().ok()?;
992    let millis: i64 = format!("{fraction:0<3}")[..3].parse().ok()?;
993    // Days from the civil date (Howard Hinnant's algorithm).
994    let year = if month <= 2 { year - 1 } else { year };
995    let era = year.div_euclid(400);
996    let year_of_era = year - era * 400;
997    let month_index = (month + 9) % 12;
998    let day_of_year = (153 * month_index + 2) / 5 + day - 1;
999    let day_of_era = year_of_era * 365 + year_of_era / 4 - year_of_era / 100 + day_of_year;
1000    let days = era * 146_097 + day_of_era - 719_468;
1001    Some(((days * 86_400 + hour * 3600 + minute * 60 + second - offset_seconds) * 1000) + millis)
1002}
1003
1004fn read_registry(path: &Path) -> BTreeMap<String, String> {
1005    fs::read(path)
1006        .ok()
1007        .and_then(|bytes| serde_json::from_slice(&bytes).ok())
1008        .unwrap_or_default()
1009}
1010
1011fn write_registry(path: &Path, registry: &BTreeMap<String, String>) {
1012    if let Some(parent) = path.parent() {
1013        let _ = fs::create_dir_all(parent);
1014    }
1015    if let Ok(bytes) = serde_json::to_vec_pretty(registry) {
1016        let temporary = path.with_extension("json.tmp");
1017        if fs::write(&temporary, bytes).is_ok() {
1018            let _ = fs::rename(temporary, path);
1019        }
1020    }
1021}
1022
1023/// The notes the relay recorded about containers it left alone.
1024pub fn notes(state: &Path) -> Vec<String> {
1025    let Ok(text) = fs::read_to_string(state) else {
1026        return Vec::new();
1027    };
1028    let mut notes = text
1029        .lines()
1030        .filter_map(|line| serde_json::from_str::<Value>(line).ok())
1031        .filter_map(|line| line.get("note").and_then(Value::as_str).map(str::to_owned))
1032        .collect::<Vec<_>>();
1033    notes.sort();
1034    notes.dedup();
1035    notes
1036}
1037
1038#[cfg(test)]
1039mod tests {
1040    use super::*;
1041    use std::sync::mpsc;
1042
1043    fn temporary(name: &str) -> PathBuf {
1044        // Short: a socket address has room for about a hundred bytes.
1045        let path = PathBuf::from("/tmp").join(format!(
1046            "scv-dp-{}-{}-{name}",
1047            std::process::id(),
1048            SystemTime::now()
1049                .duration_since(UNIX_EPOCH)
1050                .unwrap()
1051                .as_nanos()
1052                % 1_000_000_000
1053        ));
1054        fs::create_dir_all(&path).unwrap();
1055        path
1056    }
1057
1058    /// A daemon that answers what these tests ask, and reports each
1059    /// container configuration it was asked to create.
1060    fn fake_daemon(socket: &Path) -> mpsc::Receiver<Value> {
1061        let listener = UnixListener::bind(socket).unwrap();
1062        let (sender, receiver) = mpsc::channel();
1063        std::thread::spawn(move || {
1064            for stream in listener.incoming() {
1065                let Ok(stream) = stream else { break };
1066                let sender = sender.clone();
1067                std::thread::spawn(move || {
1068                    let mut reader = BufReader::new(stream.try_clone().unwrap());
1069                    let mut writer = stream;
1070                    while let Ok(Some(head)) = read_head(&mut reader) {
1071                        let body = read_body(&head, &mut reader).unwrap_or_default();
1072                        let (method, path) = endpoint(&head);
1073                        let path = path.to_owned();
1074                        let respond = |writer: &mut UnixStream, status: &str, body: &str| {
1075                            write!(
1076                                writer,
1077                                "HTTP/1.1 {status}\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{body}",
1078                                body.len()
1079                            )
1080                            .unwrap();
1081                        };
1082                        if path.starts_with("/images/") {
1083                            // Built during the run, pulled long ago, or
1084                            // built by an earlier run.
1085                            let (id, created) = if path.contains("pulled") {
1086                                ("sha256:pulled", "2020-01-01T00:00:00Z")
1087                            } else if path.contains("stale") {
1088                                ("sha256:stale", "2020-01-01T00:00:00Z")
1089                            } else {
1090                                ("sha256:fresh", "2099-01-01T00:00:00.000Z")
1091                            };
1092                            respond(
1093                                &mut writer,
1094                                "200 OK",
1095                                &json!({"Id": id, "Created": created, "Config": {"Env": ["PYTHONPATH=/srv/app"]}}).to_string(),
1096                            );
1097                        } else if method == "POST" && path == "/containers/create" {
1098                            sender.send(serde_json::from_slice(&body).unwrap()).unwrap();
1099                            respond(
1100                                &mut writer,
1101                                "201 Created",
1102                                r#"{"Id":"c0ffee","Warnings":[]}"#,
1103                            );
1104                        } else if path == "/events" {
1105                            writer
1106                                .write_all(b"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n")
1107                                .unwrap();
1108                            for event in ["first", "second"] {
1109                                write!(writer, "{:x}\r\n{event}\r\n", event.len()).unwrap();
1110                                writer.flush().unwrap();
1111                            }
1112                            writer.write_all(b"0\r\n\r\n").unwrap();
1113                        } else if path.ends_with("/attach") {
1114                            writer
1115                                .write_all(b"HTTP/1.1 101 UPGRADED\r\nConnection: Upgrade\r\nUpgrade: tcp\r\n\r\n")
1116                                .unwrap();
1117                            // Echo raw bytes until the client closes.
1118                            let mut buffer = [0_u8; 64];
1119                            loop {
1120                                let read = reader.read(&mut buffer).unwrap_or(0);
1121                                if read == 0 {
1122                                    break;
1123                                }
1124                                writer.write_all(&buffer[..read]).unwrap();
1125                            }
1126                            return;
1127                        } else {
1128                            respond(&mut writer, "200 OK", "OK");
1129                        }
1130                    }
1131                });
1132            }
1133        });
1134        receiver
1135    }
1136
1137    fn settings(directory: &Path) -> ProxySettings {
1138        ProxySettings {
1139            copy: directory.join("copy"),
1140            mount: directory.join("mount"),
1141            environment: vec![
1142                ("SUPERCOV_RUN".into(), "run_1".into()),
1143                ("PYTHONPATH".into(), "/runtime/python:/host/path".into()),
1144            ],
1145            fingerprint: "source-a".into(),
1146            registry: directory.join("registry.json"),
1147            state: directory.join("state/containers.jsonl"),
1148        }
1149    }
1150
1151    fn exchange(
1152        stream: &mut UnixStream,
1153        reader: &mut BufReader<UnixStream>,
1154        request: &str,
1155    ) -> (Head, Vec<u8>) {
1156        stream.write_all(request.as_bytes()).unwrap();
1157        let head = read_head(reader).unwrap().unwrap();
1158        let body = read_body(&head, reader).unwrap();
1159        (head, body)
1160    }
1161
1162    #[test]
1163    fn requests_pass_through_and_a_created_container_from_the_run_gets_the_settings() {
1164        let directory = temporary("relay");
1165        let daemon = directory.join("d.sock");
1166        let created = fake_daemon(&daemon);
1167        let proxy =
1168            DockerProxy::start(&directory.join("p.sock"), &daemon, settings(&directory)).unwrap();
1169        let mut stream = UnixStream::connect(directory.join("p.sock")).unwrap();
1170        let mut reader = BufReader::new(stream.try_clone().unwrap());
1171
1172        // Plain requests on one kept-alive connection.
1173        let (head, body) = exchange(
1174            &mut stream,
1175            &mut reader,
1176            "GET /_ping HTTP/1.1\r\nHost: docker\r\n\r\n",
1177        );
1178        assert!(head.start.contains("200"), "{head:?}");
1179        assert_eq!(body, b"OK");
1180        // A stream arrives as it comes, framed as sent.
1181        stream
1182            .write_all(b"GET /v1.47/events HTTP/1.1\r\nHost: docker\r\n\r\n")
1183            .unwrap();
1184        let head = read_head(&mut reader).unwrap().unwrap();
1185        assert!(head.chunked());
1186        assert_eq!(read_body(&head, &mut reader).unwrap(), b"firstsecond");
1187
1188        // Created from an image the run built: settings, a joined
1189        // PYTHONPATH, the mount, and a bind of the relay's socket moved to
1190        // the daemon's.
1191        let socket = directory.join("p.sock").display().to_string();
1192        let config = json!({
1193            "Image": "app-api",
1194            "Env": ["MODE=test"],
1195            "HostConfig": {"Binds": [format!("{socket}:/var/run/docker.sock")]},
1196        })
1197        .to_string();
1198        let (head, body) = exchange(
1199            &mut stream,
1200            &mut reader,
1201            &format!(
1202                "POST /v1.47/containers/create?name=api HTTP/1.1\r\nHost: docker\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{config}",
1203                config.len()
1204            ),
1205        );
1206        assert!(head.start.contains("201"), "{head:?}");
1207        assert_eq!(
1208            serde_json::from_slice::<Value>(&body).unwrap()["Id"],
1209            "c0ffee"
1210        );
1211        let sent = created.recv_timeout(Duration::from_secs(5)).unwrap();
1212        let environment = sent["Env"]
1213            .as_array()
1214            .unwrap()
1215            .iter()
1216            .map(|value| value.as_str().unwrap().to_owned())
1217            .collect::<Vec<_>>();
1218        assert!(
1219            environment.contains(&"MODE=test".to_owned()),
1220            "{environment:?}"
1221        );
1222        assert!(
1223            environment.contains(&"SUPERCOV_RUN=run_1".to_owned()),
1224            "{environment:?}"
1225        );
1226        assert!(
1227            environment.contains(&"PYTHONPATH=/runtime/python:/srv/app".to_owned()),
1228            "{environment:?}"
1229        );
1230        assert!(
1231            environment.contains(&"SUPERCOV_CONTAINER=1".to_owned()),
1232            "{environment:?}"
1233        );
1234        let binds = sent["HostConfig"]["Binds"].as_array().unwrap();
1235        let mount = directory.join("mount").display().to_string();
1236        assert!(
1237            binds.contains(&json!(format!("{mount}:{mount}"))),
1238            "{binds:?}"
1239        );
1240        assert!(
1241            binds.contains(&json!(format!(
1242                "{}:/var/run/docker.sock",
1243                daemon_side_socket(&daemon).display()
1244            ))),
1245            "{binds:?}"
1246        );
1247        assert_eq!(
1248            read_registry(&directory.join("registry.json"))
1249                .get("sha256:fresh")
1250                .map(String::as_str),
1251            Some("source-a")
1252        );
1253
1254        // A pulled image is the image's own: nothing added.
1255        let config = json!({"Image": "pulled/postgres:16"}).to_string();
1256        exchange(
1257            &mut stream,
1258            &mut reader,
1259            &format!(
1260                "POST /containers/create HTTP/1.1\r\nHost: docker\r\nContent-Length: {}\r\n\r\n{config}",
1261                config.len()
1262            ),
1263        );
1264        let sent = created.recv_timeout(Duration::from_secs(5)).unwrap();
1265        assert!(sent.get("Env").is_none(), "{sent}");
1266
1267        // An upgraded stream is spliced both ways.
1268        let mut attach = UnixStream::connect(directory.join("p.sock")).unwrap();
1269        attach
1270            .write_all(b"POST /containers/c0ffee/attach?stream=1 HTTP/1.1\r\nHost: docker\r\nConnection: Upgrade\r\nUpgrade: tcp\r\nContent-Length: 0\r\n\r\n")
1271            .unwrap();
1272        let mut attach_reader = BufReader::new(attach.try_clone().unwrap());
1273        let head = read_head(&mut attach_reader).unwrap().unwrap();
1274        assert!(head.start.contains("101"), "{head:?}");
1275        attach.write_all(b"ping").unwrap();
1276        let mut echoed = [0_u8; 4];
1277        attach_reader.read_exact(&mut echoed).unwrap();
1278        assert_eq!(&echoed, b"ping");
1279        drop(attach);
1280        drop(stream);
1281        proxy.finish();
1282        let state = fs::read_to_string(directory.join("state/containers.jsonl")).unwrap();
1283        assert_eq!(state.lines().count(), 2, "{state}");
1284        fs::remove_dir_all(directory).unwrap();
1285    }
1286
1287    #[test]
1288    fn an_image_built_from_an_earlier_copy_is_left_alone_and_said_to_be() {
1289        let directory = temporary("stale");
1290        let daemon = directory.join("d.sock");
1291        let created = fake_daemon(&daemon);
1292        write_registry(
1293            &directory.join("registry.json"),
1294            &BTreeMap::from([("sha256:stale".to_owned(), "source-old".to_owned())]),
1295        );
1296        let proxy =
1297            DockerProxy::start(&directory.join("p.sock"), &daemon, settings(&directory)).unwrap();
1298        let mut stream = UnixStream::connect(directory.join("p.sock")).unwrap();
1299        let mut reader = BufReader::new(stream.try_clone().unwrap());
1300        let config = json!({"Image": "stale-app"}).to_string();
1301        exchange(
1302            &mut stream,
1303            &mut reader,
1304            &format!(
1305                "POST /containers/create HTTP/1.1\r\nHost: docker\r\nContent-Length: {}\r\n\r\n{config}",
1306                config.len()
1307            ),
1308        );
1309        let sent = created.recv_timeout(Duration::from_secs(5)).unwrap();
1310        assert!(sent.get("Env").is_none(), "{sent}");
1311        drop(stream);
1312        proxy.finish();
1313        let notes = notes(&directory.join("state/containers.jsonl"));
1314        assert_eq!(notes.len(), 1, "{notes:?}");
1315        assert!(notes[0].contains("earlier copy"), "{notes:?}");
1316        fs::remove_dir_all(directory).unwrap();
1317    }
1318
1319    #[test]
1320    fn times_the_daemon_prints_are_read_to_the_millisecond() {
1321        assert_eq!(parse_rfc3339_ms("1970-01-01T00:00:00Z"), Some(0));
1322        assert_eq!(parse_rfc3339_ms("1970-01-01T00:00:01.5Z"), Some(1500));
1323        assert_eq!(
1324            parse_rfc3339_ms("2026-10-17T21:05:37.497328719Z"),
1325            Some(1_792_271_137_497)
1326        );
1327        assert_eq!(
1328            parse_rfc3339_ms("2026-10-17T23:05:37.497+02:00"),
1329            Some(1_792_271_137_497)
1330        );
1331        assert_eq!(parse_rfc3339_ms("not a time"), None);
1332    }
1333
1334    #[test]
1335    fn an_api_version_prefix_and_query_are_not_part_of_the_endpoint() {
1336        let head = |start: &str| Head {
1337            start: start.into(),
1338            headers: Vec::new(),
1339        };
1340        assert_eq!(
1341            endpoint(&head("POST /v1.47/containers/create?name=x HTTP/1.1")),
1342            ("POST", "/containers/create")
1343        );
1344        assert_eq!(
1345            endpoint(&head("POST /containers/create HTTP/1.1")),
1346            ("POST", "/containers/create")
1347        );
1348        assert_eq!(
1349            endpoint(&head("GET /volumes/v1/x HTTP/1.1")),
1350            ("GET", "/volumes/v1/x")
1351        );
1352    }
1353}