1use std::collections::BTreeMap;
8use std::path::{Component, Path};
9use std::sync::Arc;
10
11use serde::de::IgnoredAny;
12use serde::Deserialize;
13use sha2::{Digest, Sha256};
14
15use super::xsd::SchemaFiles;
16use crate::error::ConnectorError;
17
18pub const BUNDLED_CONFIRMATION: &str = "fpml-5-13-8-rec-2/confirmation";
20
21const BUNDLED_MANIFEST: &str = include_str!(concat!(
22 env!("CARGO_MANIFEST_DIR"),
23 "/fpml/artifacts/fpml-5-13-8-rec-2/closure.toml"
24));
25
26macro_rules! bundled_files {
27 ($($name:literal),* $(,)?) => {
28 &[$(($name, include_bytes!(concat!(
29 env!("CARGO_MANIFEST_DIR"),
30 "/fpml/artifacts/fpml-5-13-8-rec-2/confirmation/",
31 $name
32 )) as &[u8])),*]
33 };
34}
35
36const BUNDLED_FILES: &[(&str, &[u8])] = bundled_files!(
37 "fpml-accumulator-5-13.xsd",
38 "fpml-asset-5-13.xsd",
39 "fpml-bond-option-5-13.xsd",
40 "fpml-business-events-5-13.xsd",
41 "fpml-cd-5-13.xsd",
42 "fpml-clearing-processes-5-13.xsd",
43 "fpml-com-5-13.xsd",
44 "fpml-confirmation-processes-5-13.xsd",
45 "fpml-correlation-swaps-5-13.xsd",
46 "fpml-credit-event-notification-5-13.xsd",
47 "fpml-dividend-swaps-5-13.xsd",
48 "fpml-doc-5-13.xsd",
49 "fpml-enum-5-13.xsd",
50 "fpml-eq-shared-5-13.xsd",
51 "fpml-eqd-5-13.xsd",
52 "fpml-fx-5-13.xsd",
53 "fpml-fx-accruals-5-13.xsd",
54 "fpml-fx-targets-5-13.xsd",
55 "fpml-generic-5-13.xsd",
56 "fpml-ird-5-13.xsd",
57 "fpml-loan-5-13.xsd",
58 "fpml-main-5-13.xsd",
59 "fpml-mktenv-5-13.xsd",
60 "fpml-msg-5-13.xsd",
61 "fpml-option-shared-5-13.xsd",
62 "fpml-reg-reporting-shared-5-13.xsd",
63 "fpml-repo-5-13.xsd",
64 "fpml-return-swaps-5-13.xsd",
65 "fpml-riskdef-5-13.xsd",
66 "fpml-sec-lending-5-13.xsd",
67 "fpml-shared-5-13.xsd",
68 "fpml-standard-5-13.xsd",
69 "fpml-valuation-5-13.xsd",
70 "fpml-variance-swaps-5-13.xsd",
71 "fpml-volatility-swaps-5-13.xsd",
72 "xmldsig-core-schema.xsd",
73);
74
75const MAX_SCHEMA_FILE_BYTES: u64 = 16 * 1024 * 1024;
77const MAX_SCHEMA_FILES: usize = 256;
79
80#[derive(Debug, Deserialize)]
81#[serde(deny_unknown_fields)]
82struct Manifest {
83 artifact: String,
84 base: Option<String>,
86 release: String,
87 #[serde(default)]
88 build: Option<u32>,
89 #[serde(default, rename = "status")]
91 _status: Option<IgnoredAny>,
92 #[serde(default, rename = "source")]
93 _source: Option<IgnoredAny>,
94 #[serde(default, rename = "retrieved")]
95 _retrieved: Option<IgnoredAny>,
96 view: String,
97 namespace: String,
98 #[serde(default)]
100 extension_namespaces: Vec<String>,
101 entry: String,
102 files: BTreeMap<String, String>,
103}
104
105#[derive(Debug, Clone)]
107pub struct Artifact {
108 pub id: String,
110 pub build: Option<u32>,
112 pub release: String,
114 pub view: String,
116 pub namespace: String,
118 pub reference_namespaces: Vec<String>,
120 pub fingerprint: String,
122 pub(crate) files: SchemaFiles,
123}
124
125impl Artifact {
126 pub fn bundled() -> Result<Self, ConnectorError> {
131 let manifest = parse_manifest(BUNDLED_MANIFEST, "bundled closure manifest")?;
132 let files = BUNDLED_FILES
133 .iter()
134 .map(|(name, bytes)| ((*name).to_owned(), Arc::<[u8]>::from(*bytes)))
135 .collect::<BTreeMap<_, _>>();
136 Self::verify(manifest, files, None)
137 }
138
139 pub fn resolve(path: Option<&str>) -> Result<Self, ConnectorError> {
144 match path {
145 None => Self::bundled(),
146 Some(directory) => Self::load(Path::new(directory)),
147 }
148 }
149
150 fn load(directory: &Path) -> Result<Self, ConnectorError> {
151 let manifest_path = directory.join("closure.toml");
152 let text = std::fs::read_to_string(&manifest_path).map_err(|error| {
153 config_error(format!(
154 "cannot read schema closure manifest '{}': {error}",
155 manifest_path.display()
156 ))
157 })?;
158 let manifest = parse_manifest(&text, "schema closure manifest")?;
159 let mut files = BTreeMap::new();
160 let base = match manifest.base.as_deref() {
161 None => None,
162 Some(BUNDLED_CONFIRMATION) => Some(Self::bundled()?),
163 Some(other) => {
164 return Err(config_error(format!(
165 "schema closure base '{other}' is not a bundled closure"
166 )))
167 }
168 };
169 if manifest.files.len() > MAX_SCHEMA_FILES {
170 return Err(config_error(format!(
171 "schema closure lists more than {MAX_SCHEMA_FILES} files"
172 )));
173 }
174 for name in manifest.files.keys() {
175 let relative = Path::new(name);
176 if relative
177 .components()
178 .any(|component| !matches!(component, Component::Normal(_)))
179 {
180 return Err(config_error(format!(
181 "schema closure file '{name}' must be a relative path inside the closure"
182 )));
183 }
184 let path = directory.join(relative);
185 let size = std::fs::metadata(&path)
186 .map_err(|error| {
187 config_error(format!(
188 "cannot read schema file '{}': {error}",
189 path.display()
190 ))
191 })?
192 .len();
193 if size > MAX_SCHEMA_FILE_BYTES {
194 return Err(config_error(format!(
195 "schema file '{name}' exceeds {MAX_SCHEMA_FILE_BYTES} bytes"
196 )));
197 }
198 let bytes = std::fs::read(&path).map_err(|error| {
199 config_error(format!(
200 "cannot read schema file '{}': {error}",
201 path.display()
202 ))
203 })?;
204 files.insert(name.clone(), Arc::<[u8]>::from(bytes));
205 }
206 Self::verify(manifest, files, base.as_ref())
207 }
208
209 fn verify(
210 manifest: Manifest,
211 mut files: BTreeMap<String, Arc<[u8]>>,
212 base: Option<&Self>,
213 ) -> Result<Self, ConnectorError> {
214 let listed = manifest.files.keys().collect::<Vec<_>>();
215 let present = files.keys().collect::<Vec<_>>();
216 if listed != present {
217 return Err(config_error(format!(
218 "schema closure '{}' files do not match its manifest",
219 manifest.artifact
220 )));
221 }
222 for (name, expected) in &manifest.files {
223 let actual = hex(&Sha256::digest(&files[name]));
224 if !actual.eq_ignore_ascii_case(expected) {
225 return Err(config_error(format!(
226 "schema closure '{}' file '{name}' has SHA-256 {actual}, manifest pins {expected}",
227 manifest.artifact
228 )));
229 }
230 }
231 let mut digest = Sha256::new();
232 let mut reference_namespaces = vec![manifest.namespace.clone()];
233 if let Some(base) = base {
234 digest.update(base.fingerprint.as_bytes());
235 for namespace in &base.reference_namespaces {
236 if !reference_namespaces.contains(namespace) {
237 reference_namespaces.push(namespace.clone());
238 }
239 }
240 for (name, bytes) in &base.files.files {
241 if files.insert(name.clone(), Arc::clone(bytes)).is_some() {
242 return Err(config_error(format!(
243 "schema closure '{}' redefines base file '{name}'",
244 manifest.artifact
245 )));
246 }
247 }
248 }
249 for namespace in &manifest.extension_namespaces {
250 if !reference_namespaces.contains(namespace) {
251 reference_namespaces.push(namespace.clone());
252 }
253 }
254 if !files.contains_key(&manifest.entry) {
255 return Err(config_error(format!(
256 "schema closure entry '{}' is not a closure file",
257 manifest.entry
258 )));
259 }
260 for part in [&manifest.artifact, &manifest.entry, &manifest.namespace] {
261 digest.update(part.as_bytes());
262 digest.update([0]);
263 }
264 for namespace in &reference_namespaces {
265 digest.update(namespace.as_bytes());
266 digest.update([0]);
267 }
268 for (name, sha) in &manifest.files {
269 digest.update(name.as_bytes());
270 digest.update([0]);
271 digest.update(sha.to_ascii_lowercase().as_bytes());
272 digest.update(b"\n");
273 }
274 Ok(Self {
275 id: manifest.artifact,
276 build: manifest.build,
277 release: manifest.release,
278 view: manifest.view,
279 namespace: manifest.namespace,
280 reference_namespaces,
281 fingerprint: hex(&digest.finalize()),
282 files: SchemaFiles {
283 entry: manifest.entry,
284 files,
285 },
286 })
287 }
288}
289
290fn parse_manifest(text: &str, what: &str) -> Result<Manifest, ConnectorError> {
291 toml::from_str(text).map_err(|error| config_error(format!("invalid {what}: {error}")))
292}
293
294pub(crate) fn hex(bytes: &[u8]) -> String {
295 use std::fmt::Write as _;
296 let mut out = String::with_capacity(bytes.len() * 2);
297 for byte in bytes {
298 let _ = write!(out, "{byte:02x}");
299 }
300 out
301}
302
303fn config_error(message: String) -> ConnectorError {
304 ConnectorError::ConfigurationError(message)
305}
306
307#[cfg(test)]
308mod tests {
309 use super::*;
310
311 #[test]
312 fn bundled_closure_matches_its_pinned_digests() {
313 let artifact = Artifact::bundled().unwrap();
314 assert_eq!(artifact.id, BUNDLED_CONFIRMATION);
315 assert_eq!(artifact.release, "5-13");
316 assert_eq!(artifact.files.files.len(), 36);
317 assert_eq!(artifact.files.entry, "fpml-main-5-13.xsd");
318 assert_eq!(
319 Artifact::bundled().unwrap().fingerprint,
320 artifact.fingerprint
321 );
322 }
323
324 #[test]
325 fn tampered_or_escaping_closures_are_rejected() {
326 let directory = tempfile::tempdir().unwrap();
327 std::fs::write(directory.path().join("a.xsd"), "<x/>").unwrap();
328 let manifest = |files: &str| {
329 format!(
330 "artifact = \"t\"\nrelease = \"5-13\"\nview = \"confirmation\"\nnamespace = \"urn:t\"\nentry = \"a.xsd\"\n[files]\n{files}\n"
331 )
332 };
333 std::fs::write(
334 directory.path().join("closure.toml"),
335 manifest(
336 "\"a.xsd\" = \"0000000000000000000000000000000000000000000000000000000000000000\"",
337 ),
338 )
339 .unwrap();
340 let error = Artifact::resolve(Some(directory.path().to_str().unwrap())).unwrap_err();
341 assert!(error.to_string().contains("manifest pins"), "{error}");
342
343 std::fs::write(
344 directory.path().join("closure.toml"),
345 manifest("\"../a.xsd\" = \"00\""),
346 )
347 .unwrap();
348 let error = Artifact::resolve(Some(directory.path().to_str().unwrap())).unwrap_err();
349 assert!(error.to_string().contains("relative path"), "{error}");
350 }
351}