use crate::core::{AsxError, ErrorCode, ErrorContext, Result};
use std::fmt;
use std::io::Write;
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub struct PayloadFilename(String);
#[derive(Clone, Debug, PartialEq, Eq)]
pub 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 fn new(s: &str) -> std::result::Result<Self, PayloadFilenameError> {
Self::validate_bytes(s.as_bytes())?;
Ok(Self(s.to_owned()))
}
#[inline]
pub 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 const MIME_BOUNDARY_PREFIX: &str = "----boundary-asx-";
#[derive(Debug, Clone)]
pub 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 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 fn with_disposition(mut self, disposition: impl Into<String>) -> Self {
self.disposition = Some(disposition.into());
self
}
pub fn content_id_from_digest(payload: &[u8]) -> String {
use openssl::hash::MessageDigest;
let digest =
openssl::hash::hash(MessageDigest::sha256(), payload).expect("SHA-256 should not fail");
let hex_str = digest
.iter()
.take(8)
.map(|b| format!("{:02x}", b))
.collect::<String>();
format!("payload-{}@example.com", hex_str)
}
}
#[derive(Debug)]
pub 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<()> {
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 fn new() -> Self {
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};
static COUNTER: AtomicU64 = AtomicU64::new(0);
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_nanos() as u64)
.unwrap_or(0);
let counter = COUNTER.fetch_add(1, Ordering::Relaxed);
let combined = nanos.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 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 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 fn add_attachment(mut self, attachment: MimeAttachment) -> Self {
self.attachments.push(attachment);
self
}
pub fn add_attachments(mut self, attachments: Vec<MimeAttachment>) -> Self {
self.attachments.extend(attachments);
self
}
pub 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 fn content_type(&self) -> String {
format!(
"multipart/related; boundary=\"{}\"; type=\"{}\"",
self.boundary, self.root_soap_content_type
)
}
pub fn boundary(&self) -> &str {
&self.boundary
}
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_attachment_with_digest_generates_stable_content_id() {
let payload = b"test payload data";
let cid_1 = MimeAttachment::content_id_from_digest(payload);
let cid_2 = MimeAttachment::content_id_from_digest(payload);
assert_eq!(cid_1, cid_2);
assert!(cid_1.starts_with("payload-"));
assert!(cid_1.ends_with("@example.com"));
}
#[test]
fn mime_attachment_different_payloads_different_content_ids() {
let payload_a = b"payload A";
let payload_b = b"payload B";
let cid_a = MimeAttachment::content_id_from_digest(payload_a);
let cid_b = MimeAttachment::content_id_from_digest(payload_b);
assert_ne!(cid_a, cid_b);
}
#[test]
fn mime_package_builder_generates_boundary() {
let builder_1 = MimePackageBuilder::new();
let builder_2 = MimePackageBuilder::new();
let boundary_1 = builder_1.boundary();
let boundary_2 = builder_2.boundary();
assert_ne!(boundary_1, boundary_2);
assert!(boundary_1.starts_with(MIME_BOUNDARY_PREFIX));
assert!(boundary_2.starts_with(MIME_BOUNDARY_PREFIX));
}
#[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 mime_package_builder_with_soap_and_attachment() {
let soap_body = b"<soap:Envelope>...</soap:Envelope>".to_vec();
let payload = b"binary payload".to_vec();
let attachment = MimeAttachment::new(
"payload-001@example.com",
"application/octet-stream",
payload,
"binary",
);
let builder = MimePackageBuilder::new()
.with_soap_body(soap_body)
.add_attachment(attachment);
let result = builder.build();
assert!(result.is_ok());
let mime_body = result.unwrap();
assert!(String::from_utf8_lossy(&mime_body).contains("--"));
assert!(String::from_utf8_lossy(&mime_body).contains("Content-ID:"));
}
#[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");
}
}