use super::*;
trait Guest {
fn read_file(&self, path: &str) -> Result<Option<String>>;
fn getent(&self, db: &str, key: &str) -> Option<String>;
}
struct Instance<'a> {
client: &'a Client,
name: &'a str,
}
impl Guest for Instance<'_> {
fn read_file(&self, path: &str) -> Result<Option<String>> {
Ok(self
.client
.read_file(self.name, path)?
.map(|b| String::from_utf8_lossy(&b).into_owned()))
}
fn getent(&self, db: &str, key: &str) -> Option<String> {
let out = run_captured(
self.client,
self.name,
&["getent".into(), db.into(), key.into()],
&Request::root(),
Stdin::Null,
Some(Duration::from_secs(30)),
)
.ok()?;
if !out.success() {
return None;
}
out.stdout_text().lines().next().map(str::to_string)
}
}
#[doc(hidden)]
pub fn resolve_user(client: &Client, instance: &str, user: &str) -> Result<GuestUser> {
resolve_in(
&Instance {
client,
name: instance,
},
instance,
user,
)
}
fn resolve_in(g: &dyn Guest, instance: &str, user: &str) -> Result<GuestUser> {
let (u, group) = match user.split_once(':') {
Some((u, grp)) => (u, Some(grp)),
None => (user, None),
};
let numeric = u.parse::<u32>().ok();
let entry = match numeric {
Some(_) => file_entry(g, u)?,
None => passwd_entry(g, u)?,
};
let mut gu = entry.unwrap_or_default();
let found = gu.name.is_some();
match numeric {
Some(uid) => {
gu.uid = uid;
if !found {
gu.gid = uid;
}
}
None if !found => return Err(no_such("user", u, instance)),
None => {}
}
if let Some(grp) = group {
gu.gid = match grp.parse::<u32>() {
Ok(n) => n,
Err(_) => group_id(g, grp)?.ok_or_else(|| no_such("group", grp, instance))?,
};
}
Ok(gu)
}
fn no_such(what: &str, name: &str, instance: &str) -> Error {
Error::invalid(format!("no such {what} {name} in {instance}"))
}
fn passwd_entry(g: &dyn Guest, name: &str) -> Result<Option<GuestUser>> {
match g.getent("passwd", name).as_deref().and_then(parse_passwd) {
Some(u) => Ok(Some(u)),
None => file_entry(g, name),
}
}
fn file_entry(g: &dyn Guest, key: &str) -> Result<Option<GuestUser>> {
let Some(text) = g.read_file("/etc/passwd")? else {
return Ok(None);
};
let uid = key.parse::<u32>().ok();
Ok(text.lines().filter_map(parse_passwd).find(|u| match uid {
Some(n) => u.uid == n,
None => u.name.as_deref() == Some(key),
}))
}
fn group_id(g: &dyn Guest, name: &str) -> Result<Option<u32>> {
let from_line = |l: &str| l.split(':').nth(2).and_then(|n| n.parse().ok());
if let Some(n) = g.getent("group", name).as_deref().and_then(from_line) {
return Ok(Some(n));
}
let Some(text) = g.read_file("/etc/group")? else {
return Ok(None);
};
Ok(text
.lines()
.find(|l| l.split(':').next() == Some(name))
.and_then(from_line))
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
struct Fake {
files: HashMap<&'static str, &'static str>,
getent: HashMap<(&'static str, &'static str), &'static str>,
}
impl Guest for Fake {
fn read_file(&self, path: &str) -> Result<Option<String>> {
Ok(self.files.get(path).map(|s| s.to_string()))
}
fn getent(&self, db: &str, key: &str) -> Option<String> {
self.getent.get(&(db, key)).map(|s| s.to_string())
}
}
const PASSWD: &str =
"root:x:0:0:root:/root:/bin/sh\nnobody:x:65534:65534:nobody:/nonexistent:/sbin/nologin\n";
const GROUP: &str = "root:x:0:\nstaff:x:50:\nnogroup:x:65534:\n";
fn busybox() -> Fake {
Fake {
files: HashMap::from([("/etc/passwd", PASSWD), ("/etc/group", GROUP)]),
getent: HashMap::new(),
}
}
fn bare() -> Fake {
Fake {
files: HashMap::new(),
getent: HashMap::new(),
}
}
#[test]
fn numeric_users_need_nothing_in_the_guest() {
let u = resolve_in(&bare(), "i", "1000").unwrap();
assert_eq!((u.uid, u.gid, u.name, u.home), (1000, 1000, None, None));
let u = resolve_in(&bare(), "i", "1000:2000").unwrap();
assert_eq!((u.uid, u.gid), (1000, 2000));
let e = resolve_in(&bare(), "i", "7:staff").unwrap_err();
assert!(e.to_string().contains("no such group staff in i"), "{e}");
}
#[test]
fn numeric_users_pick_up_home_and_shell_when_passwd_is_readable() {
let u = resolve_in(&busybox(), "i", "65534").unwrap();
assert_eq!((u.uid, u.gid), (65534, 65534));
assert_eq!(u.name.as_deref(), Some("nobody"));
assert_eq!(u.home.as_deref(), Some("/nonexistent"));
let u = resolve_in(&busybox(), "i", "65534:50").unwrap();
assert_eq!((u.uid, u.gid), (65534, 50));
let u = resolve_in(&busybox(), "i", "1000").unwrap();
assert_eq!((u.uid, u.gid, u.home), (1000, 1000, None));
}
#[test]
fn names_fall_back_to_the_files_without_getent() {
let u = resolve_in(&busybox(), "i", "nobody").unwrap();
assert_eq!((u.uid, u.gid), (65534, 65534));
assert_eq!(u.shell.as_deref(), Some("/sbin/nologin"));
let u = resolve_in(&busybox(), "i", "nobody:staff").unwrap();
assert_eq!((u.uid, u.gid), (65534, 50));
let e = resolve_in(&busybox(), "web", "ghost").unwrap_err();
assert!(e.to_string().contains("no such user ghost in web"), "{e}");
let e = resolve_in(&busybox(), "web", "nobody:ghosts").unwrap_err();
assert!(e.to_string().contains("no such group ghosts in web"), "{e}");
assert!(resolve_in(&bare(), "web", "dev").is_err());
}
struct Down;
impl Guest for Down {
fn read_file(&self, _: &str) -> Result<Option<String>> {
Err(Error::Connect {
socket: "/x".into(),
source: std::io::ErrorKind::NotFound.into(),
})
}
fn getent(&self, _: &str, _: &str) -> Option<String> {
None
}
}
#[test]
fn an_unreachable_guest_is_not_a_missing_user() {
for user in ["dev", "1000", "1000:staff"] {
let e = resolve_in(&Down, "web", user).unwrap_err();
assert!(matches!(e, Error::Connect { .. }), "{user}: {e}");
}
}
#[test]
fn getent_answers_first() {
let g = Fake {
files: HashMap::from([("/etc/passwd", PASSWD)]),
getent: HashMap::from([
(("passwd", "dev"), "dev:x:1000:1000::/home/dev:/bin/bash"),
(("group", "ops"), "ops:x:77:"),
]),
};
let u = resolve_in(&g, "i", "dev:ops").unwrap();
assert_eq!((u.uid, u.gid), (1000, 77));
assert_eq!(u.home.as_deref(), Some("/home/dev"));
assert_eq!(resolve_in(&g, "i", "nobody").unwrap().uid, 65534);
}
}