hayson_engine/catalog/
write.rs1use 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
14pub 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#[cfg(not(unix))]
38fn permissions_of(_path: &Path) -> std::io::Result<Option<u32>> {
39 Ok(None)
40}
41
42pub fn write_catalog(path: &Path, catalog: &Catalog) -> Result<(), Box<dyn std::error::Error>> {
47 write_catalog_sealed(path, catalog, None)
48}
49
50pub 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
67pub const CATALOG_SCOPE: &str = "catalog";
69
70pub const CATALOG_SCOPE_NAME: &str = CATALOG_SCOPE;
72
73pub fn read_catalog_text(path: &Path, sealer: Option<&Sealer>) -> Result<Option<String>, ReadCatalogError> {
77 let bytes = match std::fs::read(path) {
78 Ok(bytes) => bytes,
79 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
80 Err(error) => return Err(ReadCatalogError::Io(error)),
81 };
82 let sealed = crate::crypto::is_sealed_blob(&bytes);
83 let plain = match (sealed, sealer) {
84 (true, Some(sealer)) => sealer
85 .open_blob(CATALOG_SCOPE, &bytes)
86 .map_err(ReadCatalogError::Cipher)?,
87 (true, None) => {
88 return Err(ReadCatalogError::Encrypted(
89 HaysonCipherError::new("", crate::crypto::STORAGE_FIELD, format!(
90 "\"{}\" is encrypted — open the database with its encryption key", path.display()
91 )),
92 ))
93 }
94 (false, Some(_)) => {
95 return Err(ReadCatalogError::NotEncrypted(
96 HaysonCipherError::new("", crate::crypto::STORAGE_FIELD, format!(
97 "\"{}\" is not encrypted, but an encryption key was given — convert the database first, or open it without the key", path.display()
98 )),
99 ))
100 }
101 (false, None) => bytes,
102 };
103 String::from_utf8(plain)
104 .map(Some)
105 .map_err(|error| ReadCatalogError::Io(std::io::Error::new(std::io::ErrorKind::InvalidData, error)))
106}
107
108#[derive(Debug)]
109pub enum ReadCatalogError {
110 Io(std::io::Error),
111 Cipher(HaysonCipherError),
113 Encrypted(HaysonCipherError),
115 NotEncrypted(HaysonCipherError),
117}
118
119impl ReadCatalogError {
120 pub fn problem_code(&self) -> &'static str {
122 match self {
123 ReadCatalogError::Io(_) => "catalog-unreadable",
124 ReadCatalogError::Cipher(_) => "catalog-key-rejected",
125 ReadCatalogError::Encrypted(_) => "catalog-encrypted",
126 ReadCatalogError::NotEncrypted(_) => "catalog-not-encrypted",
127 }
128 }
129}
130
131#[cfg(test)]
132mod tests {
133 use std::collections::BTreeMap;
134 use std::fs;
135
136 use super::*;
137 use crate::catalog::build::empty_catalog;
138 use crate::catalog::types::CatalogLayout;
139
140 #[test]
141 fn writes_the_catalog_atomically() {
142 let dir = tempfile::tempdir().unwrap();
143 let path = dir.path().join("hayson.json");
144 let catalog = empty_catalog(CatalogLayout::Flat, 1_000);
145
146 write_catalog(&path, &catalog).unwrap();
147
148 let on_disk = fs::read_to_string(&path).unwrap();
149 assert!(on_disk.contains("\"catalogVersion\": 2"));
150 assert!(!path.with_extension("json.tmp").exists());
151
152 let reparsed = parse_catalog(&serde_json::from_str(&on_disk).unwrap()).unwrap();
153 assert_eq!(reparsed.tables, BTreeMap::new());
154 }
155
156 #[test]
157 fn refuses_before_touching_disk_when_the_catalog_cannot_be_read_back() {
158 let dir = tempfile::tempdir().unwrap();
159 let path = dir.path().join("hayson.json");
160 fs::write(&path, "original").unwrap();
161
162 let mut catalog = empty_catalog(CatalogLayout::Flat, 1_000);
163 catalog.catalog_version = 3;
164
165 assert!(write_catalog(&path, &catalog).is_err());
166 assert_eq!(fs::read_to_string(&path).unwrap(), "original");
167 }
168}