use crate as cindy;
use crate::Context;
use std::io::Write as _;
use std::path::{Path, PathBuf};
#[derive(Clone, Copy, 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, PartialEq, Eq)]
#[crate::wire]
enum Kind {
Absent,
File {
content: Vec<u8>,
user: String,
group: String,
mode: Mode,
},
Directory {
user: String,
group: String,
mode: Mode,
},
Link {
target: PathBuf,
user: String,
group: String,
},
}
#[derive(Clone, PartialEq, Eq)]
#[crate::wire]
struct State {
destination: PathBuf,
kind: Kind,
}
#[crate::action]
pub fn file(
destination: impl Into<PathBuf>,
content: impl Into<Vec<u8>>,
user: impl Into<String>,
group: impl Into<String>,
mode: impl Into<Mode>,
) -> crate::Result<super::Return> {
apply(State {
destination,
kind: Kind::File {
content,
user,
group,
mode,
},
})
}
#[crate::action]
pub fn directory(
destination: impl Into<PathBuf>,
user: impl Into<String>,
group: impl Into<String>,
mode: impl Into<Mode>,
) -> crate::Result<super::Return> {
apply(State {
destination,
kind: Kind::Directory { user, group, mode },
})
}
#[crate::action]
pub fn link(
destination: impl Into<PathBuf>,
target: impl Into<PathBuf>,
user: impl Into<String>,
group: impl Into<String>,
) -> crate::Result<super::Return> {
apply(State {
destination,
kind: Kind::Link {
target,
user,
group,
},
})
}
#[crate::action]
pub fn absent(destination: impl Into<PathBuf>) -> crate::Result<super::Return> {
apply(State {
destination,
kind: Kind::Absent,
})
}
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_owner(out: &mut String, user: &str, group: &str) {
use std::fmt::Write as _;
writeln!(out, " user: {user:?},").unwrap();
writeln!(out, " group: {group:?},").unwrap();
}
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();
match &s.kind {
Kind::Absent => writeln!(out, " kind: Absent,").unwrap(),
Kind::Directory { user, group, mode } => {
writeln!(out, " kind: Directory {{").unwrap();
render_owner(&mut out, user, group);
writeln!(out, " mode: {mode},").unwrap();
writeln!(out, " }},").unwrap();
}
Kind::Link {
target,
user,
group,
} => {
writeln!(out, " kind: Link {{").unwrap();
writeln!(out, " target: {target:?},").unwrap();
render_owner(&mut out, user, group);
writeln!(out, " }},").unwrap();
}
Kind::File {
content,
user,
group,
mode,
} => {
writeln!(out, " kind: File {{").unwrap();
match std::str::from_utf8(content) {
Ok(text) => {
writeln!(out, " content: (").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, " content: <binary, {} bytes>,", content.len()).unwrap()
}
}
render_owner(&mut out, user, group);
writeln!(out, " mode: {mode},").unwrap();
writeln!(out, " }},").unwrap();
}
}
writeln!(out, "}}").unwrap();
out
}
#[derive(Clone, PartialEq, Eq)]
enum ObservedKind {
Absent,
File,
Directory,
Link(PathBuf),
}
struct OldState {
kind: ObservedKind,
user: String,
group: String,
mode: Option<Mode>,
}
impl OldState {
fn to_kind(&self, destination: &Path) -> Kind {
match &self.kind {
ObservedKind::Absent => Kind::Absent,
ObservedKind::Directory => Kind::Directory {
user: self.user.clone(),
group: self.group.clone(),
mode: self.mode.unwrap_or_else(|| 0.into()),
},
ObservedKind::Link(target) => Kind::Link {
target: target.clone(),
user: self.user.clone(),
group: self.group.clone(),
},
ObservedKind::File => Kind::File {
content: std::fs::read(destination).unwrap_or_default(),
user: self.user.clone(),
group: self.group.clone(),
mode: self.mode.unwrap_or_else(|| 0.into()),
},
}
}
}
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<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(OldState {
kind: ObservedKind::Absent,
user: String::new(),
group: String::new(),
mode: None,
});
}
Err(e) => return Err(e).context(format!("Couldn't stat {}", path.display())),
};
let ft = md.file_type();
let kind = if ft.is_file() {
ObservedKind::File
} else if ft.is_dir() {
ObservedKind::Directory
} else if ft.is_symlink() {
ObservedKind::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, ObservedKind::Link(_)) {
None
} else {
Some(md.permissions().mode().into())
};
Ok(OldState {
kind,
user: name_of_uid(md.st_uid()),
group: name_of_gid(md.st_gid()),
mode,
})
}
fn remove_existing(path: &Path, kind: &ObservedKind) -> crate::Result<()> {
match kind {
ObservedKind::Absent => Ok(()),
ObservedKind::File | ObservedKind::Link(_) => std::fs::remove_file(path).context(format!(
"Couldn't remove file or symlink {}",
path.display()
)),
ObservedKind::Directory => std::fs::remove_dir_all(path)
.context(format!("Couldn't remove directory {}", path.display())),
}
}
fn owner_matches(old: &OldState, user: &str, group: &str) -> bool {
old.user == user && old.group == group
}
fn file_matches(path: &Path, want: &[u8]) -> bool {
match std::fs::read(path) {
Ok(have) => have == want,
Err(_) => false,
}
}
fn state_matches(old: &OldState, desired: &State) -> bool {
match (&old.kind, &desired.kind) {
(ObservedKind::Absent, Kind::Absent) => true,
(_, Kind::Absent) | (ObservedKind::Absent, _) => false,
(
ObservedKind::File,
Kind::File {
content,
user,
group,
mode,
},
) => {
owner_matches(old, user, group)
&& old.mode == Some(*mode)
&& file_matches(&desired.destination, content)
}
(ObservedKind::Directory, Kind::Directory { user, group, mode }) => {
owner_matches(old, user, group) && old.mode == Some(*mode)
}
(
ObservedKind::Link(have_target),
Kind::Link {
target,
user,
group,
},
) => have_target == target && owner_matches(old, user, group),
_ => false,
}
}
fn resolve_uid(name: &str) -> crate::Result<nix::unistd::Uid> {
match nix::unistd::User::from_name(name) {
Ok(Some(user)) => Ok(user.uid),
_ => crate::bail!("Invalid user: {name}"),
}
}
fn resolve_gid(name: &str) -> crate::Result<nix::unistd::Gid> {
match nix::unistd::Group::from_name(name) {
Ok(Some(group)) => Ok(group.gid),
_ => crate::bail!("Invalid group: {name}"),
}
}
fn fsync_parent(path: &Path) -> crate::Result<()> {
let parent = path.parent().filter(|p| !p.as_os_str().is_empty());
let dir = parent.unwrap_or_else(|| Path::new("."));
let f =
std::fs::File::open(dir).context(format!("Couldn't open {} to fsync", dir.display()))?;
f.sync_all()
.context(format!("Couldn't fsync directory {}", dir.display()))
}
fn apply_dir_attrs(
path: &Path,
uid: nix::unistd::Uid,
gid: nix::unistd::Gid,
mode: Mode,
) -> crate::Result<()> {
use nix::fcntl::{OFlag, open};
use nix::sys::stat::{Mode as NixMode, fchmod};
let fd = open(
path,
OFlag::O_DIRECTORY | OFlag::O_NOFOLLOW | OFlag::O_RDONLY | OFlag::O_CLOEXEC,
NixMode::empty(),
)
.context(format!(
"Couldn't open directory {} (O_NOFOLLOW) to set attributes",
path.display()
))?;
nix::unistd::fchown(&fd, Some(uid), Some(gid))
.context("Couldn't change ownership of the directory")?;
let bits = NixMode::from_bits_truncate(mode.0);
fchmod(&fd, bits).context("Couldn't set permissions of the directory")?;
Ok(())
}
fn apply(state: State) -> crate::Result<super::Return> {
let old = capture_old_state(&state.destination)?;
if state_matches(&old, &state) {
return Ok(super::Return::Unchanged);
}
let old_view = State {
destination: state.destination.clone(),
kind: old.to_kind(&state.destination),
};
let _ = <State as crate::Diff>::diff(&old_view, &state, &mut std::io::stderr().lock());
match &state.kind {
Kind::Absent => {
remove_existing(&state.destination, &old.kind)?;
}
Kind::File {
content,
user,
group,
mode,
} => {
let new_uid = resolve_uid(user)?;
let new_gid = resolve_gid(group)?;
if matches!(old.kind, ObservedKind::Directory) {
remove_existing(&state.destination, &old.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(".cindy.")
.tempfile_in(parent)
.context("Couldn't create temporary file")?;
tmp_file
.write_all(content)
.context("Couldn't write to temporary file")?;
tmp_file
.as_file()
.sync_all()
.context("Couldn't fsync the temporary file before rename")?;
nix::unistd::chown(tmp_file.path(), Some(new_uid), Some(new_gid))
.context("Couldn't change ownership of the temporary file")?;
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")?;
fsync_parent(&state.destination)?;
}
Kind::Directory { user, group, mode } => {
let new_uid = resolve_uid(user)?;
let new_gid = resolve_gid(group)?;
match old.kind {
ObservedKind::Directory => {
}
ObservedKind::Absent => {
std::fs::create_dir(&state.destination).context("Couldn't create directory")?;
}
ref other => {
remove_existing(&state.destination, other)?;
std::fs::create_dir(&state.destination).context("Couldn't create directory")?;
}
}
apply_dir_attrs(&state.destination, new_uid, new_gid, *mode)?;
}
Kind::Link {
target,
user,
group,
} => {
let new_uid = resolve_uid(user)?;
let new_gid = resolve_gid(group)?;
if !matches!(old.kind, ObservedKind::Absent) {
remove_existing(&state.destination, &old.kind)?;
}
std::os::unix::fs::symlink(target, &state.destination)
.context("Couldn't create symbolic link")?;
nix::unistd::fchownat(
nix::fcntl::AT_FDCWD,
&state.destination,
Some(new_uid),
Some(new_gid),
nix::fcntl::AtFlags::AT_SYMLINK_NOFOLLOW,
)
.context("Couldn't change ownership of symbolic link")?;
}
}
Ok(super::Return::Changed)
}
#[cfg(test)]
mod tests {
use super::*;
fn current_owner() -> (String, String) {
let uid = nix::unistd::getuid();
let gid = nix::unistd::getgid();
let user = nix::unistd::User::from_uid(uid)
.unwrap()
.expect("current uid has a passwd entry")
.name;
let group = nix::unistd::Group::from_gid(gid)
.unwrap()
.expect("current gid has a group entry")
.name;
(user, group)
}
#[test]
fn state_postcard_roundtrips_every_variant() {
let cases = [
State {
destination: "/tmp/x".into(),
kind: Kind::Absent,
},
State {
destination: "/tmp/x".into(),
kind: Kind::File {
content: b"\x00\x01binary".to_vec(),
user: "alice".into(),
group: "users".into(),
mode: 0o640.into(),
},
},
State {
destination: "/tmp/d".into(),
kind: Kind::Directory {
user: "alice".into(),
group: "users".into(),
mode: 0o750.into(),
},
},
State {
destination: "/tmp/l".into(),
kind: Kind::Link {
target: "/tmp/target".into(),
user: "bob".into(),
group: "staff".into(),
},
},
];
for s in cases {
let bytes = postcard::to_allocvec(&s).expect("postcard serialise");
let back: State = postcard::from_bytes(&bytes).expect("postcard deserialise");
assert_eq!(s, back, "roundtrip mismatch");
}
}
#[test]
fn file_lifecycle_on_disk() {
let (u, g) = current_owner();
let tmp = tempfile::tempdir().unwrap();
let p = tmp.path().join("f");
let r = file_raw::inner(
p.clone(),
b"one".to_vec(),
u.clone(),
g.clone(),
0o644.into(),
)
.unwrap();
assert!(r.changed());
assert_eq!(std::fs::read(&p).unwrap(), b"one");
let r = file_raw::inner(
p.clone(),
b"one".to_vec(),
u.clone(),
g.clone(),
0o644.into(),
)
.unwrap();
assert!(!r.changed());
let r = file_raw::inner(
p.clone(),
b"two".to_vec(),
u.clone(),
g.clone(),
0o644.into(),
)
.unwrap();
assert!(r.changed());
assert_eq!(std::fs::read(&p).unwrap(), b"two");
let r = file_raw::inner(
p.clone(),
b"two".to_vec(),
u.clone(),
g.clone(),
0o600.into(),
)
.unwrap();
assert!(r.changed());
let r = directory_raw::inner(p.clone(), u.clone(), g.clone(), 0o755.into()).unwrap();
assert!(r.changed());
assert!(p.is_dir());
let r = absent_raw::inner(p.clone()).unwrap();
assert!(r.changed());
assert!(!p.exists());
let r = absent_raw::inner(p.clone()).unwrap();
assert!(!r.changed());
}
#[test]
fn dir_to_file_replacement_clears_tree() {
let (u, g) = current_owner();
let tmp = tempfile::tempdir().unwrap();
let d = tmp.path().join("d");
std::fs::create_dir(&d).unwrap();
std::fs::write(d.join("inner"), b"x").unwrap();
let r = file_raw::inner(d.clone(), b"now a file".to_vec(), u, g, 0o600.into()).unwrap();
assert!(r.changed());
assert!(d.is_file());
assert_eq!(std::fs::read(&d).unwrap(), b"now a file");
}
}