use crate as cindy;
use crate::Context;
use std::io::Write as _;
use std::path::{Path, PathBuf};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[crate::wire]
pub struct Mode(u32);
impl From<Mode> for std::fs::Permissions {
fn from(Mode(value): Mode) -> Self {
use std::os::unix::fs::PermissionsExt as _;
std::fs::Permissions::from_mode(value)
}
}
impl From<u32> for Mode {
fn from(value: u32) -> Self {
Mode(value & 0o7777)
}
}
impl std::fmt::Display for Mode {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "0o{:o}", self.0)
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
#[crate::wire]
pub enum Kind {
File(Vec<u8>),
Directory,
Link(PathBuf),
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
#[crate::wire]
pub struct State {
pub destination: PathBuf,
pub kind: Option<Kind>,
pub user: Option<String>,
pub group: Option<String>,
pub mode: Option<Mode>,
}
impl crate::Diff for State {
fn diff(&self, new: &Self, out: &mut dyn std::io::Write) -> std::io::Result<()> {
crate::diff::text_diff(&render_state(self), &render_state(new), out)
}
}
fn render_state(s: &State) -> String {
use std::fmt::Write as _;
let mut out = String::new();
writeln!(out, "State {{").unwrap();
writeln!(out, " destination: {:?},", s.destination).unwrap();
write!(out, " kind: ").unwrap();
match s.kind.as_ref() {
None => writeln!(out, "None,").unwrap(),
Some(Kind::Directory) => writeln!(out, "Some(Directory),").unwrap(),
Some(Kind::Link(target)) => writeln!(out, "Some(Link({target:?})),").unwrap(),
Some(Kind::File(bytes)) => match std::str::from_utf8(bytes) {
Ok(text) => {
writeln!(out, "Some(File(").unwrap();
for line in text.split_inclusive('\n') {
write!(out, " {line}").unwrap();
if !line.ends_with('\n') {
out.push('\n');
}
}
writeln!(out, " )),").unwrap();
}
Err(_) => {
writeln!(out, "Some(File(<binary, {} bytes>)),", bytes.len()).unwrap();
}
},
}
writeln!(out, " user: {:?},", s.user).unwrap();
writeln!(out, " group: {:?},", s.group).unwrap();
match s.mode {
None => writeln!(out, " mode: None,").unwrap(),
Some(m) => writeln!(out, " mode: Some({m}),").unwrap(),
}
writeln!(out, "}}").unwrap();
out
}
struct OldState {
kind: Kind,
user: String,
group: String,
mode: Option<Mode>,
}
impl OldState {
fn to_state(&self, destination: &Path) -> State {
State {
destination: destination.to_path_buf(),
kind: Some(self.kind.clone()),
user: Some(self.user.clone()),
group: Some(self.group.clone()),
mode: self.mode,
}
}
}
fn name_of_uid(uid: u32) -> String {
nix::unistd::User::from_uid(nix::unistd::Uid::from_raw(uid))
.ok()
.flatten()
.map(|u| u.name)
.unwrap_or_else(|| uid.to_string())
}
fn name_of_gid(gid: u32) -> String {
nix::unistd::Group::from_gid(nix::unistd::Gid::from_raw(gid))
.ok()
.flatten()
.map(|g| g.name)
.unwrap_or_else(|| gid.to_string())
}
fn capture_old_state(path: &Path) -> crate::Result<Option<OldState>> {
use std::os::linux::fs::MetadataExt as _;
use std::os::unix::fs::PermissionsExt as _;
let md = match std::fs::symlink_metadata(path) {
Ok(md) => md,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(e) => {
return Err(e).context(format!("Couldn't stat {}", path.display()));
}
};
let ft = md.file_type();
let kind = if ft.is_file() {
Kind::File(std::fs::read(path).context(format!("Couldn't read file {}", path.display()))?)
} else if ft.is_dir() {
Kind::Directory
} else if ft.is_symlink() {
Kind::Link(
std::fs::read_link(path)
.context(format!("Couldn't read symlink {}", path.display()))?,
)
} else {
crate::bail!(
"{} is a special file (socket, FIFO, or device); refusing to manage it",
path.display()
);
};
let mode = if matches!(kind, Kind::Link(_)) {
None
} else {
Some(md.permissions().mode().into())
};
Ok(Some(OldState {
kind,
user: name_of_uid(md.st_uid()),
group: name_of_gid(md.st_gid()),
mode,
}))
}
fn remove_existing(path: &Path, kind: &Kind) -> crate::Result<()> {
match kind {
Kind::File(..) | Kind::Link(..) => std::fs::remove_file(path).context(format!(
"Couldn't remove file or symlink {}",
path.display()
)),
Kind::Directory => std::fs::remove_dir_all(path)
.context(format!("Couldn't remove directory {}", path.display())),
}
}
fn state_matches(old: Option<&OldState>, desired: &State) -> bool {
match (old, desired.kind.as_ref()) {
(None, None) => true,
(None, Some(_)) | (Some(_), None) => false,
(Some(o), Some(desired_kind)) => {
if &o.kind != desired_kind {
return false;
}
if let Some(u) = desired.user.as_ref()
&& u != &o.user
{
return false;
}
if let Some(g) = desired.group.as_ref()
&& g != &o.group
{
return false;
}
if !matches!(desired_kind, Kind::Link(_))
&& let Some(m) = desired.mode
&& Some(m) != o.mode
{
return false;
}
true
}
}
}
fn resolve_uid(name: Option<&String>) -> crate::Result<Option<nix::unistd::Uid>> {
let Some(name) = name else { return Ok(None) };
match nix::unistd::User::from_name(name) {
Ok(Some(user)) => Ok(Some(user.uid)),
_ => crate::bail!("Invalid user: {name}"),
}
}
fn resolve_gid(name: Option<&String>) -> crate::Result<Option<nix::unistd::Gid>> {
let Some(name) = name else { return Ok(None) };
match nix::unistd::Group::from_name(name) {
Ok(Some(group)) => Ok(Some(group.gid)),
_ => crate::bail!("Invalid group: {name}"),
}
}
#[crate::remote]
pub fn path(state: State) -> crate::Result<super::Return> {
let old = capture_old_state(&state.destination)?;
let new_uid = resolve_uid(state.user.as_ref())?;
let new_gid = resolve_gid(state.group.as_ref())?;
let already_matches = state_matches(old.as_ref(), &state);
if !already_matches {
let old_view = match old.as_ref() {
Some(o) => o.to_state(&state.destination),
None => State {
destination: state.destination.clone(),
..State::default()
},
};
let _ = <State as crate::Diff>::diff(&old_view, &state, &mut std::io::stderr().lock());
match state.kind.as_ref() {
None => {
if let Some(o) = old.as_ref() {
remove_existing(&state.destination, &o.kind)?;
}
}
Some(Kind::File(content)) => {
if let Some(o) = old.as_ref()
&& matches!(o.kind, Kind::Directory)
{
remove_existing(&state.destination, &o.kind)?;
}
let parent_raw = state
.destination
.parent()
.context("Parent directory unavailable")?;
let parent = if parent_raw.as_os_str().is_empty() {
Path::new(".")
} else {
parent_raw
};
let mut tmp_file = tempfile::Builder::new()
.permissions(Mode(0o0000).into())
.prefix(".fox.")
.tempfile_in(parent)
.context("Couldn't create temporary file")?;
tmp_file
.write_all(content)
.context("Couldn't write to temporary file")?;
nix::unistd::chown(tmp_file.path(), new_uid, new_gid)
.context("Couldn't change ownership of the temporary file")?;
if let Some(mode) = state.mode {
std::fs::set_permissions(tmp_file.path(), mode.into())
.context("Couldn't set permissions of the temporary file")?;
}
tmp_file
.persist(&state.destination)
.context("Couldn't persist the temporary file")?;
}
Some(Kind::Directory) => {
match old.as_ref().map(|o| &o.kind) {
Some(Kind::Directory) => {
}
Some(other) => {
remove_existing(&state.destination, other)?;
std::fs::create_dir_all(&state.destination)
.context("Couldn't create directory")?;
}
None => {
std::fs::create_dir_all(&state.destination)
.context("Couldn't create directory")?;
}
}
nix::unistd::chown(&state.destination, new_uid, new_gid)
.context("Couldn't change ownership of the directory")?;
if let Some(mode) = state.mode {
std::fs::set_permissions(&state.destination, mode.into())
.context("Couldn't set permissions of the directory")?;
}
}
Some(Kind::Link(target)) => {
if let Some(o) = old.as_ref() {
remove_existing(&state.destination, &o.kind)?;
}
std::os::unix::fs::symlink(target, &state.destination)
.context("Couldn't create symbolic link")?;
nix::unistd::fchownat(
nix::fcntl::AT_FDCWD,
&state.destination,
new_uid,
new_gid,
nix::fcntl::AtFlags::AT_SYMLINK_NOFOLLOW,
)
.context("Couldn't change ownership of symbolic link")?;
}
}
}
Ok(if already_matches {
super::Return::Unchanged
} else {
super::Return::Changed
})
}