use std::{
io::Seek,
os::unix::fs::{FileTypeExt, MetadataExt},
};
use anyhow::Context;
use serde::{Deserialize, Serialize};
use tocat_api::{ByteSize, normalize};
use tokio::io::AsyncSeekExt;
use tracing::warn;
use crate::endpoint::{
Connection, Direction, EndpointStream, SyncHalves,
parse::{Opt, ParseEndpointError},
};
#[derive(Debug, Deserialize, Serialize)]
pub struct File {
pub path: std::path::PathBuf,
#[serde(default)]
pub append: bool,
#[serde(default)]
pub create: Option<bool>,
#[serde(default)]
pub truncate: bool,
#[serde(default)]
pub device: bool,
#[serde(default)]
pub seek: Option<ByteSize>,
#[serde(default)]
pub name: Option<String>,
}
impl File {
const SCHEME: &'static str = "file";
pub(super) fn parse<'a>(
body: &str,
opts: impl Iterator<Item = Opt<'a>>,
) -> Result<Self, ParseEndpointError> {
let mut append = false;
let mut create = None;
let mut truncate = false;
let mut device = false;
let mut seek = None;
let mut name = None;
for opt in opts {
match normalize(opt.key).as_str() {
"append" => append = opt.flag()?,
"create" => create = Some(opt.flag()?),
"device" | "dev" => device = opt.flag()?,
"name" => name = Some(opt.string()?),
"seek" => seek = Some(opt.size()?),
"truncate" | "trunc" => truncate = opt.flag()?,
_ => return Err(opt.unsupported(Self::SCHEME)),
}
}
let spec = Self {
path: std::path::PathBuf::from(body),
append,
create,
truncate,
device,
seek,
name,
};
spec.validate()?;
Ok(spec)
}
fn validate(&self) -> Result<(), ParseEndpointError> {
if self.seek.is_some() && self.append {
return Err(ParseEndpointError::Conflict {
scheme: Self::SCHEME,
reason: "seek has nothing to do under append, where every write goes to the end",
});
}
if self.device && self.create == Some(true) {
return Err(ParseEndpointError::Conflict {
scheme: Self::SCHEME,
reason: "device asserts the path already exists, so there is nothing to create",
});
}
if self.device && (self.truncate || self.append) {
return Err(ParseEndpointError::Conflict {
scheme: Self::SCHEME,
reason: "a device has no length, so it cannot be truncated or appended to",
});
}
Ok(())
}
fn creates(&self) -> bool {
self.create.unwrap_or(!self.device)
}
pub(super) fn label(&self) -> String {
format!("file://{}", self.path.display())
}
fn options(&self, dir: Direction) -> (bool, bool, bool, bool) {
match dir {
Direction::Source => (true, false, false, false),
Direction::Sink => (
false,
self.creates(),
self.append,
self.truncate && !self.append,
),
}
}
fn check_device(&self) -> anyhow::Result<()> {
if !self.device {
return Ok(());
}
let path = self.path.display();
let meta = std::fs::metadata(&self.path)
.with_context(|| format!("{path} was declared a device"))?;
let kind = meta.file_type();
if !kind.is_block_device() && !kind.is_char_device() {
anyhow::bail!("{path} is not a device");
}
Ok(())
}
fn seek_to(&self, meta: &std::fs::Metadata) -> anyhow::Result<Option<u64>> {
let Some(seek) = self.seek else {
return Ok(None);
};
let offset = seek.bytes() as u64;
if meta.file_type().is_block_device() {
let block = meta.blksize();
if block != 0 && !offset.is_multiple_of(block) {
warn!(
offset,
block,
path = %self.path.display(),
"seek is not a multiple of the block size",
);
}
}
Ok(Some(offset))
}
fn warn_if_fifo(&self, kind: std::fs::FileType) {
if kind.is_fifo() {
warn!(path = %self.path.display(), "FIFO endpoint: open blocks until a peer connects");
}
}
pub(super) async fn connect(&self, dir: Direction) -> anyhow::Result<Connection> {
let (read, create, append, truncate) = self.options(dir);
self.check_device()?;
let mut file = tokio::fs::OpenOptions::new()
.read(read)
.write(!read)
.create(create)
.append(append)
.truncate(truncate)
.open(&self.path)
.await
.with_context(|| format!("opening {}", self.path.display()))?;
let meta = file.metadata().await?;
self.warn_if_fifo(meta.file_type());
if let Some(offset) = self.seek_to(&meta)? {
file.seek(std::io::SeekFrom::Start(offset))
.await
.with_context(|| format!("seeking to {offset} in {}", self.path.display()))?;
}
Ok(match dir {
Direction::Source => EndpointStream::read_only(file),
Direction::Sink => EndpointStream::write_only(file),
}
.into_connection())
}
pub(super) fn connect_sync(&self, dir: Direction) -> anyhow::Result<SyncHalves> {
let (read, create, append, truncate) = self.options(dir);
self.check_device()?;
let mut file = std::fs::OpenOptions::new()
.read(read)
.write(!read)
.create(create)
.append(append)
.truncate(truncate)
.open(&self.path)
.with_context(|| format!("opening {}", self.path.display()))?;
let meta = file.metadata()?;
self.warn_if_fifo(meta.file_type());
if let Some(offset) = self.seek_to(&meta)? {
file.seek(std::io::SeekFrom::Start(offset))
.with_context(|| format!("seeking to {offset} in {}", self.path.display()))?;
}
Ok(match dir {
Direction::Source => SyncHalves {
reader: Some(Box::new(file)),
writer: None,
guard: None,
},
Direction::Sink => SyncHalves {
reader: None,
writer: Some(Box::new(file)),
guard: None,
},
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::endpoint::EndpointSpec;
fn file(s: &str) -> File {
match s.parse::<EndpointSpec>().expect("parses") {
EndpointSpec::File(e) => e,
other => panic!("wrong variant: {other:?}"),
}
}
fn err(s: &str) -> ParseEndpointError {
s.parse::<EndpointSpec>().expect_err("rejected")
}
#[test]
fn a_plain_file_still_creates_by_default() {
assert!(file("file:/tmp/out.bin").creates());
assert!(!file("file:/tmp/out.bin,create=false").creates());
}
#[test]
fn device_turns_creation_off() {
assert!(!file("file:/dev/sda,device").creates());
}
#[test]
fn asking_for_both_is_an_error_rather_than_an_override() {
assert!(matches!(
err("file:/dev/sda,device,create"),
ParseEndpointError::Conflict { .. }
));
assert!(!file("file:/dev/sda,device,create=false").creates());
}
#[test]
fn a_device_has_no_length_to_change() {
assert!(matches!(
err("file:/dev/sda,device,truncate"),
ParseEndpointError::Conflict { .. }
));
assert!(matches!(
err("file:/dev/sda,device,append"),
ParseEndpointError::Conflict { .. }
));
}
#[test]
fn seek_and_append_contradict() {
assert!(matches!(
err("file:/tmp/out.bin,seek=1MiB,append"),
ParseEndpointError::Conflict { .. }
));
}
#[test]
fn seek_takes_the_usual_suffixes() {
assert_eq!(
file("file:/dev/sda,device,seek=1MiB").seek,
Some(ByteSize(1024 * 1024)),
);
assert_eq!(file("file:/tmp/x,seek=512").seek, Some(ByteSize(512)));
}
}