snix_castore/directoryservice/
order_validator.rs

1use std::collections::HashSet;
2use tracing::warn;
3
4use super::Directory;
5use crate::{B3Digest, Node};
6
7pub trait OrderValidator {
8    /// Update the order validator's state with the directory
9    /// Returns whether the directory was accepted
10    fn add_directory(&mut self, directory: &Directory) -> bool;
11}
12
13#[derive(Default)]
14/// Validates that newly introduced directories are already referenced from
15/// the root via existing directories.
16/// Commonly used when _receiving_ a directory closure _from_ a store.
17pub struct RootToLeavesValidator {
18    /// Only used to remember the root node, not for validation
19    expected_digests: HashSet<B3Digest>,
20}
21
22impl RootToLeavesValidator {
23    /// Use to validate the root digest of the closure upon receiving the first
24    /// directory.
25    pub fn new_with_root_digest(root_digest: B3Digest) -> Self {
26        let mut this = Self::default();
27        this.expected_digests.insert(root_digest);
28        this
29    }
30
31    /// Checks if a directory is in-order based on its digest.
32    ///
33    /// Particularly useful when receiving directories in canonical protobuf
34    /// encoding, so that directories not connected to the root can be rejected
35    /// without parsing.
36    ///
37    /// After parsing, the directory must be passed to `add_directory_unchecked`
38    /// to add its children to the list of expected digests.
39    pub fn digest_allowed(&self, digest: &B3Digest) -> bool {
40        self.expected_digests.is_empty() // we don't know the root node; allow any
41            || self.expected_digests.contains(digest)
42    }
43
44    /// Update the order validator's state with the directory
45    pub fn add_directory_unchecked(&mut self, directory: &Directory) {
46        // No initial root was specified and this is the first directory
47        if self.expected_digests.is_empty() {
48            self.expected_digests.insert(directory.digest());
49        }
50
51        // Allow the children to appear next
52        for (_, node) in directory.nodes() {
53            if let Node::Directory { digest, .. } = node {
54                self.expected_digests.insert(digest.clone());
55            }
56        }
57    }
58}
59
60impl OrderValidator for RootToLeavesValidator {
61    fn add_directory(&mut self, directory: &Directory) -> bool {
62        if !self.digest_allowed(&directory.digest()) {
63            return false;
64        }
65        self.add_directory_unchecked(directory);
66        true
67    }
68}
69
70#[derive(Default)]
71/// Validates that newly uploaded directories only reference directories which
72/// have already been introduced.
73/// Commonly used when _uploading_ a directory closure _to_ a store.
74pub struct LeavesToRootValidator {
75    /// This is empty in the beginning, and gets filled as leaves and intermediates are
76    /// inserted
77    allowed_references: HashSet<B3Digest>,
78}
79
80impl OrderValidator for LeavesToRootValidator {
81    fn add_directory(&mut self, directory: &Directory) -> bool {
82        let digest = directory.digest();
83
84        for (_, node) in directory.nodes() {
85            if let Node::Directory {
86                digest: subdir_node_digest,
87                ..
88            } = node
89            {
90                if !self.allowed_references.contains(subdir_node_digest) {
91                    warn!(
92                        directory.digest = %digest,
93                        subdirectory.digest = %subdir_node_digest,
94                        "unexpected directory reference"
95                    );
96                    return false;
97                }
98            }
99        }
100
101        self.allowed_references.insert(digest.clone());
102
103        true
104    }
105}
106
107#[cfg(test)]
108mod tests {
109    use super::{LeavesToRootValidator, RootToLeavesValidator};
110    use crate::directoryservice::Directory;
111    use crate::directoryservice::order_validator::OrderValidator;
112    use crate::fixtures::{DIRECTORY_A, DIRECTORY_B, DIRECTORY_C};
113    use rstest::rstest;
114
115    #[rstest]
116    /// Uploading an empty directory should succeed.
117    #[case::empty_directory(&[&*DIRECTORY_A], false)]
118    /// Uploading A, then B (referring to A) should succeed.
119    #[case::simple_closure(&[&*DIRECTORY_A, &*DIRECTORY_B], false)]
120    /// Uploading A, then A, then C (referring to A twice) should succeed.
121    /// We pretend to be a dumb client not deduping directories.
122    #[case::same_child(&[&*DIRECTORY_A, &*DIRECTORY_A, &*DIRECTORY_C], false)]
123    /// Uploading A, then C (referring to A twice) should succeed.
124    #[case::same_child_dedup(&[&*DIRECTORY_A, &*DIRECTORY_C], false)]
125    /// Uploading A, then C (referring to A twice), then B (itself referring to A) should fail during close,
126    /// as B itself would be left unconnected.
127    #[case::unconnected_node(&[&*DIRECTORY_A, &*DIRECTORY_C, &*DIRECTORY_B], false)]
128    /// Uploading B (referring to A) should fail immediately, because A was never uploaded.
129    #[case::dangling_pointer(&[&*DIRECTORY_B], true)]
130    fn leaves_to_root(
131        #[case] directories_to_upload: &[&Directory],
132        #[case] exp_fail_upload_last: bool,
133    ) {
134        let mut validator = LeavesToRootValidator::default();
135        let len_directories_to_upload = directories_to_upload.len();
136
137        for (i, d) in directories_to_upload.iter().enumerate() {
138            let resp = validator.add_directory(d);
139            if i == len_directories_to_upload - 1 && exp_fail_upload_last {
140                assert!(!resp, "expect last put to fail");
141
142                // We don't really care anymore what finalize() would return, as
143                // the add() failed.
144                return;
145            } else {
146                assert!(resp, "expect put to succeed");
147            }
148        }
149    }
150
151    #[rstest]
152    /// Downloading an empty directory should succeed.
153    #[case::empty_directory(&*DIRECTORY_A, &[&*DIRECTORY_A], false)]
154    /// Downlading B, then A (referenced by B) should succeed.
155    #[case::simple_closure(&*DIRECTORY_B, &[&*DIRECTORY_B, &*DIRECTORY_A], false)]
156    /// Downloading C (referring to A twice), then A should succeed.
157    #[case::same_child_dedup(&*DIRECTORY_C, &[&*DIRECTORY_C, &*DIRECTORY_A], false)]
158    /// Downloading C, then B (both referring to A but not referring to each other) should fail immediately as B has no connection to C (the root)
159    #[case::unconnected_node(&*DIRECTORY_C, &[&*DIRECTORY_C, &*DIRECTORY_B], true)]
160    /// Downloading B (specified as the root) but receiving A instead should fail immediately, because A has no connection to B (the root).
161    #[case::dangling_pointer(&*DIRECTORY_B, &[&*DIRECTORY_A], true)]
162    fn root_to_leaves(
163        #[case] root: &Directory,
164        #[case] directories_to_upload: &[&Directory],
165        #[case] exp_fail_upload_last: bool,
166    ) {
167        let mut validator = RootToLeavesValidator::new_with_root_digest(root.digest());
168        let len_directories_to_upload = directories_to_upload.len();
169
170        for (i, d) in directories_to_upload.iter().enumerate() {
171            let resp1 = validator.digest_allowed(&d.digest());
172            let resp = validator.add_directory(d);
173            assert_eq!(
174                resp1, resp,
175                "digest_allowed should return the same value as add_directory"
176            );
177            if i == len_directories_to_upload - 1 && exp_fail_upload_last {
178                assert!(!resp, "expect last put to fail");
179
180                // We don't really care anymore what finalize() would return, as
181                // the add() failed.
182                return;
183            } else {
184                assert!(resp, "expect put to succeed");
185            }
186        }
187    }
188}