use std::path::{Path, PathBuf};
use std::process::Command;
use anyhow::{Context, ensure};
use crate::constants::{GID_ENV, GROUP_ENV, HOME_ARG, HOME_ENV, UID_ENV, USER_ENV};
#[derive(Debug, Clone, PartialEq)]
pub struct User {
pub name: String,
pub uid: u32,
pub gid: u32,
pub group: String,
pub home: PathBuf,
}
impl User {
pub fn of_host() -> anyhow::Result<Self> {
let home = std::env::var_os(HOME_ENV).context("HOME is not set")?;
Ok(Self {
name: id("-un")?,
uid: id("-u")?.parse().context("parsing `id -u`")?,
gid: id("-g")?.parse().context("parsing `id -g`")?,
group: id("-gn")?,
home: home.into(),
})
}
pub fn from_env() -> anyhow::Result<Self> {
Ok(Self {
name: env(USER_ENV)?,
uid: env(UID_ENV)?.parse().context(UID_ENV)?,
gid: env(GID_ENV)?.parse().context(GID_ENV)?,
group: env(GROUP_ENV)?,
home: env(HOME_ENV)?.into(),
})
}
pub fn env(&self) -> [(&'static str, String); 5] {
[
(USER_ENV, self.name.clone()),
(UID_ENV, self.uid.to_string()),
(GID_ENV, self.gid.to_string()),
(GROUP_ENV, self.group.clone()),
(HOME_ENV, self.home.to_string_lossy().into_owned()),
]
}
pub fn build_args(&self) -> [(&'static str, String); 5] {
[
(USER_ENV, self.name.clone()),
(UID_ENV, self.uid.to_string()),
(GID_ENV, self.gid.to_string()),
(GROUP_ENV, self.group.clone()),
(HOME_ARG, self.home.to_string_lossy().into_owned()),
]
}
pub fn passwd_line(&self, shell: &Path) -> String {
format!(
"{}:*:{}:{}::{}:{}",
self.name,
self.uid,
self.gid,
self.home.display(),
shell.display()
)
}
pub fn group_line(&self) -> String {
format!("{}:x:{}:", self.group, self.gid)
}
pub fn needs_user(&self, passwd: &str) -> anyhow::Result<bool> {
let home = self.home.to_string_lossy();
for entry in entries(passwd) {
let same_name = entry.name == self.name;
let same_uid = entry.id == Some(self.uid);
if same_name && same_uid {
ensure!(
entry.home == Some(&*home),
"the image's user `{}` has home {}, the host's is {}; \
ssh finds ~/.ssh through the image's",
self.name,
entry.home.unwrap_or_default(),
home
);
return Ok(false);
}
ensure!(
!same_uid,
"the image has user `{}` with uid {}, which clashes with the host's `{}`",
entry.name,
self.uid,
self.name
);
ensure!(
!same_name,
"the image has user `{}` with uid {}, which clashes with the host's uid {}",
self.name,
entry.id.map(|id| id.to_string()).unwrap_or_default(),
self.uid
);
}
Ok(true)
}
pub fn needs_group(&self, group_file: &str) -> anyhow::Result<bool> {
for entry in entries(group_file) {
let same_name = entry.name == self.group;
let same_gid = entry.id == Some(self.gid);
if same_name && same_gid {
return Ok(false);
}
ensure!(
!same_name && !same_gid,
"the image has group `{}` with gid {}, which clashes with the host's `{}` (gid {})",
entry.name,
entry.id.map(|id| id.to_string()).unwrap_or_default(),
self.group,
self.gid
);
}
Ok(true)
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct ExtraGroup {
pub name: String,
pub gid: u32,
}
impl ExtraGroup {
pub fn parse_list(text: &str) -> anyhow::Result<Vec<Self>> {
text.split(',')
.filter(|item| !item.is_empty())
.map(|item| {
let (name, gid) = item
.split_once(':')
.with_context(|| format!("extra group `{item}` is not `name:gid`"))?;
let gid = gid
.parse()
.with_context(|| format!("extra group `{item}` has no numeric gid"))?;
Ok(Self {
name: name.to_owned(),
gid,
})
})
.collect()
}
pub fn line_to_add(&self, group_file: &str, user: &str) -> Option<String> {
if entries(group_file).any(|entry| entry.id == Some(self.gid)) {
return None;
}
let name_taken = entries(group_file).any(|entry| entry.name == self.name);
let name = if name_taken {
format!("host-{}", self.name)
} else {
self.name.clone()
};
Some(format!("{name}:x:{}:{user}", self.gid))
}
}
pub fn with_line(text: &str, line: &str) -> String {
let separator = if text.is_empty() || text.ends_with('\n') {
""
} else {
"\n"
};
format!("{text}{separator}{line}\n")
}
struct Entry<'a> {
name: &'a str,
id: Option<u32>,
home: Option<&'a str>,
}
fn entries(text: &str) -> impl Iterator<Item = Entry<'_>> {
text.lines()
.filter(|line| !line.trim().is_empty() && !line.starts_with('#'))
.map(|line| {
let mut fields = line.split(':');
let name = fields.next().unwrap_or_default();
let id = fields.nth(1).and_then(|id| id.parse().ok());
let home = fields.nth(2);
Entry { name, id, home }
})
}
fn id(flag: &str) -> anyhow::Result<String> {
let output = Command::new("id")
.arg(flag)
.output()
.with_context(|| format!("running `id {flag}`"))?;
ensure!(output.status.success(), "`id {flag}` failed");
Ok(String::from_utf8(output.stdout)
.with_context(|| format!("`id {flag}` printed non-UTF-8"))?
.trim_end()
.to_owned())
}
fn env(name: &str) -> anyhow::Result<String> {
std::env::var(name).with_context(|| {
format!("{name} is not set; `vz entrypoint` runs only in a container vz started")
})
}
#[cfg(test)]
#[allow(non_snake_case)] mod tests {
use super::*;
const PASSWD: &str = "root:x:0:0:root:/root:/bin/bash\n\
daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin\n";
const GROUP: &str = "root:x:0:\ndaemon:x:1:\n";
fn sally() -> User {
User {
name: "sally".to_owned(),
uid: 1000,
gid: 1000,
group: "sally".to_owned(),
home: PathBuf::from("/home/sally"),
}
}
#[test]
fn passwd_line__user_and_shell__is_passwd_format() {
let line = sally().passwd_line(Path::new("/bin/bash"));
assert_eq!(line, "sally:*:1000:1000::/home/sally:/bin/bash");
}
#[test]
fn group_line__user__is_group_format() {
let line = sally().group_line();
assert_eq!(line, "sally:x:1000:");
}
#[test]
fn needs_user__absent__is_true() {
let needed = sally().needs_user(PASSWD).unwrap();
assert!(needed);
}
#[test]
fn needs_user__same_name_uid_and_home__is_false() {
let passwd = format!("{PASSWD}sally:x:1000:1000::/home/sally:/bin/bash\n");
let needed = sally().needs_user(&passwd).unwrap();
assert!(!needed);
}
#[test]
fn needs_user__same_user_other_home__refuses_naming_both_homes() {
let passwd = format!("{PASSWD}sally:x:1000:1000::/home/other:/bin/bash\n");
let error = sally().needs_user(&passwd).unwrap_err();
assert!(
error.to_string().contains(
"the image's user `sally` has home /home/other, the host's is /home/sally"
),
"{error}"
);
}
#[test]
fn needs_user__uid_taken_by_another__refuses_naming_the_user() {
let passwd = format!("{PASSWD}ubuntu:x:1000:1000::/home/ubuntu:/bin/bash\n");
let error = sally().needs_user(&passwd).unwrap_err();
assert!(
error.to_string().contains(
"the image has user `ubuntu` with uid 1000, which clashes with the host's `sally`"
),
"{error}"
);
}
#[test]
fn needs_user__name_taken_with_another_uid__refuses() {
let passwd = format!("{PASSWD}sally:x:1001:1001::/home/sally:/bin/sh\n");
let error = sally().needs_user(&passwd).unwrap_err();
assert!(
error.to_string().contains(
"the image has user `sally` with uid 1001, which clashes with the host's uid 1000"
),
"{error}"
);
}
#[test]
fn needs_group__absent__is_true() {
let needed = sally().needs_group(GROUP).unwrap();
assert!(needed);
}
#[test]
fn needs_group__same_name_and_gid__is_false() {
let group = format!("{GROUP}sally:x:1000:\n");
let needed = sally().needs_group(&group).unwrap();
assert!(!needed);
}
#[test]
fn needs_group__gid_taken_by_another__refuses() {
let group = format!("{GROUP}ubuntu:x:1000:\n");
let error = sally().needs_group(&group).unwrap_err();
assert!(
error.to_string().contains(
"the image has group `ubuntu` with gid 1000, which clashes with the host's `sally`"
),
"{error}"
);
}
#[test]
fn needs_group__name_taken_with_another_gid__refuses() {
let group = format!("{GROUP}sally:x:1001:\n");
let error = sally().needs_group(&group).unwrap_err();
assert!(
error.to_string().contains(
"the image has group `sally` with gid 1001, which clashes with the host's `sally` (gid 1000)"
),
"{error}"
);
}
fn group(name: &str, gid: u32) -> ExtraGroup {
ExtraGroup {
name: name.to_owned(),
gid,
}
}
#[test]
fn parse_list__none_one_or_several__each_name_and_gid() {
let cases = [
("", vec![]),
("docker:969", vec![group("docker", 969)]),
(
"docker:969,audio:29",
vec![group("docker", 969), group("audio", 29)],
),
];
for (text, expected) in cases {
let groups = ExtraGroup::parse_list(text).unwrap();
assert_eq!(groups, expected, "{text:?}");
}
}
#[test]
fn parse_list__malformed__is_refused_naming_the_item() {
let cases = [
("docker", "extra group `docker` is not `name:gid`"),
("docker:x", "extra group `docker:x` has no numeric gid"),
];
for (text, expected) in cases {
let error = ExtraGroup::parse_list(text).unwrap_err().to_string();
assert!(error.contains(expected), "{error}");
}
}
#[test]
fn line_to_add__gid_new_to_the_image__a_line_naming_it() {
let cases = [
("the name free", GROUP, "docker:x:969:sally"),
(
"the name taken: host- before it",
"root:x:0:\ndocker:x:101:\n",
"host-docker:x:969:sally",
),
];
for (case, group_file, expected) in cases {
let line = group("docker", 969).line_to_add(group_file, "sally");
assert_eq!(line.as_deref(), Some(expected), "{case}");
}
}
#[test]
fn line_to_add__gid_the_image_has__none() {
let group_file = "root:x:0:\nsomething:x:969:\n";
let line = group("docker", 969).line_to_add(group_file, "sally");
assert_eq!(line, None);
}
#[test]
fn with_line__with_or_without_a_trailing_newline__the_line_on_its_own() {
let cases = [("", "a\n"), ("root\n", "root\na\n"), ("root", "root\na\n")];
for (text, expected) in cases {
let appended = with_line(text, "a");
assert_eq!(appended, expected, "text: {text:?}");
}
}
}