366 lines
15 KiB
Rust
366 lines
15 KiB
Rust
use crate::db::topology::Topology;
|
|
use crate::identity::TenantId;
|
|
use chrono::{DateTime, Utc};
|
|
use rusqlite::Connection;
|
|
use std::path::{Path, PathBuf};
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub enum RegistryIssueType {
|
|
MissingTenant,
|
|
MissingTarget,
|
|
CorruptDatabase,
|
|
AccessFailure,
|
|
TrueOrphan,
|
|
Conflict,
|
|
StaleReservation,
|
|
InvalidStatus,
|
|
InvalidTargetType,
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct RegistryIssue {
|
|
pub slug: String,
|
|
pub target_type: String,
|
|
pub owner_tenant_id: String,
|
|
pub database_path: PathBuf,
|
|
pub target_id: String,
|
|
pub issue_type: RegistryIssueType,
|
|
pub description: String,
|
|
}
|
|
|
|
pub struct RegistryValidator;
|
|
|
|
impl RegistryValidator {
|
|
/// Scans the v0.8 slug registries (`global_urls.db`, `global_landing_pages.db`, `reserved.db`)
|
|
/// and returns a list of detected issues categorized per safety policies.
|
|
pub fn scan(
|
|
_system_conn: &Connection,
|
|
users_conn: &Connection,
|
|
data_dir: &Path,
|
|
slug_filter: Option<&str>,
|
|
) -> Result<Vec<RegistryIssue>, Box<dyn std::error::Error>> {
|
|
let topology = Topology::new(data_dir);
|
|
let mut issues = Vec::new();
|
|
|
|
let urls_path = topology.global_urls_db();
|
|
let pages_path = topology.global_landing_pages_db();
|
|
let reserved_path = topology.reserved_db();
|
|
|
|
if !urls_path.exists() || !pages_path.exists() || !reserved_path.exists() {
|
|
issues.push(RegistryIssue {
|
|
slug: "*".to_string(),
|
|
target_type: "system".to_string(),
|
|
owner_tenant_id: "".to_string(),
|
|
database_path: urls_path,
|
|
target_id: "".to_string(),
|
|
issue_type: RegistryIssueType::AccessFailure,
|
|
description: "One or more v0.8 slug databases are missing from disk".to_string(),
|
|
});
|
|
return Ok(issues);
|
|
}
|
|
|
|
let urls_conn = Connection::open(&urls_path)?;
|
|
let pages_conn = Connection::open(&pages_path)?;
|
|
let reserved_conn = Connection::open(&reserved_path)?;
|
|
|
|
// 1. Scan global_urls.db
|
|
Self::scan_table(
|
|
&urls_conn,
|
|
users_conn,
|
|
&reserved_conn,
|
|
&pages_conn,
|
|
&topology,
|
|
"url",
|
|
slug_filter,
|
|
&mut issues,
|
|
)?;
|
|
|
|
// 2. Scan global_landing_pages.db
|
|
Self::scan_table(
|
|
&pages_conn,
|
|
users_conn,
|
|
&reserved_conn,
|
|
&urls_conn,
|
|
&topology,
|
|
"page",
|
|
slug_filter,
|
|
&mut issues,
|
|
)?;
|
|
|
|
Ok(issues)
|
|
}
|
|
|
|
#[allow(clippy::too_many_arguments)]
|
|
fn scan_table(
|
|
conn: &Connection,
|
|
users_conn: &Connection,
|
|
reserved_conn: &Connection,
|
|
other_conn: &Connection,
|
|
topology: &Topology,
|
|
target_type: &str,
|
|
slug_filter: Option<&str>,
|
|
issues: &mut Vec<RegistryIssue>,
|
|
) -> Result<(), Box<dyn std::error::Error>> {
|
|
let (query, params_vec) = if let Some(slug) = slug_filter {
|
|
(
|
|
format!(
|
|
"SELECT slug, owner_tenant_id, target_id, created_at, status FROM global_{}s WHERE slug = ?1;",
|
|
if target_type == "url" { "url" } else { "landing_page" }
|
|
),
|
|
vec![slug.to_string()],
|
|
)
|
|
} else {
|
|
(
|
|
format!(
|
|
"SELECT slug, owner_tenant_id, target_id, created_at, status FROM global_{}s;",
|
|
if target_type == "url" {
|
|
"url"
|
|
} else {
|
|
"landing_page"
|
|
}
|
|
),
|
|
vec![],
|
|
)
|
|
};
|
|
|
|
let mut stmt = conn.prepare(&query)?;
|
|
let mut rows = stmt.query(rusqlite::params_from_iter(params_vec))?;
|
|
|
|
while let Some(row) = rows.next()? {
|
|
let slug: String = row.get(0)?;
|
|
let owner_tenant_id_str: String = row.get(1)?;
|
|
let target_id: String = row.get(2)?;
|
|
let created_at_str: String = row.get(3)?;
|
|
let status: String = row.get(4)?;
|
|
|
|
let tenant_id_res = TenantId::parse(&owner_tenant_id_str);
|
|
let content_db_path = match tenant_id_res {
|
|
Ok(tid) => topology.tenant_dir(tid).join("content.db"),
|
|
Err(_) => topology.users_dir().join("_invalid").join("content.db"),
|
|
};
|
|
|
|
// 1. Conflict with reserved.db
|
|
let is_reserved: bool = reserved_conn
|
|
.query_row(
|
|
"SELECT EXISTS(SELECT 1 FROM reserved_slugs WHERE slug = ?1);",
|
|
[&slug],
|
|
|r| r.get(0),
|
|
)
|
|
.unwrap_or(false);
|
|
|
|
if is_reserved {
|
|
issues.push(RegistryIssue {
|
|
slug: slug.clone(),
|
|
target_type: target_type.to_string(),
|
|
owner_tenant_id: owner_tenant_id_str.clone(),
|
|
database_path: topology.reserved_db(),
|
|
target_id: target_id.clone(),
|
|
issue_type: RegistryIssueType::Conflict,
|
|
description: format!("Slug '{}' conflicts with a reserved system route", slug),
|
|
});
|
|
}
|
|
|
|
// 2. Conflict with the other slug database
|
|
let other_table = if target_type == "url" {
|
|
"global_landing_pages"
|
|
} else {
|
|
"global_urls"
|
|
};
|
|
let in_other: bool = other_conn
|
|
.query_row(
|
|
&format!("SELECT EXISTS(SELECT 1 FROM {other_table} WHERE slug = ?1);"),
|
|
[&slug],
|
|
|r| r.get(0),
|
|
)
|
|
.unwrap_or(false);
|
|
|
|
if in_other {
|
|
issues.push(RegistryIssue {
|
|
slug: slug.clone(),
|
|
target_type: target_type.to_string(),
|
|
owner_tenant_id: owner_tenant_id_str.clone(),
|
|
database_path: conn.path().map(PathBuf::from).unwrap_or_default(),
|
|
target_id: target_id.clone(),
|
|
issue_type: RegistryIssueType::Conflict,
|
|
description: format!(
|
|
"Slug '{}' exists in both global_urls.db and global_landing_pages.db",
|
|
slug
|
|
),
|
|
});
|
|
}
|
|
|
|
// 3. Status check
|
|
if status != "active"
|
|
&& status != "disabled"
|
|
&& status != "reserving"
|
|
&& status != "retired"
|
|
{
|
|
issues.push(RegistryIssue {
|
|
slug: slug.clone(),
|
|
target_type: target_type.to_string(),
|
|
owner_tenant_id: owner_tenant_id_str.clone(),
|
|
database_path: conn.path().map(PathBuf::from).unwrap_or_default(),
|
|
target_id: target_id.clone(),
|
|
issue_type: RegistryIssueType::InvalidStatus,
|
|
description: format!("Slug '{}' has invalid status '{}'", slug, status),
|
|
});
|
|
}
|
|
|
|
// If retired, no active target check is needed
|
|
if status == "retired" {
|
|
continue;
|
|
}
|
|
|
|
// 4. Owner tenant check in users.db
|
|
let tid = match tenant_id_res {
|
|
Ok(t) => t,
|
|
Err(_) => {
|
|
issues.push(RegistryIssue {
|
|
slug: slug.clone(),
|
|
target_type: target_type.to_string(),
|
|
owner_tenant_id: owner_tenant_id_str.clone(),
|
|
database_path: content_db_path,
|
|
target_id: target_id.clone(),
|
|
issue_type: RegistryIssueType::MissingTenant,
|
|
description: format!(
|
|
"Slug '{}' has invalid TenantId '{}'",
|
|
slug, owner_tenant_id_str
|
|
),
|
|
});
|
|
continue;
|
|
}
|
|
};
|
|
|
|
let owner_opt = crate::db::users::get_user_by_tenant_id(users_conn, tid)?;
|
|
let owner_exists = match owner_opt {
|
|
Some(ref u) => u.status != "deleted",
|
|
None => false,
|
|
};
|
|
|
|
if !owner_exists {
|
|
issues.push(RegistryIssue {
|
|
slug: slug.clone(),
|
|
target_type: target_type.to_string(),
|
|
owner_tenant_id: owner_tenant_id_str.clone(),
|
|
database_path: content_db_path.clone(),
|
|
target_id: target_id.clone(),
|
|
issue_type: RegistryIssueType::MissingTenant,
|
|
description: format!(
|
|
"Slug '{}' references missing or deleted owner tenant '{}'",
|
|
slug, owner_tenant_id_str
|
|
),
|
|
});
|
|
continue;
|
|
}
|
|
|
|
// 5. Stale reservation check
|
|
if status == "reserving" {
|
|
if let Ok(created_at) = DateTime::parse_from_rfc3339(&created_at_str) {
|
|
let age = Utc::now().signed_duration_since(created_at.with_timezone(&Utc));
|
|
if age > chrono::Duration::try_minutes(15).unwrap_or_default() {
|
|
issues.push(RegistryIssue {
|
|
slug: slug.clone(),
|
|
target_type: target_type.to_string(),
|
|
owner_tenant_id: owner_tenant_id_str.clone(),
|
|
database_path: content_db_path.clone(),
|
|
target_id: target_id.clone(),
|
|
issue_type: RegistryIssueType::StaleReservation,
|
|
description: format!(
|
|
"Reserving slug '{}' has been stale for over 15 minutes",
|
|
slug
|
|
),
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
// 6. Target record check in tenant content DB
|
|
if status == "active" || status == "disabled" {
|
|
if !content_db_path.exists() {
|
|
issues.push(RegistryIssue {
|
|
slug: slug.clone(),
|
|
target_type: target_type.to_string(),
|
|
owner_tenant_id: owner_tenant_id_str.clone(),
|
|
database_path: content_db_path.clone(),
|
|
target_id: target_id.clone(),
|
|
issue_type: RegistryIssueType::TrueOrphan,
|
|
description: format!(
|
|
"Slug '{}' owner content database does not exist at {:?}",
|
|
slug, content_db_path
|
|
),
|
|
});
|
|
} else {
|
|
match Connection::open(&content_db_path) {
|
|
Ok(tenant_conn) => {
|
|
let exists = if target_type == "url" {
|
|
tenant_conn
|
|
.query_row(
|
|
"SELECT EXISTS(SELECT 1 FROM urls WHERE id = ?1);",
|
|
[&target_id],
|
|
|r| r.get(0),
|
|
)
|
|
.unwrap_or(false)
|
|
} else {
|
|
tenant_conn
|
|
.query_row(
|
|
"SELECT EXISTS(SELECT 1 FROM landing_pages WHERE id = ?1);",
|
|
[&target_id],
|
|
|r| r.get(0),
|
|
)
|
|
.unwrap_or(false)
|
|
};
|
|
|
|
if !exists {
|
|
issues.push(RegistryIssue {
|
|
slug: slug.clone(),
|
|
target_type: target_type.to_string(),
|
|
owner_tenant_id: owner_tenant_id_str.clone(),
|
|
database_path: content_db_path.clone(),
|
|
target_id: target_id.clone(),
|
|
issue_type: RegistryIssueType::MissingTarget,
|
|
description: format!(
|
|
"Slug '{}' (type: '{}', id: '{}') references missing target record in tenant content database",
|
|
slug, target_type, target_id
|
|
),
|
|
});
|
|
}
|
|
}
|
|
Err(rusqlite::Error::SqliteFailure(err, _))
|
|
if err.code == rusqlite::ErrorCode::DatabaseCorrupt =>
|
|
{
|
|
issues.push(RegistryIssue {
|
|
slug: slug.clone(),
|
|
target_type: target_type.to_string(),
|
|
owner_tenant_id: owner_tenant_id_str.clone(),
|
|
database_path: content_db_path.clone(),
|
|
target_id: target_id.clone(),
|
|
issue_type: RegistryIssueType::CorruptDatabase,
|
|
description: format!(
|
|
"Slug '{}' owner content database is corrupt at {:?}",
|
|
slug, content_db_path
|
|
),
|
|
});
|
|
}
|
|
Err(e) => {
|
|
issues.push(RegistryIssue {
|
|
slug: slug.clone(),
|
|
target_type: target_type.to_string(),
|
|
owner_tenant_id: owner_tenant_id_str.clone(),
|
|
database_path: content_db_path.clone(),
|
|
target_id: target_id.clone(),
|
|
issue_type: RegistryIssueType::AccessFailure,
|
|
description: format!(
|
|
"Slug '{}' owner content database could not be accessed: {}",
|
|
slug, e
|
|
),
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
}
|