use std::fmt;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum DataKind {
Contact,
Identifier,
Fitness,
Usage,
Content,
Financial,
}
impl DataKind {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Contact => "contact",
Self::Identifier => "identifier",
Self::Fitness => "fitness",
Self::Usage => "usage",
Self::Content => "content",
Self::Financial => "financial",
}
}
}
impl fmt::Display for DataKind {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum Disposition {
Erase,
Anonymise(&'static [&'static str]),
Retain(&'static str),
Unreachable(&'static str),
}
impl Disposition {
#[must_use]
pub const fn keeps_row(self) -> bool {
matches!(
self,
Self::Anonymise(_) | Self::Retain(_) | Self::Unreachable(_)
)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct PersonalDataSet {
pub table: &'static str,
pub subject: &'static str,
pub kind: DataKind,
pub disposition: Disposition,
pub description: &'static str,
pub redacted: &'static [&'static str],
pub subject_via: Option<SubjectVia>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SubjectVia {
pub table: &'static str,
pub subject: &'static str,
pub key: &'static str,
}
impl PersonalDataSet {
#[must_use]
pub const fn none(table: &'static str, reason: &'static str) -> Self {
Self {
table,
subject: "",
kind: DataKind::Identifier,
disposition: Disposition::Retain(reason),
description: "",
redacted: &[],
subject_via: None,
}
}
#[must_use]
pub const fn unreachable(
table: &'static str,
kind: DataKind,
description: &'static str,
reason: &'static str,
) -> Self {
Self {
table,
subject: "",
kind,
disposition: Disposition::Unreachable(reason),
description,
redacted: &[],
subject_via: None,
}
}
#[must_use]
pub const fn is_none(&self) -> bool {
self.subject.is_empty()
}
#[must_use]
pub const fn is_unreachable(&self) -> bool {
matches!(self.disposition, Disposition::Unreachable(_))
}
#[must_use]
pub fn validate(&self, module: &str, owns: &[&'static str]) -> Vec<String> {
let mut errors: Vec<String> = Vec::new();
for (label, name) in [("table", self.table), ("subject column", self.subject)] {
if name.is_empty() {
continue;
}
if !is_plain_identifier(name) {
errors.push(format!(
"module `{module}` declares {label} `{name}`, which is not a plain identifier"
));
}
}
if let Disposition::Anonymise(columns) = self.disposition {
for column in columns {
if !is_plain_identifier(column) {
errors.push(format!(
"module `{module}` anonymises column `{column}` of `{}`, which is not a plain identifier",
self.table
));
}
}
}
if let Some(via) = self.subject_via {
for (label, name) in [
("via table", via.table),
("via subject column", via.subject),
("via key column", via.key),
] {
check_identifier(
&mut errors,
module,
label,
name,
&format!("for `{}`", self.table),
);
}
}
for column in self.redacted {
if !is_plain_identifier(column) {
errors.push(format!(
"module `{module}` redacts column `{column}` of `{}`, which is not a plain identifier",
self.table
));
}
if *column == self.subject && !self.subject.is_empty() {
errors.push(format!(
"module `{module}` redacts the subject column `{}` of `{}`, which the caller already has",
self.subject, self.table
));
}
}
if !owns.contains(&self.table) {
errors.push(format!(
"module `{module}` declares personal data in table `{}`, which it does not own",
self.table
));
}
if self.is_none() {
if self.is_unreachable() {
errors.extend(self.unreachable_errors(module));
} else {
errors.extend(self.none_errors(module));
}
return errors;
}
if self.description.trim().is_empty() {
errors.push(format!(
"module `{module}` declares personal data in `{}` with no description; it is published verbatim",
self.table
));
}
match self.disposition {
Disposition::Anonymise([]) => errors.push(format!(
"module `{module}` anonymises `{}` without naming a column, which would erase nothing",
self.table
)),
Disposition::Anonymise(columns) if columns.contains(&self.subject) => {
errors.push(format!(
"module `{module}` anonymises the subject column `{}` of `{}`; erasure could then never find the row again",
self.subject, self.table
));
}
Disposition::Retain(reason) if reason.trim().is_empty() => errors.push(format!(
"module `{module}` retains personal data in `{}` without saying why",
self.table
)),
_ => {}
}
errors
}
fn unreachable_errors(&self, module: &str) -> Vec<String> {
let mut errors: Vec<String> = Vec::new();
if let Disposition::Unreachable(reason) = self.disposition
&& reason.trim().is_empty()
{
errors.push(format!(
"module `{module}` declares personal data in `{}` as unreachable without saying why",
self.table
));
}
if self.description.trim().is_empty() {
errors.push(format!(
"module `{module}` declares personal data in `{}` as unreachable with no description; it is published verbatim",
self.table
));
}
if self.subject_via.is_some() {
errors.push(format!(
"module `{module}` declares table `{}` as unreachable and names a way to reach a subject; one or the other",
self.table
));
}
if !self.redacted.is_empty() {
errors.push(format!(
"module `{module}` redacts a column of `{}`, which is declared unreachable; nothing is exported from it to redact",
self.table
));
}
errors
}
fn none_errors(&self, module: &str) -> Vec<String> {
let mut errors: Vec<String> = Vec::new();
if self.subject_via.is_some() {
errors.push(format!(
"module `{module}` declares table `{}` as holding no personal data and names a way to reach a subject; one or the other",
self.table
));
}
if let Disposition::Retain(reason) = self.disposition
&& reason.trim().is_empty()
{
errors.push(format!(
"module `{module}` declares table `{}` as holding no personal data without saying why",
self.table
));
}
if !self.redacted.is_empty() {
errors.push(format!(
"module `{module}` redacts a column of `{}`, which it declares holds no personal data; nothing is exported from it to redact",
self.table
));
}
errors
}
}
fn check_identifier(errors: &mut Vec<String>, module: &str, label: &str, name: &str, where_: &str) {
if !is_plain_identifier(name) {
errors.push(format!(
"module `{module}` declares {label} `{name}` {where_}, which is not a plain identifier"
));
}
}
#[derive(Debug, Clone, Default)]
pub struct PersonalDataCatalog {
entries: Vec<CatalogEntry>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct CatalogEntry {
pub module: &'static str,
pub set: PersonalDataSet,
}
impl PersonalDataCatalog {
#[must_use]
pub fn compose<'a>(
modules: impl IntoIterator<Item = (&'static str, &'a [PersonalDataSet])>,
) -> Self {
let mut entries = Vec::new();
for (module, sets) in modules {
for set in sets {
entries.push(CatalogEntry { module, set: *set });
}
}
Self { entries }
}
#[must_use]
pub fn entries(&self) -> &[CatalogEntry] {
&self.entries
}
pub fn subject_sets(&self) -> impl Iterator<Item = &CatalogEntry> {
self.entries.iter().filter(|e| !e.set.is_none())
}
#[must_use]
pub fn is_empty(&self) -> bool {
!self
.entries
.iter()
.any(|e| !e.set.is_none() || e.set.is_unreachable())
}
}
#[must_use]
pub fn undeclared_tables(module: &dyn crate::Module) -> Vec<&'static str> {
let declared: Vec<&'static str> = module.personal_data().iter().map(|set| set.table).collect();
module
.tables()
.iter()
.copied()
.filter(|table| !declared.contains(table))
.collect()
}
#[must_use]
pub fn migration_tables(module: &dyn crate::Module) -> Vec<String> {
let migrations = module.migrations();
let mut found: Vec<String> = Vec::new();
for set in [migrations.sqlite, migrations.postgres] {
let sql = set
.iter()
.map(|migration| migration.sql)
.collect::<Vec<_>>()
.join("\n");
for table in crate::lint::created_tables(&sql) {
if !found.iter().any(|seen| seen.eq_ignore_ascii_case(&table)) {
found.push(table);
}
}
}
found
}
#[must_use]
pub fn unlisted_tables(module: &dyn crate::Module) -> Vec<String> {
let listed = module.tables();
let mut found = migration_tables(module);
found.retain(|table| !listed.iter().any(|name| name.eq_ignore_ascii_case(table)));
found
}
#[must_use]
pub fn is_plain_identifier(name: &str) -> bool {
if name.is_empty() || name.len() > 63 {
return false;
}
let mut chars = name.chars();
let first = chars.next().unwrap_or('\0');
if !(first.is_ascii_alphabetic() || first == '_') {
return false;
}
chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
}