use crate::corrupt::corrupt;
use crate::error::DecayError;
use crate::format::{
parse_filename, AudioSpec, Header, ImageDimensions, Metadata, AUDIO_BITS_PER_SAMPLE,
AUDIO_BYTES_PER_SAMPLE, HEADER_SIZE,
};
use fs2::FileExt;
use std::fs::OpenOptions;
use std::io::{IsTerminal, Read, Seek, SeekFrom, Write};
use std::path::{Path, PathBuf};
use std::process::Command;
use std::time::{SystemTime, UNIX_EPOCH};
const RGBA_BYTES_PER_PIXEL: usize = 4;
fn ensure_writable(path: &Path) -> Result<(), DecayError> {
let metadata = std::fs::metadata(path).map_err(|error| DecayError::Io {
context: format!("open: stat '{}'", path.display()),
source: error,
})?;
if metadata.permissions().readonly() {
return Err(DecayError::ReadOnly {
path: path.display().to_string(),
});
}
Ok(())
}
pub fn decay_in_place(path: &Path) -> Result<(Header, Vec<u8>), DecayError> {
let (filename_type, x) = parse_filename(path)?;
ensure_writable(path)?;
let mut file = OpenOptions::new()
.read(true)
.write(true)
.open(path)
.map_err(|error| DecayError::Io {
context: format!("open: open '{}'", path.display()),
source: error,
})?;
file.lock_exclusive().map_err(|error| DecayError::Io {
context: format!("open: lock '{}' for a single open", path.display()),
source: error,
})?;
let mut file_bytes = Vec::new();
file.read_to_end(&mut file_bytes)
.map_err(|error| DecayError::Io {
context: format!("open: read '{}'", path.display()),
source: error,
})?;
let header = Header::read(&file_bytes)?;
if filename_type != header.file_type {
return Err(DecayError::MismatchedFileType {
extension_kind: filename_type.label(),
header_kind: header.file_type.label(),
});
}
corrupt(&mut file_bytes[HEADER_SIZE..], header.file_type, x);
file.seek(SeekFrom::Start(HEADER_SIZE as u64))
.map_err(|error| DecayError::Io {
context: format!("open: seek past header in '{}'", path.display()),
source: error,
})?;
file.write_all(&file_bytes[HEADER_SIZE..])
.map_err(|error| DecayError::Io {
context: format!("open: write corrupted payload to '{}'", path.display()),
source: error,
})?;
Ok((header, file_bytes))
}
pub fn open_file(path: &Path) -> Result<(), DecayError> {
cleanup_old_view_files();
let (header, file_bytes) = decay_in_place(path)?;
let payload = &file_bytes[HEADER_SIZE..];
match header.metadata {
Metadata::Image(dimensions) => display_image(payload, dimensions),
Metadata::Text => display_text(payload),
Metadata::Audio(spec) => play_audio(payload, spec),
}
}
fn display_text(payload: &[u8]) -> Result<(), DecayError> {
let text = String::from_utf8_lossy(payload);
print!("{}", text);
if !text.ends_with('\n') {
println!();
}
if std::io::stdout().is_terminal() {
return Ok(());
}
let viewer_path = temporary_output_path("txt");
std::fs::write(&viewer_path, text.as_bytes()).map_err(|error| DecayError::Io {
context: format!("open: write display text '{}'", viewer_path.display()),
source: error,
})?;
open_in_default_app(&viewer_path)
}
fn display_image(payload: &[u8], dimensions: ImageDimensions) -> Result<(), DecayError> {
let expected = (dimensions.width as usize)
.saturating_mul(dimensions.height as usize)
.saturating_mul(RGBA_BYTES_PER_PIXEL);
let image = image::RgbaImage::from_raw(dimensions.width, dimensions.height, payload.to_vec())
.ok_or(DecayError::PayloadSizeMismatch {
expected,
found: payload.len(),
})?;
let viewer_path = temporary_output_path("png");
image
.save(&viewer_path)
.map_err(|error| DecayError::ImageEncode {
context: format!(
"open: encode display png '{}': {}",
viewer_path.display(),
error
),
})?;
open_in_default_app(&viewer_path)
}
fn play_audio(payload: &[u8], spec: AudioSpec) -> Result<(), DecayError> {
if spec.bits_per_sample != AUDIO_BITS_PER_SAMPLE {
return Err(DecayError::AudioEncode {
context: format!(
"open: header declares {} bits per sample but this build only writes and plays {}.",
spec.bits_per_sample, AUDIO_BITS_PER_SAMPLE
),
});
}
if spec.channels == 0 {
return Err(DecayError::AudioEncode {
context: "open: header declares zero audio channels.".to_string(),
});
}
let bytes_per_frame = AUDIO_BYTES_PER_SAMPLE * spec.channels as usize;
#[allow(unknown_lints, clippy::manual_is_multiple_of)]
if payload.len() % bytes_per_frame != 0 {
let whole_frames = payload.len() / bytes_per_frame;
return Err(DecayError::PayloadSizeMismatch {
expected: whole_frames.saturating_add(1) * bytes_per_frame,
found: payload.len(),
});
}
let viewer_path = temporary_output_path("wav");
let encode_error = |message: String| DecayError::AudioEncode {
context: format!(
"open: encode playback wav '{}': {}",
viewer_path.display(),
message
),
};
let wav_spec = hound::WavSpec {
channels: u16::from(spec.channels),
sample_rate: spec.sample_rate,
bits_per_sample: u16::from(spec.bits_per_sample),
sample_format: hound::SampleFormat::Int,
};
let mut writer = hound::WavWriter::create(&viewer_path, wav_spec)
.map_err(|error| encode_error(error.to_string()))?;
#[allow(unknown_lints, clippy::chunks_exact_to_as_chunks)]
for sample in payload.chunks_exact(AUDIO_BYTES_PER_SAMPLE) {
let value = i16::from_le_bytes([sample[0], sample[1]]);
writer
.write_sample(value)
.map_err(|error| encode_error(error.to_string()))?;
}
writer
.finalize()
.map_err(|error| encode_error(error.to_string()))?;
open_in_default_app(&viewer_path)
}
fn temporary_output_path(extension: &str) -> PathBuf {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|elapsed| elapsed.as_nanos())
.unwrap_or(0);
std::env::temp_dir().join(format!("decayfmt_view_{}.{}", nanos, extension))
}
pub fn cleanup_old_view_files() {
if let Ok(entries) = std::fs::read_dir(std::env::temp_dir()) {
for entry in entries.flatten() {
if let Some(name) = entry.file_name().to_str() {
if name.starts_with("decayfmt_view_") {
let _ = std::fs::remove_file(entry.path());
}
}
}
}
}
fn open_in_default_app(path: &Path) -> Result<(), DecayError> {
let spawn_result = if cfg!(target_os = "windows") {
Command::new("cmd")
.args(["/C", "start", ""])
.arg(path)
.spawn()
} else if cfg!(target_os = "macos") {
Command::new("open").arg(path).spawn()
} else {
Command::new("xdg-open").arg(path).spawn()
};
spawn_result
.map(|_child| ())
.map_err(|error| DecayError::Io {
context: format!("open: launch default viewer for '{}'", path.display()),
source: error,
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::encode::encode_file;
use std::fs;
use std::time::{SystemTime, UNIX_EPOCH};
fn unique_temp_path(suffix: &str) -> PathBuf {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("system clock before unix epoch")
.as_nanos();
std::env::temp_dir().join(format!("decayfmt_open_test_{}_{}", nanos, suffix))
}
fn wav_bytes(sample_rate: u32, channels: u16, frames: u32) -> Vec<u8> {
let data_len = frames * u32::from(channels) * 2;
let mut bytes = Vec::new();
bytes.extend_from_slice(b"RIFF");
bytes.extend_from_slice(&(36 + data_len).to_le_bytes());
bytes.extend_from_slice(b"WAVEfmt ");
bytes.extend_from_slice(&16u32.to_le_bytes());
bytes.extend_from_slice(&1u16.to_le_bytes());
bytes.extend_from_slice(&channels.to_le_bytes());
bytes.extend_from_slice(&sample_rate.to_le_bytes());
bytes.extend_from_slice(&(sample_rate * u32::from(channels) * 2).to_le_bytes());
bytes.extend_from_slice(&(channels * 2).to_le_bytes());
bytes.extend_from_slice(&16u16.to_le_bytes());
bytes.extend_from_slice(b"data");
bytes.extend_from_slice(&data_len.to_le_bytes());
for frame in 0..frames {
for _ in 0..channels {
let sample = (frame % 30_000) as i16;
bytes.extend_from_slice(&sample.to_le_bytes());
}
}
bytes
}
#[test]
fn open_changes_the_payload_but_never_the_header_on_disk() {
let text_source = unique_temp_path("source.txt");
fs::write(&text_source, vec![b'a'; 4096]).expect("write test source");
let audio_source = unique_temp_path("source.wav");
fs::write(&audio_source, wav_bytes(22_050, 1, 4_000)).expect("write test wav");
for (source, name) in [(&text_source, "note.tdcy5"), (&audio_source, "clip.adcy10")] {
let decay_file = unique_temp_path(name);
encode_file(source, &decay_file).expect("encode should succeed");
let before = fs::read(&decay_file).expect("read encoded file");
decay_in_place(&decay_file).expect("decay should succeed");
let after = fs::read(&decay_file).expect("read opened file");
assert_eq!(
&after[..HEADER_SIZE],
&before[..HEADER_SIZE],
"{name}: the header bytes on disk must be untouched by open"
);
assert_ne!(
&after[HEADER_SIZE..],
&before[HEADER_SIZE..],
"{name}: payload must differ on disk after open"
);
assert_eq!(
after.len(),
before.len(),
"{name}: in-place payload overwrite must not change the file length"
);
let _ = fs::remove_file(&decay_file);
}
let _ = fs::remove_file(&text_source);
let _ = fs::remove_file(&audio_source);
}
#[test]
fn audio_round_trip_preserves_spec_and_payload_length() {
let input = unique_temp_path("source.wav");
let decay_file = unique_temp_path("clip.adcy3");
fs::write(&input, wav_bytes(44_100, 2, 1_000)).expect("write test wav");
encode_file(&input, &decay_file).expect("encode audio should succeed");
let encoded = fs::read(&decay_file).expect("read encoded file");
let header = Header::read(&encoded).expect("audio header must parse");
let spec = header.audio_spec().expect("audio header must carry a spec");
assert_eq!(spec.sample_rate, 44_100);
assert_eq!(spec.channels, 2);
assert_eq!(spec.bits_per_sample, AUDIO_BITS_PER_SAMPLE);
assert_eq!(
encoded.len() - HEADER_SIZE,
1_000 * 2 * AUDIO_BYTES_PER_SAMPLE,
"payload must be exactly the decoded PCM, every frame of it"
);
let _ = fs::remove_file(&input);
let _ = fs::remove_file(&decay_file);
}
#[test]
fn sweeping_removes_stale_view_files() {
let stale = std::env::temp_dir().join(format!(
"decayfmt_view_{}.txt",
SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("system clock before unix epoch")
.as_nanos()
));
fs::write(&stale, b"a less corrupted state").expect("write stale view file");
assert!(stale.exists(), "the stale file must exist before the sweep");
cleanup_old_view_files();
assert!(
!stale.exists(),
"the sweep must remove view files left by earlier opens"
);
}
#[test]
fn concurrent_opens_each_cost_a_corruption() {
use std::sync::Barrier;
const THREADS: usize = 4;
const PAYLOAD: usize = 4 * 1024 * 1024;
let input = unique_temp_path("source.txt");
let decay_file = unique_temp_path("note.tdcy10");
fs::write(&input, vec![b'a'; PAYLOAD]).expect("write test source");
encode_file(&input, &decay_file).expect("encode should succeed");
let barrier = Barrier::new(THREADS);
std::thread::scope(|scope| {
for _ in 0..THREADS {
let path = decay_file.clone();
let barrier = &barrier;
scope.spawn(move || {
barrier.wait();
decay_in_place(&path).expect("decay should succeed");
});
}
});
let on_disk = fs::read(&decay_file).expect("read opened file");
let survivors = on_disk[HEADER_SIZE..]
.iter()
.filter(|&&b| b == b'a')
.count();
let survival = survivors as f64 / (PAYLOAD as f64);
assert!(
survival < 0.035,
"{survivors} of {PAYLOAD} bytes survived ({survival:.4}); an open was lost to a concurrent write"
);
let _ = fs::remove_file(&input);
let _ = fs::remove_file(&decay_file);
}
#[test]
fn mismatched_extension_and_header_type_is_refused() {
let source_image = image::RgbaImage::from_fn(2, 2, |_, _| image::Rgba([1, 2, 3, 255]));
let input = unique_temp_path("source.png");
let image_file = unique_temp_path("photo.idcy3");
let mismatched = unique_temp_path("photo.tdcy3");
source_image.save(&input).expect("save test png");
encode_file(&input, &image_file).expect("encode image should succeed");
let bytes = fs::read(&image_file).expect("read image decayfmt file");
fs::write(&mismatched, &bytes).expect("write mismatched-name copy");
assert!(
matches!(
decay_in_place(&mismatched),
Err(DecayError::MismatchedFileType { .. })
),
"a file whose extension and header disagree must be refused"
);
let _ = fs::remove_file(&input);
let _ = fs::remove_file(&image_file);
let _ = fs::remove_file(&mismatched);
}
#[allow(clippy::permissions_set_readonly_false)]
#[test]
fn read_only_file_is_refused() {
let input = unique_temp_path("source.txt");
let decay_file = unique_temp_path("note.tdcy3");
fs::write(&input, b"some text").expect("write test source");
encode_file(&input, &decay_file).expect("encode should succeed");
let mut permissions = fs::metadata(&decay_file)
.expect("stat decay file")
.permissions();
permissions.set_readonly(true);
fs::set_permissions(&decay_file, permissions).expect("set read-only");
assert!(
matches!(
decay_in_place(&decay_file),
Err(DecayError::ReadOnly { .. })
),
"a read-only file must be refused"
);
let mut permissions = fs::metadata(&decay_file)
.expect("stat decay file")
.permissions();
permissions.set_readonly(false);
let _ = fs::set_permissions(&decay_file, permissions);
let _ = fs::remove_file(&input);
let _ = fs::remove_file(&decay_file);
}
#[test]
fn wrong_magic_is_refused() {
let decay_file = unique_temp_path("bogus.tdcy3");
fs::write(&decay_file, [0u8; 32]).expect("write bogus file");
assert!(
matches!(
decay_in_place(&decay_file),
Err(DecayError::WrongMagic { .. })
),
"a file without the magic bytes must be refused"
);
let _ = fs::remove_file(&decay_file);
}
#[test]
fn unrecognized_name_is_refused_before_touching_the_file() {
let missing = Path::new("this_file_does_not_exist.txt");
assert!(matches!(
decay_in_place(missing),
Err(DecayError::UnrecognizedExtension { .. })
));
}
}