96 lines
3 KiB
Rust
96 lines
3 KiB
Rust
|
|
//! Post rules 1, 3 and 4 from PLAN.md, enforced at receipt by the daemon and
|
||
|
|
//! usable by any author-side tool.
|
||
|
|
|
||
|
|
pub const MAX_CODEPOINTS: usize = 160;
|
||
|
|
pub const MAX_BYTES: usize = 640;
|
||
|
|
|
||
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||
|
|
pub enum RuleError {
|
||
|
|
TooManyCodepoints,
|
||
|
|
TooManyBytes,
|
||
|
|
ForbiddenChar,
|
||
|
|
BadTimestamp,
|
||
|
|
DuplicateTimestamp,
|
||
|
|
}
|
||
|
|
|
||
|
|
impl std::fmt::Display for RuleError {
|
||
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||
|
|
let msg = match self {
|
||
|
|
RuleError::TooManyCodepoints => "more than 160 Unicode code points",
|
||
|
|
RuleError::TooManyBytes => "more than 640 bytes",
|
||
|
|
RuleError::ForbiddenChar => "contains TAB, LF, CR or U+2028",
|
||
|
|
RuleError::BadTimestamp => "not an RFC 3339 UTC timestamp at second precision",
|
||
|
|
RuleError::DuplicateTimestamp => "timestamp already exists in this feed",
|
||
|
|
};
|
||
|
|
f.write_str(msg)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
impl std::error::Error for RuleError {}
|
||
|
|
|
||
|
|
/// Rules 3 and 4: at most 160 code points and 640 bytes, and no TAB, LF, CR
|
||
|
|
/// or U+2028 — any of those would break the one-record-per-line invariant.
|
||
|
|
/// The byte cap is mathematically implied by the codepoint cap over UTF-8 but
|
||
|
|
/// is kept as an explicit guard.
|
||
|
|
pub fn validate_text(text: &str) -> Result<(), RuleError> {
|
||
|
|
if text.chars().count() > MAX_CODEPOINTS {
|
||
|
|
return Err(RuleError::TooManyCodepoints);
|
||
|
|
}
|
||
|
|
if text.len() > MAX_BYTES {
|
||
|
|
return Err(RuleError::TooManyBytes);
|
||
|
|
}
|
||
|
|
if text.contains(['\t', '\n', '\r', '\u{2028}']) {
|
||
|
|
return Err(RuleError::ForbiddenChar);
|
||
|
|
}
|
||
|
|
Ok(())
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Rule 1: RFC 3339 at second precision, rendered in UTC with `Z`.
|
||
|
|
pub fn validate_timestamp(timestamp: &str) -> Result<(), RuleError> {
|
||
|
|
if crate::time::parse(timestamp).is_none() {
|
||
|
|
return Err(RuleError::BadTimestamp);
|
||
|
|
}
|
||
|
|
Ok(())
|
||
|
|
}
|
||
|
|
|
||
|
|
#[cfg(test)]
|
||
|
|
mod tests {
|
||
|
|
use super::*;
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn accepts_a_short_post() {
|
||
|
|
assert_eq!(validate_text("hello smolverse"), Ok(()));
|
||
|
|
assert_eq!(validate_timestamp("2026-10-01T12:47:00Z"), Ok(()));
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn rejects_over_length_text() {
|
||
|
|
let chars: String = std::iter::repeat('a').take(161).collect();
|
||
|
|
assert_eq!(validate_text(&chars), Err(RuleError::TooManyCodepoints));
|
||
|
|
|
||
|
|
// 160 four-byte code points sit exactly on both caps.
|
||
|
|
let four_byte: String = "🙂".repeat(160);
|
||
|
|
assert_eq!(validate_text(&four_byte), Ok(()));
|
||
|
|
let over: String = "🙂".repeat(161);
|
||
|
|
assert_eq!(
|
||
|
|
validate_text(&over),
|
||
|
|
Err(RuleError::TooManyCodepoints),
|
||
|
|
"codepoints are checked before bytes"
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn rejects_record_breaking_characters() {
|
||
|
|
for bad in ["a\tb", "a\nb", "a\rb", "a\u{2028}b"] {
|
||
|
|
assert_eq!(validate_text(bad), Err(RuleError::ForbiddenChar));
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn rejects_non_z_timestamps() {
|
||
|
|
for bad in ["2026-10-01T12:47:00+03:00", "not a date", ""] {
|
||
|
|
assert_eq!(validate_timestamp(bad), Err(RuleError::BadTimestamp));
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|