use std::io::Read;
use std::path::{Path, PathBuf};
use bollard::exec::{CreateExecOptions, StartExecResults};
use bollard::query_parameters::{DownloadFromContainerOptions, UploadToContainerOptions};
use futures_util::StreamExt;
use super::{DockerClient, describe, time_ago};
const LIST_CAP: usize = 16 << 20;
pub const PREVIEW_LIMIT: u64 = 512 << 10;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum FileOp {
List {
container: String,
path: String,
},
Preview {
container: String,
path: String,
},
Download {
container: String,
path: String,
},
Upload {
container: String,
dir: String,
files: Vec<PathBuf>,
},
BrowseVolume {
volume: String,
},
EndBrowse {
volume: String,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FileKind {
Dir,
File,
Link,
Other,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FileEntry {
pub name: String,
pub kind: FileKind,
pub size: u64,
pub modified: String,
pub target: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Listing {
pub container_id: String,
pub path: String,
pub entries: Vec<FileEntry>,
pub complete: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Preview {
Text(String),
Binary,
TooLarge(u64),
}
pub fn join_path(dir: &str, name: &str) -> String {
if dir.ends_with('/') {
format!("{dir}{name}")
} else {
format!("{dir}/{name}")
}
}
pub fn parent_path(path: &str) -> String {
let trimmed = path.trim_end_matches('/');
match trimmed.rfind('/') {
Some(0) | None => "/".into(),
Some(i) => trimmed[..i].to_owned(),
}
}
fn parse_ls(output: &str) -> Vec<FileEntry> {
output.lines().filter_map(parse_ls_line).collect()
}
fn parse_ls_line(line: &str) -> Option<FileEntry> {
let mut rest = line;
let mut next = || {
let trimmed = rest.trim_start_matches(' ');
let end = trimmed.find(' ').unwrap_or(trimmed.len());
let (token, after) = trimmed.split_at(end);
rest = after;
(!token.is_empty()).then_some(token)
};
let perms = next()?;
let kind = match perms.chars().next()? {
'd' => FileKind::Dir,
'-' => FileKind::File,
'l' => FileKind::Link,
'c' | 'b' | 'p' | 's' => FileKind::Other,
_ => return None, };
let (_links, _owner, _group) = (next()?, next()?, next()?);
let mut size = next()?;
if size.ends_with(',') {
next()?;
size = "0";
}
let date = [next()?, next()?, next()?].join(" ");
let name = rest.strip_prefix(' ')?;
let (name, target) = match (kind, name.split_once(" -> ")) {
(FileKind::Link, Some((name, target))) => (name, Some(target.to_owned())),
_ => (name, None),
};
if name.is_empty() || name == "." || name == ".." {
return None;
}
Some(FileEntry {
name: name.to_owned(),
kind,
size: size.parse().unwrap_or(0),
modified: date,
target,
})
}
fn parse_tar_listing(bytes: &[u8]) -> Vec<FileEntry> {
let mut entries = Vec::new();
let mut archive = tar::Archive::new(bytes);
let Ok(iter) = archive.entries() else {
return entries;
};
for entry in iter.map_while(Result::ok) {
let Ok(path) = entry.path() else { continue };
let parts: Vec<_> = path.components().collect();
if parts.len() != 2 {
continue;
}
let header = entry.header();
let kind = match header.entry_type() {
t if t.is_dir() => FileKind::Dir,
t if t.is_file() => FileKind::File,
t if t.is_symlink() => FileKind::Link,
_ => FileKind::Other,
};
entries.push(FileEntry {
name: parts[1].as_os_str().to_string_lossy().into_owned(),
kind,
size: header.size().unwrap_or(0),
modified: header
.mtime()
.map(|t| time_ago(t as i64))
.unwrap_or_default(),
target: header
.link_name()
.ok()
.flatten()
.map(|t| t.to_string_lossy().into_owned()),
});
}
entries
}
fn tar_is_folder(bytes: &[u8]) -> bool {
let mut archive = tar::Archive::new(bytes);
archive
.entries()
.ok()
.and_then(|mut entries| entries.next())
.and_then(Result::ok)
.is_some_and(|entry| entry.header().entry_type().is_dir())
}
fn sort(entries: &mut [FileEntry]) {
entries.sort_by(|a, b| {
(a.kind != FileKind::Dir)
.cmp(&(b.kind != FileKind::Dir))
.then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase()))
});
}
pub(super) fn unique_path(dir: &Path, name: &str) -> PathBuf {
let first = dir.join(name);
if !first.exists() {
return first;
}
let (stem, ext) = match name.rsplit_once('.') {
Some((stem, ext)) if !stem.is_empty() => (stem, format!(".{ext}")),
_ => (name, String::new()),
};
(1..)
.map(|n| dir.join(format!("{stem} ({n}){ext}")))
.find(|p| !p.exists())
.expect("some name is free")
}
pub fn downloads_dir() -> PathBuf {
std::env::var_os("HOME")
.or_else(|| std::env::var_os("USERPROFILE"))
.map(|home| PathBuf::from(home).join("Downloads"))
.filter(|d| d.is_dir())
.unwrap_or_else(std::env::temp_dir)
}
impl DockerClient {
pub async fn list_files(&self, id: &str, path: &str) -> Result<Listing, String> {
let (mut entries, complete) = match self.list_with_ls(id, path).await {
Ok(entries) => (entries, true),
Err(ls_error) => {
let (bytes, complete) =
self.archive(id, path, LIST_CAP)
.await
.map_err(|archive_error| {
if archive_error.contains("No such") {
archive_error
} else {
ls_error
}
})?;
if !tar_is_folder(&bytes) {
return Err(format!("{path} is not a folder"));
}
(parse_tar_listing(&bytes), complete)
}
};
sort(&mut entries);
Ok(Listing {
container_id: id.to_owned(),
path: path.to_owned(),
entries,
complete,
})
}
async fn list_with_ls(&self, id: &str, path: &str) -> Result<Vec<FileEntry>, String> {
let dir = if path.ends_with('/') {
path.to_owned()
} else {
format!("{path}/")
};
let exec = self
.docker
.create_exec(
id,
CreateExecOptions {
cmd: Some(vec!["ls".into(), "-la".into(), "--".into(), dir]),
env: Some(vec!["LC_ALL=C".into()]),
attach_stdout: Some(true),
attach_stderr: Some(true),
..Default::default()
},
)
.await
.map_err(describe)?;
let StartExecResults::Attached { mut output, .. } = self
.docker
.start_exec(&exec.id, None)
.await
.map_err(describe)?
else {
return Err("ls didn't attach".into());
};
let (mut stdout, mut stderr) = (String::new(), String::new());
while let Some(chunk) = output.next().await {
match chunk.map_err(describe)? {
bollard::container::LogOutput::StdOut { message } => {
stdout.push_str(&String::from_utf8_lossy(&message));
}
bollard::container::LogOutput::StdErr { message } => {
stderr.push_str(&String::from_utf8_lossy(&message));
}
_ => {}
}
}
let code = self
.docker
.inspect_exec(&exec.id)
.await
.map_err(describe)?
.exit_code;
match code {
Some(0) => Ok(parse_ls(&stdout)),
_ => Err(stderr
.trim()
.trim_start_matches("ls: ")
.to_owned()
.replace("\n", " ")),
}
}
async fn archive(&self, id: &str, path: &str, cap: usize) -> Result<(Vec<u8>, bool), String> {
let options = DownloadFromContainerOptions {
path: path.to_owned(),
};
let mut stream = self.docker.download_from_container(id, Some(options));
let mut bytes = Vec::new();
while let Some(chunk) = stream.next().await {
bytes.extend_from_slice(&chunk.map_err(describe)?);
if bytes.len() >= cap {
return Ok((bytes, false));
}
}
Ok((bytes, true))
}
pub async fn preview_file(&self, id: &str, path: &str) -> Result<Preview, String> {
let cap = PREVIEW_LIMIT as usize + 64 * 1024;
let (bytes, _) = self.archive(id, path, cap).await?;
let mut archive = tar::Archive::new(bytes.as_slice());
let mut entry = archive
.entries()
.map_err(|e| e.to_string())?
.next()
.ok_or("The file is empty")?
.map_err(|e| e.to_string())?;
let size = entry.header().size().unwrap_or(0);
if size > PREVIEW_LIMIT {
return Ok(Preview::TooLarge(size));
}
let mut content = Vec::new();
entry.read_to_end(&mut content).map_err(|e| e.to_string())?;
if content.iter().take(8192).any(|b| *b == 0) {
return Ok(Preview::Binary);
}
Ok(Preview::Text(
String::from_utf8_lossy(&content).into_owned(),
))
}
pub async fn download_file(
&self,
id: &str,
path: &str,
dest: &Path,
) -> Result<PathBuf, String> {
let stamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or_default();
let staging = dest.join(format!(".ferrodock-download-{stamp}"));
std::fs::create_dir_all(&staging).map_err(|e| e.to_string())?;
let result = self.download_into(id, path, &staging, dest).await;
let _ = std::fs::remove_dir_all(&staging);
result
}
async fn download_into(
&self,
id: &str,
path: &str,
staging: &Path,
dest: &Path,
) -> Result<PathBuf, String> {
let options = DownloadFromContainerOptions {
path: path.to_owned(),
};
let tar_path = staging.join("archive.tar");
let mut file = std::fs::File::create(&tar_path).map_err(|e| e.to_string())?;
let mut stream = self.docker.download_from_container(id, Some(options));
while let Some(chunk) = stream.next().await {
std::io::Write::write_all(&mut file, &chunk.map_err(describe)?)
.map_err(|e| e.to_string())?;
}
drop(file);
let unpacked = staging.join("unpacked");
let (tar_path, unpacked_dir, dest) = (tar_path.clone(), unpacked.clone(), dest.to_owned());
tokio::task::spawn_blocking(move || -> Result<PathBuf, String> {
let file = std::fs::File::open(&tar_path).map_err(|e| e.to_string())?;
tar::Archive::new(file)
.unpack(&unpacked_dir)
.map_err(|e| e.to_string())?;
let top = std::fs::read_dir(&unpacked_dir)
.map_err(|e| e.to_string())?
.next()
.ok_or("The container sent nothing")?
.map_err(|e| e.to_string())?;
let name = top.file_name().to_string_lossy().into_owned();
let target = unique_path(&dest, &name);
std::fs::rename(top.path(), &target).map_err(|e| e.to_string())?;
Ok(target)
})
.await
.map_err(|e| e.to_string())?
}
pub async fn upload_files(&self, id: &str, dir: &str, files: &[PathBuf]) -> Result<(), String> {
let files = files.to_vec();
let tar = tokio::task::spawn_blocking(move || -> Result<Vec<u8>, String> {
let mut builder = tar::Builder::new(Vec::new());
for file in &files {
let name = file
.file_name()
.ok_or_else(|| format!("{} has no name", file.display()))?;
let added = if file.is_dir() {
builder.append_dir_all(name, file)
} else {
builder.append_path_with_name(file, name)
};
added.map_err(|e| format!("Couldn't read {}: {e}", file.display()))?;
}
builder.into_inner().map_err(|e| e.to_string())
})
.await
.map_err(|e| e.to_string())??;
let options = UploadToContainerOptions {
path: dir.to_owned(),
..Default::default()
};
self.docker
.upload_to_container(id, Some(options), bollard::body_full(tar.into()))
.await
.map_err(describe)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_gnu_and_busybox_ls() {
let gnu = "total 12\n\
drwxr-xr-x 2 root root 4096 Oct 17 21:02 .\n\
drwxr-xr-x 1 root root 4096 Oct 17 21:02 ..\n\
-rw-r--r-- 1 root root 612 Aug 11 2025 my notes.txt\n\
lrwxrwxrwx 1 root root 7 Oct 1 12:00 bin -> usr/bin\n\
crw-rw-rw- 1 root root 1, 3 Oct 17 21:02 null\n";
let entries = parse_ls(gnu);
assert_eq!(entries.len(), 3);
assert_eq!(entries[0].name, "my notes.txt");
assert_eq!(entries[0].size, 612);
assert_eq!(entries[0].modified, "Aug 11 2025");
assert_eq!(entries[1].kind, FileKind::Link);
assert_eq!(entries[1].target.as_deref(), Some("usr/bin"));
assert_eq!(
(entries[2].name.as_str(), entries[2].kind),
("null", FileKind::Other)
);
let busybox = "drwxr-xr-x 2 root root 4096 Oct 17 21:02 conf.d\n";
let entries = parse_ls(busybox);
assert_eq!(entries[0].name, "conf.d");
assert_eq!(entries[0].kind, FileKind::Dir);
}
#[test]
fn reads_a_folder_from_its_tar() {
let mut builder = tar::Builder::new(Vec::new());
let mut add = |path: &str, data: &[u8]| {
let mut header = tar::Header::new_gnu();
header.set_size(data.len() as u64);
header.set_mode(0o644);
header.set_cksum();
builder.append_data(&mut header, path, data).unwrap();
};
add("etc/hosts", b"127.0.0.1 localhost\n");
add("etc/ssl/cert.pem", b"x");
let bytes = builder.into_inner().unwrap();
assert!(!tar_is_folder(&bytes));
let entries = parse_tar_listing(&bytes);
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].name, "hosts");
assert_eq!(entries[0].size, 20);
}
#[test]
fn joins_paths() {
assert_eq!(join_path("/", "etc"), "/etc");
assert_eq!(join_path("/etc", "hosts"), "/etc/hosts");
assert_eq!(parent_path("/etc/nginx"), "/etc");
assert_eq!(parent_path("/etc"), "/");
assert_eq!(parent_path("/"), "/");
}
#[test]
fn never_overwrites_downloads() {
let dir = std::env::temp_dir().join(format!("ferrodock-unique-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("a.conf"), "").unwrap();
assert_eq!(unique_path(&dir, "a.conf"), dir.join("a (1).conf"));
assert_eq!(unique_path(&dir, "b"), dir.join("b"));
std::fs::remove_dir_all(&dir).unwrap();
}
#[tokio::test]
#[ignore]
async fn live_daemon_copies_files_in_and_out() {
let client = DockerClient::connect().unwrap();
let Some(image) = client.list_images().await.unwrap().into_iter().next() else {
return;
};
let spec = crate::docker::RunSpec {
image: image.id,
name: "ferrodock-files".into(),
command: "sleep 300".into(),
..Default::default()
};
let opts = || bollard::query_parameters::RemoveContainerOptions {
force: true,
..Default::default()
};
let _ = client
.docker
.remove_container(&spec.name, Some(opts()))
.await;
let id = client.run(&spec).await.unwrap();
let result = copy_in_and_out(&client, &id).await;
client
.docker
.remove_container(&id, Some(opts()))
.await
.unwrap();
result.unwrap();
}
async fn copy_in_and_out(client: &DockerClient, id: &str) -> Result<(), String> {
let work = std::env::temp_dir().join(format!("ferrodock-files-{}", std::process::id()));
std::fs::create_dir_all(&work).unwrap();
let local = work.join("ferrodock-test.txt");
std::fs::write(&local, "hello from ferrodock\n").unwrap();
client.upload_files(id, "/etc", &[local]).await.unwrap();
let listing = client.list_files(id, "/etc").await.unwrap();
assert!(
listing
.entries
.iter()
.any(|e| e.name == "ferrodock-test.txt")
);
let preview = client
.preview_file(id, "/etc/ferrodock-test.txt")
.await
.unwrap();
assert_eq!(preview, Preview::Text("hello from ferrodock\n".into()));
let saved = client
.download_file(id, "/etc/ferrodock-test.txt", &work)
.await
.unwrap();
assert_eq!(saved, work.join("ferrodock-test (1).txt"));
assert_eq!(
std::fs::read_to_string(&saved).unwrap(),
"hello from ferrodock\n"
);
let folder = client.download_file(id, "/etc", &work).await.unwrap();
assert!(folder.join("hostname").exists() || folder.join("hosts").exists());
std::fs::remove_dir_all(&work).unwrap();
client.perform(crate::docker::Action::Stop, id).await?;
let listing = client.list_files(id, "/etc").await?;
assert!(
listing
.entries
.iter()
.any(|e| e.name == "ferrodock-test.txt")
);
Ok(())
}
}