Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2,353 changes: 2,353 additions & 0 deletions Cargo.lock

Large diffs are not rendered by default.

16 changes: 16 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,19 @@ keywords = ["privacy", "data-brokers", "cli", "opt-out"]
categories = ["command-line-utilities"]

[dependencies]
tokio = { version = "1", features = ["full"] }
clap = { version = "4", features = ["derive"] }
rusqlite = { version = "0.32", features = ["bundled"] }
reqwest = { version = "0.12", features = ["json", "rustls-tls"], default-features = false }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
toml = "0.8"
directories = "6"
thiserror = "2"
anyhow = "1"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
comfy-table = "7"
uuid = { version = "1", features = ["v4"] }
chrono = { version = "0.4", features = ["serde"] }
async-trait = "0.1"
95 changes: 95 additions & 0 deletions src/broker/dummy.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
use async_trait::async_trait;

use super::{
BrokerConnector, ConnectorCapabilities, DeletionStatusCheck, DeletionSubmission, FoundRecord,
PersonQuery,
};

/// A dummy broker connector for testing and demonstration.
/// Returns fake data — useful for verifying the CLI pipeline works end-to-end.
pub struct DummyBroker;

#[async_trait]
impl BrokerConnector for DummyBroker {
fn id(&self) -> &str {
"dummy-broker"
}

fn name(&self) -> &str {
"Dummy Broker"
}

fn capabilities(&self) -> ConnectorCapabilities {
ConnectorCapabilities {
can_scan: true,
can_delete: true,
can_check_status: true,
}
}

async fn scan(&self, query: &PersonQuery) -> anyhow::Result<Vec<FoundRecord>> {
let full_name = format!("{} {}", query.first_name, query.last_name);

let mut records = vec![
FoundRecord {
data_type: "name".into(),
data_value: full_name,
profile_url: Some("https://dummy-broker.example.com/profile/12345".into()),
metadata: None,
},
FoundRecord {
data_type: "address".into(),
data_value: format!(
"123 Main St, {}, {}",
query.city.as_deref().unwrap_or("Anytown"),
query.state.as_deref().unwrap_or("CA")
),
profile_url: Some("https://dummy-broker.example.com/profile/12345".into()),
metadata: None,
},
];

if let Some(email) = &query.email {
records.push(FoundRecord {
data_type: "email".into(),
data_value: email.clone(),
profile_url: None,
metadata: None,
});
}

if let Some(phone) = &query.phone {
records.push(FoundRecord {
data_type: "phone".into(),
data_value: phone.clone(),
profile_url: None,
metadata: None,
});
}

Ok(records)
}

async fn request_deletion(
&self,
_query: &PersonQuery,
_records: &[FoundRecord],
) -> anyhow::Result<DeletionSubmission> {
Ok(DeletionSubmission {
external_ref: format!("DUMMY-{}", uuid::Uuid::new_v4()),
message: Some("Deletion request submitted to Dummy Broker".into()),
})
}

async fn check_deletion_status(
&self,
external_ref: &str,
) -> anyhow::Result<DeletionStatusCheck> {
// Always returns in_progress for the dummy
Ok(DeletionStatusCheck {
status: "in_progress".into(),
completed_at: None,
message: Some(format!("Request {external_ref} is being processed")),
})
}
}
127 changes: 127 additions & 0 deletions src/broker/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
pub mod dummy;
pub mod registry;

use std::collections::HashMap;
use std::sync::Arc;

use async_trait::async_trait;
use serde::{Deserialize, Serialize};

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PersonQuery {
pub first_name: String,
pub last_name: String,
pub email: Option<String>,
pub phone: Option<String>,
pub city: Option<String>,
pub state: Option<String>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FoundRecord {
pub data_type: String,
pub data_value: String,
pub profile_url: Option<String>,
pub metadata: Option<serde_json::Value>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DeletionSubmission {
pub external_ref: String,
pub message: Option<String>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DeletionStatusCheck {
pub status: String,
pub completed_at: Option<String>,
pub message: Option<String>,
}

#[derive(Debug, Clone)]
pub struct ConnectorCapabilities {
pub can_scan: bool,
pub can_delete: bool,
pub can_check_status: bool,
}

#[async_trait]
pub trait BrokerConnector: Send + Sync {
fn id(&self) -> &str;
fn name(&self) -> &str;
fn capabilities(&self) -> ConnectorCapabilities;
async fn scan(&self, query: &PersonQuery) -> anyhow::Result<Vec<FoundRecord>>;
async fn request_deletion(
&self,
query: &PersonQuery,
records: &[FoundRecord],
) -> anyhow::Result<DeletionSubmission>;
async fn check_deletion_status(
&self,
external_ref: &str,
) -> anyhow::Result<DeletionStatusCheck>;
}

/// Build the map of all compiled-in connectors.
/// Contributors: add your connector here.
pub fn build_connector_registry() -> HashMap<String, Arc<dyn BrokerConnector>> {
let mut map: HashMap<String, Arc<dyn BrokerConnector>> = HashMap::new();

let dummy = Arc::new(dummy::DummyBroker);
map.insert(dummy.id().to_string(), dummy);

map
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn test_build_connector_registry() {
let reg = build_connector_registry();
assert!(reg.contains_key("dummy-broker"));
let dummy = reg.get("dummy-broker").unwrap();
assert_eq!(dummy.name(), "Dummy Broker");
assert!(dummy.capabilities().can_scan);
assert!(dummy.capabilities().can_delete);
assert!(dummy.capabilities().can_check_status);
}

#[tokio::test]
async fn test_dummy_scan() {
let dummy = dummy::DummyBroker;
let query = PersonQuery {
first_name: "John".into(),
last_name: "Doe".into(),
email: Some("john@example.com".into()),
phone: None,
city: None,
state: None,
};
let results = dummy.scan(&query).await.unwrap();
assert!(!results.is_empty());
}

#[tokio::test]
async fn test_dummy_deletion() {
let dummy = dummy::DummyBroker;
let query = PersonQuery {
first_name: "John".into(),
last_name: "Doe".into(),
email: None,
phone: None,
city: None,
state: None,
};
let records = dummy.scan(&query).await.unwrap();
let submission = dummy.request_deletion(&query, &records).await.unwrap();
assert!(!submission.external_ref.is_empty());

let status = dummy
.check_deletion_status(&submission.external_ref)
.await
.unwrap();
assert_eq!(status.status, "in_progress");
}
}
48 changes: 48 additions & 0 deletions src/broker/registry.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
use serde::Deserialize;

use crate::config::REGISTRY_URL;
use crate::db::models::Broker;

#[derive(Debug, Deserialize)]
struct RegistryBroker {
id: String,
name: String,
website: Option<String>,
description: Option<String>,
category: Option<String>,
connector: Option<String>,
}

/// Fetch the broker registry from the remote URL and return Broker models.
pub async fn fetch_registry() -> anyhow::Result<Vec<Broker>> {
let client = reqwest::Client::new();
let resp = client
.get(REGISTRY_URL)
.header("User-Agent", "data-breaker")
.send()
.await?;

if !resp.status().is_success() {
anyhow::bail!("Failed to fetch registry: HTTP {}", resp.status());
}

let registry_brokers: Vec<RegistryBroker> = resp.json().await?;
let now = chrono::Utc::now().to_rfc3339();

let brokers = registry_brokers
.into_iter()
.map(|rb| Broker {
id: rb.id,
name: rb.name,
website: rb.website,
description: rb.description,
category: rb.category,
connector: rb.connector,
registry_updated_at: Some(now.clone()),
created_at: now.clone(),
updated_at: now.clone(),
})
.collect();

Ok(brokers)
}
56 changes: 56 additions & 0 deletions src/cli/broker.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
use comfy_table::{Cell, Table};

use crate::db::Database;

pub fn list_brokers(db: &Database, category: Option<&str>) -> anyhow::Result<()> {
let brokers = db.list_brokers(category)?;

if brokers.is_empty() {
println!(
"No brokers found. Run `data-breaker registry update` to fetch the broker registry."
);
return Ok(());
}

let mut table = Table::new();
table.set_header(vec!["ID", "Name", "Category", "Website"]);

for b in &brokers {
table.add_row(vec![
Cell::new(&b.id),
Cell::new(&b.name),
Cell::new(b.category.as_deref().unwrap_or("-")),
Cell::new(b.website.as_deref().unwrap_or("-")),
]);
}

println!("{table}");
Ok(())
}

pub fn broker_info(db: &Database, id: &str) -> anyhow::Result<()> {
let broker = db.get_broker(id)?;
match broker {
Some(b) => {
println!("ID: {}", b.id);
println!("Name: {}", b.name);
if let Some(w) = &b.website {
println!("Website: {w}");
}
if let Some(d) = &b.description {
println!("Description: {d}");
}
if let Some(c) = &b.category {
println!("Category: {c}");
}
if let Some(conn) = &b.connector {
println!("Connector: {conn}");
}
println!("Updated: {}", b.updated_at);
}
None => {
anyhow::bail!("Broker '{}' not found", id);
}
}
Ok(())
}
Loading