aede_core/
accounts.rs

1//! Installation persistence facade for the independent account domain.
2//!
3//! Identities, roles, credential policy and revocable client keys belong to
4//! `aede-accounts`. This adapter retains bounded private-file access, the
5//! existing JSON parser, atomic writes and compatibility with full backups.
6//! Callers hold the data-directory writer lock for read/modify/save operations.
7
8use std::io::Read;
9use std::path::{Path, PathBuf};
10
11use crate::json::{self, Json};
12use crate::store::StoreError;
13
14pub use aede_accounts::{
15    ACCOUNTS_FILE, Account, Accounts, ApiKey, FORMAT_VERSION, LOCAL_OWNER, MAX_ACCOUNTS,
16    MAX_API_KEY_LABEL_BYTES, MAX_API_KEYS, MAX_API_KEYS_PER_ACCOUNT, MAX_PASSWORD_BYTES, Role,
17    login_name, random_token,
18};
19
20const MAX_FILE_BYTES: u64 = 1024 * 1024;
21
22/// Build the private credential-store path.
23pub fn accounts_path(data_dir: &Path) -> PathBuf {
24    data_dir.join(ACCOUNTS_FILE)
25}
26
27/// Refuse links, special files and Unix credentials readable by other users.
28/// An absent destination is accepted for a new atomic write.
29pub fn check_private_file(path: &Path) -> Result<(), StoreError> {
30    inspect_private_file(path).map(|_| ())
31}
32
33fn inspect_private_file(path: &Path) -> Result<Option<std::fs::Metadata>, StoreError> {
34    match std::fs::symlink_metadata(path) {
35        Ok(metadata) => {
36            check_private_metadata(&metadata)?;
37            Ok(Some(metadata))
38        }
39        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
40        Err(error) => Err(error.into()),
41    }
42}
43
44fn check_private_metadata(metadata: &std::fs::Metadata) -> Result<(), StoreError> {
45    if !metadata.is_file() {
46        return Err(StoreError::AccountsInvalid(
47            "credentials require a regular file, never a symbolic link",
48        ));
49    }
50    #[cfg(unix)]
51    {
52        use std::os::unix::fs::PermissionsExt;
53        if metadata.permissions().mode() & 0o077 != 0 {
54            return Err(StoreError::AccountsInvalid(
55                "credential files must be private (chmod 600)",
56            ));
57        }
58    }
59    Ok(())
60}
61
62/// Validate the inspected source and the descriptor that supplied credentials.
63/// On Unix their device/inode identity must also match, so an intervening
64/// replacement cannot supply a different account store or private backup.
65pub(crate) fn validate_private_read(
66    inspected: &std::fs::Metadata,
67    opened: &std::fs::Metadata,
68) -> Result<(), StoreError> {
69    check_private_metadata(inspected)?;
70    check_private_metadata(opened)?;
71    #[cfg(unix)]
72    {
73        use std::os::unix::fs::MetadataExt;
74        if inspected.dev() != opened.dev() || inspected.ino() != opened.ino() {
75            return Err(StoreError::AccountsInvalid(
76                "credential source changed while it was opened; retry",
77            ));
78        }
79    }
80    Ok(())
81}
82
83/// Read a bounded private account file; initial absence is distinct from malformed data.
84/// The opened descriptor is rechecked for regular-file type and private Unix
85/// permissions, including device/inode identity on Unix. Disappearance after
86/// inspection is an error, so it cannot silently disable authentication.
87pub fn load(path: &Path) -> Result<Option<Accounts>, StoreError> {
88    load_with_open(path, |path| std::fs::File::open(path))
89}
90
91fn load_with_open(
92    path: &Path,
93    open: impl FnOnce(&Path) -> std::io::Result<std::fs::File>,
94) -> Result<Option<Accounts>, StoreError> {
95    let Some(inspected) = inspect_private_file(path)? else {
96        return Ok(None);
97    };
98    let file = open(path)?;
99    let opened = file.metadata()?;
100    validate_private_read(&inspected, &opened)?;
101    if opened.len() > MAX_FILE_BYTES {
102        return Err(StoreError::AccountsInvalid("account file exceeds 1 MiB"));
103    }
104    let mut text = String::new();
105    file.take(MAX_FILE_BYTES + 1).read_to_string(&mut text)?;
106    if text.len() as u64 > MAX_FILE_BYTES {
107        return Err(StoreError::AccountsInvalid("account file exceeds 1 MiB"));
108    }
109    from_json(&json::parse(&text).map_err(StoreError::AccountsParse)?).map(Some)
110}
111
112/// Save credentials atomically, preserving private permissions and validating all rows.
113/// The caller must hold the existing data-directory writer lock.
114pub fn save(accounts: &Accounts, path: &Path) -> Result<(), StoreError> {
115    check_private_file(path)?;
116    let document = to_json(accounts);
117    from_json(&document)?;
118    crate::atomic_file::write(path, document.to_string_compact().as_bytes())?;
119    Ok(())
120}
121
122/// Serialize the credential store for protected persistence and full backups only.
123/// Never expose this document as an account-list or catalog API response.
124pub fn to_json(accounts: &Accounts) -> Json {
125    from_value(aede_accounts::to_json(accounts))
126}
127
128/// Refuse malformed, duplicate or administrator-less credentials as one whole store.
129/// The independent domain retains the bounded hash policy and numeric contract.
130pub fn from_json(root: &Json) -> Result<Accounts, StoreError> {
131    aede_accounts::from_json(&to_value(root, 0)?)
132        .map_err(|error| StoreError::AccountsInvalid(error.reason()))
133}
134
135// Keep the existing parser and public Json API at the persistence boundary.
136// Its numbers are f64; translating the tree never reparses or rounds a value.
137fn to_value(value: &Json, depth: usize) -> Result<serde_json::Value, StoreError> {
138    use serde_json::Value;
139    if depth >= 128 && matches!(value, Json::Arr(_) | Json::Obj(_)) {
140        return Err(StoreError::AccountsInvalid(
141            "malformed account store or unsupported version",
142        ));
143    }
144    Ok(match value {
145        Json::Null => Value::Null,
146        Json::Bool(value) => Value::Bool(*value),
147        // Json's writer also emits null for a non-finite programmatic number.
148        Json::Num(value) => serde_json::Number::from_f64(*value).map_or(Value::Null, Value::Number),
149        Json::Str(value) => Value::String(value.clone()),
150        Json::Arr(values) => Value::Array(
151            values
152                .iter()
153                .map(|value| to_value(value, depth + 1))
154                .collect::<Result<_, _>>()?,
155        ),
156        Json::Obj(values) => Value::Object(
157            values
158                .iter()
159                .map(|(key, value)| Ok((key.clone(), to_value(value, depth + 1)?)))
160                .collect::<Result<_, StoreError>>()?,
161        ),
162    })
163}
164
165// Only the domain's generated, shallow document reaches this direction.
166// Every emitted number is an ordinary finite serde_json number.
167fn from_value(value: serde_json::Value) -> Json {
168    use serde_json::Value;
169    match value {
170        Value::Null => Json::Null,
171        Value::Bool(value) => Json::Bool(value),
172        Value::Number(value) => value.as_f64().map_or(Json::Null, Json::Num),
173        Value::String(value) => Json::Str(value),
174        Value::Array(values) => Json::Arr(values.into_iter().map(from_value).collect()),
175        Value::Object(values) => Json::Obj(
176            values
177                .into_iter()
178                .map(|(key, value)| (key, from_value(value)))
179                .collect(),
180        ),
181    }
182}
183
184#[cfg(test)]
185#[path = "accounts_tests.rs"]
186mod tests;
187
188#[cfg(test)]
189#[path = "accounts_test_support.rs"]
190mod test_support;