use crate::core::{AsxError, ErrorCode, ErrorContext, Result};
use std::fmt;
use std::io::Write;
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub(crate) struct PayloadFilename(String);
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct PayloadFilenameError(String);
impl fmt::Display for PayloadFilenameError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
impl std::error::Error for PayloadFilenameError {}
impl PayloadFilename {
const MAX_LEN: usize = 255;
pub(crate) fn new(s: &str) -> std::result::Result<Self, PayloadFilenameError> {
Self::validate_bytes(s.as_bytes())?;
Ok(Self(s.to_owned()))
}
#[inline]
pub(crate) fn as_str(&self) -> &str {
&self.0
}
fn validate_bytes(bytes: &[u8]) -> std::result::Result<(), PayloadFilenameError> {
if bytes.is_empty() {
return Err(PayloadFilenameError(
"payload filename must not be empty \
(use None to omit the Content-Disposition filename parameter)"
.into(),
));
}
if bytes.len() > Self::MAX_LEN {
return Err(PayloadFilenameError(format!(
"payload filename exceeds maximum length ({} bytes, limit is {})",
bytes.len(),
Self::MAX_LEN,
)));
}
if let Some(bad) = bytes
.iter()
.copied()
.find(|&b| !(0x20..=0x7E).contains(&b) || b == b'"' || b == b'\\')
{
return Err(PayloadFilenameError(format!(
"payload filename contains character 0x{bad:02X} not allowed in a \
Content-Disposition header value (must be printable ASCII, \
no double-quote or backslash)"
)));
}
Ok(())
}
}
impl fmt::Display for PayloadFilename {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
impl AsRef<str> for PayloadFilename {
fn as_ref(&self) -> &str {
&self.0
}
}
impl std::str::FromStr for PayloadFilename {
type Err = PayloadFilenameError;
fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
Self::new(s)
}
}
impl TryFrom<String> for PayloadFilename {
type Error = PayloadFilenameError;
fn try_from(s: String) -> std::result::Result<Self, Self::Error> {
Self::validate_bytes(s.as_bytes())?;
Ok(Self(s))
}
}
impl TryFrom<&str> for PayloadFilename {
type Error = PayloadFilenameError;
fn try_from(s: &str) -> std::result::Result<Self, Self::Error> {
Self::new(s)
}
}
pub(crate) const MIME_BOUNDARY_PREFIX: &str = "----boundary-asx-";
#[derive(Debug, Clone)]
pub(crate) struct MimeAttachment {
pub content_id: String,
pub content_type: String,
pub transfer_encoding: String,
pub body: Vec<u8>,
pub disposition: Option<String>,
}
impl MimeAttachment {
pub(crate) fn new(
content_id: impl Into<String>,
content_type: impl Into<String>,
body: impl Into<Vec<u8>>,
transfer_encoding: impl Into<String>,
) -> Self {
Self {
content_id: content_id.into(),
content_type: content_type.into(),
transfer_encoding: transfer_encoding.into(),
body: body.into(),
disposition: None,
}
}
pub(crate) fn with_disposition(mut self, disposition: impl Into<String>) -> Self {
self.disposition = Some(disposition.into());
self
}
}
pub(crate) fn validate_content_id(value: &str, field: &str) -> Result<()> {
let reject = |reason: String| {
Err(AsxError::new(
ErrorCode::InvalidInput,
format!("{field} {reason}"),
ErrorContext::new("as4_send_validate"),
))
};
if value.is_empty() || value.len() > 255 {
return reject(format!("must be 1-255 bytes, got {} bytes", value.len()));
}
if let Some(bad) = value
.bytes()
.find(|&b| !(0x21..=0x7E).contains(&b) || matches!(b, b'"' | b'<' | b'>' | b'\\'))
{
return reject(format!(
"contains byte 0x{bad:02X}; a Content-ID is printable ASCII without \
space, '\"', '<', '>' or '\\'"
));
}
Ok(())
}
pub(crate) fn validate_media_type(value: &str, field: &str) -> Result<()> {
fn is_tchar(b: u8) -> bool {
b.is_ascii_alphanumeric() || b"!#$%&'*+-.^_`|~".contains(&b)
}
fn token(bytes: &[u8], mut i: usize) -> Option<usize> {
let start = i;
while i < bytes.len() && is_tchar(bytes[i]) {
i += 1;
}
(i > start).then_some(i)
}
fn quoted_string(bytes: &[u8], mut i: usize) -> Option<usize> {
if bytes.get(i) != Some(&b'"') {
return None;
}
i += 1;
while i < bytes.len() {
match bytes[i] {
b'"' => return Some(i + 1),
b'\\' => i += 2,
_ => i += 1,
}
}
None
}
fn skip_spaces(bytes: &[u8], mut i: usize) -> usize {
while bytes.get(i) == Some(&b' ') {
i += 1;
}
i
}
fn parse(bytes: &[u8]) -> Option<()> {
let mut i = token(bytes, 0)?;
if bytes.get(i) != Some(&b'/') {
return None;
}
i = token(bytes, i + 1)?;
loop {
let j = skip_spaces(bytes, i);
if j == bytes.len() {
return (j == i).then_some(());
}
if bytes[j] != b';' {
return None;
}
i = token(bytes, skip_spaces(bytes, j + 1))?;
if bytes.get(i) != Some(&b'=') {
return None;
}
i = token(bytes, i + 1).or_else(|| quoted_string(bytes, i + 1))?;
}
}
let bytes = value.as_bytes();
if let Some(bad) = bytes.iter().copied().find(|&b| !(0x20..0x7F).contains(&b)) {
return Err(AsxError::new(
ErrorCode::InvalidInput,
format!("{field} contains byte 0x{bad:02X}; a media type is printable ASCII"),
ErrorContext::new("as4_send_validate"),
));
}
parse(bytes).ok_or_else(|| {
AsxError::new(
ErrorCode::InvalidInput,
format!(
"{field} {value:?} is not a media type (type/subtype with optional \
; name=value parameters)"
),
ErrorContext::new("as4_send_validate"),
)
})
}
#[derive(Debug)]
pub(crate) struct MimePackageBuilder {
boundary: String,
root_soap_content_type: String,
soap_attachment: Option<MimeAttachment>,
attachments: Vec<MimeAttachment>,
}
impl MimePackageBuilder {
fn write_crlf_line(body: &mut Vec<u8>, line: &str, stage: &'static str) -> Result<()> {
if let Some(bad) = line.bytes().find(|&b| b < 0x20 || b == 0x7F) {
return Err(AsxError::new(
ErrorCode::InvalidInput,
format!("MIME header line contains control character 0x{bad:02X}"),
ErrorContext::new(stage),
));
}
body.write_all(line.as_bytes()).map_err(|_| {
AsxError::new(
ErrorCode::ReliabilityFailure,
"Failed to write MIME line",
ErrorContext::new(stage),
)
})?;
body.write_all(b"\r\n").map_err(|_| {
AsxError::new(
ErrorCode::ReliabilityFailure,
"Failed to write MIME CRLF",
ErrorContext::new(stage),
)
})
}
pub(crate) fn new() -> Self {
use std::sync::atomic::{AtomicU64, Ordering};
static COUNTER: AtomicU64 = AtomicU64::new(0);
let mut random = [0u8; 8];
let _ = getrandom::fill(&mut random);
let counter = COUNTER.fetch_add(1, Ordering::Relaxed);
let combined = u64::from_le_bytes(random).wrapping_add(counter);
let boundary = format!("{}{:016x}", MIME_BOUNDARY_PREFIX, combined);
Self {
boundary,
root_soap_content_type: "application/soap+xml".to_string(),
soap_attachment: None,
attachments: Vec::new(),
}
}
pub(crate) fn with_root_soap_content_type(
mut self,
soap_content_type: impl Into<String>,
) -> Self {
self.root_soap_content_type = soap_content_type.into();
self
}
pub(crate) fn with_soap_body(mut self, soap_xml: Vec<u8>) -> Self {
let content_type = format!("{}; charset=UTF-8", self.root_soap_content_type);
self.soap_attachment = Some(MimeAttachment::new(
"soap-body@example.com",
content_type,
soap_xml,
"8bit",
));
self
}
pub(crate) fn add_attachments(mut self, attachments: Vec<MimeAttachment>) -> Self {
self.attachments.extend(attachments);
self
}
pub(crate) fn build(self) -> Result<Vec<u8>> {
if self.soap_attachment.is_none() {
return Err(AsxError::new(
ErrorCode::PolicyViolation,
"MIME package requires at least a SOAP body attachment",
ErrorContext::new("mime_packaging"),
));
}
let mut body = Vec::new();
if let Some(ref soap) = self.soap_attachment {
Self::write_crlf_line(&mut body, &format!("--{}", self.boundary), "mime_packaging")?;
Self::write_attachment(&mut body, soap)?;
}
for attachment in &self.attachments {
Self::write_crlf_line(&mut body, &format!("--{}", self.boundary), "mime_packaging")?;
Self::write_attachment(&mut body, attachment)?;
}
Self::write_crlf_line(
&mut body,
&format!("--{}--", self.boundary),
"mime_packaging",
)?;
Ok(body)
}
pub(crate) fn content_type(&self) -> String {
format!(
"multipart/related; boundary=\"{}\"; type=\"{}\"",
self.boundary, self.root_soap_content_type
)
}
fn write_attachment(body: &mut Vec<u8>, attachment: &MimeAttachment) -> Result<()> {
Self::write_crlf_line(
body,
&format!("Content-Type: {}", attachment.content_type),
"mime_packaging",
)?;
Self::write_crlf_line(
body,
&format!(
"Content-Transfer-Encoding: {}",
attachment.transfer_encoding
),
"mime_packaging",
)?;
Self::write_crlf_line(
body,
&format!("Content-ID: <{}>", attachment.content_id),
"mime_packaging",
)?;
if let Some(ref disposition) = attachment.disposition {
Self::write_crlf_line(
body,
&format!("Content-Disposition: {}", disposition),
"mime_packaging",
)?;
}
body.write_all(b"\r\n").map_err(|_| {
AsxError::new(
ErrorCode::ReliabilityFailure,
"Failed to write empty line before attachment body",
ErrorContext::new("mime_packaging"),
)
})?;
body.extend_from_slice(&attachment.body);
body.write_all(b"\r\n").map_err(|_| {
AsxError::new(
ErrorCode::ReliabilityFailure,
"Failed to write CRLF after attachment body",
ErrorContext::new("mime_packaging"),
)
})?;
Ok(())
}
}
impl Default for MimePackageBuilder {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn mime_package_content_type_header() {
let builder = MimePackageBuilder::new();
let content_type = builder.content_type();
assert!(content_type.starts_with("multipart/related"));
assert!(content_type.contains("boundary="));
assert!(content_type.contains("type=\"application/soap+xml\""));
}
#[test]
fn mime_package_builder_requires_soap_body() {
let builder = MimePackageBuilder::new();
let result = builder.build();
assert!(result.is_err());
if let Err(e) = result {
assert_eq!(e.code, ErrorCode::PolicyViolation);
}
}
#[test]
fn payload_filename_accepts_printable_ascii() {
assert!(PayloadFilename::new("invoice.xml").is_ok());
assert!(
PayloadFilename::new("MSCONS_4011234000000_4011234000001_260705_1230_REF.txt").is_ok()
);
assert!(PayloadFilename::new("file with spaces.txt").is_ok());
assert!(PayloadFilename::new(" ~").is_ok());
}
#[test]
fn payload_filename_rejects_empty() {
let err = PayloadFilename::new("").unwrap_err();
assert!(err.to_string().contains("empty"), "{err}");
}
#[test]
fn payload_filename_rejects_over_length() {
let long = "a".repeat(256);
let err = PayloadFilename::new(&long).unwrap_err();
assert!(err.to_string().contains("maximum length"), "{err}");
}
#[test]
fn payload_filename_rejects_control_characters() {
assert!(PayloadFilename::new("evil\r\nX-Injected: hdr").is_err());
assert!(PayloadFilename::new("evil\n").is_err());
assert!(PayloadFilename::new("evil\x00null").is_err());
assert!(PayloadFilename::new("evil\x7f").is_err());
assert!(PayloadFilename::new("caf\u{00e9}").is_err());
}
#[test]
fn payload_filename_rejects_quoted_string_specials() {
assert!(PayloadFilename::new("evil\"quote").is_err());
assert!(PayloadFilename::new("evil\\backslash").is_err());
}
#[test]
fn payload_filename_tryfrom_conversions_work() {
let from_str: PayloadFilename = "invoice.xml".try_into().unwrap();
let from_string: PayloadFilename = "invoice.xml".to_string().try_into().unwrap();
let from_parse: PayloadFilename = "invoice.xml".parse().unwrap();
assert_eq!(from_str, from_string);
assert_eq!(from_str, from_parse);
assert_eq!(from_str.as_str(), "invoice.xml");
}
#[test]
fn payload_filename_display_and_asref() {
let name = PayloadFilename::new("report.pdf").unwrap();
assert_eq!(name.to_string(), "report.pdf");
assert_eq!(AsRef::<str>::as_ref(&name), "report.pdf");
}
}