Skip to main content

ahri_tre_protocol/
acquisition.rs

1//! Non-secret acquisition intent. Authentication bytes use a separate channel.
2use serde::{Deserialize, Serialize};
3
4#[derive(Debug, Clone, Serialize, Deserialize)]
5#[serde(deny_unknown_fields)]
6pub struct AcquisitionRequest {
7    pub request: crate::ProtocolRequestEnvelope,
8    pub path: ahri_tre_types::AcquisitionPath,
9    pub endpoint: AcquisitionEndpoint,
10    pub authentication: ahri_tre_types::SourceAuthentication,
11    /// SHA-256 of the exact sensitive signed URL bytes. Its public endpoint is
12    /// only the origin, so path-embedded credentials never enter control JSON.
13    #[serde(default)]
14    pub source_sha256: Option<String>,
15    pub filename: String,
16    pub media_type: String,
17    #[serde(default)]
18    pub shared_source: Option<String>,
19    #[serde(default)]
20    pub budgets: ahri_tre_types::DisclosureBudgetOverrides,
21}
22
23#[derive(Debug, Clone, Serialize, Deserialize)]
24#[serde(deny_unknown_fields)]
25pub struct AcquisitionReservation {
26    pub id: uuid::Uuid,
27    pub deadline: chrono::DateTime<chrono::Utc>,
28    pub budgets: ahri_tre_types::DisclosureBudgets,
29}
30
31impl AcquisitionRequest {
32    pub fn session(&self) -> Option<crate::refs::SessionRef> {
33        use crate::{refs::SessionSelector, request::ProtocolRequest};
34        let selected = match &self.request.request {
35            ProtocolRequest::IngestFile(value) => value.session.as_ref(),
36            ProtocolRequest::IngestDatasetFile(value) => value.session.as_ref(),
37            ProtocolRequest::IngestRedcap(value) => value.session.as_ref(),
38            ProtocolRequest::IngestSql(value) => value.session.as_ref(),
39            _ => None,
40        };
41        match selected {
42            Some(SessionSelector::Reference { reference }) => Some(*reference),
43            _ => None,
44        }
45    }
46    pub fn valid(&self) -> bool {
47        use crate::{
48            ingest::{IngestDatasetFileSource, IngestFileSource},
49            request::ProtocolRequest,
50        };
51        if let ProtocolRequest::IngestSql(value) = &self.request.request {
52            use crate::ingest::{SqlEngine, SqlSource};
53            use ahri_tre_types::{AcquisitionPath, SourceAuthentication};
54            let source_matches = match &value.source {
55                SqlSource::Duckdb { path, .. } => {
56                    self.path == AcquisitionPath::Client
57                        && !path.is_empty()
58                        && self.endpoint.as_str() == "client://duckdb"
59                }
60                SqlSource::Sqlite { path, .. } => {
61                    self.path == AcquisitionPath::Client
62                        && !path.is_empty()
63                        && self.endpoint.as_str() == "client://sqlite"
64                }
65                SqlSource::Stream {
66                    engine: SqlEngine::Duckdb,
67                } => {
68                    self.path == AcquisitionPath::Client
69                        && self.endpoint.as_str() == "client://duckdb"
70                }
71                SqlSource::Stream {
72                    engine: SqlEngine::Sqlite,
73                } => {
74                    self.path == AcquisitionPath::Client
75                        && self.endpoint.as_str() == "client://sqlite"
76                }
77                SqlSource::Remote { endpoint } => {
78                    endpoint == &self.endpoint
79                        && matches!(
80                            self.authentication,
81                            SourceAuthentication::PostgreSql | SourceAuthentication::MsSql
82                        )
83                }
84            };
85            let authentication_matches = match self.authentication {
86                SourceAuthentication::None => self.endpoint.as_str().starts_with("client://"),
87                SourceAuthentication::PostgreSql => {
88                    self.endpoint.as_str().starts_with("postgresql://")
89                }
90                SourceAuthentication::MsSql => self.endpoint.as_str().starts_with("mssql://"),
91                _ => false,
92            };
93            return self.session().is_some()
94                && self
95                    .request
96                    .protocol_version
97                    .ensure_supported(&crate::ProtocolVersion::current())
98                    .is_ok()
99                && source_matches
100                && authentication_matches
101                && self.source_sha256.is_none()
102                && self.filename == "source.parquet"
103                && self.media_type == "application/vnd.apache.parquet";
104        }
105        if !self.endpoint.as_str().starts_with("https://") {
106            return false;
107        }
108        if let ProtocolRequest::IngestRedcap(value) = &self.request.request {
109            let options = &value.materialization;
110            return self.session().is_some()
111                && self
112                    .request
113                    .protocol_version
114                    .ensure_supported(&crate::ProtocolVersion::current())
115                    .is_ok()
116                && matches!(&value.source, crate::ingest::RedcapIngestSource::LiveApi { endpoint } if endpoint.as_str() == self.endpoint.as_str())
117                && self.authentication == ahri_tre_types::SourceAuthentication::Redcap
118                && self.source_sha256.is_none()
119                && self.shared_source.is_none()
120                && self.filename == "redcap.bundle"
121                && self.media_type == "application/vnd.ahri-tre.redcap"
122                && (options.forms.is_empty() || options.skip_forms.is_empty())
123                && matches!(
124                    options.response_encoding.as_deref(),
125                    None | Some("utf8" | "iso88592")
126                )
127                && (1..=5).contains(&options.max_attempts.unwrap_or(5))
128                && (1..=120).contains(&options.timeout_secs.unwrap_or(120));
129        }
130        let format = match &self.request.request {
131            ProtocolRequest::IngestFile(value)
132                if matches!(value.source, IngestFileSource::Stream)
133                    && !value.new_version
134                    && !value.bump_major
135                    && !value.bump_minor =>
136            {
137                Some(value.format.as_str())
138            }
139            ProtocolRequest::IngestDatasetFile(value)
140                if matches!(value.source, IngestDatasetFileSource::Stream)
141                    && !value.materialization.replace
142                    && value.materialization.new_version.is_none() =>
143            {
144                value.parse.format.as_deref()
145            }
146            _ => None,
147        };
148        self.session().is_some()
149            && match self.authentication {
150                ahri_tre_types::SourceAuthentication::SignedUrl => {
151                    self.source_sha256.as_ref().is_some_and(|digest| {
152                        digest.len() == 64
153                            && digest
154                                .bytes()
155                                .all(|b| b.is_ascii_hexdigit() && !b.is_ascii_uppercase())
156                    }) && url::Url::parse(self.endpoint.as_str()).is_ok_and(|url| url.path() == "/")
157                }
158                _ => self.source_sha256.is_none(),
159            }
160            && self
161                .request
162                .protocol_version
163                .ensure_supported(&crate::ProtocolVersion::current())
164                .is_ok()
165            && format
166                .and_then(|format| {
167                    crate::upload::upload_format(&self.filename, format, &self.media_type)
168                })
169                .is_some()
170            && matches!(
171                self.authentication,
172                ahri_tre_types::SourceAuthentication::None
173                    | ahri_tre_types::SourceAuthentication::Basic
174                    | ahri_tre_types::SourceAuthentication::Bearer
175                    | ahri_tre_types::SourceAuthentication::SignedUrl
176            )
177    }
178}
179
180/// Non-secret source address. Local markers identify an engine, never a path.
181#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
182#[serde(transparent)]
183pub struct AcquisitionEndpoint(String);
184impl AcquisitionEndpoint {
185    pub fn as_str(&self) -> &str {
186        &self.0
187    }
188}
189impl From<HttpsEndpoint> for AcquisitionEndpoint {
190    fn from(value: HttpsEndpoint) -> Self {
191        Self(value.0)
192    }
193}
194impl std::str::FromStr for AcquisitionEndpoint {
195    type Err = &'static str;
196    fn from_str(value: &str) -> Result<Self, Self::Err> {
197        serde_json::from_value(serde_json::Value::String(value.into()))
198            .map_err(|_| "Invalid public source endpoint")
199    }
200}
201impl<'de> Deserialize<'de> for AcquisitionEndpoint {
202    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
203        let value = String::deserialize(deserializer)?;
204        if matches!(value.as_str(), "client://duckdb" | "client://sqlite") {
205            return Ok(Self(value));
206        }
207        let url = url::Url::parse(&value).ok().filter(|url| {
208            value.len() <= 4096 && url.host_str().is_some()
209                && url.username().is_empty() && url.password().is_none()
210                && url.query().is_none() && url.fragment().is_none()
211                && (url.scheme() == "https" || (matches!(url.scheme(), "postgresql" | "mssql")
212                    && url.port().is_some_and(|port| port != 0)
213                    && url.path().strip_prefix('/').is_some_and(|database| !database.is_empty() && !database.contains('/') && database.len() <= 255)))
214        }).ok_or_else(|| serde::de::Error::custom("A public source endpoint without credentials or connection options is required"))?;
215        Ok(Self(url.into()))
216    }
217}
218
219#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
220#[serde(transparent)]
221pub struct HttpsEndpoint(String);
222
223impl HttpsEndpoint {
224    pub fn as_str(&self) -> &str {
225        &self.0
226    }
227}
228impl<'de> Deserialize<'de> for HttpsEndpoint {
229    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
230        let value = String::deserialize(deserializer)?;
231        let valid = url::Url::parse(&value).ok().is_some_and(|url| {
232            value.len() <= 4096
233                && url.scheme() == "https"
234                && url.host_str().is_some()
235                && url.username().is_empty()
236                && url.password().is_none()
237                && url.query().is_none()
238                && url.fragment().is_none()
239        });
240        if !valid {
241            return Err(serde::de::Error::custom(
242                "A public HTTPS endpoint without credentials, query or fragment is required",
243            ));
244        }
245        // Canonical form makes source binding independent of equivalent URL spelling.
246        Ok(Self(
247            url::Url::parse(&value)
248                .map_err(|_| serde::de::Error::custom("Invalid HTTPS endpoint"))?
249                .into(),
250        ))
251    }
252}
253
254/// Source role facts accompany the verified acquisition upload, outside Secret material.
255#[derive(Debug, Clone, Serialize, Deserialize)]
256#[serde(deny_unknown_fields)]
257pub struct RedcapUploadRoles {
258    pub project_info: crate::upload::UploadMetadata,
259    pub metadata: crate::upload::UploadMetadata,
260    pub records_eav: crate::upload::UploadMetadata,
261}
262impl From<RedcapUploadRoles> for crate::ingest::RedcapIngestSource {
263    fn from(roles: RedcapUploadRoles) -> Self {
264        Self::Stream {
265            project_info: roles.project_info,
266            metadata: roles.metadata,
267            records_eav: roles.records_eav,
268        }
269    }
270}
271#[derive(Debug, Clone, Serialize, Deserialize)]
272pub struct AcquisitionUploadMetadata {
273    #[serde(flatten)]
274    pub upload: crate::upload::UploadMetadata,
275    #[serde(default, skip_serializing_if = "Option::is_none")]
276    pub redcap: Option<RedcapUploadRoles>,
277}