Skip to main content

aion_server/
store_harden.rs

1//! `aion store harden`: the one-time full walk over a store's tree.
2//!
3//! Until haematite 0.12.1, the library made no promise about the mode of the
4//! files it created, so the server walked every object file under the data
5//! root at every boot — twice — to `chmod` each one. On a store of 1.18 M
6//! files that was 171 s of silence per boot. Files are now private from
7//! their own creation, and the boot checks the data root and each shard
8//! directory only. The walk survives here, as an operator verb, for a store
9//! that predates that release or that someone loosened by hand: it applies
10//! 0700 to every directory and 0600 to every file, refuses a symbolic link
11//! anywhere in the tree, and narrates one line per top-level entry with the
12//! running file count.
13//!
14//! It refuses to run while a live server holds the store's writer lock:
15//! the walk is safe against a concurrent writer (a mode change on a file the
16//! server is writing is harmless), but an operator running it expects the
17//! store to be theirs, and a refusal that names the lock is the honest
18//! answer. The lock is held for the walk's duration, so a server that starts
19//! meanwhile waits on it, narrated by its own boot.
20
21use std::fs::{File, TryLockError};
22use std::io;
23use std::path::{Path, PathBuf};
24
25use cap_fs_ext::{DirExt, FollowSymlinks, OpenOptionsFollowExt};
26#[cfg(unix)]
27use cap_std::fs::PermissionsExt;
28use cap_std::fs::{Dir, OpenOptions};
29
30use crate::filesystem::{ConfinedDir, PRIVATE_DIR_MODE, PRIVATE_FILE_MODE, note_open};
31
32/// The file haematite anchors its writer lock on, at the data root.
33const WRITER_LOCK_FILE: &str = "writer.lock";
34
35/// Why a hardening walk could not run or could not finish.
36#[derive(Debug, thiserror::Error)]
37pub enum HardenError {
38    /// A live process holds the store's writer lock.
39    #[error(
40        "the store at `{data_dir}` is held by a live server: its writer lock at `{lock_path}` \
41         is taken. Stop the server (`aion stop`), run this verb, then start it again — the \
42         walk needs the store to itself"
43    )]
44    WriterLockHeld {
45        /// The store that is held.
46        data_dir: PathBuf,
47        /// The lock that is taken.
48        lock_path: PathBuf,
49    },
50    /// The writer lock exists but could not be opened or locked.
51    #[error("could not take the store's writer lock at `{lock_path}`: {error}")]
52    Lock {
53        /// The lock file.
54        lock_path: PathBuf,
55        /// The underlying failure.
56        #[source]
57        error: io::Error,
58    },
59    /// The data root could not be acquired as a private root.
60    #[error("could not open the store at `{data_dir}`: {error}")]
61    Open {
62        /// The store.
63        data_dir: PathBuf,
64        /// The underlying failure.
65        #[source]
66        error: io::Error,
67    },
68    /// A directory or file under the root could not be brought to its mode.
69    #[error("hardening `{entry}` under `{data_dir}` failed: {error}")]
70    Walk {
71        /// The store.
72        data_dir: PathBuf,
73        /// The top-level entry being walked when it failed.
74        entry: String,
75        /// The underlying failure.
76        #[source]
77        error: io::Error,
78    },
79}
80
81/// What one walk did.
82#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
83pub struct HardenReport {
84    /// Directories brought to 0700, the root included.
85    pub directories: u64,
86    /// Files brought to 0600.
87    pub files: u64,
88}
89
90/// One narrated step: a top-level entry finished, with the files it held and
91/// the running total.
92#[derive(Clone, Debug, PartialEq, Eq)]
93pub struct HardenProgress {
94    /// The entry's name under the data root (`shard-17`, `config.json`).
95    pub entry: String,
96    /// Files brought to 0600 under this entry (1 for a file).
97    pub files: u64,
98    /// Files brought to 0600 so far, this entry included.
99    pub total_files: u64,
100}
101
102/// Walk the whole store under `data_dir`, applying 0700 to every directory
103/// and 0600 to every file, narrating each top-level entry through `progress`.
104///
105/// # Errors
106///
107/// Refuses with [`HardenError::WriterLockHeld`] while a live server holds the
108/// store; otherwise surfaces the first filesystem failure, naming the
109/// top-level entry it happened under. A symbolic link anywhere in the tree is
110/// a failure — sensitive state never contains one.
111pub fn harden_store_tree(
112    data_dir: &Path,
113    mut progress: impl FnMut(&HardenProgress),
114) -> Result<HardenReport, HardenError> {
115    let _held = take_writer_lock(data_dir)?;
116    let root = ConfinedDir::open(data_dir).map_err(|error| HardenError::Open {
117        data_dir: data_dir.to_path_buf(),
118        error,
119    })?;
120    let mut report = HardenReport {
121        directories: 1,
122        files: 0,
123    };
124    let mut walk = |report: &mut HardenReport| -> io::Result<()> {
125        let mut entries = Vec::new();
126        for entry in root.dir().entries()? {
127            let entry = entry?;
128            entries.push((entry.file_name(), entry.file_type()?));
129        }
130        entries.sort_by(|left, right| left.0.cmp(&right.0));
131        for (name, file_type) in entries {
132            let entry = name.to_string_lossy().into_owned();
133            let files_before = report.files;
134            harden_entry(root.dir(), &name, file_type, report)
135                .map_err(|error| io::Error::new(error.kind(), format!("{entry}: {error}")))?;
136            progress(&HardenProgress {
137                entry,
138                files: report.files - files_before,
139                total_files: report.files,
140            });
141        }
142        Ok(())
143    };
144    walk(&mut report).map_err(|error| {
145        let (entry, error) = split_entry(error);
146        HardenError::Walk {
147            data_dir: data_dir.to_path_buf(),
148            entry,
149            error,
150        }
151    })?;
152    Ok(report)
153}
154
155/// The walk's errors are prefixed `entry: ` by the closure above so the
156/// top-level entry survives into the typed error; this splits them apart
157/// again without a second error type.
158fn split_entry(error: io::Error) -> (String, io::Error) {
159    let text = error.to_string();
160    match text.split_once(": ") {
161        Some((entry, rest)) => (
162            entry.to_owned(),
163            io::Error::new(error.kind(), rest.to_owned()),
164        ),
165        None => (String::new(), error),
166    }
167}
168
169/// Take the store's writer lock for the walk. A store that has never been
170/// opened has no lock file and nothing that could hold it, so `None`.
171fn take_writer_lock(data_dir: &Path) -> Result<Option<File>, HardenError> {
172    let lock_path = data_dir.join(WRITER_LOCK_FILE);
173    let file = match File::options().read(true).write(true).open(&lock_path) {
174        Ok(file) => file,
175        Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None),
176        Err(error) => return Err(HardenError::Lock { lock_path, error }),
177    };
178    match file.try_lock() {
179        Ok(()) => Ok(Some(file)),
180        Err(TryLockError::WouldBlock) => Err(HardenError::WriterLockHeld {
181            data_dir: data_dir.to_path_buf(),
182            lock_path,
183        }),
184        Err(TryLockError::Error(error)) => Err(HardenError::Lock { lock_path, error }),
185    }
186}
187
188fn harden_entry(
189    parent: &Dir,
190    name: &std::ffi::OsStr,
191    file_type: cap_std::fs::FileType,
192    report: &mut HardenReport,
193) -> io::Result<()> {
194    if file_type.is_symlink() {
195        return Err(io::Error::new(
196            io::ErrorKind::InvalidInput,
197            "sensitive state contains a symbolic link",
198        ));
199    }
200    if file_type.is_dir() {
201        note_open();
202        let child = parent.open_dir_nofollow(name)?;
203        harden_dir(&child, report)
204    } else if file_type.is_file() {
205        harden_file(parent, name, report)
206    } else {
207        Err(io::Error::new(
208            io::ErrorKind::InvalidInput,
209            "sensitive state contains something that is neither a file nor a directory",
210        ))
211    }
212}
213
214fn harden_dir(dir: &Dir, report: &mut HardenReport) -> io::Result<()> {
215    #[cfg(unix)]
216    dir.set_permissions(
217        Path::new("."),
218        cap_std::fs::Permissions::from_mode(PRIVATE_DIR_MODE),
219    )?;
220    report.directories += 1;
221    for entry in dir.entries()? {
222        let entry = entry?;
223        let name = entry.file_name();
224        let file_type = entry.file_type()?;
225        harden_entry(dir, &name, file_type, report).map_err(|error| {
226            io::Error::new(error.kind(), format!("{}: {error}", name.to_string_lossy()))
227        })?;
228    }
229    Ok(())
230}
231
232fn harden_file(parent: &Dir, name: &std::ffi::OsStr, report: &mut HardenReport) -> io::Result<()> {
233    let mut options = OpenOptions::new();
234    options.read(true).follow(FollowSymlinks::No);
235    note_open();
236    let file = parent.open_with(name, &options)?;
237    #[cfg(unix)]
238    file.set_permissions(cap_std::fs::Permissions::from_mode(PRIVATE_FILE_MODE))?;
239    #[cfg(not(unix))]
240    drop(file);
241    report.files += 1;
242    Ok(())
243}
244
245#[cfg(test)]
246#[path = "store_harden_tests.rs"]
247mod tests;