falcon_mdf 0.6.0

High-performance Rust library for reading ASAM MDF v4 (MF4) measurement data files
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
//! Anonymising a measurement by replacing its text, byte for byte.
//!
//! Sharing a measurement means sharing the names of everything in it: channel
//! names, units, comments, the device that recorded it, the strings a
//! value-to-text table maps to. The numbers are usually the part worth sharing
//! and the names are the part that cannot be. [`scramble_file`] writes a copy
//! of a file with every piece of identifying text replaced by random letters
//! of the same byte length, and nothing else touched at all.
//!
//! ```no_run
//! use falcon_mdf::scramble_file;
//!
//! let report = scramble_file("measurement.mf4", "shareable.mf4", 0x5EED)?;
//! println!("{} text blocks randomised", report.blocks_scrambled);
//! # Ok::<(), falcon_mdf::error::Mf4Error>(())
//! ```
//!
//! # What is preserved
//!
//! Every byte that is not text inside a `##TX` or `##MD` block. The copy is
//! made with [`std::fs::copy`] and then patched in place, so block addresses,
//! lengths, link sections, the record layout and every sample byte are the
//! bytes of the original file. Within a text block the replacement runs only
//! up to the null terminator: the terminator itself and the padding a writer
//! left after it stay as they were, so the block's length field remains true
//! and the text keeps its original length. A reader of the scrambled file sees
//! names of the same shape as the ones it replaced.
//!
//! # What is not scrambled, and why
//!
//! Some text in an MF4 file is not a name but an instruction the file needs in
//! order to decode: randomising it would change the numbers, which is the one
//! thing this must never do. Those blocks are left alone and counted in
//! [`ScrambleReport::blocks_preserved`]:
//!
//! - the formula of an algebraic conversion (CC type 3), which is arithmetic,
//!   not a name;
//! - the *key* side of the text-keyed tables — every reference of a
//!   text-to-value table (type 9) and the even references of a text-to-text
//!   table (type 10). A key that no longer matches the sample text sends the
//!   lookup to its default and changes the result.
//!
//! The text those tables *produce* — value-to-text (type 7), range-to-text
//! (type 8) and the odd, replacement references of type 10 — is scrambled: it
//! is output, so it is identifying, and no number depends on it.
//!
//! # Limits
//!
//! - Only blocks reachable from the header block are visited, which is exactly
//!   the set a reader can see. Text in a region no link points at is left as
//!   it is rather than risk writing over the records of an unfinalized file,
//!   whose tail is uncovered by design.
//! - Channel *names* are what bus decoding matches on, so a scrambled
//!   bus-logging file no longer decodes to named CAN signals. Its frames and
//!   samples are unchanged.
//! - Sample data is never touched, so text carried as samples (a string
//!   channel's values) survives scrambling. Those are measurements, not
//!   metadata.
//! - Lengths are preserved, so a scrambled file still says how long each name
//!   was.

use std::collections::HashSet;
use std::fs;
use std::io::{Seek, SeekFrom, Write};
use std::path::Path;

use crate::blocks::conversion::ConversionType;
use crate::blocks::BLOCK_HEADER_SIZE;
use crate::error::{Mf4Error, Result};
use crate::inspect::BlockMap;
use crate::io::{ByteSource, IoBackend};
use crate::parser::{parse_cc_block, parse_id_block};

/// What a [`scramble_file`] run changed.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ScrambleReport {
    /// Text blocks whose text was replaced.
    pub blocks_scrambled: usize,
    /// Bytes of text overwritten, terminators and padding excluded.
    pub bytes_scrambled: u64,
    /// Text blocks deliberately left alone because decoding reads them — see
    /// the module documentation for which ones and why.
    pub blocks_preserved: usize,
}

/// Writes `dst` as a copy of `src` with every identifying string randomised.
///
/// `seed` fixes the randomisation: the same file and seed give the same
/// output, which is what makes a scrambled file reproducible and testable.
/// The mapping is one-way regardless of the seed — the replacement text is
/// drawn without reference to the text it replaces, so no seed recovers an
/// original name.
///
/// Returns [`Mf4Error::Unsupported`] for a file that is not MF4; the block
/// walk this relies on is MF4's. MDF 3.x files are read by `falcon_mdf::mdf3` and
/// have a different block layout, so they are rejected by name rather than
/// copied through unchanged.
///
/// # Errors
///
/// Fails if `src` cannot be read, if it is not an MF4 file, or if `dst`
/// cannot be written. On a write failure `dst` is left partially patched and
/// should be discarded.
pub fn scramble_file<P: AsRef<Path>, Q: AsRef<Path>>(
    src: P,
    dst: Q,
    seed: u64,
) -> Result<ScrambleReport> {
    let src = src.as_ref();
    let dst = dst.as_ref();

    let patches = {
        let source = IoBackend::open(src)?;

        let id = parse_id_block(&source)?;
        if id.version_number < 400 {
            return Err(Mf4Error::unsupported(
                "scramble",
                format!(
                    "file declares MDF version {}, and this walks MF4 blocks; \
                     only version 400 and above can be scrambled",
                    id.version_number
                ),
            ));
        }

        plan_patches(&source, seed)?
    };

    fs::copy(src, dst)?;

    let mut out = fs::OpenOptions::new().write(true).open(dst)?;
    let mut report = ScrambleReport {
        blocks_scrambled: 0,
        bytes_scrambled: 0,
        blocks_preserved: patches.preserved,
    };
    for patch in &patches.writes {
        // The replacement is built by overwriting a copy of the block's data
        // in place, so it cannot change length. Checking anyway is the cheap
        // half of a bargain whose expensive half is a file whose every block
        // after this one has silently moved.
        if patch.replacement.len() as u64 != patch.original_len {
            return Err(Mf4Error::write_error(format!(
                "refusing to scramble: replacement text for the block at {:#x} is {} bytes \
                 where the original is {}, and writing it would shift every later block",
                patch.address,
                patch.replacement.len(),
                patch.original_len
            )));
        }
        out.seek(SeekFrom::Start(patch.address))?;
        out.write_all(&patch.replacement)?;
        report.blocks_scrambled += 1;
        report.bytes_scrambled += patch.changed;
    }
    out.flush()?;

    Ok(report)
}

/// One text block's data section, rewritten.
struct Patch {
    /// Where the block's data section starts in the file.
    address: u64,
    /// The bytes to write there.
    replacement: Vec<u8>,
    /// Length of the data section being replaced, which the replacement must
    /// match exactly.
    original_len: u64,
    /// How many of those bytes are text that changed.
    changed: u64,
}

/// The edits a scramble will make, worked out before anything is written.
struct Patches {
    /// Every block's rewritten data section.
    writes: Vec<Patch>,
    /// Text blocks skipped because decoding reads them.
    preserved: usize,
}

/// Walks the block graph and builds the replacement text for every text block
/// that is safe to replace.
fn plan_patches<S: ByteSource>(source: &S, seed: u64) -> Result<Patches> {
    let map = BlockMap::scan(source);
    let protected = decode_critical_text(source, &map);

    let mut rng = Rng::new(seed);
    let mut writes = Vec::new();
    let mut preserved = 0;

    for block in &map.blocks {
        let is_md = match block.block_type.as_str() {
            "##TX" => false,
            "##MD" => true,
            _ => continue,
        };

        if protected.contains(&block.address) {
            preserved += 1;
            continue;
        }

        let start = block.address + BLOCK_HEADER_SIZE as u64 + 8 * block.link_count;
        let len = block.data_size as usize;
        if len == 0 {
            continue;
        }

        let data = source.read_bytes(start, len)?;
        let (replacement, changed) = if is_md {
            scramble_markup(&data, &mut rng)
        } else {
            scramble_text(&data, &mut rng)
        };
        if changed > 0 {
            writes.push(Patch {
                address: start,
                replacement,
                original_len: len as u64,
                changed,
            });
        }
    }

    Ok(Patches { writes, preserved })
}

/// Returns the addresses of text blocks a conversion reads to produce numbers.
///
/// A block whose header will not parse is simply not protected; the walk that
/// found it already recorded the damage, and this is not the place to report
/// it a second time.
fn decode_critical_text<S: ByteSource>(source: &S, map: &BlockMap) -> HashSet<u64> {
    let mut protected = HashSet::new();

    for block in &map.blocks {
        if block.block_type != "##CC" {
            continue;
        }
        let Ok(cc) = parse_cc_block(source, block.address) else {
            continue;
        };

        match cc.conversion_type {
            // cc_ref[0] is the formula text.
            ConversionType::Algebraic => protected.extend(cc.references.first().copied()),
            // Every reference is a lookup key.
            ConversionType::TabTextToValue => protected.extend(cc.references.iter().copied()),
            // References alternate key, replacement, ending in a default; the
            // keys are the even ones.
            ConversionType::TabTextToText => {
                protected.extend(cc.references.iter().step_by(2).copied())
            }
            _ => {}
        }
    }

    protected.remove(&0);
    protected
}

/// Replaces a text block's string with random letters of the same byte length.
///
/// Returns the block's new data section and how many bytes of it changed.
/// Everything from the null terminator on is copied through untouched.
fn scramble_text(data: &[u8], rng: &mut Rng) -> (Vec<u8>, u64) {
    let end = data.iter().position(|&b| b == 0).unwrap_or(data.len());
    let mut out = data.to_vec();
    for byte in &mut out[..end] {
        *byte = rng.letter();
    }
    (out, end as u64)
}

/// Replaces the text content of an XML metadata block, leaving its markup.
///
/// An MD block carries its comment as XML. Randomising the whole payload the
/// way [`scramble_text`] does would leave a block that is no longer XML, so
/// only the runs between `>` and `<` are replaced — the element and attribute
/// names stay, the content they hold does not. Byte lengths are unchanged
/// either way, so this costs nothing over the cruder option.
fn scramble_markup(data: &[u8], rng: &mut Rng) -> (Vec<u8>, u64) {
    let end = data.iter().position(|&b| b == 0).unwrap_or(data.len());
    let mut out = data.to_vec();
    let mut in_tag = false;
    let mut changed = 0;

    for byte in &mut out[..end] {
        match *byte {
            b'<' => in_tag = true,
            b'>' => in_tag = false,
            // Whitespace between elements is layout, not content; keeping it
            // stops a pretty-printed document collapsing into one long word.
            _ if in_tag || byte.is_ascii_whitespace() => {}
            _ => {
                *byte = rng.letter();
                changed += 1;
            }
        }
    }

    (out, changed)
}

/// SplitMix64, seeded by the caller.
///
/// A generator is wanted here for unpredictability of the *replacement*, not
/// for statistical quality, and this one is a few lines rather than a
/// dependency. It is not a cryptographic generator and the scrambling does not
/// need it to be: the replacement text is drawn independently of the text it
/// replaces, so there is nothing in the output to work backwards from.
struct Rng(u64);

impl Rng {
    fn new(seed: u64) -> Self {
        Rng(seed)
    }

    fn next_u64(&mut self) -> u64 {
        self.0 = self.0.wrapping_add(0x9E37_79B9_7F4A_7C15);
        let mut z = self.0;
        z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
        z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
        z ^ (z >> 31)
    }

    /// An uppercase ASCII letter, so the replacement is one byte per byte and
    /// always valid UTF-8 whatever the original encoded.
    fn letter(&mut self) -> u8 {
        b'A' + (self.next_u64() % 26) as u8
    }
}

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

    #[test]
    fn text_keeps_its_length_terminator_and_padding() {
        let data = b"Speed\0\0\0";
        let (out, changed) = scramble_text(data, &mut Rng::new(1));

        assert_eq!(
            out.len(),
            data.len(),
            "the data section may not change size"
        );
        assert_eq!(changed, 5);
        assert_eq!(&out[5..], b"\0\0\0", "terminator and padding are untouched");
        assert_ne!(&out[..5], b"Speed");
        assert!(out[..5].iter().all(|b| b.is_ascii_uppercase()));
    }

    #[test]
    fn a_multibyte_name_stays_the_same_number_of_bytes() {
        // "Temperatur°C" is 12 characters but 13 bytes: the degree sign
        // takes two. Replacement counts bytes, not characters.
        let data = "Temperatur°C\0".as_bytes();
        assert_eq!(data.len(), 14, "13 text bytes and a terminator");
        let (out, changed) = scramble_text(data, &mut Rng::new(2));

        assert_eq!(out.len(), data.len());
        assert_eq!(changed, 13, "the degree sign counts as its two bytes");
        assert!(
            std::str::from_utf8(&out[..13]).is_ok(),
            "replacing bytes with ASCII letters must leave valid UTF-8"
        );
    }

    #[test]
    fn empty_text_has_nothing_to_replace() {
        let (out, changed) = scramble_text(b"\0\0\0\0", &mut Rng::new(3));
        assert_eq!(changed, 0);
        assert_eq!(&out, b"\0\0\0\0");
    }

    #[test]
    fn markup_survives_the_scrambling_of_what_it_holds() {
        let data = b"<TXcomment><TX>Engine speed</TX></TXcomment>\0";
        let (out, changed) = scramble_markup(data, &mut Rng::new(4));
        let text = std::str::from_utf8(&out[..out.len() - 1]).unwrap();

        assert_eq!(out.len(), data.len());
        assert_eq!(changed, 11, "'Engine speed' less its space");
        assert!(text.starts_with("<TXcomment><TX>"), "tags are kept: {text}");
        assert!(text.ends_with("</TX></TXcomment>"), "tags are kept: {text}");
        assert!(!text.contains("Engine"), "the content is gone: {text}");
        assert!(text.contains(' '), "word spacing is kept: {text}");
    }

    #[test]
    fn the_same_seed_gives_the_same_scrambling() {
        let data = b"Coolant temperature\0";
        let (a, _) = scramble_text(data, &mut Rng::new(7));
        let (b, _) = scramble_text(data, &mut Rng::new(7));
        let (c, _) = scramble_text(data, &mut Rng::new(8));

        assert_eq!(a, b, "one seed, one result");
        assert_ne!(a, c, "a different seed is a different result");
    }
}