prov_fixity/cache.rs
1//! A device-local memory of what each file in a workspace hashed to last time.
2//!
3//! [`digest`](super::digest) is the cheapest thing in prov to describe and one
4//! of the most expensive to run: it reads a whole file and pushes every byte
5//! through SHA-256. A [`history_capture`] does that for *every* file in the
6//! capture set, on every capture, whether or not anything changed — so a
7//! workspace where one document was edited pays to read and hash the other
8//! nine hundred to find that out.
9//!
10//! Almost none of them changed. So remember what they hashed to, and check
11//! rather than read. Validating an entry costs one stat; a capture over a
12//! workspace where nothing changed does no reads and no hashing at all.
13//!
14//! ## Why this may serve a capture and may never serve `check`
15//!
16//! An entry is served only when the file's modification time *and* its length
17//! both still match what was recorded — the test every build system trusts. But
18//! prov has a pass whose entire purpose is to detect changes that test cannot
19//! see: [`fixity_findings`], the bit-rot check. Silent corruption is by
20//! construction a change to the bytes that does not touch the inode's mtime or
21//! length — a disk flipping a bit does not restat the file. A cache keyed on
22//! mtime would confidently vouch for precisely the file that rotted.
23//!
24//! So the line is drawn in the callers, not here:
25//!
26//! > A remembered digest may **decide what to do**, and it may land somewhere
27//! > content-addressed. It may never **establish or verify a fixity baseline**.
28//!
29//! [`history_capture`] is on the safe side of that line, and its own use is
30//! narrower still: a remembered digest is used only when the blob it names is
31//! *already parked*, so the bytes at that address are on disk and were hashed
32//! from the real file when they got there. Whenever a capture actually reads a
33//! file, it hashes the bytes it read. A stale entry can therefore cost an event
34//! that misdescribes an instant; it can never park bytes under an address that
35//! is not their digest, and it can never make `check` miss corruption — because
36//! `check` does not ask.
37//!
38//! ## Why device-local, and not in the workspace
39//!
40//! A prov workspace is an archive of plain files that explains itself. A binary
41//! cache is not part of that explanation, and two devices writing one would
42//! produce sync conflicts over a file whose only job is to describe *this*
43//! device's disk. It is also *derived state* in the sense DESIGN §5 means:
44//! disposable, rebuildable, and load-bearing for nothing. Deleting it costs one
45//! slow capture.
46//!
47//! Which is why this type does no I/O. It decodes from bytes and encodes to
48//! bytes; where those bytes live is the host's business (`prov-cli` keeps them
49//! under the user's cache directory), and prov itself stays free of any notion
50//! of a location outside the workspace.
51//!
52//! ## Staleness
53//!
54//! Every failure mode — a missing file, a truncated one, the wrong magic, a
55//! version this build does not know, a cache written for a different workspace —
56//! decodes to the same answer: nothing is remembered. There is nothing in here
57//! worth recovering, only re-deriving. And because the validator is the file's
58//! own stat, *any* write by anyone — prov, an editor, a sync daemon — retires
59//! the entry on its own. [`forget`](FixityCache::forget) is prov being tidy
60//! about its own writes, not the mechanism that keeps this honest.
61//!
62//! The capture and check operations that consume this cache live in the
63//! higher-level `prov` crate.
64
65use std::collections::BTreeMap;
66use std::path::{Path, PathBuf};
67use std::time::UNIX_EPOCH;
68
69use prov_graph::fs::Metadata;
70
71/// Identifies the format, so a file written by another program is refused
72/// rather than misread.
73const MAGIC: &[u8; 8] = b"PROVFIXC";
74
75/// Bumped whenever the layout below changes. A file at a version this build does
76/// not know is discarded, not migrated — it is a cache.
77const VERSION: u32 = 1;
78
79/// The most files one cache may remember. Beyond this it stops growing rather
80/// than becoming an index of the disk; the excess is re-hashed next time.
81const MAX_ENTRIES: usize = 200_000;
82
83/// One remembered file: the stat it was hashed at, and what it hashed to.
84#[derive(Debug, Clone)]
85struct Entry {
86 /// Modification time in nanoseconds either side of the Unix epoch.
87 ///
88 /// Nanoseconds rather than the milliseconds a cache like this usually keeps,
89 /// because the whole risk here is two different contents sharing one
90 /// timestamp *and* one length, and the width of that window is the width of
91 /// the clock's resolution. Signed, so a file stamped before 1970 — which a
92 /// restored archive genuinely can be — records its real time instead of
93 /// saturating at the epoch and colliding with everything else that did.
94 mtime_ns: i128,
95 len: u64,
96 /// The digest, in [`digest`](super::digest)'s self-describing
97 /// `sha256:<hex>` spelling. Stored as written rather than as raw bytes, so a
98 /// future algorithm needs no format change and an entry this build cannot
99 /// interpret is still legible to one that can.
100 hash: String,
101}
102
103/// What this device remembers of a workspace's file digests.
104///
105/// Keyed by **workspace-relative path**, so a workspace that moves keeps its
106/// cache; `root` is recorded only to refuse a cache that was written for a
107/// different workspace entirely.
108#[derive(Debug, Clone)]
109pub struct FixityCache {
110 root: PathBuf,
111 entries: BTreeMap<PathBuf, Entry>,
112 /// Whether anything has changed since it was decoded — a capture that
113 /// learned nothing should not rewrite the file to say so.
114 dirty: bool,
115}
116
117impl FixityCache {
118 /// An empty cache for the workspace rooted at `root`.
119 pub fn new(root: impl Into<PathBuf>) -> Self {
120 Self {
121 root: root.into(),
122 entries: BTreeMap::new(),
123 dirty: false,
124 }
125 }
126
127 /// Decode what was persisted for the workspace at `root`.
128 ///
129 /// `None` for anything that is not exactly what some build of prov wrote
130 /// for *this* workspace — the caller's move is to start
131 /// [`new`](Self::new), never to investigate.
132 pub fn decode(bytes: &[u8], root: &Path) -> Option<Self> {
133 let mut r = Reader { bytes, at: 0 };
134 if r.take(MAGIC.len())? != MAGIC {
135 return None;
136 }
137 if r.u32()? != VERSION {
138 return None;
139 }
140 let stored_root = r.string()?;
141 if Path::new(&stored_root) != root {
142 // Written for a workspace at a different path. Its entries may well
143 // still describe real files, but nothing here proves it, and a wrong
144 // guess would serve one workspace's digests as another's.
145 return None;
146 }
147 let count = r.u32()? as usize;
148 let mut entries = BTreeMap::new();
149 for _ in 0..count {
150 let rel = r.string()?;
151 let mtime_ns = r.i128()?;
152 let len = r.u64()?;
153 let hash = r.string()?;
154 entries.insert(
155 PathBuf::from(rel),
156 Entry {
157 mtime_ns,
158 len,
159 hash,
160 },
161 );
162 }
163 Some(Self {
164 root: root.to_path_buf(),
165 entries,
166 dirty: false,
167 })
168 }
169
170 /// The bytes to persist. Pair with [`is_dirty`](Self::is_dirty): a cache
171 /// that learned nothing this run is worth writing to nobody.
172 pub fn encode(&self) -> Vec<u8> {
173 let mut out = Vec::with_capacity(self.entries.len() * 128 + 64);
174 out.extend_from_slice(MAGIC);
175 out.extend_from_slice(&VERSION.to_le_bytes());
176 push_str(&mut out, &self.root.to_string_lossy());
177 out.extend_from_slice(&(self.entries.len() as u32).to_le_bytes());
178 // `BTreeMap`, so the bytes are a function of the contents and not of the
179 // order they were learned in — two runs that saw the same workspace
180 // write the same file.
181 for (rel, entry) in &self.entries {
182 push_str(&mut out, &rel.to_string_lossy());
183 out.extend_from_slice(&entry.mtime_ns.to_le_bytes());
184 out.extend_from_slice(&entry.len.to_le_bytes());
185 push_str(&mut out, &entry.hash);
186 }
187 out
188 }
189
190 /// The remembered digest for the workspace-relative `path`, if the file
191 /// `meta` describes is still the one it was recorded against.
192 ///
193 /// Both halves of the stat must agree: a length alone misses an edit that
194 /// preserved the size, and a timestamp alone trusts a clock the file may
195 /// have arrived with.
196 pub fn get(&self, path: &Path, meta: &Metadata) -> Option<&str> {
197 let stamp = stamp(meta)?;
198 let entry = self.entries.get(path)?;
199 (entry.mtime_ns == stamp && entry.len == meta.len()).then_some(entry.hash.as_str())
200 }
201
202 /// Remember that `path` hashed to `hash` at the stat `meta` describes.
203 ///
204 /// Three things are declined rather than stored wrong: a file whose backend
205 /// reports no modification time (nothing could ever validate it, so keeping
206 /// it would only cost space), a path that is not valid UTF-8 (it has no
207 /// stable key, and a lossy one could collide with a different file), and
208 /// anything at all once [`MAX_ENTRIES`] is reached.
209 pub fn put(&mut self, path: &Path, meta: &Metadata, hash: &str) {
210 let Some(mtime_ns) = stamp(meta) else { return };
211 if path.to_str().is_none() || hash.is_empty() {
212 return;
213 }
214 let entry = Entry {
215 mtime_ns,
216 len: meta.len(),
217 hash: hash.to_string(),
218 };
219 match self.entries.get_mut(path) {
220 Some(slot) => {
221 if slot.mtime_ns == entry.mtime_ns
222 && slot.len == entry.len
223 && slot.hash == entry.hash
224 {
225 return;
226 }
227 *slot = entry;
228 }
229 None => {
230 if self.entries.len() >= MAX_ENTRIES {
231 return;
232 }
233 self.entries.insert(path.to_path_buf(), entry);
234 }
235 }
236 self.dirty = true;
237 }
238
239 /// Forget `path` — what a write to it means.
240 pub fn forget(&mut self, path: &Path) {
241 if self.entries.remove(path).is_some() {
242 self.dirty = true;
243 }
244 }
245
246 /// Forget everything. For a write prov cannot attribute to one path.
247 pub fn clear(&mut self) {
248 if !self.entries.is_empty() {
249 self.entries.clear();
250 self.dirty = true;
251 }
252 }
253
254 /// Whether anything has changed since this was decoded.
255 pub fn is_dirty(&self) -> bool {
256 self.dirty
257 }
258
259 /// The workspace this cache was built for.
260 pub fn root(&self) -> &Path {
261 &self.root
262 }
263
264 /// How many files are remembered.
265 pub fn len(&self) -> usize {
266 self.entries.len()
267 }
268
269 /// Whether nothing is remembered.
270 pub fn is_empty(&self) -> bool {
271 self.entries.is_empty()
272 }
273}
274
275/// A file's modification time as nanoseconds either side of the Unix epoch, or
276/// `None` when the backend does not report one.
277fn stamp(meta: &Metadata) -> Option<i128> {
278 let modified = meta.modified().ok()?;
279 Some(match modified.duration_since(UNIX_EPOCH) {
280 Ok(since) => since.as_nanos() as i128,
281 // Before the epoch — a real state for a restored archive, and one that
282 // must stay distinguishable rather than clamping to zero.
283 Err(before) => -(before.duration().as_nanos() as i128),
284 })
285}
286
287fn push_str(out: &mut Vec<u8>, s: &str) {
288 out.extend_from_slice(&(s.len() as u32).to_le_bytes());
289 out.extend_from_slice(s.as_bytes());
290}
291
292/// A bounds-checked cursor. Every read returns `None` past the end, so a
293/// truncated file falls out as "no cache" rather than a panic.
294struct Reader<'a> {
295 bytes: &'a [u8],
296 at: usize,
297}
298
299impl<'a> Reader<'a> {
300 fn take(&mut self, n: usize) -> Option<&'a [u8]> {
301 let end = self.at.checked_add(n)?;
302 let slice = self.bytes.get(self.at..end)?;
303 self.at = end;
304 Some(slice)
305 }
306 fn u32(&mut self) -> Option<u32> {
307 Some(u32::from_le_bytes(self.take(4)?.try_into().ok()?))
308 }
309 fn u64(&mut self) -> Option<u64> {
310 Some(u64::from_le_bytes(self.take(8)?.try_into().ok()?))
311 }
312 fn i128(&mut self) -> Option<i128> {
313 Some(i128::from_le_bytes(self.take(16)?.try_into().ok()?))
314 }
315 fn string(&mut self) -> Option<String> {
316 let len = self.u32()? as usize;
317 String::from_utf8(self.take(len)?.to_vec()).ok()
318 }
319}
320
321#[cfg(test)]
322mod tests {
323 use super::*;
324 use prov_graph::fs::FileType;
325 use std::time::Duration;
326
327 fn meta(secs: u64, len: u64) -> Metadata {
328 Metadata::new(
329 FileType::FILE,
330 len,
331 Some(UNIX_EPOCH + Duration::from_secs(secs)),
332 )
333 }
334
335 /// A backend that reports no modification time — `InMemoryFs` is one.
336 fn timeless(len: u64) -> Metadata {
337 Metadata::new(FileType::FILE, len, None)
338 }
339
340 const HASH: &str = "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
341
342 #[test]
343 fn what_was_remembered_survives_a_round_trip() {
344 let root = Path::new("/vault");
345 let mut cache = FixityCache::new(root);
346 cache.put(Path::new("index.md"), &meta(1_700_000_000, 5), HASH);
347 cache.put(
348 Path::new("notes/a.md"),
349 &meta(1_700_000_001, 9),
350 "sha256:beef",
351 );
352
353 let bytes = cache.encode();
354 let reloaded = FixityCache::decode(&bytes, root).unwrap();
355 assert_eq!(reloaded.len(), 2);
356 assert_eq!(
357 reloaded.get(Path::new("index.md"), &meta(1_700_000_000, 5)),
358 Some(HASH)
359 );
360 assert_eq!(
361 reloaded.get(Path::new("notes/a.md"), &meta(1_700_000_001, 9)),
362 Some("sha256:beef")
363 );
364 assert!(!reloaded.is_dirty(), "a freshly decoded cache is not dirty");
365 }
366
367 /// The whole safety argument in one test: an entry is served only while both
368 /// halves of the stat still agree.
369 #[test]
370 fn a_changed_file_is_not_served_from_the_cache() {
371 let mut cache = FixityCache::new("/vault");
372 let path = Path::new("index.md");
373 cache.put(path, &meta(1_700_000_000, 5), HASH);
374
375 assert!(cache.get(path, &meta(1_700_000_000, 5)).is_some());
376 assert!(
377 cache.get(path, &meta(1_700_000_001, 5)).is_none(),
378 "a newer modification time is a different file"
379 );
380 assert!(
381 cache.get(path, &meta(1_700_000_000, 6)).is_none(),
382 "a different length is a different file"
383 );
384 assert!(
385 cache.get(path, &timeless(5)).is_none(),
386 "a backend that cannot say when is never trusted"
387 );
388 }
389
390 #[test]
391 fn a_file_with_no_modification_time_is_never_remembered() {
392 let mut cache = FixityCache::new("/vault");
393 cache.put(Path::new("index.md"), &timeless(5), HASH);
394 assert_eq!(cache.len(), 0);
395 assert!(!cache.is_dirty());
396 }
397
398 #[test]
399 fn a_write_forgets_the_file_it_wrote() {
400 let mut cache = FixityCache::new("/vault");
401 let path = Path::new("index.md");
402 cache.put(path, &meta(1, 5), HASH);
403 cache.forget(path);
404 assert!(cache.get(path, &meta(1, 5)).is_none());
405 assert_eq!(cache.len(), 0);
406 }
407
408 /// A cache is not a database. Every way it can be wrong reads as nothing
409 /// remembered.
410 #[test]
411 fn a_damaged_or_foreign_cache_decodes_to_nothing() {
412 let root = Path::new("/vault");
413 let mut cache = FixityCache::new(root);
414 cache.put(Path::new("index.md"), &meta(1, 5), HASH);
415 let good = cache.encode();
416
417 assert!(
418 FixityCache::decode(&good[..good.len() - 3], root).is_none(),
419 "a truncated cache was read anyway"
420 );
421
422 let mut wrong_magic = good.clone();
423 wrong_magic[0] = b'X';
424 assert!(FixityCache::decode(&wrong_magic, root).is_none());
425
426 let mut wrong_version = good.clone();
427 wrong_version[MAGIC.len()] = 0xff;
428 assert!(FixityCache::decode(&wrong_version, root).is_none());
429
430 assert!(
431 FixityCache::decode(&good, Path::new("/elsewhere")).is_none(),
432 "a cache written for another workspace was accepted"
433 );
434
435 assert!(FixityCache::decode(b"", root).is_none());
436 }
437
438 /// A capture that learned nothing must not rewrite the file to say so.
439 #[test]
440 fn re_recording_the_same_answer_leaves_the_cache_clean() {
441 let root = Path::new("/vault");
442 let mut cache = FixityCache::new(root);
443 cache.put(Path::new("index.md"), &meta(1, 5), HASH);
444 let mut reloaded = FixityCache::decode(&cache.encode(), root).unwrap();
445
446 reloaded.put(Path::new("index.md"), &meta(1, 5), HASH);
447 assert!(
448 !reloaded.is_dirty(),
449 "recording an answer already held marked the cache dirty"
450 );
451
452 reloaded.put(Path::new("index.md"), &meta(2, 5), "sha256:beef");
453 assert!(
454 reloaded.is_dirty(),
455 "a genuinely new answer was not recorded"
456 );
457 }
458
459 /// The encoding is a function of the contents, not of the order they arrived
460 /// in — so an unchanged workspace produces an unchanged file.
461 #[test]
462 fn the_encoding_is_order_independent() {
463 let mut one = FixityCache::new("/vault");
464 one.put(Path::new("b.md"), &meta(2, 2), "sha256:bb");
465 one.put(Path::new("a.md"), &meta(1, 1), "sha256:aa");
466
467 let mut two = FixityCache::new("/vault");
468 two.put(Path::new("a.md"), &meta(1, 1), "sha256:aa");
469 two.put(Path::new("b.md"), &meta(2, 2), "sha256:bb");
470
471 assert_eq!(one.encode(), two.encode());
472 }
473
474 /// A pre-epoch timestamp is a real state for a restored archive, and two of
475 /// them must stay distinguishable from each other and from the epoch.
476 #[test]
477 fn a_pre_epoch_timestamp_round_trips() {
478 let root = Path::new("/vault");
479 let old = Metadata::new(
480 FileType::FILE,
481 5,
482 Some(UNIX_EPOCH - Duration::from_secs(86_400)),
483 );
484 let older = Metadata::new(
485 FileType::FILE,
486 5,
487 Some(UNIX_EPOCH - Duration::from_secs(172_800)),
488 );
489
490 let mut cache = FixityCache::new(root);
491 cache.put(Path::new("relic.md"), &old, HASH);
492 let reloaded = FixityCache::decode(&cache.encode(), root).unwrap();
493
494 assert_eq!(reloaded.get(Path::new("relic.md"), &old), Some(HASH));
495 assert!(
496 reloaded.get(Path::new("relic.md"), &older).is_none(),
497 "two pre-epoch timestamps collapsed onto one another"
498 );
499 assert!(
500 reloaded.get(Path::new("relic.md"), &meta(0, 5)).is_none(),
501 "a pre-epoch timestamp was clamped to the epoch"
502 );
503 }
504
505 /// Laws over the frame, rather than examples of it.
506 ///
507 /// This is the crate's one hand-rolled binary parser, and it reads a file
508 /// prov did not necessarily write: it lives outside the workspace, in a
509 /// user cache directory, where a half-finished write, a truncating backup,
510 /// or an unrelated file of the same name are all ordinary. The module's
511 /// promise is absolute — "`None` for anything that is not exactly what some
512 /// build of prov wrote for *this* workspace", with **every failure decoding
513 /// to nothing remembered** — and a length prefix read straight out of
514 /// untrusted bytes is exactly where that sort of promise usually has a hole.
515 ///
516 /// A parser is also the one place property testing most resembles fuzzing,
517 /// so both are here, and the difference between them is the lesson:
518 /// uniformly random bytes almost never get past `MAGIC`, so they prove only
519 /// that the front door is locked. *Corrupting a valid encoding* keeps the
520 /// header intact and lands the damage in a length prefix or a UTF-8
521 /// boundary — the code that never runs otherwise.
522 mod properties {
523 use super::*;
524 use proptest::prelude::*;
525
526 const ROOT: &str = "/vault";
527
528 /// A cache built the only way one ever is: through `put`.
529 fn cache() -> impl Strategy<Value = FixityCache> {
530 prop::collection::vec(
531 (
532 "[a-z/]{1,8}",
533 0..4_000_000_000u64,
534 0..64u64,
535 "[a-f0-9]{0,8}",
536 ),
537 0..5usize,
538 )
539 .prop_map(|puts| {
540 let mut cache = FixityCache::new(ROOT);
541 for (path, secs, len, hash) in puts {
542 cache.put(
543 Path::new(&path),
544 &meta(secs, len),
545 &format!("sha256:{hash}"),
546 );
547 }
548 cache
549 })
550 }
551
552 proptest! {
553 /// `decode ∘ encode = id`. The entries survive, the root survives,
554 /// and the reloaded cache is **not dirty** — the last clause is the
555 /// one with consequences, since a cache that decoded itself dirty
556 /// would rewrite the file on every run that learned nothing.
557 #[test]
558 fn what_was_encoded_decodes_back_to_the_same_cache(cache in cache()) {
559 let bytes = cache.encode();
560 let reloaded = FixityCache::decode(&bytes, Path::new(ROOT))
561 .expect("prov's own bytes must decode");
562 prop_assert_eq!(reloaded.len(), cache.len());
563 prop_assert_eq!(reloaded.root(), cache.root());
564 prop_assert!(!reloaded.is_dirty());
565 // Encoding is a function of the contents, not of the order they
566 // were learned in — which is what makes the file diffable and
567 // two runs over one workspace agree byte for byte.
568 prop_assert_eq!(reloaded.encode(), bytes);
569 }
570
571 /// Arbitrary bytes: never a panic, and never a cache claiming a
572 /// root other than the one asked for. This is the front-door test —
573 /// it rarely gets past `MAGIC`, which is exactly why the next one
574 /// exists.
575 #[test]
576 fn arbitrary_bytes_decode_to_nothing_or_to_this_workspace(
577 bytes in prop::collection::vec(any::<u8>(), 0..96),
578 ) {
579 if let Some(cache) = FixityCache::decode(&bytes, Path::new(ROOT)) {
580 prop_assert_eq!(cache.root(), Path::new(ROOT));
581 prop_assert!(!cache.is_dirty());
582 }
583 }
584
585 /// **Corrupt one byte of a real encoding.** The header still passes,
586 /// so the damage lands in a length prefix, a UTF-8 sequence, or an
587 /// entry count — the paths random bytes never reach.
588 ///
589 /// What is *not* claimed: that corruption is detected. There is no
590 /// per-entry checksum, deliberately, because a wrong digest here can
591 /// only ever be served past the mtime-and-length gate the entry also
592 /// carries. The claim is the weaker, sufficient one: no panic, no
593 /// hang, and no cache attributed to the wrong workspace.
594 #[test]
595 fn a_corrupted_encoding_never_panics_and_never_changes_workspace(
596 cache in cache(),
597 at in any::<prop::sample::Index>(),
598 xor in 1..=255u8,
599 ) {
600 let mut bytes = cache.encode();
601 let at = at.index(bytes.len());
602 bytes[at] ^= xor;
603 if let Some(decoded) = FixityCache::decode(&bytes, Path::new(ROOT)) {
604 prop_assert_eq!(decoded.root(), Path::new(ROOT));
605 prop_assert!(!decoded.is_dirty());
606 }
607 }
608
609 /// **Truncation is never partial acceptance.** A short read — an
610 /// interrupted write, a copy that stopped — must decode to nothing,
611 /// not to the entries that happened to arrive. Anything less would
612 /// let a half-written cache answer questions about files whose
613 /// records never landed.
614 #[test]
615 fn a_truncated_encoding_decodes_to_nothing(
616 cache in cache(),
617 at in any::<prop::sample::Index>(),
618 ) {
619 let bytes = cache.encode();
620 let cut = at.index(bytes.len());
621 prop_assert!(
622 FixityCache::decode(&bytes[..cut], Path::new(ROOT)).is_none(),
623 "{cut} of {} bytes still decoded",
624 bytes.len()
625 );
626 }
627
628 /// A cache written for another workspace is refused outright, however
629 /// well-formed it is — the entries may describe real files, but
630 /// nothing in them proves which workspace's.
631 #[test]
632 fn a_cache_from_another_workspace_is_refused(cache in cache()) {
633 prop_assert!(
634 FixityCache::decode(&cache.encode(), Path::new("/elsewhere")).is_none()
635 );
636 }
637 }
638 }
639}