use std::path::Path;
use tokio::process::Command;
use crate::error::Error;
#[cfg(doc)]
use super::run;
async fn resolve_id(release: &Path, user: &str, flag: &str, kind: &str) -> Result<u32, Error> {
let output = Command::new("id")
.arg(flag)
.arg(user)
.output()
.await
.map_err(Error::at(release))?;
if !output.status.success() {
return Err(Error::Config(format!(
"the app's `user = {user:?}` does not resolve to a {kind} on this host (`id {flag} \
{user}` failed); create that account, or pin a different one in \
Flockfile.override.toml"
)));
}
String::from_utf8_lossy(&output.stdout)
.trim()
.parse()
.map_err(|_| Error::Config(format!("`id {flag} {user}` did not print a {kind}")))
}
pub(super) async fn effective_uid() -> Option<u32> {
let output = Command::new("id").arg("-u").output().await.ok()?;
if !output.status.success() {
return None;
}
String::from_utf8_lossy(&output.stdout).trim().parse().ok()
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Step {
Build,
ReleaseHook,
AfterHook,
}
pub(crate) async fn root_warning(sheep: &str, as_user: Option<&str>, step: Step) -> Option<String> {
if as_user.is_some() {
return None;
}
root_step_warning(sheep, effective_uid().await?, as_user, step)
}
fn root_step_warning(sheep: &str, euid: u32, as_user: Option<&str>, step: Step) -> Option<String> {
const ROOT: u32 = 0;
(euid == ROOT && as_user.is_none()).then(|| match step {
Step::Build => format!(
"shep-deploy: warning: {sheep}'s build is about to run as root, because its app sets \
no `user`. The build command comes from the deployed repository, so whatever that \
repository can be made to run, it runs as root. Set `user` on the app to build as \
that user instead."
),
Step::ReleaseHook => format!(
"shep-deploy: warning: {sheep}'s release hook is about to run as root, because its \
app sets no `user`. The hook runs inside a release the deployed repository's build \
has just written, so whatever that build left there, the hook runs with root's \
privilege and the hook's own credentials. Set `user` on the app to run it as that \
user instead."
),
Step::AfterHook => format!(
"shep-deploy: warning: {sheep}'s after hook is about to run as root, because its \
app sets no `user`. The hook runs inside a release the deployed repository's build \
has written, so whatever that build left there, the hook runs with root's \
privilege and the hook's own credentials. Set `user` on the app to run it as that \
user instead."
),
})
}
pub(crate) async fn uid_for(release: &Path, user: &str) -> Result<u32, Error> {
resolve_id(release, user, "-u", "uid").await
}
pub(crate) async fn gid_for(release: &Path, user: &str) -> Result<u32, Error> {
resolve_id(release, user, "-g", "gid").await
}
#[cfg(test)]
mod tests {
use super::super::test_support::TEST_BUILD_BUDGET;
use super::super::{BuildSpec, run};
use super::*;
use crate::fixtures;
fn current_username() -> String {
let output = std::process::Command::new("id")
.arg("-un")
.output()
.expect("id -un runs");
assert!(output.status.success(), "id -un must succeed");
String::from_utf8(output.stdout)
.expect("id -un prints utf-8")
.trim()
.to_owned()
}
fn current_uid() -> u32 {
let output = std::process::Command::new("id")
.arg("-u")
.output()
.expect("id -u runs");
assert!(output.status.success(), "id -u must succeed");
String::from_utf8(output.stdout)
.expect("id -u prints utf-8")
.trim()
.parse()
.expect("id -u prints a number")
}
fn current_gid() -> u32 {
let output = std::process::Command::new("id")
.arg("-g")
.output()
.expect("id -g runs");
assert!(output.status.success(), "id -g must succeed");
String::from_utf8(output.stdout)
.expect("id -g prints utf-8")
.trim()
.parse()
.expect("id -g prints a number")
}
#[test]
fn only_a_rootless_build_as_root_is_warned_about() {
assert!(root_step_warning("web", 0, None, Step::Build).is_some());
assert!(root_step_warning("web", 0, Some("reactmap"), Step::Build).is_none());
assert!(root_step_warning("web", 501, None, Step::Build).is_none());
assert!(root_step_warning("web", 501, Some("reactmap"), Step::Build).is_none());
}
#[test]
fn the_build_warning_text_is_pinned() {
assert_eq!(
root_step_warning("web", 0, None, Step::Build).as_deref(),
Some(
"shep-deploy: warning: web's build is about to run as root, because its app \
sets no `user`. The build command comes from the deployed repository, so \
whatever that repository can be made to run, it runs as root. Set `user` on \
the app to build as that user instead."
)
);
}
#[test]
fn the_hook_warning_names_the_step_and_the_way_out() {
let warning = root_step_warning("web", 0, None, Step::ReleaseHook).expect("warns");
assert!(warning.contains("web's release hook"), "{warning}");
assert!(warning.contains("`user`"), "{warning}");
assert!(!warning.contains("command comes from"), "{warning}");
}
#[test]
fn the_after_hook_warning_names_its_own_step() {
let warning = root_step_warning("web", 0, None, Step::AfterHook).expect("warns");
assert!(warning.contains("web's after hook"), "{warning}");
assert!(warning.contains("`user`"), "{warning}");
}
#[test]
fn the_root_warning_names_the_sheep_and_the_way_out() {
let warning = root_step_warning("bpm", 0, None, Step::Build).expect("warns");
assert!(warning.contains("bpm"), "{warning}");
assert!(warning.contains("root"), "{warning}");
assert!(warning.contains("repository"), "{warning}");
assert!(warning.contains("`user`"), "{warning}");
}
#[tokio::test]
async fn the_effective_uid_can_be_read() {
assert!(effective_uid().await.is_some());
}
#[tokio::test]
async fn the_warning_reads_this_process_and_agrees_with_the_decision() {
let euid = effective_uid().await.expect("a uid");
assert!(
root_warning("web", Some("reactmap"), Step::Build)
.await
.is_none()
);
assert_eq!(
root_warning("web", None, Step::Build).await.is_some(),
euid == 0,
"warned about as uid {euid}"
);
}
#[tokio::test]
async fn running_as_the_current_user_succeeds() {
let rel = fixtures::fixture_release(&[]);
let spec = BuildSpec {
command: Some("true".into()),
..Default::default()
};
let user = current_username();
run(
"web",
rel.path(),
&spec,
Some(&user),
&[],
fixtures::tempdir().path(),
TEST_BUILD_BUDGET,
)
.await
.expect("dropping to one's own user and group is always permitted");
}
#[tokio::test]
async fn an_unknown_as_user_is_a_config_error() {
let rel = fixtures::fixture_release(&[]);
let spec = BuildSpec {
command: Some("true".into()),
..Default::default()
};
let err = run(
"web",
rel.path(),
&spec,
Some("shep-deploy-test-no-such-user"),
&[],
fixtures::tempdir().path(),
TEST_BUILD_BUDGET,
)
.await
.expect_err("no such user");
assert!(matches!(err, Error::Config(_)));
assert!(err.to_string().contains("does not resolve to a gid"));
}
#[tokio::test]
async fn uid_for_resolves_the_current_users_own_uid() {
let user = current_username();
let uid = uid_for(Path::new("."), &user).await.expect("resolves");
assert_eq!(uid, current_uid());
}
#[tokio::test]
async fn gid_for_resolves_the_current_users_primary_gid() {
let user = current_username();
let gid = gid_for(Path::new("."), &user).await.expect("resolves");
assert_eq!(gid, current_gid());
}
#[tokio::test]
async fn uid_for_reports_a_config_error_for_an_unknown_user() {
let err = uid_for(Path::new("."), "shep-deploy-test-no-such-user")
.await
.expect_err("no such user");
assert!(matches!(err, Error::Config(_)));
assert!(err.to_string().contains("does not resolve to a uid"));
}
#[tokio::test]
async fn gid_for_reports_a_config_error_for_an_unknown_user() {
let err = gid_for(Path::new("."), "shep-deploy-test-no-such-user")
.await
.expect_err("no such user");
assert!(matches!(err, Error::Config(_)));
assert!(err.to_string().contains("does not resolve to a gid"));
}
}