vomit-m2dir 0.3.1

Library for the m2dir email storage format
Documentation
use std::{
    borrow::Cow,
    fs::{self, File},
    io,
    num::Wrapping,
    path::Path,
};

use tempfile::Builder;

pub fn write_atomic<F>(path: impl AsRef<Path>, write: F) -> std::io::Result<()>
where
    F: FnOnce(&mut std::fs::File) -> std::io::Result<()>,
{
    let parent = path.as_ref().parent().ok_or_else(|| {
        std::io::Error::new(
            std::io::ErrorKind::InvalidInput,
            "cannot create file without parent directory",
        )
    })?;

    fs::create_dir_all(parent)?;

    let mut tmpfile = Builder::new().prefix(".m2dir.tmp.").tempfile_in(parent)?;

    write(tmpfile.as_file_mut())?;
    tmpfile.as_file().sync_all()?;
    tmpfile.persist_noclobber(&path)?;
    Ok(())
}

pub fn copy_atomic(from: impl AsRef<Path>, to: impl AsRef<Path>) -> std::io::Result<()> {
    let mut from = File::open(&from)?;

    write_atomic(to, |f| {
        io::copy(&mut from, f)?;
        Ok(())
    })
}

pub fn fnv64(salt: &[u8], data: &[u8]) -> u64 {
    let prime = Wrapping(1099511628211u64);
    let offset: u64 = 14695981039346656037;
    let mut sum = Wrapping(offset);

    for b in salt {
        let byte = Wrapping(u64::from(*b));
        sum ^= byte;
        sum *= prime;
    }
    for b in data {
        let byte = Wrapping(u64::from(*b));
        sum ^= byte;
        sum *= prime;
    }
    sum.0
}

pub fn sanitize_filename(input: &str) -> String {
    input.replace(['/', '"', '\'', '|', '\\', '*', '&', '$'], "_")
}

/// Encode a folder name for on-disk storage.
///
/// M2dir imposes very few character restrictions on folder names, which means
/// that a folder can for example contain a slash (`/`). To be able to store
/// such a folder as a directory on disk, some encoding is required. This
/// function performs this encoding.
///
/// Most users should rely the folder handling methods provided by
/// [M2store](crate::M2store) instead, but it can occasionally be handy to know
/// for example what the future directory of a folder that has not been created
/// might be.
pub fn encode_folder_name<'a>(input: &'a str) -> Cow<'a, str> {
    if !input.contains(['%', '/']) {
        Cow::Borrowed(input)
    } else {
        let mut result = String::with_capacity(input.len());
        let mut last_match = 0;
        for (i, m) in input.match_indices(['%', '/']).peekable() {
            result.push_str(&input[last_match..i]);
            last_match = i + 1;
            match m {
                "/" => {
                    result.push_str("%2F");
                }
                "%" => {
                    result.push_str("%25");
                }
                // TODO more characters?
                _ => unreachable!("match broken"),
            }
        }
        result.push_str(&input[last_match..]);
        Cow::Owned(result)
    }
}

/// Decode a folder name from its on-disk encoding.
///
/// See [`encode_folder_name`].
pub fn decode_folder_name<'a>(input: &'a str) -> Cow<'a, str> {
    if !input.contains('%') {
        Cow::Borrowed(input)
    } else {
        let mut result: Vec<u8> = Vec::with_capacity(input.len());
        let mut last_match = 0;
        for (i, _) in input.match_indices('%').peekable() {
            result.extend_from_slice(&input.as_bytes()[last_match..i]);
            last_match = i;
            if input[i..].len() >= 3 {
                let h = char::from(input.as_bytes()[i + 1]).to_digit(16);
                let l = char::from(input.as_bytes()[i + 2]).to_digit(16);
                if let (Some(h), Some(l)) = (h, l) {
                    result.push(h as u8 * 0x10 + l as u8);
                    last_match += 3;
                }
            }
        }
        result.extend_from_slice(&input.as_bytes()[last_match..]);
        Cow::Owned(String::from_utf8(result).expect("decode_folder_name: invalid UTF-8"))
    }
}

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

    #[test]
    fn test_decode_folder_name() {
        assert_eq!(decode_folder_name("foo"), "foo");
        assert_eq!(decode_folder_name("foo%2Fbar"), "foo/bar");
        assert_eq!(decode_folder_name("foo%2F"), "foo/");
        assert_eq!(decode_folder_name("foo%2"), "foo%2");
        assert_eq!(decode_folder_name("foo%"), "foo%");
        assert_eq!(decode_folder_name("foo%%glurb"), "foo%%glurb");
        assert_eq!(decode_folder_name("foo%2F%25bar"), "foo/%bar");
        assert_eq!(decode_folder_name("foo%2Fbaz%25bar"), "foo/baz%bar");
        assert_eq!(decode_folder_name("foo%252Fbar"), "foo%2Fbar");
        assert_eq!(decode_folder_name("föö%252Fbär"), "föö%2Fbär");
        assert_eq!(decode_folder_name("%2F%2F%2F"), "///");
        assert_eq!(decode_folder_name("%E2%82%AC"), "");
    }

    #[test]
    fn test_encode_folder_name() {
        assert_eq!(encode_folder_name("foo"), "foo");
        assert_eq!(encode_folder_name("foo/bar"), "foo%2Fbar");
        assert_eq!(encode_folder_name("foo%bar"), "foo%25bar");
        assert_eq!(encode_folder_name("foo%/"), "foo%25%2F");
    }
}