use crate::ssh_master::Runner;
use std::path::{Path, PathBuf};
use std::time::Duration;
pub const LIST_TIMEOUT: Duration = Duration::from_secs(5);
pub const REPOS_TIMEOUT: Duration = Duration::from_secs(10);
const LIST_CAP: usize = 500;
const REPOS_CAP: usize = 200;
const EXPAND: &str = r#"p=$1
case $p in "~"|"") p=$HOME ;; "~/"*) p=$HOME/${p#"~/"} ;; esac"#;
fn script_line(script: &str, arg: &str) -> String {
use crate::sandbox::sh_quote;
format!("exec sh -c {} cctop {}", sh_quote(script), sh_quote(arg))
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Entry {
pub name: String,
pub readable: bool,
pub writable: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct Listing {
pub resolved: String,
pub dirs: Vec<Entry>,
}
pub fn list(runner: &dyn Runner, host: &str, dir: &str) -> Result<Option<Listing>, String> {
let script = format!(
r#"{EXPAND}
cd -- "$p" 2>/dev/null || {{ echo missing; exit 0; }}
printf 'dir=%s\n' "$(pwd -P)"
n=0
for f in * .*; do
case $f in .|..) continue ;; esac
[ -d "$f" ] || continue
m=-; [ -r "$f" ] && [ -x "$f" ] && m=r
[ -w "$f" ] && m=${{m}}w
printf '%s\t%s\n' "$m" "$f"
n=$((n+1)); [ $n -ge {LIST_CAP} ] && break
done
exit 0"#
);
let out = runner.run(host, &script_line(&script, dir), LIST_TIMEOUT)?;
Ok(parse_listing(&out))
}
fn parse_listing(out: &str) -> Option<Listing> {
let mut lines = out.lines();
let resolved = lines.next()?.strip_prefix("dir=")?.to_string();
let mut dirs: Vec<Entry> = lines
.filter_map(|line| {
let (mode, name) = line.split_once('\t')?;
(!name.is_empty()).then(|| Entry {
name: name.to_string(),
readable: mode.starts_with('r'),
writable: mode.ends_with('w'),
})
})
.collect();
dirs.sort_by(|a, b| a.name.cmp(&b.name));
Some(Listing { resolved, dirs })
}
pub fn repos(runner: &dyn Runner, host: &str) -> Result<Vec<String>, String> {
let script = format!(
r#"cd || exit 0
t=; command -v timeout >/dev/null 2>&1 && t="timeout {secs}"
$t find . -mindepth 1 -maxdepth 4 -name .git -print -prune -o -name '.*' -prune 2>/dev/null | head -n {REPOS_CAP}"#,
secs = REPOS_TIMEOUT.as_secs().saturating_sub(1).max(1),
);
let out = runner.run(host, &script_line(&script, ""), REPOS_TIMEOUT)?;
Ok(parse_repos(&out))
}
fn parse_repos(out: &str) -> Vec<String> {
let mut found: Vec<String> = out
.lines()
.filter_map(|line| {
let rel = line.strip_prefix("./")?.strip_suffix("/.git")?;
(!rel.is_empty()).then(|| format!("~/{rel}"))
})
.collect();
found.sort();
let mut kept: Vec<String> = Vec::new();
for repo in found {
if !kept
.iter()
.any(|outer| repo.starts_with(&format!("{outer}/")))
{
kept.push(repo);
}
}
kept
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct RemoteFacts {
pub exists: bool,
pub is_dir: bool,
pub readable: bool,
pub writable: bool,
pub resolved: Option<String>,
}
pub fn check(runner: &dyn Runner, host: &str, path: &str) -> Result<RemoteFacts, String> {
let script = format!(
r#"{EXPAND}
[ -e "$p" ] && echo exists
[ -d "$p" ] && echo dir
[ -r "$p" ] && [ -x "$p" ] && echo readable
[ -w "$p" ] && echo writable
cd -- "$p" 2>/dev/null && printf 'root=%s\n' "$(pwd -P)"
exit 0"#
);
let out = runner.run(host, &script_line(&script, path), LIST_TIMEOUT)?;
Ok(parse_check(&out))
}
pub fn parse_check(out: &str) -> RemoteFacts {
let mut facts = RemoteFacts::default();
for line in out.lines() {
match line {
"exists" => facts.exists = true,
"dir" => facts.is_dir = true,
"readable" => facts.readable = true,
"writable" => facts.writable = true,
_ => {
if let Some(root) = line.strip_prefix("root=").filter(|r| r.starts_with('/')) {
facts.resolved = Some(root.to_string());
}
}
}
}
facts
}
pub fn home(runner: &dyn Runner, host: &str) -> Result<String, String> {
let out = runner.run(host, "printf '%s\\n' \"$HOME\"", LIST_TIMEOUT)?;
let home = out.lines().next().unwrap_or_default().trim().to_string();
match home.starts_with('/') {
true => Ok(home),
false => Err("the host did not say where its home is".to_string()),
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Mount {
pub point: PathBuf,
pub fstype: String,
}
pub fn parse_mountinfo(table: &str) -> Vec<Mount> {
table
.lines()
.filter_map(|line| {
let (left, right) = line.split_once(" - ")?;
let point = left.split(' ').nth(4)?;
let fstype = right.split(' ').next()?;
Some(Mount {
point: PathBuf::from(unescape_mountinfo(point)),
fstype: fstype.to_string(),
})
})
.collect()
}
pub fn mounts_here() -> Vec<Mount> {
std::fs::read_to_string("/proc/self/mountinfo")
.map(|t| parse_mountinfo(&t))
.unwrap_or_default()
}
pub fn unescape_mountinfo(field: &str) -> String {
let bytes = field.as_bytes();
let mut out = Vec::with_capacity(bytes.len());
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'\\'
&& i + 4 <= bytes.len()
&& let Ok(code) = u8::from_str_radix(&field[i + 1..i + 4], 8)
{
out.push(code);
i += 4;
continue;
}
out.push(bytes[i]);
i += 1;
}
String::from_utf8_lossy(&out).into_owned()
}
fn foreign(fstype: &str) -> bool {
fstype.starts_with("fuse")
|| fstype.starts_with("nfs")
|| fstype.starts_with("cgroup")
|| matches!(
fstype,
"9p" | "v9fs"
| "drvfs"
| "cifs"
| "smb3"
| "smbfs"
| "autofs"
| "proc"
| "sysfs"
| "devtmpfs"
| "devpts"
| "securityfs"
| "debugfs"
| "tracefs"
| "configfs"
| "binfmt_misc"
| "mqueue"
| "hugetlbfs"
| "pstore"
| "bpf"
| "squashfs"
| "iso9660"
)
}
const SYSTEM: [&str; 4] = ["/proc", "/sys", "/dev", "/run"];
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Here {
Missing { creatable: bool },
Dir { empty: bool },
NotDir,
StaleFuse,
}
pub fn here(path: &Path) -> Here {
match std::fs::metadata(path) {
Ok(meta) if !meta.is_dir() => Here::NotDir,
Ok(_) => Here::Dir {
empty: std::fs::read_dir(path).is_ok_and(|mut d| d.next().is_none()),
},
Err(e) if e.raw_os_error() == Some(libc::ENOTCONN) => Here::StaleFuse,
Err(_) => Here::Missing {
creatable: path
.ancestors()
.skip(1)
.find(|dir| dir.exists())
.is_some_and(writable),
},
}
}
fn writable(dir: &Path) -> bool {
let Ok(c) = std::ffi::CString::new(dir.as_os_str().as_encoded_bytes()) else {
return false;
};
unsafe { libc::access(c.as_ptr(), libc::W_OK | libc::X_OK) == 0 }
}
pub fn verdict(remote: &RemoteFacts) -> Result<(), String> {
if !remote.exists {
return Err("does not exist on the host".into());
}
if !remote.is_dir {
return Err("not a directory on the host".into());
}
if !remote.readable {
return Err("not readable on the host".into());
}
if !remote.writable {
return Err("read-only on the host".into());
}
Ok(())
}
pub fn mount_problem(at: &Path, mounts: &[Mount], here: &Here) -> Option<String> {
if at == Path::new("/") {
return Some("the root directory".into());
}
if let Some(sys) = SYSTEM.iter().find(|s| at.starts_with(s)) {
return Some(format!("{sys} is system plumbing here"));
}
let holder = mounts
.iter()
.rev()
.filter(|m| at.starts_with(&m.point))
.max_by_key(|m| m.point.components().count());
if let Some(m) = holder {
if m.point == at {
if !(m.fstype.starts_with("fuse") && *here == Here::StaleFuse) {
return Some(format!("mounted here already ({})", m.fstype));
}
} else if foreign(&m.fstype) {
return Some(format!(
"inside {}, mounted here ({})",
m.point.display(),
m.fstype
));
}
}
if let Some(m) = mounts
.iter()
.find(|m| m.point != at && m.point.starts_with(at))
{
return Some(format!(
"would cover {}, mounted here ({})",
m.point.display(),
m.fstype
));
}
match here {
Here::NotDir => Some("a file here, not a directory".into()),
Here::Dir { empty: false } => Some("not empty here".into()),
Here::Missing { creatable: false } => Some("can't be created here".into()),
Here::Dir { empty: true } | Here::Missing { creatable: true } | Here::StaleFuse => None,
}
}
const SLOTS: u32 = 9;
const POINT_MAX: usize = 2048;
pub fn mount_point(
host: &str,
root: &Path,
mounts: &[Mount],
here: &dyn Fn(&Path) -> Here,
base: &Path,
) -> Result<PathBuf, String> {
if mount_problem(root, mounts, &here(root)).is_none() {
return Ok(root.to_path_buf());
}
let mut why = String::new();
for slot in 1..=SLOTS {
let at = cache_point(base, host, root, slot);
match mount_problem(&at, mounts, &here(&at)) {
None => return Ok(at),
Some(problem) => why = format!("{}: {problem}", at.display()),
}
}
Err(format!("nowhere to mount it here — {why}"))
}
pub fn cache_point(base: &Path, host: &str, root: &Path, slot: u32) -> PathBuf {
let mut name: String = host
.chars()
.map(
|c| match c.is_ascii_alphanumeric() || "._-@:+,=".contains(c) {
true => c,
false => '_',
},
)
.collect();
if matches!(name.as_str(), "" | "." | "..") {
name = format!("_{name}");
}
if name.len() > 200 {
name = format!("{}-{:012x}", &name[..180], fnv(host.as_bytes()));
}
if slot > 1 {
name = format!("{name}~{slot}");
}
let dir = base.join(name);
let rel = root.strip_prefix("/").unwrap_or(root);
let full = dir.join(rel);
match full.as_os_str().len() <= POINT_MAX {
true => full,
false => {
let last = root
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default();
dir.join(format!(
"long-{:012x}",
fnv(root.as_os_str().as_encoded_bytes())
))
.join(last)
}
}
}
fn fnv(bytes: &[u8]) -> u64 {
let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
for byte in bytes {
hash ^= u64::from(*byte);
hash = hash.wrapping_mul(0x0100_0000_01b3);
}
hash & 0xffff_ffff_ffff
}
#[cfg(test)]
pub struct LocalSh {
pub home: PathBuf,
}
#[cfg(test)]
impl Runner for LocalSh {
fn run(&self, _host: &str, command: &str, timeout: Duration) -> Result<String, String> {
let mut cmd = std::process::Command::new("sh");
cmd.arg("-c")
.arg(command)
.env("HOME", &self.home)
.current_dir(&self.home);
crate::ssh_master::run_bounded(cmd, timeout)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn fake_home() -> (tempfile::TempDir, LocalSh) {
let dir = tempfile::tempdir().expect("tempdir");
let home = dir.path().canonicalize().expect("canonical");
(dir, LocalSh { home })
}
#[test]
fn a_listing_names_the_subdirectories_and_what_can_be_done_in_them() {
let (_keep, sh) = fake_home();
for d in ["src", "docs", ".hidden", "with space"] {
std::fs::create_dir(sh.home.join(d)).expect("mkdir");
}
std::fs::write(sh.home.join("file"), "").expect("file");
std::os::unix::fs::symlink(sh.home.join("src"), sh.home.join("link")).expect("link");
let ro = sh.home.join("docs");
std::fs::set_permissions(&ro, std::os::unix::fs::PermissionsExt::from_mode(0o555))
.expect("chmod");
let listing = list(&sh, "box", "~").expect("ran").expect("there");
assert_eq!(listing.resolved, sh.home.to_string_lossy());
let names: Vec<&str> = listing.dirs.iter().map(|e| e.name.as_str()).collect();
assert_eq!(names, [".hidden", "docs", "link", "src", "with space"]);
let docs = &listing.dirs[1];
assert!(docs.readable);
if unsafe { libc::getuid() } != 0 {
assert!(!docs.writable, "{docs:?}");
}
assert!(listing.dirs[3].writable);
let src = list(&sh, "box", "~/src").expect("ran").expect("there");
assert!(src.dirs.is_empty());
assert_eq!(list(&sh, "box", "~/nope").expect("ran"), None);
std::fs::set_permissions(&ro, std::os::unix::fs::PermissionsExt::from_mode(0o755))
.expect("chmod back");
}
#[test]
fn the_repository_scan_finds_checkouts_and_not_what_is_inside_them() {
let (_keep, sh) = fake_home();
for repo in [
"api",
"code/web",
"code/web/vendor/sub",
"a/b/c",
".config/x",
] {
std::fs::create_dir_all(sh.home.join(repo).join(".git")).expect("repo");
}
std::fs::create_dir_all(sh.home.join("a/b/c/d/.git")).expect("deep");
let found = repos(&sh, "box").expect("ran");
assert_eq!(found, ["~/a/b/c", "~/api", "~/code/web"]);
}
#[test]
fn a_check_reports_what_the_host_has_at_the_path() {
let (_keep, sh) = fake_home();
std::fs::create_dir(sh.home.join("work")).expect("mkdir");
std::fs::write(sh.home.join("f"), "").expect("file");
let work = check(&sh, "box", "~/work").expect("ran");
assert!(work.exists && work.is_dir && work.readable && work.writable);
assert_eq!(
work.resolved.as_deref(),
Some(sh.home.join("work").to_str().expect("utf8"))
);
let file = check(&sh, "box", "~/f").expect("ran");
assert!(file.exists && !file.is_dir);
assert_eq!(
check(&sh, "box", "~/gone").expect("ran"),
RemoteFacts::default()
);
assert_eq!(
home(&sh, "box").as_deref(),
Ok(sh.home.to_str().expect("utf8"))
);
}
const WSL: &str = "\
22 1 8:32 / / rw,relatime - ext4 /dev/sdc rw
23 22 0:20 / /proc rw - proc proc rw
40 22 0:44 / /mnt/c rw,noatime - 9p C:\\134 rw
41 22 0:45 / /mnt/wsl rw - tmpfs none rw
50 22 0:50 / /home/f/remote rw,nosuid - fuse.sshfs box:/x rw
51 22 0:51 / /srv/data rw - nfs4 nas:/data rw
52 22 0:52 / /tmp rw - tmpfs tmpfs rw";
fn ok_remote() -> RemoteFacts {
RemoteFacts {
exists: true,
is_dir: true,
readable: true,
writable: true,
resolved: None,
}
}
fn problem(path: &str, here: Here) -> Option<String> {
mount_problem(Path::new(path), &parse_mountinfo(WSL), &here)
}
const FREE: Here = Here::Missing { creatable: true };
#[test]
fn a_path_that_is_mounted_here_cannot_be_mounted_on() {
assert_eq!(problem("/home/f/work", FREE), None);
assert_eq!(problem("/tmp/x", FREE), None, "tmpfs is this machine's own");
let reason = |p: &str, h: Here| problem(p, h).expect(p);
assert_eq!(reason("/mnt/c", FREE), "mounted here already (9p)");
assert!(reason("/mnt/c/Users/f", FREE).contains("inside /mnt/c"));
assert!(reason("/srv/data/x", FREE).contains("nfs4"));
assert!(reason("/home/f/remote/sub", FREE).contains("fuse.sshfs"));
assert_eq!(
reason("/home/f/remote", Here::Dir { empty: true }),
"mounted here already (fuse.sshfs)"
);
assert_eq!(problem("/home/f/remote", Here::StaleFuse), None);
assert!(reason("/mnt", FREE).contains("would cover /mnt/c"));
assert!(reason("/", FREE).contains("root"));
assert!(reason("/proc/1", FREE).contains("/proc"));
assert!(reason("/run/user/1000/x", FREE).contains("/run"));
assert!(reason("/dev/shm/x", FREE).contains("/dev"));
}
#[test]
fn a_path_has_to_be_somewhere_the_mount_can_go() {
let reason = |h: Here| problem("/home/f/work", h).expect("refused");
assert_eq!(problem("/home/f/work", Here::Dir { empty: true }), None);
assert!(reason(Here::Dir { empty: false }).contains("not empty"));
assert!(reason(Here::NotDir).contains("file"));
assert!(reason(Here::Missing { creatable: false }).contains("created"));
}
#[test]
fn a_path_has_to_be_usable_on_the_host() {
let with = |r: RemoteFacts| verdict(&r).expect_err("refused");
assert_eq!(verdict(&ok_remote()), Ok(()));
assert!(with(RemoteFacts::default()).contains("does not exist"));
assert!(
with(RemoteFacts {
is_dir: false,
..ok_remote()
})
.contains("not a directory")
);
assert!(
with(RemoteFacts {
readable: false,
..ok_remote()
})
.contains("not readable")
);
assert!(
with(RemoteFacts {
writable: false,
..ok_remote()
})
.contains("read-only")
);
}
const BASE: &str = "/home/f/.cache/cctop/remote";
fn pick(root: &str, table: &str, here: &dyn Fn(&Path) -> Here) -> Result<PathBuf, String> {
mount_point(
"procdb",
Path::new(root),
&parse_mountinfo(table),
here,
Path::new(BASE),
)
}
#[test]
fn the_same_path_is_used_when_it_can_be() {
assert_eq!(
pick("/srv/api", WSL, &|_| FREE),
Ok(PathBuf::from("/srv/api"))
);
assert_eq!(
pick("/home/f/empty", WSL, &|_| Here::Dir { empty: true }),
Ok(PathBuf::from("/home/f/empty"))
);
}
#[test]
fn a_path_that_cannot_be_mounted_on_goes_under_the_cache() {
let cached = PathBuf::from(format!("{BASE}/procdb/home/florian.leprat"));
let root_owned = |p: &Path| match p.starts_with(BASE) {
true => FREE,
false => Here::Missing { creatable: false },
};
assert_eq!(
pick("/home/florian.leprat", WSL, &root_owned),
Ok(cached.clone())
);
let full = |p: &Path| match p.starts_with(BASE) {
true => FREE,
false => Here::Dir { empty: false },
};
assert_eq!(pick("/home/florian.leprat", WSL, &full), Ok(cached));
for (root, rel) in [
("/mnt/c/Users/f", "mnt/c/Users/f"),
("/home/f/remote", "home/f/remote"),
("/", ""),
] {
let want = Path::new(BASE).join("procdb").join(rel);
assert_eq!(pick(root, WSL, &|_| FREE), Ok(want), "{root}");
}
}
#[test]
fn a_taken_cache_point_moves_to_the_next_slot() {
let first = format!("{BASE}/procdb/home/x");
let table = format!("{WSL}\n60 22 0:60 / {first} rw,nosuid - fuse.sshfs procdb:/home/x rw");
let busy = |p: &Path| match p.starts_with(BASE) {
true if p == Path::new(&first) => Here::Dir { empty: true },
true => FREE,
false => Here::Missing { creatable: false },
};
assert_eq!(
pick("/home/x", &table, &busy),
Ok(PathBuf::from(format!("{BASE}/procdb~2/home/x")))
);
let stale = |p: &Path| match p.starts_with(BASE) {
true if p == Path::new(&first) => Here::StaleFuse,
true => FREE,
false => Here::Missing { creatable: false },
};
assert_eq!(pick("/home/x", &table, &stale), Ok(PathBuf::from(&first)));
let outer =
format!("{WSL}\n61 22 0:61 / {BASE}/procdb/home rw - fuse.sshfs procdb:/home rw");
assert_eq!(
pick("/home/x", &outer, &busy),
Ok(PathBuf::from(format!("{BASE}/procdb~2/home/x")))
);
}
#[test]
fn a_cache_point_is_a_safe_and_bounded_name() {
let base = Path::new(BASE);
let at = |host: &str, root: &str| cache_point(base, host, Path::new(root), 1);
assert_eq!(
at("me@10.0.0.5", "/srv"),
Path::new(BASE).join("me@10.0.0.5/srv")
);
assert_eq!(at("[::1]", "/srv"), Path::new(BASE).join("_::1_/srv"));
assert_eq!(at("..", "/srv"), Path::new(BASE).join("_../srv"));
assert_eq!(at("a/b", "/srv"), Path::new(BASE).join("a_b/srv"));
assert_eq!(at("procdb", "/home/x"), at("procdb", "/home/x"));
let long_host = "h".repeat(300);
let name = at(&long_host, "/srv");
let host_part = name
.strip_prefix(base)
.expect("under base")
.components()
.next()
.expect("host");
assert!(host_part.as_os_str().len() <= 255, "{name:?}");
let deep = format!("/{}/end", "d/".repeat(1200));
let short = at("procdb", &deep);
assert!(short.as_os_str().len() <= POINT_MAX, "{short:?}");
assert!(short.ends_with("end"));
assert_eq!(short, at("procdb", &deep));
}
#[test]
fn here_reads_what_is_at_a_path() {
let dir = tempfile::tempdir().expect("tempdir");
assert_eq!(here(dir.path()), Here::Dir { empty: true });
std::fs::write(dir.path().join("f"), "").expect("file");
assert_eq!(here(dir.path()), Here::Dir { empty: false });
assert_eq!(here(&dir.path().join("f")), Here::NotDir);
assert_eq!(
here(&dir.path().join("a/b")),
Here::Missing { creatable: true }
);
}
}