use std::ffi::{OsStr, OsString};
use std::fs::File;
use std::io;
use std::path::{Component, Path};
#[cfg(unix)]
use cap_fs_ext::OpenOptionsMaybeDirExt as _;
use cap_fs_ext::{DirExt as _, FollowSymlinks, OpenOptionsFollowExt as _};
use cap_std::ambient_authority;
use cap_std::fs::{Dir, OpenOptions};
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct MigrationDirectoryEntry {
file_name: OsString,
is_directory: bool,
is_regular: bool,
}
impl MigrationDirectoryEntry {
pub fn file_name(&self) -> &OsStr {
&self.file_name
}
pub const fn is_directory(&self) -> bool {
self.is_directory
}
pub const fn is_regular(&self) -> bool {
self.is_regular
}
}
pub struct MigrationDirectory {
directory: Dir,
}
pub struct MigrationAuthoringLock<'a> {
_file: File,
pub(crate) directory: &'a MigrationDirectory,
}
impl std::fmt::Debug for MigrationDirectory {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("MigrationDirectory")
.finish_non_exhaustive()
}
}
impl MigrationDirectory {
pub fn open_ambient(path: &Path) -> io::Result<Self> {
Ok(Self {
directory: Dir::open_ambient_dir(path, ambient_authority())?,
})
}
pub fn open_beneath(root: &Path, relative: &Path, create: bool) -> io::Result<Self> {
let directory = Dir::open_ambient_dir(root, ambient_authority())?;
Self::open_beneath_directory(&directory, relative, create)
}
pub fn open_beneath_directory(root: &Dir, relative: &Path, create: bool) -> io::Result<Self> {
let mut directory = root.try_clone()?;
for component in relative.components() {
let Component::Normal(name) = component else {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"migration directory is not a confined relative path",
));
};
let next = match directory.open_dir_nofollow(name) {
Ok(next) => next,
Err(error) if create && error.kind() == io::ErrorKind::NotFound => {
match directory.create_dir(name) {
Ok(()) => {}
Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {}
Err(error) => return Err(error),
}
directory.open_dir_nofollow(name)?
}
Err(error) => return Err(error),
};
directory = next;
}
Ok(Self { directory })
}
pub fn entries(&self, limit: usize) -> io::Result<Vec<MigrationDirectoryEntry>> {
let mut entries = Vec::new();
for entry in self.directory.entries()? {
if entries.len() == limit {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"migration directory exceeds the entry ceiling",
));
}
let entry = entry?;
let file_type = entry.file_type()?;
entries.push(MigrationDirectoryEntry {
file_name: entry.file_name(),
is_directory: file_type.is_dir(),
is_regular: file_type.is_file(),
});
}
Ok(entries)
}
pub fn open_regular_readonly(&self, name: &OsStr) -> io::Result<File> {
let name = validate_portable_direct_child(name)?;
let mut options = OpenOptions::new();
options.read(true).follow(FollowSymlinks::No);
let file = self.directory.open_with(name, &options)?.into_std();
require_regular(&file)?;
Ok(file)
}
pub fn open_regular_lock(&self, name: &OsStr) -> io::Result<File> {
let name = validate_portable_direct_child(name)?;
let mut options = OpenOptions::new();
options
.read(true)
.write(true)
.create(true)
.follow(FollowSymlinks::No);
let mut denied = None;
for _ in 0..16 {
match self.directory.open_with(name, &options) {
Ok(file) => {
let file = file.into_std();
require_regular(&file)?;
return Ok(file);
}
Err(error) if error.kind() == io::ErrorKind::NotFound => denied = Some(error),
Err(error) => return Err(error),
}
}
Err(denied.expect("every exhausted attempt recorded its refusal"))
}
pub fn try_acquire_authoring_lock(&self) -> io::Result<MigrationAuthoringLock<'_>> {
use fs2::FileExt as _;
let file = self.open_regular_lock(".typebridge-authoring.lock".as_ref())?;
file.try_lock_exclusive().map_err(|error| {
if error.raw_os_error() == fs2::lock_contended_error().raw_os_error() {
io::Error::new(io::ErrorKind::WouldBlock, error)
} else {
error
}
})?;
Ok(MigrationAuthoringLock {
_file: file,
directory: self,
})
}
pub fn create_new(&self, name: &OsStr) -> io::Result<File> {
let name = validate_portable_direct_child(name)?;
let mut options = OpenOptions::new();
options
.write(true)
.create_new(true)
.follow(FollowSymlinks::No);
let file = self.directory.open_with(name, &options)?.into_std();
require_regular(&file)?;
Ok(file)
}
pub fn entry_exists(&self, name: &OsStr) -> io::Result<bool> {
let name = validate_portable_direct_child(name)?;
match self.directory.symlink_metadata(name) {
Ok(_) => Ok(true),
Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(false),
Err(error) => Err(error),
}
}
pub fn remove_file(&self, name: &OsStr) -> io::Result<()> {
self.directory
.remove_file(validate_portable_direct_child(name)?)
}
pub fn hard_link(&self, temporary: &OsStr, target: &OsStr) -> io::Result<()> {
self.directory.hard_link(
validate_portable_direct_child(temporary)?,
&self.directory,
validate_portable_direct_child(target)?,
)
}
pub fn sync_all(&self) -> io::Result<()> {
#[cfg(unix)]
{
let mut options = OpenOptions::new();
options
.read(true)
.maybe_dir(true)
.follow(FollowSymlinks::No);
self.directory
.open_with(".", &options)?
.into_std()
.sync_all()
}
#[cfg(not(unix))]
{
Ok(())
}
}
}
pub fn validate_portable_direct_child(name: &OsStr) -> io::Result<&Path> {
let path = Path::new(name);
let mut components = path.components();
if !matches!(components.next(), Some(Component::Normal(_))) || components.next().is_some() {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"authority name is not a direct child",
));
}
let portable = name.to_str().ok_or_else(|| {
io::Error::new(
io::ErrorKind::InvalidInput,
"authority name is not valid UTF-8",
)
})?;
let trimmed = portable.trim_end_matches(['.', ' ']);
let windows_stem = portable
.split('.')
.next()
.unwrap_or(portable)
.trim_end_matches(['.', ' ']);
let windows_stem = windows_stem.to_ascii_uppercase();
let windows_device = matches!(
windows_stem.as_str(),
"CON" | "PRN" | "AUX" | "NUL" | "CLOCK$" | "CONIN$" | "CONOUT$"
) || ["COM", "LPT"].iter().any(|prefix| {
windows_stem.strip_prefix(prefix).is_some_and(|suffix| {
matches!(
suffix,
"0" | "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9" | "¹" | "²" | "³"
)
})
});
if portable.is_empty()
|| portable.contains(['<', '>', ':', '"', '/', '\\', '|', '?', '*', '\0'])
|| portable.chars().any(char::is_control)
|| trimmed != portable
|| windows_device
{
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"authority name is not portable",
));
}
Ok(path)
}
fn require_regular(file: &File) -> io::Result<()> {
if file.metadata()?.is_file() {
Ok(())
} else {
Err(io::Error::new(
io::ErrorKind::InvalidInput,
"migration authority is not a regular file",
))
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write as _;
#[test]
fn direct_child_publication_primitives_round_trip() {
let temporary = tempfile::tempdir().expect("temporary directory");
let directory =
MigrationDirectory::open_ambient(temporary.path()).expect("directory capability opens");
let _lock = directory
.open_regular_lock(".lock".as_ref())
.expect("lock opens without following");
let mut candidate = directory
.create_new(".candidate.tmp".as_ref())
.expect("candidate creates exclusively");
candidate.write_all(b"authority").expect("candidate writes");
candidate.sync_all().expect("candidate flushes");
directory
.hard_link(".candidate.tmp".as_ref(), "authority.json".as_ref())
.expect("no-replace publication links");
directory.sync_all().expect("directory flushes");
let mut published = directory
.open_regular_readonly("authority.json".as_ref())
.expect("published authority opens without following");
let mut bytes = Vec::new();
std::io::Read::read_to_end(&mut published, &mut bytes).expect("published bytes read");
assert_eq!(bytes, b"authority");
}
#[test]
fn contended_authoring_lock_surfaces_would_block_on_every_platform() {
let temporary = tempfile::tempdir().expect("temporary directory");
let directory =
MigrationDirectory::open_ambient(temporary.path()).expect("directory capability opens");
let held = directory
.try_acquire_authoring_lock()
.expect("first publisher acquires the authoring lock");
let contended = match directory.try_acquire_authoring_lock() {
Ok(_) => panic!("second publisher must observe contention"),
Err(error) => error,
};
assert_eq!(contended.kind(), io::ErrorKind::WouldBlock);
drop(held);
directory
.try_acquire_authoring_lock()
.expect("released lock reacquires");
}
#[test]
fn direct_child_rejects_nonportable_and_windows_alias_spellings() {
for name in [
"nested/manifest.json",
"nested\\manifest.json",
"manifest.json:stream",
"manifest.json.",
"manifest.json ",
"manifest?.json",
"manifest*.json",
"manifest<copy>.json",
"manifest|copy.json",
"manifest\"copy.json",
"NUL",
"con.json",
"COM0",
"com1.json",
"COM¹.log",
"LPT³",
"CLOCK$",
"conout$.txt",
"line\nbreak",
] {
assert!(
validate_portable_direct_child(name.as_ref()).is_err(),
"nonportable name {name:?} was accepted"
);
}
assert!(validate_portable_direct_child(".typebridge-authoring.lock".as_ref()).is_ok());
assert!(validate_portable_direct_child("0001_init.tbmigration.json".as_ref()).is_ok());
}
}