1use 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
43pub const CONTAINER_VARIABLE: &str = "SUPERCOV_CONTAINER";
45
46#[derive(Debug, Clone)]
48pub struct ProxySettings {
49 pub copy: PathBuf,
51 pub mount: PathBuf,
53 pub environment: Vec<(String, String)>,
55 pub fingerprint: String,
57 pub registry: PathBuf,
59 pub state: PathBuf,
61}
62
63pub 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
96fn 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
108pub 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
130fn 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
145pub struct DockerProxy {
147 socket: PathBuf,
148 upstream: PathBuf,
149 shared: Arc<Shared>,
150 stop: Arc<AtomicBool>,
151 thread: Option<JoinHandle<()>>,
152}
153
154struct Shared {
156 settings: ProxySettings,
157 upstream: PathBuf,
158 socket: PathBuf,
159 started_ms: i64,
160 created: Mutex<Vec<CreatedContainer>>,
162 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 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 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 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#[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
350fn 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 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
387fn 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 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
446fn 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
470fn 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
490fn 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
499fn 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 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 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 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
608fn 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
629fn 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#[derive(Debug, Clone, PartialEq)]
661struct CreateContext {
662 measured: bool,
663 image: String,
664 project: Option<String>,
665 service: Option<String>,
666 note: Option<String>,
668}
669
670impl Shared {
671 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 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, ®istry);
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 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 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
871fn 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 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
932fn joined_with_image(name: &str, ours: &str, image: Option<&str>) -> String {
936 let (runtime, separator) = match name {
937 "PYTHONPATH" => (ours.split(':').next().unwrap_or(ours).to_owned(), ":"),
939 _ => (
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
954fn 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
968fn 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 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
1023pub 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 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 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 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 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 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 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 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 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 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}