decayfmt 0.1.1

A file format where decay is a first-class property. Every open permanently corrupts the file.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
//! The decayfmt format definition: the binary header and the filename convention.
//!
//! This module owns two pieces of format metadata: the fixed 16-byte header (magic,
//! version, file type, and image dimensions) and the filename convention that carries
//! the payload type and the instability value x (`name.idcy<x>` / `name.tdcy<x>`). It
//! knows nothing about corruption, file I/O, or the CLI. The header is written exactly
//! once at encode time and never mutated afterward; only the payload that follows it
//! ever changes. The invariant this module upholds is that a buffer is only accepted
//! as a header, and a name only accepted as a decayfmt name, if they match this build
//! exactly. Anything else is a typed refusal, never a guess.

use crate::error::DecayError;
use std::path::Path;

/// The four magic bytes that identify a decayfmt file: ASCII "DCYF".
pub const MAGIC: [u8; 4] = *b"DCYF";

/// The format version this build reads and writes. An unknown version is refused,
/// never interpreted, because the meaning of later versions is not knowable here.
pub const VERSION: u8 = 0x01;

/// file_type byte for an image payload (raw RGBA pixels).
pub const FILE_TYPE_IMAGE: u8 = 0x01;

/// file_type byte for a text payload (raw UTF-8 bytes).
pub const FILE_TYPE_TEXT: u8 = 0x02;

/// Filename extension prefix that precedes x for an image, for example `idcy3`.
pub const IMAGE_EXTENSION_PREFIX: &str = "idcy";

/// Filename extension prefix that precedes x for text, for example `tdcy7`.
pub const TEXT_EXTENSION_PREFIX: &str = "tdcy";

/// Byte offset of the 4-byte little-endian image width within the header.
const WIDTH_OFFSET: usize = 6;

/// Byte offset of the 4-byte little-endian image height within the header.
const HEIGHT_OFFSET: usize = 10;

/// Byte offset of the reserved region within the header.
const RESERVED_OFFSET: usize = 14;

/// Number of reserved bytes after the dimensions. Zero-filled on write, ignored on read.
const RESERVED_LEN: usize = 2;

/// Total size of the fixed header: 4 (magic) + 1 (version) + 1 (file_type)
/// + 4 (width) + 4 (height) + 2 (reserved).
pub const HEADER_SIZE: usize = RESERVED_OFFSET + RESERVED_LEN;

/// Which kind of payload follows the header.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FileType {
    Image,
    Text,
}

impl FileType {
    /// Maps a FileType to its on-disk byte.
    fn to_byte(self) -> u8 {
        match self {
            FileType::Image => FILE_TYPE_IMAGE,
            FileType::Text => FILE_TYPE_TEXT,
        }
    }

    /// Maps an on-disk byte to a FileType, refusing any byte that is not a known
    /// file type rather than defaulting to one.
    fn from_byte(byte: u8) -> Result<FileType, DecayError> {
        match byte {
            FILE_TYPE_IMAGE => Ok(FileType::Image),
            FILE_TYPE_TEXT => Ok(FileType::Text),
            other => Err(DecayError::UnsupportedFileType { found: other }),
        }
    }

    /// A short human-readable name for this file type, used in error messages when
    /// the filename's extension and the header disagree about the payload type.
    pub fn label(self) -> &'static str {
        match self {
            FileType::Image => "image",
            FileType::Text => "text",
        }
    }
}

/// Parses the decayfmt filename convention into the payload type and instability x.
///
/// The convention is `name.idcy<x>` for images and `name.tdcy<x>` for text, where x
/// is a positive integer. The payload type comes from the prefix and x from the
/// integer suffix. Both encode (to validate its output name) and open (to read x and
/// cross-check the type against the header) go through here, so the naming rule lives
/// in exactly one place. Every way a name can fail to fit the convention is a distinct
/// typed error: an unrecognized prefix, a missing or non-numeric x, a zero x, or an x
/// too large to fit a u32. x is never inferred from anywhere but the filename.
pub fn parse_filename(path: &Path) -> Result<(FileType, f64), DecayError> {
    let extension = path.extension().and_then(|raw| raw.to_str()).unwrap_or("");

    let (file_type, digits) = if let Some(rest) = extension.strip_prefix(IMAGE_EXTENSION_PREFIX) {
        (FileType::Image, rest)
    } else if let Some(rest) = extension.strip_prefix(TEXT_EXTENSION_PREFIX) {
        (FileType::Text, rest)
    } else {
        return Err(DecayError::UnrecognizedExtension {
            extension: extension.to_string(),
        });
    };

    if digits.is_empty() || !digits.bytes().all(|byte| byte.is_ascii_digit()) {
        return Err(DecayError::FilenameNoX {
            filename: path.to_string_lossy().into_owned(),
        });
    }

    match digits.parse::<u32>() {
        Ok(0) => Err(DecayError::XNotPositive { value: 0.0 }),
        Ok(value) => Ok((file_type, f64::from(value))),
        Err(_) => Err(DecayError::XOutOfRange {
            value: digits.to_string(),
        }),
    }
}

/// The pixel dimensions of an image payload. Stored in the header so the flat RGBA
/// payload can be turned back into a viewable image when the file is opened.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ImageDimensions {
    pub width: u32,
    pub height: u32,
}

/// The parsed, validated header of a decayfmt file. It carries the payload type and,
/// for images, the pixel dimensions needed to interpret the raw RGBA payload. Magic
/// and version are validated on read and not stored, because they are fixed for a
/// given build.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Header {
    pub file_type: FileType,
    /// Present only for images; None for text, which has no dimensions.
    pub dimensions: Option<ImageDimensions>,
}

/// Reads a little-endian u32 from `buffer` at `offset`. The caller must have already
/// checked that the buffer is at least HEADER_SIZE bytes, so the four bytes are in range.
fn read_u32_le(buffer: &[u8], offset: usize) -> u32 {
    u32::from_le_bytes([
        buffer[offset],
        buffer[offset + 1],
        buffer[offset + 2],
        buffer[offset + 3],
    ])
}

impl Header {
    /// Builds a header for an image payload of the given pixel dimensions.
    pub fn for_image(width: u32, height: u32) -> Header {
        Header {
            file_type: FileType::Image,
            dimensions: Some(ImageDimensions { width, height }),
        }
    }

    /// Builds a header for a text payload, which carries no dimensions.
    pub fn for_text() -> Header {
        Header {
            file_type: FileType::Text,
            dimensions: None,
        }
    }

    /// Serializes the header to its fixed 16-byte on-disk form.
    ///
    /// Upholds the invariant that the reserved bytes are always zero on write. Image
    /// dimensions are written as two little-endian u32 values; for text those bytes
    /// stay zero. The header produced here is written once and never rewritten.
    pub fn write(&self) -> [u8; HEADER_SIZE] {
        let mut bytes = [0u8; HEADER_SIZE];
        bytes[0..4].copy_from_slice(&MAGIC);
        bytes[4] = VERSION;
        bytes[5] = self.file_type.to_byte();
        if let Some(dimensions) = self.dimensions {
            bytes[WIDTH_OFFSET..WIDTH_OFFSET + 4].copy_from_slice(&dimensions.width.to_le_bytes());
            bytes[HEIGHT_OFFSET..HEIGHT_OFFSET + 4]
                .copy_from_slice(&dimensions.height.to_le_bytes());
        }
        // For text the dimension bytes stay zero, and the reserved bytes at
        // bytes[RESERVED_OFFSET..] are always left zero.
        bytes
    }

    /// Parses and validates a header from the start of a buffer.
    ///
    /// Upholds the invariant that a header is only accepted if its magic and version
    /// match this build exactly. Image dimensions are read from the header; for text
    /// they are absent. The trailing reserved bytes are ignored. Returns a typed
    /// error for every way the buffer can fail to be a header this build understands.
    pub fn read(buffer: &[u8]) -> Result<Header, DecayError> {
        if buffer.len() < HEADER_SIZE {
            return Err(DecayError::PayloadTooSmall {
                found: buffer.len(),
                needed: HEADER_SIZE,
            });
        }

        let mut found_magic = [0u8; 4];
        found_magic.copy_from_slice(&buffer[0..4]);
        if found_magic != MAGIC {
            return Err(DecayError::WrongMagic { found: found_magic });
        }

        let version = buffer[4];
        if version != VERSION {
            return Err(DecayError::UnsupportedVersion { found: version });
        }

        let file_type = FileType::from_byte(buffer[5])?;
        let dimensions = match file_type {
            FileType::Image => Some(ImageDimensions {
                width: read_u32_le(buffer, WIDTH_OFFSET),
                height: read_u32_le(buffer, HEIGHT_OFFSET),
            }),
            FileType::Text => None,
        };

        // The reserved bytes at buffer[RESERVED_OFFSET..HEADER_SIZE] are ignored.
        Ok(Header {
            file_type,
            dimensions,
        })
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    /// Builds a valid image header buffer with known dimensions for tests to mutate.
    fn valid_image_header() -> Vec<u8> {
        Header::for_image(640, 480).write().to_vec()
    }

    #[test]
    fn image_header_round_trips_with_dimensions() {
        let original = Header::for_image(640, 480);
        let bytes = original.write();
        let parsed = Header::read(&bytes).expect("valid image header must parse");
        assert_eq!(parsed, original, "image round-trip changed the header");
        assert_eq!(
            parsed.dimensions,
            Some(ImageDimensions {
                width: 640,
                height: 480
            })
        );
    }

    #[test]
    fn text_header_round_trips_without_dimensions() {
        let original = Header::for_text();
        let bytes = original.write();
        let parsed = Header::read(&bytes).expect("valid text header must parse");
        assert_eq!(parsed, original, "text round-trip changed the header");
        assert_eq!(parsed.dimensions, None, "text headers carry no dimensions");
    }

    #[test]
    fn dimensions_are_written_little_endian() {
        let bytes = Header::for_image(0x0403_0201, 0x0807_0605).write();
        assert_eq!(
            &bytes[6..10],
            &[0x01, 0x02, 0x03, 0x04],
            "width must be little-endian"
        );
        assert_eq!(
            &bytes[10..14],
            &[0x05, 0x06, 0x07, 0x08],
            "height must be little-endian"
        );
    }

    #[test]
    fn reserved_bytes_are_zero_on_write() {
        // Even with dimensions set, the trailing reserved bytes must stay zero.
        let bytes = Header::for_image(640, 480).write();
        assert!(
            bytes[14..HEADER_SIZE].iter().all(|&b| b == 0),
            "reserved region must be zero-filled on write"
        );
    }

    #[test]
    fn magic_and_version_bytes_are_exact() {
        let bytes = Header::for_image(2, 2).write();
        assert_eq!(&bytes[0..4], b"DCYF", "magic bytes must be DCYF");
        assert_eq!(bytes[4], 0x01, "version byte must be 0x01");
        assert_eq!(bytes[5], FILE_TYPE_IMAGE, "file_type byte must be image");
    }

    #[test]
    fn wrong_magic_is_refused() {
        let mut bytes = valid_image_header();
        bytes[0] = b'X';
        match Header::read(&bytes) {
            Err(DecayError::WrongMagic { found }) => assert_eq!(found[0], b'X'),
            other => panic!("expected WrongMagic, got {:?}", other),
        }
    }

    #[test]
    fn wrong_version_is_refused() {
        let mut bytes = valid_image_header();
        bytes[4] = 0x02;
        match Header::read(&bytes) {
            Err(DecayError::UnsupportedVersion { found }) => assert_eq!(found, 0x02),
            other => panic!("expected UnsupportedVersion, got {:?}", other),
        }
    }

    #[test]
    fn unknown_file_type_is_refused() {
        let mut bytes = valid_image_header();
        bytes[5] = 0x09;
        match Header::read(&bytes) {
            Err(DecayError::UnsupportedFileType { found }) => assert_eq!(found, 0x09),
            other => panic!("expected UnsupportedFileType, got {:?}", other),
        }
    }

    #[test]
    fn buffer_smaller_than_header_is_refused() {
        let short = [0u8; HEADER_SIZE - 1];
        match Header::read(&short) {
            Err(DecayError::PayloadTooSmall { found, needed }) => {
                assert_eq!(found, HEADER_SIZE - 1);
                assert_eq!(needed, HEADER_SIZE);
            }
            other => panic!("expected PayloadTooSmall, got {:?}", other),
        }
    }

    #[test]
    fn reserved_bytes_are_ignored_on_read() {
        // Only the trailing reserved bytes are ignored; flipping them must not stop
        // the header from parsing nor disturb the dimensions read before them.
        let mut bytes = valid_image_header();
        for b in bytes.iter_mut().take(HEADER_SIZE).skip(RESERVED_OFFSET) {
            *b = 0xFF;
        }
        let parsed = Header::read(&bytes).expect("reserved bytes must be ignored");
        assert_eq!(parsed.file_type, FileType::Image);
        assert_eq!(
            parsed.dimensions,
            Some(ImageDimensions {
                width: 640,
                height: 480
            })
        );
    }

    #[test]
    fn parse_filename_reads_type_and_x() {
        assert_eq!(
            parse_filename(Path::new("photo.idcy3")).expect("idcy3 parses"),
            (FileType::Image, 3.0)
        );
        assert_eq!(
            parse_filename(Path::new("note.tdcy12")).expect("tdcy12 parses"),
            (FileType::Text, 12.0)
        );
    }

    #[test]
    fn parse_filename_refuses_unrecognized_extension() {
        for name in ["photo.png", "note.txt", "no_extension"] {
            assert!(
                matches!(
                    parse_filename(Path::new(name)),
                    Err(DecayError::UnrecognizedExtension { .. })
                ),
                "'{}' should be an unrecognized extension",
                name
            );
        }
    }

    #[test]
    fn parse_filename_refuses_missing_or_non_numeric_x() {
        for name in ["photo.idcy", "note.tdcyx", "photo.idcy3a"] {
            assert!(
                matches!(
                    parse_filename(Path::new(name)),
                    Err(DecayError::FilenameNoX { .. })
                ),
                "'{}' should yield FilenameNoX",
                name
            );
        }
    }

    #[test]
    fn parse_filename_refuses_zero_x() {
        assert!(matches!(
            parse_filename(Path::new("photo.idcy0")),
            Err(DecayError::XNotPositive { .. })
        ));
    }

    #[test]
    fn parse_filename_refuses_x_too_large_for_u32() {
        // A run of digits that overflows u32 reports an out-of-range error, not the
        // misleading "no x" error, since there clearly is an x, it is just too big.
        assert!(matches!(
            parse_filename(Path::new("photo.idcy99999999999")),
            Err(DecayError::XOutOfRange { .. })
        ));
    }
}