Skip to main content

strop_containers/
read.rs

1//! Read-only filesystem access: directory listings and bounded file
2//! reads, both over the engine's own file facility — `docker cp
3//! <id>:<path> -` streams a tar archive to stdout, so no `ls`/`stat`/`sh`
4//! has to exist inside the container (distroless included) and nothing
5//! user-controlled passes through a shell anywhere.
6//!
7//! Every read first re-inspects the container ([`refresh`]): the cheap
8//! incarnation check that turns "restarted between inspect and read"
9//! into [`ContainerError::StaleIdentity`] instead of wrong bytes.
10
11use 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
17/// Slack above `max` in a read's capture bound: the tar header, padding
18/// and any longname/pax records around the content itself.
19const HEADER_SLACK: u64 = 64 * 1024;
20
21/// What one directory entry is.
22#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
23pub enum DirEntryKind {
24    File,
25    Dir,
26    Symlink,
27    /// Hard links, devices, fifos — kinds this backend does not
28    /// distinguish further.
29    Other,
30}
31
32/// One direct child of a listed directory.
33#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
34pub struct DirEntry {
35    pub name: String,
36    pub kind: DirEntryKind,
37    /// The file's size in bytes; `None` for non-files.
38    pub size: Option<u64>,
39}
40
41/// The direct children of `path` inside the container, parsed strictly
42/// from the tar archive the engine streams for the directory.
43///
44/// The archive is consumed incrementally as it arrives: only
45/// direct-child metadata (names, kinds, sizes) is retained, so a
46/// direct-child directory's subtree — however large — streams through
47/// without being held. The transfer cost is still the engine's stream:
48/// the whole archive flows through the pipe, deadline-bounded; it is
49/// retention, not transfer, that this listing bounds. A listing whose
50/// retained metadata exceeds [`LIST_LIMIT`] is refused as
51/// [`ContainerError::OutputTooLarge`] — a partial listing is never
52/// presented as complete. `path` naming a non-directory is a capability
53/// refusal, not an empty listing. A symlinked `path` is resolved one hop
54/// (`docker cp` reports the link itself); chains are refused.
55pub 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
73/// The first `max` bytes of the file at `path` inside the container
74/// (`head -c` semantics: a larger file yields exactly `max` bytes, never
75/// an error for size alone).
76///
77/// The capture bound is `max` plus tar framing slack; content past `max`
78/// is dropped by the bounded capture, not buffered whole. `path` naming a
79/// directory (or any non-regular entry) is a capability refusal. A
80/// symlinked `path` is resolved one hop; chains are refused.
81pub 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
117/// One captured `docker cp` archive: the retained bytes and the parsed
118/// entry headers (whose content offsets index into `bytes`).
119struct Archive {
120    bytes: Vec<u8>,
121    entries: Vec<TarEntry>,
122}
123
124/// Capture and strictly parse the archive for `path`, bounded by
125/// `limit` — the read path's explicit `max` semantics: truncation past
126/// the bound is expected and tolerated by the parse (`complete` false).
127/// Listings do not use this; they stream ([`list_once`]).
128fn 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
151/// When the archive is a lone symlink (`docker cp` does not resolve a
152/// symlinked source path), the resolved absolute target path.
153fn 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
170/// Resolve a symlink target against the link's location, the POSIX way:
171/// absolute targets replace the path, relative ones join the link's
172/// parent directory; `.` and `..` are normalized lexically. The result
173/// is always absolute.
174fn 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
192/// `docker cp`'s source spelling: the canonical id pins the container,
193/// the path rides in the same argv element — no shell ever parses it.
194fn target(id: &ContainerRef, path: &str) -> String {
195    format!("{}:{path}", id.id())
196}
197
198/// Classify a failed `docker cp`: a missing path is typed, a container
199/// that stopped mid-read is typed, anything else is bounded diagnostics.
200fn 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
218/// One streamed listing pass's answer.
219enum Listing {
220    Children(Vec<DirEntry>),
221    /// `path` itself is a symlink: the resolved absolute target. The
222    /// caller re-lists once; a second symlink answer is a chain refusal.
223    Symlink(String),
224}
225
226/// Stream and strictly parse the archive for `path`, retaining only
227/// direct-child metadata. A consumer-side refusal (capability,
228/// protocol, retention overflow) aborts the transfer rather than
229/// draining past a known answer.
230fn 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
250/// The streaming listing fold: entry headers arrive in the archive's
251/// pre-order, so a direct-child directory's subtree follows its header
252/// and is consumed without retention. `retained` counts the direct
253/// children's metadata against [`LIST_LIMIT`].
254struct ListingConsumer<'a> {
255    path: &'a str,
256    parser: tar::StreamParser,
257    /// Components of the first entry's name — the listed path itself.
258    root: Option<Vec<String>>,
259    /// Set when the listed path itself is a symlink: the resolved target.
260    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    /// Fold one stream chunk. The first entry-level error aborts the
278    /// stream: the transfer is killed, not drained past a known refusal.
279    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    /// One entry header. The archive's first entry is the listed path
297    /// itself (a file answer means `path` was not a directory); every
298    /// other entry must sit under it, and only exactly-one-level-deeper
299    /// entries are children. Anything escaping that shape is a protocol
300    /// violation, not a guess.
301    fn on_entry(&mut self, entry: StreamEntry) -> Result<(), ContainerError> {
302        if self.symlink.is_some() {
303            return Ok(()); // a symlink answer stands alone; extras are drained
304        }
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    /// The archive's first entry: a directory roots the listing, a
340    /// symlink resolves one hop, anything else is a capability refusal.
341    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    /// The stream ended and the engine reported success (the caller
372    /// checks the exit status first): validate the archive tail and
373    /// yield the listing.
374    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
387/// Path components of an archive name: `/`, `.` and empty segments carry
388/// no meaning in `docker cp` archives.
389fn 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    /// One ustar header + content blocks for a single archive entry.
426    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]); // end marker
450        out
451    }
452
453    /// Drive a listing over archive bytes in mid-sized chunks, the way
454    /// the engine's pipe delivers them.
455    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        // A direct-child directory holding megabytes of nested files:
580        // the old shape refused this past the archive bound; streaming
581        // retains only the direct children's metadata.
582        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]; // mid-content of big.bin
655        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]; // mid-header of big.bin
662        let mut consumer = ListingConsumer::new("/data");
663        consumer.feed(cut).unwrap();
664        assert!(matches!(
665            consumer.finish(),
666            Err(ContainerError::Protocol { .. })
667        ));
668    }
669}