use std::collections::HashSet;
use crate::gpkg::GeoPackage;
pub const GPKG_APP_ID: u32 = 0x4750_4B47;
pub const MIN_USER_VERSION: u32 = 10_300;
pub const REQUIRED_SRS: &[i32] = &[-1, 0, 4326];
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum IntegrityIssue {
MissingRequiredTable(String),
MissingRequiredSrs {
code: i32,
},
ContentsRefsMissingTable {
table_name: String,
},
GeometryColumnsRefsMissingTable {
table_name: String,
},
GeometryColumnsRefsMissingSrs {
table_name: String,
srs_id: i32,
},
OrphanedExtensionRow {
extension_name: String,
table_name: Option<String>,
},
AppIdMismatch {
actual: u32,
},
UserVersionTooOld {
actual: u32,
minimum: u32,
},
}
impl IntegrityIssue {
pub fn description(&self) -> String {
match self {
IntegrityIssue::MissingRequiredTable(name) => {
format!("required system table '{name}' is missing from sqlite_master")
}
IntegrityIssue::MissingRequiredSrs { code } => {
format!("mandatory SRS row with srs_id={code} is missing from gpkg_spatial_ref_sys")
}
IntegrityIssue::ContentsRefsMissingTable { table_name } => {
format!(
"gpkg_contents references table '{table_name}' which does not exist in the database"
)
}
IntegrityIssue::GeometryColumnsRefsMissingTable { table_name } => {
format!(
"gpkg_geometry_columns references table '{table_name}' which does not exist in the database"
)
}
IntegrityIssue::GeometryColumnsRefsMissingSrs { table_name, srs_id } => {
format!(
"gpkg_geometry_columns row for table '{table_name}' references srs_id={srs_id} which is not present in gpkg_spatial_ref_sys"
)
}
IntegrityIssue::OrphanedExtensionRow {
extension_name,
table_name,
} => match table_name {
Some(t) => format!(
"gpkg_extensions row for extension '{extension_name}' references table '{t}' which does not exist in the database"
),
None => format!(
"gpkg_extensions row for extension '{extension_name}' has a NULL table_name but is flagged as orphaned (should not happen)"
),
},
IntegrityIssue::AppIdMismatch { actual } => {
format!(
"SQLite application_id is 0x{actual:08X} but a GeoPackage must use 0x{GPKG_APP_ID:08X}"
)
}
IntegrityIssue::UserVersionTooOld { actual, minimum } => {
format!("GeoPackage user_version is {actual} but {minimum} or higher is required")
}
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct IntegrityReport {
pub passed: bool,
pub issues: Vec<IntegrityIssue>,
}
impl IntegrityReport {
pub fn issue_count(&self) -> usize {
self.issues.len()
}
pub fn has_issue_of<F>(&self, pred: F) -> bool
where
F: Fn(&IntegrityIssue) -> bool,
{
self.issues.iter().any(pred)
}
}
pub fn check_integrity(gpkg: &GeoPackage) -> IntegrityReport {
let mut issues: Vec<IntegrityIssue> = Vec::new();
check_app_id(gpkg, &mut issues);
check_user_version(gpkg, &mut issues);
check_required_srs(gpkg, &mut issues);
check_contents_refs(gpkg, &mut issues);
check_geometry_columns_refs(gpkg, &mut issues);
check_extensions_refs(gpkg, &mut issues);
IntegrityReport {
passed: issues.is_empty(),
issues,
}
}
pub fn check_integrity_strict(gpkg: &GeoPackage) -> Result<(), Vec<IntegrityIssue>> {
let report = check_integrity(gpkg);
if report.passed {
Ok(())
} else {
Err(report.issues)
}
}
fn check_app_id(gpkg: &GeoPackage, issues: &mut Vec<IntegrityIssue>) {
let actual = gpkg.reader.header.application_id;
if actual != GPKG_APP_ID {
issues.push(IntegrityIssue::AppIdMismatch { actual });
}
}
fn check_user_version(gpkg: &GeoPackage, issues: &mut Vec<IntegrityIssue>) {
let actual = gpkg.reader.header.user_version;
if actual < MIN_USER_VERSION {
issues.push(IntegrityIssue::UserVersionTooOld {
actual,
minimum: MIN_USER_VERSION,
});
}
}
fn check_required_srs(gpkg: &GeoPackage, issues: &mut Vec<IntegrityIssue>) {
let rows = match gpkg.scan_table_by_name("gpkg_spatial_ref_sys") {
Ok(Some(rows)) => rows,
Ok(None) | Err(_) => {
issues.push(IntegrityIssue::MissingRequiredTable(
"gpkg_spatial_ref_sys".to_string(),
));
return;
}
};
let present: HashSet<i32> = rows
.iter()
.filter_map(|(_rowid, cols)| {
if cols.len() < 2 {
return None;
}
cell_to_i32(&cols[1])
})
.collect();
for code in REQUIRED_SRS {
if !present.contains(code) {
issues.push(IntegrityIssue::MissingRequiredSrs { code: *code });
}
}
}
fn check_contents_refs(gpkg: &GeoPackage, issues: &mut Vec<IntegrityIssue>) {
let table_names = match collect_existing_table_names(gpkg) {
Some(s) => s,
None => return, };
if !gpkg.contents.is_empty() {
for row in &gpkg.contents {
if !table_names.contains(row.table_name.as_str()) {
issues.push(IntegrityIssue::ContentsRefsMissingTable {
table_name: row.table_name.clone(),
});
}
}
return;
}
let rows = match gpkg.scan_table_by_name("gpkg_contents") {
Ok(Some(r)) => r,
Ok(None) | Err(_) => return,
};
for (_rowid, cols) in &rows {
if cols.is_empty() {
continue;
}
let referenced = cell_to_string(&cols[0]);
if referenced.is_empty() {
continue;
}
if !table_names.contains(referenced.as_str()) {
issues.push(IntegrityIssue::ContentsRefsMissingTable {
table_name: referenced,
});
}
}
}
fn check_geometry_columns_refs(gpkg: &GeoPackage, issues: &mut Vec<IntegrityIssue>) {
let rows = match gpkg.scan_table_by_name("gpkg_geometry_columns") {
Ok(Some(r)) => r,
Ok(None) | Err(_) => return,
};
let table_names = match collect_existing_table_names(gpkg) {
Some(s) => s,
None => return,
};
let srs_ids = collect_srs_ids(gpkg);
for (_rowid, cols) in &rows {
if cols.len() < 4 {
continue;
}
let referenced_table = cell_to_string(&cols[0]);
if !referenced_table.is_empty() && !table_names.contains(referenced_table.as_str()) {
issues.push(IntegrityIssue::GeometryColumnsRefsMissingTable {
table_name: referenced_table.clone(),
});
}
if let Some(srs_id) = cell_to_i32(&cols[3])
&& !srs_ids.is_empty()
&& !srs_ids.contains(&srs_id)
{
issues.push(IntegrityIssue::GeometryColumnsRefsMissingSrs {
table_name: referenced_table,
srs_id,
});
}
}
}
fn check_extensions_refs(gpkg: &GeoPackage, issues: &mut Vec<IntegrityIssue>) {
let rows = match gpkg.scan_table_by_name("gpkg_extensions") {
Ok(Some(r)) => r,
Ok(None) | Err(_) => return,
};
let table_names = match collect_existing_table_names(gpkg) {
Some(s) => s,
None => return,
};
for (_rowid, cols) in &rows {
if cols.len() < 3 {
continue;
}
let table_name_opt = cell_to_optional_string(&cols[0]);
let extension_name = cell_to_string(&cols[2]);
if let Some(t) = table_name_opt
&& !table_names.contains(t.as_str())
{
issues.push(IntegrityIssue::OrphanedExtensionRow {
extension_name: extension_name.clone(),
table_name: Some(t),
});
}
}
}
fn collect_existing_table_names(gpkg: &GeoPackage) -> Option<HashSet<String>> {
let entries = gpkg.scan_sqlite_master().ok()?;
Some(
entries
.into_iter()
.filter(|e| e.entry_type == "table")
.map(|e| e.name)
.collect(),
)
}
fn collect_srs_ids(gpkg: &GeoPackage) -> HashSet<i32> {
let rows = match gpkg.scan_table_by_name("gpkg_spatial_ref_sys") {
Ok(Some(r)) => r,
_ => return HashSet::new(),
};
rows.iter()
.filter_map(|(_rowid, cols)| {
if cols.len() < 2 {
None
} else {
cell_to_i32(&cols[1])
}
})
.collect()
}
fn cell_to_string(v: &crate::btree::CellValue) -> String {
use crate::btree::CellValue;
match v {
CellValue::Text(s) => s.clone(),
CellValue::Integer(i) => i.to_string(),
CellValue::Float(f) => f.to_string(),
CellValue::Blob(b) => String::from_utf8_lossy(b).into_owned(),
CellValue::Null => String::new(),
}
}
fn cell_to_optional_string(v: &crate::btree::CellValue) -> Option<String> {
use crate::btree::CellValue;
match v {
CellValue::Null => None,
CellValue::Text(s) if s.is_empty() => None,
other => Some(cell_to_string(other)),
}
}
fn cell_to_i32(v: &crate::btree::CellValue) -> Option<i32> {
use crate::btree::CellValue;
match v {
CellValue::Integer(i) => {
if *i > i32::MAX as i64 {
Some(i32::MAX)
} else if *i < i32::MIN as i64 {
Some(i32::MIN)
} else {
Some(*i as i32)
}
}
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_description_app_id_mismatch_contains_actual_hex() {
let issue = IntegrityIssue::AppIdMismatch {
actual: 0xDEAD_BEEF,
};
let s = issue.description();
assert!(s.contains("DEADBEEF"), "description = {s}");
assert!(
s.contains("47504B47"),
"expected the GPKG magic in the message: {s}"
);
}
#[test]
fn test_description_user_version_too_old_includes_numbers() {
let issue = IntegrityIssue::UserVersionTooOld {
actual: 10_200,
minimum: MIN_USER_VERSION,
};
let s = issue.description();
assert!(s.contains("10200"));
assert!(s.contains("10300"));
}
#[test]
fn test_description_missing_required_srs() {
let issue = IntegrityIssue::MissingRequiredSrs { code: 4326 };
let s = issue.description();
assert!(s.contains("4326"));
assert!(s.contains("gpkg_spatial_ref_sys"));
}
#[test]
fn test_description_geometry_columns_refs_missing_table() {
let issue = IntegrityIssue::GeometryColumnsRefsMissingTable {
table_name: "ghost".to_string(),
};
let s = issue.description();
assert!(s.contains("ghost"));
}
#[test]
fn test_description_orphan_extension_row_with_table() {
let issue = IntegrityIssue::OrphanedExtensionRow {
extension_name: "gpkg_rtree_index".to_string(),
table_name: Some("vanished_table".to_string()),
};
let s = issue.description();
assert!(s.contains("gpkg_rtree_index"));
assert!(s.contains("vanished_table"));
}
#[test]
fn test_integrity_report_passed_is_true_when_empty() {
let report = IntegrityReport {
passed: true,
issues: Vec::new(),
};
assert!(report.passed);
assert_eq!(report.issue_count(), 0);
assert!(!report.has_issue_of(|_| true));
}
#[test]
fn test_integrity_report_has_issue_of_matches() {
let report = IntegrityReport {
passed: false,
issues: vec![IntegrityIssue::AppIdMismatch { actual: 0 }],
};
assert!(report.has_issue_of(|i| matches!(i, IntegrityIssue::AppIdMismatch { .. })));
assert!(!report.has_issue_of(|i| matches!(i, IntegrityIssue::UserVersionTooOld { .. })));
}
#[test]
fn test_constants_match_spec() {
assert_eq!(GPKG_APP_ID, 0x4750_4B47);
assert_eq!(MIN_USER_VERSION, 10_300);
assert_eq!(REQUIRED_SRS, &[-1, 0, 4326]);
}
}