use std::{
fs::{self, File, Metadata, OpenOptions},
io::{ErrorKind, Write},
path::{Path, PathBuf},
};
#[cfg(unix)]
use std::os::unix::fs::OpenOptionsExt;
use anyhow::{Context, Result, ensure};
use crate::pipeline::OutputFormat;
#[derive(Debug)]
pub struct OutputPaths {
pub final_path: PathBuf,
pub temp_path: PathBuf,
overwrite: bool,
}
#[derive(Debug)]
pub struct FinalizedRecording {
pub path: PathBuf,
pub bytes: u64,
}
pub fn prepare(
output: Option<&Path>,
dir: &Path,
overwrite: bool,
format: OutputFormat,
) -> Result<OutputPaths> {
let mut final_path =
output.map_or_else(|| dir.join(default_file_name(format)), Path::to_path_buf);
ensure_extension(&mut final_path, format)?;
ensure_not_existing_directory(&final_path)?;
let parent = parent_dir(&final_path);
fs::create_dir_all(parent)
.with_context(|| format!("failed to create output directory `{}`", parent.display()))?;
if !overwrite {
final_path = unique_path(final_path)?;
}
let temp_path = reserve_unique_temp_path(&final_path)?;
Ok(OutputPaths {
final_path,
temp_path,
overwrite,
})
}
pub fn validate_request(output: Option<&Path>, dir: &Path, format: OutputFormat) -> Result<()> {
let Some(output) = output else {
preflight_output_directory(dir)?;
return Ok(());
};
let mut output = output.to_path_buf();
ensure_extension(&mut output, format)?;
let parent = parent_dir(&output);
preflight_output_directory(parent)?;
ensure_not_existing_directory(&output)?;
Ok(())
}
pub fn finalize(paths: OutputPaths) -> Result<FinalizedRecording> {
let metadata = temp_recording_metadata(&paths.temp_path).with_context(|| {
format!(
"temporary recording `{}` was not created",
paths.temp_path.display()
)
})?;
ensure!(
metadata.len() > 0,
"temporary recording `{}` is empty",
paths.temp_path.display()
);
sync_file(&paths.temp_path)?;
let final_path = if paths.overwrite {
promote_with_overwrite(&paths.temp_path, paths.final_path)?
} else {
promote_without_overwrite(&paths.temp_path, paths.final_path)?
};
Ok(FinalizedRecording {
path: final_path,
bytes: metadata.len(),
})
}
pub fn finalize_nonempty(paths: OutputPaths) -> Result<Option<FinalizedRecording>> {
let metadata = match temp_recording_metadata(&paths.temp_path) {
Ok(metadata) => metadata,
Err(err) if is_not_found_error(&err) => return Ok(None),
Err(err) => {
return Err(err).with_context(|| {
format!(
"temporary recording `{}` could not be inspected",
paths.temp_path.display()
)
});
}
};
if metadata.len() == 0 {
discard_temp_file(&paths.temp_path)?;
return Ok(None);
}
finalize(paths).map(Some)
}
pub fn discard(paths: OutputPaths) -> Result<()> {
discard_temp_file(&paths.temp_path)
}
fn default_file_name(format: OutputFormat) -> String {
let stamp = glib::DateTime::now_local()
.and_then(|time| time.format("%Y-%m-%d-%H-%M-%S"))
.map(|stamp| stamp.to_string())
.unwrap_or_else(|_| "unknown-time".to_string());
format!("Ocula-{stamp}.{}", format.extension())
}
fn ensure_extension(path: &mut PathBuf, format: OutputFormat) -> Result<()> {
ensure_output_file_name(path)?;
if path.extension().is_none() {
path.set_extension(format.extension());
return Ok(());
}
let extension = path.extension().and_then(|extension| extension.to_str());
ensure!(
extension.is_some_and(|extension| extension.eq_ignore_ascii_case(format.extension())),
"output path must use .{} for the selected format",
format.extension()
);
Ok(())
}
fn ensure_output_file_name(path: &Path) -> Result<()> {
let file_name = path.file_name().and_then(|file_name| file_name.to_str());
ensure!(
file_name.is_some_and(|file_name| !matches!(file_name, "." | "..")),
"output path must include a file name"
);
Ok(())
}
fn unique_path(path: PathBuf) -> Result<PathBuf> {
if !path_exists_or_symlink(&path)? {
return Ok(path);
}
let parent = parent_dir(&path);
let stem = path
.file_stem()
.and_then(|stem| stem.to_str())
.unwrap_or("recording");
let extension = path.extension().and_then(|extension| extension.to_str());
for index in 1..10_000 {
let file_name = match extension {
Some(extension) => format!("{stem}-{index:03}.{extension}"),
None => format!("{stem}-{index:03}"),
};
let candidate = parent.join(file_name);
if !path_exists_or_symlink(&candidate)? {
return Ok(candidate);
}
}
anyhow::bail!(
"could not find an unused output path near `{}`",
path.display()
)
}
fn reserve_unique_temp_path(final_path: &Path) -> Result<PathBuf> {
let parent = parent_dir(final_path);
let file_name = final_path
.file_name()
.and_then(|name| name.to_str())
.unwrap_or("recording.mkv");
for index in 0..10_000 {
let temp_name = if index == 0 {
format!(".{file_name}.part")
} else {
format!(".{file_name}.{index:03}.part")
};
let candidate = parent.join(temp_name);
match try_reserve_temp_file(&candidate) {
Ok(()) => return Ok(candidate),
Err(err) if err.kind() == ErrorKind::AlreadyExists => continue,
Err(err) => {
return Err(err).with_context(|| {
format!("failed to reserve temporary file `{}`", candidate.display())
});
}
}
}
anyhow::bail!(
"could not find an unused temporary path near `{}`",
final_path.display()
)
}
fn try_reserve_temp_file(path: &Path) -> std::io::Result<()> {
OpenOptions::new()
.write(true)
.create_new(true)
.open(path)
.and_then(|file| file.sync_all())
}
fn path_exists_or_symlink(path: &Path) -> Result<bool> {
match fs::symlink_metadata(path) {
Ok(_) => Ok(true),
Err(err) if err.kind() == ErrorKind::NotFound => Ok(false),
Err(err) => {
Err(err).with_context(|| format!("failed to inspect output path `{}`", path.display()))
}
}
}
fn temp_recording_metadata(path: &Path) -> Result<Metadata> {
let metadata = fs::symlink_metadata(path)?;
ensure!(
metadata.file_type().is_file(),
"temporary recording `{}` is not a regular file",
path.display()
);
Ok(metadata)
}
fn promote_with_overwrite(temp_path: &Path, final_path: PathBuf) -> Result<PathBuf> {
fs::rename(temp_path, &final_path).with_context(|| {
format!(
"failed to rename `{}` to `{}`",
temp_path.display(),
final_path.display()
)
})?;
sync_parent_dir(&final_path)?;
Ok(final_path)
}
fn promote_without_overwrite(temp_path: &Path, requested_final_path: PathBuf) -> Result<PathBuf> {
for _ in 0..10_000 {
let final_path = unique_path(requested_final_path.clone())?;
match fs::hard_link(temp_path, &final_path) {
Ok(()) => {
sync_parent_dir(&final_path)?;
match discard_temp_file(temp_path) {
Ok(()) => sync_parent_dir(&final_path)?,
Err(err) => eprintln!(
"warning: saved recording but failed to remove temporary link `{}`: {err:#}",
temp_path.display()
),
}
return Ok(final_path);
}
Err(err) if err.kind() == ErrorKind::AlreadyExists => continue,
Err(err) => {
return Err(err).with_context(|| {
format!(
"failed to promote `{}` to `{}` without overwriting",
temp_path.display(),
final_path.display()
)
});
}
}
}
anyhow::bail!(
"could not promote recording without overwriting near `{}`",
requested_final_path.display()
)
}
fn sync_file(path: &Path) -> Result<()> {
open_file_for_sync(path)
.and_then(|file| file.sync_all())
.with_context(|| format!("failed to sync temporary file `{}`", path.display()))
}
#[cfg(unix)]
fn open_file_for_sync(path: &Path) -> std::io::Result<File> {
OpenOptions::new()
.read(true)
.custom_flags(libc::O_NOFOLLOW)
.open(path)
}
#[cfg(not(unix))]
fn open_file_for_sync(path: &Path) -> std::io::Result<File> {
OpenOptions::new().read(true).open(path)
}
fn is_not_found_error(err: &anyhow::Error) -> bool {
err.downcast_ref::<std::io::Error>()
.is_some_and(|err| err.kind() == ErrorKind::NotFound)
}
fn sync_parent_dir(path: &Path) -> Result<()> {
let parent = parent_dir(path);
File::open(parent)
.and_then(|file| file.sync_all())
.with_context(|| format!("failed to sync output directory `{}`", parent.display()))
}
fn parent_dir(path: &Path) -> &Path {
match path.parent() {
Some(parent) if !parent.as_os_str().is_empty() => parent,
_ => Path::new("."),
}
}
fn preflight_output_directory(path: &Path) -> Result<()> {
let existing = nearest_existing_output_directory(path)?;
probe_writable_existing_directory(&existing)
}
fn nearest_existing_output_directory(path: &Path) -> Result<PathBuf> {
let mut current = if path.as_os_str().is_empty() {
PathBuf::from(".")
} else {
path.to_path_buf()
};
loop {
match fs::metadata(¤t) {
Ok(metadata) => {
ensure!(
metadata.is_dir(),
"output directory ancestor `{}` is not a directory",
current.display()
);
return Ok(current);
}
Err(err) if err.kind() == ErrorKind::NotFound => {
ensure!(
!path_exists_or_symlink(¤t)?,
"output directory ancestor `{}` is a dangling symlink",
current.display()
);
if !current.pop() || current.as_os_str().is_empty() {
current = PathBuf::from(".");
}
}
Err(err) => {
return Err(err).with_context(|| {
format!("failed to inspect output directory `{}`", current.display())
});
}
}
}
}
fn ensure_not_existing_directory(path: &Path) -> Result<()> {
if path.try_exists()? {
ensure!(
!path.is_dir(),
"output path `{}` is a directory",
path.display()
);
}
Ok(())
}
fn discard_temp_file(path: &Path) -> Result<()> {
match fs::remove_file(path) {
Ok(()) => Ok(()),
Err(err) if err.kind() == ErrorKind::NotFound => Ok(()),
Err(err) => Err(err)
.with_context(|| format!("failed to remove temporary file `{}`", path.display())),
}
}
fn probe_writable_existing_directory(dir: &Path) -> Result<()> {
if !dir.try_exists()? {
return Ok(());
}
for index in 0..100 {
let probe_path = dir.join(format!(
".ocula-write-test-{}-{index}.tmp",
std::process::id()
));
let mut file = match OpenOptions::new()
.write(true)
.create_new(true)
.open(&probe_path)
{
Ok(file) => file,
Err(err) if err.kind() == ErrorKind::AlreadyExists => continue,
Err(err) => {
return Err(err).with_context(|| {
format!("output directory `{}` is not writable", dir.display())
});
}
};
let write_result = file
.write_all(b"ocula output preflight\n")
.and_then(|()| file.sync_all())
.with_context(|| format!("failed to write output probe in `{}`", dir.display()));
drop(file);
let cleanup_result = discard_temp_file(&probe_path);
return match (write_result, cleanup_result) {
(Ok(()), Ok(())) => Ok(()),
(Err(err), Ok(())) => Err(err),
(Ok(()), Err(err)) => Err(err),
(Err(err), Err(cleanup_err)) => {
eprintln!("warning: failed to clean up output probe: {cleanup_err:#}");
Err(err)
}
};
}
anyhow::bail!(
"could not create an output writability probe in `{}`",
dir.display()
)
}
#[cfg(test)]
mod tests {
use super::*;
use std::{
env,
time::{SystemTime, UNIX_EPOCH},
};
#[test]
fn extension_added_when_missing() {
let mut path = PathBuf::from("capture");
ensure_extension(&mut path, OutputFormat::Mkv).unwrap();
assert_eq!(path, PathBuf::from("capture.mkv"));
}
#[test]
fn selected_extension_is_allowed_case_insensitively() {
let mut path = PathBuf::from("capture.MKV");
ensure_extension(&mut path, OutputFormat::Mkv).unwrap();
assert_eq!(path, PathBuf::from("capture.MKV"));
let mut path = PathBuf::from("capture.MP4");
ensure_extension(&mut path, OutputFormat::Mp4).unwrap();
assert_eq!(path, PathBuf::from("capture.MP4"));
let mut path = PathBuf::from("capture.GIF");
ensure_extension(&mut path, OutputFormat::Gif).unwrap();
assert_eq!(path, PathBuf::from("capture.GIF"));
}
#[test]
fn wrong_extension_is_rejected() {
let mut path = PathBuf::from("capture.webm");
assert!(ensure_extension(&mut path, OutputFormat::Mkv).is_err());
let mut path = PathBuf::from("capture.mkv");
assert!(ensure_extension(&mut path, OutputFormat::Mp4).is_err());
}
#[test]
fn output_path_must_have_real_file_name() {
assert!(validate_request(Some(Path::new(".")), Path::new("."), OutputFormat::Mkv).is_err());
assert!(
validate_request(Some(Path::new("..")), Path::new("."), OutputFormat::Mkv).is_err()
);
}
#[test]
fn validate_request_accepts_selected_format_output() {
assert!(
validate_request(
Some(Path::new("capture.mp4")),
Path::new("."),
OutputFormat::Mkv
)
.is_err()
);
assert!(
validate_request(
Some(Path::new("capture.mkv")),
Path::new("."),
OutputFormat::Mkv
)
.is_ok()
);
assert!(
validate_request(
Some(Path::new("capture.mp4")),
Path::new("."),
OutputFormat::Mp4
)
.is_ok()
);
assert!(
validate_request(
Some(Path::new("capture.gif")),
Path::new("."),
OutputFormat::Gif
)
.is_ok()
);
assert!(validate_request(None, Path::new("."), OutputFormat::Mkv).is_ok());
}
#[test]
fn validate_request_rejects_existing_file_as_output_dir() {
let dir = test_dir("file-as-output-dir");
fs::create_dir_all(&dir).unwrap();
let file = dir.join("not-a-dir");
fs::write(&file, b"not a directory").unwrap();
assert!(validate_request(None, &file, OutputFormat::Mkv).is_err());
fs::remove_dir_all(dir).unwrap();
}
#[test]
fn validate_request_rejects_existing_file_as_output_parent() {
let dir = test_dir("file-as-output-parent");
fs::create_dir_all(&dir).unwrap();
let file = dir.join("not-a-dir");
fs::write(&file, b"not a directory").unwrap();
assert!(
validate_request(
Some(&file.join("capture.mkv")),
Path::new("."),
OutputFormat::Mkv
)
.is_err()
);
fs::remove_dir_all(dir).unwrap();
}
#[test]
fn validate_request_rejects_existing_file_as_output_ancestor() {
let dir = test_dir("file-as-output-ancestor");
fs::create_dir_all(&dir).unwrap();
let file = dir.join("not-a-dir");
fs::write(&file, b"not a directory").unwrap();
assert!(
validate_request(
Some(&file.join("child/capture.mkv")),
Path::new("."),
OutputFormat::Mkv
)
.is_err()
);
fs::remove_dir_all(dir).unwrap();
}
#[test]
fn validate_request_rejects_existing_directory_as_output_path() {
let dir = test_dir("directory-as-output");
let output = dir.join("capture.mkv");
fs::create_dir_all(&output).unwrap();
assert!(validate_request(Some(&output), Path::new("."), OutputFormat::Mkv).is_err());
fs::remove_dir_all(dir).unwrap();
}
#[test]
fn validate_request_probes_writable_output_parent_without_leaving_files() {
let dir = test_dir("writable-output-parent");
fs::create_dir_all(&dir).unwrap();
validate_request(
Some(&dir.join("capture.mkv")),
Path::new("."),
OutputFormat::Mkv,
)
.unwrap();
assert!(!contains_output_probe(&dir));
fs::remove_dir_all(dir).unwrap();
}
#[test]
fn validate_request_probes_nearest_existing_parent_without_creating_missing_dirs() {
let dir = test_dir("missing-output-parent");
fs::create_dir_all(&dir).unwrap();
let output = dir.join("missing/child/capture.mkv");
validate_request(Some(&output), Path::new("."), OutputFormat::Mkv).unwrap();
assert!(!dir.join("missing").exists());
assert!(!contains_output_probe(&dir));
fs::remove_dir_all(dir).unwrap();
}
#[test]
fn validate_request_probes_writable_default_dir_without_leaving_files() {
let dir = test_dir("writable-default-dir");
fs::create_dir_all(&dir).unwrap();
validate_request(None, &dir, OutputFormat::Mkv).unwrap();
assert!(!contains_output_probe(&dir));
fs::remove_dir_all(dir).unwrap();
}
#[cfg(unix)]
#[test]
fn prepare_without_overwrite_avoids_dangling_output_symlink() {
use std::os::unix::fs::symlink;
let dir = test_dir("dangling-output-symlink");
fs::create_dir_all(&dir).unwrap();
let output = dir.join("capture.mkv");
symlink(dir.join("missing-target.mkv"), &output).unwrap();
let paths = prepare(Some(&output), Path::new("."), false, OutputFormat::Mkv).unwrap();
assert_eq!(paths.final_path, dir.join("capture-001.mkv"));
assert_eq!(paths.temp_path, dir.join(".capture-001.mkv.part"));
assert!(
fs::symlink_metadata(&output)
.unwrap()
.file_type()
.is_symlink()
);
discard(paths).unwrap();
fs::remove_file(output).unwrap();
fs::remove_dir_all(dir).unwrap();
}
#[cfg(unix)]
#[test]
fn prepare_skips_dangling_temp_symlink() {
use std::os::unix::fs::symlink;
let dir = test_dir("dangling-temp-symlink");
fs::create_dir_all(&dir).unwrap();
let temp_symlink = dir.join(".capture.mkv.part");
symlink(dir.join("missing-temp-target.part"), &temp_symlink).unwrap();
let paths = prepare(
Some(&dir.join("capture.mkv")),
Path::new("."),
false,
OutputFormat::Mkv,
)
.unwrap();
assert_eq!(paths.temp_path, dir.join(".capture.mkv.001.part"));
assert!(paths.temp_path.exists());
assert!(
fs::symlink_metadata(&temp_symlink)
.unwrap()
.file_type()
.is_symlink()
);
discard(paths).unwrap();
fs::remove_file(temp_symlink).unwrap();
fs::remove_dir_all(dir).unwrap();
}
#[cfg(unix)]
#[test]
fn validate_request_rejects_dangling_default_dir_symlink() {
use std::os::unix::fs::symlink;
let dir = test_dir("dangling-default-dir-symlink");
fs::create_dir_all(&dir).unwrap();
let link = dir.join("output-dir");
symlink(dir.join("missing-output-dir"), &link).unwrap();
let err = validate_request(None, &link, OutputFormat::Mkv)
.unwrap_err()
.to_string();
assert!(err.contains("dangling symlink"));
fs::remove_file(link).unwrap();
fs::remove_dir_all(dir).unwrap();
}
#[cfg(unix)]
#[test]
fn validate_request_rejects_dangling_output_parent_symlink() {
use std::os::unix::fs::symlink;
let dir = test_dir("dangling-output-parent-symlink");
fs::create_dir_all(&dir).unwrap();
let link = dir.join("parent");
symlink(dir.join("missing-parent"), &link).unwrap();
let err = validate_request(
Some(&link.join("capture.mkv")),
Path::new("."),
OutputFormat::Mkv,
)
.unwrap_err()
.to_string();
assert!(err.contains("dangling symlink"));
fs::remove_file(link).unwrap();
fs::remove_dir_all(dir).unwrap();
}
#[test]
fn bare_relative_output_parent_is_current_directory() {
assert_eq!(parent_dir(Path::new("capture.mkv")), Path::new("."));
assert_eq!(parent_dir(Path::new("./capture.mkv")), Path::new("."));
assert_eq!(
parent_dir(Path::new("recordings/capture.mkv")),
Path::new("recordings")
);
}
#[test]
fn finalize_nonempty_discards_empty_temp_file() {
let dir = test_dir("empty-temp");
fs::create_dir_all(&dir).unwrap();
let paths = test_paths(&dir);
File::create(&paths.temp_path).unwrap();
let saved = finalize_nonempty(paths).unwrap();
assert!(saved.is_none());
assert!(!dir.join(".capture.mkv.part").exists());
assert!(!dir.join("capture.mkv").exists());
fs::remove_dir_all(dir).unwrap();
}
#[test]
fn finalize_nonempty_promotes_nonempty_temp_file() {
let dir = test_dir("nonempty-temp");
fs::create_dir_all(&dir).unwrap();
let paths = test_paths(&dir);
fs::write(&paths.temp_path, b"recording").unwrap();
let saved = finalize_nonempty(paths).unwrap().unwrap();
assert_eq!(saved.bytes, 9);
assert_eq!(saved.path, dir.join("capture.mkv"));
assert!(dir.join("capture.mkv").exists());
assert!(!dir.join(".capture.mkv.part").exists());
fs::remove_dir_all(dir).unwrap();
}
#[test]
fn discard_removes_reserved_temp_file() {
let dir = test_dir("discard-temp");
fs::create_dir_all(&dir).unwrap();
let paths = test_paths(&dir);
File::create(&paths.temp_path).unwrap();
discard(paths).unwrap();
assert!(!dir.join(".capture.mkv.part").exists());
assert!(!dir.join("capture.mkv").exists());
fs::remove_dir_all(dir).unwrap();
}
#[test]
fn finalize_without_overwrite_preserves_existing_output() {
let dir = test_dir("preserve-existing");
fs::create_dir_all(&dir).unwrap();
let paths = test_paths(&dir);
fs::write(&paths.final_path, b"existing").unwrap();
fs::write(&paths.temp_path, b"new recording").unwrap();
let saved = finalize(paths).unwrap();
assert_eq!(fs::read(dir.join("capture.mkv")).unwrap(), b"existing");
assert_eq!(saved.path, dir.join("capture-001.mkv"));
assert_eq!(
fs::read(dir.join("capture-001.mkv")).unwrap(),
b"new recording"
);
assert!(!dir.join(".capture.mkv.part").exists());
fs::remove_dir_all(dir).unwrap();
}
#[cfg(unix)]
#[test]
fn finalize_rejects_symlinked_temp_recording() {
use std::os::unix::fs::symlink;
let dir = test_dir("symlink-temp-finalize");
fs::create_dir_all(&dir).unwrap();
let paths = test_paths(&dir);
let target = dir.join("target.mkv");
fs::write(&target, b"recording").unwrap();
symlink(&target, &paths.temp_path).unwrap();
let err = format!("{:#}", finalize(paths).unwrap_err());
assert!(err.contains("not a regular file"));
assert_eq!(fs::read(target).unwrap(), b"recording");
fs::remove_dir_all(dir).unwrap();
}
#[cfg(unix)]
#[test]
fn finalize_nonempty_rejects_symlinked_temp_recording() {
use std::os::unix::fs::symlink;
let dir = test_dir("symlink-temp-finalize-nonempty");
fs::create_dir_all(&dir).unwrap();
let paths = test_paths(&dir);
let target = dir.join("target.mkv");
fs::write(&target, b"recording").unwrap();
symlink(&target, &paths.temp_path).unwrap();
let err = format!("{:#}", finalize_nonempty(paths).unwrap_err());
assert!(err.contains("not a regular file"));
assert_eq!(fs::read(target).unwrap(), b"recording");
fs::remove_dir_all(dir).unwrap();
}
#[cfg(unix)]
#[test]
fn sync_file_does_not_follow_symlink() {
use std::os::unix::fs::symlink;
let dir = test_dir("symlink-temp-sync");
fs::create_dir_all(&dir).unwrap();
let target = dir.join("target.mkv");
let link = dir.join("link.part");
fs::write(&target, b"recording").unwrap();
symlink(&target, &link).unwrap();
assert!(sync_file(&link).is_err());
fs::remove_dir_all(dir).unwrap();
}
fn test_paths(dir: &Path) -> OutputPaths {
OutputPaths {
final_path: dir.join("capture.mkv"),
temp_path: dir.join(".capture.mkv.part"),
overwrite: false,
}
}
fn test_dir(name: &str) -> PathBuf {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
env::temp_dir().join(format!(
"ocula-output-test-{name}-{}-{nanos}",
std::process::id()
))
}
fn contains_output_probe(dir: &Path) -> bool {
fs::read_dir(dir).unwrap().any(|entry| {
entry
.unwrap()
.file_name()
.to_string_lossy()
.starts_with(".ocula-write-test-")
})
}
}