Skip to main content

ic_backup/ops/persistence/json/
mod.rs

1//! Module: `persistence::json`
2//!
3//! Responsibility: read and durably create or replace JSON persistence documents.
4//! Does not own: document validation, layout paths, or integrity checks.
5//! Boundary: provides explicit create-only and replace filesystem primitives.
6
7use crate::ops::persistence::PersistenceError;
8
9use std::{
10    fs::File,
11    io::{self, Write},
12    path::Path,
13};
14
15use serde::{Serialize, de::DeserializeOwned};
16
17use ic_host_fs::durable::{PublicationMode, WriteOptions};
18
19/// Check the maintained pretty-JSON budget without allocating an encoded record.
20pub(super) fn check_json_size(
21    value: &impl Serialize,
22    max_bytes: u64,
23) -> Result<(), PersistenceError> {
24    encode_into(value, io::sink(), max_bytes).map(|_| ())
25}
26
27/// Encode and admit the exact publication bytes before any filesystem effect.
28pub(super) fn encode_json(
29    value: &impl Serialize,
30    max_bytes: u64,
31) -> Result<Vec<u8>, PersistenceError> {
32    encode_into(value, Vec::new(), max_bytes)
33}
34
35fn encode_into<W: Write>(
36    value: &impl Serialize,
37    sink: W,
38    max_bytes: u64,
39) -> Result<W, PersistenceError> {
40    let mut writer = ic_host_artifacts::artifact::BoundedWriter::new(sink, max_bytes);
41    let result = serde_json::to_writer_pretty(&mut writer, value);
42    if writer.limit_exceeded() {
43        return Err(PersistenceError::RecordTooLarge { limit: max_bytes });
44    }
45    result?;
46    Ok(writer.into_inner())
47}
48
49pub(super) fn write_encoded(path: &Path, bytes: &[u8]) -> Result<(), PersistenceError> {
50    publish_bytes_at_barriers(path, bytes, PublicationMode::Replace, |_| {})
51}
52
53pub(super) fn create_encoded(path: &Path, bytes: &[u8]) -> Result<(), PersistenceError> {
54    publish_bytes_at_barriers(path, bytes, PublicationMode::CreateNew, |_| {})
55}
56
57/// Durably replace a machine record using a sibling temporary and rename.
58///
59/// # Errors
60/// Returns encoding/parent IO failures or structured shared publication failures.
61/// An after-publication failure or lost response requires reconciliation.
62pub fn write_json_durable<T>(path: &Path, value: &T) -> Result<(), PersistenceError>
63where
64    T: Serialize,
65{
66    let bytes = encode_json(value, u64::MAX)?;
67    write_encoded(path, &bytes)
68}
69
70/// Publish a new machine record without replacing an existing entry.
71///
72/// # Errors
73/// Returns encoding/parent IO failures or structured shared publication failures,
74/// including create-only conflicts and visible output after failed completion.
75pub fn create_json_durable<T>(path: &Path, value: &T) -> Result<(), PersistenceError>
76where
77    T: Serialize,
78{
79    let bytes = encode_json(value, u64::MAX)?;
80    create_encoded(path, &bytes)
81}
82
83/// Read one regular no-follow JSON file within an explicit byte limit.
84///
85/// Reuses the shared bounded regular-file reader; caller-selected parents and
86/// concurrent byte custody remain local. Only the final symlink is rejected.
87///
88/// # Errors
89/// Rejects unsafe files, excessive bytes, invalid JSON and filesystem failures.
90pub fn read_json<T>(path: &Path, max_bytes: u64) -> Result<T, PersistenceError>
91where
92    T: DeserializeOwned,
93{
94    #[cfg(unix)]
95    {
96        let bytes = ic_host_fs::read::read_file_no_follow(
97            path,
98            usize::try_from(max_bytes).unwrap_or(usize::MAX),
99        )
100        .map_err(|error| record_read_error(error, max_bytes))?;
101        Ok(serde_json::from_slice(&bytes)?)
102    }
103    #[cfg(not(unix))]
104    {
105        let _ = (path, max_bytes);
106        Err(io::Error::from(io::ErrorKind::Unsupported).into())
107    }
108}
109
110#[cfg(unix)]
111fn record_read_error(
112    error: ic_host_artifacts::artifact::ArtifactError,
113    max_bytes: u64,
114) -> PersistenceError {
115    use ic_host_artifacts::artifact::ArtifactError;
116    match error {
117        ArtifactError::NotRegularFile => PersistenceError::Io(io::Error::new(
118            io::ErrorKind::InvalidInput,
119            "record must be a regular file",
120        )),
121        ArtifactError::LimitExceeded { .. } => {
122            PersistenceError::RecordTooLarge { limit: max_bytes }
123        }
124        error => PersistenceError::Io(error.into()),
125    }
126}
127
128#[derive(Clone, Copy, Debug, Eq, PartialEq)]
129pub(crate) enum DurableWriteBarrier {
130    BeforeRename,
131    AfterDirectorySync,
132}
133
134// Keep one shared publication engine for ordinary writes and crash qualification.
135pub(super) fn publish_bytes_at_barriers(
136    path: &Path,
137    bytes: &[u8],
138    mode: PublicationMode,
139    mut barrier: impl FnMut(DurableWriteBarrier),
140) -> Result<(), PersistenceError> {
141    let parent = path
142        .parent()
143        .filter(|parent| !parent.as_os_str().is_empty())
144        .unwrap_or_else(|| Path::new("."));
145    ic_host_fs::durable::prepare_private_parents(parent)?;
146    let options = WriteOptions {
147        mode,
148        permissions: 0o600,
149    };
150    let produce = |file: &mut File| file.write_all(bytes);
151    #[cfg(unix)]
152    let result = {
153        use std::os::fd::AsFd;
154        let name = path.file_name().ok_or_else(|| {
155            io::Error::new(
156                io::ErrorKind::InvalidInput,
157                "durable write target has no filename",
158            )
159        })?;
160        let directory = File::open(parent)?;
161        ic_host_fs::durable::write_at_validated_with(
162            directory.as_fd(),
163            name,
164            options,
165            produce,
166            |()| {
167                barrier(DurableWriteBarrier::BeforeRename);
168                Ok(())
169            },
170        )
171    };
172    #[cfg(not(unix))]
173    let result = ic_host_fs::durable::write_validated_with(path, options, produce, |_, ()| {
174        barrier(DurableWriteBarrier::BeforeRename);
175        Ok(())
176    });
177    result.map_err(|error| {
178        PersistenceError::Publication(super::PublicationError::from_host(error))
179    })?;
180    // Successful Host completion includes publication and the held-parent sync.
181    barrier(DurableWriteBarrier::AfterDirectorySync);
182    Ok(())
183}
184
185#[cfg(test)]
186pub(crate) fn write_json_durable_at_barriers<T>(
187    path: &Path,
188    value: &T,
189    barrier: impl FnMut(DurableWriteBarrier),
190) -> Result<(), PersistenceError>
191where
192    T: Serialize,
193{
194    let bytes = serde_json::to_vec_pretty(value)?;
195    publish_bytes_at_barriers(path, &bytes, PublicationMode::Replace, barrier)
196}
197
198#[cfg(test)]
199pub(crate) fn create_json_durable_at_barriers<T>(
200    path: &Path,
201    value: &T,
202    mut before_publication: impl FnMut(),
203    mut after_directory_sync: impl FnMut(),
204) -> Result<(), PersistenceError>
205where
206    T: Serialize,
207{
208    let bytes = serde_json::to_vec_pretty(value)?;
209    publish_bytes_at_barriers(
210        path,
211        &bytes,
212        PublicationMode::CreateNew,
213        |barrier| match barrier {
214            DurableWriteBarrier::BeforeRename => before_publication(),
215            DurableWriteBarrier::AfterDirectorySync => after_directory_sync(),
216        },
217    )
218}
219
220// -----------------------------------------------------------------------------
221// Tests
222// -----------------------------------------------------------------------------
223
224#[cfg(test)]
225mod regressions;
226#[cfg(test)]
227mod tests;