mount-fstab 0.1.1

Type-safe /etc/fstab parsing, editing, and validation library
Documentation
//! fstab escape sequence encoding and decoding.
//!
//! Implements the fstab(5)/getmntent(3) escape encoding scheme:
//! - `\NNN` (3-digit octal) for arbitrary byte values
//! - `\\` for literal backslash
//! - Space, tab, newline encoded as `\040`, `\011`, `\012`
//!
//! These functions match glibc's `decode_name()` and `write_string()`
//! behavior.

/// Decode fstab escape sequences in a field value.
///
/// Handles `\NNN` (3-digit octal) and `\\` (literal backslash).
/// Unknown escape sequences and trailing backslash are preserved as-is,
/// matching glibc `decode_name()` behavior.
///
/// Uses char-level iteration to correctly preserve multi-byte UTF-8.
/// Octal parsing uses direct arithmetic (no allocation, no `from_str_radix`).
///
/// # Examples
///
/// ```
/// # use mount_fstab::escape::decode_escapes;
/// assert_eq!(decode_escapes(r"hello\040world"), "hello world");
/// assert_eq!(decode_escapes(r"a\\b"), r"a\b");
/// ```
#[must_use]
pub fn decode_escapes(input: &str) -> String {
    let mut out = String::with_capacity(input.len());
    let chars: Vec<char> = input.chars().collect();
    let len = chars.len();
    let mut i = 0;

    while i < len {
        if chars[i] == '\\' {
            // 3-digit octal escape: \NNN where each digit is 0-7
            if i + 3 < len {
                let d1 = chars[i + 1];
                let d2 = chars[i + 2];
                let d3 = chars[i + 3];
                if ('0'..='7').contains(&d1)
                    && ('0'..='7').contains(&d2)
                    && ('0'..='7').contains(&d3)
                {
                    // Direct arithmetic — no allocation, no from_str_radix.
                    // Use u16 intermediates to avoid debug-mode overflow panics,
                    // then cast to u8, matching glibc truncation behavior.
                    let byte = ((d1 as u8 - b'0') as u16 * 64
                        + (d2 as u8 - b'0') as u16 * 8
                        + (d3 as u8 - b'0') as u16) as u8;
                    out.push(byte as char);
                    i += 4;
                    continue;
                }
            }

            // \\ → literal backslash
            if i + 1 < len && chars[i + 1] == '\\' {
                out.push('\\');
                i += 2;
                continue;
            }

            // Trailing backslash or unknown escape — preserve as-is
            if i + 1 < len {
                out.push('\\');
                out.push(chars[i + 1]);
                i += 2;
            } else {
                out.push('\\');
                i += 1;
            }
        } else {
            out.push(chars[i]);
            i += 1;
        }
    }

    out
}

/// Encode special characters in a field value for fstab output.
///
/// Encodes: space to `\040`, tab to `\011`, newline to `\012`,
/// backslash to `\\`. Matches glibc `write_string()` and the fstab(5)
/// specification. Non-special characters pass through unchanged,
/// including UTF-8 multi-byte sequences.
///
/// # Examples
///
/// ```
/// # use mount_fstab::escape::encode_escapes;
/// assert_eq!(encode_escapes("hello world"), r"hello\040world");
/// assert_eq!(encode_escapes("café"), "café");
/// ```
#[must_use]
pub fn encode_escapes(input: &str) -> String {
    let mut out = String::with_capacity(input.len());
    for ch in input.chars() {
        match ch {
            ' ' => out.push_str(r"\040"),
            '\t' => out.push_str(r"\011"),
            '\n' => out.push_str(r"\012"),
            '\\' => out.push_str(r"\\"),
            _ => out.push(ch),
        }
    }
    out
}

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

    #[test]
    fn decode_space() {
        assert_eq!(decode_escapes(r"\040"), " ");
    }

    #[test]
    fn decode_tab() {
        assert_eq!(decode_escapes(r"\011"), "\t");
    }

    #[test]
    fn decode_newline() {
        assert_eq!(decode_escapes(r"\012"), "\n");
    }

    #[test]
    fn decode_backslash_literal() {
        assert_eq!(decode_escapes(r"\\"), "\\");
    }

    #[test]
    fn decode_backslash_octal_134() {
        assert_eq!(decode_escapes(r"\134"), "\\");
    }

    #[test]
    fn decode_mixed() {
        assert_eq!(decode_escapes(r"foo\040bar\011baz"), "foo bar\tbaz");
    }

    #[test]
    fn decode_plain_text_passthrough() {
        assert_eq!(decode_escapes("hello world"), "hello world");
    }

    #[test]
    fn decode_empty() {
        assert_eq!(decode_escapes(""), "");
    }

    #[test]
    fn decode_trailing_backslash() {
        assert_eq!(decode_escapes(r"hello\"), r"hello\");
    }

    #[test]
    fn decode_unknown_escape_preserved() {
        assert_eq!(decode_escapes(r"\999"), r"\999");
        assert_eq!(decode_escapes(r"\x"), r"\x");
    }

    #[test]
    fn encode_space() {
        assert_eq!(encode_escapes("hello world"), r"hello\040world");
    }

    #[test]
    fn encode_tab() {
        assert_eq!(encode_escapes("a\tb"), r"a\011b");
    }

    #[test]
    fn encode_newline() {
        assert_eq!(encode_escapes("a\nb"), r"a\012b");
    }

    #[test]
    fn encode_backslash() {
        assert_eq!(encode_escapes(r"a\b"), r"a\\b");
    }

    #[test]
    fn encode_multiple_specials() {
        assert_eq!(encode_escapes("a b\tc\nd\\e"), r"a\040b\011c\012d\\e");
    }

    #[test]
    fn encode_plain_text_passthrough() {
        assert_eq!(encode_escapes("hello"), "hello");
    }

    #[test]
    fn roundtrip_decode_encode() {
        let original = "/mnt/My Drive with spaces";
        let encoded = encode_escapes(original);
        let decoded = decode_escapes(&encoded);
        assert_eq!(decoded, original);
    }

    #[test]
    fn roundtrip_encode_decode() {
        let encoded = r"UUID=3e6be9de\\8139\\11d1\\9106\\a43f08d823a6";
        let decoded = decode_escapes(encoded);
        let re_encoded = encode_escapes(&decoded);
        assert_eq!(re_encoded, encoded);
    }

    #[test]
    fn decode_escape_in_middle_of_field() {
        assert_eq!(decode_escapes(r"foo\040bar"), "foo bar");
    }

    #[test]
    fn decode_multiple_escapes_in_one_field() {
        assert_eq!(decode_escapes(r"a\040b\040c"), "a b c");
    }

    #[test]
    fn decode_all_five_escapes_in_order() {
        assert_eq!(decode_escapes(r"\040\011\012\\\134"), " \t\n\\\\");
    }

    #[test]
    fn decode_escaped_equal_sign() {
        assert_eq!(decode_escapes(r"key\075value"), "key=value");
    }

    #[test]
    fn decode_zero_byte() {
        assert_eq!(decode_escapes(r"\000"), "\u{0000}");
    }

    #[test]
    fn decode_all_octal_values() {
        for byte in 0u8..=255u8 {
            let octal = format!("\\{:03o}", byte);
            let expected = char::from(byte).to_string();
            let decoded = decode_escapes(&octal);
            assert_eq!(decoded, expected, "mismatch for octal {:03o}", byte);
        }
    }

    #[test]
    fn decode_single_backslash_with_space_after() {
        assert_eq!(decode_escapes(r"\ "), r"\ ");
    }

    #[test]
    fn encode_only_targets_reserved_chars() {
        let input = "abc123!@#$%^&*()_+-=[]{}|;:',.<>?/~`";
        assert_eq!(encode_escapes(input), input);
    }

    #[test]
    fn encode_then_decode_is_identity_for_any_string() {
        let cases = [
            "simple",
            "with space",
            "with\ttab",
            "with\nnewline",
            r"with\backslash",
            "\u{0000}null",
            "unicode: café 日本語",
        ];
        for original in cases {
            assert_eq!(
                decode_escapes(&encode_escapes(original)),
                original,
                "roundtrip failed for: {original:?}"
            );
        }
    }

    #[test]
    fn decode_preserves_utf8() {
        assert_eq!(decode_escapes("café"), "café");
        assert_eq!(decode_escapes("ñoño"), "ñoño");
    }

    #[test]
    fn encode_preserves_utf8() {
        assert_eq!(encode_escapes("café"), "café");
        assert_eq!(encode_escapes("日本語"), "日本語");
    }
}