aede_core/parental/
storage.rs

1//! Bounded private persistence; callers own the installation writer lock.
2use super::intervals::{Interval, union};
3use super::{Media, Policy, PolicySnapshot};
4use serde::{Deserialize, Serialize};
5use std::{
6    collections::BTreeMap,
7    io::{Read, Write},
8    path::{Path, PathBuf},
9};
10
11const FORMAT_VERSION: u32 = 1;
12const MAX_BYTES: usize = 8 * 1024 * 1024;
13const MAX_REVISION: u64 = (1 << 53) - 1;
14pub(super) const RETAIN_MS: u64 = 32 * 86_400_000;
15
16#[derive(Debug, Clone, Serialize, Deserialize)]
17#[serde(deny_unknown_fields)]
18pub(super) struct Reservation {
19    pub(super) id: String,
20    pub(super) media: Media,
21    pub(super) interval: Interval,
22    pub(super) consumed_ms: u64,
23}
24#[derive(Debug, Clone, Default, Serialize, Deserialize)]
25#[serde(deny_unknown_fields)]
26pub(super) struct Owner {
27    pub(super) revision: u64,
28    #[serde(default, skip_serializing_if = "Option::is_none")]
29    pub(super) locked_by: Option<String>,
30    pub(super) policy: Policy,
31    pub(super) settled: Vec<Interval>,
32    pub(super) reservations: Vec<Reservation>,
33}
34/// Protected installation-level parental policies and conservative usage ledger.
35#[derive(Debug, Clone, Serialize, Deserialize)]
36#[serde(deny_unknown_fields)]
37pub struct ParentalStore {
38    format_version: u32,
39    #[serde(deserialize_with = "unique_owners")]
40    pub(super) owners: BTreeMap<String, Owner>,
41}
42impl Default for ParentalStore {
43    fn default() -> Self {
44        Self {
45            format_version: FORMAT_VERSION,
46            owners: BTreeMap::new(),
47        }
48    }
49}
50/// The independently versioned, private parental store inside an Aède data directory.
51pub fn path(data_dir: &Path) -> PathBuf {
52    data_dir.join("parental.json")
53}
54pub(super) fn valid_owner(owner: &str) -> bool {
55    !owner.is_empty() && owner.len() <= 256 && !owner.chars().any(char::is_control)
56}
57pub(super) fn valid_lease(id: &str) -> bool {
58    id.len() == 64 && id.bytes().all(|byte| byte.is_ascii_hexdigit())
59}
60impl ParentalStore {
61    /// Load a private regular document; only initial absence yields an empty store.
62    pub fn load(path: &Path) -> Result<Self, String> {
63        Self::load_optional(path).map(Option::unwrap_or_default)
64    }
65    /// Distinguish initial absence from a present private document at the read boundary.
66    /// Servers can latch observed presence and refuse a subsequent disappearance.
67    pub fn load_optional(path: &Path) -> Result<Option<Self>, String> {
68        let inspected = match std::fs::symlink_metadata(path) {
69            Ok(metadata) => metadata,
70            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
71                return Ok(None);
72            }
73            Err(_) => return Err("parental policy could not be read".into()),
74        };
75        crate::accounts::check_private_file(path)
76            .map_err(|_| "parental store must be a private regular file")?;
77        let mut file =
78            std::fs::File::open(path).map_err(|_| "parental policy could not be opened")?;
79        let opened = file
80            .metadata()
81            .map_err(|_| "parental policy metadata is unavailable")?;
82        crate::accounts::validate_private_read(&inspected, &opened)
83            .map_err(|_| "parental policy changed while opening")?;
84        if opened.len() > MAX_BYTES as u64 {
85            return Err("parental store exceeds 8 MiB".into());
86        }
87        let mut bytes = Vec::new();
88        (&mut file)
89            .take(MAX_BYTES as u64 + 1)
90            .read_to_end(&mut bytes)
91            .map_err(|_| "parental policy read failed")?;
92        if bytes.len() > MAX_BYTES {
93            return Err("parental store exceeds 8 MiB".into());
94        }
95        let after = file
96            .metadata()
97            .map_err(|_| "parental policy metadata is unavailable")?;
98        let current =
99            std::fs::symlink_metadata(path).map_err(|_| "parental policy changed while reading")?;
100        crate::accounts::validate_private_read(&opened, &after)
101            .map_err(|_| "parental policy changed while reading")?;
102        crate::accounts::validate_private_read(&opened, &current)
103            .map_err(|_| "parental policy changed while reading")?;
104        if opened.len() != after.len()
105            || opened.modified().ok() != after.modified().ok()
106            || opened.len() != current.len()
107            || opened.modified().ok() != current.modified().ok()
108        {
109            return Err("parental policy changed while reading".into());
110        }
111        let store: Self = serde_json::from_slice(&bytes)
112            .map_err(|_| "parental store is unreadable or unsupported")?;
113        store.validate()?;
114        Ok(Some(store))
115    }
116    /// Publish one validated replacement atomically; the caller holds `StoreLock`.
117    pub fn save(&self, path: &Path) -> Result<(), String> {
118        self.validate()?;
119        crate::accounts::check_private_file(path)
120            .map_err(|_| "parental store must remain private")?;
121        let mut bytes = Limited(Vec::new());
122        serde_json::to_writer(&mut bytes, self)
123            .map_err(|_| "parental store exceeds its serialization budget")?;
124        crate::atomic_file::write(path, &bytes.0)
125            .map_err(|_| "parental store could not be saved".to_owned())
126    }
127    /// Return only the selected owner's policy, including explicit unrestricted defaults.
128    pub fn policy(&self, owner: &str) -> PolicySnapshot {
129        let value = self.owners.get(owner);
130        PolicySnapshot {
131            owner: owner.into(),
132            revision: value.map_or(0, |value| value.revision),
133            locked_by: value.and_then(|value| value.locked_by.clone()),
134            policy: value.map_or_else(Policy::default, |value| value.policy.clone()),
135        }
136    }
137    /// Replace an administrator-approved policy without altering credentials or opinions.
138    pub fn set_policy(
139        &mut self,
140        owner: &str,
141        policy: Policy,
142        expected_revision: u64,
143    ) -> Result<PolicySnapshot, String> {
144        self.set_policy_by(owner, None, policy, expected_revision)
145    }
146    /// Apply an authenticated administrator's edit and claim/release its exclusive lock.
147    /// Legacy unclaimed rules may be claimed; a local token cannot override a named owner.
148    pub fn set_policy_by(
149        &mut self,
150        owner: &str,
151        administrator: Option<&str>,
152        policy: Policy,
153        expected_revision: u64,
154    ) -> Result<PolicySnapshot, String> {
155        if !valid_owner(owner) {
156            return Err("invalid parental owner".into());
157        }
158        let policy = policy.canonicalize()?;
159        let old = self.owners.get(owner);
160        if administrator.is_some_and(|value| !valid_owner(value) || value == owner)
161            || old
162                .and_then(|value| value.locked_by.as_deref())
163                .is_some_and(|locked| Some(locked) != administrator)
164        {
165            return Err("parental policy is locked by another administrator".into());
166        }
167        let locked_by = policy
168            .enabled
169            .then_some(administrator)
170            .flatten()
171            .map(str::to_owned);
172        if old.map_or(0, |value| value.revision) != expected_revision {
173            return Err("parental policy revision conflict".into());
174        }
175        if old.is_some_and(|value| value.policy == policy && value.locked_by == locked_by) {
176            return Ok(self.policy(owner));
177        }
178        let mut candidate = self.clone();
179        let row = candidate.owners.entry(owner.into()).or_default();
180        row.revision = row
181            .revision
182            .checked_add(1)
183            .filter(|value| *value <= MAX_REVISION)
184            .ok_or("parental policy revision exhausted")?;
185        row.policy = policy;
186        row.locked_by = locked_by;
187        candidate.validate()?;
188        *self = candidate;
189        Ok(self.policy(owner))
190    }
191    /// Whether an administrator still owns at least one active supervision lock.
192    pub fn supervises(&self, administrator: &str) -> bool {
193        self.owners
194            .values()
195            .any(|owner| owner.locked_by.as_deref() == Some(administrator))
196    }
197    pub(crate) fn validate(&self) -> Result<(), String> {
198        if self.format_version != FORMAT_VERSION || self.owners.len() > 256 {
199            return Err("unsupported or oversized parental store".into());
200        }
201        let mut intervals = 0_usize;
202        let mut text_bytes = 0_usize;
203        for (name, owner) in &self.owners {
204            if !valid_owner(name)
205                || owner
206                    .locked_by
207                    .as_deref()
208                    .is_some_and(|id| !valid_owner(id) || id == name || !owner.policy.enabled)
209                || owner.revision > MAX_REVISION
210                || owner.reservations.len() > 8
211                || owner.settled.len() > 8192
212            {
213                return Err("invalid parental owner or ledger bounds".into());
214            }
215            owner.policy.validate()?;
216            text_bytes = text_bytes.saturating_add(
217                owner
218                    .policy
219                    .blocked_artists
220                    .iter()
221                    .chain(&owner.policy.blocked_podcasts)
222                    .map(String::len)
223                    .sum::<usize>(),
224            );
225            intervals += owner.settled.len();
226            let mut previous_end = 0;
227            for value in &owner.settled {
228                if value.start_ms >= value.end_ms
229                    || value.end_ms > 253_402_300_799_999
230                    || value.end_ms - value.start_ms > RETAIN_MS
231                    || value.start_ms < previous_end
232                {
233                    return Err("invalid parental usage interval".into());
234                }
235                previous_end = value.end_ms;
236            }
237            let mut ids = std::collections::BTreeSet::new();
238            for value in &owner.reservations {
239                if !valid_lease(&value.id)
240                    || !ids.insert(&value.id)
241                    || value.interval.start_ms >= value.interval.end_ms
242                    || value.interval.end_ms > 253_402_300_799_999
243                    || value.interval.end_ms - value.interval.start_ms > 10_000
244                    || value.consumed_ms > value.interval.end_ms - value.interval.start_ms
245                {
246                    return Err("invalid parental listening reservation".into());
247                }
248                if let Media::Podcast { feed } = &value.media {
249                    super::canonical_feed(feed)?;
250                }
251            }
252        }
253        if intervals > 65_536 || text_bytes > 2 * 1024 * 1024 {
254            return Err("parental store exceeds its global budget".into());
255        }
256        Ok(())
257    }
258}
259impl Owner {
260    pub(super) fn intervals(&self) -> Vec<Interval> {
261        union(
262            self.settled
263                .iter()
264                .copied()
265                .chain(self.reservations.iter().map(|value| value.interval))
266                .collect(),
267        )
268    }
269    pub(super) fn prune(&mut self, now_ms: u64) {
270        // Preserve the format's 32-day span when already transmitted PCM is
271        // prepaid slightly ahead of now. At most ten seconds of the oldest
272        // retained past are dropped; neither daily nor weekly usage is affected.
273        let horizon = self
274            .settled
275            .iter()
276            .map(|interval| interval.end_ms)
277            .max()
278            .unwrap_or(now_ms)
279            .max(now_ms)
280            .min(now_ms.saturating_add(10_000));
281        let cutoff = horizon.saturating_sub(RETAIN_MS);
282        // Retain a recent expired identity so another voice's renewal does not
283        // destroy it. Its full interval still contributes to the usage union.
284        self.reservations
285            .retain(|reservation| reservation.interval.end_ms > cutoff);
286        self.settled = union(
287            self.settled
288                .iter()
289                .filter_map(|value| {
290                    (value.end_ms > cutoff).then_some(Interval {
291                        start_ms: value.start_ms.max(cutoff),
292                        end_ms: value.end_ms,
293                    })
294                })
295                .collect(),
296        );
297    }
298    pub(super) fn make_room(&mut self, now_ms: u64) {
299        if self.reservations.len() < 8 {
300            return;
301        }
302        if let Some(index) = self
303            .reservations
304            .iter()
305            .enumerate()
306            .filter(|(_, value)| value.interval.end_ms <= now_ms)
307            .min_by_key(|(_, value)| value.interval.end_ms)
308            .map(|(index, _)| index)
309        {
310            let expired = self.reservations.remove(index);
311            self.settled.push(expired.interval);
312            self.prune(now_ms);
313        }
314    }
315}
316struct Limited(Vec<u8>);
317impl Write for Limited {
318    fn write(&mut self, bytes: &[u8]) -> std::io::Result<usize> {
319        if self.0.len().saturating_add(bytes.len()) > MAX_BYTES {
320            return Err(std::io::Error::other("parental store byte budget exceeded"));
321        }
322        self.0.extend_from_slice(bytes);
323        Ok(bytes.len())
324    }
325    fn flush(&mut self) -> std::io::Result<()> {
326        Ok(())
327    }
328}
329#[cfg(test)]
330#[path = "storage_tests.rs"]
331mod tests;
332
333fn unique_owners<'de, D: serde::Deserializer<'de>>(
334    deserializer: D,
335) -> Result<BTreeMap<String, Owner>, D::Error> {
336    struct Owners;
337    impl<'de> serde::de::Visitor<'de> for Owners {
338        type Value = BTreeMap<String, Owner>;
339        fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
340            formatter.write_str("at most 256 unique parental owners")
341        }
342        fn visit_map<A: serde::de::MapAccess<'de>>(
343            self,
344            mut input: A,
345        ) -> Result<Self::Value, A::Error> {
346            let mut values = BTreeMap::new();
347            while let Some((owner, value)) = input.next_entry::<String, Owner>()? {
348                if values.len() >= 256 || values.insert(owner, value).is_some() {
349                    return Err(serde::de::Error::custom(
350                        "duplicate or excessive parental owners",
351                    ));
352                }
353            }
354            Ok(values)
355        }
356    }
357    deserializer.deserialize_map(Owners)
358}