184 lines
5.5 KiB
Rust
184 lines
5.5 KiB
Rust
use base64::Engine;
|
|
use url::Url;
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
pub struct TalParsed {
|
|
pub raw: Vec<u8>,
|
|
/// Lines split by '\n' and normalized by stripping a trailing '\r' per line.
|
|
pub lines: Vec<String>,
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
pub struct Tal {
|
|
pub raw: Vec<u8>,
|
|
pub comments: Vec<String>,
|
|
pub ta_uris: Vec<Url>,
|
|
pub subject_public_key_info_der: Vec<u8>,
|
|
}
|
|
|
|
#[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<TalParsed, TalParseError> {
|
|
let raw = input.to_vec();
|
|
let text = std::str::from_utf8(input).map_err(|_| TalParseError::InvalidUtf8)?;
|
|
|
|
let lines: Vec<String> = 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<Self, TalDecodeError> {
|
|
Ok(Self::parse_bytes(input)?.validate_profile()?)
|
|
}
|
|
}
|
|
|
|
impl TalParsed {
|
|
pub fn validate_profile(self) -> Result<Tal, TalProfileError> {
|
|
let mut idx = 0usize;
|
|
|
|
// 1) Leading comments.
|
|
let mut comments: Vec<String> = 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<Url> = 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,
|
|
})
|
|
}
|
|
}
|