Skip to main content

hayson_engine/catalog/
write.rs

1use std::path::Path;
2
3use crate::atomic::write_atomic;
4use crate::crypto::Sealer;
5use crate::errors::{HaysonCipherError, HaysonSchemaError};
6
7use super::parse::parse_catalog;
8use super::serialize::{catalog_document, serialize_catalog};
9use super::types::Catalog;
10
11#[cfg(unix)]
12const PERMISSION_BITS: u32 = 0o777;
13
14/// Parses the catalog's own serialized form back before it is allowed near the disk — a catalog
15/// this build could write but not read back would leave the next open() unable to see its tables.
16pub fn assert_catalog_readable(path: &Path, catalog: &Catalog) -> Result<(), HaysonSchemaError> {
17    parse_catalog(&catalog_document(catalog)).map(|_| ()).map_err(|error| {
18        HaysonSchemaError(format!(
19            "refusing to write \"{}\": the catalog it would hold is one this build of hayson cannot read back — {}; nothing on disk was changed, and the declaration in the way has to go before the change can",
20            path.display(),
21            error.0,
22        ))
23    })
24}
25
26#[cfg(unix)]
27fn permissions_of(path: &Path) -> std::io::Result<Option<u32>> {
28    use std::os::unix::fs::PermissionsExt;
29    match std::fs::metadata(path) {
30        Ok(metadata) => Ok(Some(metadata.permissions().mode() & PERMISSION_BITS)),
31        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
32        Err(error) => Err(error),
33    }
34}
35
36// NOTE: wasi has no permission bits at all; the host sandbox bounds fs access instead.
37#[cfg(not(unix))]
38fn permissions_of(_path: &Path) -> std::io::Result<Option<u32>> {
39    Ok(None)
40}
41
42/// Writes the catalog atomically (`.tmp` → `fsync` → `rename`, via [`write_atomic`]), inheriting
43/// the permissions of the file it replaces so `.tmp` is never briefly world-readable
44/// (see `docs/format.md`, "Zapis, kolejność kluczy, uprawnienia"). Refuses first, via
45/// [`assert_catalog_readable`], leaving the file on disk untouched.
46pub fn write_catalog(path: &Path, catalog: &Catalog) -> Result<(), Box<dyn std::error::Error>> {
47    write_catalog_sealed(path, catalog, None)
48}
49
50/// [`write_catalog`] whose file is sealed with `sealer` when one is given — the catalog is the first
51/// thing an open reads, so it is also what tells a wrong key from a right one.
52pub fn write_catalog_sealed(
53    path: &Path,
54    catalog: &Catalog,
55    sealer: Option<&Sealer>,
56) -> Result<(), Box<dyn std::error::Error>> {
57    assert_catalog_readable(path, catalog)?;
58    let mode = permissions_of(path)?;
59    let contents = serialize_catalog(catalog);
60    match sealer {
61        Some(sealer) => write_atomic(path, &sealer.seal_blob(CATALOG_SCOPE, contents.as_bytes()), mode)?,
62        None => write_atomic(path, contents.as_bytes(), mode)?,
63    }
64    Ok(())
65}
66
67/// The scope the catalog is sealed under.
68pub const CATALOG_SCOPE: &str = "catalog";
69
70/// The text of the catalog file at `path`. A sealed one is opened with `sealer`: an encrypted file
71/// read without keys, a plain one read with keys, and a wrong key are each refused with a message of
72/// their own, so a caller can tell them apart. `None` when there is no file.
73pub fn read_catalog_text(path: &Path, sealer: Option<&Sealer>) -> Result<Option<String>, ReadCatalogError> {
74    let bytes = match std::fs::read(path) {
75        Ok(bytes) => bytes,
76        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
77        Err(error) => return Err(ReadCatalogError::Io(error)),
78    };
79    let sealed = crate::crypto::is_sealed_blob(&bytes);
80    let plain = match (sealed, sealer) {
81        (true, Some(sealer)) => sealer
82            .open_blob(CATALOG_SCOPE, &bytes)
83            .map_err(ReadCatalogError::Cipher)?,
84        (true, None) => {
85            return Err(ReadCatalogError::Encrypted(
86                HaysonCipherError::new("", crate::crypto::STORAGE_FIELD, format!(
87                    "\"{}\" is encrypted — open the database with its encryption key", path.display()
88                )),
89            ))
90        }
91        (false, Some(_)) => {
92            return Err(ReadCatalogError::NotEncrypted(
93                HaysonCipherError::new("", crate::crypto::STORAGE_FIELD, format!(
94                    "\"{}\" is not encrypted, but an encryption key was given — convert the database first, or open it without the key", path.display()
95                )),
96            ))
97        }
98        (false, None) => bytes,
99    };
100    String::from_utf8(plain)
101        .map(Some)
102        .map_err(|error| ReadCatalogError::Io(std::io::Error::new(std::io::ErrorKind::InvalidData, error)))
103}
104
105#[derive(Debug)]
106pub enum ReadCatalogError {
107    Io(std::io::Error),
108    /// The key does not open the catalog (wrong key, or a damaged file).
109    Cipher(HaysonCipherError),
110    /// The catalog is sealed and no key was given.
111    Encrypted(HaysonCipherError),
112    /// A key was given for a catalog that is not sealed.
113    NotEncrypted(HaysonCipherError),
114}
115
116impl ReadCatalogError {
117    /// The code a [`crate::database::Problem`] about this refusal carries.
118    pub fn problem_code(&self) -> &'static str {
119        match self {
120            ReadCatalogError::Io(_) => "catalog-unreadable",
121            ReadCatalogError::Cipher(_) => "catalog-key-rejected",
122            ReadCatalogError::Encrypted(_) => "catalog-encrypted",
123            ReadCatalogError::NotEncrypted(_) => "catalog-not-encrypted",
124        }
125    }
126}
127
128#[cfg(test)]
129mod tests {
130    use std::collections::BTreeMap;
131    use std::fs;
132
133    use super::*;
134    use crate::catalog::build::empty_catalog;
135    use crate::catalog::types::CatalogLayout;
136
137    #[test]
138    fn writes_the_catalog_atomically() {
139        let dir = tempfile::tempdir().unwrap();
140        let path = dir.path().join("hayson.json");
141        let catalog = empty_catalog(CatalogLayout::Flat, 1_000);
142
143        write_catalog(&path, &catalog).unwrap();
144
145        let on_disk = fs::read_to_string(&path).unwrap();
146        assert!(on_disk.contains("\"catalogVersion\": 2"));
147        assert!(!path.with_extension("json.tmp").exists());
148
149        let reparsed = parse_catalog(&serde_json::from_str(&on_disk).unwrap()).unwrap();
150        assert_eq!(reparsed.tables, BTreeMap::new());
151    }
152
153    #[test]
154    fn refuses_before_touching_disk_when_the_catalog_cannot_be_read_back() {
155        let dir = tempfile::tempdir().unwrap();
156        let path = dir.path().join("hayson.json");
157        fs::write(&path, "original").unwrap();
158
159        let mut catalog = empty_catalog(CatalogLayout::Flat, 1_000);
160        catalog.catalog_version = 3;
161
162        assert!(write_catalog(&path, &catalog).is_err());
163        assert_eq!(fs::read_to_string(&path).unwrap(), "original");
164    }
165}