#[derive(Debug, PartialEq, Eq)]
pub enum ValidationError {
DangerousCharacter {
ch: char,
},
InvalidGithubUsername {
input: String,
reason: &'static str,
},
InvalidTangledUsername {
input: String,
reason: &'static str,
},
InvalidRepoName {
input: String,
reason: &'static str,
},
}
impl std::fmt::Display for ValidationError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ValidationError::DangerousCharacter { ch } => write!(
f,
"Input contains a disallowed character: '{}'. \
Shell metacharacters, spaces, and control characters are not allowed.",
ch.escape_debug()
),
ValidationError::InvalidGithubUsername { input, reason } => {
write!(f, "'{input}' is not a valid GitHub username: {reason}.")
}
ValidationError::InvalidTangledUsername { input, reason } => {
write!(f, "'{input}' is not a valid Tangled username: {reason}.")
}
ValidationError::InvalidRepoName { input, reason } => {
write!(f, "'{input}' is not a valid repository name: {reason}.")
}
}
}
}
impl std::error::Error for ValidationError {}
const DANGEROUS_CHARS: &[char] = &[
' ', '\t', '\n', '\r', '$', '`', ';', '|', '&', '>', '<', '*', '?', '[', ']', '(', ')', '{', '}', '\\', '~',
];
pub fn sanitize(input: &str) -> Result<String, ValidationError> {
let trimmed = input.trim();
let dequoted: String = trimmed
.chars()
.filter(|c| *c != '\'' && *c != '"')
.collect();
for ch in dequoted.chars() {
if DANGEROUS_CHARS.contains(&ch) {
return Err(ValidationError::DangerousCharacter { ch });
}
}
Ok(dequoted.to_lowercase())
}
pub fn validate_github_username(input: &str) -> Result<String, ValidationError> {
let s = sanitize(input)?;
let err = |reason| {
Err(ValidationError::InvalidGithubUsername {
input: s.clone(),
reason,
})
};
if s.is_empty() {
return err("must not be empty");
}
if s.len() > 39 {
return err("must be 39 characters or fewer");
}
if s.starts_with('-') {
return err("must not start with a hyphen");
}
if s.ends_with('-') {
return err("must not end with a hyphen");
}
if s.contains("--") {
return err("must not contain consecutive hyphens");
}
if !s.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') {
return err("may only contain alphanumeric characters and hyphens");
}
Ok(s)
}
pub fn validate_tangled_username(input: &str) -> Result<String, ValidationError> {
let s = sanitize(input)?;
let err = |reason| {
Err(ValidationError::InvalidTangledUsername {
input: s.clone(),
reason,
})
};
if s.is_empty() {
return err("must not be empty");
}
if s.contains('_') {
return err("underscores are not allowed in ATProto handles");
}
let labels: Vec<&str> = s.split('.').collect();
if labels.len() < 2 {
return err("must contain at least one dot (e.g. 'user.bsky.social')");
}
let (tld, non_tld_labels) = labels.split_last().unwrap();
for label in non_tld_labels {
validate_atproto_non_tld_label(label).map_err(|reason| {
ValidationError::InvalidTangledUsername {
input: s.clone(),
reason,
}
})?;
}
validate_atproto_tld_label(tld).map_err(|reason| ValidationError::InvalidTangledUsername {
input: s.clone(),
reason,
})?;
Ok(s)
}
fn validate_atproto_non_tld_label(label: &str) -> Result<(), &'static str> {
if label.is_empty() {
return Err("each part of the handle must not be empty (check for double dots)");
}
if label.len() > 63 {
return Err("each part of the handle must be 63 characters or fewer");
}
if label.starts_with('-') {
return Err("each part of the handle must not start with a hyphen");
}
if label.ends_with('-') {
return Err("each part of the handle must not end with a hyphen");
}
if !label.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') {
return Err("each part of the handle may only contain alphanumeric characters and hyphens");
}
Ok(())
}
fn validate_atproto_tld_label(tld: &str) -> Result<(), &'static str> {
if tld.is_empty() {
return Err("must end with a valid TLD (e.g. '.fyi', '.social', '.sh')");
}
if tld.len() > 63 {
return Err("TLD must be 63 characters or fewer");
}
if !tld.chars().all(|c| c.is_ascii_alphabetic()) {
return Err("TLD must contain only letters (e.g. '.fyi', '.social')");
}
Ok(())
}
pub fn validate_repo_name(input: &str) -> Result<String, ValidationError> {
let s = sanitize(input)?;
let err = |reason| {
Err(ValidationError::InvalidRepoName {
input: s.clone(),
reason,
})
};
if s.is_empty() {
return err("must not be empty");
}
if s.len() > 100 {
return err("must be 100 characters or fewer");
}
if s.starts_with('-') {
return err("must not start with a hyphen");
}
if s.ends_with('-') {
return err("must not end with a hyphen");
}
if s.contains("--") {
return err("must not contain consecutive hyphens");
}
if s.contains('.') {
return err("periods are not allowed (use hyphens instead)");
}
if !s.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') {
return err("may only contain alphanumeric characters and hyphens");
}
Ok(s)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sanitize_lowercases() {
assert_eq!(sanitize("CyrusAE").unwrap(), "cyrusae");
}
#[test]
fn sanitize_strips_surrounding_whitespace() {
assert_eq!(sanitize(" hello ").unwrap(), "hello");
}
#[test]
fn sanitize_removes_double_quotes() {
assert_eq!(sanitize(r#""cyrusae""#).unwrap(), "cyrusae");
}
#[test]
fn sanitize_removes_single_quotes() {
assert_eq!(sanitize("'cyrusae'").unwrap(), "cyrusae");
}
#[test]
fn sanitize_removes_mixed_quotes() {
assert_eq!(sanitize(r#"'cy"rus'ae"#).unwrap(), "cyrusae");
}
#[test]
fn sanitize_rejects_space() {
let err = sanitize("hello world").unwrap_err();
assert!(matches!(
err,
ValidationError::DangerousCharacter { ch: ' ' }
));
}
#[test]
fn sanitize_rejects_backtick() {
let err = sanitize("hello`world").unwrap_err();
assert!(matches!(
err,
ValidationError::DangerousCharacter { ch: '`' }
));
}
#[test]
fn sanitize_rejects_dollar_sign() {
let err = sanitize("$VAR").unwrap_err();
assert!(matches!(
err,
ValidationError::DangerousCharacter { ch: '$' }
));
}
#[test]
fn sanitize_rejects_semicolon() {
let err = sanitize("foo;bar").unwrap_err();
assert!(matches!(
err,
ValidationError::DangerousCharacter { ch: ';' }
));
}
#[test]
fn sanitize_rejects_pipe() {
let err = sanitize("foo|bar").unwrap_err();
assert!(matches!(
err,
ValidationError::DangerousCharacter { ch: '|' }
));
}
#[test]
fn sanitize_rejects_ampersand() {
let err = sanitize("foo&bar").unwrap_err();
assert!(matches!(
err,
ValidationError::DangerousCharacter { ch: '&' }
));
}
#[test]
fn sanitize_rejects_gt() {
let err = sanitize("foo>bar").unwrap_err();
assert!(matches!(
err,
ValidationError::DangerousCharacter { ch: '>' }
));
}
#[test]
fn sanitize_rejects_lt() {
let err = sanitize("foo<bar").unwrap_err();
assert!(matches!(
err,
ValidationError::DangerousCharacter { ch: '<' }
));
}
#[test]
fn sanitize_rejects_newline() {
let err = sanitize("foo\nbar").unwrap_err();
assert!(matches!(
err,
ValidationError::DangerousCharacter { ch: '\n' }
));
}
#[test]
fn sanitize_rejects_carriage_return() {
let err = sanitize("foo\rbar").unwrap_err();
assert!(matches!(
err,
ValidationError::DangerousCharacter { ch: '\r' }
));
}
#[test]
fn sanitize_rejects_asterisk() {
let err = sanitize("foo*bar").unwrap_err();
assert!(matches!(
err,
ValidationError::DangerousCharacter { ch: '*' }
));
}
#[test]
fn sanitize_rejects_backslash() {
let err = sanitize("foo\\bar").unwrap_err();
assert!(matches!(
err,
ValidationError::DangerousCharacter { ch: '\\' }
));
}
#[test]
fn sanitize_rejects_tilde() {
let err = sanitize("~foo").unwrap_err();
assert!(matches!(
err,
ValidationError::DangerousCharacter { ch: '~' }
));
}
#[test]
fn dangerous_char_display_uses_escape_debug_for_newline() {
let err = ValidationError::DangerousCharacter { ch: '\n' };
let msg = err.to_string();
assert!(
msg.contains("\\n"),
"newline must be rendered as \\n in error message, got: {msg}"
);
assert!(
!msg.contains('\n'),
"raw newline must not appear in error message: {msg}"
);
}
#[test]
fn github_valid_simple() {
assert_eq!(validate_github_username("cyrusae").unwrap(), "cyrusae");
}
#[test]
fn github_valid_with_hyphen() {
assert_eq!(validate_github_username("cyrus-ae").unwrap(), "cyrus-ae");
}
#[test]
fn github_valid_at_exactly_39_chars() {
let name = "a".repeat(39);
assert_eq!(validate_github_username(&name).unwrap(), name);
}
#[test]
fn github_invalid_at_40_chars() {
let name = "a".repeat(40);
let err = validate_github_username(&name).unwrap_err();
assert!(matches!(err, ValidationError::InvalidGithubUsername { .. }));
}
#[test]
fn github_uppercase_is_lowercased_before_validation() {
assert_eq!(validate_github_username("CyrusAE").unwrap(), "cyrusae");
}
#[test]
fn github_quoted_input_is_dequoted() {
assert_eq!(validate_github_username(r#""cyrusae""#).unwrap(), "cyrusae");
}
#[test]
fn github_leading_hyphen_invalid() {
let err = validate_github_username("-cyrusae").unwrap_err();
assert!(matches!(err, ValidationError::InvalidGithubUsername { .. }));
}
#[test]
fn github_trailing_hyphen_invalid() {
let err = validate_github_username("cyrusae-").unwrap_err();
assert!(matches!(err, ValidationError::InvalidGithubUsername { .. }));
}
#[test]
fn github_consecutive_hyphens_invalid() {
let err = validate_github_username("cy--rusae").unwrap_err();
assert!(matches!(err, ValidationError::InvalidGithubUsername { .. }));
}
#[test]
fn github_underscore_invalid() {
let err = validate_github_username("cyrus_ae").unwrap_err();
assert!(matches!(err, ValidationError::InvalidGithubUsername { .. }));
}
#[test]
fn github_empty_invalid() {
let err = validate_github_username("").unwrap_err();
assert!(matches!(err, ValidationError::InvalidGithubUsername { .. }));
}
#[test]
fn github_single_char_valid() {
assert_eq!(validate_github_username("a").unwrap(), "a");
}
#[test]
fn tangled_valid_simple() {
assert_eq!(validate_tangled_username("atdot.fyi").unwrap(), "atdot.fyi");
}
#[test]
fn tangled_valid_subdomain() {
assert_eq!(
validate_tangled_username("user.tngl.sh").unwrap(),
"user.tngl.sh"
);
}
#[test]
fn tangled_valid_minimal() {
assert_eq!(validate_tangled_username("a.b").unwrap(), "a.b");
}
#[test]
fn tangled_uppercase_lowercased_before_validation() {
assert_eq!(validate_tangled_username("AtDot.FYI").unwrap(), "atdot.fyi");
}
#[test]
fn tangled_no_dot_invalid() {
let err = validate_tangled_username("nodot").unwrap_err();
assert!(matches!(
err,
ValidationError::InvalidTangledUsername { .. }
));
}
#[test]
fn tangled_underscore_invalid() {
let err = validate_tangled_username("has_under.score").unwrap_err();
assert!(matches!(
err,
ValidationError::InvalidTangledUsername { .. }
));
}
#[test]
fn tangled_tld_with_digit_invalid() {
let err = validate_tangled_username("user.fyi2").unwrap_err();
assert!(matches!(
err,
ValidationError::InvalidTangledUsername { .. }
));
}
#[test]
fn tangled_label_leading_hyphen_invalid() {
let err = validate_tangled_username("-user.fyi").unwrap_err();
assert!(matches!(
err,
ValidationError::InvalidTangledUsername { .. }
));
}
#[test]
fn tangled_label_trailing_hyphen_invalid() {
let err = validate_tangled_username("user-.fyi").unwrap_err();
assert!(matches!(
err,
ValidationError::InvalidTangledUsername { .. }
));
}
#[test]
fn tangled_empty_label_from_double_dot_invalid() {
let err = validate_tangled_username("user..fyi").unwrap_err();
assert!(matches!(
err,
ValidationError::InvalidTangledUsername { .. }
));
}
#[test]
fn tangled_empty_tld_invalid() {
let err = validate_tangled_username("user.fyi.").unwrap_err();
assert!(matches!(
err,
ValidationError::InvalidTangledUsername { .. }
));
}
#[test]
fn tangled_empty_invalid() {
let err = validate_tangled_username("").unwrap_err();
assert!(matches!(
err,
ValidationError::InvalidTangledUsername { .. }
));
}
#[test]
fn tangled_hyphen_in_middle_of_label_valid() {
assert_eq!(
validate_tangled_username("my-handle.bsky.social").unwrap(),
"my-handle.bsky.social"
);
}
#[test]
fn tangled_numeric_subdomain_valid() {
assert_eq!(
validate_tangled_username("user123.fyi").unwrap(),
"user123.fyi"
);
}
#[test]
fn repo_valid_simple() {
assert_eq!(validate_repo_name("entangle").unwrap(), "entangle");
}
#[test]
fn repo_valid_with_hyphen() {
assert_eq!(validate_repo_name("my-project").unwrap(), "my-project");
}
#[test]
fn repo_valid_single_char() {
assert_eq!(validate_repo_name("a").unwrap(), "a");
}
#[test]
fn repo_valid_at_exactly_100_chars() {
let name = "a".repeat(100);
assert_eq!(validate_repo_name(&name).unwrap(), name);
}
#[test]
fn repo_invalid_at_101_chars() {
let name = "a".repeat(101);
let err = validate_repo_name(&name).unwrap_err();
assert!(matches!(err, ValidationError::InvalidRepoName { .. }));
}
#[test]
fn repo_uppercase_lowercased_before_validation() {
assert_eq!(validate_repo_name("ENTANGLE").unwrap(), "entangle");
}
#[test]
fn repo_leading_hyphen_invalid() {
let err = validate_repo_name("-entangle").unwrap_err();
assert!(matches!(err, ValidationError::InvalidRepoName { .. }));
}
#[test]
fn repo_trailing_hyphen_invalid() {
let err = validate_repo_name("entangle-").unwrap_err();
assert!(matches!(err, ValidationError::InvalidRepoName { .. }));
}
#[test]
fn repo_consecutive_hyphens_invalid() {
let err = validate_repo_name("en--tangle").unwrap_err();
assert!(matches!(err, ValidationError::InvalidRepoName { .. }));
}
#[test]
fn repo_period_invalid() {
let err = validate_repo_name("my.project").unwrap_err();
assert!(matches!(err, ValidationError::InvalidRepoName { .. }));
}
#[test]
fn repo_underscore_invalid() {
let err = validate_repo_name("my_project").unwrap_err();
assert!(matches!(err, ValidationError::InvalidRepoName { .. }));
}
#[test]
fn repo_empty_invalid() {
let err = validate_repo_name("").unwrap_err();
assert!(matches!(err, ValidationError::InvalidRepoName { .. }));
}
#[test]
fn github_sanitize_before_validate_mixed_case_passes() {
let result = validate_github_username("CyrusAE");
assert_eq!(result.unwrap(), "cyrusae");
}
#[test]
fn repo_sanitize_before_validate_mixed_case_passes() {
let result = validate_repo_name("MyRepo");
assert_eq!(result.unwrap(), "myrepo");
}
#[test]
fn tangled_sanitize_before_validate_mixed_case_passes() {
let result = validate_tangled_username("MyHandle.FYI");
assert_eq!(result.unwrap(), "myhandle.fyi");
}
#[test]
fn dangerous_char_propagates_through_github_validator() {
let err = validate_github_username("cyrus$ae").unwrap_err();
assert!(matches!(
err,
ValidationError::DangerousCharacter { ch: '$' }
));
}
#[test]
fn dangerous_char_propagates_through_repo_validator() {
let err = validate_repo_name("my|repo").unwrap_err();
assert!(matches!(
err,
ValidationError::DangerousCharacter { ch: '|' }
));
}
#[test]
fn dangerous_char_propagates_through_tangled_validator() {
let err = validate_tangled_username("user;name.fyi").unwrap_err();
assert!(matches!(
err,
ValidationError::DangerousCharacter { ch: ';' }
));
}
}