Skip to main content

hayson_engine/database/
convert.rs

1use std::collections::HashMap;
2use std::fs;
3use std::io;
4use std::path::{Path, PathBuf};
5use std::sync::Arc;
6
7use serde_json::Value;
8
9use crate::catalog::{read_catalog_text, Catalog, ReadCatalogError, CATALOG_SCOPE_NAME};
10use crate::crypto::{keyed_physical_path, CipherOptions, Sealer};
11use crate::schema::{is_physical_path, physical_path_of, FieldType};
12use crate::storage::LOG_SUFFIX;
13
14use super::backup::{
15    owned_entries, read_catalog_of, refuse, restore_staged, sealer_of, ManifestEntry, Owned, RestorePart,
16};
17use super::database::{Database, DatabaseError, LockMode};
18use super::layout::LayoutOptions;
19
20#[derive(Debug, Clone, Default)]
21pub struct ConvertOptions {
22    /// The keys the database is sealed with now; `None` for a plain database.
23    pub from: Option<CipherOptions>,
24    /// The keys it is to be sealed with afterwards; `None` to decrypt it.
25    pub to: Option<CipherOptions>,
26    pub lock: LockMode,
27}
28
29#[derive(Debug, Clone, PartialEq, Eq)]
30pub struct ConvertResult {
31    pub files: usize,
32    pub tables: usize,
33    pub images: usize,
34    /// Log lines whose image references had to be renamed.
35    pub lines_renamed: u64,
36}
37
38fn read_error(error: ReadCatalogError) -> DatabaseError {
39    match error {
40        ReadCatalogError::Io(error) => DatabaseError::Io(error),
41        ReadCatalogError::Cipher(error)
42        | ReadCatalogError::Encrypted(error)
43        | ReadCatalogError::NotEncrypted(error) => DatabaseError::Cipher(Box::new(error)),
44    }
45}
46
47fn open_stored(sealer: Option<&Sealer>, scope: &str, bytes: Vec<u8>) -> Result<Vec<u8>, DatabaseError> {
48    match sealer {
49        Some(sealer) => Ok(sealer.open_blob(scope, &bytes)?),
50        None => Ok(bytes),
51    }
52}
53
54fn seal_stored(sealer: Option<&Sealer>, scope: &str, bytes: &[u8]) -> Vec<u8> {
55    match sealer {
56        Some(sealer) => sealer.seal_blob(scope, bytes),
57        None => bytes.to_vec(),
58    }
59}
60
61fn write_synced(path: &Path, bytes: &[u8]) -> io::Result<()> {
62    if let Some(parent) = path.parent() {
63        fs::create_dir_all(parent)?;
64    }
65    let mut file = fs::File::create(path)?;
66    io::Write::write_all(&mut file, bytes)?;
67    file.sync_all()
68}
69
70/// Rewrites every `image` field of a record line so its `physicalPath` follows `renamed`.
71fn rename_images(line: &str, image_fields: &[String], renamed: &HashMap<String, String>) -> Option<String> {
72    let mut value: Value = serde_json::from_str(line).ok()?;
73    let object = value.as_object_mut()?;
74    let mut changed = false;
75    for field in image_fields {
76        let Some(Value::Object(image)) = object.get_mut(field) else {
77            continue;
78        };
79        let Some(Value::String(path)) = image.get_mut("physicalPath") else {
80            continue;
81        };
82        if let Some(new) = renamed.get(path.as_str()) {
83            *path = new.clone();
84            changed = true;
85        }
86    }
87    changed.then(|| serde_json::to_string(&value).expect("a parsed value serializes"))
88}
89
90/// Seals a plain database, unseals an encrypted one, or re-seals one under other keys, in place and
91/// recoverably (the stage, verify, swap and roll-back of [`super::restore_database`]).
92///
93/// Every file is read through `from` and written through `to`: the metadata document, every log line
94/// (fresh IVs; image references renamed where the file name changes), every image file. Image file
95/// names are a plain content hash in a plain database and a keyed one in an encrypted one, so they are
96/// recomputed and the records that name them are rewritten. Index snapshots are dropped; the next open
97/// rebuilds them. A database with an unfinished unit of work is refused.
98pub fn convert_encryption(dir: &Path, options: &ConvertOptions) -> Result<ConvertResult, DatabaseError> {
99    if options.from.is_none() && options.to.is_none() {
100        return Err(refuse(
101            "neither \"from\" nor \"to\" names keys — there is nothing to convert".to_string(),
102        ));
103    }
104    let from = sealer_of(options.from.as_ref())?;
105    let to = sealer_of(options.to.as_ref())?;
106    let layout = Database::resolve_layout_sealed(dir, &LayoutOptions::default(), from.as_deref())?;
107    let catalog = read_catalog_of(&layout, from.as_deref())?;
108    // NOTE: read again under the lock by `convert_files`, which refuses if the table set moved.
109    let tables_before: Vec<String> = catalog.tables.keys().cloned().collect();
110    let part = RestorePart {
111        sub: PathBuf::new(),
112        encryption: options.to.clone(),
113        current_encryption: Some(options.from.clone()),
114        metadata_file: layout.metadata_file().to_string(),
115        entries: Vec::new(),
116    };
117    let mut summary = ConvertResult {
118        files: 0,
119        tables: tables_before.len(),
120        images: 0,
121        lines_renamed: 0,
122    };
123    let outcome = {
124        let summary = &mut summary;
125        restore_staged(dir, vec![part], &options.lock, None, |staged, parts| {
126            let (bytes, entries, result) = convert_files(dir, staged, &tables_before, from.clone(), to.clone())?;
127            parts[0].entries = entries;
128            *summary = result;
129            Ok(bytes)
130        })?
131    };
132    let _ = outcome;
133    Ok(summary)
134}
135
136fn convert_files(
137    root: &Path,
138    staged: &Path,
139    tables_before: &[String],
140    from: Option<Arc<Sealer>>,
141    to: Option<Arc<Sealer>>,
142) -> Result<(u64, Vec<ManifestEntry>, ConvertResult), DatabaseError> {
143    let layout = Database::resolve_layout_sealed(root, &LayoutOptions::default(), from.as_deref())?;
144    let catalog: Catalog = read_catalog_of(&layout, from.as_deref())?;
145    if catalog.tables.keys().cloned().collect::<Vec<_>>() != tables_before {
146        return Err(refuse(
147            "the database changed while the conversion was being prepared — run it again".to_string(),
148        ));
149    }
150    if layout.journal_path().exists() {
151        return Err(refuse(format!(
152            "\"{}\" holds an unfinished unit of work — open the database for writing once so it is finished, then convert it",
153            layout.journal_path().display()
154        )));
155    }
156    let files = owned_entries(root, &layout, &catalog, true, &[])?;
157
158    let mut entries = Vec::new();
159    let mut total = 0u64;
160    let mut image_count = 0usize;
161    let mut lines_renamed = 0u64;
162
163    // Images first: their new names decide how the log lines are rewritten.
164    let mut renamed: HashMap<String, HashMap<String, String>> = HashMap::new();
165    let mut written: Vec<(PathBuf, u64)> = Vec::new();
166    for (relative, kind) in &files {
167        let Owned::Image(table) = kind else { continue };
168        let name = relative.file_name().and_then(|n| n.to_str()).unwrap_or_default();
169        if !is_physical_path(name) {
170            // Not a file this engine wrote; carried over untouched.
171            let bytes = fs::read(root.join(relative))?;
172            write_synced(&staged.join(relative), &bytes)?;
173            written.push((relative.clone(), bytes.len() as u64));
174            continue;
175        }
176        let stored = fs::read(root.join(relative))?;
177        let plain = open_stored(from.as_deref(), &format!("image:{table}"), stored)?;
178        let new_name = match to.as_deref() {
179            Some(sealer) => keyed_physical_path(&sealer.image_name_key(), &plain),
180            None => physical_path_of(&plain),
181        };
182        let target = relative.with_file_name(&new_name);
183        let sealed = seal_stored(to.as_deref(), &format!("image:{table}"), &plain);
184        write_synced(&staged.join(&target), &sealed)?;
185        written.push((target, sealed.len() as u64));
186        if new_name != name {
187            renamed
188                .entry(table.clone())
189                .or_default()
190                .insert(name.to_string(), new_name);
191        }
192        image_count += 1;
193    }
194
195    for (relative, kind) in &files {
196        match kind {
197            Owned::Image(_) | Owned::Index(_) => continue,
198            Owned::Metadata => {
199                let text = read_catalog_text(&root.join(relative), from.as_deref()).map_err(read_error)?;
200                let text = text.ok_or_else(|| refuse("the metadata document disappeared".to_string()))?;
201                let bytes = seal_stored(to.as_deref(), CATALOG_SCOPE_NAME, text.as_bytes());
202                write_synced(&staged.join(relative), &bytes)?;
203                written.push((relative.clone(), bytes.len() as u64));
204            }
205            Owned::Log(table) => {
206                let image_fields: Vec<String> = catalog.tables[table]
207                    .fields
208                    .iter()
209                    .filter(|(_, spec)| spec.spec.field_type == FieldType::Image)
210                    .map(|(field, _)| field.clone())
211                    .collect();
212                let names = renamed.get(table);
213                let scope = format!("log:{table}");
214                let text = fs::read_to_string(root.join(relative))?;
215                let mut out = String::with_capacity(text.len());
216                for line in text.split_inclusive('\n') {
217                    if !line.ends_with('\n') {
218                        // A half-written tail is what a writable open cuts off; it is not carried.
219                        break;
220                    }
221                    let stored = &line[..line.len() - 1];
222                    if stored.is_empty() {
223                        continue;
224                    }
225                    let mut plain = match from.as_deref() {
226                        Some(sealer) => sealer.open_line(&scope, stored)?,
227                        None => stored.to_string(),
228                    };
229                    if let (Some(names), false) = (names, image_fields.is_empty()) {
230                        if let Some(rewritten) = rename_images(&plain, &image_fields, names) {
231                            plain = rewritten;
232                            lines_renamed += 1;
233                        }
234                    }
235                    out.push_str(&match to.as_deref() {
236                        Some(sealer) => sealer.seal_line(&scope, &plain),
237                        None => plain,
238                    });
239                    out.push('\n');
240                }
241                write_synced(&staged.join(relative), out.as_bytes())?;
242                written.push((relative.clone(), out.len() as u64));
243            }
244        }
245    }
246    let _ = LOG_SUFFIX;
247
248    for (relative, bytes) in written {
249        total += bytes;
250        entries.push(ManifestEntry { relative, bytes });
251    }
252    let result = ConvertResult {
253        files: entries.len(),
254        tables: tables_before.len(),
255        images: image_count,
256        lines_renamed,
257    };
258    Ok((total, entries, result))
259}