stamparm/maltrail · error

hex is ascii

Error message

hex is ascii

What it means

Test assertion in macs_match_the_python_sender: mts_sign hex-encodes the 128-bit truncated MAC, so bytes 5..37 of the output frame must be valid ASCII hex; from_utf8(...).expect("hex is ascii") fails if the encoding emitted non-ASCII bytes. It guards the wire format shared with the Python sender (core/log.py).

Solutions

  1. Keep mts_sign emitting 'MTS1 ' + 32 hex chars + ' ' + payload; fix the encoder if the prefix/format changed
  2. Update the test offsets if the frame format legitimately changed, and mirror the change in core/log.py
  3. Verify with the golden test vectors (including the unicode-secret case) that MACs still match the Python implementation

Example fix

// before
let mac = std::str::from_utf8(&out[5..37]).expect("hex is ascii");
// after (offset updated to a new frame layout)
let mac = std::str::from_utf8(&out[PREFIX_LEN..PREFIX_LEN + 32]).expect("hex is ascii");
Defensive patterns

Strategy: validation

Validate before calling

let frame = mts_sign(secret, payload);
assert!(frame.len() >= 38 && frame[5..37].iter().all(|b| b.is_ascii_hexdigit()), "MAC field must be 32 ASCII hex chars");

Type guard

fn is_hex_ascii(b: &[u8]) -> bool { b.iter().all(|c| c.is_ascii_hexdigit()) }

Try / catch

let mac = match std::str::from_utf8(&out[5..37]) {
    Ok(s) if s.bytes().all(|b| b.is_ascii_hexdigit()) => s,
    _ => { eprintln!("MTS1 frame MAC field malformed"); return; }
};

Prevention

When it happens

Trigger: A change to mts_sign's output framing (offset shift, binary tag instead of hex, different prefix length) makes out[5..37] non-UTF-8, or the slice indices no longer align with the 32-char hex MAC.

Common situations: Refactoring the MTS1 frame layout (prefix length, MAC length, separators) without updating the test's hardcoded offsets.

Related errors


AI-assisted analysis of stamparm/maltrail@77cfb06d76 (2026-09-13). Data as JSON: /api/errors/522bff9ceefb4e0d. Report an issue: GitHub.

Appendix: source

Thrown at sensor/src/output.rs:90

                "s3cr3t",
                b"1767261603 \"2026-01-01 10:00:03.123456\" box 10.0.0.8 6666 5.5.5.5 80 TCP IP 5.5.5.5 \"malware (test)\" (static)\n",
                "157a86bdbdf4940dccfd73668f1e74e3",
            ),
            ("k", b"", "8bb990c40a7d61cb97597a942125025b"),
            (
                "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
                b"short",
                "c67f8c6d0fec3da7b0fd48be37f06a9d",
            ),
            // a key longer than SHA-256's block is hashed first by HMAC; a payload that is not
            // ASCII must be signed as the bytes it is, not as text
            ("čž secret", "unicode payload čž\n".as_bytes(), "2b576daf9cbbebe124c8320c8a19fedb"),
        ];

        for (secret, payload, expected_mac) in cases {
            let out = mts_sign(secret, payload);
            assert!(out.starts_with(b"MTS1 "), "frame prefix missing");
            let mac = std::str::from_utf8(&out[5..37]).expect("hex is ascii");
            assert_eq!(mac, *expected_mac, "MAC disagrees with core/log.py for secret {secret:?}");
            assert_eq!(&out[37..38], b" ", "one space between MAC and payload");
            assert_eq!(&out[38..], *payload, "payload must be carried byte-for-byte");
        }
    }
}

pub struct OutputConfig {
    pub sensor_name: String,
    pub log_dir: PathBuf,
    pub trails_file: PathBuf,
    pub disable_local_log_storage: bool,
    /// `LOCAL_LOG_FORMAT json`: write the event log as one JSON object per line.
    pub local_log_json: bool,
    pub console: bool,
    pub log_server: Option<String>,
    /// `LOG_SERVER_SECRET`: shared secret authenticating every LOG_SERVER datagram. `None` sends
    /// them unsigned, which is what every deployment did before this existed.

View on GitHub (pinned to 77cfb06d76)