Skip to main content

laminar_connectors/fpml/
artifacts.rs

1//! Pinned schema closures: the bundled FpML release and operator-supplied closures.
2//!
3//! A closure is a flat set of named schema files, an entry point, and a manifest of SHA-256
4//! digests. Every file is verified before use, and its digest set defines the closure's
5//! fingerprint, which is recorded in the source's schema contract. Nothing is ever downloaded.
6
7use 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
18/// Identifier of the bundled FpML 5.13 REC-2 confirmation-view closure.
19pub 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
75/// Largest schema file accepted in an operator-supplied closure.
76const MAX_SCHEMA_FILE_BYTES: u64 = 16 * 1024 * 1024;
77/// Most files in one closure.
78const MAX_SCHEMA_FILES: usize = 256;
79
80#[derive(Debug, Deserialize)]
81#[serde(deny_unknown_fields)]
82struct Manifest {
83    artifact: String,
84    /// Bundled closure whose files this closure extends.
85    base: Option<String>,
86    release: String,
87    #[serde(default)]
88    build: Option<u32>,
89    /// Provenance only: publication status, download location, and retrieval date.
90    #[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    /// Additional target namespaces whose elements carry FpML `id`/`href` semantics.
99    #[serde(default)]
100    extension_namespaces: Vec<String>,
101    entry: String,
102    files: BTreeMap<String, String>,
103}
104
105/// A verified schema closure.
106#[derive(Debug, Clone)]
107pub struct Artifact {
108    /// Closure identifier (for example `fpml-5-13-8-rec-2/confirmation`).
109    pub id: String,
110    /// Publication build number, when the closure states one.
111    pub build: Option<u32>,
112    /// FpML release required in each document's `fpmlVersion` attribute (for example `5-13`).
113    pub release: String,
114    /// FpML view (for example `confirmation`).
115    pub view: String,
116    /// Namespace of document roots.
117    pub namespace: String,
118    /// Namespaces whose elements carry `id`/`href` semantics.
119    pub reference_namespaces: Vec<String>,
120    /// SHA-256 over the entry point, namespaces, and every file digest.
121    pub fingerprint: String,
122    pub(crate) files: SchemaFiles,
123}
124
125impl Artifact {
126    /// The bundled FpML 5.13 REC-2 confirmation-view closure, verified against its manifest.
127    ///
128    /// # Errors
129    /// Returns an error if an embedded file does not match its pinned digest.
130    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    /// Resolve `fpml.schema.path` (a directory holding `closure.toml`) or the bundled closure.
140    ///
141    /// # Errors
142    /// Returns an error for a missing, malformed, oversized, or digest-mismatched closure.
143    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}