This repository has no description
1use std::collections::HashSet;
2use std::path::{Path, PathBuf};
3use std::process::{Command, Stdio};
4use std::time::{Duration, Instant, SystemTime};
5
6use knot_cob::{CobHome, CobStore};
7use knot_cobs::{Registration, RegistryChange, RepoRef, RepoRegistryCob, deregister_repo};
8use knot_git::{Layout, Repo};
9use knot_lfs::{ClaimedSize, DiskStore, LfsOid, LfsStore, LfsStorePath};
10use knot_runtime::OsEntropy;
11use knot_secrets::{MasterKey, SealedStore};
12use knot_types::{KnotId, OwnerDid, RepoDid, RepoName, RepoRkey, UnixSeconds};
13use sha2::{Digest, Sha256};
14
15const REPO_DID: &str = "did:plc:squid";
16const REPO_NAME: &str = "anemone";
17const OWNER_DID: &str = "did:plc:nel";
18const KNOT_DID: &str = "did:web:nel.pet";
19const MEDIA: &[u8] = b"\xff\x00media that mustn't outlive its repo";
20const GRACE: Duration = Duration::from_secs(86_400);
21const BACKDATE: Duration = Duration::from_secs(60 * 86_400);
22
23fn did() -> RepoDid {
24 RepoDid::new(REPO_DID).unwrap()
25}
26
27fn knot() -> KnotId {
28 KnotId::new(KNOT_DID).unwrap()
29}
30
31fn master() -> MasterKey {
32 MasterKey::new([7u8; 32]).unwrap()
33}
34
35fn media_oid() -> LfsOid {
36 LfsOid::from_digest(Sha256::digest(MEDIA).into())
37}
38
39struct Paths {
40 meta: PathBuf,
41 scan: PathBuf,
42 store: PathBuf,
43 keys: PathBuf,
44}
45
46impl Paths {
47 fn under(root: &Path) -> Self {
48 Self {
49 meta: root.join("meta"),
50 scan: root.join("repos"),
51 store: root.join("lfs"),
52 keys: root.join("keys.sealed"),
53 }
54 }
55}
56
57fn build_fixture(root: &Path) -> Paths {
58 let paths = Paths::under(root);
59 Repo::create(&paths.meta).unwrap();
60 Layout::new(&paths.scan).create(&did()).unwrap();
61
62 let secrets = SealedStore::open(&paths.keys, &master(), Box::new(OsEntropy)).unwrap();
63 secrets.ensure(&knot()).unwrap();
64 let signer = secrets.signer(&knot()).unwrap();
65 let meta = Repo::open(&paths.meta).unwrap();
66 CobStore::new(&meta)
67 .create(
68 &CobHome::from(&knot()),
69 &RegistryChange::Register(Registration {
70 owner: OwnerDid::new(OWNER_DID).unwrap(),
71 rkey: RepoRkey::new(REPO_NAME).unwrap(),
72 name: RepoName::new(REPO_NAME).unwrap(),
73 repo: did(),
74 created_at: UnixSeconds::new(1),
75 }),
76 &signer,
77 UnixSeconds::new(1),
78 )
79 .unwrap();
80
81 std::fs::create_dir_all(&paths.store).unwrap();
82 let store = DiskStore::open(LfsStorePath::new(&paths.store)).unwrap();
83 store
84 .put(
85 &did(),
86 &media_oid(),
87 ClaimedSize::new(MEDIA.len() as u64),
88 &mut &MEDIA[..],
89 )
90 .unwrap();
91 let object_path = store.object_file(&did(), &media_oid()).unwrap().unwrap().1;
92 std::fs::OpenOptions::new()
93 .write(true)
94 .open(object_path)
95 .unwrap()
96 .set_modified(SystemTime::now() - BACKDATE)
97 .unwrap();
98 paths
99}
100
101fn spawn_worker(paths: &Paths) -> std::process::Child {
102 Command::new(std::env::current_exe().unwrap())
103 .args(["--exact", "chaos_delete_worker", "--nocapture"])
104 .env("KNOT_CHAOS_ROLE", "delete")
105 .env("KNOT_CHAOS_META", &paths.meta)
106 .env("KNOT_CHAOS_SCAN", &paths.scan)
107 .env("KNOT_CHAOS_STORE", &paths.store)
108 .env("KNOT_CHAOS_KEYS", &paths.keys)
109 .stdout(Stdio::null())
110 .stderr(Stdio::null())
111 .spawn()
112 .expect("spawn chaos worker")
113}
114
115#[test]
116fn chaos_delete_worker() {
117 if std::env::var("KNOT_CHAOS_ROLE").as_deref() != Ok("delete") {
118 return;
119 }
120 let meta = Repo::open(std::env::var("KNOT_CHAOS_META").unwrap()).unwrap();
121 let store = CobStore::new(&meta);
122 let secrets = SealedStore::open(
123 std::env::var("KNOT_CHAOS_KEYS").unwrap(),
124 &master(),
125 Box::new(OsEntropy),
126 )
127 .unwrap();
128 let signer = secrets.signer(&knot()).unwrap();
129 let object = store.list::<RepoRegistryCob>().unwrap()[0];
130 deregister_repo(
131 &store,
132 &CobHome::from(&knot()),
133 object,
134 RepoRef {
135 owner: OwnerDid::new(OWNER_DID).unwrap(),
136 rkey: RepoRkey::new(REPO_NAME).unwrap(),
137 },
138 did(),
139 &signer,
140 UnixSeconds::new(2),
141 )
142 .unwrap();
143 Layout::new(std::env::var("KNOT_CHAOS_SCAN").unwrap())
144 .remove(&did())
145 .unwrap();
146 DiskStore::open(LfsStorePath::new(
147 std::env::var("KNOT_CHAOS_STORE").unwrap(),
148 ))
149 .unwrap()
150 .remove_repo(&did())
151 .unwrap();
152}
153
154#[test]
155fn kill9_between_delete_steps_never_strands_the_store_prefix() {
156 let scratch = tempfile::tempdir().unwrap();
157
158 let warm = build_fixture(&scratch.path().join("warm"));
159 let started = Instant::now();
160 spawn_worker(&warm).wait().unwrap();
161 let full = started.elapsed();
162 let warm_store = DiskStore::open(LfsStorePath::new(&warm.store)).unwrap();
163 assert_eq!(
164 warm_store.probe(&did(), &media_oid()).unwrap(),
165 None,
166 "an uninterrupted delete removes the store prefix itself"
167 );
168
169 let fractions = [0.20, 0.35, 0.45, 0.55, 0.65, 0.75, 0.85, 0.95];
170 let delays: Vec<Duration> = std::iter::once(Duration::from_millis(1))
171 .chain(std::iter::once(Duration::from_millis(3)))
172 .chain(fractions.iter().map(|fraction| full.mul_f64(*fraction)))
173 .chain(std::iter::once(full.mul_f64(2.0)))
174 .collect();
175
176 let outcomes: Vec<bool> = delays
177 .iter()
178 .enumerate()
179 .map(|(trial, delay)| {
180 let paths = build_fixture(&scratch.path().join(format!("trial-{trial}")));
181 let mut child = spawn_worker(&paths);
182 std::thread::sleep(*delay);
183 let _ = child.kill();
184 child.wait().unwrap();
185
186 let index = knot_index::Index::new(paths.meta.clone(), Layout::new(&paths.scan));
187 index.rebuild().unwrap_or_else(|error| {
188 panic!("trial {trial}: registry must rebuild after a killed delete: {error}")
189 });
190 assert_eq!(
191 index.coverage().registry,
192 knot_index::Coverage::Ready,
193 "trial {trial}: a rebuilt projection is ready"
194 );
195 let hosted: HashSet<RepoDid> = index.hosted_repos().into_iter().collect();
196 let registered = hosted.contains(&did());
197
198 let store = DiskStore::open(LfsStorePath::new(&paths.store)).unwrap();
199 store
200 .sweep_orphans(&hosted, GRACE, SystemTime::now())
201 .unwrap_or_else(|error| {
202 panic!("trial {trial}: orphan sweep must run after a killed delete: {error}")
203 });
204 let present = store.probe(&did(), &media_oid()).unwrap().is_some();
205 match registered {
206 true => assert!(
207 present,
208 "trial {trial}: a still-registered repo's store prefix is never condemned"
209 ),
210 false => assert!(
211 !present,
212 "trial {trial}: the orphan sweep reclaims the prefix the killed delete left behind"
213 ),
214 }
215 registered
216 })
217 .collect();
218
219 assert!(
220 outcomes.iter().any(|registered| *registered),
221 "some trial must die before the deregister lands, or the chaos window never opened"
222 );
223 assert!(
224 outcomes.iter().any(|registered| !registered),
225 "some trial must land the deregister, or the kill delays are all too short"
226 );
227}