캡스톤 1 도메인 타입과 에러 문서의 문제 1 풀이 코드다.
//! 캡스톤 1단계: 단축 URL 서비스의 도메인 타입(Url, Code), 도메인 에러, 코드 생성기.
//! 의존성: thiserror = "2", proptest = "1" (proptest는 테스트에서만 쓴다)
//! 실행: python3 "_시스템/Rust 계획/rs_check.py" "Rust/4.연습문제/24_capstone-1_p1.rs"
use std::fmt;
use std::hash::{BuildHasher, RandomState};
use std::str::FromStr;
// ===== 1. 도메인 상수 =====
/// 짧은 코드의 글자 수. 62^7 = 3,521,614,606,208가지.
pub const CODE_LEN: usize = 7;
/// 받아 주는 URL의 최대 길이(바이트).
pub const MAX_URL_LEN: usize = 2048;
const ALPHABET: &[u8; 62] = b"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
// ===== 2. 도메인 에러 =====
/// 입력이 도메인 규칙을 어겼을 때의 에러. 메시지는 API 응답에 그대로 실어도 안전하다.
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum DomainError {
#[error("url is empty")]
EmptyUrl,
#[error("url is {len} bytes, longer than the {max}-byte limit")]
UrlTooLong { len: usize, max: usize },
#[error("url contains a character outside visible ASCII at byte {index}")]
InvalidCharacter { index: usize },
#[error("url must start with http:// or https://")]
UnsupportedScheme,
#[error("url has no host")]
MissingHost,
#[error("url must not contain user info (user@host)")]
UserInfo,
#[error("code must be exactly {} characters from [0-9A-Za-z]", CODE_LEN)]
InvalidCode,
}
// ===== 3. Url 뉴타입: 만들어졌으면 검증을 통과한 것 =====
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct Url(String);
fn strip_prefix_ignore_case<'a>(s: &'a str, prefix: &str) -> Option<&'a str> {
let head = s.get(..prefix.len())?;
head.eq_ignore_ascii_case(prefix)
.then(|| &s[prefix.len()..])
}
impl Url {
/// 앞뒤 공백을 걷어 낸 뒤 검사한다. 검사 순서: 비었나 → 길이 → 글자 → 스킴 → 호스트.
pub fn parse(input: &str) -> Result<Self, DomainError> {
let s = input.trim();
if s.is_empty() {
return Err(DomainError::EmptyUrl);
}
if s.len() > MAX_URL_LEN {
return Err(DomainError::UrlTooLong {
len: s.len(),
max: MAX_URL_LEN,
});
}
// Location 헤더에 넣을 수 있는 글자(보이는 ASCII 0x21..=0x7E)만 받는다.
if let Some(index) = s.bytes().position(|b| !(0x21..=0x7e).contains(&b)) {
return Err(DomainError::InvalidCharacter { index });
}
let rest = strip_prefix_ignore_case(s, "https://")
.or_else(|| strip_prefix_ignore_case(s, "http://"))
.ok_or(DomainError::UnsupportedScheme)?;
let authority = rest.split(['/', '?', '#']).next().unwrap_or("");
if authority.contains('@') {
return Err(DomainError::UserInfo);
}
if authority.is_empty() || authority.starts_with(':') {
return Err(DomainError::MissingHost);
}
Ok(Self(s.to_owned()))
}
pub fn as_str(&self) -> &str {
&self.0
}
}
impl FromStr for Url {
type Err = DomainError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Self::parse(s)
}
}
impl fmt::Display for Url {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
// ===== 4. Code 뉴타입: 정확히 7글자 base62 =====
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct Code(String);
impl Code {
pub fn parse(s: &str) -> Result<Self, DomainError> {
if s.len() == CODE_LEN && s.bytes().all(|b| b.is_ascii_alphanumeric()) {
Ok(Self(s.to_owned()))
} else {
Err(DomainError::InvalidCode)
}
}
pub fn as_str(&self) -> &str {
&self.0
}
}
impl FromStr for Code {
type Err = DomainError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Self::parse(s)
}
}
impl fmt::Display for Code {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
// ===== 5. 코드 생성기: SplitMix64 + 거절 샘플링 =====
/// 씨앗이 같으면 같은 코드 순서를 낸다(테스트 재현용). 암호학적으로 안전한 난수는 아니다.
pub struct CodeGen {
state: u64,
}
impl CodeGen {
pub fn from_seed(seed: u64) -> Self {
Self { state: seed }
}
/// 프로세스마다 다른 씨앗. std의 RandomState 키와 현재 시각을 섞는다.
pub fn from_entropy() -> Self {
Self::from_seed(RandomState::new().hash_one(std::time::SystemTime::now()))
}
fn next_u64(&mut self) -> u64 {
self.state = self.state.wrapping_add(0x9E37_79B9_7F4A_7C15);
let mut z = self.state;
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
z ^ (z >> 31)
}
pub fn next_code(&mut self) -> Code {
let mut out = String::with_capacity(CODE_LEN);
while out.len() < CODE_LEN {
for b in self.next_u64().to_le_bytes() {
// 248 = 62 × 4. 248 이상을 버려야 62글자가 모두 같은 확률로 나온다.
if b < 248 && out.len() < CODE_LEN {
out.push(char::from(ALPHABET[usize::from(b % 62)]));
}
}
}
Code(out)
}
}
// ===== 6. 스스로 확인 =====
fn self_check() {
let ok = Url::parse(" https://example.com/a?b=c ").expect("valid url");
assert_eq!(ok.as_str(), "https://example.com/a?b=c");
assert!(Url::parse("HTTP://Example.com").is_ok());
let cases = [
("", DomainError::EmptyUrl),
("javascript:alert(1)", DomainError::UnsupportedScheme),
("ftp://example.com", DomainError::UnsupportedScheme),
("https://", DomainError::MissingHost),
("https://:8080/x", DomainError::MissingHost),
("https://bank.com@evil.example/", DomainError::UserInfo),
(
"https://exa mple.com",
DomainError::InvalidCharacter { index: 11 },
),
(
"https://예시.com",
DomainError::InvalidCharacter { index: 8 },
),
];
for (input, want) in cases {
assert_eq!(Url::parse(input), Err(want), "input {input:?}");
}
let long = format!("https://a.com/{}", "x".repeat(MAX_URL_LEN));
assert!(matches!(
Url::parse(&long),
Err(DomainError::UrlTooLong { max: 2048, .. })
));
assert!(Code::parse("aZ09xyz").is_ok());
for bad in ["", "abc", "abcdefgh", "abc-def", "abcdéf"] {
assert_eq!(Code::parse(bad), Err(DomainError::InvalidCode), "{bad:?}");
}
let mut a = CodeGen::from_seed(42);
let mut b = CodeGen::from_seed(42);
let first = a.next_code();
assert_eq!(first, b.next_code());
println!("seed 42의 첫 코드: {first}");
let mut g = CodeGen::from_entropy();
let mut counts = [0u32; 62];
for _ in 0..10_000 {
let c = g.next_code();
assert_eq!(Code::parse(c.as_str()), Ok(c.clone()));
for byte in c.as_str().bytes() {
let i = ALPHABET
.iter()
.position(|&x| x == byte)
.expect("in alphabet");
counts[i] += 1;
}
}
// 70,000글자 / 62 ≈ 1,129. 모든 글자가 고르게 나오는지 넉넉한 범위로 본다.
let (min, max) = (counts.iter().min(), counts.iter().max());
println!("글자별 등장 횟수: 최소 {min:?}, 최대 {max:?}");
assert!(counts.iter().all(|&n| (900..1400).contains(&n)));
println!("에러 메시지 예: {}", DomainError::InvalidCode);
}
fn main() {
self_check();
println!("ALL CHECKS PASSED");
}
#[cfg(test)]
mod tests {
use super::*;
use proptest::prelude::*;
#[test]
fn self_check_passes() {
self_check();
}
proptest! {
#[test]
fn generated_codes_always_parse(seed in any::<u64>()) {
let mut g = CodeGen::from_seed(seed);
for _ in 0..20 {
let c = g.next_code();
prop_assert_eq!(Code::parse(c.as_str()), Ok(c.clone()));
}
}
// 아무 문자열(".{0,64}")만 쓰면 256개 중 0개가 검사를 통과해 아래 불변식은 한 번도 확인되지 않는다.
// 절반은 아무 문자열(panic 없음 확인), 절반은 스킴을 붙인 문자열(절반쯤 통과)로 만든다.
#[test]
fn url_parse_never_panics_and_keeps_invariants(
s in prop_oneof![".{0,64}", "(?i:https?)://[ -~]{0,40}(?s:.)?"]
) {
if let Ok(u) = Url::parse(&s) {
prop_assert!(u.as_str().len() <= MAX_URL_LEN);
prop_assert!(u.as_str().bytes().all(|b| (0x21..=0x7e).contains(&b)));
prop_assert!(u.as_str().to_ascii_lowercase().starts_with("http"));
}
}
#[test]
fn plain_http_urls_are_accepted(host in "[a-z]{1,20}", path in "[A-Za-z0-9/_-]{0,40}") {
let input = format!("https://{host}.com/{path}");
prop_assert_eq!(Url::parse(&input).map(|u| u.to_string()), Ok(input));
}
}
}