use std::time::Duration;
use crate::client::Client;
use crate::error::{Error, Result};
use crate::sftp::Sftp;
pub const FRESH_MODE: u32 = 0o711;
pub fn parse_mode(m: &str) -> std::result::Result<u32, String> {
u32::from_str_radix(m.trim().trim_start_matches("0o"), 8)
.ok()
.filter(|v| *v <= 0o7777)
.ok_or_else(|| format!("mode {m:?} is not an octal mode like 0770"))
}
pub fn numeric_ids(owner: &str) -> Option<(u32, u32)> {
let (u, g) = owner.split_once(':').unwrap_or((owner, owner));
Some((u.parse().ok()?, g.parse().ok()?))
}
#[derive(Debug, Clone, Default, PartialEq)]
pub struct Fix<'a> {
pub path: &'a str,
pub owner: Option<&'a str>,
pub mode: Option<u32>,
pub fresh_only: bool,
}
pub fn apply(client: &Client, name: &str, fix: &Fix) -> Result<bool> {
let step = |what: &str| format!("{what} {} in {name}", fix.path);
let resolved = match fix.owner {
Some(o) => Some(
resolve(client, name, o).map_err(|e| Error::OperationFailed {
step: step(&format!("chown {o}")),
message: e,
})?,
),
None => None,
};
let mut sftp = Sftp::open(client, name, Duration::from_secs(30))?;
let st = sftp.stat(fix.path)?.ok_or_else(|| Error::OperationFailed {
step: step("stat"),
message: "the mount point does not exist".into(),
})?;
if fix.fresh_only && (st.uid != 0 || (st.mode != FRESH_MODE && Some(st.mode) != fix.mode)) {
return Ok(false);
}
if let Some(r) = &resolved {
sftp.chown(fix.path, r.uid, r.gid)?;
if let Some(home) = r.home.as_deref().filter(|h| !h.is_empty() && *h != "/") {
if fix
.path
.starts_with(&format!("{}/", home.trim_end_matches('/')))
{
let mut d = parent(fix.path);
while d != home.trim_end_matches('/') && d != "/" {
match sftp.stat(&d)? {
Some(s) if s.uid == 0 => sftp.chown(&d, r.uid, r.gid)?,
_ => break,
}
d = parent(&d);
}
}
}
}
if let Some(m) = fix.mode {
sftp.chmod(fix.path, m)?;
}
Ok(true)
}
fn parent(p: &str) -> String {
match p.trim_end_matches('/').rsplit_once('/') {
Some(("", _)) | None => "/".into(),
Some((d, _)) => d.into(),
}
}
#[derive(Debug, PartialEq)]
struct Ids {
uid: u32,
gid: u32,
home: Option<String>,
}
fn resolve(client: &Client, name: &str, owner: &str) -> std::result::Result<Ids, String> {
let read = |path: &str| -> std::result::Result<String, String> {
match client.read_file(name, path) {
Ok(b) => Ok(String::from_utf8_lossy(&b.unwrap_or_default()).into_owned()),
Err(e) => Err(format!("read {path}: {e}")),
}
};
let (_, group) = split(owner);
let passwd = read("/etc/passwd")?;
let groups = match group {
Some(g) if g.parse::<u32>().is_err() => read("/etc/group")?,
_ => String::new(),
};
resolve_in(owner, &passwd, &groups)
}
fn split(owner: &str) -> (&str, Option<&str>) {
match owner.split_once(':') {
Some((u, g)) => (u, Some(g)),
None => (owner, None),
}
}
fn resolve_in(owner: &str, passwd: &str, group: &str) -> std::result::Result<Ids, String> {
let (user, grp) = split(owner);
let entry = passwd.lines().find_map(|l| {
let f: Vec<&str> = l.split(':').collect();
(f.len() >= 6 && (f[0] == user || (user.parse::<u32>().is_ok() && f[2] == user)))
.then_some(f)
});
let mut ids = match entry {
Some(f) => Ids {
uid: f[2]
.parse()
.map_err(|_| format!("bad uid for {user} in /etc/passwd"))?,
gid: f[3]
.parse()
.map_err(|_| format!("bad gid for {user} in /etc/passwd"))?,
home: Some(f[5].to_string()),
},
None => match user.parse() {
Ok(n) => Ids {
uid: n,
gid: n,
home: None,
},
Err(_) => return Err(format!("no such user in the image's /etc/passwd: {user}")),
},
};
match grp {
None | Some("") => {}
Some(g) => {
ids.gid = match g.parse() {
Ok(n) => n,
Err(_) => group
.lines()
.map(|l| l.split(':').collect::<Vec<_>>())
.find(|f| f.len() >= 3 && f[0] == g)
.and_then(|f| f[2].parse().ok())
.ok_or_else(|| format!("no such group in the image's /etc/group: {g}"))?,
}
}
}
Ok(ids)
}
#[cfg(test)]
mod tests {
use super::*;
const PASSWD: &str = "root:x:0:0:root:/root:/bin/sh\ndev:x:1000:1000::/home/dev:/bin/bash\napp:x:999:998::/:/sbin/nologin\n";
const GROUP: &str = "root:x:0:\nstaff:x:50:dev\n";
fn ids(uid: u32, gid: u32, home: Option<&str>) -> Ids {
Ids {
uid,
gid,
home: home.map(String::from),
}
}
#[test]
fn owners_resolve_as_getent_and_chown_do() {
assert_eq!(
resolve_in("dev", PASSWD, GROUP),
Ok(ids(1000, 1000, Some("/home/dev")))
);
assert_eq!(
resolve_in("1000", PASSWD, GROUP),
Ok(ids(1000, 1000, Some("/home/dev")))
);
assert_eq!(
resolve_in("dev:staff", PASSWD, GROUP),
Ok(ids(1000, 50, Some("/home/dev")))
);
assert_eq!(resolve_in("app:7", PASSWD, ""), Ok(ids(999, 7, Some("/"))));
assert_eq!(resolve_in("4242", PASSWD, ""), Ok(ids(4242, 4242, None)));
assert_eq!(resolve_in("4242:7", "", ""), Ok(ids(4242, 7, None)));
let e = resolve_in("nobody", PASSWD, GROUP).unwrap_err();
assert!(e.contains("no such user") && e.contains("nobody"), "{e}");
let e = resolve_in("dev:wheel", PASSWD, GROUP).unwrap_err();
assert!(e.contains("no such group") && e.contains("wheel"), "{e}");
}
#[test]
fn modes_and_numeric_ids() {
assert_eq!(parse_mode("0770"), Ok(0o770));
assert_eq!(parse_mode("2775"), Ok(0o2775));
assert_eq!(parse_mode("0o750"), Ok(0o750));
assert!(parse_mode("0890").is_err());
assert!(parse_mode("17777").is_err());
assert_eq!(numeric_ids("1000"), Some((1000, 1000)));
assert_eq!(numeric_ids("1000:50"), Some((1000, 50)));
assert_eq!(numeric_ids("dev"), None);
assert_eq!(numeric_ids("1000:staff"), None);
}
#[test]
fn parents() {
assert_eq!(parent("/home/dev/.cache/x"), "/home/dev/.cache");
assert_eq!(parent("/data"), "/");
assert_eq!(parent("/data/"), "/");
}
}