use base64::Engine; use url::Url; #[derive(Clone, Debug, PartialEq, Eq)] pub struct TalParsed { pub raw: Vec, /// Lines split by '\n' and normalized by stripping a trailing '\r' per line. pub lines: Vec, } #[derive(Clone, Debug, PartialEq, Eq)] pub struct Tal { pub raw: Vec, pub comments: Vec, pub ta_uris: Vec, pub subject_public_key_info_der: Vec, } #[derive(Debug, thiserror::Error)] pub enum TalParseError { #[error("TAL must be valid UTF-8 (RFC 8630 §2.2)")] InvalidUtf8, } #[derive(Debug, thiserror::Error)] pub enum TalProfileError { #[error("TAL comments must appear only at the beginning (RFC 8630 §2.2)")] CommentAfterHeader, #[error("TAL must contain at least one TA URI line (RFC 8630 §2.2)")] MissingTaUris, #[error( "TAL must contain an empty line separator between URI list and SPKI base64 (RFC 8630 §2.2)" )] MissingSeparatorEmptyLine, #[error("TAL TA URI invalid: {0} (RFC 8630 §2.2)")] InvalidUri(String), #[error("TAL TA URI scheme must be rsync or https, got {0} (RFC 8630 §2.2)")] UnsupportedUriScheme(String), #[error( "TAL TA URI must reference a single object (must not end with '/'): {0} (RFC 8630 §2.3)" )] UriIsDirectory(String), #[error( "TAL must contain base64-encoded SubjectPublicKeyInfo after the separator (RFC 8630 §2.2)" )] MissingSpki, #[error("TAL SPKI base64 decode failed (RFC 8630 §2.2)")] SpkiBase64Decode, #[error("TAL SPKI DER is empty (RFC 8630 §2.2)")] SpkiDerEmpty, } #[derive(Debug, thiserror::Error)] pub enum TalDecodeError { #[error("{0}")] Parse(#[from] TalParseError), #[error("{0}")] Validate(#[from] TalProfileError), } impl Tal { /// Parse step of scheme A (`parse → validate → verify`). pub fn parse_bytes(input: &[u8]) -> Result { let raw = input.to_vec(); let text = std::str::from_utf8(input).map_err(|_| TalParseError::InvalidUtf8)?; let lines: Vec = text .split('\n') .map(|l| l.strip_suffix('\r').unwrap_or(l).to_string()) .collect(); Ok(TalParsed { raw, lines }) } /// Validate step of scheme A (`parse → validate → verify`). /// /// `Tal` is already profile-validated when constructed via `decode_bytes()` / /// `TalParsed::validate_profile()`. pub fn validate_profile(&self) -> Result<(), TalProfileError> { Ok(()) } pub fn decode_bytes(input: &[u8]) -> Result { Ok(Self::parse_bytes(input)?.validate_profile()?) } } impl TalParsed { pub fn validate_profile(self) -> Result { let mut idx = 0usize; // 1) Leading comments. let mut comments: Vec = Vec::new(); while idx < self.lines.len() && self.lines[idx].starts_with('#') { comments.push(self.lines[idx][1..].to_string()); idx += 1; } // 2) URI list (one or more non-empty lines). let mut ta_uris: Vec = Vec::new(); while idx < self.lines.len() { let line = self.lines[idx].trim(); if line.is_empty() { break; } if line.starts_with('#') { return Err(TalProfileError::CommentAfterHeader); } let url = match Url::parse(line) { Ok(u) => u, Err(_) => { if !ta_uris.is_empty() { return Err(TalProfileError::MissingSeparatorEmptyLine); } return Err(TalProfileError::InvalidUri(line.to_string())); } }; match url.scheme() { "rsync" | "https" => {} s => return Err(TalProfileError::UnsupportedUriScheme(s.to_string())), } if url.path().ends_with('/') { return Err(TalProfileError::UriIsDirectory(line.to_string())); } if url .path_segments() .and_then(|mut s| s.next_back()) .unwrap_or("") .is_empty() { return Err(TalProfileError::UriIsDirectory(line.to_string())); } ta_uris.push(url); idx += 1; } if ta_uris.is_empty() { return Err(TalProfileError::MissingTaUris); } // 3) Empty line separator (must exist). if idx >= self.lines.len() || !self.lines[idx].trim().is_empty() { return Err(TalProfileError::MissingSeparatorEmptyLine); } idx += 1; // 4) Base64(SPKI DER) remainder; allow line wrapping. let mut b64 = String::new(); while idx < self.lines.len() { let line = self.lines[idx].trim(); if !line.is_empty() { b64.push_str(line); } idx += 1; } if b64.is_empty() { return Err(TalProfileError::MissingSpki); } let spki_der = base64::engine::general_purpose::STANDARD .decode(b64.as_bytes()) .map_err(|_| TalProfileError::SpkiBase64Decode)?; if spki_der.is_empty() { return Err(TalProfileError::SpkiDerEmpty); } Ok(Tal { raw: self.raw, comments, ta_uris, subject_public_key_info_der: spki_der, }) } }