use std::{
collections::BTreeMap,
io::{Read, Write},
path::Path,
};
use cap_fs_ext::{DirExt, FollowSymlinks, MetadataExt, OpenOptionsFollowExt, OpenOptionsSyncExt};
use cap_std::fs::{Dir, Metadata, OpenOptions};
use serde::{Deserialize, Serialize};
use super::{Change, Content, Result, invalid};
use crate::setup_core::digest;
pub(super) const FILE_LIMIT: usize = 32 * 1024 * 1024;
pub(super) type Identity = (u64, u64);
pub(super) fn io<T>(value: std::io::Result<T>) -> Result<T> {
value.map_err(|error| invalid().with_source(error))
}
pub(super) fn identity(metadata: &Metadata) -> Result<Identity> {
#[cfg(windows)]
{
use cap_std::fs::MetadataExt as _;
if metadata.file_attributes() & 0x400 != 0 {
return Err(invalid());
}
}
if metadata.is_symlink() {
return Err(invalid());
}
Ok((metadata.dev(), metadata.ino()))
}
pub(super) fn dir_id(directory: &Dir) -> Result<Identity> {
identity(&io(directory.dir_metadata())?)
}
pub(super) fn open_root(path: &Path) -> Result<Dir> {
let parent = path.parent().ok_or_else(invalid)?;
let name = path.file_name().ok_or_else(invalid)?;
let parent = io(Dir::open_ambient_dir(parent, cap_std::ambient_authority()))?;
let root = io(parent.open_dir_nofollow(name))?;
dir_id(&root)?;
Ok(root)
}
#[cfg_attr(
not(unix),
allow(
clippy::unnecessary_wraps,
reason = "Directory flushing is available on Unix only."
)
)]
pub(super) fn sync(directory: &Dir) -> Result<()> {
#[cfg(unix)]
io(io(directory.open("."))?.sync_all())?;
#[cfg(not(unix))]
let _ = directory;
Ok(())
}
pub(super) fn mode(metadata: &Metadata) -> u32 {
#[cfg(unix)]
{
use cap_std::fs::MetadataExt as _;
metadata.mode() & 0o777
}
#[cfg(not(unix))]
{
let _ = metadata;
0o600
}
}
pub(super) fn valid_path(path: &str) -> bool {
!path.is_empty()
&& path.len() <= 4096
&& !path.contains(['\\', ':', '\0'])
&& path.split('/').count() <= 64
&& path.split('/').all(|part| {
!part.is_empty() && !matches!(part, "." | "..") && !part.ends_with(['.', ' '])
})
}
#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)]
#[serde(deny_unknown_fields)]
pub(super) struct Stamp {
pub(super) identity: Identity,
pub(super) digest: String,
pub(super) length: usize,
pub(super) mode: u32,
}
#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)]
#[serde(deny_unknown_fields)]
pub struct Observation {
pub(super) parents: BTreeMap<String, Identity>,
pub(super) file: Option<Stamp>,
}
pub(super) fn parent(
root: &Dir,
path: &str,
create: bool,
) -> Result<(Option<Dir>, BTreeMap<String, Identity>)> {
if !valid_path(path) {
return Err(invalid());
}
let mut current = io(root.try_clone())?;
let mut parents = BTreeMap::new();
let mut relative = String::new();
let mut parts = path.split('/').peekable();
while let Some(name) = parts.next() {
if parts.peek().is_none() {
break;
}
if !relative.is_empty() {
relative.push('/');
}
relative.push_str(name);
let next = match current.open_dir_nofollow(name) {
Ok(directory) => directory,
Err(error) if error.kind() == std::io::ErrorKind::NotFound && create => {
io(current.create_dir(name))?;
sync(¤t)?;
io(current.open_dir_nofollow(name))?
}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
return Ok((None, parents));
}
Err(error) => return Err(invalid().with_source(error)),
};
parents.insert(relative.clone(), dir_id(&next)?);
current = next;
}
Ok((Some(current), parents))
}
fn read_file(directory: &Dir, name: &str) -> Result<Option<(Stamp, Vec<u8>)>> {
let mut options = OpenOptions::new();
options.read(true).follow(FollowSymlinks::No).nonblock(true);
let mut file = match directory.open_with(name, &options) {
Ok(file) => file,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(error) => return Err(invalid().with_source(error)),
};
let before = io(file.metadata())?;
let id = identity(&before)?;
if !before.is_file() || before.nlink() != 1 || before.len() > FILE_LIMIT as u64 {
return Err(invalid());
}
let mut bytes = Vec::new();
io(Read::by_ref(&mut file)
.take(FILE_LIMIT as u64 + 1)
.read_to_end(&mut bytes))?;
let after = io(file.metadata())?;
let named = io(directory.symlink_metadata(name))?;
if bytes.len() as u64 != before.len()
|| after.len() != before.len()
|| after.modified().ok() != before.modified().ok()
|| after.nlink() != 1
|| mode(&after) != mode(&before)
|| identity(&named)? != id
|| named.nlink() != 1
{
return Err(invalid());
}
Ok(Some((
Stamp {
identity: id,
digest: digest::of_bytes(&bytes),
length: bytes.len(),
mode: mode(&before),
},
bytes,
)))
}
pub(super) fn read(root: &Dir, path: &str) -> Result<(Observation, Option<Content>)> {
let (directory, parents) = parent(root, path, false)?;
let Some(directory) = directory else {
if parent(root, path, false)?.1 != parents {
return Err(invalid());
}
return Ok((
Observation {
parents,
file: None,
},
None,
));
};
let name = path.rsplit('/').next().ok_or_else(invalid)?;
let (file, content) = match read_file(&directory, name)? {
Some((stamp, bytes)) => {
let content = Content {
bytes,
mode: stamp.mode,
};
(Some(stamp), Some(content))
}
None => (None, None),
};
if parent(root, path, false)?.1 != parents {
return Err(invalid());
}
Ok((Observation { parents, file }, content))
}
pub(super) fn same_content(stamp: Option<&Stamp>, desired: Option<&Content>) -> bool {
match (stamp, desired) {
(None, None) => true,
(Some(stamp), Some(content)) => {
stamp.digest == digest::of_bytes(&content.bytes)
&& stamp.length == content.bytes.len()
&& stamp.mode == content.mode
}
_ => false,
}
}
pub(super) fn check_parents(before: &Observation, now: &Observation) -> Result<()> {
if before
.parents
.iter()
.any(|(path, id)| now.parents.get(path) != Some(id))
{
return Err(invalid());
}
Ok(())
}
pub(super) fn stage(directory: &Dir, name: &str, content: &Content) -> Result<()> {
let previous = read_file(directory, name)?;
if let Some((_, existing)) = &previous
&& !content.bytes.starts_with(existing)
{
return Err(invalid());
}
let mut options = OpenOptions::new();
options
.write(true)
.create_new(previous.is_none())
.follow(FollowSymlinks::No)
.nonblock(true);
#[cfg(unix)]
{
use cap_std::fs::OpenOptionsExt;
options.mode(0o600);
}
let mut file = io(directory.open_with(name, &options))?;
let metadata = io(file.metadata())?;
let id = identity(&metadata)?;
if !metadata.is_file()
|| metadata.nlink() != 1
|| previous
.as_ref()
.is_some_and(|(stamp, _)| stamp.identity != id)
{
return Err(invalid());
}
io(file.set_len(0))?;
io(file.write_all(&content.bytes))?;
#[cfg(unix)]
{
use cap_std::fs::PermissionsExt;
io(file.set_permissions(cap_std::fs::Permissions::from_mode(content.mode)))?;
}
io(file.sync_all())?;
sync(directory)
}
pub(super) fn publish(root: &Dir, staging: &Dir, name: &str, change: &Change) -> Result<()> {
let (now, _) = read(root, &change.path)?;
check_parents(&change.before, &now)?;
if same_content(now.file.as_ref(), change.after.as_ref()) {
return Ok(());
}
if now.file != change.before.file {
return Err(invalid());
}
let (destination, parents) = parent(root, &change.path, change.after.is_some())?;
let destination = destination.ok_or_else(invalid)?;
let leaf = change.path.rsplit('/').next().ok_or_else(invalid)?;
if let Some(content) = &change.after {
stage(staging, name, content)?;
}
let (checked, _) = read(root, &change.path)?;
if checked.file != now.file || checked.parents != parents {
return Err(invalid());
}
if change.after.is_some() {
io(staging.rename(name, &destination, leaf))?;
} else {
io(destination.remove_file(leaf))?;
}
sync(&destination)?;
sync(staging)?;
let (after, _) = read(root, &change.path)?;
if after.parents != parents || !same_content(after.file.as_ref(), change.after.as_ref()) {
return Err(invalid());
}
Ok(())
}