use std::fs;
use std::path::{Component, Path, PathBuf};
use std::process::Command;
use std::sync::atomic::AtomicBool;
use std::time::{Duration, SystemTime};
use super::{Entry, Location, Remote};
use crate::text::unix_seconds;
pub(super) const SSH_TIMEOUT: Duration = Duration::from_secs(15);
const SSH_OPTIONS: [&str; 5] = ["-o", "BatchMode=yes", "-o", "ConnectTimeout=8", "-T"];
pub(super) const MARKER: &[u8] = b"KRAAL:LIST\0";
const MAX_LISTING: usize = 16 << 20;
pub(super) type Listing = Result<(Location, Vec<Entry>), String>;
#[derive(Debug, Clone)]
pub(super) struct Ssh {
pub(super) program: String,
pub(super) timeout: Duration,
}
impl Default for Ssh {
fn default() -> Self {
Ssh { program: "ssh".to_owned(), timeout: SSH_TIMEOUT }
}
}
pub(super) fn list(location: &Location, ssh: &Ssh, cancel: &AtomicBool) -> Listing {
match location {
Location::Local(path) => {
let (path, entries) = list_local(path)?;
Ok((Location::Local(path), entries))
}
Location::Remote(remote) => {
let (path, entries) = list_remote(remote, ssh, cancel)?;
Ok((Location::Remote(remote.with_path(path)), entries))
}
}
}
pub(super) fn list_local(path: &Path) -> Result<(PathBuf, Vec<Entry>), String> {
let path = resolve_local(path);
let reader = fs::read_dir(&path).map_err(|err| format!("cannot list {}: {err}", path.display()))?;
let entries = reader
.filter_map(Result::ok)
.map(|entry| {
let entry_path = entry.path();
let own = fs::symlink_metadata(&entry_path).ok();
let is_link = own.as_ref().is_some_and(|m| m.file_type().is_symlink());
let is_dir = if is_link {
fs::metadata(&entry_path).is_ok_and(|m| m.is_dir())
} else {
own.as_ref().is_some_and(fs::Metadata::is_dir)
};
let raw = entry.file_name();
Entry {
name: raw.to_string_lossy().into_owned(),
is_dir,
is_link,
size: own.as_ref().map_or(0, fs::Metadata::len),
mtime: own.and_then(|m| m.modified().ok()).map_or(0, unix_seconds),
exact: raw.to_str().is_some(),
}
})
.collect();
Ok((path, entries))
}
pub(super) fn resolve_local(path: &Path) -> PathBuf {
let expanded = match (path.strip_prefix("~"), home_dir()) {
(Ok(rest), Some(home)) => home.join(rest),
_ => path.to_path_buf(),
};
let absolute = std::path::absolute(&expanded).unwrap_or(expanded);
let mut normal = PathBuf::new();
for component in absolute.components() {
match component {
Component::CurDir => {}
Component::ParentDir => {
normal.pop();
}
other => normal.push(other.as_os_str()),
}
}
normal
}
pub(super) fn home_dir() -> Option<PathBuf> {
std::env::var_os("HOME").filter(|home| !home.is_empty()).map(PathBuf::from)
}
pub(super) fn list_remote(remote: &Remote, ssh: &Ssh, cancel: &AtomicBool) -> Result<(String, Vec<Entry>), String> {
let args = ssh_command_args(remote).ok_or_else(|| format!("no ssh destination for {}", remote.server))?;
let mut cmd = Command::new(&ssh.program);
cmd.args(&args);
let output = crate::exec::run_cancellable(&mut cmd, ssh.timeout, MAX_LISTING, cancel)
.map_err(|err| format!("cannot run {}: {err}", ssh.program))?;
if output.cancelled {
return Err("cancelled".to_owned());
}
if output.timed_out {
return Err(format!("timed out after {:?}", ssh.timeout));
}
if output.truncated {
return Err(format!("too many entries to list here (over {} MiB): use a shell (key s)", MAX_LISTING >> 20));
}
if !output.success() {
return Err(failure_message(&output));
}
parse_listing(&output.stdout, unix_seconds(SystemTime::now()))
}
pub(super) fn ssh_command_args(remote: &Remote) -> Option<Vec<String>> {
let (destination, options) = remote.ssh_args.split_last()?;
let mut args = options.to_vec();
args.extend(SSH_OPTIONS.iter().map(|option| (*option).to_owned()));
args.push(destination.clone());
args.push(remote_command(&remote.path));
Some(args)
}
pub(super) fn remote_command(path: &str) -> String {
crate::shell::sh_c(&remote_script(path))
}
pub(super) fn remote_script(path: &str) -> String {
let cd = format!("cd -- {} || exit 1", remote_dir_arg(path));
[
cd.as_str(),
"printf 'KRAAL:LIST\\0%s\\0' \"$PWD\"",
"if find . -maxdepth 0 -printf '' >/dev/null 2>&1",
"then printf 'F\\0'",
"find . -mindepth 1 -maxdepth 1 -printf '%y%Y\\t%s\\t%T@\\t%f\\0' || exit 1",
"else printf 'S\\0'",
"for f in .* *; do case $f in .|..) continue;; esac; \
[ -e \"$f\" ] || [ -L \"$f\" ] || continue; \
if [ -L \"$f\" ]; then k=l; elif [ -d \"$f\" ]; then k=d; else k=f; fi; \
if [ -d \"$f\" ]; then t=d; else t=f; fi; \
m=$(stat -f %m -- \"$f\" 2>/dev/null); \
set -- $(LC_ALL=C TZ=UTC0 ls -ldn -- \"$f\" 2>/dev/null); \
case $m in ''|*[!0-9]*) d=\"$6 $7 $8\";; *) d=\"@$m\";; esac; \
printf '%s%s\\t%s\\t%s\\t%s\\0' \"$k\" \"$t\" \"$5\" \"$d\" \"$f\"; done",
"fi",
"exit 0",
]
.join("; ")
}
pub(crate) fn remote_dir_arg(path: &str) -> String {
if path.is_empty() || path == "~" {
return "\"$HOME\"".to_owned();
}
if let Some(rest) = path.strip_prefix("~/") {
return format!("\"$HOME\"/{}", crate::shell::quote_always(rest));
}
if let Some(rest) = path.strip_prefix('~') {
let (user, tail) = rest.split_once('/').unwrap_or((rest, ""));
if !user.is_empty() && user.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-')) {
return format!("~{user}/{}", crate::shell::quote_always(tail));
}
}
crate::shell::quote_always(path)
}
pub(super) fn failure_message(output: &crate::exec::Output) -> String {
let stderr = String::from_utf8_lossy(&output.stderr);
let lines: Vec<&str> = stderr.lines().map(str::trim).filter(|line| !line.is_empty()).collect();
if lines.is_empty() {
return match output.code {
Some(255) => "ssh failed (exit status 255)".to_owned(),
Some(code) => format!("listing failed (exit status {code})"),
None => "listing failed (killed by a signal)".to_owned(),
};
}
lines[lines.len().saturating_sub(3)..].join("\n")
}
pub(super) fn parse_listing(stdout: &[u8], now: i64) -> Result<(String, Vec<Entry>), String> {
let unexpected = || "the server's answer is not a listing".to_owned();
let start = stdout.windows(MARKER.len()).position(|window| window == MARKER).ok_or_else(unexpected)?;
let mut fields = stdout[start + MARKER.len()..].splitn(3, |&b| b == 0);
let raw_dir = fields.next().ok_or_else(unexpected)?;
let mode = fields.next().ok_or_else(unexpected)?;
let body = fields.next().unwrap_or_default();
if raw_dir.is_empty() {
return Err(unexpected());
}
let dir = std::str::from_utf8(raw_dir)
.map_err(|_| {
"this directory's path is not valid UTF-8: kraal cannot point at it safely; use a shell".to_owned()
})?
.to_owned();
let mut records: Vec<&[u8]> = body.split(|&b| b == 0).collect();
records.pop();
let entries = match mode {
b"F" => records.into_iter().filter_map(parse_find_record).collect(),
b"S" => records.into_iter().filter_map(|r| parse_shell_record(r, now)).collect(),
_ => return Err(unexpected()),
};
Ok((dir, entries))
}
pub(super) fn parse_find_record(record: &[u8]) -> Option<Entry> {
let mut fields = record.splitn(4, |&b| b == b'\t');
let (kind, size, mtime, name) = (fields.next()?, fields.next()?, fields.next()?, fields.next()?);
let exact = std::str::from_utf8(name).is_ok();
let name = String::from_utf8_lossy(name).into_owned();
if name.is_empty() || name == "." || name == ".." {
return None;
}
let own = *kind.first()?;
let target = kind.get(1).copied().unwrap_or(own);
let mtime = std::str::from_utf8(mtime).ok()?.split('.').next()?.parse().unwrap_or(0);
Some(Entry {
name,
is_dir: target == b'd',
is_link: own == b'l',
size: std::str::from_utf8(size).ok().and_then(|s| s.parse().ok()).unwrap_or(0),
mtime,
exact,
})
}
pub(super) fn parse_shell_record(record: &[u8], now: i64) -> Option<Entry> {
let mut fields = record.splitn(4, |&b| b == b'\t');
let (kind, size, date, name) = (fields.next()?, fields.next()?, fields.next()?, fields.next()?);
if name.is_empty() || name == b"." || name == b".." {
return None;
}
let date = std::str::from_utf8(date).ok()?;
let mut parts = date.split_whitespace();
let mtime = match (date.strip_prefix('@'), parts.next(), parts.next(), parts.next()) {
(Some(secs), ..) => secs.parse().unwrap_or(0),
(None, Some(m), Some(d), Some(t)) => ls_time(m, d, t, now).unwrap_or(0),
_ => 0,
};
Some(Entry {
name: String::from_utf8_lossy(name).into_owned(),
is_dir: kind.get(1) == Some(&b'd'),
is_link: kind.first() == Some(&b'l'),
size: std::str::from_utf8(size).ok().and_then(|s| s.parse().ok()).unwrap_or(0),
mtime,
exact: std::str::from_utf8(name).is_ok(),
})
}
pub(super) fn ls_time(month: &str, day: &str, time: &str, now: i64) -> Option<i64> {
const MONTHS: [&str; 12] = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
let month = u32::try_from(MONTHS.iter().position(|m| *m == month)? + 1).ok()?;
let day: u32 = day.parse().ok().filter(|d| (1..=31).contains(d))?;
if let Some((hour, minute)) = time.split_once(':') {
let hour = hour.parse::<i64>().ok().filter(|h| (0..24).contains(h))?;
let minute = minute.parse::<i64>().ok().filter(|m| (0..60).contains(m))?;
let clock = hour * 3600 + minute * 60;
let (year, _, _) = civil_from_days(now.div_euclid(86_400));
let this_year = days_from_civil(year, month, day) * 86_400 + clock;
Some(if this_year > now.saturating_add(86_400) {
days_from_civil(year - 1, month, day) * 86_400 + clock
} else {
this_year
})
} else {
let year = time.parse::<i64>().ok().filter(|y| (0..=1_000_000).contains(y))?;
Some(days_from_civil(year, month, day) * 86_400)
}
}
pub(super) fn days_from_civil(year: i64, month: u32, day: u32) -> i64 {
let year = if month <= 2 { year - 1 } else { year };
let era = year.div_euclid(400);
let year_of_era = year - era * 400;
let month = i64::from(month);
let day_of_year = (153 * (if month > 2 { month - 3 } else { month + 9 }) + 2) / 5 + i64::from(day) - 1;
let day_of_era = year_of_era * 365 + year_of_era / 4 - year_of_era / 100 + day_of_year;
era * 146_097 + day_of_era - 719_468
}
pub(super) fn civil_from_days(days: i64) -> (i64, u32, u32) {
let days = days.saturating_add(719_468);
let era = days.div_euclid(146_097);
let day_of_era = days - era * 146_097;
let year_of_era = (day_of_era - day_of_era / 1460 + day_of_era / 36_524 - day_of_era / 146_096) / 365;
let day_of_year = day_of_era - (365 * year_of_era + year_of_era / 4 - year_of_era / 100);
let shifted_month = (5 * day_of_year + 2) / 153;
let day = u32::try_from(day_of_year - (153 * shifted_month + 2) / 5 + 1).unwrap_or(1);
let month = u32::try_from(if shifted_month < 10 { shifted_month + 3 } else { shifted_month - 9 }).unwrap_or(1);
let year = year_of_era + era * 400;
(if month <= 2 { year + 1 } else { year }, month, day)
}