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            // Recorded before the client hears of it: a client that has its
584            // answer may end the run, and the run's end reads the record.
585            if let Ok(created) = serde_json::from_slice::<Value>(&body)
586                && let Some(id) = created.get("Id").and_then(Value::as_str)
587            {
588                shared.record(id, context);
589            }
590            // Sent as it came: chunked re-framed as one chunk.
591            if response.chunked() {
592                write!(client_writer, "{:x}\r\n", body.len())?;
593                client_writer.write_all(&body)?;
594                client_writer.write_all(b"\r\n0\r\n\r\n")?;
595            } else {
596                client_writer.write_all(&body)?;
597            }
598            client_writer.flush()?;
599            continue;
600        }
601        if !response.chunked() && response.content_length().is_none() {
602            // Delimited by the connection closing.
603            io::copy(&mut upstream_reader, &mut client_writer)?;
604            return Ok(());
605        }
606        relay_body(&response, &mut upstream_reader, &mut client_writer)?;
607    }
608}
609
610/// Raw bytes both ways until either side closes: what was already read into
611/// a reader's buffer goes first.
612fn splice(
613    mut client_reader: BufReader<UnixStream>,
614    mut client_writer: UnixStream,
615    mut upstream_reader: BufReader<UnixStream>,
616    mut upstream_writer: UnixStream,
617) -> io::Result<()> {
618    let buffered = client_reader.buffer().to_vec();
619    client_reader.consume(buffered.len());
620    upstream_writer.write_all(&buffered)?;
621    let to_daemon = std::thread::spawn(move || {
622        let _ = io::copy(&mut client_reader, &mut upstream_writer);
623        let _ = upstream_writer.shutdown(std::net::Shutdown::Write);
624    });
625    let _ = io::copy(&mut upstream_reader, &mut client_writer);
626    let _ = client_writer.shutdown(std::net::Shutdown::Write);
627    let _ = to_daemon.join();
628    Ok(())
629}
630
631/// One request to the daemon, made by the relay itself: the body of the
632/// answer when it succeeded.
633fn daemon_request(socket: &Path, method: &str, path: &str, body: Option<&[u8]>) -> Option<Vec<u8>> {
634    let mut stream = UnixStream::connect(socket).ok()?;
635    stream
636        .set_read_timeout(Some(Duration::from_secs(60)))
637        .ok()?;
638    let body = body.unwrap_or_default();
639    write!(
640        stream,
641        "{method} {path} HTTP/1.1\r\nHost: docker\r\nConnection: close\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n",
642        body.len()
643    )
644    .ok()?;
645    stream.write_all(body).ok()?;
646    let mut reader = BufReader::new(stream);
647    let head = read_head(&mut reader).ok()??;
648    let status = head.start.split_whitespace().nth(1).unwrap_or("");
649    if !status.starts_with('2') {
650        return None;
651    }
652    if head.chunked() || head.content_length().is_some() {
653        read_body(&head, &mut reader).ok()
654    } else {
655        let mut rest = Vec::new();
656        reader.read_to_end(&mut rest).ok()?;
657        Some(rest)
658    }
659}
660
661/// What deciding about one container found, kept until it is created.
662#[derive(Debug, Clone, PartialEq)]
663struct CreateContext {
664    measured: bool,
665    image: String,
666    project: Option<String>,
667    service: Option<String>,
668    /// Why it was left alone, when it was.
669    note: Option<String>,
670}
671
672impl Shared {
673    /// A create request's configuration, with the run's settings when its
674    /// image holds the copy's code.
675    fn instrument(&self, mut config: Value) -> (Value, CreateContext) {
676        let image = config
677            .get("Image")
678            .and_then(Value::as_str)
679            .unwrap_or("")
680            .to_owned();
681        let labels = config.get("Labels").cloned().unwrap_or(Value::Null);
682        let label = |name: &str| labels.get(name).and_then(Value::as_str).map(str::to_owned);
683        let project = label("com.docker.compose.project");
684        let service = label("com.docker.compose.service");
685        self.rewrite_socket_mounts(&mut config);
686        let mut context = CreateContext {
687            measured: false,
688            image: image.clone(),
689            project: project.clone(),
690            service: service.clone(),
691            note: None,
692        };
693        // A compose service built from the copy is built from it now, so the
694        // container holds the copy as it is.
695        let compose_built = match (&project, &service) {
696            (Some(project), Some(service)) => self.build_compose_service(
697                project,
698                service,
699                label("com.docker.compose.project.config_files"),
700                label("com.docker.compose.project.working_dir"),
701            ),
702            _ => None,
703        };
704        let Some(inspected) = daemon_request(
705            &self.upstream,
706            "GET",
707            &format!("/images/{}/json", url_path_segment(&image)),
708            None,
709        )
710        .and_then(|body| serde_json::from_slice::<Value>(&body).ok()) else {
711            return (config, context);
712        };
713        let id = inspected
714            .get("Id")
715            .and_then(Value::as_str)
716            .unwrap_or("")
717            .to_owned();
718        let created_ms = inspected
719            .get("Created")
720            .and_then(Value::as_str)
721            .and_then(parse_rfc3339_ms)
722            .unwrap_or(0);
723        let mut registry = read_registry(&self.settings.registry);
724        let decision = if compose_built == Some(true) || created_ms >= self.started_ms {
725            registry.insert(id.clone(), self.settings.fingerprint.clone());
726            write_registry(&self.settings.registry, &registry);
727            Some(true)
728        } else if let Some(fingerprint) = registry.get(&id) {
729            if *fingerprint == self.settings.fingerprint {
730                Some(true)
731            } else {
732                context.note = Some(format!(
733                    "image {image} was built from an earlier copy of the project; build it in the test command to measure it"
734                ));
735                Some(false)
736            }
737        } else {
738            None
739        };
740        if decision != Some(true) {
741            return (config, context);
742        }
743        let image_environment = inspected
744            .get("Config")
745            .and_then(|config| config.get("Env"))
746            .and_then(Value::as_array)
747            .map(|values| {
748                values
749                    .iter()
750                    .filter_map(Value::as_str)
751                    .filter_map(|entry| entry.split_once('='))
752                    .map(|(key, value)| (key.to_owned(), value.to_owned()))
753                    .collect::<BTreeMap<_, _>>()
754            })
755            .unwrap_or_default();
756        add_settings(&mut config, &self.settings, &image_environment);
757        context.measured = true;
758        (config, context)
759    }
760
761    /// A bind of the relay's own socket is a bind of the daemon's: inside a
762    /// VM the relay's path is no socket at all.
763    fn rewrite_socket_mounts(&self, config: &mut Value) {
764        let ours = self.socket.display().to_string();
765        let theirs = daemon_side_socket(&self.upstream).display().to_string();
766        let Some(host) = config.get_mut("HostConfig").and_then(Value::as_object_mut) else {
767            return;
768        };
769        if let Some(binds) = host.get_mut("Binds").and_then(Value::as_array_mut) {
770            for bind in binds.iter_mut() {
771                if let Some(text) = bind.as_str()
772                    && let Some(rest) = text.strip_prefix(&ours)
773                    && rest.starts_with(':')
774                {
775                    *bind = json!(format!("{theirs}{rest}"));
776                }
777            }
778        }
779        if let Some(mounts) = host.get_mut("Mounts").and_then(Value::as_array_mut) {
780            for mount in mounts.iter_mut() {
781                if mount.get("Source").and_then(Value::as_str) == Some(ours.as_str()) {
782                    mount["Source"] = json!(theirs);
783                }
784            }
785        }
786    }
787
788    /// Build a compose service from the copy when its build context is in
789    /// it. Some(true) when it was built (now or earlier in this run), Some
790    /// (false) when it builds from somewhere else, None when compose could
791    /// not say.
792    fn build_compose_service(
793        &self,
794        project: &str,
795        service: &str,
796        config_files: Option<String>,
797        working_dir: Option<String>,
798    ) -> Option<bool> {
799        if self
800            .built
801            .lock()
802            .is_ok_and(|built| built.contains(&(project.to_owned(), service.to_owned())))
803        {
804            return Some(true);
805        }
806        let mut arguments = vec!["compose".to_owned(), "-p".into(), project.to_owned()];
807        for file in config_files.iter().flat_map(|files| files.split(',')) {
808            if !file.is_empty() {
809                arguments.extend(["-f".into(), file.to_owned()]);
810            }
811        }
812        if let Some(directory) = &working_dir {
813            arguments.extend(["--project-directory".into(), directory.clone()]);
814        }
815        let mut config = Command::new("docker");
816        config
817            .args(&arguments)
818            .args(["config", "--format", "json"])
819            .env("DOCKER_HOST", format!("unix://{}", self.upstream.display()))
820            .stdin(Stdio::null())
821            .stderr(Stdio::null());
822        let output = config.output().ok()?;
823        if !output.status.success() {
824            return None;
825        }
826        let resolved = serde_json::from_slice::<Value>(&output.stdout).ok()?;
827        let context = resolved["services"][service]["build"]["context"].as_str()?;
828        if !Path::new(context).starts_with(&self.settings.copy) {
829            return Some(false);
830        }
831        let built = Command::new("docker")
832            .args(&arguments)
833            .args(["build", service])
834            .env("DOCKER_HOST", format!("unix://{}", self.upstream.display()))
835            .stdin(Stdio::null())
836            .stdout(Stdio::null())
837            .stderr(Stdio::null())
838            .status()
839            .ok()?
840            .success();
841        if built && let Ok(mut done) = self.built.lock() {
842            done.insert((project.to_owned(), service.to_owned()));
843        }
844        Some(built)
845    }
846
847    fn record(&self, id: &str, context: CreateContext) {
848        if let Ok(mut created) = self.created.lock() {
849            created.push(CreatedContainer {
850                id: id.to_owned(),
851                measured: context.measured,
852                project: context.project.clone(),
853            });
854        }
855        let line = json!({
856            "id": id,
857            "image": context.image,
858            "measured": context.measured,
859            "project": context.project,
860            "service": context.service,
861            "note": context.note,
862        });
863        if let Ok(mut file) = fs::OpenOptions::new()
864            .create(true)
865            .append(true)
866            .open(&self.settings.state)
867        {
868            let _ = writeln!(file, "{line}");
869        }
870    }
871}
872
873/// A measured container's settings: the run's variables, the mount, and a
874/// Python or Ruby runtime in front of what the image itself sets.
875fn add_settings(
876    config: &mut Value,
877    settings: &ProxySettings,
878    image_environment: &BTreeMap<String, String>,
879) {
880    let mut environment = config
881        .get("Env")
882        .and_then(Value::as_array)
883        .map(|values| {
884            values
885                .iter()
886                .filter_map(Value::as_str)
887                .map(str::to_owned)
888                .collect::<Vec<_>>()
889        })
890        .unwrap_or_default();
891    let own = |environment: &Vec<String>, name: &str| {
892        environment
893            .iter()
894            .any(|entry| entry.split_once('=').is_some_and(|(key, _)| key == name))
895    };
896    for (name, value) in &settings.environment {
897        let value = match name.as_str() {
898            "PYTHONPATH" | "RUBYOPT" => {
899                joined_with_image(name, value, image_environment.get(name).map(String::as_str))
900            }
901            _ => value.clone(),
902        };
903        environment.retain(|entry| entry.split_once('=').is_none_or(|(key, _)| key != name));
904        environment.push(format!("{name}={value}"));
905    }
906    environment.retain(|entry| {
907        entry
908            .split_once('=')
909            .is_none_or(|(key, _)| key != CONTAINER_VARIABLE)
910    });
911    environment.push(format!("{CONTAINER_VARIABLE}=1"));
912    // Python writes the bytecode of Supercov's runtime beside it, in the
913    // mounted directory, as the container's root: the run could not remove
914    // it. A container's own setting stays.
915    if !own(&environment, "PYTHONPYCACHEPREFIX")
916        && !image_environment.contains_key("PYTHONPYCACHEPREFIX")
917    {
918        environment.push("PYTHONPYCACHEPREFIX=/tmp/supercov-pycache".into());
919    }
920    config["Env"] = json!(environment);
921    let mount = settings.mount.display().to_string();
922    if !config.get("HostConfig").is_some_and(Value::is_object) {
923        config["HostConfig"] = json!({});
924    }
925    let host = &mut config["HostConfig"];
926    if !host.get("Binds").is_some_and(Value::is_array) {
927        host["Binds"] = json!([]);
928    }
929    if let Some(binds) = host["Binds"].as_array_mut() {
930        binds.push(json!(format!("{mount}:{mount}")));
931    }
932}
933
934/// What a variable a runtime needs is in a container whose image sets it
935/// too: the runtime's part of ours first, then the image's. Ours was made
936/// for the run's own processes and carries the host's value after it.
937fn joined_with_image(name: &str, ours: &str, image: Option<&str>) -> String {
938    let (runtime, separator) = match name {
939        // `<runtime>:<host's PYTHONPATH>`.
940        "PYTHONPATH" => (ours.split(':').next().unwrap_or(ours).to_owned(), ":"),
941        // `-r<runtime> <host's RUBYOPT>`.
942        _ => (
943            ours.split_whitespace()
944                .filter(|option| option.contains("supercov"))
945                .collect::<Vec<_>>()
946                .join(" "),
947            " ",
948        ),
949    };
950    match image.filter(|value| !value.is_empty()) {
951        Some(image) => format!("{runtime}{separator}{image}"),
952        None => runtime,
953    }
954}
955
956/// An image name as one segment of a request path: `/` stays (the daemon
957/// takes `repo/name:tag` as written), the rest of the reserved set does not.
958fn url_path_segment(name: &str) -> String {
959    let mut out = String::with_capacity(name.len());
960    for byte in name.bytes() {
961        if byte.is_ascii_alphanumeric() || b"-._~/:@".contains(&byte) {
962            out.push(byte as char);
963        } else {
964            out.push_str(&format!("%{byte:02X}"));
965        }
966    }
967    out
968}
969
970/// Milliseconds since the epoch of an RFC 3339 time the daemon prints:
971/// `2026-10-17T21:05:37.497328719Z` or with an offset.
972fn parse_rfc3339_ms(text: &str) -> Option<i64> {
973    let (date, rest) = text.split_once('T')?;
974    let mut date_parts = date.split('-');
975    let year: i64 = date_parts.next()?.parse().ok()?;
976    let month: i64 = date_parts.next()?.parse().ok()?;
977    let day: i64 = date_parts.next()?.parse().ok()?;
978    let (time, offset_seconds) = if let Some(time) = rest.strip_suffix('Z') {
979        (time, 0)
980    } else {
981        let index = rest.rfind(['+', '-'])?;
982        let (time, offset) = rest.split_at(index);
983        let sign = if offset.starts_with('-') { -1 } else { 1 };
984        let mut parts = offset[1..].split(':');
985        let hours: i64 = parts.next()?.parse().ok()?;
986        let minutes: i64 = parts.next().unwrap_or("0").parse().ok()?;
987        (time, sign * (hours * 3600 + minutes * 60))
988    };
989    let (clock, fraction) = time.split_once('.').unwrap_or((time, "0"));
990    let mut clock_parts = clock.split(':');
991    let hour: i64 = clock_parts.next()?.parse().ok()?;
992    let minute: i64 = clock_parts.next()?.parse().ok()?;
993    let second: i64 = clock_parts.next()?.parse().ok()?;
994    let millis: i64 = format!("{fraction:0<3}")[..3].parse().ok()?;
995    // Days from the civil date (Howard Hinnant's algorithm).
996    let year = if month <= 2 { year - 1 } else { year };
997    let era = year.div_euclid(400);
998    let year_of_era = year - era * 400;
999    let month_index = (month + 9) % 12;
1000    let day_of_year = (153 * month_index + 2) / 5 + day - 1;
1001    let day_of_era = year_of_era * 365 + year_of_era / 4 - year_of_era / 100 + day_of_year;
1002    let days = era * 146_097 + day_of_era - 719_468;
1003    Some(((days * 86_400 + hour * 3600 + minute * 60 + second - offset_seconds) * 1000) + millis)
1004}
1005
1006fn read_registry(path: &Path) -> BTreeMap<String, String> {
1007    fs::read(path)
1008        .ok()
1009        .and_then(|bytes| serde_json::from_slice(&bytes).ok())
1010        .unwrap_or_default()
1011}
1012
1013fn write_registry(path: &Path, registry: &BTreeMap<String, String>) {
1014    if let Some(parent) = path.parent() {
1015        let _ = fs::create_dir_all(parent);
1016    }
1017    if let Ok(bytes) = serde_json::to_vec_pretty(registry) {
1018        let temporary = path.with_extension("json.tmp");
1019        if fs::write(&temporary, bytes).is_ok() {
1020            let _ = fs::rename(temporary, path);
1021        }
1022    }
1023}
1024
1025/// The notes the relay recorded about containers it left alone.
1026pub fn notes(state: &Path) -> Vec<String> {
1027    let Ok(text) = fs::read_to_string(state) else {
1028        return Vec::new();
1029    };
1030    let mut notes = text
1031        .lines()
1032        .filter_map(|line| serde_json::from_str::<Value>(line).ok())
1033        .filter_map(|line| line.get("note").and_then(Value::as_str).map(str::to_owned))
1034        .collect::<Vec<_>>();
1035    notes.sort();
1036    notes.dedup();
1037    notes
1038}
1039
1040#[cfg(test)]
1041mod tests {
1042    use super::*;
1043    use std::sync::mpsc;
1044
1045    fn temporary(name: &str) -> PathBuf {
1046        // Short: a socket address has room for about a hundred bytes.
1047        let path = PathBuf::from("/tmp").join(format!(
1048            "scv-dp-{}-{}-{name}",
1049            std::process::id(),
1050            SystemTime::now()
1051                .duration_since(UNIX_EPOCH)
1052                .unwrap()
1053                .as_nanos()
1054                % 1_000_000_000
1055        ));
1056        fs::create_dir_all(&path).unwrap();
1057        path
1058    }
1059
1060    /// A daemon that answers what these tests ask, and reports each
1061    /// container configuration it was asked to create.
1062    fn fake_daemon(socket: &Path) -> mpsc::Receiver<Value> {
1063        let listener = UnixListener::bind(socket).unwrap();
1064        let (sender, receiver) = mpsc::channel();
1065        std::thread::spawn(move || {
1066            for stream in listener.incoming() {
1067                let Ok(stream) = stream else { break };
1068                let sender = sender.clone();
1069                std::thread::spawn(move || {
1070                    let mut reader = BufReader::new(stream.try_clone().unwrap());
1071                    let mut writer = stream;
1072                    while let Ok(Some(head)) = read_head(&mut reader) {
1073                        let body = read_body(&head, &mut reader).unwrap_or_default();
1074                        let (method, path) = endpoint(&head);
1075                        let path = path.to_owned();
1076                        let respond = |writer: &mut UnixStream, status: &str, body: &str| {
1077                            write!(
1078                                writer,
1079                                "HTTP/1.1 {status}\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{body}",
1080                                body.len()
1081                            )
1082                            .unwrap();
1083                        };
1084                        if path.starts_with("/images/") {
1085                            // Built during the run, pulled long ago, or
1086                            // built by an earlier run.
1087                            let (id, created) = if path.contains("pulled") {
1088                                ("sha256:pulled", "2020-01-01T00:00:00Z")
1089                            } else if path.contains("stale") {
1090                                ("sha256:stale", "2020-01-01T00:00:00Z")
1091                            } else {
1092                                ("sha256:fresh", "2099-01-01T00:00:00.000Z")
1093                            };
1094                            respond(
1095                                &mut writer,
1096                                "200 OK",
1097                                &json!({"Id": id, "Created": created, "Config": {"Env": ["PYTHONPATH=/srv/app"]}}).to_string(),
1098                            );
1099                        } else if method == "POST" && path == "/containers/create" {
1100                            sender.send(serde_json::from_slice(&body).unwrap()).unwrap();
1101                            respond(
1102                                &mut writer,
1103                                "201 Created",
1104                                r#"{"Id":"c0ffee","Warnings":[]}"#,
1105                            );
1106                        } else if path == "/events" {
1107                            writer
1108                                .write_all(b"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n")
1109                                .unwrap();
1110                            for event in ["first", "second"] {
1111                                write!(writer, "{:x}\r\n{event}\r\n", event.len()).unwrap();
1112                                writer.flush().unwrap();
1113                            }
1114                            writer.write_all(b"0\r\n\r\n").unwrap();
1115                        } else if path.ends_with("/attach") {
1116                            writer
1117                                .write_all(b"HTTP/1.1 101 UPGRADED\r\nConnection: Upgrade\r\nUpgrade: tcp\r\n\r\n")
1118                                .unwrap();
1119                            // Echo raw bytes until the client closes.
1120                            let mut buffer = [0_u8; 64];
1121                            loop {
1122                                let read = reader.read(&mut buffer).unwrap_or(0);
1123                                if read == 0 {
1124                                    break;
1125                                }
1126                                writer.write_all(&buffer[..read]).unwrap();
1127                            }
1128                            return;
1129                        } else {
1130                            respond(&mut writer, "200 OK", "OK");
1131                        }
1132                    }
1133                });
1134            }
1135        });
1136        receiver
1137    }
1138
1139    fn settings(directory: &Path) -> ProxySettings {
1140        ProxySettings {
1141            copy: directory.join("copy"),
1142            mount: directory.join("mount"),
1143            environment: vec![
1144                ("SUPERCOV_RUN".into(), "run_1".into()),
1145                ("PYTHONPATH".into(), "/runtime/python:/host/path".into()),
1146            ],
1147            fingerprint: "source-a".into(),
1148            registry: directory.join("registry.json"),
1149            state: directory.join("state/containers.jsonl"),
1150        }
1151    }
1152
1153    fn exchange(
1154        stream: &mut UnixStream,
1155        reader: &mut BufReader<UnixStream>,
1156        request: &str,
1157    ) -> (Head, Vec<u8>) {
1158        stream.write_all(request.as_bytes()).unwrap();
1159        let head = read_head(reader).unwrap().unwrap();
1160        let body = read_body(&head, reader).unwrap();
1161        (head, body)
1162    }
1163
1164    #[test]
1165    fn requests_pass_through_and_a_created_container_from_the_run_gets_the_settings() {
1166        let directory = temporary("relay");
1167        let daemon = directory.join("d.sock");
1168        let created = fake_daemon(&daemon);
1169        let proxy =
1170            DockerProxy::start(&directory.join("p.sock"), &daemon, settings(&directory)).unwrap();
1171        let mut stream = UnixStream::connect(directory.join("p.sock")).unwrap();
1172        let mut reader = BufReader::new(stream.try_clone().unwrap());
1173
1174        // Plain requests on one kept-alive connection.
1175        let (head, body) = exchange(
1176            &mut stream,
1177            &mut reader,
1178            "GET /_ping HTTP/1.1\r\nHost: docker\r\n\r\n",
1179        );
1180        assert!(head.start.contains("200"), "{head:?}");
1181        assert_eq!(body, b"OK");
1182        // A stream arrives as it comes, framed as sent.
1183        stream
1184            .write_all(b"GET /v1.47/events HTTP/1.1\r\nHost: docker\r\n\r\n")
1185            .unwrap();
1186        let head = read_head(&mut reader).unwrap().unwrap();
1187        assert!(head.chunked());
1188        assert_eq!(read_body(&head, &mut reader).unwrap(), b"firstsecond");
1189
1190        // Created from an image the run built: settings, a joined
1191        // PYTHONPATH, the mount, and a bind of the relay's socket moved to
1192        // the daemon's.
1193        let socket = directory.join("p.sock").display().to_string();
1194        let config = json!({
1195            "Image": "app-api",
1196            "Env": ["MODE=test"],
1197            "HostConfig": {"Binds": [format!("{socket}:/var/run/docker.sock")]},
1198        })
1199        .to_string();
1200        let (head, body) = exchange(
1201            &mut stream,
1202            &mut reader,
1203            &format!(
1204                "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}",
1205                config.len()
1206            ),
1207        );
1208        assert!(head.start.contains("201"), "{head:?}");
1209        assert_eq!(
1210            serde_json::from_slice::<Value>(&body).unwrap()["Id"],
1211            "c0ffee"
1212        );
1213        let sent = created.recv_timeout(Duration::from_secs(5)).unwrap();
1214        let environment = sent["Env"]
1215            .as_array()
1216            .unwrap()
1217            .iter()
1218            .map(|value| value.as_str().unwrap().to_owned())
1219            .collect::<Vec<_>>();
1220        assert!(
1221            environment.contains(&"MODE=test".to_owned()),
1222            "{environment:?}"
1223        );
1224        assert!(
1225            environment.contains(&"SUPERCOV_RUN=run_1".to_owned()),
1226            "{environment:?}"
1227        );
1228        assert!(
1229            environment.contains(&"PYTHONPATH=/runtime/python:/srv/app".to_owned()),
1230            "{environment:?}"
1231        );
1232        assert!(
1233            environment.contains(&"SUPERCOV_CONTAINER=1".to_owned()),
1234            "{environment:?}"
1235        );
1236        let binds = sent["HostConfig"]["Binds"].as_array().unwrap();
1237        let mount = directory.join("mount").display().to_string();
1238        assert!(
1239            binds.contains(&json!(format!("{mount}:{mount}"))),
1240            "{binds:?}"
1241        );
1242        assert!(
1243            binds.contains(&json!(format!(
1244                "{}:/var/run/docker.sock",
1245                daemon_side_socket(&daemon).display()
1246            ))),
1247            "{binds:?}"
1248        );
1249        assert_eq!(
1250            read_registry(&directory.join("registry.json"))
1251                .get("sha256:fresh")
1252                .map(String::as_str),
1253            Some("source-a")
1254        );
1255
1256        // A pulled image is the image's own: nothing added.
1257        let config = json!({"Image": "pulled/postgres:16"}).to_string();
1258        exchange(
1259            &mut stream,
1260            &mut reader,
1261            &format!(
1262                "POST /containers/create HTTP/1.1\r\nHost: docker\r\nContent-Length: {}\r\n\r\n{config}",
1263                config.len()
1264            ),
1265        );
1266        let sent = created.recv_timeout(Duration::from_secs(5)).unwrap();
1267        assert!(sent.get("Env").is_none(), "{sent}");
1268
1269        // An upgraded stream is spliced both ways.
1270        let mut attach = UnixStream::connect(directory.join("p.sock")).unwrap();
1271        attach
1272            .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")
1273            .unwrap();
1274        let mut attach_reader = BufReader::new(attach.try_clone().unwrap());
1275        let head = read_head(&mut attach_reader).unwrap().unwrap();
1276        assert!(head.start.contains("101"), "{head:?}");
1277        attach.write_all(b"ping").unwrap();
1278        let mut echoed = [0_u8; 4];
1279        attach_reader.read_exact(&mut echoed).unwrap();
1280        assert_eq!(&echoed, b"ping");
1281        drop(attach);
1282        drop(stream);
1283        proxy.finish();
1284        let state = fs::read_to_string(directory.join("state/containers.jsonl")).unwrap();
1285        assert_eq!(state.lines().count(), 2, "{state}");
1286        fs::remove_dir_all(directory).unwrap();
1287    }
1288
1289    #[test]
1290    fn an_image_built_from_an_earlier_copy_is_left_alone_and_said_to_be() {
1291        let directory = temporary("stale");
1292        let daemon = directory.join("d.sock");
1293        let created = fake_daemon(&daemon);
1294        write_registry(
1295            &directory.join("registry.json"),
1296            &BTreeMap::from([("sha256:stale".to_owned(), "source-old".to_owned())]),
1297        );
1298        let proxy =
1299            DockerProxy::start(&directory.join("p.sock"), &daemon, settings(&directory)).unwrap();
1300        let mut stream = UnixStream::connect(directory.join("p.sock")).unwrap();
1301        let mut reader = BufReader::new(stream.try_clone().unwrap());
1302        let config = json!({"Image": "stale-app"}).to_string();
1303        exchange(
1304            &mut stream,
1305            &mut reader,
1306            &format!(
1307                "POST /containers/create HTTP/1.1\r\nHost: docker\r\nContent-Length: {}\r\n\r\n{config}",
1308                config.len()
1309            ),
1310        );
1311        let sent = created.recv_timeout(Duration::from_secs(5)).unwrap();
1312        assert!(sent.get("Env").is_none(), "{sent}");
1313        drop(stream);
1314        proxy.finish();
1315        let notes = notes(&directory.join("state/containers.jsonl"));
1316        assert_eq!(notes.len(), 1, "{notes:?}");
1317        assert!(notes[0].contains("earlier copy"), "{notes:?}");
1318        fs::remove_dir_all(directory).unwrap();
1319    }
1320
1321    #[test]
1322    fn times_the_daemon_prints_are_read_to_the_millisecond() {
1323        assert_eq!(parse_rfc3339_ms("1970-01-01T00:00:00Z"), Some(0));
1324        assert_eq!(parse_rfc3339_ms("1970-01-01T00:00:01.5Z"), Some(1500));
1325        assert_eq!(
1326            parse_rfc3339_ms("2026-10-17T21:05:37.497328719Z"),
1327            Some(1_792_271_137_497)
1328        );
1329        assert_eq!(
1330            parse_rfc3339_ms("2026-10-17T23:05:37.497+02:00"),
1331            Some(1_792_271_137_497)
1332        );
1333        assert_eq!(parse_rfc3339_ms("not a time"), None);
1334    }
1335
1336    #[test]
1337    fn an_api_version_prefix_and_query_are_not_part_of_the_endpoint() {
1338        let head = |start: &str| Head {
1339            start: start.into(),
1340            headers: Vec::new(),
1341        };
1342        assert_eq!(
1343            endpoint(&head("POST /v1.47/containers/create?name=x HTTP/1.1")),
1344            ("POST", "/containers/create")
1345        );
1346        assert_eq!(
1347            endpoint(&head("POST /containers/create HTTP/1.1")),
1348            ("POST", "/containers/create")
1349        );
1350        assert_eq!(
1351            endpoint(&head("GET /volumes/v1/x HTTP/1.1")),
1352            ("GET", "/volumes/v1/x")
1353        );
1354    }
1355}