oxedyne/ore/cli/tests/sweep.rs
14.2 KiB, 1 run
created by r2848102244:1175, which is this file's identity for as long as the history lasts, whatever it is later renamed to
download · who wrote it · its history
| 1 | //! What a store no longer notices, what the sweep notices instead, and what |
| 2 | //! happens next. |
| 3 | //! |
| 4 | //! A vouched read of a sealed segment does not hash the record bodies. That was |
| 5 | //! the only check standing between a body byte that flipped on the disk and a |
| 6 | //! history that can never forget it: the signature is skipped on the same |
| 7 | //! warrant, the file keeps its length and its modification time, and the fold a |
| 8 | //! verdict is filed under is over the digests written beside the bodies rather |
| 9 | //! than over the bodies. So the damage this file plants is damage nothing on the |
| 10 | //! hot path can see, and that is asserted here rather than assumed. |
| 11 | //! |
| 12 | //! Every test plants the same thing: one byte of one record body, in a sealed |
| 13 | //! segment a verdict vouches for, flipped in place. The file is the length it |
| 14 | //! was and dated as it was, checked both times, because a tamper that moved |
| 15 | //! either would be caught by something other than what is under test. |
| 16 | //! |
| 17 | //! The three answers, one test each: |
| 18 | //! |
| 19 | //! - the hot path does not notice, which is the trade; |
| 20 | //! - `ore repack --verify` does, and says something a person can act on; |
| 21 | //! - a verdict that has gone off does too, with no sweep involved at all, which |
| 22 | //! is why a timer that stopped cannot take the checking with it. |
| 23 | |
| 24 | mod support; |
| 25 | |
| 26 | use support::{ |
| 27 | ore, |
| 28 | write, |
| 29 | Ran, |
| 30 | Scratch, |
| 31 | }; |
| 32 | |
| 33 | use oxedyne_fe2o3_core::prelude::*; |
| 34 | |
| 35 | use std::fs; |
| 36 | use std::path::{ |
| 37 | Path, |
| 38 | PathBuf, |
| 39 | }; |
| 40 | |
| 41 | |
| 42 | /// The marker planted in a file's content, and damaged in the segment. |
| 43 | /// |
| 44 | /// Content reaches a segment as itself, so a string written into a working file |
| 45 | /// is findable in the bytes on the disk. Flipping a letter of it is bit rot as |
| 46 | /// it actually reads: the record is the length it was and the same shape, and |
| 47 | /// nothing but a hash over it could ever have told. |
| 48 | const MARK: &[u8] = b"ROTHERE"; |
| 49 | |
| 50 | |
| 51 | /// A repository holding more than one segment, so that at least one of them is |
| 52 | /// sealed and can earn a verdict. |
| 53 | /// |
| 54 | /// A segment is sealed once the next one has been started, which happens past |
| 55 | /// `SEGMENT_LIMIT` -- a megabyte of records -- so this writes rather more than |
| 56 | /// that. The marker goes in the first file, which is therefore in the first |
| 57 | /// segment and sealed long before the writing stops. |
| 58 | fn stocked(what: &str) |
| 59 | -> Outcome<Scratch> |
| 60 | { |
| 61 | let scratch = res!(Scratch::new(what)); |
| 62 | let root = &scratch.path; |
| 63 | res!(ore(root, &["init"])).good("init")?; |
| 64 | let mut first = Vec::new(); |
| 65 | first.extend_from_slice(MARK); |
| 66 | first.extend_from_slice(b"\n"); |
| 67 | for i in 0..8_000 { |
| 68 | first.extend_from_slice(fmt!("the {}th line of the first file\n", i).as_bytes()); |
| 69 | } |
| 70 | res!(write(root, "first.txt", &first)); |
| 71 | res!(ore(root, &["mark", "one"])).good("mark one")?; |
| 72 | for f in 0..7 { |
| 73 | let mut body = Vec::new(); |
| 74 | for i in 0..8_000 { |
| 75 | body.extend_from_slice(fmt!("file {} line {} of filler\n", f, i).as_bytes()); |
| 76 | } |
| 77 | res!(write(root, &fmt!("filler{}.txt", f), &body)); |
| 78 | res!(ore(root, &["mark", &fmt!("fill-{}", f)])).good("mark filler")?; |
| 79 | } |
| 80 | Ok(scratch) |
| 81 | } |
| 82 | |
| 83 | /// Every segment file, in log order. |
| 84 | fn segments(root: &Path) |
| 85 | -> Outcome<Vec<PathBuf>> |
| 86 | { |
| 87 | let dir = root.join(".ore").join("log"); |
| 88 | let mut out: Vec<PathBuf> = Vec::new(); |
| 89 | for entry in res!(fs::read_dir(&dir)) { |
| 90 | let path = res!(entry).path(); |
| 91 | if path.extension().map(|e| e == "seg").unwrap_or(false) { |
| 92 | out.push(path); |
| 93 | } |
| 94 | } |
| 95 | out.sort(); |
| 96 | Ok(out) |
| 97 | } |
| 98 | |
| 99 | /// The segment holding the marker, which is sealed because it is not the last. |
| 100 | fn marked_segment(root: &Path) |
| 101 | -> Outcome<PathBuf> |
| 102 | { |
| 103 | let all = res!(segments(root)); |
| 104 | if all.len() < 2 { |
| 105 | return Err(err!( |
| 106 | "The fixture left {} segment{}, so nothing in it is sealed and there is \ |
| 107 | nothing to vouch for.", all.len(), if all.len() == 1 { "" } else { "s" }; |
| 108 | Test, Invalid)); |
| 109 | } |
| 110 | for path in &all[..all.len() - 1] { |
| 111 | let bytes = res!(fs::read(path)); |
| 112 | if bytes.windows(MARK.len()).any(|w| w == MARK) { |
| 113 | return Ok(path.clone()); |
| 114 | } |
| 115 | } |
| 116 | Err(err!( |
| 117 | "No sealed segment carries the marker, so the fixture is not damaging what \ |
| 118 | it thinks it is."; Test, Missing)) |
| 119 | } |
| 120 | |
| 121 | /// Flips one letter of the marker where it sits in a segment file, and puts the |
| 122 | /// modification time back. |
| 123 | /// |
| 124 | /// Both halves are asserted rather than trusted. A tamper that changed the |
| 125 | /// length or the date would be caught by [`ore_store::verdict`] before anything |
| 126 | /// under test here got a chance, and the test would pass for the wrong reason. |
| 127 | fn rot(path: &Path) |
| 128 | -> Outcome<()> |
| 129 | { |
| 130 | let was = res!(fs::metadata(path)); |
| 131 | let when = res!(was.modified()); |
| 132 | let mut bytes = res!(fs::read(path)); |
| 133 | let at = res!(bytes.windows(MARK.len()).position(|w| w == MARK).ok_or_else(|| err!( |
| 134 | "The marker is not in {:?}.", path; Test, Missing))); |
| 135 | bytes[at + 1] ^= 0x20; |
| 136 | res!(fs::write(path, &bytes)); |
| 137 | let now = res!(fs::metadata(path)); |
| 138 | assert_eq!(now.len(), was.len(), "the rot must not change the length"); |
| 139 | let file = res!(fs::OpenOptions::new().write(true).open(path)); |
| 140 | res!(file.set_modified(when)); |
| 141 | let after = res!(fs::metadata(path)); |
| 142 | assert_eq!(res!(after.modified()), when, "nor the modification time"); |
| 143 | Ok(()) |
| 144 | } |
| 145 | |
| 146 | /// The verdict file, as text, or empty where there is none. |
| 147 | fn verdicts(root: &Path) -> String { |
| 148 | fs::read_to_string(root.join(".ore").join("verified")).unwrap_or_default() |
| 149 | } |
| 150 | |
| 151 | /// Puts every verdict's date back by some days, which is the only thing |
| 152 | /// separating a store that is swept from one that is not. |
| 153 | fn age_verdicts(root: &Path, days: u128) |
| 154 | -> Outcome<()> |
| 155 | { |
| 156 | let path = root.join(".ore").join("verified"); |
| 157 | let text = res!(fs::read_to_string(&path)); |
| 158 | let mut out = String::new(); |
| 159 | for (i, line) in text.lines().enumerate() { |
| 160 | if i == 0 || line.trim().is_empty() { |
| 161 | out.push_str(line); |
| 162 | out.push('\n'); |
| 163 | continue; |
| 164 | } |
| 165 | let field: Vec<&str> = line.split_whitespace().collect(); |
| 166 | if field.len() != 5 { |
| 167 | return Err(err!( |
| 168 | "A verdict line has {} fields, not five: {:?}", field.len(), line; |
| 169 | Test, Invalid)); |
| 170 | } |
| 171 | let was: u128 = res!(field[4].parse::<u128>().map_err(|e| err!( |
| 172 | "The verdict date {:?} is not a number: {}", field[4], e; Test, Invalid))); |
| 173 | let back = days * 24 * 60 * 60 * 1_000_000_000; |
| 174 | out.push_str(&fmt!("{} {} {} {} {}\n", field[0], field[1], field[2], field[3], |
| 175 | was.saturating_sub(back))); |
| 176 | } |
| 177 | res!(fs::write(&path, out)); |
| 178 | Ok(()) |
| 179 | } |
| 180 | |
| 181 | /// Everything a test says about a command it expected to fail. |
| 182 | fn refused(ran: &Ran, what: &str) |
| 183 | -> Outcome<String> |
| 184 | { |
| 185 | if ran.ok { |
| 186 | return Err(err!( |
| 187 | "`ore {}` succeeded over a damaged segment: {}{}", what, ran.out, ran.err; |
| 188 | Test, Invalid)); |
| 189 | } |
| 190 | Ok(fmt!("{}{}", ran.out, ran.err)) |
| 191 | } |
| 192 | |
| 193 | |
| 194 | /// **The trade.** A flipped body byte in a vouched sealed segment goes |
| 195 | /// unnoticed by the commands, and the same byte with the verdict withdrawn does |
| 196 | /// not. |
| 197 | /// |
| 198 | /// The second half is what stops the first half being vacuous. A test that only |
| 199 | /// showed a command succeeding over a damaged store would pass just as well if |
| 200 | /// the damage had never landed, or had landed somewhere nothing reads; putting |
| 201 | /// the verdict aside and watching the same command refuse the same bytes proves |
| 202 | /// the damage is real, is on the path, and is exactly what the skip is skipping. |
| 203 | #[test] |
| 204 | fn a_flipped_byte_in_a_vouched_segment_is_invisible_on_the_hot_path() -> Outcome<()> { |
| 205 | let scratch = res!(stocked("invisible")); |
| 206 | let root = &scratch.path; |
| 207 | // A verdict for the sealed segment, earned by a read that checked it. |
| 208 | res!(ore(root, &["flags"])).good("flags")?; |
| 209 | let seg = res!(marked_segment(root)); |
| 210 | let name = fmt!("{}", res!(seg.file_name().ok_or_else(|| err!( |
| 211 | "The segment has no file name."; Test, Missing))).to_string_lossy()); |
| 212 | assert!(verdicts(root).contains(&name), |
| 213 | "the segment must be vouched for, or nothing is being skipped:\n{}", |
| 214 | verdicts(root)); |
| 215 | |
| 216 | res!(rot(&seg)); |
| 217 | |
| 218 | let after = res!(ore(root, &["flags"])); |
| 219 | assert!(after.ok, "the hot path does not notice: {}{}", after.out, after.err); |
| 220 | let again = res!(ore(root, &["who", "first.txt"])); |
| 221 | assert!(again.ok, "and neither does the next one: {}{}", again.out, again.err); |
| 222 | |
| 223 | // The same bytes, with nothing vouching for them. The framing digest is |
| 224 | // intact and firing; it was being skipped, and that is the whole of what |
| 225 | // changed. |
| 226 | res!(fs::remove_file(root.join(".ore").join("verified"))); |
| 227 | let said = res!(refused(&res!(ore(root, &["flags"])), "flags")); |
| 228 | assert!(said.contains("fails its integrity check"), |
| 229 | "and says what is wrong when it does look: {}", said); |
| 230 | assert!(said.contains(&name), "naming the file: {}", said); |
| 231 | Ok(()) |
| 232 | } |
| 233 | |
| 234 | /// **The sweep notices, and says something a person could act on.** |
| 235 | /// |
| 236 | /// Not only that it fails. The message has to name the segment, say what is |
| 237 | /// wrong with it in the words the reader used, say what the tool has done about |
| 238 | /// it, and say what the person is to do -- because the whole reason this exists |
| 239 | /// is that nothing else is looking, and a sweep whose output does not survive |
| 240 | /// being read once is the same as no sweep. |
| 241 | #[test] |
| 242 | fn the_sweep_finds_the_rot_and_says_what_to_do_about_it() -> Outcome<()> { |
| 243 | let scratch = res!(stocked("finds")); |
| 244 | let root = &scratch.path; |
| 245 | res!(ore(root, &["flags"])).good("flags")?; |
| 246 | let seg = res!(marked_segment(root)); |
| 247 | let name = fmt!("{}", res!(seg.file_name().ok_or_else(|| err!( |
| 248 | "The segment has no file name."; Test, Missing))).to_string_lossy()); |
| 249 | res!(rot(&seg)); |
| 250 | |
| 251 | let said = res!(refused(&res!(ore(root, &["repack", "--verify"])), "repack --verify")); |
| 252 | assert!(said.contains(&name), "the sweep names the segment: {}", said); |
| 253 | assert!(said.contains("is damaged"), "and says so plainly: {}", said); |
| 254 | assert!(said.contains("fails its integrity check"), |
| 255 | "and carries the reader's own sentence: {}", said); |
| 256 | assert!(said.contains("no verdict was left"), |
| 257 | "and says what it has done about it: {}", said); |
| 258 | assert!(said.contains("backup") || said.contains("replica"), |
| 259 | "and what the person is to do: {}", said); |
| 260 | |
| 261 | // What it did about it, on the disk: the damaged segment has no verdict, so |
| 262 | // the next command reads it the slow way and refuses. |
| 263 | assert!(!verdicts(root).contains(&name), |
| 264 | "the damaged segment keeps no verdict:\n{}", verdicts(root)); |
| 265 | let next = res!(refused(&res!(ore(root, &["flags"])), "flags")); |
| 266 | assert!(next.contains("fails its integrity check"), |
| 267 | "and the next verb refuses, naming the fault: {}", next); |
| 268 | |
| 269 | // And the record of the sweep says the same thing, for anybody who was not |
| 270 | // watching the terminal it ran in. |
| 271 | let record = res!(fs::read_to_string(root.join(".ore").join("checked"))); |
| 272 | assert!(record.contains("ORESWEEP"), "the record says what it is: {}", record); |
| 273 | assert!(record.contains(&name), "and names the damaged segment: {}", record); |
| 274 | Ok(()) |
| 275 | } |
| 276 | |
| 277 | /// **A verdict that has gone off buys nothing, and no sweep was involved.** |
| 278 | /// |
| 279 | /// This is the answer to a timer that stopped six months ago. The store here has |
| 280 | /// never been swept and never will be; the notes are simply a week and a day |
| 281 | /// old, and that is enough to put every body byte back under the hash. What a |
| 282 | /// dead sweep costs is speed, not the check. |
| 283 | #[test] |
| 284 | fn a_verdict_that_has_gone_off_puts_the_checking_back() -> Outcome<()> { |
| 285 | let scratch = res!(stocked("expiry")); |
| 286 | let root = &scratch.path; |
| 287 | res!(ore(root, &["flags"])).good("flags")?; |
| 288 | let seg = res!(marked_segment(root)); |
| 289 | res!(rot(&seg)); |
| 290 | // Fresh notes, so the damage is invisible -- the state the previous test |
| 291 | // leaves off at, restated here so this one does not depend on it. |
| 292 | let hidden = res!(ore(root, &["flags"])); |
| 293 | assert!(hidden.ok, "while the notes are fresh: {}{}", hidden.out, hidden.err); |
| 294 | |
| 295 | res!(age_verdicts(root, 8)); |
| 296 | let said = res!(refused(&res!(ore(root, &["flags"])), "flags")); |
| 297 | assert!(said.contains("fails its integrity check"), |
| 298 | "a week later the same command reads the same bytes and refuses: {}", said); |
| 299 | Ok(()) |
| 300 | } |
| 301 | |
| 302 | /// A sound store sweeps clean, says how much it read, and leaves fresh notes. |
| 303 | /// |
| 304 | /// The dates moving is the assertion that matters: a sweep that reported success |
| 305 | /// without refreshing anything would leave every verdict to go off on its old |
| 306 | /// date, and the store would be re-read by whichever command came next -- which |
| 307 | /// is safe, and is also the sweep having done nothing at all. |
| 308 | #[test] |
| 309 | fn a_sound_store_sweeps_clean_and_the_notes_move_forward() -> Outcome<()> { |
| 310 | let scratch = res!(stocked("clean")); |
| 311 | let root = &scratch.path; |
| 312 | res!(ore(root, &["flags"])).good("flags")?; |
| 313 | res!(age_verdicts(root, 3)); |
| 314 | let before = verdicts(root); |
| 315 | |
| 316 | let ran = res!(ore(root, &["repack", "--verify"])); |
| 317 | let said = ran.good("repack --verify")?; |
| 318 | assert!(said.contains("every record holds its digest"), |
| 319 | "the sweep says what it checked: {}", said); |
| 320 | assert!(said.contains("every signature verified"), |
| 321 | "signatures included: {}", said); |
| 322 | assert!(said.contains("segments read"), "and how much: {}", said); |
| 323 | |
| 324 | let after = verdicts(root); |
| 325 | assert_ne!(after, before, "the notes must be rewritten, not left where they were"); |
| 326 | let dates = |text: &str| -> Vec<u128> { |
| 327 | text.lines().skip(1) |
| 328 | .filter_map(|l| l.split_whitespace().nth(4)) |
| 329 | .filter_map(|f| f.parse::<u128>().ok()) |
| 330 | .collect() |
| 331 | }; |
| 332 | let (was, now) = (dates(&before), dates(&after)); |
| 333 | assert!(!now.is_empty(), "there are notes to have moved:\n{}", after); |
| 334 | assert_eq!(now.len(), was.len(), "and the same segments are vouched for"); |
| 335 | for (i, (old, new)) in was.iter().zip(now.iter()).enumerate() { |
| 336 | assert!(new > old, "note {} moved forward: {} to {}", i, old, new); |
| 337 | } |
| 338 | |
| 339 | // A repack --verify moves no byte of the log, which is the difference between |
| 340 | // it and the repack it shares a word with. |
| 341 | let paths = res!(segments(root)); |
| 342 | assert!(paths.len() > 1, "the fixture has segments to leave alone"); |
| 343 | Ok(()) |
| 344 | } |
| 345 | |
| 346 | /// `ore flags` says how long it has been since anything read the whole log, and |
| 347 | /// stops saying it once something has. |
| 348 | /// |
| 349 | /// This is the only place the tool volunteers it. Every other verb is silent, |
| 350 | /// because `ore mark` runs on every commit in every tree on this machine and a |
| 351 | /// line printed that often is a line nobody reads. |
| 352 | #[test] |
| 353 | fn flags_says_when_the_whole_log_was_last_checked() -> Outcome<()> { |
| 354 | let scratch = res!(stocked("unswept")); |
| 355 | let root = &scratch.path; |
| 356 | let first = res!(ore(root, &["flags"])); |
| 357 | let said = first.good("flags")?; |
| 358 | assert!(said.contains("nothing has ever read this whole log"), |
| 359 | "an unswept store says so: {}", said); |
| 360 | assert!(said.contains("ore repack --verify"), "and names the command: {}", said); |
| 361 | |
| 362 | res!(ore(root, &["repack", "--verify"])).good("repack --verify")?; |
| 363 | let after = res!(ore(root, &["flags"])); |
| 364 | let quiet = after.good("flags")?; |
| 365 | assert!(!quiet.contains("read this whole log"), |
| 366 | "and a swept one says nothing: {}", quiet); |
| 367 | Ok(()) |
| 368 | } |