#![cfg(feature = "field-encryption")]
#![allow(clippy::unwrap_used, clippy::expect_used)]
#![allow(unsafe_code)]
use std::collections::BTreeMap;
use std::path::Path;
use std::time::Duration;
use arrow::array::Array as _;
use base64::Engine as _;
use chronix::prelude::*;
use chronix::{Chronix, fields, tags};
use chronix_core::FieldEncryption;
use tempfile::TempDir;
fn keys() {
static SET: std::sync::Once = std::sync::Once::new();
SET.call_once(|| {
for (var, seed) in [
("CHRONIX_TEST_KEY_PHI", 7u8),
("CHRONIX_TEST_KEY_RIGHT", 3),
("CHRONIX_TEST_KEY_WRONG", 9),
("CHRONIX_TEST_KEY_COMPACT", 5),
("CHRONIX_TEST_KEY_TAG", 11),
("CHRONIX_TEST_KEY_BACKUP", 13),
("CHRONIX_TEST_KEY_EXPORT", 17),
] {
let encoded = base64::engine::general_purpose::STANDARD.encode([seed; 32]);
unsafe { std::env::set_var(var, encoded) };
}
});
}
fn settings(column: &str, key_id: &str, var: &str) -> FieldEncryption {
FieldEncryption {
columns: BTreeMap::from([(column.to_owned(), key_id.to_owned())]),
keys: BTreeMap::from([(key_id.to_owned(), var.to_owned())]),
}
}
fn open_with(dir: &Path, enc: FieldEncryption) -> Result<Chronix, chronix::DbError> {
Chronix::open(
ChronixConfig::builder()
.data_dir(dir)
.field_encryption(enc)
.maintenance_interval(Duration::from_secs(86_400 * 365))
.build()
.unwrap(),
)
}
fn point(secret: &str, ts: i64) -> Point {
Point::new(
SeriesKey::new("visits", tags! { "clinic" => "north" }).unwrap(),
fields! { "patient_id" => secret, "minutes" => 12.0 },
ts,
)
.unwrap()
}
fn read_back(db: &Chronix) -> Vec<String> {
let plan = db
.query()
.measurement("visits")
.range(i64::MIN, i64::MAX)
.build()
.unwrap();
let batch = db.execute(&plan).unwrap();
let col = batch.column_by_name("patient_id").unwrap();
let arr = col
.as_any()
.downcast_ref::<arrow::array::StringArray>()
.unwrap();
(0..arr.len()).map(|i| arr.value(i).to_owned()).collect()
}
fn all_segment_bytes(dir: &Path) -> Vec<u8> {
let mut out = Vec::new();
let mut stack = vec![dir.join("segments")];
while let Some(d) = stack.pop() {
let Ok(entries) = std::fs::read_dir(&d) else {
continue;
};
for entry in entries.flatten() {
let p = entry.path();
if p.is_dir() {
stack.push(p);
} else if p.extension().is_some_and(|e| e == "csx") {
out.extend(std::fs::read(&p).unwrap());
}
}
}
out
}
#[test]
fn the_plaintext_is_not_in_the_segment_file() {
keys();
let dir = TempDir::new().unwrap();
let secret = "PATIENT-0000-SECRET-VALUE";
let db = open_with(
dir.path(),
settings("patient_id", "phi", "CHRONIX_TEST_KEY_PHI"),
)
.unwrap();
for ts in 0..200i64 {
db.insert(&point(secret, ts * 1_000_000)).unwrap();
}
db.flush().unwrap();
assert_eq!(read_back(&db)[0], secret, "it still reads back");
db.close().unwrap();
let bytes = all_segment_bytes(dir.path());
assert!(!bytes.is_empty(), "there has to be a segment to look at");
assert!(
!bytes.windows(secret.len()).any(|w| w == secret.as_bytes()),
"the declared column's plaintext is in the segment file"
);
assert!(
bytes.windows(5).any(|w| w == b"north"),
"an undeclared tag must be findable — otherwise the test above proves nothing"
);
}
#[test]
fn a_missing_key_is_a_startup_error() {
keys();
let dir = TempDir::new().unwrap();
let err = open_with(
dir.path(),
settings("patient_id", "a", "CHRONIX_TEST_KEY_NOBODY_SETS_THIS"),
)
.expect_err("a declared key that does not resolve must stop the open");
let message = err.to_string();
assert!(
message.contains("CHRONIX_TEST_KEY_NOBODY_SETS_THIS"),
"{message}"
);
assert!(
!message.contains("AAAA"),
"the message must never carry key material: {message}"
);
}
#[test]
fn the_wrong_key_cannot_read_the_column() {
keys();
let dir = TempDir::new().unwrap();
let db = open_with(
dir.path(),
settings("patient_id", "k", "CHRONIX_TEST_KEY_RIGHT"),
)
.unwrap();
db.insert(&point("secret", 1_000)).unwrap();
db.flush().unwrap();
db.close().unwrap();
let db = open_with(
dir.path(),
settings("patient_id", "k", "CHRONIX_TEST_KEY_WRONG"),
)
.unwrap();
let plan = db
.query()
.measurement("visits")
.range(i64::MIN, i64::MAX)
.build()
.unwrap();
assert!(
db.execute(&plan).is_err(),
"a wrong key must fail loudly, not return ciphertext as a string"
);
db.close().unwrap();
}
#[test]
fn compaction_keeps_the_column_encrypted() {
keys();
let dir = TempDir::new().unwrap();
let secret = "SECRET-SURVIVES-COMPACTION";
let db = open_with(
dir.path(),
settings("patient_id", "c", "CHRONIX_TEST_KEY_COMPACT"),
)
.unwrap();
for batch in 0..6i64 {
for ts in 0..50i64 {
db.insert(&point(secret, (batch * 50 + ts) * 1_000_000))
.unwrap();
}
db.flush().unwrap();
}
db.compact().unwrap();
db.gc().unwrap();
assert_eq!(read_back(&db)[0], secret, "still readable after compaction");
db.close().unwrap();
let bytes = all_segment_bytes(dir.path());
assert!(
!bytes.windows(secret.len()).any(|w| w == secret.as_bytes()),
"compaction wrote the column back in plaintext"
);
}
#[test]
fn a_tag_column_cannot_be_encrypted() {
keys();
let dir = TempDir::new().unwrap();
let db = open_with(dir.path(), settings("clinic", "t", "CHRONIX_TEST_KEY_TAG")).unwrap();
db.insert(&point("x", 1_000)).unwrap();
let err = db
.flush()
.expect_err("a segment naming an encrypted tag must not be written");
let message = err.to_string();
assert!(message.contains("clinic"), "{message}");
assert!(message.contains("tag"), "{message}");
}
#[test]
fn a_backup_of_an_encrypted_database_is_ciphertext() {
keys();
let dir = TempDir::new().unwrap();
let data = dir.path().join("db");
let backup = dir.path().join("backup");
let secret = "SECRET-IN-A-BACKUP";
let db = open_with(
&data,
settings("patient_id", "b", "CHRONIX_TEST_KEY_BACKUP"),
)
.unwrap();
for ts in 0..200i64 {
db.insert(&point(secret, ts * 1_000_000)).unwrap();
}
db.backup(&backup).unwrap();
db.close().unwrap();
let bytes = all_segment_bytes(&backup);
assert!(!bytes.is_empty());
assert!(
!bytes.windows(secret.len()).any(|w| w == secret.as_bytes()),
"the backup carries the plaintext"
);
}
#[test]
fn an_export_of_an_encrypted_column_is_refused() {
keys();
let dir = TempDir::new().unwrap();
let db = open_with(
dir.path(),
settings("patient_id", "e", "CHRONIX_TEST_KEY_EXPORT"),
)
.unwrap();
db.insert(&point("secret", 1_000)).unwrap();
db.flush().unwrap();
let plan = db
.query()
.measurement("visits")
.range(i64::MIN, i64::MAX)
.build()
.unwrap();
let err = db
.export_parquet(
&plan,
&dir.path().join("out.parquet"),
&chronix::ParquetExportConfig::default(),
)
.expect_err("a Parquet export would write the plaintext");
assert!(err.to_string().contains("patient_id"), "{err}");
let rollup = chronix::RollupBuilder::new()
.name("hourly")
.source("visits")
.target("visits_hourly")
.bucket(chronix_core::timebucket::TimeBucket::fixed_ns(
3_600_000_000_000,
))
.aggregation(chronix::RollupAggFn::Avg)
.build()
.unwrap();
let err = db
.create_rollup(rollup)
.expect_err("a rollup would write the aggregate in plaintext");
assert!(err.to_string().contains("patient_id"), "{err}");
db.close().unwrap();
}
#[test]
fn an_undeclared_key_is_a_configuration_error() {
let enc = FieldEncryption {
columns: BTreeMap::from([("patient_id".to_owned(), "missing".to_owned())]),
keys: BTreeMap::new(),
};
let err = ChronixConfig::builder()
.data_dir("/tmp/chronix-unused")
.field_encryption(enc)
.build()
.expect_err("a column naming an undeclared key must not build");
assert!(err.to_string().contains("missing"), "{err}");
}
#[test]
fn a_restore_without_the_key_says_what_is_wrong() {
keys();
let dir = TempDir::new().unwrap();
let data = dir.path().join("db");
let backup = dir.path().join("backup");
let restored = dir.path().join("restored");
let db = open_with(
&data,
settings("patient_id", "b", "CHRONIX_TEST_KEY_BACKUP"),
)
.unwrap();
for ts in 0..200i64 {
db.insert(&point("secret", ts * 1_000_000)).unwrap();
}
db.backup(&backup).unwrap();
db.close().unwrap();
Chronix::restore(&backup, &restored).unwrap();
let plain = Chronix::open(
ChronixConfig::builder()
.data_dir(&restored)
.maintenance_interval(Duration::from_secs(86_400 * 365))
.build()
.unwrap(),
)
.expect("the database still opens — the segments are just bytes");
let plan = plain
.query()
.measurement("visits")
.range(i64::MIN, i64::MAX)
.field("minutes")
.build()
.unwrap();
assert_eq!(
plain.execute(&plan).unwrap().num_rows(),
200,
"a column that was never encrypted must still read"
);
let plan = plain
.query()
.measurement("visits")
.range(i64::MIN, i64::MAX)
.build()
.unwrap();
let err = plain
.execute(&plan)
.expect_err("an encrypted column with no key must not be readable");
let message = err.to_string();
assert!(
message.contains("patient_id"),
"the message must name the column: {message}"
);
assert!(
message.contains("key"),
"the message must say this is a key problem, not corruption: {message}"
);
assert!(
!message.contains("corrupt"),
"a missing key is not corruption, and the two have different fixes: {message}"
);
plain.close().unwrap();
}