1use crate::engine::{capture, refresh, stderr_tail, stream, EngineRef, LIST_LIMIT, READ_DEADLINE};
12use crate::identity::ContainerRef;
13use crate::tar::{self, StreamEntry, TarEntry, TarKind};
14use crate::ContainerError;
15use strop_core::worker::CancelToken;
16
17const HEADER_SLACK: u64 = 64 * 1024;
20
21#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
23pub enum DirEntryKind {
24 File,
25 Dir,
26 Symlink,
27 Other,
30}
31
32#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
34pub struct DirEntry {
35 pub name: String,
36 pub kind: DirEntryKind,
37 pub size: Option<u64>,
39}
40
41pub fn list_dir(
56 engine: &EngineRef,
57 id: &ContainerRef,
58 path: &str,
59 token: &CancelToken,
60) -> Result<Vec<DirEntry>, ContainerError> {
61 refresh(engine, id, token)?;
62 match list_once(engine, id, path, token)? {
63 Listing::Children(children) => Ok(children),
64 Listing::Symlink(resolved) => match list_once(engine, id, &resolved, token)? {
65 Listing::Children(children) => Ok(children),
66 Listing::Symlink(_) => Err(ContainerError::CapabilityRefused {
67 what: format!("list_dir: {path} is a symlink chain"),
68 }),
69 },
70 }
71}
72
73pub fn read_file(
82 engine: &EngineRef,
83 id: &ContainerRef,
84 path: &str,
85 max: u64,
86 token: &CancelToken,
87) -> Result<Vec<u8>, ContainerError> {
88 refresh(engine, id, token)?;
89 let limit = max.saturating_add(HEADER_SLACK);
90 let mut archive = fetch_archive(engine, id, path, limit, token)?;
91 let mut spelling = path.to_string();
92 if let Some(resolved) = symlink_target(path, &archive.entries)? {
93 archive = fetch_archive(engine, id, &resolved, limit, token)?;
94 if symlink_target(&resolved, &archive.entries)?.is_some() {
95 return Err(ContainerError::CapabilityRefused {
96 what: format!("read_file: {path} is a symlink chain"),
97 });
98 }
99 spelling = resolved;
100 }
101 let first = archive
102 .entries
103 .first()
104 .ok_or_else(|| ContainerError::Protocol {
105 detail: format!("read archive of {spelling} holds no entry"),
106 })?;
107 if first.kind != TarKind::File {
108 return Err(ContainerError::CapabilityRefused {
109 what: format!("read_file: {spelling} is a {}", describe(first.kind)),
110 });
111 }
112 let content = &archive.bytes[first.data.clone()];
113 let keep = (max as usize).min(content.len());
114 Ok(content[..keep].to_vec())
115}
116
117struct Archive {
120 bytes: Vec<u8>,
121 entries: Vec<TarEntry>,
122}
123
124fn fetch_archive(
129 engine: &EngineRef,
130 id: &ContainerRef,
131 path: &str,
132 limit: u64,
133 token: &CancelToken,
134) -> Result<Archive, ContainerError> {
135 let _ = engine;
136 let output = capture(&["cp", &target(id, path), "-"], limit, READ_DEADLINE, token)?;
137 if output.code != Some(0) {
138 return Err(classify_cp(id, path, &output.stderr));
139 }
140 let entries = tar::parse(&output.stdout, output.stdout_dropped == 0).map_err(|detail| {
141 ContainerError::Protocol {
142 detail: format!("archive of {path}: {detail}"),
143 }
144 })?;
145 Ok(Archive {
146 bytes: output.stdout,
147 entries,
148 })
149}
150
151fn symlink_target(path: &str, entries: &[TarEntry]) -> Result<Option<String>, ContainerError> {
154 let Some(first) = entries.first() else {
155 return Ok(None);
156 };
157 if first.kind != TarKind::Symlink {
158 return Ok(None);
159 }
160 let target = first
161 .link_target
162 .as_deref()
163 .filter(|target| !target.is_empty())
164 .ok_or_else(|| ContainerError::Protocol {
165 detail: format!("archive of {path} carries a symlink without a target"),
166 })?;
167 Ok(Some(resolve_link(path, target)))
168}
169
170fn resolve_link(link_path: &str, target: &str) -> String {
175 let mut resolved: Vec<&str> = if target.starts_with('/') {
176 Vec::new()
177 } else {
178 components(link_path.rsplit_once('/').map_or("", |(parent, _)| parent))
179 };
180 for part in target.split('/') {
181 match part {
182 "" | "." => {}
183 ".." => {
184 resolved.pop();
185 }
186 part => resolved.push(part),
187 }
188 }
189 format!("/{}", resolved.join("/"))
190}
191
192fn target(id: &ContainerRef, path: &str) -> String {
195 format!("{}:{path}", id.id())
196}
197
198fn classify_cp(id: &ContainerRef, path: &str, stderr: &[u8]) -> ContainerError {
201 let tail = stderr_tail(stderr);
202 if tail.contains("Could not find the file") {
203 ContainerError::NoSuchPath {
204 id: id.id().to_string(),
205 path: path.to_string(),
206 }
207 } else if tail.contains("is not running") {
208 ContainerError::NotRunning {
209 id: id.id().to_string(),
210 }
211 } else {
212 ContainerError::Io {
213 detail: format!("docker cp failed: {tail}"),
214 }
215 }
216}
217
218enum Listing {
220 Children(Vec<DirEntry>),
221 Symlink(String),
224}
225
226fn list_once(
231 engine: &EngineRef,
232 id: &ContainerRef,
233 path: &str,
234 token: &CancelToken,
235) -> Result<Listing, ContainerError> {
236 let _ = engine;
237 let mut listing = ListingConsumer::new(path);
238 let streamed = stream(
239 &["cp", &target(id, path), "-"],
240 READ_DEADLINE,
241 token,
242 |chunk| listing.feed(chunk),
243 )?;
244 if streamed.code != Some(0) {
245 return Err(classify_cp(id, path, &streamed.stderr));
246 }
247 listing.finish()
248}
249
250struct ListingConsumer<'a> {
255 path: &'a str,
256 parser: tar::StreamParser,
257 root: Option<Vec<String>>,
259 symlink: Option<String>,
261 children: Vec<DirEntry>,
262 retained: u64,
263}
264
265impl<'a> ListingConsumer<'a> {
266 fn new(path: &'a str) -> Self {
267 Self {
268 path,
269 parser: tar::StreamParser::default(),
270 root: None,
271 symlink: None,
272 children: Vec::new(),
273 retained: 0,
274 }
275 }
276
277 fn feed(&mut self, chunk: &[u8]) -> Result<(), ContainerError> {
280 let mut parser = std::mem::take(&mut self.parser);
281 let mut failed = None;
282 let parsed = parser.feed(chunk, &mut |entry| {
283 if failed.is_none() {
284 failed = self.on_entry(entry).err();
285 }
286 });
287 self.parser = parser;
288 if let Some(error) = failed {
289 return Err(error);
290 }
291 parsed.map_err(|detail| ContainerError::Protocol {
292 detail: format!("archive of {}: {detail}", self.path),
293 })
294 }
295
296 fn on_entry(&mut self, entry: StreamEntry) -> Result<(), ContainerError> {
302 if self.symlink.is_some() {
303 return Ok(()); }
305 let Some(root) = &self.root else {
306 return self.on_first(entry);
307 };
308 let parts = components(&entry.name);
309 let escapes = parts.len() <= root.len()
310 || !parts
311 .iter()
312 .zip(root.iter())
313 .all(|(part, segment)| *part == segment.as_str());
314 if escapes {
315 return Err(ContainerError::Protocol {
316 detail: format!(
317 "listing archive entry {:?} escapes the listed directory",
318 entry.name
319 ),
320 });
321 }
322 if parts.len() == root.len() + 1 {
323 let cost = entry.name.len() as u64 + size_of::<DirEntry>() as u64;
324 if self.retained.saturating_add(cost) > LIST_LIMIT {
325 return Err(ContainerError::OutputTooLarge {
326 what: format!("listing of {}", self.path),
327 });
328 }
329 self.retained += cost;
330 self.children.push(DirEntry {
331 name: parts[root.len()].to_string(),
332 kind: kind(entry.kind),
333 size: (entry.kind == TarKind::File).then_some(entry.size),
334 });
335 }
336 Ok(())
337 }
338
339 fn on_first(&mut self, entry: StreamEntry) -> Result<(), ContainerError> {
342 match entry.kind {
343 TarKind::Dir => {
344 self.root = Some(
345 components(&entry.name)
346 .iter()
347 .map(|part| part.to_string())
348 .collect(),
349 );
350 Ok(())
351 }
352 TarKind::Symlink => {
353 let target = entry
354 .link_target
355 .filter(|target| !target.is_empty())
356 .ok_or_else(|| ContainerError::Protocol {
357 detail: format!(
358 "archive of {} carries a symlink without a target",
359 self.path
360 ),
361 })?;
362 self.symlink = Some(resolve_link(self.path, &target));
363 Ok(())
364 }
365 other => Err(ContainerError::CapabilityRefused {
366 what: format!("list_dir: {} is a {}", self.path, describe(other)),
367 }),
368 }
369 }
370
371 fn finish(self) -> Result<Listing, ContainerError> {
375 self.parser
376 .finish()
377 .map_err(|detail| ContainerError::Protocol {
378 detail: format!("archive of {}: {detail}", self.path),
379 })?;
380 if let Some(target) = self.symlink {
381 return Ok(Listing::Symlink(target));
382 }
383 Ok(Listing::Children(self.children))
384 }
385}
386
387fn components(name: &str) -> Vec<&str> {
390 name.split('/')
391 .filter(|part| !part.is_empty() && *part != ".")
392 .collect()
393}
394
395fn kind(tar_kind: TarKind) -> DirEntryKind {
396 match tar_kind {
397 TarKind::File => DirEntryKind::File,
398 TarKind::Dir => DirEntryKind::Dir,
399 TarKind::Symlink => DirEntryKind::Symlink,
400 TarKind::Other => DirEntryKind::Other,
401 }
402}
403
404fn describe(tar_kind: TarKind) -> &'static str {
405 match tar_kind {
406 TarKind::File => "file",
407 TarKind::Dir => "directory",
408 TarKind::Symlink => "symlink",
409 TarKind::Other => "special entry",
410 }
411}
412
413#[cfg(test)]
414mod tests {
415 use super::*;
416
417 fn tar_entry(kind: TarKind) -> TarEntry {
418 TarEntry {
419 kind,
420 data: 0..0,
421 link_target: None,
422 }
423 }
424
425 fn tar_part(name: &str, typeflag: u8, content: &[u8]) -> Vec<u8> {
427 let mut header = [0u8; 512];
428 let name_bytes = name.as_bytes();
429 assert!(name_bytes.len() <= 100);
430 header[..name_bytes.len()].copy_from_slice(name_bytes);
431 let size = format!("{:011o}", content.len());
432 header[124..124 + size.len()].copy_from_slice(size.as_bytes());
433 header[257..262].copy_from_slice(b"ustar");
434 header[156] = typeflag;
435 let mut out = header.to_vec();
436 out.extend_from_slice(content);
437 out.resize(out.len() + (512 - content.len() % 512) % 512, 0);
438 out
439 }
440
441 fn tar_symlink(name: &str, target: &str) -> Vec<u8> {
442 let mut part = tar_part(name, b'2', b"");
443 part[157..157 + target.len()].copy_from_slice(target.as_bytes());
444 part
445 }
446
447 fn archive(parts: &[Vec<u8>]) -> Vec<u8> {
448 let mut out = parts.concat();
449 out.extend_from_slice(&[0u8; 512]); out
451 }
452
453 fn list(path: &str, bytes: &[u8]) -> Result<Listing, ContainerError> {
456 let mut consumer = ListingConsumer::new(path);
457 for chunk in bytes.chunks(1000) {
458 consumer.feed(chunk)?;
459 }
460 consumer.finish()
461 }
462
463 fn children_of(path: &str, bytes: &[u8]) -> Result<Vec<DirEntry>, ContainerError> {
464 match list(path, bytes)? {
465 Listing::Children(children) => Ok(children),
466 Listing::Symlink(target) => panic!("expected children, got symlink to {target}"),
467 }
468 }
469
470 #[test]
471 fn symlink_targets_resolve_posix_style() {
472 assert_eq!(resolve_link("/data/link", "hello.txt"), "/data/hello.txt");
473 assert_eq!(resolve_link("/data/link", "/etc/hostname"), "/etc/hostname");
474 assert_eq!(
475 resolve_link("/data/sub/link", "../hello.txt"),
476 "/data/hello.txt"
477 );
478 assert_eq!(resolve_link("/link", "a/./b"), "/a/b");
479 assert_eq!(resolve_link("/a/b/link", "../../x"), "/x", "clamps at root");
480 }
481
482 #[test]
483 fn a_symlink_archive_offers_its_target_once() {
484 let mut link = tar_entry(TarKind::Symlink);
485 link.link_target = Some("hello.txt".into());
486 assert_eq!(
487 symlink_target("/data/link", &[link]).unwrap(),
488 Some("/data/hello.txt".to_string())
489 );
490 let file = tar_entry(TarKind::File);
491 assert_eq!(symlink_target("/data/hello.txt", &[file]).unwrap(), None);
492 let mut empty = tar_entry(TarKind::Symlink);
493 empty.link_target = Some(String::new());
494 assert!(matches!(
495 symlink_target("/data/link", &[empty]),
496 Err(ContainerError::Protocol { .. })
497 ));
498 }
499
500 #[test]
501 fn a_streamed_symlink_answer_resolves_one_hop() {
502 let bytes = archive(&[tar_symlink("link", "hello.txt")]);
503 assert!(matches!(
504 list("/data/link", &bytes).unwrap(),
505 Listing::Symlink(target) if target == "/data/hello.txt"
506 ));
507 let mut bare = tar_part("link", b'2', b"");
508 bare[157..257].fill(0);
509 let bytes = archive(&[bare]);
510 assert!(
511 matches!(
512 list("/data/link", &bytes),
513 Err(ContainerError::Protocol { .. })
514 ),
515 "a symlink without a target is a protocol violation"
516 );
517 }
518
519 #[test]
520 fn direct_children_only_with_sizes_on_files() {
521 let bytes = archive(&[
522 tar_part("data", b'5', b""),
523 tar_part("data/hello.txt", b'0', b"hello strop\n"),
524 tar_part("data/sub", b'5', b""),
525 tar_part("data/sub/inner.bin", b'0', b"inner"),
526 tar_symlink("data/link", "hello.txt"),
527 tar_part("data/fifo", b'6', b""),
528 ]);
529 let children = children_of("/data", &bytes).unwrap();
530 let by_name = |name: &str| children.iter().find(|e| e.name == name);
531 assert_eq!(children.len(), 4, "grandchildren are not children");
532 assert_eq!(
533 by_name("hello.txt").map(|e| (e.kind, e.size)),
534 Some((DirEntryKind::File, Some(12)))
535 );
536 assert_eq!(
537 by_name("sub").map(|e| (e.kind, e.size)),
538 Some((DirEntryKind::Dir, None))
539 );
540 assert_eq!(by_name("link").map(|e| e.kind), Some(DirEntryKind::Symlink));
541 assert_eq!(by_name("fifo").map(|e| e.kind), Some(DirEntryKind::Other));
542 }
543
544 #[test]
545 fn root_components_are_normalized() {
546 for root_name in [".", "/", "./"] {
547 let bytes = archive(&[
548 tar_part(root_name, b'5', b""),
549 tar_part("etc", b'5', b""),
550 tar_part("etc/hostname", b'0', b"container\n"),
551 ]);
552 let children = children_of("/", &bytes).unwrap();
553 assert_eq!(children.len(), 1, "root {root_name:?}");
554 assert_eq!(children[0].name, "etc");
555 }
556 }
557
558 #[test]
559 fn a_file_answer_is_a_refusal_and_escapes_are_protocol_errors() {
560 let file = archive(&[tar_part("data/hello.txt", b'0', b"x")]);
561 assert!(matches!(
562 list("/data/hello.txt", &file),
563 Err(ContainerError::CapabilityRefused { .. })
564 ));
565 let escape = archive(&[
566 tar_part("data", b'5', b""),
567 tar_part("other/evil", b'0', b"x"),
568 ]);
569 assert!(matches!(
570 list("/data", &escape),
571 Err(ContainerError::Protocol { .. })
572 ));
573 let empty = archive(&[tar_part("data", b'5', b"")]);
574 assert_eq!(children_of("/data", &empty).unwrap(), vec![]);
575 }
576
577 #[test]
578 fn subtree_bulk_streams_through_without_retention() {
579 let bulk = vec![7u8; 4 * 1024 * 1024];
583 let mut parts = vec![
584 tar_part("data", b'5', b""),
585 tar_part("data/deep", b'5', b""),
586 ];
587 for index in 0..8 {
588 parts.push(tar_part(
589 &format!("data/deep/nest/file{index}.bin"),
590 b'0',
591 &bulk,
592 ));
593 }
594 parts.push(tar_part("data/shallow.txt", b'0', b"shallow"));
595 let bytes = archive(&parts);
596 let mut consumer = ListingConsumer::new("/data");
597 for chunk in bytes.chunks(65536) {
598 consumer.feed(chunk).unwrap();
599 }
600 let Listing::Children(children) = consumer.finish().unwrap() else {
601 panic!("a directory answer is children");
602 };
603 let mut names: Vec<&str> = children.iter().map(|e| e.name.as_str()).collect();
604 names.sort();
605 assert_eq!(names, ["deep", "shallow.txt"]);
606 assert!(
607 consumer_retained_is_tiny(&children),
608 "32 MB streamed; retention is the two direct children"
609 );
610 }
611
612 fn consumer_retained_is_tiny(children: &[DirEntry]) -> bool {
613 children
614 .iter()
615 .map(|e| e.name.len() as u64 + size_of::<DirEntry>() as u64)
616 .sum::<u64>()
617 < 1024
618 }
619
620 #[test]
621 fn retention_overflow_is_output_too_large() {
622 let mut consumer = ListingConsumer::new("/data");
623 let entry = |name: String, kind| StreamEntry {
624 name,
625 kind,
626 size: 0,
627 link_target: None,
628 };
629 consumer
630 .on_entry(entry("data".to_string(), TarKind::Dir))
631 .unwrap();
632 let wide = "n".repeat(2048);
633 let mut overflowed = false;
634 for index in 0.. {
635 let name = format!("data/{wide}{index}");
636 if consumer.on_entry(entry(name, TarKind::File)).is_err() {
637 overflowed = true;
638 break;
639 }
640 }
641 assert!(overflowed, "enough direct children trip the bound");
642 assert!(
643 consumer.retained <= LIST_LIMIT,
644 "retention stops at the bound, not past it"
645 );
646 }
647
648 #[test]
649 fn a_truncated_stream_is_a_protocol_error_at_finish() {
650 let full = archive(&[
651 tar_part("data", b'5', b""),
652 tar_part("data/big.bin", b'0', &[9u8; 1000]),
653 ]);
654 let cut = &full[..512 + 512 + 400]; let mut consumer = ListingConsumer::new("/data");
656 consumer.feed(cut).unwrap();
657 assert!(matches!(
658 consumer.finish(),
659 Err(ContainerError::Protocol { .. })
660 ));
661 let cut = &full[..512 + 100]; let mut consumer = ListingConsumer::new("/data");
663 consumer.feed(cut).unwrap();
664 assert!(matches!(
665 consumer.finish(),
666 Err(ContainerError::Protocol { .. })
667 ));
668 }
669}