Skip to main content

haematite_cli/serve/
registry.rs

1//! Atomic ephemeral records for served stores.
2
3use std::fmt;
4use std::fs::{self, File, Metadata, OpenOptions};
5use std::io::{self, Read, Write};
6use std::num::NonZeroU32;
7use std::os::unix::fs::{MetadataExt, OpenOptionsExt, PermissionsExt};
8use std::os::unix::net::SocketAddr;
9use std::path::{Component, Path, PathBuf};
10use std::sync::atomic::{AtomicU64, Ordering};
11
12use serde::{Deserialize, Serialize};
13use sha2::{Digest, Sha256};
14
15use super::runtime::{self, Endpoint};
16
17const MOST_BYTES: u64 = 65_536;
18static TEMPORARIES: AtomicU64 = AtomicU64::new(0);
19
20#[derive(Debug)]
21pub struct Refusal {
22    pub code: &'static str,
23    detail: String,
24}
25
26impl Refusal {
27    pub(super) fn new(code: &'static str, detail: impl fmt::Display) -> Self {
28        Self {
29            code,
30            detail: detail.to_string(),
31        }
32    }
33}
34
35impl fmt::Display for Refusal {
36    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
37        write!(formatter, "{}: {}", self.code, self.detail)
38    }
39}
40
41impl std::error::Error for Refusal {}
42
43#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
44#[serde(deny_unknown_fields)]
45pub struct Entry {
46    pub name: String,
47    pub socket: PathBuf,
48    pub data_dir: PathBuf,
49    pub pid: NonZeroU32,
50    pub version: String,
51}
52
53impl Entry {
54    /// The current process and its resolved endpoint as one registry record.
55    ///
56    /// # Errors
57    /// Returns a named publication refusal for an invalid endpoint or data location.
58    pub fn current(endpoint: &Endpoint) -> Result<Self, Refusal> {
59        let data_dir = fs::canonicalize(&endpoint.data_dir)
60            .map_err(|error| Refusal::new("registry_publication_failed", error))?;
61        if !fs::metadata(&data_dir)
62            .map_err(|error| Refusal::new("registry_publication_failed", error))?
63            .is_dir()
64        {
65            return Err(Refusal::new(
66                "registry_publication_failed",
67                "data location is not a directory",
68            ));
69        }
70        let pid = NonZeroU32::new(std::process::id())
71            .ok_or_else(|| Refusal::new("registry_publication_failed", "process id is zero"))?;
72        let entry = Self {
73            name: endpoint.name.clone(),
74            socket: endpoint.socket.clone(),
75            data_dir,
76            pid,
77            version: env!("CARGO_PKG_VERSION").to_owned(),
78        };
79        entry
80            .validate()
81            .map_err(|error| Refusal::new("registry_publication_failed", error))?;
82        Ok(entry)
83    }
84
85    fn validate(&self) -> Result<(), &'static str> {
86        if self.name.is_empty()
87            || self.name.len() > 128
88            || matches!(self.name.as_str(), "." | "..")
89            || self.name.contains(['/', '\\', '\0'])
90        {
91            return Err("invalid logical store name");
92        }
93        if !valid_path(&self.socket) || SocketAddr::from_pathname(&self.socket).is_err() {
94            return Err("invalid Unix endpoint");
95        }
96        if !valid_path(&self.data_dir)
97            || self.data_dir.file_name().is_none()
98            || self
99                .data_dir
100                .components()
101                .any(|part| !matches!(part, Component::RootDir | Component::Normal(_)))
102        {
103            return Err("invalid canonical data directory");
104        }
105        if self.version.is_empty()
106            || self.version.len() > 128
107            || !self
108                .version
109                .bytes()
110                .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'-' | b'+'))
111        {
112            return Err("invalid haematite version");
113        }
114        Ok(())
115    }
116}
117
118fn valid_path(path: &Path) -> bool {
119    path.is_absolute() && path.to_str().is_some_and(|value| !value.contains('\0'))
120}
121
122fn entry_path(directory: &Path, name: &str) -> PathBuf {
123    const HEX: &[u8; 16] = b"0123456789abcdef";
124    let digest = Sha256::digest(name.as_bytes());
125    let mut filename = String::with_capacity(69);
126    for byte in digest {
127        filename.push(char::from(HEX[usize::from(byte >> 4)]));
128        filename.push(char::from(HEX[usize::from(byte & 15)]));
129    }
130    filename.push_str(".json");
131    directory.join(filename)
132}
133
134fn private_directory(directory: &Path) -> io::Result<Metadata> {
135    let metadata = fs::symlink_metadata(directory)?;
136    if !directory.is_absolute()
137        || !metadata.is_dir()
138        || metadata.permissions().mode() & 0o7777 != 0o700
139    {
140        return Err(io::Error::new(
141            io::ErrorKind::PermissionDenied,
142            "registry directory must be a non-symlink absolute mode-0700 directory",
143        ));
144    }
145    Ok(metadata)
146}
147
148/// Reads one complete record, validating its fields, filename, ownership and mode.
149///
150/// # Errors
151/// Returns `invalid_registry_entry` identifying the file for every read or validation failure.
152pub fn read(path: &Path) -> Result<Entry, Refusal> {
153    let refused = |error: &dyn fmt::Display| {
154        Refusal::new(
155            "invalid_registry_entry",
156            format!("{}: {error}", path.display()),
157        )
158    };
159    let parent = path
160        .parent()
161        .ok_or_else(|| refused(&"entry has no parent"))?;
162    let directory = private_directory(parent).map_err(|error| refused(&error))?;
163    let named = fs::symlink_metadata(path).map_err(|error| refused(&error))?;
164    if !named.is_file() {
165        return Err(refused(&"entry is not a regular file"));
166    }
167    let mut file = File::open(path).map_err(|error| refused(&error))?;
168    let metadata = file.metadata().map_err(|error| refused(&error))?;
169    if !metadata.is_file()
170        || metadata.uid() != directory.uid()
171        || metadata.permissions().mode() & 0o7777 != 0o600
172        || metadata.len() > MOST_BYTES
173    {
174        return Err(refused(
175            &"entry must be an owned mode-0600 regular file within the size bound",
176        ));
177    }
178    let mut bytes = Vec::new();
179    Read::by_ref(&mut file)
180        .take(MOST_BYTES + 1)
181        .read_to_end(&mut bytes)
182        .map_err(|error| refused(&error))?;
183    if u64::try_from(bytes.len()).map_err(|error| refused(&error))? > MOST_BYTES {
184        return Err(refused(&"entry exceeds the size bound"));
185    }
186    let entry: Entry = serde_json::from_slice(&bytes).map_err(|error| refused(&error))?;
187    entry.validate().map_err(|error| refused(&error))?;
188    if path != entry_path(parent, &entry.name) {
189        return Err(refused(&"entry name does not match its filename"));
190    }
191    Ok(entry)
192}
193
194/// Reads the namespace without creating it or interpreting a refusal as an empty list.
195///
196/// # Errors
197/// Returns `registry_unavailable` for directory failures and the named entry refusal for invalid records.
198pub fn read_directory(directory: &Path) -> Result<Vec<Entry>, Refusal> {
199    private_directory(directory).map_err(|error| {
200        Refusal::new(
201            "registry_unavailable",
202            format!("{}: {error}", directory.display()),
203        )
204    })?;
205    let entries = fs::read_dir(directory).map_err(|error| {
206        Refusal::new(
207            "registry_unavailable",
208            format!("{}: {error}", directory.display()),
209        )
210    })?;
211    let mut records = Vec::new();
212    for entry in entries {
213        let entry = entry.map_err(|error| Refusal::new("registry_unavailable", error))?;
214        let path = entry.path();
215        if path
216            .extension()
217            .is_some_and(|extension| extension == "json")
218        {
219            records.push(read(&path)?);
220        }
221    }
222    records.sort_by(|left, right| left.name.cmp(&right.name));
223    Ok(records)
224}
225
226struct Prepared {
227    temporary: Option<PathBuf>,
228    destination: PathBuf,
229    identity: (u64, u64),
230    entry: Entry,
231}
232
233impl Prepared {
234    fn new(directory: &Path, entry: Entry) -> Result<Self, Refusal> {
235        let error = |cause: &dyn fmt::Display| {
236            Refusal::new(
237                "registry_publication_failed",
238                format!("{}: {cause}", directory.display()),
239            )
240        };
241        entry.validate().map_err(|cause| error(&cause))?;
242        let owner = private_directory(directory)
243            .map_err(|cause| error(&cause))?
244            .uid();
245        let bytes = serde_json::to_vec(&entry).map_err(|cause| error(&cause))?;
246        if u64::try_from(bytes.len()).map_err(|cause| error(&cause))? > MOST_BYTES {
247            return Err(error(&"entry exceeds size bound"));
248        }
249        let sequence = TEMPORARIES
250            .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |value| {
251                value.checked_add(1)
252            })
253            .map_err(|cause| error(&cause))?;
254        let temporary = directory.join(format!(".entry-{}-{sequence}", std::process::id()));
255        let mut file = OpenOptions::new()
256            .write(true)
257            .create_new(true)
258            .mode(0o600)
259            .open(&temporary)
260            .map_err(|cause| error(&cause))?;
261        let metadata = match file.metadata() {
262            Ok(metadata) => metadata,
263            Err(cause) => {
264                drop(file);
265                return Err(match fs::remove_file(&temporary) {
266                    Ok(()) => error(&cause),
267                    Err(cleanup) => error(&format!(
268                        "metadata: {cause}; registry_cleanup_failed: {cleanup}"
269                    )),
270                });
271            }
272        };
273        let mut prepared = Self {
274            temporary: Some(temporary),
275            destination: entry_path(directory, &entry.name),
276            identity: (metadata.dev(), metadata.ino()),
277            entry,
278        };
279        let written = if metadata.uid() == owner
280            && metadata.is_file()
281            && metadata.permissions().mode() & 0o7777 == 0o600
282        {
283            file.write_all(&bytes)
284        } else {
285            Err(io::Error::new(
286                io::ErrorKind::PermissionDenied,
287                "temporary entry ownership or mode is invalid",
288            ))
289        };
290        drop(file);
291        if let Err(cause) = written {
292            return Err(match prepared.clean_temporary() {
293                Ok(()) => error(&cause),
294                Err(cleanup) => error(&format!("write: {cause}; {cleanup}")),
295            });
296        }
297        Ok(prepared)
298    }
299
300    fn clean_temporary(&mut self) -> Result<(), Refusal> {
301        if let Some(path) = self.temporary.take() {
302            remove_same(&path, self.identity)?;
303        }
304        Ok(())
305    }
306
307    fn publish(mut self) -> Result<Publication, Refusal> {
308        let temporary = self.temporary.as_ref().ok_or_else(|| {
309            Refusal::new("registry_publication_failed", "temporary entry is absent")
310        })?;
311        if let Err(cause) = fs::rename(temporary, &self.destination) {
312            return Err(match self.clean_temporary() {
313                Ok(()) => Refusal::new("registry_publication_failed", cause),
314                Err(cleanup) => Refusal::new(
315                    "registry_publication_failed",
316                    format!("rename: {cause}; {cleanup}"),
317                ),
318            });
319        }
320        self.temporary.take();
321        // Cleanup retains the contents and inode to preserve a replacement record.
322        Ok(Publication {
323            path: self.destination.clone(),
324            identity: self.identity,
325            entry: self.entry.clone(),
326            active: true,
327        })
328    }
329}
330
331impl Drop for Prepared {
332    fn drop(&mut self) {
333        if let Err(error) = self.clean_temporary() {
334            eprintln!("{error}");
335        }
336    }
337}
338
339fn remove_same(path: &Path, identity: (u64, u64)) -> Result<(), Refusal> {
340    let refused = |cause: &dyn fmt::Display| {
341        Refusal::new(
342            "registry_cleanup_failed",
343            format!("{}: {cause}", path.display()),
344        )
345    };
346    let metadata = fs::symlink_metadata(path).map_err(|cause| refused(&cause))?;
347    if !metadata.is_file() || (metadata.dev(), metadata.ino()) != identity {
348        return Err(refused(&"owned entry was replaced"));
349    }
350    fs::remove_file(path).map_err(|cause| refused(&cause))
351}
352
353pub(super) struct Publication {
354    path: PathBuf,
355    identity: (u64, u64),
356    entry: Entry,
357    active: bool,
358}
359
360impl Publication {
361    pub(super) fn publish(endpoint: &Endpoint) -> Result<Self, Refusal> {
362        let namespace = namespace(endpoint)
363            .map_err(|cause| Refusal::new("registry_publication_failed", cause))?;
364        let directory = namespace.socket.parent().ok_or_else(|| {
365            Refusal::new(
366                "registry_publication_failed",
367                "runtime endpoint has no parent",
368            )
369        })?;
370        Self::publish_in(directory, Entry::current(endpoint)?)
371    }
372
373    pub(super) fn publish_in(directory: &Path, entry: Entry) -> Result<Self, Refusal> {
374        Prepared::new(directory, entry)?.publish()
375    }
376
377    pub(super) fn finish(&mut self) -> Result<(), Refusal> {
378        if !self.active {
379            return Ok(());
380        }
381        self.active = false;
382        let actual =
383            read(&self.path).map_err(|cause| Refusal::new("registry_cleanup_failed", cause))?;
384        if actual != self.entry {
385            return Err(Refusal::new(
386                "registry_cleanup_failed",
387                "owned entry contents changed",
388            ));
389        }
390        remove_same(&self.path, self.identity)
391    }
392}
393
394impl Drop for Publication {
395    fn drop(&mut self) {
396        if let Err(error) = self.finish() {
397            eprintln!("{error}");
398        }
399    }
400}
401
402fn namespace(endpoint: &Endpoint) -> Result<Endpoint, runtime::Refusal> {
403    #[cfg(test)]
404    if let Some((root, uid)) = super::cli::endpoint_tests::input() {
405        return runtime::resolve_input(
406            &endpoint.data_dir,
407            Some(&endpoint.name),
408            None,
409            false,
410            &root,
411            uid,
412        );
413    }
414    runtime::resolve(&endpoint.data_dir, Some(&endpoint.name), None, false)
415}
416
417#[cfg(test)]
418mod tests {
419    use std::error::Error;
420    use std::fs;
421    use std::io::{self, BufRead, Read, Write};
422    use std::os::unix::fs::PermissionsExt;
423    use std::path::Path;
424    use std::process::{Child, ChildStdout, Command, Stdio};
425
426    use super::{Entry, Prepared, Publication, entry_path, read, read_directory};
427    use crate::serve::cli::endpoint_tests::{Fixture, with_fixture};
428    use crate::serve::runtime::Endpoint;
429    use crate::serve::transport::Listener;
430
431    type TestResult = Result<(), Box<dyn Error>>;
432
433    fn endpoint(root: &Path, name: &str) -> Endpoint {
434        Endpoint {
435            name: name.to_owned(),
436            socket: root.join(format!("{name}.sock")),
437            data_dir: root.join("database"),
438        }
439    }
440
441    fn directory(fixture: &Fixture) -> Result<std::path::PathBuf, io::Error> {
442        let directory = fixture.directory.path().join("haem");
443        fs::create_dir(&directory)?;
444        fs::set_permissions(&directory, fs::Permissions::from_mode(0o700))?;
445        Ok(directory)
446    }
447
448    #[test]
449    fn publication_precedes_readiness_and_contains_the_five_fields() -> TestResult {
450        with_fixture(|fixture| {
451            let directory = directory(fixture)?;
452            let endpoint = endpoint(fixture.directory.path(), "ready");
453            let mut listener = Listener::bind(&endpoint.socket)?;
454            listener.publish_in(&directory, Entry::current(&endpoint)?)?;
455            let entry = read(&entry_path(&directory, &endpoint.name))?;
456            assert_eq!(entry.name, endpoint.name);
457            assert_eq!(entry.socket, endpoint.socket);
458            assert_eq!(entry.data_dir, fs::canonicalize(&fixture.data_dir)?);
459            assert_eq!(entry.pid.get(), std::process::id());
460            assert_eq!(entry.version, env!("CARGO_PKG_VERSION"));
461            let path = entry_path(&directory, &endpoint.name);
462            let value: serde_json::Value = serde_json::from_slice(&fs::read(&path)?)?;
463            assert_eq!(value.as_object().ok_or("entry is not an object")?.len(), 5);
464            assert_eq!(fs::metadata(&path)?.permissions().mode() & 0o7777, 0o600);
465            listener.finish()?;
466            assert!(!path.try_exists()?);
467            assert!(Listener::bind(&endpoint.socket).is_err());
468            drop(listener);
469            drop(Listener::bind(&endpoint.socket)?);
470            Ok(())
471        })
472    }
473
474    #[test]
475    fn a_reader_sees_the_complete_prior_entry_until_atomic_rename() -> TestResult {
476        with_fixture(|fixture| {
477            let directory = directory(fixture)?;
478            let endpoint = endpoint(fixture.directory.path(), "atomic");
479            let prior = Entry::current(&endpoint)?;
480            let path = entry_path(&directory, &prior.name);
481            fs::write(&path, serde_json::to_vec(&prior)?)?;
482            fs::set_permissions(&path, fs::Permissions::from_mode(0o600))?;
483            let mut replacement = prior.clone();
484            replacement.version = "0.1.1".to_owned();
485            let pending = Prepared::new(&directory, replacement.clone())?;
486            assert_eq!(read(&path)?, prior);
487            let mut publication = pending.publish()?;
488            assert_eq!(read(&path)?, replacement);
489            assert_eq!(read_directory(&directory)?, vec![replacement]);
490            publication.finish()?;
491            Ok(())
492        })
493    }
494
495    #[test]
496    fn clean_exit_removes_only_our_entry_and_kill_leaves_it_complete() -> TestResult {
497        with_fixture(|fixture| {
498            let directory = directory(fixture)?;
499            let other = Entry::current(&endpoint(fixture.directory.path(), "other"))?;
500            let mut other_owner = Publication::publish_in(&directory, other.clone())?;
501            let child_path = entry_path(&directory, "child");
502            let child_socket = fixture.directory.path().join("child.sock");
503            let mut clean = Process::start(fixture.directory.path())?;
504            let clean_entry = read(&child_path)?;
505            assert_eq!(clean_entry.pid.get(), clean.pid()?);
506            assert!(Listener::bind(&child_socket).is_err());
507            clean.finish()?;
508            assert!(!child_path.try_exists()?);
509            assert_eq!(read(&entry_path(&directory, "other"))?, other);
510            drop(Listener::bind(&child_socket)?);
511            let mut killed = Process::start(fixture.directory.path())?;
512            let last = read(&child_path)?;
513            killed.kill()?;
514            assert_eq!(read(&child_path)?, last);
515            drop(Listener::bind(&child_socket)?);
516            assert_eq!(read(&entry_path(&directory, "other"))?, other);
517            other_owner.finish()?;
518            Ok(())
519        })
520    }
521
522    #[test]
523    fn publication_and_cleanup_failures_are_named_and_preserve_replacements() -> TestResult {
524        with_fixture(|fixture| {
525            let directory = directory(fixture)?;
526            let endpoint = endpoint(fixture.directory.path(), "failure");
527            let entry = Entry::current(&endpoint)?;
528            let path = entry_path(&directory, &entry.name);
529            fs::create_dir(&path)?;
530            let mut listener = Listener::bind(&endpoint.socket)?;
531            let mut ready = false;
532            let result = listener.publish_in(&directory, entry.clone()).map(|()| {
533                ready = true;
534            });
535            let refused = result.err().ok_or("publication unexpectedly succeeded")?;
536            assert_eq!(refused.code, "registry_publication_failed");
537            assert!(!ready);
538            assert!(path.is_dir());
539            assert_eq!(fs::read_dir(&directory)?.count(), 1);
540            fs::remove_dir(&path)?;
541            listener.publish_in(&directory, entry)?;
542            let saved = directory.join("saved");
543            fs::rename(&path, &saved)?;
544            fs::create_dir(&path)?;
545            let refused = listener.finish().err().ok_or("replacement was removed")?;
546            assert_eq!(refused.code, "registry_cleanup_failed");
547            assert!(path.is_dir());
548            assert!(saved.try_exists()?);
549            fs::remove_dir(&path)?;
550            fs::rename(&saved, &path)?;
551            fs::remove_file(&path)?;
552            Ok(())
553        })
554    }
555
556    #[test]
557    fn malformed_entries_are_refused_at_the_reader_boundary() -> TestResult {
558        with_fixture(|fixture| {
559            let directory = directory(fixture)?;
560            let entry = Entry::current(&endpoint(fixture.directory.path(), "validate"))?;
561            let path = entry_path(&directory, &entry.name);
562            let valid = serde_json::to_value(entry)?;
563            for (field, replacement) in [
564                ("name", serde_json::json!("../escape")),
565                ("name", serde_json::json!("another")),
566                ("socket", serde_json::json!("relative.sock")),
567                ("data_dir", serde_json::json!("relative")),
568                ("pid", serde_json::json!(0)),
569                ("pid", serde_json::json!("1")),
570                ("version", serde_json::json!("")),
571                ("extra", serde_json::json!(true)),
572            ] {
573                let mut value = valid.clone();
574                value[field] = replacement;
575                fs::write(&path, serde_json::to_vec(&value)?)?;
576                fs::set_permissions(&path, fs::Permissions::from_mode(0o600))?;
577                let refused = read(&path).err().ok_or("invalid entry was accepted")?;
578                assert_eq!(refused.code, "invalid_registry_entry");
579                assert!(refused.to_string().contains(&path.display().to_string()));
580            }
581            fs::write(&path, b"{")?;
582            assert_eq!(
583                read(&path).err().ok_or("partial entry was accepted")?.code,
584                "invalid_registry_entry"
585            );
586            Ok(())
587        })
588    }
589
590    #[test]
591    fn missing_namespace_is_named_and_the_next_publication_recreates_it() -> TestResult {
592        with_fixture(|fixture| {
593            let directory = fixture.directory.path().join("haem");
594            assert_eq!(
595                read_directory(&directory)
596                    .err()
597                    .ok_or("missing namespace was accepted")?
598                    .code,
599                "registry_unavailable"
600            );
601            let endpoint = endpoint(fixture.directory.path(), "recreate");
602            let mut listener = Listener::bind(&endpoint.socket)?;
603            listener.publish(&endpoint)?;
604            assert_eq!(read_directory(&directory)?.len(), 1);
605            listener.finish()?;
606            Ok(())
607        })
608    }
609
610    #[test]
611    fn explicit_socket_cannot_skip_a_refused_namespace_lookup() -> TestResult {
612        with_fixture(|fixture| {
613            let endpoint = endpoint(fixture.directory.path(), "explicit");
614            let mut listener = Listener::bind(&endpoint.socket)?;
615            fs::set_permissions(fixture.directory.path(), fs::Permissions::from_mode(0o500))?;
616            let result = listener.publish(&endpoint);
617            fs::set_permissions(fixture.directory.path(), fs::Permissions::from_mode(0o700))?;
618            let refused = result.err().ok_or("invalid namespace was accepted")?;
619            assert_eq!(refused.code, "registry_publication_failed");
620            assert!(refused.to_string().contains("invalid_runtime_location"));
621            assert!(!fixture.directory.path().join("haem").try_exists()?);
622            Ok(())
623        })
624    }
625
626    #[test]
627    fn registry_child() -> TestResult {
628        let Some(root) = std::env::var_os("HAEM_REGISTRY_TEST_CHILD") else {
629            return Ok(());
630        };
631        let root = Path::new(&root);
632        let endpoint = endpoint(root, "child");
633        let mut listener = Listener::bind(&endpoint.socket)?;
634        listener.publish_in(&root.join("haem"), Entry::current(&endpoint)?)?;
635        println!("haematite-registry-ready");
636        io::stdout().flush()?;
637        let mut command = String::new();
638        io::stdin().read_line(&mut command)?;
639        if command != "finish\n" {
640            return Err("child did not receive its finish signal".into());
641        }
642        listener.finish()?;
643        Ok(())
644    }
645
646    struct Process {
647        child: Option<Child>,
648        output: io::BufReader<ChildStdout>,
649    }
650
651    impl Process {
652        fn start(root: &Path) -> Result<Self, Box<dyn Error>> {
653            let mut child = Command::new(std::env::current_exe()?)
654                .args([
655                    "--exact",
656                    "serve::registry::tests::registry_child",
657                    "--nocapture",
658                ])
659                .env("HAEM_REGISTRY_TEST_CHILD", root)
660                .stdin(Stdio::piped())
661                .stdout(Stdio::piped())
662                .stderr(Stdio::piped())
663                .spawn()?;
664            let Some(output) = child.stdout.take() else {
665                let kill = child.kill();
666                let wait = child.wait();
667                return Err(format!(
668                    "child stdout is absent; cleanup kill: {kill:?}; cleanup wait: {wait:?}"
669                )
670                .into());
671            };
672            let mut process = Self {
673                child: Some(child),
674                output: io::BufReader::new(output),
675            };
676            loop {
677                let mut line = String::new();
678                match process.output.read_line(&mut line) {
679                    Ok(0) => {
680                        return Err(match process.wait(true) {
681                            Ok(()) => "child exited before readiness".into(),
682                            Err(error) => format!("child exited before readiness: {error}").into(),
683                        });
684                    }
685                    Ok(_) if line.contains("haematite-registry-ready") => return Ok(process),
686                    Ok(_) => {}
687                    Err(error) => return Err(format!("read child readiness: {error}").into()),
688                }
689            }
690        }
691
692        fn pid(&self) -> Result<u32, Box<dyn Error>> {
693            Ok(self.child.as_ref().ok_or("child already exited")?.id())
694        }
695
696        fn finish(&mut self) -> TestResult {
697            let child = self.child.as_mut().ok_or("child already exited")?;
698            child
699                .stdin
700                .take()
701                .ok_or("child stdin is absent")?
702                .write_all(b"finish\n")?;
703            self.wait(true)
704        }
705
706        fn kill(&mut self) -> TestResult {
707            self.child.as_mut().ok_or("child already exited")?.kill()?;
708            self.wait(false)
709        }
710
711        fn wait(&mut self, clean: bool) -> TestResult {
712            let child = self.child.as_mut().ok_or("child already exited")?;
713            let status = child.wait()?;
714            let mut errors = String::new();
715            child
716                .stderr
717                .take()
718                .ok_or("child stderr is absent")?
719                .read_to_string(&mut errors)?;
720            self.child.take();
721            if clean && !status.success() {
722                return Err(format!("child exited {status}: {errors}").into());
723            }
724            if !clean && status.success() {
725                return Err("killed child exited successfully".into());
726            }
727            if !errors.is_empty() {
728                return Err(format!("child stderr: {errors}").into());
729            }
730            Ok(())
731        }
732    }
733
734    impl Drop for Process {
735        fn drop(&mut self) {
736            if let Some(child) = self.child.as_mut() {
737                match child.try_wait() {
738                    Ok(Some(_)) => {}
739                    Ok(None) => {
740                        if let Err(error) = child.kill() {
741                            eprintln!("registry child cleanup kill: {error}");
742                        }
743                        if let Err(error) = child.wait() {
744                            eprintln!("registry child cleanup wait: {error}");
745                        }
746                    }
747                    Err(error) => {
748                        eprintln!("registry child cleanup status: {error}");
749                        if let Err(error) = child.kill() {
750                            eprintln!("registry child cleanup kill: {error}");
751                        }
752                        if let Err(error) = child.wait() {
753                            eprintln!("registry child cleanup wait: {error}");
754                        }
755                    }
756                }
757                if let Some(mut stderr) = child.stderr.take() {
758                    let mut errors = String::new();
759                    match stderr.read_to_string(&mut errors) {
760                        Ok(_) if !errors.is_empty() => {
761                            eprintln!("registry child cleanup stderr: {errors}");
762                        }
763                        Ok(_) => {}
764                        Err(error) => eprintln!("registry child cleanup stderr read: {error}"),
765                    }
766                }
767            }
768        }
769    }
770}