use std::ffi::OsStr;
use std::io;
use std::os::fd::OwnedFd;
use std::os::unix::ffi::OsStrExt as _;
use std::path::Path;
use rustix::fs::{AtFlags, Dir, FileType, Gid, Mode, OFlags, Uid};
use rustix::io::Errno;
use crate::error::Error;
use crate::shared::printable;
const WALK_DIRECTORY: OFlags = OFlags::RDONLY
.union(OFlags::DIRECTORY)
.union(OFlags::NOFOLLOW)
.union(OFlags::CLOEXEC);
pub(crate) enum Handle<'a> {
Dir(&'a OwnedFd),
File(&'a OwnedFd),
}
const WALK_FILE: OFlags = OFlags::RDONLY
.union(OFlags::NOFOLLOW)
.union(OFlags::NONBLOCK)
.union(OFlags::CLOEXEC);
fn walk_release(
release: &Path,
visit: &mut impl FnMut(&Path, Handle<'_>) -> Result<(), Error>,
) -> Result<(), Error> {
let root = rustix::fs::open(release, WALK_DIRECTORY, Mode::empty())
.map_err(|errno| io_error(release, errno))?;
walk_dir(release, Path::new(""), &root, visit)
}
fn walk_dir(
base: &Path,
relative: &Path,
dir: &OwnedFd,
visit: &mut impl FnMut(&Path, Handle<'_>) -> Result<(), Error>,
) -> Result<(), Error> {
visit(relative, Handle::Dir(dir))?;
let here = base.join(relative);
let mut listing = Dir::read_from(dir).map_err(|errno| io_error(&here, errno))?;
let mut entries = Vec::new();
while let Some(entry) = listing.read() {
let entry = entry.map_err(|errno| io_error(&here, errno))?;
let name = OsStr::from_bytes(entry.file_name().to_bytes()).to_owned();
if name == "." || name == ".." {
continue;
}
entries.push((name, entry.file_type()));
}
for (name, kind) in entries {
if relative.as_os_str().is_empty() && name == ".git" {
continue;
}
let below = relative.join(&name);
let kind = if kind == FileType::Unknown {
rustix::fs::statat(dir, &name, AtFlags::SYMLINK_NOFOLLOW)
.map_or(FileType::Unknown, |stat| {
FileType::from_raw_mode(stat.st_mode)
})
} else {
kind
};
match kind {
FileType::Symlink => {}
FileType::Directory => {
match rustix::fs::openat(dir, &name, WALK_DIRECTORY, Mode::empty()) {
Ok(child) => walk_dir(base, &below, &child, visit)?,
Err(errno)
if errno == Errno::LOOP
|| errno == Errno::NOTDIR
|| errno == Errno::NOENT => {}
Err(errno) => return Err(io_error(&base.join(&below), errno)),
}
}
_ => match rustix::fs::openat(dir, &name, WALK_FILE, Mode::empty()) {
Ok(file) => match rustix::fs::fstat(&file) {
Ok(stat) if stat.st_nlink > 1 => {}
Ok(_) => visit(&below, Handle::File(&file))?,
Err(errno) => return Err(io_error(&base.join(&below), errno)),
},
Err(errno)
if errno == Errno::LOOP || errno == Errno::NOENT || errno == Errno::NXIO => {}
Err(errno) => return Err(io_error(&base.join(&below), errno)),
},
}
}
Ok(())
}
fn visit_cache_target(
cache_target: &Path,
visit: &mut impl FnMut(&Path, Handle<'_>) -> Result<(), Error>,
) -> Result<(), Error> {
match rustix::fs::open(cache_target, WALK_DIRECTORY, Mode::empty()) {
Ok(dir) => visit(Path::new(""), Handle::Dir(&dir)),
Err(errno) if errno == Errno::NOENT => Ok(()),
Err(errno) => Err(io_error(cache_target, errno)),
}
}
pub(crate) fn is_due(euid: Option<u32>, uid: u32) -> bool {
euid == Some(0) && uid != 0
}
pub(crate) fn hand_over(
release: &Path,
cache_target: &Path,
uid: u32,
gid: u32,
) -> Result<(), Error> {
if uid == u32::MAX || gid == u32::MAX {
return Err(Error::Config(format!(
"uid {uid} and gid {gid} cannot be handed a release: (uint32_t)-1 names no account"
)));
}
walk_release(release, &mut chown_under(release, uid, gid))?;
visit_cache_target(cache_target, &mut chown_under(cache_target, uid, gid))
}
fn chown_under(
base: &Path,
uid: u32,
gid: u32,
) -> impl FnMut(&Path, Handle<'_>) -> Result<(), Error> {
let (owner, group) = (Some(Uid::from_raw(uid)), Some(Gid::from_raw(gid)));
move |relative, handle| {
match handle {
Handle::Dir(fd) | Handle::File(fd) => rustix::fs::fchown(fd, owner, group),
}
.map_err(|errno| io_error(&base.join(relative), errno))
}
}
pub(crate) struct GitPointer(Vec<u8>);
const GIT_POINTER: &str = ".git";
const GIT_POINTER_LIMIT: u64 = 64 * 1024;
pub(crate) fn pin_git_pointer(release: &Path, dog_uid: u32) -> Result<Option<GitPointer>, Error> {
use std::io::Read as _;
let path = release.join(GIT_POINTER);
let root = rustix::fs::open(release, WALK_DIRECTORY, Mode::empty())
.map_err(|errno| io_error(release, errno))?;
let file = match rustix::fs::openat(&root, GIT_POINTER, WALK_FILE, Mode::empty()) {
Ok(file) => file,
Err(errno) if errno == Errno::NOENT || errno == Errno::LOOP || errno == Errno::NXIO => {
return Ok(None);
}
Err(errno) => return Err(io_error(&path, errno)),
};
let stat = rustix::fs::fstat(&file).map_err(|errno| io_error(&path, errno))?;
if FileType::from_raw_mode(stat.st_mode) != FileType::RegularFile || stat.st_uid != dog_uid {
return Ok(None);
}
let mut content = Vec::new();
std::fs::File::from(file)
.take(GIT_POINTER_LIMIT)
.read_to_end(&mut content)
.map_err(Error::at(&path))?;
Ok(Some(GitPointer(content)))
}
pub(crate) fn restore_git_pointer(release: &Path, pointer: &GitPointer) -> Result<(), Error> {
use std::io::Write as _;
let path = release.join(GIT_POINTER);
let root = rustix::fs::open(release, WALK_DIRECTORY, Mode::empty())
.map_err(|errno| io_error(release, errno))?;
match rustix::fs::unlinkat(&root, GIT_POINTER, AtFlags::empty()) {
Ok(()) => {}
Err(errno) if errno == Errno::NOENT => {}
Err(errno) => return Err(io_error(&path, errno)),
}
let file = rustix::fs::openat(
&root,
GIT_POINTER,
OFlags::WRONLY | OFlags::CREATE | OFlags::EXCL | OFlags::NOFOLLOW | OFlags::CLOEXEC,
Mode::from_bits_truncate(0o644),
)
.map_err(|errno| io_error(&path, errno))?;
std::fs::File::from(file)
.write_all(&pointer.0)
.map_err(Error::at(&path))
}
pub(crate) fn explain_spawn_failure(error: Error, user: Option<&str>, release: &Path) -> Error {
match (error, user) {
(Error::Io { source, .. }, Some(user))
if source.kind() == io::ErrorKind::PermissionDenied =>
{
Error::Config(format!(
"the build could not start as `user = {user:?}`: {user} was refused on the way to \
{}. That user needs search permission on every directory down to the release. \
Either put `$SHEP_HOME` somewhere {user} can search, or run the shepherd as that \
user",
printable(release.display())
))
}
(error, _) => error,
}
}
fn io_error(path: &Path, errno: Errno) -> Error {
Error::Io {
path: path.to_owned(),
source: io::Error::from(errno),
}
}
#[cfg(test)]
mod tests {
use std::fs;
use std::path::PathBuf;
use super::*;
use crate::fixtures;
fn visited(release: &Path) -> Vec<PathBuf> {
let mut seen = Vec::new();
walk_release(release, &mut |relative, _| {
seen.push(relative.to_owned());
Ok(())
})
.expect("walks");
seen.sort();
seen
}
#[test]
fn the_walk_reaches_every_file_and_directory_of_the_release() {
let release = fixtures::fixture_release(&[
("Flockfile.toml", ""),
("src/main.rs", ""),
("src/deep/er/lib.rs", ""),
]);
let paths: Vec<_> = visited(release.path())
.iter()
.map(|p| p.display().to_string())
.collect();
assert_eq!(
paths,
[
"",
"Flockfile.toml",
"src",
"src/deep",
"src/deep/er",
"src/deep/er/lib.rs",
"src/main.rs",
]
);
}
#[test]
fn a_symlink_is_never_visited_or_followed() {
let outside = fixtures::fixture_release(&[("secret/.env", ""), ("loose.txt", "")]);
let release = fixtures::fixture_release(&[("src/main.rs", "")]);
std::os::unix::fs::symlink(outside.path().join("secret"), release.path().join("shared"))
.expect("link to a directory");
std::os::unix::fs::symlink(
outside.path().join("loose.txt"),
release.path().join("loose"),
)
.expect("link to a file");
std::os::unix::fs::symlink("src/main.rs", release.path().join("src/alias"))
.expect("link inside the release");
let paths: Vec<_> = visited(release.path())
.iter()
.map(|p| p.display().to_string())
.collect();
assert_eq!(paths, ["", "src", "src/main.rs"]);
}
#[test]
fn a_file_with_another_hard_link_is_never_visited() {
let outside = fixtures::fixture_release(&[("secret", "")]);
let release = fixtures::fixture_release(&[("plain.txt", ""), ("twin.txt", "")]);
fs::hard_link(outside.path().join("secret"), release.path().join("linked")).expect("link");
fs::hard_link(
release.path().join("twin.txt"),
release.path().join("twin-too.txt"),
)
.expect("link");
let paths: Vec<_> = visited(release.path())
.iter()
.map(|p| p.display().to_string())
.collect();
assert_eq!(paths, ["", "plain.txt"]);
}
#[test]
fn the_git_pointer_at_the_release_root_is_left_alone() {
let release = fixtures::fixture_release(&[
(".git", "gitdir: /srv/tree/git/worktrees/abc\n"),
("vendor/.git", "a nested one is the build's to own"),
]);
let paths: Vec<_> = visited(release.path())
.iter()
.map(|p| p.display().to_string())
.collect();
assert_eq!(paths, ["", "vendor", "vendor/.git"]);
}
#[test]
fn only_the_cache_target_itself_is_handed_over() {
let cache = fixtures::fixture_release(&[("release/koji", ""), ("debug/deps/a.o", "")]);
let mut seen = Vec::new();
visit_cache_target(cache.path(), &mut |relative, handle| {
seen.push((relative.to_owned(), matches!(handle, Handle::Dir(_))));
Ok(())
})
.expect("visits");
assert_eq!(seen, [(PathBuf::new(), true)]);
}
fn denied() -> Error {
Error::Io {
path: PathBuf::from("/srv/tree/releases/abc"),
source: io::Error::from(io::ErrorKind::PermissionDenied),
}
}
#[test]
fn a_permission_failure_with_a_user_names_the_user_and_the_release() {
let release = Path::new("/srv/tree/releases/abc");
let Error::Config(message) = explain_spawn_failure(denied(), Some("reactmap"), release)
else {
panic!("a refused spawn as a user is a configuration problem");
};
assert!(message.contains("reactmap"), "{message}");
assert!(message.contains("/srv/tree/releases/abc"), "{message}");
assert!(message.contains("search permission"), "{message}");
assert!(message.contains("$SHEP_HOME"), "{message}");
assert!(message.contains("as that user"), "{message}");
}
#[test]
fn any_other_spawn_failure_is_left_as_it_was() {
let release = Path::new("/srv/tree/releases/abc");
let no_user = explain_spawn_failure(denied(), None, release);
assert!(matches!(&no_user, Error::Io { source, .. }
if source.kind() == io::ErrorKind::PermissionDenied));
let missing = Error::Io {
path: release.to_owned(),
source: io::Error::from(io::ErrorKind::NotFound),
};
let kept = explain_spawn_failure(missing, Some("reactmap"), release);
assert!(matches!(&kept, Error::Io { source, .. }
if source.kind() == io::ErrorKind::NotFound));
let build =
explain_spawn_failure(Error::Build { status: Some(1) }, Some("reactmap"), release);
assert!(matches!(build, Error::Build { status: Some(1) }));
}
#[test]
fn only_a_root_dog_building_as_somebody_else_hands_over() {
assert!(is_due(Some(0), 1001));
assert!(!is_due(Some(0), 0));
assert!(!is_due(Some(501), 1001));
assert!(!is_due(None, 1001));
}
#[test]
fn handing_a_release_to_its_own_owner_succeeds_and_skips_links() {
use std::os::unix::fs::MetadataExt as _;
let release = fixtures::fixture_release(&[("src/main.rs", ""), (".git", "gitdir: x")]);
let cache = fixtures::tempdir();
std::os::unix::fs::symlink(cache.path(), release.path().join("target")).expect("link");
let me = fs::metadata(release.path()).expect("stat");
hand_over(release.path(), cache.path(), me.uid(), me.gid()).expect("hands over");
assert_eq!(
fs::metadata(release.path().join("src/main.rs"))
.expect("stat")
.uid(),
me.uid()
);
let invalid = hand_over(release.path(), cache.path(), u32::MAX, me.gid());
assert!(matches!(invalid, Err(Error::Config(_))));
}
fn my_uid(path: &Path) -> u32 {
use std::os::unix::fs::MetadataExt as _;
fs::metadata(path).expect("stat").uid()
}
#[test]
fn a_re_aimed_git_pointer_is_put_back_after_the_build() {
let release =
fixtures::fixture_release(&[(".git", "gitdir: /srv/tree/git/worktrees/abc\n")]);
let pointer = pin_git_pointer(release.path(), my_uid(release.path()))
.expect("pins")
.expect("a pointer the dog owns");
fs::remove_file(release.path().join(".git")).expect("the build deletes it");
fs::write(release.path().join(".git"), "gitdir: /elsewhere\n").expect("and re-aims it");
restore_git_pointer(release.path(), &pointer).expect("restores");
assert_eq!(
fs::read_to_string(release.path().join(".git")).expect("reads"),
"gitdir: /srv/tree/git/worktrees/abc\n"
);
}
#[test]
fn restoring_the_git_pointer_does_not_write_through_a_link() {
let release = fixtures::fixture_release(&[(".git", "gitdir: x\n")]);
let outside = fixtures::fixture_release(&[("victim", "untouched")]);
let pointer = pin_git_pointer(release.path(), my_uid(release.path()))
.expect("pins")
.expect("a pointer the dog owns");
fs::remove_file(release.path().join(".git")).expect("removed");
std::os::unix::fs::symlink(outside.path().join("victim"), release.path().join(".git"))
.expect("link");
restore_git_pointer(release.path(), &pointer).expect("restores");
assert_eq!(
fs::read_to_string(outside.path().join("victim")).expect("reads"),
"untouched"
);
assert_eq!(
fs::read_to_string(release.path().join(".git")).expect("reads"),
"gitdir: x\n"
);
}
#[test]
fn a_git_pointer_owned_by_somebody_else_is_not_pinned() {
let release = fixtures::fixture_release(&[(".git", "gitdir: x\n")]);
let other = my_uid(release.path()).wrapping_add(1);
assert!(
pin_git_pointer(release.path(), other)
.expect("reads")
.is_none()
);
let bare = fixtures::fixture_release(&[]);
assert!(
pin_git_pointer(bare.path(), my_uid(bare.path()))
.expect("reads")
.is_none()
);
}
}