use std::path::Path;
use crate::error::{QcError, QcIssue, QcResult, Severity};
const GPKG_APP_ID: u32 = oxigdal_gpkg::GPKG_APP_ID;
const MIN_USER_VERSION: u32 = oxigdal_gpkg::MIN_USER_VERSION;
const SQLITE_MAGIC: &[u8; 16] = b"SQLite format 3\x00";
#[derive(Debug, Clone)]
pub struct GpkgValidator {
pub verify_table_existence: bool,
}
impl GpkgValidator {
pub const fn new() -> Self {
Self {
verify_table_existence: true,
}
}
}
impl Default for GpkgValidator {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, Clone)]
pub struct GpkgValidationResult {
pub issues: Vec<QcIssue>,
pub application_id: Option<u32>,
pub user_version: Option<u32>,
pub table_count: usize,
}
impl GpkgValidationResult {
pub fn is_valid(&self) -> bool {
self.issues.iter().all(|i| i.severity < Severity::Major)
}
}
impl GpkgValidator {
pub fn check_file<P: AsRef<Path>>(&self, path: P) -> QcResult<GpkgValidationResult> {
let path = path.as_ref();
let bytes = std::fs::read(path)?;
let loc = path.to_string_lossy().into_owned();
let mut issues: Vec<QcIssue> = Vec::new();
if bytes.len() < 16 || &bytes[..16] != SQLITE_MAGIC.as_ref() {
issues.push(
QcIssue::new(
Severity::Critical,
"GPKG",
"Not a valid SQLite file",
"File does not start with the SQLite magic bytes ('SQLite format 3\\0')",
)
.with_location(loc.clone())
.with_rule_id("GPKG-001"),
);
return Ok(GpkgValidationResult {
issues,
application_id: None,
user_version: None,
table_count: 0,
});
}
let mut gpkg = oxigdal_gpkg::GeoPackage::from_bytes(bytes.clone())
.map_err(|e| QcError::InvalidInput(format!("GeoPackage parse error: {}", e)))?;
let application_id = gpkg.reader.header.application_id;
let user_version = gpkg.reader.header.user_version;
if application_id != GPKG_APP_ID {
issues.push(
QcIssue::new(
Severity::Critical,
"GPKG",
"Wrong application_id",
format!(
"SQLite application_id is 0x{:08X}; a GeoPackage must use 0x{:08X} (\"GPKG\")",
application_id, GPKG_APP_ID
),
)
.with_location(loc.clone())
.with_rule_id("GPKG-002"),
);
}
if user_version < MIN_USER_VERSION {
issues.push(
QcIssue::new(
Severity::Major,
"GPKG",
"user_version too old",
format!(
"GeoPackage user_version is {} but {} (1.3.0) or higher is required",
user_version, MIN_USER_VERSION
),
)
.with_location(loc.clone())
.with_rule_id("GPKG-003"),
);
}
let _ = gpkg.load_contents(); let report = oxigdal_gpkg::check_integrity(&gpkg);
for issue in &report.issues {
let (severity, rule_id) = integrity_issue_severity(issue);
issues.push(
QcIssue::new(
severity,
"GPKG",
"Integrity check failed",
issue.description(),
)
.with_location(loc.clone())
.with_rule_id(rule_id),
);
}
let table_count = if self.verify_table_existence {
check_contents_table(&gpkg, &loc, &mut issues)?
} else {
0
};
Ok(GpkgValidationResult {
issues,
application_id: Some(application_id),
user_version: Some(user_version),
table_count,
})
}
}
fn integrity_issue_severity(issue: &oxigdal_gpkg::IntegrityIssue) -> (Severity, &'static str) {
use oxigdal_gpkg::IntegrityIssue;
match issue {
IntegrityIssue::AppIdMismatch { .. } => (Severity::Critical, "GPKG-010"),
IntegrityIssue::UserVersionTooOld { .. } => (Severity::Major, "GPKG-011"),
IntegrityIssue::MissingRequiredTable(_) => (Severity::Critical, "GPKG-012"),
IntegrityIssue::MissingRequiredSrs { .. } => (Severity::Major, "GPKG-013"),
IntegrityIssue::ContentsRefsMissingTable { .. } => (Severity::Major, "GPKG-014"),
IntegrityIssue::GeometryColumnsRefsMissingTable { .. } => (Severity::Major, "GPKG-015"),
IntegrityIssue::GeometryColumnsRefsMissingSrs { .. } => (Severity::Minor, "GPKG-016"),
IntegrityIssue::OrphanedExtensionRow { .. } => (Severity::Warning, "GPKG-017"),
}
}
fn check_contents_table(
gpkg: &oxigdal_gpkg::GeoPackage,
loc: &str,
issues: &mut Vec<QcIssue>,
) -> QcResult<usize> {
match gpkg
.scan_table_by_name("gpkg_contents")
.map_err(|e| QcError::InvalidInput(format!("gpkg_contents scan error: {}", e)))?
{
None => {
issues.push(
QcIssue::new(
Severity::Critical,
"GPKG",
"Missing gpkg_contents table",
"Required system table 'gpkg_contents' was not found in sqlite_master",
)
.with_location(loc.to_owned())
.with_rule_id("GPKG-020"),
);
Ok(0)
}
Some(rows) => Ok(rows.len()),
}
}
#[cfg(test)]
#[allow(clippy::expect_used)]
mod tests {
use super::*;
use std::io::Write as _;
fn write_tmp_bytes(bytes: &[u8]) -> tempfile::NamedTempFile {
let mut f = tempfile::NamedTempFile::new_in(std::env::temp_dir()).expect("temp file");
f.write_all(bytes).expect("write bytes");
f
}
fn make_minimal_sqlite(app_id: u32, user_version: u32) -> Vec<u8> {
const PAGE_SIZE: usize = 4096;
let mut data = vec![0u8; PAGE_SIZE];
data[..16].copy_from_slice(b"SQLite format 3\x00");
data[16..18].copy_from_slice(&(PAGE_SIZE as u16).to_be_bytes());
data[18] = 1;
data[19] = 1;
data[21] = 64;
data[22] = 32;
data[23] = 32;
data[24..28].copy_from_slice(&1u32.to_be_bytes());
data[28..32].copy_from_slice(&1u32.to_be_bytes());
data[40..44].copy_from_slice(&1u32.to_be_bytes());
data[44] = 4;
data[56..60].copy_from_slice(&1u32.to_be_bytes());
data[60..64].copy_from_slice(&user_version.to_be_bytes());
data[68..72].copy_from_slice(&app_id.to_be_bytes());
data[92..96].copy_from_slice(&1u32.to_be_bytes());
data[96..100].copy_from_slice(&3_040_001u32.to_be_bytes());
data[100] = 13; data[101..103].copy_from_slice(&(PAGE_SIZE as u16).to_be_bytes()); data[103..105].copy_from_slice(&0u16.to_be_bytes());
data[105..107].copy_from_slice(&(PAGE_SIZE as u16).to_be_bytes());
data
}
#[test]
fn test_gpkg_not_sqlite() {
let garbage = b"This is not a SQLite file at all, just garbage bytes!!!!!";
let tmp = write_tmp_bytes(garbage);
let result = GpkgValidator::new()
.check_file(tmp.path())
.expect("check_file ok");
let has_critical = result
.issues
.iter()
.any(|i| i.severity == Severity::Critical && i.rule_id.as_deref() == Some("GPKG-001"));
assert!(
has_critical,
"Expected GPKG-001 Critical; got: {:?}",
result.issues
);
assert!(!result.is_valid());
}
#[test]
fn test_gpkg_wrong_app_id() {
let bytes = make_minimal_sqlite(0xDEAD_BEEF, MIN_USER_VERSION);
let tmp = write_tmp_bytes(&bytes);
let result = GpkgValidator::new()
.check_file(tmp.path())
.expect("check_file ok");
let has_critical = result
.issues
.iter()
.any(|i| i.severity == Severity::Critical && i.rule_id.as_deref() == Some("GPKG-002"));
assert!(
has_critical,
"Expected GPKG-002 Critical; got: {:?}",
result.issues
);
assert!(!result.is_valid());
}
#[test]
fn test_gpkg_old_user_version() {
let bytes = make_minimal_sqlite(GPKG_APP_ID, 10_200);
let tmp = write_tmp_bytes(&bytes);
let result = GpkgValidator::new()
.check_file(tmp.path())
.expect("check_file ok");
let has_major = result
.issues
.iter()
.any(|i| i.severity >= Severity::Major && i.rule_id.as_deref() == Some("GPKG-003"));
assert!(
has_major,
"Expected GPKG-003 Major; got: {:?}",
result.issues
);
}
#[test]
fn test_gpkg_minimal_valid_from_builder() {
let bytes = oxigdal_gpkg::GeoPackageBuilder::new(4326)
.build()
.expect("builder");
let tmp = write_tmp_bytes(&bytes);
let result = GpkgValidator::new()
.check_file(tmp.path())
.expect("check_file ok");
let major_or_critical: Vec<_> = result
.issues
.iter()
.filter(|i| i.severity >= Severity::Major)
.collect();
assert!(
major_or_critical.is_empty(),
"Expected no Major/Critical on a fresh GeoPackage; got: {:?}",
major_or_critical
);
assert!(result.is_valid());
assert_eq!(result.application_id, Some(GPKG_APP_ID));
}
#[test]
fn test_gpkg_validation_result_is_valid_logic() {
let result = GpkgValidationResult {
issues: vec![QcIssue::new(
Severity::Warning,
"GPKG",
"test",
"test warning",
)],
application_id: Some(GPKG_APP_ID),
user_version: Some(MIN_USER_VERSION),
table_count: 0,
};
assert!(result.is_valid());
let result_bad = GpkgValidationResult {
issues: vec![QcIssue::new(Severity::Major, "GPKG", "test", "test major")],
application_id: Some(GPKG_APP_ID),
user_version: Some(MIN_USER_VERSION),
table_count: 0,
};
assert!(!result_bad.is_valid());
}
}