Skip to main content

ahri_tre_protocol/
upload.rs

1//! Path-free control metadata for a synchronous, integrity-checked upload.
2use crate::{
3    ProtocolRequestEnvelope,
4    ingest::{IngestDatasetFileSource, IngestFileSource},
5    request::ProtocolRequest,
6};
7use ahri_tre_types::DisclosureBudgetOverrides;
8use serde::{Deserialize, Serialize};
9
10#[derive(Debug, Clone, Serialize, Deserialize)]
11#[serde(deny_unknown_fields)]
12pub struct UploadRequest {
13    pub request: ProtocolRequestEnvelope,
14    pub metadata: UploadMetadata,
15    #[serde(default)]
16    pub budgets: DisclosureBudgetOverrides,
17}
18/// Managed-only local source intent. Paths are replaced before Trusted transfer.
19#[derive(Debug, Clone, Serialize, Deserialize)]
20#[serde(deny_unknown_fields)]
21pub struct LocalUploadRequest {
22    pub request: ProtocolRequestEnvelope,
23    #[serde(default)]
24    pub budgets: DisclosureBudgetOverrides,
25}
26#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
27#[serde(deny_unknown_fields)]
28pub struct UploadMetadata {
29    pub filename: String,
30    pub media_type: String,
31    pub content_length: u64,
32    pub digest: String,
33}
34impl UploadRequest {
35    pub fn is_stream_request(&self) -> bool {
36        matches!(&self.request.request,
37            ProtocolRequest::IngestFile(request) if matches!(request.source, IngestFileSource::Stream))
38            || matches!(&self.request.request,
39            ProtocolRequest::IngestDatasetFile(request) if matches!(request.source, IngestDatasetFileSource::Stream))
40            || matches!(&self.request.request,
41            ProtocolRequest::IngestRedcap(request) if matches!(request.source, crate::ingest::RedcapIngestSource::Stream { .. }))
42    }
43}
44/// Declared formats, filename extensions and media types must all agree.
45pub fn upload_format(filename: &str, declared: &str, media_type: &str) -> Option<&'static str> {
46    if filename.is_empty()
47        || filename.len() > 255
48        || filename.starts_with('.')
49        || filename
50            .chars()
51            .any(|c| c.is_control() || matches!(c, '/' | '\\' | ':'))
52    {
53        return None;
54    }
55    let extension = filename.rsplit_once('.')?.1.to_ascii_lowercase();
56    let format = normalize_format(declared)?;
57    let extension_format = normalize_format(&extension)?;
58    (format == extension_format && media_type == upload_media_type(format)?).then_some(format)
59}
60pub fn normalize_format(format: &str) -> Option<&'static str> {
61    match format.to_ascii_lowercase().as_str() {
62        "csv" | "text/csv" | "edam:format_3752" => Some("csv"),
63        "json" | "ndjson" | "application/json" | "edam:format_3464" => Some("json"),
64        "xlsx"
65        | "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
66        | "edam:format_3620" => Some("xlsx"),
67        "arrow"
68        | "ipc"
69        | "arrow_ipc"
70        | "feather"
71        | "application/vnd.apache.arrow.stream"
72        | "application/vnd.apache.arrow.file" => Some("arrow"),
73        "parquet" | "application/vnd.apache.parquet" => Some("parquet"),
74        _ => None,
75    }
76}
77pub fn upload_media_type(format: &str) -> Option<&'static str> {
78    match normalize_format(format)? {
79        "csv" => Some("text/csv"),
80        "json" => Some("application/json"),
81        "xlsx" => Some("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"),
82        "arrow" => Some("application/vnd.apache.arrow.stream"),
83        "parquet" | "application/vnd.apache.parquet" => Some("application/vnd.apache.parquet"),
84        _ => None,
85    }
86}