1use crate::errors::MetaContentsError;
6use fuchsia_merkle::Hash;
7use std::collections::HashMap;
8use std::io;
9use std::str::FromStr;
10
11#[derive(Debug, PartialEq, Eq, Clone)]
16pub struct MetaContents {
17 contents: HashMap<String, Hash>,
18}
19
20impl MetaContents {
21 pub const PATH: &'static str = "meta/contents";
22
23 pub fn from_map(map: HashMap<String, Hash>) -> Result<Self, MetaContentsError> {
50 for resource_path in map.keys() {
51 fuchsia_url::Resource::validate_str(resource_path).map_err(|e| {
52 MetaContentsError::InvalidResourcePath { cause: e, path: resource_path.to_string() }
53 })?;
54 if resource_path.starts_with("meta/") || resource_path == "meta" {
55 return Err(MetaContentsError::ExternalContentInMetaDirectory {
56 path: resource_path.to_string(),
57 });
58 }
59 for (i, _) in resource_path.match_indices('/') {
60 if map.contains_key(&resource_path[..i]) {
61 return Err(MetaContentsError::FileDirectoryCollision {
62 path: resource_path[..i].to_string(),
63 });
64 }
65 }
66 }
67 Ok(MetaContents { contents: map })
68 }
69
70 pub fn serialize(&self, writer: &mut impl io::Write) -> io::Result<()> {
96 let mut entries = self.contents.iter().collect::<Vec<_>>();
97 entries.sort();
98 for (path, hash) in entries {
99 writeln!(writer, "{path}={hash}")?;
100 }
101 Ok(())
102 }
103
104 pub fn deserialize(mut reader: impl io::BufRead) -> Result<Self, MetaContentsError> {
128 let mut contents = HashMap::new();
129 let mut buf = String::new();
130 while reader.read_line(&mut buf)? > 0 {
131 let line = buf.trim_end();
132 let i = line.rfind('=').ok_or_else(|| MetaContentsError::EntryHasNoEqualsSign {
133 entry: line.to_string(),
134 })?;
135
136 let hash = Hash::from_str(&line[i + 1..])?;
137 let path = line[..i].to_string();
138
139 use std::collections::hash_map::Entry;
140 match contents.entry(path) {
141 Entry::Vacant(entry) => {
142 entry.insert(hash);
143 }
144 Entry::Occupied(entry) => {
145 return Err(MetaContentsError::DuplicateResourcePath {
146 path: entry.key().clone(),
147 });
148 }
149 }
150
151 buf.clear();
152 }
153 contents.shrink_to_fit();
154 Self::from_map(contents)
155 }
156
157 pub fn contents(&self) -> &HashMap<String, Hash> {
159 &self.contents
160 }
161
162 pub fn into_contents(self) -> HashMap<String, Hash> {
164 self.contents
165 }
166
167 pub fn into_hashes_undeduplicated(self) -> impl Iterator<Item = Hash> {
170 self.contents.into_values()
171 }
172}
173
174#[cfg(test)]
175mod tests {
176 use super::*;
177 use crate::test::*;
178 use assert_matches::assert_matches;
179 use fuchsia_url::ResourcePathError;
180 use fuchsia_url::test::*;
181 use proptest::prelude::*;
182
183 fn zeros_hash() -> Hash {
184 Hash::from_str("0000000000000000000000000000000000000000000000000000000000000000").unwrap()
185 }
186
187 fn ones_hash() -> Hash {
188 Hash::from_str("1111111111111111111111111111111111111111111111111111111111111111").unwrap()
189 }
190
191 #[test]
192 fn deserialize_empty_file() {
193 let empty = Vec::new();
194 let meta_contents = MetaContents::deserialize(empty.as_slice()).unwrap();
195 assert_eq!(meta_contents.contents(), &HashMap::new());
196 assert_eq!(meta_contents.into_contents(), HashMap::new());
197 }
198
199 #[test]
200 fn deserialize_known_file() {
201 let bytes =
202 "a-host/path=0000000000000000000000000000000000000000000000000000000000000000\n\
203 other/host/path=1111111111111111111111111111111111111111111111111111111111111111\n"
204 .as_bytes();
205 let meta_contents = MetaContents::deserialize(bytes).unwrap();
206 let expected_contents = HashMap::from([
207 ("a-host/path".to_string(), zeros_hash()),
208 ("other/host/path".to_string(), ones_hash()),
209 ]);
210 assert_eq!(meta_contents.contents(), &expected_contents);
211 assert_eq!(meta_contents.into_contents(), expected_contents);
212 }
213
214 #[test]
215 fn from_map_rejects_meta_file() {
216 let map = HashMap::from([("meta".to_string(), zeros_hash())]);
217 assert_matches!(
218 MetaContents::from_map(map),
219 Err(MetaContentsError::ExternalContentInMetaDirectory { path }) if path == "meta"
220 );
221 }
222
223 #[test]
224 fn from_map_rejects_file_dir_collisions() {
225 for (map, expected_path) in [
226 (
227 HashMap::from([
228 ("foo".to_string(), zeros_hash()),
229 ("foo/bar".to_string(), zeros_hash()),
230 ]),
231 "foo",
232 ),
233 (
234 HashMap::from([
235 ("foo/bar".to_string(), zeros_hash()),
236 ("foo/bar/baz".to_string(), zeros_hash()),
237 ]),
238 "foo/bar",
239 ),
240 (
241 HashMap::from([
242 ("foo".to_string(), zeros_hash()),
243 ("foo/bar/baz".to_string(), zeros_hash()),
244 ]),
245 "foo",
246 ),
247 ] {
248 assert_matches!(
249 MetaContents::from_map(map),
250 Err(MetaContentsError::FileDirectoryCollision { path }) if path == expected_path
251 );
252 }
253 }
254
255 proptest! {
256 #![proptest_config(ProptestConfig{
257 failure_persistence: None,
258 ..Default::default()
259 })]
260
261 #[test]
262 fn from_map_rejects_invalid_resource_path(
263 ref path in random_resource_path(1, 3),
264 ref hex in random_merkle_hex())
265 {
266 prop_assume!(!path.starts_with("meta/"));
267 let invalid_path = format!("{path}/");
268 let map = HashMap::from([
269 (invalid_path.clone(), Hash::from_str(hex.as_str()).unwrap()),
270 ]);
271 assert_matches!(
272 MetaContents::from_map(map),
273 Err(MetaContentsError::InvalidResourcePath {
274 cause: ResourcePathError::PathEndsWithSlash,
275 path }) if path == invalid_path
276 );
277 }
278
279 #[test]
280 fn from_map_rejects_file_in_meta(
281 ref path in random_resource_path(1, 3),
282 ref hex in random_merkle_hex())
283 {
284 let invalid_path = format!("meta/{path}");
285 let map = HashMap::from([
286 (invalid_path.clone(), Hash::from_str(hex.as_str()).unwrap()),
287 ]);
288 assert_matches!(
289 MetaContents::from_map(map),
290 Err(MetaContentsError::ExternalContentInMetaDirectory { path }) if path == invalid_path
291 );
292 }
293
294 #[test]
295 fn serialize(
296 ref path0 in random_external_resource_path(),
297 ref hex0 in random_merkle_hex(),
298 ref path1 in random_external_resource_path(),
299 ref hex1 in random_merkle_hex())
300 {
301 prop_assume!(path0 != path1);
302 let map = HashMap::from([
303 (path0.clone(), Hash::from_str(hex0.as_str()).unwrap()),
304 (path1.clone(), Hash::from_str(hex1.as_str()).unwrap()),
305 ]);
306 let meta_contents = MetaContents::from_map(map);
307 prop_assume!(meta_contents.is_ok());
308 let meta_contents = meta_contents.unwrap();
309 let mut bytes = Vec::new();
310
311 meta_contents.serialize(&mut bytes).unwrap();
312
313 let ((first_path, first_hex), (second_path, second_hex)) = if path0 <= path1 {
314 ((path0, hex0), (path1, hex1))
315 } else {
316 ((path1, hex1), (path0, hex0))
317 };
318 let expected = format!(
319 "{}={}\n{}={}\n",
320 first_path,
321 first_hex.to_ascii_lowercase(),
322 second_path,
323 second_hex.to_ascii_lowercase());
324 prop_assert_eq!(bytes.as_slice(), expected.as_bytes());
325 }
326
327 #[test]
328 fn serialize_deserialize_is_id(
329 contents in prop::collection::hash_map(
330 random_external_resource_path(), random_hash(), 0..4)
331 ) {
332 let meta_contents = MetaContents::from_map(contents);
333 prop_assume!(meta_contents.is_ok());
334 let meta_contents = meta_contents.unwrap();
335 let mut serialized = Vec::new();
336 meta_contents.serialize(&mut serialized).unwrap();
337 let deserialized = MetaContents::deserialize(serialized.as_slice()).unwrap();
338 prop_assert_eq!(meta_contents, deserialized);
339 }
340 }
341}