Files
inbuxa-server/crates/features/src/privacy/snapshot.rs
T
jcoffey-dev 966241070e
ci / fork-checks (pull_request) Skipped
ci / build (pull_request) Skipped
github/ci (branch) GitHub Actions
ci / github (pull_request) Successful in 6m29s
Name Coffey Labs LLC as the copyright holder
Coffey Labs is now Coffey Labs LLC, an Arizona limited liability company. Copyright lines and SPDX-FileCopyrightText headers naming Coffey Labs or John Coffey now name Coffey Labs LLC. Upstream copyright notices are unchanged.
2026-10-05 23:33:00 -07:00

156 lines
4.6 KiB
Rust

/*
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
//! Dated copies of the evaluated inventory (personal-data catalog spec, §6,
//! `inbuxa:InventorySnapshot`), so the Overview can show when and why what
//! the server holds changed. Stored as JSON under `C` `i` and the time taken
//! (seconds, big-endian) in the fork's subspace; kept as long as the audit
//! log keeps its records (settled 2026-09-28).
use super::{Inventory, Summary};
use serde::{Deserialize as SerdeDeserialize, Serialize as SerdeSerialize};
use store::{
Deserialize, IterateParams, SUBSPACE_INBUXA, Store, U64_LEN, ValueKey,
write::{AnyClass, BatchBuilder, ValueClass, key::DeserializeBigEndian},
};
use trc::AddContext;
const PREFIX: &[u8] = b"Ci";
/// Why a snapshot was taken.
#[derive(Debug, Clone, PartialEq, Eq, SerdeSerialize, SerdeDeserialize)]
#[serde(rename_all = "camelCase", tag = "kind")]
pub enum Trigger {
/// A setting the catalog names changed: the object type that changed.
SettingChanged { setting: String },
/// The daily snapshot.
Daily,
}
#[derive(Debug, Clone, PartialEq, Eq, SerdeSerialize, SerdeDeserialize)]
#[serde(rename_all = "camelCase")]
pub struct Snapshot {
/// Seconds since the epoch; also the snapshot's id.
pub taken_at: u64,
pub trigger: Trigger,
pub summary: Summary,
pub inventory: Inventory,
}
fn key(taken_at: u64) -> Vec<u8> {
let mut key = PREFIX.to_vec();
key.extend_from_slice(&taken_at.to_be_bytes());
key
}
fn class(taken_at: u64) -> ValueClass {
ValueClass::Any(AnyClass {
subspace: SUBSPACE_INBUXA,
key: key(taken_at),
})
}
struct Json(Snapshot);
impl Deserialize for Json {
fn deserialize(bytes: &[u8]) -> trc::Result<Self> {
serde_json::from_slice(bytes).map(Json).map_err(|err| {
trc::StoreEvent::DataCorruption
.caused_by(trc::location!())
.reason(err)
})
}
}
/// Stores a snapshot. Two in the same second: the later one wins.
pub async fn record(data: &Store, snapshot: &Snapshot) -> trc::Result<()> {
let bytes = serde_json::to_vec(snapshot).map_err(|err| {
trc::StoreEvent::UnexpectedError
.caused_by(trc::location!())
.reason(err)
})?;
let mut batch = BatchBuilder::new();
batch.set(class(snapshot.taken_at), bytes);
data.write(batch.build_all())
.await
.caused_by(trc::location!())
.map(|_| ())
}
/// One snapshot, by the time it was taken.
pub async fn get(data: &Store, taken_at: u64) -> trc::Result<Option<Snapshot>> {
Ok(data
.get_value::<Json>(ValueKey::from(class(taken_at)))
.await
.caused_by(trc::location!())?
.map(|Json(snapshot)| snapshot))
}
/// The times snapshots were taken between `after` and `before` (inclusive,
/// seconds), newest first.
pub async fn list(data: &Store, after: u64, before: u64) -> trc::Result<Vec<u64>> {
let mut times = Vec::new();
data.iterate(
IterateParams::new(
ValueKey::from(class(after)),
ValueKey::from(class(before)),
)
.no_values(),
|key, _| {
times.push(key.deserialize_be_u64(key.len() - U64_LEN)?);
Ok(true)
},
)
.await
.caused_by(trc::location!())?;
times.reverse();
Ok(times)
}
/// The newest snapshot's time, if any.
pub async fn latest(data: &Store) -> trc::Result<Option<u64>> {
Ok(list(data, 0, u64::MAX).await?.first().copied())
}
/// Removes snapshots taken before `before` (seconds). Returns how many went.
pub async fn purge(data: &Store, before: u64) -> trc::Result<usize> {
let old = list(data, 0, before.saturating_sub(1)).await?;
if old.is_empty() {
return Ok(0);
}
let mut batch = BatchBuilder::new();
for taken_at in &old {
batch.clear(class(*taken_at));
}
data.write(batch.build_all())
.await
.caused_by(trc::location!())?;
Ok(old.len())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn keys_sort_by_time() {
assert!(key(1) < key(2));
assert!(key(255) < key(256));
assert_eq!(&key(7)[..2], PREFIX);
}
#[test]
fn a_trigger_reads_as_json_names_it() {
let changed = serde_json::to_value(Trigger::SettingChanged {
setting: "x:DataRetention".into(),
})
.unwrap();
assert_eq!(changed["kind"], "settingChanged");
assert_eq!(changed["setting"], "x:DataRetention");
assert_eq!(serde_json::to_value(Trigger::Daily).unwrap()["kind"], "daily");
}
}