Implement HARP and CPL milestones through v0.5.11
This commit is contained in:
Generated
+12
-1
@@ -3980,7 +3980,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "thinkloom"
|
||||
version = "0.5.0"
|
||||
version = "0.5.11"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"hex",
|
||||
@@ -3989,12 +3989,14 @@ dependencies = [
|
||||
"reqwest 0.12.28",
|
||||
"rfd",
|
||||
"rusqlite",
|
||||
"ryu",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"tauri",
|
||||
"tauri-build",
|
||||
"tempfile",
|
||||
"unicode-normalization",
|
||||
"uuid",
|
||||
"walkdir",
|
||||
"zip",
|
||||
@@ -4426,6 +4428,15 @@ version = "1.0.24"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-normalization"
|
||||
version = "0.1.25"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8"
|
||||
dependencies = [
|
||||
"tinyvec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicode-segmentation"
|
||||
version = "1.13.3"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "thinkloom"
|
||||
version = "0.5.0"
|
||||
version = "0.5.11"
|
||||
description = "Local-first writing studio with creative provenance"
|
||||
authors = ["Christopher Chambers"]
|
||||
license = "AGPL-3.0-only"
|
||||
@@ -24,6 +24,8 @@ sha2 = "0.10"
|
||||
hex = "0.4"
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
uuid = { version = "1", features = ["v4", "serde"] }
|
||||
unicode-normalization = "0.1"
|
||||
ryu = "1"
|
||||
reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] }
|
||||
zip = { version = "2", default-features = false, features = ["deflate"] }
|
||||
keyring = "3"
|
||||
|
||||
+1026
-474
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,463 @@
|
||||
//! Project-format classification and legacy preservation boundaries.
|
||||
//!
|
||||
//! Inspection is deliberately read-only. Only an exact supported marker can
|
||||
//! proceed to CPL recovery; every other recognized project remains read-only.
|
||||
|
||||
use crate::provenance::{canonical::sha256_digest, CplError, CplResult};
|
||||
use serde::Serialize;
|
||||
use serde_json::Value;
|
||||
use std::{
|
||||
fs::{self, File},
|
||||
io::{Read, Write},
|
||||
path::Path,
|
||||
};
|
||||
use walkdir::WalkDir;
|
||||
use zip::{write::SimpleFileOptions, CompressionMethod, ZipWriter};
|
||||
|
||||
pub const PROJECT_FORMAT: &str = "thinkloom-cpl";
|
||||
pub const PROJECT_FORMAT_VERSION: &str = "1.0";
|
||||
pub const PROVENANCE_CONFORMANCE: &str = "cpl-1.0";
|
||||
|
||||
pub fn manifest_has_supported_marker(bytes: &[u8]) -> bool {
|
||||
serde_json::from_slice::<Value>(bytes).is_ok_and(|value| {
|
||||
value.get("project_format").and_then(Value::as_str) == Some(PROJECT_FORMAT)
|
||||
&& value.get("project_format_version").and_then(Value::as_str)
|
||||
== Some(PROJECT_FORMAT_VERSION)
|
||||
&& value.get("provenance_conformance").and_then(Value::as_str)
|
||||
== Some(PROVENANCE_CONFORMANCE)
|
||||
})
|
||||
}
|
||||
|
||||
pub const REQUIRED_DIRECTORIES: &[&str] = &[
|
||||
"records",
|
||||
"provenance/ledger/active",
|
||||
"provenance/ledger/sealed",
|
||||
"reports",
|
||||
".app",
|
||||
".app/locks",
|
||||
".app/temp",
|
||||
".app/recovery",
|
||||
];
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
|
||||
pub enum ProjectClassification {
|
||||
CplConforming,
|
||||
LegacyPreviewReadOnly,
|
||||
UnsupportedReadOnly,
|
||||
CplBlocked,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct ProjectInspection {
|
||||
pub classification: ProjectClassification,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
pub fn initialize_conforming_layout(root: &Path) -> CplResult<()> {
|
||||
for directory in [
|
||||
"manuscript/sections",
|
||||
"ideas",
|
||||
"conversations/transcripts",
|
||||
"records/conversations",
|
||||
"records/invocations",
|
||||
"records/prompt-templates",
|
||||
"records/sources",
|
||||
"records/transformations",
|
||||
"records/composition",
|
||||
"provenance/schema",
|
||||
"provenance/ledger/active",
|
||||
"provenance/ledger/sealed",
|
||||
"provenance/indexes",
|
||||
"provenance/integrity",
|
||||
"provenance/report-config",
|
||||
"releases",
|
||||
"deposits",
|
||||
"reports/harp",
|
||||
"assets",
|
||||
".app/locks",
|
||||
".app/temp",
|
||||
".app/temp/staging",
|
||||
".app/recovery/orphans",
|
||||
".app/snapshots",
|
||||
] {
|
||||
fs::create_dir_all(root.join(directory)).map_err(|error| {
|
||||
CplError::io("Could not create the conforming project layout", error)
|
||||
})?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn inspect_project(root: &Path) -> CplResult<ProjectInspection> {
|
||||
let manifest_path = root.join("project.json");
|
||||
if !manifest_path.is_file() {
|
||||
return Err(CplError::new(
|
||||
"PROJECT_INVALID",
|
||||
"The selected folder does not contain project.json.",
|
||||
false,
|
||||
));
|
||||
}
|
||||
let bytes = fs::read(&manifest_path)
|
||||
.map_err(|error| CplError::io("Could not read the project marker", error))?;
|
||||
let value: Value = match serde_json::from_slice(&bytes) {
|
||||
Ok(value) => value,
|
||||
Err(_) => {
|
||||
return Ok(ProjectInspection {
|
||||
classification: ProjectClassification::UnsupportedReadOnly,
|
||||
message: "The project manifest is not valid JSON. It was not modified, recovered, or verified.".to_owned(),
|
||||
})
|
||||
}
|
||||
};
|
||||
|
||||
let format = value.get("project_format").and_then(Value::as_str);
|
||||
let version = value.get("project_format_version").and_then(Value::as_str);
|
||||
let conformance = value.get("provenance_conformance").and_then(Value::as_str);
|
||||
let marker_absent = [
|
||||
"project_format",
|
||||
"project_format_version",
|
||||
"provenance_conformance",
|
||||
]
|
||||
.iter()
|
||||
.all(|key| value.get(key).is_none());
|
||||
if marker_absent {
|
||||
return Ok(ProjectInspection {
|
||||
classification: ProjectClassification::LegacyPreviewReadOnly,
|
||||
message: "Legacy preview project detected. Migration is deferred until after Thinkloom 1.0.0; only folder access and a byte-preserving preservation archive are available.".to_owned(),
|
||||
});
|
||||
}
|
||||
if format != Some(PROJECT_FORMAT)
|
||||
|| version != Some(PROJECT_FORMAT_VERSION)
|
||||
|| conformance != Some(PROVENANCE_CONFORMANCE)
|
||||
{
|
||||
return Ok(ProjectInspection {
|
||||
classification: ProjectClassification::UnsupportedReadOnly,
|
||||
message: "The project has an incomplete or unsupported CPL marker. It was not modified, recovered, or verified.".to_owned(),
|
||||
});
|
||||
}
|
||||
|
||||
let missing = REQUIRED_DIRECTORIES
|
||||
.iter()
|
||||
.filter(|relative| {
|
||||
let path = root.join(relative);
|
||||
!path.is_dir()
|
||||
|| fs::symlink_metadata(&path)
|
||||
.is_ok_and(|metadata| metadata.file_type().is_symlink())
|
||||
})
|
||||
.copied()
|
||||
.collect::<Vec<_>>();
|
||||
if !missing.is_empty() {
|
||||
return Ok(ProjectInspection {
|
||||
classification: ProjectClassification::CplBlocked,
|
||||
message: format!(
|
||||
"The CPL marker is present, but the required project structure is incomplete: {}. No recovery was attempted.",
|
||||
missing.join(", ")
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(ProjectInspection {
|
||||
classification: ProjectClassification::CplConforming,
|
||||
message: "The exact supported CPL 1.0 marker and required project structure are present."
|
||||
.to_owned(),
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
struct InventoryEntry {
|
||||
path: String,
|
||||
kind: &'static str,
|
||||
digest_or_target: String,
|
||||
}
|
||||
|
||||
fn source_inventory(source: &Path) -> CplResult<Vec<InventoryEntry>> {
|
||||
let mut entries = Vec::new();
|
||||
for entry in WalkDir::new(source).follow_links(false).into_iter() {
|
||||
let entry = entry.map_err(|error| {
|
||||
CplError::new(
|
||||
"LEGACY_ARCHIVE_READ_FAILED",
|
||||
format!("Could not inspect the legacy project: {error}"),
|
||||
true,
|
||||
)
|
||||
})?;
|
||||
if entry.path() == source {
|
||||
continue;
|
||||
}
|
||||
let relative = entry
|
||||
.path()
|
||||
.strip_prefix(source)
|
||||
.map_err(|error| CplError::new("LEGACY_ARCHIVE_PATH_FAILED", error.to_string(), false))?
|
||||
.to_string_lossy()
|
||||
.replace('\\', "/");
|
||||
let metadata = fs::symlink_metadata(entry.path())
|
||||
.map_err(|error| CplError::io("Could not inspect a legacy project entry", error))?;
|
||||
if metadata.file_type().is_symlink() {
|
||||
let target = fs::read_link(entry.path())
|
||||
.map_err(|error| CplError::io("Could not read a legacy project link", error))?;
|
||||
entries.push(InventoryEntry {
|
||||
path: relative,
|
||||
kind: "symlink",
|
||||
digest_or_target: target.to_string_lossy().into_owned(),
|
||||
});
|
||||
} else if metadata.is_dir() {
|
||||
entries.push(InventoryEntry {
|
||||
path: relative,
|
||||
kind: "directory",
|
||||
digest_or_target: String::new(),
|
||||
});
|
||||
} else if metadata.is_file() {
|
||||
entries.push(InventoryEntry {
|
||||
path: relative,
|
||||
kind: "file",
|
||||
digest_or_target: sha256_digest(&fs::read(entry.path()).map_err(|error| {
|
||||
CplError::io("Could not read a legacy project file", error)
|
||||
})?),
|
||||
});
|
||||
} else {
|
||||
return Err(CplError::new(
|
||||
"LEGACY_ARCHIVE_UNSUPPORTED_ENTRY",
|
||||
format!("The legacy project contains an unsupported entry: {relative}"),
|
||||
false,
|
||||
));
|
||||
}
|
||||
}
|
||||
entries.sort_by(|left, right| left.path.as_bytes().cmp(right.path.as_bytes()));
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
pub fn create_legacy_preservation_archive(source: &Path, destination: &Path) -> CplResult<()> {
|
||||
let inspection = inspect_project(source)?;
|
||||
if inspection.classification != ProjectClassification::LegacyPreviewReadOnly {
|
||||
return Err(CplError::new(
|
||||
"LEGACY_ARCHIVE_NOT_PERMITTED",
|
||||
"Preservation archives are available only for unmarked legacy preview projects.",
|
||||
false,
|
||||
));
|
||||
}
|
||||
let source = fs::canonicalize(source)
|
||||
.map_err(|error| CplError::io("Could not resolve the legacy project folder", error))?;
|
||||
let parent = destination.parent().ok_or_else(|| {
|
||||
CplError::new(
|
||||
"LEGACY_ARCHIVE_PATH_INVALID",
|
||||
"The preservation archive destination has no parent folder.",
|
||||
false,
|
||||
)
|
||||
})?;
|
||||
let parent = fs::canonicalize(parent)
|
||||
.map_err(|error| CplError::io("Could not resolve the archive destination", error))?;
|
||||
let destination = parent.join(destination.file_name().ok_or_else(|| {
|
||||
CplError::new(
|
||||
"LEGACY_ARCHIVE_PATH_INVALID",
|
||||
"Choose a complete ZIP destination.",
|
||||
false,
|
||||
)
|
||||
})?);
|
||||
if destination.starts_with(&source) {
|
||||
return Err(CplError::new(
|
||||
"LEGACY_ARCHIVE_INSIDE_PROJECT",
|
||||
"The preservation archive must be saved outside the legacy project so the source remains untouched.",
|
||||
false,
|
||||
));
|
||||
}
|
||||
|
||||
let before = source_inventory(&source)?;
|
||||
let temporary = parent.join(format!(
|
||||
".{}.{}.tmp",
|
||||
destination
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.unwrap_or("legacy-preservation.zip"),
|
||||
uuid::Uuid::new_v4()
|
||||
));
|
||||
let result = (|| -> CplResult<()> {
|
||||
let file = File::create(&temporary)
|
||||
.map_err(|error| CplError::io("Could not create the preservation archive", error))?;
|
||||
let mut zip = ZipWriter::new(file);
|
||||
let stored = SimpleFileOptions::default().compression_method(CompressionMethod::Stored);
|
||||
zip.start_file("PRESERVATION-NOTICE.txt", stored)
|
||||
.map_err(zip_error)?;
|
||||
zip.write_all(b"Legacy preview-project preservation archive\nNot verified, converted, or CPL-conforming\nMigration is deferred until after Thinkloom 1.0.0.\n")
|
||||
.map_err(|error| CplError::io("Could not write the preservation label", error))?;
|
||||
|
||||
for item in &before {
|
||||
let archive_path = format!("legacy-project/{}", item.path);
|
||||
let source_path = item
|
||||
.path
|
||||
.split('/')
|
||||
.fold(source.clone(), |path, component| path.join(component));
|
||||
match item.kind {
|
||||
"directory" => zip
|
||||
.add_directory(format!("{archive_path}/"), stored)
|
||||
.map_err(zip_error)?,
|
||||
"symlink" => {
|
||||
let link_options = stored.unix_permissions(0o120777);
|
||||
zip.start_file(archive_path, link_options)
|
||||
.map_err(zip_error)?;
|
||||
zip.write_all(item.digest_or_target.as_bytes())
|
||||
.map_err(|error| {
|
||||
CplError::io("Could not preserve a legacy project link", error)
|
||||
})?;
|
||||
}
|
||||
"file" => {
|
||||
zip.start_file(archive_path, stored).map_err(zip_error)?;
|
||||
let mut input = File::open(&source_path).map_err(|error| {
|
||||
CplError::io("Could not read a legacy project file", error)
|
||||
})?;
|
||||
let mut bytes = Vec::new();
|
||||
input.read_to_end(&mut bytes).map_err(|error| {
|
||||
CplError::io("Could not read a legacy project file", error)
|
||||
})?;
|
||||
zip.write_all(&bytes).map_err(|error| {
|
||||
CplError::io("Could not preserve a legacy project file", error)
|
||||
})?;
|
||||
}
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
let file = zip.finish().map_err(zip_error)?;
|
||||
file.sync_all()
|
||||
.map_err(|error| CplError::io("Could not flush the preservation archive", error))?;
|
||||
crate::provenance::ledger::atomic_replace(&temporary, &destination)?;
|
||||
crate::provenance::ledger::sync_directory(&parent)?;
|
||||
Ok(())
|
||||
})();
|
||||
if result.is_err() {
|
||||
let _ = fs::remove_file(&temporary);
|
||||
return result;
|
||||
}
|
||||
let after = source_inventory(&source)?;
|
||||
if before != after {
|
||||
let _ = fs::remove_file(&destination);
|
||||
return Err(CplError::new(
|
||||
"LEGACY_PROJECT_CHANGED_DURING_ARCHIVE",
|
||||
"The legacy project changed while it was being archived. The incomplete archive was removed; retry after writes stop.",
|
||||
true,
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn zip_error(error: zip::result::ZipError) -> CplError {
|
||||
CplError::new("LEGACY_ARCHIVE_FAILED", error.to_string(), true)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::io::Read;
|
||||
use tempfile::tempdir;
|
||||
use zip::ZipArchive;
|
||||
|
||||
fn write_manifest(root: &Path, value: Value) {
|
||||
fs::write(
|
||||
root.join("project.json"),
|
||||
serde_json::to_vec(&value).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn schema_version_without_marker_is_legacy_and_inspection_is_read_only() {
|
||||
let temp = tempdir().unwrap();
|
||||
write_manifest(
|
||||
temp.path(),
|
||||
serde_json::json!({"schemaVersion":"1.0","applicationVersion":"0.5.3"}),
|
||||
);
|
||||
fs::write(temp.path().join("original.bin"), [0, 1, 2, 255]).unwrap();
|
||||
let before = source_inventory(temp.path()).unwrap();
|
||||
let result = inspect_project(temp.path()).unwrap();
|
||||
assert_eq!(
|
||||
result.classification,
|
||||
ProjectClassification::LegacyPreviewReadOnly
|
||||
);
|
||||
assert_eq!(before, source_inventory(temp.path()).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exact_marker_requires_the_complete_layout() {
|
||||
let temp = tempdir().unwrap();
|
||||
write_manifest(
|
||||
temp.path(),
|
||||
serde_json::json!({
|
||||
"project_format": PROJECT_FORMAT,
|
||||
"project_format_version": PROJECT_FORMAT_VERSION,
|
||||
"provenance_conformance": PROVENANCE_CONFORMANCE
|
||||
}),
|
||||
);
|
||||
assert_eq!(
|
||||
inspect_project(temp.path()).unwrap().classification,
|
||||
ProjectClassification::CplBlocked
|
||||
);
|
||||
initialize_conforming_layout(temp.path()).unwrap();
|
||||
assert_eq!(
|
||||
inspect_project(temp.path()).unwrap().classification,
|
||||
ProjectClassification::CplConforming
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsupported_or_partial_marker_never_enters_cpl_recovery() {
|
||||
let temp = tempdir().unwrap();
|
||||
write_manifest(
|
||||
temp.path(),
|
||||
serde_json::json!({
|
||||
"project_format": PROJECT_FORMAT,
|
||||
"project_format_version": "2.0",
|
||||
"provenance_conformance": PROVENANCE_CONFORMANCE
|
||||
}),
|
||||
);
|
||||
assert_eq!(
|
||||
inspect_project(temp.path()).unwrap().classification,
|
||||
ProjectClassification::UnsupportedReadOnly
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preservation_archive_retains_source_bytes_without_changing_source() {
|
||||
let source_parent = tempdir().unwrap();
|
||||
let source = source_parent.path().join("preview");
|
||||
fs::create_dir_all(source.join("nested")).unwrap();
|
||||
write_manifest(
|
||||
&source,
|
||||
serde_json::json!({"schemaVersion":"1.0","applicationVersion":"0.5.3"}),
|
||||
);
|
||||
let original = [0, 13, 10, 255, 128, 42];
|
||||
fs::write(source.join("nested/data.bin"), original).unwrap();
|
||||
let before = source_inventory(&source).unwrap();
|
||||
let destination_parent = tempdir().unwrap();
|
||||
let destination = destination_parent.path().join("preview-preservation.zip");
|
||||
create_legacy_preservation_archive(&source, &destination).unwrap();
|
||||
assert_eq!(before, source_inventory(&source).unwrap());
|
||||
|
||||
let mut archive = ZipArchive::new(File::open(destination).unwrap()).unwrap();
|
||||
let mut restored = Vec::new();
|
||||
archive
|
||||
.by_name("legacy-project/nested/data.bin")
|
||||
.unwrap()
|
||||
.read_to_end(&mut restored)
|
||||
.unwrap();
|
||||
assert_eq!(restored, original);
|
||||
let mut notice = String::new();
|
||||
archive
|
||||
.by_name("PRESERVATION-NOTICE.txt")
|
||||
.unwrap()
|
||||
.read_to_string(&mut notice)
|
||||
.unwrap();
|
||||
assert!(notice.contains("Not verified, converted, or CPL-conforming"));
|
||||
}
|
||||
#[test]
|
||||
fn preservation_archive_cannot_write_inside_the_legacy_project() {
|
||||
let source = tempdir().unwrap();
|
||||
write_manifest(
|
||||
source.path(),
|
||||
serde_json::json!({"schemaVersion":"1.0","applicationVersion":"0.5.3"}),
|
||||
);
|
||||
fs::write(source.path().join("original.txt"), b"unchanged").unwrap();
|
||||
let before = source_inventory(source.path()).unwrap();
|
||||
let destination = source.path().join("preservation.zip");
|
||||
let error = create_legacy_preservation_archive(source.path(), &destination)
|
||||
.expect_err("an archive inside the source must be refused");
|
||||
assert_eq!(error.code, "LEGACY_ARCHIVE_INSIDE_PROJECT");
|
||||
assert_eq!(before, source_inventory(source.path()).unwrap());
|
||||
assert!(!destination.exists());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
use super::records::CplRecord;
|
||||
|
||||
const PREDICATES: &[&str] = &[
|
||||
"derived_from",
|
||||
"generated_by",
|
||||
"modified_by_human",
|
||||
"selected_by_human",
|
||||
"arranged_by_human",
|
||||
"included_in_deposit",
|
||||
];
|
||||
|
||||
const EVALUATION_STATUSES: &[&str] = &["exact", "degraded", "refused", "stale", "unverified"];
|
||||
|
||||
pub fn validate_record(record: &CplRecord) -> Result<(), String> {
|
||||
match record.record_type.as_str() {
|
||||
"provenance-assertion" => {
|
||||
let predicate = record
|
||||
.payload
|
||||
.get("predicate")
|
||||
.and_then(|value| value.as_str())
|
||||
.ok_or("A provenance assertion requires a predicate.")?;
|
||||
if !PREDICATES.contains(&predicate) {
|
||||
return Err(format!(
|
||||
"Unknown composition assertion predicate '{predicate}'."
|
||||
));
|
||||
}
|
||||
if !record
|
||||
.payload
|
||||
.get("dependencies")
|
||||
.is_some_and(|value| value.is_array())
|
||||
{
|
||||
return Err("A provenance assertion requires explicit dependencies.".to_owned());
|
||||
}
|
||||
}
|
||||
"assertion-evaluation" => {
|
||||
let status = record
|
||||
.payload
|
||||
.get("status")
|
||||
.and_then(|value| value.as_str())
|
||||
.ok_or("An assertion evaluation requires a status.")?;
|
||||
if !EVALUATION_STATUSES.contains(&status) {
|
||||
return Err(format!("Unknown assertion evaluation status '{status}'."));
|
||||
}
|
||||
if record
|
||||
.payload
|
||||
.get("evaluated_against")
|
||||
.and_then(|value| value.get("chain_head"))
|
||||
.and_then(|value| value.as_str())
|
||||
.is_none()
|
||||
{
|
||||
return Err("An assertion evaluation must bind an explicit chain head.".to_owned());
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
use super::{CplError, CplResult};
|
||||
use serde_json::{Map, Number, Value};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::cmp::Ordering;
|
||||
use unicode_normalization::UnicodeNormalization;
|
||||
|
||||
fn utf16_cmp(left: &str, right: &str) -> Ordering {
|
||||
left.encode_utf16().cmp(right.encode_utf16())
|
||||
}
|
||||
|
||||
pub fn normalize_nfc(value: &Value) -> CplResult<Value> {
|
||||
match value {
|
||||
Value::Null | Value::Bool(_) | Value::Number(_) => Ok(value.clone()),
|
||||
Value::String(text) => Ok(Value::String(text.nfc().collect())),
|
||||
Value::Array(values) => values
|
||||
.iter()
|
||||
.map(normalize_nfc)
|
||||
.collect::<CplResult<Vec<_>>>()
|
||||
.map(Value::Array),
|
||||
Value::Object(values) => {
|
||||
let mut normalized = Map::new();
|
||||
for (key, child) in values {
|
||||
let key: String = key.nfc().collect();
|
||||
if normalized.contains_key(&key) {
|
||||
return Err(CplError::new(
|
||||
"CPL_NFC_KEY_COLLISION",
|
||||
format!("Multiple object keys normalize to '{key}'."),
|
||||
false,
|
||||
));
|
||||
}
|
||||
normalized.insert(key, normalize_nfc(child)?);
|
||||
}
|
||||
Ok(Value::Object(normalized))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn canonical_number(number: &Number) -> CplResult<String> {
|
||||
if let Some(value) = number.as_i64() {
|
||||
return Ok(value.to_string());
|
||||
}
|
||||
if let Some(value) = number.as_u64() {
|
||||
return Ok(value.to_string());
|
||||
}
|
||||
let value = number.as_f64().ok_or_else(|| {
|
||||
CplError::new(
|
||||
"CPL_NUMBER_INVALID",
|
||||
"The JSON number is not finite.",
|
||||
false,
|
||||
)
|
||||
})?;
|
||||
if !value.is_finite() {
|
||||
return Err(CplError::new(
|
||||
"CPL_NUMBER_INVALID",
|
||||
"NaN and infinity are prohibited.",
|
||||
false,
|
||||
));
|
||||
}
|
||||
if value == 0.0 {
|
||||
return Ok("0".to_owned());
|
||||
}
|
||||
let mut buffer = ryu::Buffer::new();
|
||||
let rendered = buffer.format_finite(value);
|
||||
let rendered = rendered.strip_suffix(".0").unwrap_or(rendered);
|
||||
let Some(exponent_index) = rendered.find('e') else {
|
||||
return Ok(rendered.to_owned());
|
||||
};
|
||||
let (negative, unsigned) = rendered
|
||||
.strip_prefix('-')
|
||||
.map_or((false, rendered), |value| (true, value));
|
||||
let exponent_index = unsigned.find('e').unwrap_or(exponent_index);
|
||||
let mantissa = &unsigned[..exponent_index];
|
||||
let exponent: i32 = unsigned[exponent_index + 1..].parse().map_err(|error| {
|
||||
CplError::new(
|
||||
"CPL_NUMBER_INVALID",
|
||||
format!("Invalid numeric exponent: {error}"),
|
||||
false,
|
||||
)
|
||||
})?;
|
||||
let point = mantissa.find('.').unwrap_or(mantissa.len()) as i32;
|
||||
let digits = mantissa.replace('.', "");
|
||||
let decimal_position = point + exponent;
|
||||
let scientific_exponent = decimal_position - 1;
|
||||
let body = if (0..21).contains(&scientific_exponent) {
|
||||
if decimal_position as usize >= digits.len() {
|
||||
format!(
|
||||
"{}{}",
|
||||
digits,
|
||||
"0".repeat(decimal_position as usize - digits.len())
|
||||
)
|
||||
} else {
|
||||
let split = decimal_position as usize;
|
||||
format!("{}.{}", &digits[..split], &digits[split..])
|
||||
}
|
||||
} else if (-6..0).contains(&scientific_exponent) {
|
||||
format!("0.{}{}", "0".repeat((-decimal_position) as usize), digits)
|
||||
} else {
|
||||
let fraction = &digits[1..];
|
||||
let coefficient = if fraction.is_empty() {
|
||||
digits[..1].to_owned()
|
||||
} else {
|
||||
format!("{}.{}", &digits[..1], fraction)
|
||||
};
|
||||
format!(
|
||||
"{coefficient}e{}{scientific_exponent}",
|
||||
if scientific_exponent >= 0 { "+" } else { "" }
|
||||
)
|
||||
};
|
||||
Ok(if negative { format!("-{body}") } else { body })
|
||||
}
|
||||
|
||||
fn write_canonical(value: &Value, output: &mut String) -> CplResult<()> {
|
||||
match value {
|
||||
Value::Null => output.push_str("null"),
|
||||
Value::Bool(value) => output.push_str(if *value { "true" } else { "false" }),
|
||||
Value::Number(value) => output.push_str(&canonical_number(value)?),
|
||||
Value::String(value) => {
|
||||
output.push_str(&serde_json::to_string(value).map_err(|error| {
|
||||
CplError::new("CPL_CANONICALIZATION_FAILED", error.to_string(), false)
|
||||
})?)
|
||||
}
|
||||
Value::Array(values) => {
|
||||
output.push('[');
|
||||
for (index, child) in values.iter().enumerate() {
|
||||
if index > 0 {
|
||||
output.push(',');
|
||||
}
|
||||
write_canonical(child, output)?;
|
||||
}
|
||||
output.push(']');
|
||||
}
|
||||
Value::Object(values) => {
|
||||
output.push('{');
|
||||
let mut entries: Vec<_> = values.iter().collect();
|
||||
entries.sort_by(|(left, _), (right, _)| utf16_cmp(left, right));
|
||||
for (index, (key, child)) in entries.into_iter().enumerate() {
|
||||
if index > 0 {
|
||||
output.push(',');
|
||||
}
|
||||
output.push_str(&serde_json::to_string(key).map_err(|error| {
|
||||
CplError::new("CPL_CANONICALIZATION_FAILED", error.to_string(), false)
|
||||
})?);
|
||||
output.push(':');
|
||||
write_canonical(child, output)?;
|
||||
}
|
||||
output.push('}');
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn canonicalize(value: &Value) -> CplResult<Vec<u8>> {
|
||||
let normalized = normalize_nfc(value)?;
|
||||
let mut output = String::new();
|
||||
write_canonical(&normalized, &mut output)?;
|
||||
Ok(output.into_bytes())
|
||||
}
|
||||
|
||||
pub fn sha256_digest(bytes: &[u8]) -> String {
|
||||
format!("sha256:{}", hex::encode(Sha256::digest(bytes)))
|
||||
}
|
||||
pub fn canonical_digest(value: &Value) -> CplResult<String> {
|
||||
canonicalize(value).map(|bytes| sha256_digest(&bytes))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn matches_normative_canonicalization_vectors() {
|
||||
let cases = [
|
||||
(
|
||||
json!({"z":1,"a":2,"nested":{"beta":true,"alpha":false}}),
|
||||
"{\"a\":2,\"nested\":{\"alpha\":false,\"beta\":true},\"z\":1}",
|
||||
),
|
||||
(
|
||||
json!({"text":"Cafe\u{301}","e\u{301}":"normalize keys and values"}),
|
||||
"{\"text\":\"Café\",\"é\":\"normalize keys and values\"}",
|
||||
),
|
||||
(
|
||||
json!({"numbers":[333333333.3333333,1e30,4.5,0.002,1e-27]}),
|
||||
"{\"numbers\":[333333333.3333333,1e+30,4.5,0.002,1e-27]}",
|
||||
),
|
||||
(
|
||||
json!({"numbers":[1e20,1e-6,1e21,1e-7]}),
|
||||
"{\"numbers\":[100000000000000000000,0.000001,1e+21,1e-7]}",
|
||||
),
|
||||
];
|
||||
for (input, expected) in cases {
|
||||
assert_eq!(
|
||||
String::from_utf8(canonicalize(&input).unwrap()).unwrap(),
|
||||
expected
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_nfc_key_collisions() {
|
||||
let value: Value = serde_json::from_str("{\"é\":1,\"é\":2}").unwrap();
|
||||
assert_eq!(
|
||||
normalize_nfc(&value).unwrap_err().code,
|
||||
"CPL_NFC_KEY_COLLISION"
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,559 @@
|
||||
//! Read-only CPL explorer and HARP traceability projection.
|
||||
|
||||
use super::{
|
||||
composition::{self, CompositionProjection},
|
||||
contribution_map::{self, ContributionMapProjection},
|
||||
harp::{self, HarpProjection},
|
||||
ledger::{self, LedgerPaths},
|
||||
records::{CplRecord, VerificationReport},
|
||||
verifier, CplResult,
|
||||
};
|
||||
use serde::Serialize;
|
||||
use serde_json::Value;
|
||||
use std::{collections::BTreeSet, fs, path::Path};
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct ExplorerRecord {
|
||||
pub record_id: String,
|
||||
pub record_type: String,
|
||||
pub path: String,
|
||||
pub record_sha256: String,
|
||||
pub subject_ids: Vec<String>,
|
||||
pub accessible: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct ExplorerEvent {
|
||||
pub event_id: String,
|
||||
pub event_sequence: u64,
|
||||
pub timestamp: String,
|
||||
pub event_type: String,
|
||||
pub actor: String,
|
||||
pub event_sha256: String,
|
||||
pub previous_event_sha256: Option<String>,
|
||||
pub records: Vec<ExplorerRecord>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct HarpStatementTrace {
|
||||
pub statement_id: String,
|
||||
pub harp_path: String,
|
||||
pub statement: String,
|
||||
pub category: String,
|
||||
pub segment_ids: Vec<String>,
|
||||
pub assertion_ids: Vec<String>,
|
||||
pub evaluation_ids: Vec<String>,
|
||||
pub record_ids: Vec<String>,
|
||||
pub trace_note: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct CplExplorerProjection {
|
||||
pub verification: VerificationReport,
|
||||
pub events: Vec<ExplorerEvent>,
|
||||
pub composition: CompositionProjection,
|
||||
pub contribution_map: Option<ContributionMapProjection>,
|
||||
pub harp: Option<HarpProjection>,
|
||||
pub harp_statement_traces: Vec<HarpStatementTrace>,
|
||||
}
|
||||
|
||||
pub fn load(root: &Path, project_id: &str) -> CplResult<CplExplorerProjection> {
|
||||
let verification = verifier::verify_project(root, project_id)?;
|
||||
let events = explorer_events(root)?;
|
||||
let composition = composition::reconstruct(root, project_id)?;
|
||||
let contribution_map = contribution_map::load_latest(root, project_id)?;
|
||||
let harp = harp::load_latest(root, project_id)?;
|
||||
let harp_statement_traces = match (&harp, &contribution_map) {
|
||||
(Some(harp), Some(map)) => statement_traces(harp, map, &events),
|
||||
_ => Vec::new(),
|
||||
};
|
||||
Ok(CplExplorerProjection {
|
||||
verification,
|
||||
events,
|
||||
composition,
|
||||
contribution_map,
|
||||
harp,
|
||||
harp_statement_traces,
|
||||
})
|
||||
}
|
||||
|
||||
fn explorer_events(root: &Path) -> CplResult<Vec<ExplorerEvent>> {
|
||||
ledger::read_all_events(&LedgerPaths::new(root))?
|
||||
.into_iter()
|
||||
.map(|event| {
|
||||
let records = event
|
||||
.record_references
|
||||
.iter()
|
||||
.map(|reference| {
|
||||
let path = resolve_relative(root, &reference.path);
|
||||
let record = fs::read(&path)
|
||||
.ok()
|
||||
.and_then(|bytes| serde_json::from_slice::<CplRecord>(&bytes).ok());
|
||||
let subject_ids = record
|
||||
.as_ref()
|
||||
.map(|record| identifiers_in(&record.payload))
|
||||
.unwrap_or_default();
|
||||
ExplorerRecord {
|
||||
record_id: reference.record_id.clone(),
|
||||
record_type: reference.record_type.clone(),
|
||||
path: reference.path.clone(),
|
||||
record_sha256: reference.record_sha256.clone(),
|
||||
subject_ids,
|
||||
accessible: record.is_some(),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
Ok(ExplorerEvent {
|
||||
event_id: event.event_id,
|
||||
event_sequence: event.event_sequence,
|
||||
timestamp: event.timestamp,
|
||||
event_type: event.event_type,
|
||||
actor: event.actor,
|
||||
event_sha256: event.event_sha256,
|
||||
previous_event_sha256: event.previous_event_sha256,
|
||||
records,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn statement_traces(
|
||||
harp: &HarpProjection,
|
||||
map: &ContributionMapProjection,
|
||||
events: &[ExplorerEvent],
|
||||
) -> Vec<HarpStatementTrace> {
|
||||
let all_segments = map
|
||||
.contribution_map
|
||||
.segments
|
||||
.iter()
|
||||
.map(|segment| segment.segment_id.clone())
|
||||
.collect::<Vec<_>>();
|
||||
let all_assertions = map
|
||||
.assertions
|
||||
.iter()
|
||||
.filter_map(|value| value["assertion_id"].as_str().map(str::to_owned))
|
||||
.collect::<Vec<_>>();
|
||||
let all_evaluations = map
|
||||
.assertion_evaluations
|
||||
.iter()
|
||||
.filter_map(|value| value["evaluation_id"].as_str().map(str::to_owned))
|
||||
.collect::<Vec<_>>();
|
||||
let all_records = matching_records(
|
||||
events,
|
||||
&all_segments,
|
||||
&all_assertions,
|
||||
&all_evaluations,
|
||||
&["human-authorship-record", "harp-export-manifest"],
|
||||
);
|
||||
let approval_records = matching_records(
|
||||
events,
|
||||
&[],
|
||||
&[],
|
||||
&[],
|
||||
&["harp-generation-approval", "human-authorship-record"],
|
||||
);
|
||||
let mut traces = vec![
|
||||
trace(
|
||||
"deposit-binding",
|
||||
"deposit",
|
||||
format!("Exact deposit {} is bound to manuscript revision {}.", harp.harp["deposit"]["deposit_sha256"].as_str().unwrap_or("unknown"), harp.harp["deposit"]["manuscript_revision_id"].as_str().unwrap_or("unknown")),
|
||||
"evidence_fact",
|
||||
&all_segments,
|
||||
&all_assertions,
|
||||
&all_evaluations,
|
||||
&all_records,
|
||||
"The deposit binding is supported by inclusion assertions, their current evaluations, the frozen map, and deposit records.",
|
||||
),
|
||||
trace(
|
||||
"cpl-binding",
|
||||
"cpl_binding",
|
||||
format!("HARP uses CPL chain sequence {} at {}.", harp.harp["cpl_binding"]["event_sequence"], harp.harp["cpl_binding"]["chain_head"].as_str().unwrap_or("unknown")),
|
||||
"evidence_fact",
|
||||
&all_segments,
|
||||
&all_assertions,
|
||||
&all_evaluations,
|
||||
&all_records,
|
||||
"The chain binding is checked only by the native verifier.",
|
||||
),
|
||||
trace(
|
||||
"contribution-map-binding",
|
||||
"contribution_map",
|
||||
format!("Contribution map {} has digest {}.", harp.harp["contribution_map"]["contribution_map_id"].as_str().unwrap_or("unknown"), harp.harp["contribution_map"]["contribution_map_sha256"].as_str().unwrap_or("unknown")),
|
||||
"derived_classification",
|
||||
&all_segments,
|
||||
&all_assertions,
|
||||
&all_evaluations,
|
||||
&all_records,
|
||||
"The map is a deterministic projection of recorded origin, lineage, assertions, and evaluations.",
|
||||
),
|
||||
trace(
|
||||
"evidentiary-status",
|
||||
"evidentiary_status",
|
||||
format!("Evidentiary status is {} and applicability is {}.", harp.harp["evidentiary_status"].as_str().unwrap_or("unknown"), harp.applicability_status),
|
||||
"derived_classification",
|
||||
&all_segments,
|
||||
&all_assertions,
|
||||
&all_evaluations,
|
||||
&all_records,
|
||||
"This status describes recorded evidence integrity and coverage; it is not a frontend validity or legal-authorship declaration.",
|
||||
),
|
||||
trace(
|
||||
"claim-summary",
|
||||
"claim_summary",
|
||||
harp.harp["claim_summary"].as_str().unwrap_or("No claim summary recorded.").to_owned(),
|
||||
"derived_classification",
|
||||
&all_segments,
|
||||
&all_assertions,
|
||||
&all_evaluations,
|
||||
&all_records,
|
||||
"Every included segment is connected to its inclusion assertion, current evaluation, and underlying composition records.",
|
||||
),
|
||||
trace(
|
||||
"coverage",
|
||||
"coverage.statement",
|
||||
harp.harp["coverage"]["statement"].as_str().unwrap_or("No coverage statement recorded.").to_owned(),
|
||||
"derived_classification",
|
||||
&all_segments,
|
||||
&all_assertions,
|
||||
&all_evaluations,
|
||||
&all_records,
|
||||
"Coverage counts normalized Unicode scalar positions with recorded provenance; it is never a human-authorship percentage.",
|
||||
),
|
||||
trace(
|
||||
"identity-declaration",
|
||||
"identity_declaration",
|
||||
format!("Identity was {} as {}.", harp.harp["identity_declaration"]["identity_status"].as_str().unwrap_or("unknown"), harp.harp["identity_declaration"]["declared_name"].as_str().unwrap_or("unnamed")),
|
||||
"user_declaration",
|
||||
&[],
|
||||
&[],
|
||||
&[],
|
||||
&approval_records,
|
||||
"Identity is a user declaration tied to the approval record; Thinkloom does not infer or verify it unless separate evidence is recorded.",
|
||||
),
|
||||
trace(
|
||||
"policy-profile",
|
||||
"policy_profile",
|
||||
format!("Registration suggestions use policy profile {} version {}, retrieved {}.", harp.harp["policy_profile"]["policy_profile_id"].as_str().unwrap_or("unknown"), harp.harp["policy_profile"]["profile_version"].as_str().unwrap_or("unknown"), harp.harp["policy_profile"]["retrieved_on"].as_str().unwrap_or("unknown")),
|
||||
"evidence_fact",
|
||||
&[],
|
||||
&[],
|
||||
&[],
|
||||
&all_records,
|
||||
"The immutable policy-profile identity and digest are embedded in the HARP record.",
|
||||
),
|
||||
trace(
|
||||
"registration-language",
|
||||
"suggested_registration_language",
|
||||
"Author Created, Material Excluded, New Material Included, and Note to CO language was explicitly approved.".into(),
|
||||
"suggested_application_language",
|
||||
&all_segments,
|
||||
&all_assertions,
|
||||
&all_evaluations,
|
||||
&approval_records,
|
||||
"The wording is an approved suggestion linked to the evidence set and approval event; it is editable and is not legal advice.",
|
||||
),
|
||||
trace(
|
||||
"limitations",
|
||||
"limitation_codes",
|
||||
format!("HARP records {} limitation codes.", harp.harp["limitation_codes"].as_array().map_or(0, Vec::len)),
|
||||
"derived_classification",
|
||||
&all_segments,
|
||||
&all_assertions,
|
||||
&all_evaluations,
|
||||
&all_records,
|
||||
"Limitations expose missing, degraded, sanitized, self-declared, or legally undetermined dimensions.",
|
||||
),
|
||||
trace(
|
||||
"legal-scope",
|
||||
"explanation_codes",
|
||||
harp.report_metadata.legal_scope_statement.clone(),
|
||||
"legal_determination_not_made",
|
||||
&[],
|
||||
&[],
|
||||
&[],
|
||||
&all_records,
|
||||
"This is an explicit boundary: Thinkloom does not make the listed legal determinations.",
|
||||
),
|
||||
];
|
||||
for (index, disclosure) in harp.harp["ai_system_disclosures"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.enumerate()
|
||||
{
|
||||
let segment_ids = disclosure["included_expression_segment_ids"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(Value::as_str)
|
||||
.map(str::to_owned)
|
||||
.collect::<Vec<_>>();
|
||||
let assertion_ids = assertion_ids_for_segments(map, &segment_ids);
|
||||
let evaluation_ids = evaluation_ids_for_assertions(map, &assertion_ids);
|
||||
let records = matching_records(events, &segment_ids, &assertion_ids, &evaluation_ids, &[]);
|
||||
traces.push(trace(
|
||||
&format!("ai-disclosure-{index}"),
|
||||
&format!("ai_system_disclosures[{index}]"),
|
||||
format!("Provider {} model {} is recorded for {} included segments.", disclosure["provider_id"].as_str().unwrap_or("unknown"), disclosure["model_id"].as_str().unwrap_or("unknown"), segment_ids.len()),
|
||||
"evidence_fact",
|
||||
&segment_ids,
|
||||
&assertion_ids,
|
||||
&evaluation_ids,
|
||||
&records,
|
||||
"AI-system identity is read from recorded invocation requests and joined to accepted-output segment lineage.",
|
||||
));
|
||||
}
|
||||
traces
|
||||
}
|
||||
|
||||
fn trace(
|
||||
statement_id: &str,
|
||||
harp_path: &str,
|
||||
statement: String,
|
||||
category: &str,
|
||||
segment_ids: &[String],
|
||||
assertion_ids: &[String],
|
||||
evaluation_ids: &[String],
|
||||
record_ids: &[String],
|
||||
trace_note: &str,
|
||||
) -> HarpStatementTrace {
|
||||
HarpStatementTrace {
|
||||
statement_id: statement_id.into(),
|
||||
harp_path: harp_path.into(),
|
||||
statement,
|
||||
category: category.into(),
|
||||
segment_ids: segment_ids.to_vec(),
|
||||
assertion_ids: assertion_ids.to_vec(),
|
||||
evaluation_ids: evaluation_ids.to_vec(),
|
||||
record_ids: record_ids.to_vec(),
|
||||
trace_note: trace_note.into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn assertion_ids_for_segments(map: &ContributionMapProjection, segments: &[String]) -> Vec<String> {
|
||||
map.contribution_map
|
||||
.segments
|
||||
.iter()
|
||||
.filter(|segment| segments.contains(&segment.segment_id))
|
||||
.flat_map(|segment| segment.assertion_ids.iter().cloned())
|
||||
.collect::<BTreeSet<_>>()
|
||||
.into_iter()
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn evaluation_ids_for_assertions(
|
||||
map: &ContributionMapProjection,
|
||||
assertions: &[String],
|
||||
) -> Vec<String> {
|
||||
map.assertion_evaluations
|
||||
.iter()
|
||||
.filter(|value| {
|
||||
value["assertion_id"]
|
||||
.as_str()
|
||||
.is_some_and(|id| assertions.iter().any(|assertion| assertion == id))
|
||||
})
|
||||
.filter_map(|value| value["evaluation_id"].as_str().map(str::to_owned))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn matching_records(
|
||||
events: &[ExplorerEvent],
|
||||
segments: &[String],
|
||||
assertions: &[String],
|
||||
evaluations: &[String],
|
||||
record_types: &[&str],
|
||||
) -> Vec<String> {
|
||||
let wanted = segments
|
||||
.iter()
|
||||
.chain(assertions)
|
||||
.chain(evaluations)
|
||||
.collect::<BTreeSet<_>>();
|
||||
events
|
||||
.iter()
|
||||
.flat_map(|event| &event.records)
|
||||
.filter(|record| {
|
||||
record_types.contains(&record.record_type.as_str())
|
||||
|| record.subject_ids.iter().any(|id| wanted.contains(id))
|
||||
})
|
||||
.map(|record| record.record_id.clone())
|
||||
.collect::<BTreeSet<_>>()
|
||||
.into_iter()
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn identifiers_in(value: &Value) -> Vec<String> {
|
||||
let mut values = BTreeSet::new();
|
||||
collect_identifiers(value, &mut values);
|
||||
values.into_iter().collect()
|
||||
}
|
||||
|
||||
fn collect_identifiers(value: &Value, output: &mut BTreeSet<String>) {
|
||||
match value {
|
||||
Value::String(value) if is_identifier(value) => {
|
||||
output.insert(value.clone());
|
||||
}
|
||||
Value::Array(values) => {
|
||||
for value in values {
|
||||
collect_identifiers(value, output);
|
||||
}
|
||||
}
|
||||
Value::Object(values) => {
|
||||
for value in values.values() {
|
||||
collect_identifiers(value, output);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn is_identifier(value: &str) -> bool {
|
||||
const PREFIXES: &[&str] = &[
|
||||
"assertion_",
|
||||
"deposit_",
|
||||
"disposition_",
|
||||
"evaluation_",
|
||||
"event_",
|
||||
"fragment_",
|
||||
"harp_",
|
||||
"invocation_",
|
||||
"map_",
|
||||
"operation_",
|
||||
"record_",
|
||||
"revision_",
|
||||
"segment_",
|
||||
];
|
||||
PREFIXES.iter().any(|prefix| value.starts_with(prefix))
|
||||
}
|
||||
|
||||
fn resolve_relative(root: &Path, relative: &str) -> std::path::PathBuf {
|
||||
relative
|
||||
.split('/')
|
||||
.fold(root.to_path_buf(), |path, part| path.join(part))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn recursively_finds_traceable_identifiers_without_hashes_or_text() {
|
||||
let value = serde_json::json!({
|
||||
"subject": { "segment_id": "segment_01J0000000000000000000000Y" },
|
||||
"dependencies": ["assertion_01J0000000000000000000000V"],
|
||||
"digest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||
"text": "ordinary prose",
|
||||
});
|
||||
assert_eq!(
|
||||
identifiers_in(&value),
|
||||
vec![
|
||||
"assertion_01J0000000000000000000000V".to_owned(),
|
||||
"segment_01J0000000000000000000000Y".to_owned(),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_evidentiary_harp_statement_links_to_native_cpl_evidence() {
|
||||
use crate::provenance::{
|
||||
composition::{
|
||||
CompositionAction, CompositionBoundary, CompositionCommand, RecordedOrigin,
|
||||
},
|
||||
contribution_map::ContributionMapRequest,
|
||||
harp::{HarpGenerationRequest, RegistrationLanguageInput},
|
||||
identifiers::timestamp_millis,
|
||||
records::VerificationStatus,
|
||||
};
|
||||
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let project_id = "project_01J00000000000000000000009";
|
||||
let apply = |id: &str, action: CompositionAction| {
|
||||
composition::apply_command(
|
||||
temp.path(),
|
||||
project_id,
|
||||
CompositionCommand {
|
||||
client_action_id: id.into(),
|
||||
actor: "user".into(),
|
||||
summary: id.into(),
|
||||
occurred_at: timestamp_millis(),
|
||||
action,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
};
|
||||
apply(
|
||||
"explorer_initialize",
|
||||
CompositionAction::Initialize {
|
||||
text: String::new(),
|
||||
origin: RecordedOrigin::Unattested,
|
||||
},
|
||||
);
|
||||
apply(
|
||||
"explorer_human_edit",
|
||||
CompositionAction::Edit {
|
||||
before_text: String::new(),
|
||||
after_text: "Traceable human expression.".into(),
|
||||
boundary: CompositionBoundary::ExplicitSave,
|
||||
origin: RecordedOrigin::RecordedDirectHumanInput,
|
||||
operation_kind_hint: None,
|
||||
ai_acceptance: None,
|
||||
},
|
||||
);
|
||||
contribution_map::freeze_current(
|
||||
temp.path(),
|
||||
project_id,
|
||||
ContributionMapRequest::default(),
|
||||
)
|
||||
.unwrap();
|
||||
harp::generate_current(
|
||||
temp.path(),
|
||||
project_id,
|
||||
HarpGenerationRequest {
|
||||
declared_name: Some("Example Author".into()),
|
||||
identity_status: "self_declared".into(),
|
||||
identity_evidence_reference_ids: vec![],
|
||||
sanitization_profile: "sanitized".into(),
|
||||
suggested_registration_language: RegistrationLanguageInput::default(),
|
||||
user_approved: true,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let projection = load(temp.path(), project_id).unwrap();
|
||||
assert_eq!(projection.verification.status, VerificationStatus::Verified);
|
||||
assert!(!projection.events.is_empty());
|
||||
assert!(projection.harp.is_some());
|
||||
assert!(!projection.harp_statement_traces.is_empty());
|
||||
|
||||
for trace in &projection.harp_statement_traces {
|
||||
assert!(!trace.statement.is_empty());
|
||||
assert!(!trace.harp_path.is_empty());
|
||||
assert!(!trace.record_ids.is_empty());
|
||||
if matches!(
|
||||
trace.category.as_str(),
|
||||
"derived_classification" | "suggested_application_language"
|
||||
) {
|
||||
assert!(!trace.assertion_ids.is_empty());
|
||||
assert!(!trace.evaluation_ids.is_empty());
|
||||
}
|
||||
}
|
||||
let claim = projection
|
||||
.harp_statement_traces
|
||||
.iter()
|
||||
.find(|trace| trace.statement_id == "claim-summary")
|
||||
.unwrap();
|
||||
assert!(!claim.segment_ids.is_empty());
|
||||
assert!(!claim.assertion_ids.is_empty());
|
||||
assert!(!claim.evaluation_ids.is_empty());
|
||||
assert!(!claim.record_ids.is_empty());
|
||||
|
||||
let identity = projection
|
||||
.harp_statement_traces
|
||||
.iter()
|
||||
.find(|trace| trace.statement_id == "identity-declaration")
|
||||
.unwrap();
|
||||
assert_eq!(identity.category, "user_declaration");
|
||||
assert!(identity.assertion_ids.is_empty());
|
||||
assert!(identity.evaluation_ids.is_empty());
|
||||
assert!(!identity.record_ids.is_empty());
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,148 @@
|
||||
use super::{CplError, CplResult};
|
||||
use chrono::{DateTime, SecondsFormat, Utc};
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
use uuid::Uuid;
|
||||
|
||||
static ID_CLOCK: OnceLock<Mutex<(i64, u16)>> = OnceLock::new();
|
||||
|
||||
pub fn timestamp_millis_at(timestamp: DateTime<Utc>) -> String {
|
||||
timestamp.to_rfc3339_opts(SecondsFormat::Millis, true)
|
||||
}
|
||||
pub fn timestamp_millis() -> String {
|
||||
timestamp_millis_at(Utc::now())
|
||||
}
|
||||
|
||||
fn validate_prefix(prefix: &str) -> CplResult<()> {
|
||||
if prefix.is_empty()
|
||||
|| prefix.len() > 24
|
||||
|| !prefix
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_lowercase() || byte == b'_')
|
||||
{
|
||||
return Err(CplError::new(
|
||||
"CPL_ID_PREFIX_INVALID",
|
||||
"Identifier prefixes must contain lowercase ASCII letters and underscores.",
|
||||
false,
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn sortable_id_at(
|
||||
prefix: &str,
|
||||
unix_millis: i64,
|
||||
sequence: u16,
|
||||
entropy: &str,
|
||||
) -> CplResult<String> {
|
||||
validate_prefix(prefix)?;
|
||||
if !(0..=0x0000_ffff_ffff_ffff).contains(&unix_millis)
|
||||
|| entropy.len() != 32
|
||||
|| !entropy.bytes().all(|byte| byte.is_ascii_hexdigit())
|
||||
{
|
||||
return Err(CplError::new(
|
||||
"CPL_ID_COMPONENT_INVALID",
|
||||
"Sortable identifier components are invalid.",
|
||||
false,
|
||||
));
|
||||
}
|
||||
let mut bytes = [0u8; 16];
|
||||
let timestamp = (unix_millis as u64).to_be_bytes();
|
||||
bytes[..6].copy_from_slice(×tamp[2..]);
|
||||
bytes[6..8].copy_from_slice(&sequence.to_be_bytes());
|
||||
for (index, byte) in bytes[8..].iter_mut().enumerate() {
|
||||
*byte = u8::from_str_radix(&entropy[index * 2..index * 2 + 2], 16).map_err(|_| {
|
||||
CplError::new(
|
||||
"CPL_ID_COMPONENT_INVALID",
|
||||
"Sortable identifier entropy is invalid.",
|
||||
false,
|
||||
)
|
||||
})?;
|
||||
}
|
||||
Ok(format!("{prefix}_{}", crockford_128(bytes)))
|
||||
}
|
||||
|
||||
fn crockford_128(bytes: [u8; 16]) -> String {
|
||||
const ALPHABET: &[u8; 32] = b"0123456789ABCDEFGHJKMNPQRSTVWXYZ";
|
||||
let mut output = String::with_capacity(26);
|
||||
let mut buffer = 0u32;
|
||||
let mut bits = 2u8;
|
||||
for byte in bytes {
|
||||
buffer = (buffer << 8) | u32::from(byte);
|
||||
bits += 8;
|
||||
while bits >= 5 {
|
||||
bits -= 5;
|
||||
output.push(ALPHABET[((buffer >> bits) & 31) as usize] as char);
|
||||
}
|
||||
}
|
||||
debug_assert_eq!(output.len(), 26);
|
||||
output
|
||||
}
|
||||
|
||||
fn advance_clock(clock: &mut (i64, u16), observed_millis: i64) -> CplResult<(i64, u16)> {
|
||||
if observed_millis <= clock.0 {
|
||||
clock.1 = clock.1.checked_add(1).ok_or_else(|| {
|
||||
CplError::new(
|
||||
"CPL_ID_SEQUENCE_EXHAUSTED",
|
||||
"Too many identifiers in one logical millisecond.",
|
||||
true,
|
||||
)
|
||||
})?;
|
||||
} else {
|
||||
*clock = (observed_millis, 0);
|
||||
}
|
||||
Ok(*clock)
|
||||
}
|
||||
|
||||
pub fn sortable_id(prefix: &str) -> CplResult<String> {
|
||||
let observed_millis = Utc::now().timestamp_millis();
|
||||
let mut clock = ID_CLOCK
|
||||
.get_or_init(|| Mutex::new((-1, 0)))
|
||||
.lock()
|
||||
.map_err(|_| {
|
||||
CplError::new(
|
||||
"CPL_ID_CLOCK_LOCKED",
|
||||
"Identifier clock is unavailable.",
|
||||
true,
|
||||
)
|
||||
})?;
|
||||
let (logical_millis, sequence) = advance_clock(&mut clock, observed_millis)?;
|
||||
sortable_id_at(
|
||||
prefix,
|
||||
logical_millis,
|
||||
sequence,
|
||||
&Uuid::new_v4().simple().to_string(),
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use chrono::TimeZone;
|
||||
|
||||
#[test]
|
||||
fn identifiers_are_prefixed_and_sort_by_time_then_sequence() {
|
||||
let first = sortable_id_at("event", 1_700_000_000_000, 0, &"0".repeat(32)).unwrap();
|
||||
let second = sortable_id_at("event", 1_700_000_000_000, 1, &"0".repeat(32)).unwrap();
|
||||
let third = sortable_id_at("event", 1_700_000_000_001, 0, &"0".repeat(32)).unwrap();
|
||||
assert!(first < second && second < third);
|
||||
assert!(first.starts_with("event_"));
|
||||
assert_eq!(first.len(), "event_".len() + 26);
|
||||
assert!(first["event_".len()..]
|
||||
.bytes()
|
||||
.all(|byte| b"0123456789ABCDEFGHJKMNPQRSTVWXYZ".contains(&byte)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clock_rollback_preserves_identifier_order() {
|
||||
let mut clock = (2_000, 3);
|
||||
assert_eq!(advance_clock(&mut clock, 1_999).unwrap(), (2_000, 4));
|
||||
assert_eq!(advance_clock(&mut clock, 2_001).unwrap(), (2_001, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timestamps_are_exact_utc_milliseconds() {
|
||||
let value = Utc.with_ymd_and_hms(2026, 7, 17, 18, 42, 10).unwrap()
|
||||
+ chrono::Duration::milliseconds(123);
|
||||
assert_eq!(timestamp_millis_at(value), "2026-07-17T18:42:10.123Z");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,462 @@
|
||||
use super::{
|
||||
canonical::{canonical_digest, canonicalize, sha256_digest},
|
||||
identifiers::timestamp_millis,
|
||||
records::{ChainHead, CplEvent, SegmentManifest},
|
||||
CplError, CplResult, DurableBoundary, CPL_SCHEMA_VERSION,
|
||||
};
|
||||
use serde::Serialize;
|
||||
use serde_json::Value;
|
||||
use std::{
|
||||
fs::{self, File, OpenOptions},
|
||||
io::Write,
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct LedgerConfig {
|
||||
pub max_events_per_segment: u64,
|
||||
pub max_bytes_per_segment: u64,
|
||||
}
|
||||
|
||||
impl Default for LedgerConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
max_events_per_segment: 10_000,
|
||||
max_bytes_per_segment: 10 * 1024 * 1024,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct LedgerPaths {
|
||||
pub root: PathBuf,
|
||||
pub active: PathBuf,
|
||||
pub sealed: PathBuf,
|
||||
pub chain_head: PathBuf,
|
||||
}
|
||||
|
||||
impl LedgerPaths {
|
||||
pub fn new(project_root: &Path) -> Self {
|
||||
let root = project_root.join("provenance/ledger");
|
||||
Self {
|
||||
active: root.join("active"),
|
||||
sealed: root.join("sealed"),
|
||||
chain_head: root.join("chain-head.json"),
|
||||
root,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn initialize(&self) -> CplResult<()> {
|
||||
fs::create_dir_all(&self.active)
|
||||
.and_then(|_| fs::create_dir_all(&self.sealed))
|
||||
.map_err(|error| CplError::io("Could not create CPL ledger directories", error))?;
|
||||
if active_segments(self)?.is_empty() {
|
||||
let next_number = sealed_segments(self)?
|
||||
.iter()
|
||||
.filter_map(|path| parse_segment_number(path))
|
||||
.max()
|
||||
.unwrap_or(0)
|
||||
+ 1;
|
||||
File::create(self.active.join(segment_filename(next_number)))
|
||||
.and_then(|file| file.sync_all())
|
||||
.map_err(|error| {
|
||||
CplError::io("Could not initialize the active CPL segment", error)
|
||||
})?;
|
||||
sync_directory(&self.active)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn segment_filename(number: u64) -> String {
|
||||
format!("segment-{number:06}.jsonl")
|
||||
}
|
||||
|
||||
pub fn segment_manifest_filename(number: u64) -> String {
|
||||
format!("segment-{number:06}.manifest.json")
|
||||
}
|
||||
|
||||
fn parse_segment_number(path: &Path) -> Option<u64> {
|
||||
path.file_name()?
|
||||
.to_str()?
|
||||
.strip_prefix("segment-")?
|
||||
.strip_suffix(".jsonl")?
|
||||
.parse()
|
||||
.ok()
|
||||
}
|
||||
|
||||
pub fn active_segments(paths: &LedgerPaths) -> CplResult<Vec<PathBuf>> {
|
||||
let mut segments = fs::read_dir(&paths.active)
|
||||
.map_err(|error| CplError::io("Could not inspect active CPL segments", error))?
|
||||
.filter_map(Result::ok)
|
||||
.map(|entry| entry.path())
|
||||
.filter(|path| parse_segment_number(path).is_some())
|
||||
.collect::<Vec<_>>();
|
||||
segments.sort();
|
||||
Ok(segments)
|
||||
}
|
||||
|
||||
pub fn sealed_segments(paths: &LedgerPaths) -> CplResult<Vec<PathBuf>> {
|
||||
let mut segments = fs::read_dir(&paths.sealed)
|
||||
.map_err(|error| CplError::io("Could not inspect sealed CPL segments", error))?
|
||||
.filter_map(Result::ok)
|
||||
.map(|entry| entry.path())
|
||||
.filter(|path| parse_segment_number(path).is_some())
|
||||
.collect::<Vec<_>>();
|
||||
segments.sort();
|
||||
Ok(segments)
|
||||
}
|
||||
|
||||
pub fn current_active_segment(paths: &LedgerPaths) -> CplResult<(u64, PathBuf)> {
|
||||
let segments = active_segments(paths)?;
|
||||
if segments.len() != 1 {
|
||||
return Err(CplError::new(
|
||||
"CPL_ACTIVE_SEGMENT_INVALID",
|
||||
format!(
|
||||
"Expected exactly one active segment, found {}.",
|
||||
segments.len()
|
||||
),
|
||||
false,
|
||||
));
|
||||
}
|
||||
let path = segments[0].clone();
|
||||
let number = parse_segment_number(&path).ok_or_else(|| {
|
||||
CplError::new(
|
||||
"CPL_SEGMENT_NAME_INVALID",
|
||||
"The active segment name is invalid.",
|
||||
false,
|
||||
)
|
||||
})?;
|
||||
Ok((number, path))
|
||||
}
|
||||
|
||||
pub fn read_segment(path: &Path) -> CplResult<Vec<CplEvent>> {
|
||||
let bytes =
|
||||
fs::read(path).map_err(|error| CplError::io("Could not read CPL segment", error))?;
|
||||
if !bytes.is_empty() && !bytes.ends_with(b"\n") {
|
||||
return Err(CplError::new(
|
||||
"CPL_SEGMENT_TRUNCATED",
|
||||
format!("{} has an incomplete final JSONL line.", path.display()),
|
||||
true,
|
||||
));
|
||||
}
|
||||
bytes
|
||||
.split(|byte| *byte == b'\n')
|
||||
.filter(|line| !line.is_empty())
|
||||
.enumerate()
|
||||
.map(|(index, line)| {
|
||||
let event: CplEvent = serde_json::from_slice(line).map_err(|error| {
|
||||
CplError::new(
|
||||
"CPL_EVENT_INVALID",
|
||||
format!("{} line {} is invalid: {error}", path.display(), index + 1),
|
||||
false,
|
||||
)
|
||||
})?;
|
||||
if canonical_event_line(&event)? != line {
|
||||
return Err(CplError::new(
|
||||
"CPL_EVENT_NONCANONICAL",
|
||||
format!(
|
||||
"{} line {} is valid JSON but not canonical JSON.",
|
||||
path.display(),
|
||||
index + 1
|
||||
),
|
||||
false,
|
||||
));
|
||||
}
|
||||
Ok(event)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn read_all_events(paths: &LedgerPaths) -> CplResult<Vec<CplEvent>> {
|
||||
let mut events = Vec::new();
|
||||
for path in sealed_segments(paths)? {
|
||||
events.extend(read_segment(&path)?);
|
||||
}
|
||||
for path in active_segments(paths)? {
|
||||
events.extend(read_segment(&path)?);
|
||||
}
|
||||
Ok(events)
|
||||
}
|
||||
|
||||
pub fn read_chain_head(paths: &LedgerPaths) -> CplResult<Option<ChainHead>> {
|
||||
if !paths.chain_head.exists() {
|
||||
return Ok(None);
|
||||
}
|
||||
let bytes = fs::read(&paths.chain_head)
|
||||
.map_err(|error| CplError::io("Could not read the CPL chain head", error))?;
|
||||
let head: ChainHead = serde_json::from_slice(&bytes)
|
||||
.map_err(|error| CplError::new("CPL_CHAIN_HEAD_INVALID", error.to_string(), false))?;
|
||||
if serialize_canonical(&head)? != bytes {
|
||||
return Err(CplError::new(
|
||||
"CPL_CHAIN_HEAD_NONCANONICAL",
|
||||
"The CPL chain head is not canonical JSON.",
|
||||
false,
|
||||
));
|
||||
}
|
||||
Ok(Some(head))
|
||||
}
|
||||
|
||||
fn serialize_canonical<T: Serialize>(value: &T) -> CplResult<Vec<u8>> {
|
||||
let value = serde_json::to_value(value)
|
||||
.map_err(|error| CplError::new("CPL_SERIALIZATION_FAILED", error.to_string(), false))?;
|
||||
canonicalize(&value)
|
||||
}
|
||||
|
||||
pub(crate) fn sync_directory(path: &Path) -> CplResult<()> {
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
File::open(path)
|
||||
.and_then(|file| file.sync_all())
|
||||
.map_err(|error| CplError::io("Could not flush a CPL directory", error))?;
|
||||
}
|
||||
#[cfg(target_os = "windows")]
|
||||
let _ = path;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
pub(crate) fn atomic_replace(source: &Path, destination: &Path) -> CplResult<()> {
|
||||
use std::os::windows::ffi::OsStrExt;
|
||||
const MOVEFILE_REPLACE_EXISTING: u32 = 0x1;
|
||||
const MOVEFILE_WRITE_THROUGH: u32 = 0x8;
|
||||
#[link(name = "Kernel32")]
|
||||
extern "system" {
|
||||
fn MoveFileExW(existing: *const u16, replacement: *const u16, flags: u32) -> i32;
|
||||
}
|
||||
let source = source
|
||||
.as_os_str()
|
||||
.encode_wide()
|
||||
.chain(Some(0))
|
||||
.collect::<Vec<_>>();
|
||||
let destination = destination
|
||||
.as_os_str()
|
||||
.encode_wide()
|
||||
.chain(Some(0))
|
||||
.collect::<Vec<_>>();
|
||||
let result = unsafe {
|
||||
MoveFileExW(
|
||||
source.as_ptr(),
|
||||
destination.as_ptr(),
|
||||
MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH,
|
||||
)
|
||||
};
|
||||
if result == 0 {
|
||||
return Err(CplError::io(
|
||||
"Could not atomically replace a CPL file",
|
||||
std::io::Error::last_os_error(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
pub(crate) fn atomic_replace(source: &Path, destination: &Path) -> CplResult<()> {
|
||||
fs::rename(source, destination)
|
||||
.map_err(|error| CplError::io("Could not atomically replace a CPL file", error))
|
||||
}
|
||||
|
||||
pub(crate) fn write_atomic<T: Serialize>(path: &Path, value: &T) -> CplResult<()> {
|
||||
let parent = path.parent().ok_or_else(|| {
|
||||
CplError::new(
|
||||
"CPL_PATH_INVALID",
|
||||
"The CPL file has no parent directory.",
|
||||
false,
|
||||
)
|
||||
})?;
|
||||
fs::create_dir_all(parent)
|
||||
.map_err(|error| CplError::io("Could not create a CPL parent directory", error))?;
|
||||
let temp = parent.join(format!(
|
||||
".{}.tmp",
|
||||
path.file_name().unwrap_or_default().to_string_lossy()
|
||||
));
|
||||
let bytes = serialize_canonical(value)?;
|
||||
let mut file = File::create(&temp)
|
||||
.map_err(|error| CplError::io("Could not create a temporary CPL file", error))?;
|
||||
file.write_all(&bytes)
|
||||
.and_then(|_| file.sync_all())
|
||||
.map_err(|error| CplError::io("Could not flush a temporary CPL file", error))?;
|
||||
drop(file);
|
||||
atomic_replace(&temp, path)?;
|
||||
sync_directory(parent)
|
||||
}
|
||||
|
||||
fn last_sealed_digest(paths: &LedgerPaths) -> CplResult<Option<String>> {
|
||||
sealed_segments(paths)?
|
||||
.last()
|
||||
.map(|path| {
|
||||
fs::read(path)
|
||||
.map(|bytes| sha256_digest(&bytes))
|
||||
.map_err(|error| CplError::io("Could not hash the prior sealed segment", error))
|
||||
})
|
||||
.transpose()
|
||||
}
|
||||
|
||||
fn rotate_if_needed(
|
||||
paths: &LedgerPaths,
|
||||
config: LedgerConfig,
|
||||
boundary: &mut dyn FnMut(DurableBoundary) -> CplResult<()>,
|
||||
) -> CplResult<()> {
|
||||
let (number, active) = current_active_segment(paths)?;
|
||||
let bytes = fs::read(&active)
|
||||
.map_err(|error| CplError::io("Could not inspect the active segment", error))?;
|
||||
let events = read_segment(&active)?;
|
||||
if events.is_empty()
|
||||
|| (events.len() as u64) < config.max_events_per_segment
|
||||
&& (bytes.len() as u64) < config.max_bytes_per_segment
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
OpenOptions::new()
|
||||
.write(true)
|
||||
.open(&active)
|
||||
.and_then(|file| file.sync_all())
|
||||
.map_err(|error| {
|
||||
CplError::io("Could not flush the active segment before sealing", error)
|
||||
})?;
|
||||
boundary(DurableBoundary::SegmentFlushed)?;
|
||||
let manifest = SegmentManifest {
|
||||
schema_version: CPL_SCHEMA_VERSION.to_owned(),
|
||||
segment_number: number,
|
||||
previous_segment_file_sha256: last_sealed_digest(paths)?,
|
||||
first_event_sha256: events.first().unwrap().event_sha256.clone(),
|
||||
final_event_sha256: events.last().unwrap().event_sha256.clone(),
|
||||
first_event_sequence: events.first().unwrap().event_sequence,
|
||||
final_event_sequence: events.last().unwrap().event_sequence,
|
||||
event_count: events.len() as u64,
|
||||
byte_length: bytes.len() as u64,
|
||||
segment_file_sha256: sha256_digest(&bytes),
|
||||
sealed_at: timestamp_millis(),
|
||||
};
|
||||
let manifest_temp = paths
|
||||
.active
|
||||
.join(format!(".{}.tmp", segment_manifest_filename(number)));
|
||||
let manifest_bytes = serialize_canonical(&manifest)?;
|
||||
File::create(&manifest_temp)
|
||||
.and_then(|mut file| {
|
||||
file.write_all(&manifest_bytes)
|
||||
.and_then(|_| file.sync_all())
|
||||
})
|
||||
.map_err(|error| CplError::io("Could not flush a sealed segment manifest", error))?;
|
||||
boundary(DurableBoundary::SegmentManifestFlushed)?;
|
||||
let sealed_segment = paths.sealed.join(segment_filename(number));
|
||||
let sealed_manifest = paths.sealed.join(segment_manifest_filename(number));
|
||||
atomic_replace(&active, &sealed_segment)?;
|
||||
boundary(DurableBoundary::SegmentMoved)?;
|
||||
atomic_replace(&manifest_temp, &sealed_manifest)?;
|
||||
boundary(DurableBoundary::SegmentManifestMoved)?;
|
||||
File::create(paths.active.join(segment_filename(number + 1)))
|
||||
.and_then(|file| file.sync_all())
|
||||
.map_err(|error| CplError::io("Could not create the next active segment", error))?;
|
||||
boundary(DurableBoundary::NewActiveSegmentCreated)?;
|
||||
sync_directory(&paths.active)?;
|
||||
sync_directory(&paths.sealed)
|
||||
}
|
||||
|
||||
pub fn append_event(
|
||||
paths: &LedgerPaths,
|
||||
config: LedgerConfig,
|
||||
mut event: CplEvent,
|
||||
boundary: &mut dyn FnMut(DurableBoundary) -> CplResult<()>,
|
||||
) -> CplResult<(CplEvent, ChainHead)> {
|
||||
paths.initialize()?;
|
||||
rotate_if_needed(paths, config, boundary)?;
|
||||
let events = read_all_events(paths)?;
|
||||
let previous = events.last();
|
||||
event.event_sequence = previous.map_or(1, |item| item.event_sequence + 1);
|
||||
event.previous_event_sha256 = previous.map(|item| item.event_sha256.clone());
|
||||
event.event_sha256 = canonical_digest(&event.identity())?;
|
||||
|
||||
let (segment_number, active) = current_active_segment(paths)?;
|
||||
let mut line = serialize_canonical(&event)?;
|
||||
line.push(b'\n');
|
||||
let mut file = OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(&active)
|
||||
.map_err(|error| CplError::io("Could not open the active CPL segment", error))?;
|
||||
file.write_all(&line)
|
||||
.map_err(|error| CplError::io("Could not append the CPL event", error))?;
|
||||
boundary(DurableBoundary::LedgerAppendBeforeFlush)?;
|
||||
file.sync_all()
|
||||
.map_err(|error| CplError::io("Could not flush the CPL event", error))?;
|
||||
boundary(DurableBoundary::LedgerFlushed)?;
|
||||
|
||||
let head = ChainHead {
|
||||
schema_version: CPL_SCHEMA_VERSION.to_owned(),
|
||||
project_id: event.project_id.clone(),
|
||||
segment_number,
|
||||
segment_file: format!(
|
||||
"provenance/ledger/active/{}",
|
||||
segment_filename(segment_number)
|
||||
),
|
||||
event_id: event.event_id.clone(),
|
||||
event_sequence: event.event_sequence,
|
||||
event_sha256: event.event_sha256.clone(),
|
||||
updated_at: event.timestamp.clone(),
|
||||
};
|
||||
let head_temp = paths.root.join(".chain-head.json.tmp");
|
||||
let head_bytes = serialize_canonical(&head)?;
|
||||
File::create(&head_temp)
|
||||
.and_then(|mut file| file.write_all(&head_bytes).and_then(|_| file.sync_all()))
|
||||
.map_err(|error| CplError::io("Could not flush the temporary CPL chain head", error))?;
|
||||
boundary(DurableBoundary::ChainHeadTemporaryWritten)?;
|
||||
atomic_replace(&head_temp, &paths.chain_head)?;
|
||||
boundary(DurableBoundary::ChainHeadReplaced)?;
|
||||
sync_directory(&paths.root)?;
|
||||
boundary(DurableBoundary::ChainHeadDirectorySynced)?;
|
||||
Ok((event, head))
|
||||
}
|
||||
|
||||
pub fn advance_chain_head(
|
||||
paths: &LedgerPaths,
|
||||
project_id: &str,
|
||||
event: &CplEvent,
|
||||
) -> CplResult<ChainHead> {
|
||||
let segment_number = locate_event_segment(paths, &event.event_id)?.ok_or_else(|| {
|
||||
CplError::new(
|
||||
"CPL_EVENT_NOT_FOUND",
|
||||
"Cannot advance the chain head to a missing event.",
|
||||
false,
|
||||
)
|
||||
})?;
|
||||
let in_active = active_segments(paths)?
|
||||
.iter()
|
||||
.any(|path| parse_segment_number(path) == Some(segment_number));
|
||||
let kind = if in_active { "active" } else { "sealed" };
|
||||
let head = ChainHead {
|
||||
schema_version: CPL_SCHEMA_VERSION.to_owned(),
|
||||
project_id: project_id.to_owned(),
|
||||
segment_number,
|
||||
segment_file: format!(
|
||||
"provenance/ledger/{kind}/{}",
|
||||
segment_filename(segment_number)
|
||||
),
|
||||
event_id: event.event_id.clone(),
|
||||
event_sequence: event.event_sequence,
|
||||
event_sha256: event.event_sha256.clone(),
|
||||
updated_at: timestamp_millis(),
|
||||
};
|
||||
write_atomic(&paths.chain_head, &head)?;
|
||||
Ok(head)
|
||||
}
|
||||
|
||||
pub fn locate_event_segment(paths: &LedgerPaths, event_id: &str) -> CplResult<Option<u64>> {
|
||||
let mut segments = sealed_segments(paths)?;
|
||||
segments.extend(active_segments(paths)?);
|
||||
for path in segments {
|
||||
if read_segment(&path)?
|
||||
.iter()
|
||||
.any(|event| event.event_id == event_id)
|
||||
{
|
||||
return Ok(parse_segment_number(&path));
|
||||
}
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
pub fn canonical_event_line(event: &CplEvent) -> CplResult<Vec<u8>> {
|
||||
let value: Value = serde_json::to_value(event)
|
||||
.map_err(|error| CplError::new("CPL_SERIALIZATION_FAILED", error.to_string(), false))?;
|
||||
canonicalize(&value)
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
//! Native Composition Provenance Ledger service.
|
||||
//!
|
||||
//! This is the only module allowed to create authoritative records, event
|
||||
//! digests, ledger entries, chain heads, or native verification results.
|
||||
|
||||
pub mod assertions;
|
||||
pub mod canonical;
|
||||
pub mod composition;
|
||||
pub mod contribution_map;
|
||||
pub mod explorer;
|
||||
pub mod export;
|
||||
pub mod harp;
|
||||
pub mod identifiers;
|
||||
pub mod ledger;
|
||||
pub mod phase1;
|
||||
pub mod projections;
|
||||
pub mod records;
|
||||
pub mod recovery;
|
||||
pub mod verifier;
|
||||
pub mod writer;
|
||||
|
||||
use serde::Serialize;
|
||||
use std::{error::Error, fmt};
|
||||
|
||||
pub use records::{
|
||||
ChainHead, CplEvent, CplRecord, RecordInput, RecoveryClassification, RecoveryReport,
|
||||
VerificationFinding, VerificationReport, VerificationStatus, WriteCommand, WriteResult,
|
||||
};
|
||||
pub use recovery::recover_project;
|
||||
pub use verifier::verify_project;
|
||||
pub use writer::{CplService, WriterConfig};
|
||||
|
||||
pub const CPL_SCHEMA_VERSION: &str = "1.0";
|
||||
pub type CplResult<T> = Result<T, CplError>;
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct CplError {
|
||||
pub code: String,
|
||||
pub message: String,
|
||||
pub recoverable: bool,
|
||||
}
|
||||
|
||||
impl CplError {
|
||||
pub fn new(code: &str, message: impl Into<String>, recoverable: bool) -> Self {
|
||||
Self {
|
||||
code: code.to_owned(),
|
||||
message: message.into(),
|
||||
recoverable,
|
||||
}
|
||||
}
|
||||
pub fn io(context: &str, error: impl fmt::Display) -> Self {
|
||||
Self::new("CPL_IO_ERROR", format!("{context}: {error}"), true)
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for CplError {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(formatter, "{}: {}", self.code, self.message)
|
||||
}
|
||||
}
|
||||
impl Error for CplError {}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum DurableBoundary {
|
||||
IntentPrepared,
|
||||
FirstRecordStaged,
|
||||
RecordFlushed,
|
||||
RecordMoved,
|
||||
RecordDirectorySynced,
|
||||
SegmentFlushed,
|
||||
SegmentManifestFlushed,
|
||||
SegmentMoved,
|
||||
SegmentManifestMoved,
|
||||
NewActiveSegmentCreated,
|
||||
LedgerAppendBeforeFlush,
|
||||
LedgerFlushed,
|
||||
ChainHeadTemporaryWritten,
|
||||
ChainHeadReplaced,
|
||||
ChainHeadDirectorySynced,
|
||||
SqliteApplied,
|
||||
Complete,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn injected_failure(boundary: DurableBoundary) -> CplError {
|
||||
CplError::new(
|
||||
"CPL_INJECTED_FAILURE",
|
||||
format!("Injected termination after {boundary:?}"),
|
||||
true,
|
||||
)
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,19 @@
|
||||
use super::{canonical::canonical_digest, records::RecordReference, CplResult};
|
||||
use serde_json::{json, Value};
|
||||
|
||||
pub fn deterministic_projection(
|
||||
project_id: &str,
|
||||
chain_head_sha256: &str,
|
||||
mut records: Vec<RecordReference>,
|
||||
) -> CplResult<Value> {
|
||||
records.sort_by(|left, right| left.record_id.as_bytes().cmp(right.record_id.as_bytes()));
|
||||
let content = json!({
|
||||
"project_id": project_id,
|
||||
"chain_head_sha256": chain_head_sha256,
|
||||
"records": records,
|
||||
});
|
||||
Ok(json!({
|
||||
"content_sha256": canonical_digest(&content)?,
|
||||
"content": content,
|
||||
}))
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
use super::{canonical::canonical_digest, CplResult, CPL_SCHEMA_VERSION};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct RecordInput {
|
||||
pub record_type: String,
|
||||
pub payload: Value,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct CplRecord {
|
||||
pub schema_version: String,
|
||||
pub record_id: String,
|
||||
pub record_type: String,
|
||||
pub project_id: String,
|
||||
pub intent_id: String,
|
||||
pub client_action_id: String,
|
||||
pub created_at: String,
|
||||
pub payload: Value,
|
||||
pub record_sha256: String,
|
||||
}
|
||||
impl CplRecord {
|
||||
pub fn identity(&self) -> Value {
|
||||
json!({"schema_version":self.schema_version,"record_id":self.record_id,"record_type":self.record_type,"project_id":self.project_id,"intent_id":self.intent_id,"client_action_id":self.client_action_id,"created_at":self.created_at,"payload":self.payload})
|
||||
}
|
||||
pub fn verify_digest(&self) -> CplResult<bool> {
|
||||
Ok(canonical_digest(&self.identity())? == self.record_sha256)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct RecordReference {
|
||||
pub record_id: String,
|
||||
pub record_type: String,
|
||||
pub path: String,
|
||||
pub record_sha256: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct CplEvent {
|
||||
pub schema_version: String,
|
||||
pub event_id: String,
|
||||
pub project_id: String,
|
||||
pub event_sequence: u64,
|
||||
pub timestamp: String,
|
||||
pub event_type: String,
|
||||
pub actor: String,
|
||||
pub client_action_id: String,
|
||||
pub command_sha256: String,
|
||||
pub record_references: Vec<RecordReference>,
|
||||
pub metadata: Value,
|
||||
pub previous_event_sha256: Option<String>,
|
||||
pub event_sha256: String,
|
||||
}
|
||||
impl CplEvent {
|
||||
pub fn identity(&self) -> Value {
|
||||
json!({"schema_version":self.schema_version,"event_id":self.event_id,"project_id":self.project_id,"event_sequence":self.event_sequence,"timestamp":self.timestamp,"event_type":self.event_type,"actor":self.actor,"client_action_id":self.client_action_id,"command_sha256":self.command_sha256,"record_references":self.record_references,"metadata":self.metadata,"previous_event_sha256":self.previous_event_sha256})
|
||||
}
|
||||
pub fn verify_digest(&self) -> CplResult<bool> {
|
||||
Ok(canonical_digest(&self.identity())? == self.event_sha256)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct ChainHead {
|
||||
pub schema_version: String,
|
||||
pub project_id: String,
|
||||
pub segment_number: u64,
|
||||
pub segment_file: String,
|
||||
pub event_id: String,
|
||||
pub event_sequence: u64,
|
||||
pub event_sha256: String,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct SegmentManifest {
|
||||
pub schema_version: String,
|
||||
pub segment_number: u64,
|
||||
pub previous_segment_file_sha256: Option<String>,
|
||||
pub first_event_sha256: String,
|
||||
pub final_event_sha256: String,
|
||||
pub first_event_sequence: u64,
|
||||
pub final_event_sequence: u64,
|
||||
pub event_count: u64,
|
||||
pub byte_length: u64,
|
||||
pub segment_file_sha256: String,
|
||||
pub sealed_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct WriteCommand {
|
||||
pub client_action_id: String,
|
||||
pub project_id: String,
|
||||
pub event_type: String,
|
||||
pub actor: String,
|
||||
#[serde(default)]
|
||||
pub metadata: Value,
|
||||
#[serde(default)]
|
||||
pub records: Vec<RecordInput>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub operational_state: Option<Value>,
|
||||
}
|
||||
impl WriteCommand {
|
||||
pub fn digest(&self) -> CplResult<String> {
|
||||
canonical_digest(&serde_json::to_value(self).map_err(|error| {
|
||||
super::CplError::new("CPL_COMMAND_INVALID", error.to_string(), false)
|
||||
})?)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct WriteResult {
|
||||
pub idempotent_replay: bool,
|
||||
pub intent_id: String,
|
||||
pub event: CplEvent,
|
||||
pub records: Vec<RecordReference>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
|
||||
pub enum VerificationStatus {
|
||||
Verified,
|
||||
VerifiedWithWarnings,
|
||||
Incomplete,
|
||||
Failed,
|
||||
Unsafe,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct VerificationFinding {
|
||||
pub code: String,
|
||||
pub severity: String,
|
||||
pub scope: String,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct VerificationReport {
|
||||
pub schema_version: String,
|
||||
pub report_id: String,
|
||||
pub project_id: String,
|
||||
pub status: VerificationStatus,
|
||||
pub verified_at: String,
|
||||
pub chain_head: Option<ChainHead>,
|
||||
pub event_count: u64,
|
||||
pub record_count: u64,
|
||||
pub findings: Vec<VerificationFinding>,
|
||||
}
|
||||
impl VerificationReport {
|
||||
pub fn empty(project_id: &str, report_id: String, verified_at: String) -> Self {
|
||||
Self {
|
||||
schema_version: CPL_SCHEMA_VERSION.to_owned(),
|
||||
report_id,
|
||||
project_id: project_id.to_owned(),
|
||||
status: VerificationStatus::Incomplete,
|
||||
verified_at,
|
||||
chain_head: None,
|
||||
event_count: 0,
|
||||
record_count: 0,
|
||||
findings: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
|
||||
pub enum RecoveryClassification {
|
||||
Clean,
|
||||
RecoverableAutomatically,
|
||||
RequiresUserConfirmation,
|
||||
IntegrityFailure,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct RecoveryReport {
|
||||
pub classification: RecoveryClassification,
|
||||
pub actions: Vec<String>,
|
||||
pub quarantined_paths: Vec<String>,
|
||||
pub verification: VerificationReport,
|
||||
}
|
||||
@@ -0,0 +1,404 @@
|
||||
use super::{
|
||||
canonical::sha256_digest,
|
||||
ledger::{self, atomic_replace, LedgerPaths},
|
||||
records::{
|
||||
CplEvent, RecoveryClassification, RecoveryReport, SegmentManifest, VerificationStatus,
|
||||
},
|
||||
verifier,
|
||||
writer::{init_database, rebuild_indexes, set_intent_phase, ProjectWriterLock},
|
||||
CplError, CplResult,
|
||||
};
|
||||
use std::{
|
||||
fs::{self, OpenOptions},
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
pub fn recover_project(root: &Path, project_id: &str) -> CplResult<RecoveryReport> {
|
||||
let paths = LedgerPaths::new(root);
|
||||
paths.initialize()?;
|
||||
let _lock = ProjectWriterLock::acquire(root)?;
|
||||
recover_locked(root, project_id)
|
||||
}
|
||||
|
||||
pub(crate) fn recover_locked(root: &Path, project_id: &str) -> CplResult<RecoveryReport> {
|
||||
let paths = LedgerPaths::new(root);
|
||||
paths.initialize()?;
|
||||
let mut actions = Vec::new();
|
||||
let mut quarantined_paths = Vec::new();
|
||||
let database = recover_database(root, &mut actions, &mut quarantined_paths)?;
|
||||
let mut repaired = !actions.is_empty();
|
||||
|
||||
for entry in fs::read_dir(&paths.active)
|
||||
.map_err(|error| CplError::io("Could not inspect interrupted segment rotation", error))?
|
||||
.filter_map(Result::ok)
|
||||
{
|
||||
let name = entry.file_name().to_string_lossy().into_owned();
|
||||
if !name.starts_with(".segment-") || !name.ends_with(".manifest.json.tmp") {
|
||||
continue;
|
||||
}
|
||||
let manifest: SegmentManifest =
|
||||
serde_json::from_slice(&fs::read(entry.path()).map_err(|error| {
|
||||
CplError::io("Could not read an interrupted segment manifest", error)
|
||||
})?)
|
||||
.map_err(|error| {
|
||||
CplError::new("CPL_SEGMENT_MANIFEST_INVALID", error.to_string(), false)
|
||||
})?;
|
||||
let sealed_segment = paths
|
||||
.sealed
|
||||
.join(ledger::segment_filename(manifest.segment_number));
|
||||
let sealed_manifest = paths
|
||||
.sealed
|
||||
.join(ledger::segment_manifest_filename(manifest.segment_number));
|
||||
if sealed_segment.exists() && !sealed_manifest.exists() {
|
||||
let bytes = fs::read(&sealed_segment).map_err(|error| {
|
||||
CplError::io("Could not verify an interrupted sealed segment", error)
|
||||
})?;
|
||||
if sha256_digest(&bytes) != manifest.segment_file_sha256 {
|
||||
return Err(CplError::new(
|
||||
"CPL_SEGMENT_MANIFEST_MISMATCH",
|
||||
"Interrupted segment rotation has a manifest/segment digest contradiction.",
|
||||
false,
|
||||
));
|
||||
}
|
||||
atomic_replace(&entry.path(), &sealed_manifest)?;
|
||||
actions.push(format!(
|
||||
"Completed the durable manifest move for sealed segment {}.",
|
||||
manifest.segment_number
|
||||
));
|
||||
repaired = true;
|
||||
}
|
||||
}
|
||||
|
||||
let head_before = ledger::read_chain_head(&paths)?;
|
||||
let (_, active) = ledger::current_active_segment(&paths)?;
|
||||
let active_bytes = fs::read(&active).map_err(|error| {
|
||||
CplError::io(
|
||||
"Could not inspect the active segment during recovery",
|
||||
error,
|
||||
)
|
||||
})?;
|
||||
if !active_bytes.is_empty() && !active_bytes.ends_with(b"\n") {
|
||||
let complete_end = active_bytes
|
||||
.iter()
|
||||
.rposition(|byte| *byte == b'\n')
|
||||
.map_or(0, |index| index + 1);
|
||||
let complete_events = parse_complete_prefix(&active_bytes[..complete_end])?;
|
||||
let readable_ids = ledger::sealed_segments(&paths)?
|
||||
.into_iter()
|
||||
.map(|path| ledger::read_segment(&path))
|
||||
.collect::<CplResult<Vec<_>>>()?
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.chain(complete_events.iter().cloned())
|
||||
.map(|event| event.event_id)
|
||||
.collect::<Vec<_>>();
|
||||
if head_before
|
||||
.as_ref()
|
||||
.is_some_and(|head| !readable_ids.iter().any(|id| id == &head.event_id))
|
||||
{
|
||||
let mut verification = verifier::verify_project(root, project_id)?;
|
||||
verification.status = VerificationStatus::Failed;
|
||||
return Ok(RecoveryReport {
|
||||
classification: RecoveryClassification::IntegrityFailure,
|
||||
actions: vec![
|
||||
"Refused to truncate an active suffix referenced by the chain head.".to_owned(),
|
||||
],
|
||||
quarantined_paths,
|
||||
verification,
|
||||
});
|
||||
}
|
||||
let file = OpenOptions::new()
|
||||
.write(true)
|
||||
.open(&active)
|
||||
.map_err(|error| CplError::io("Could not open the truncated active segment", error))?;
|
||||
file.set_len(complete_end as u64)
|
||||
.and_then(|_| file.sync_all())
|
||||
.map_err(|error| {
|
||||
CplError::io(
|
||||
"Could not remove an uncommitted partial JSONL suffix",
|
||||
error,
|
||||
)
|
||||
})?;
|
||||
actions.push("Removed an incomplete active-segment suffix that was not referenced by the chain head.".to_owned());
|
||||
repaired = true;
|
||||
}
|
||||
|
||||
let events = ledger::read_all_events(&paths)?;
|
||||
if let Err(message) = validate_event_chain(&events, project_id) {
|
||||
let mut verification = verifier::verify_project(root, project_id)?;
|
||||
verification.status = VerificationStatus::Failed;
|
||||
verification.findings.push(super::VerificationFinding {
|
||||
code: "CPL_RECOVERY_CHAIN_CONTRADICTION".to_owned(),
|
||||
severity: "CRITICAL".to_owned(),
|
||||
scope: "recovery".to_owned(),
|
||||
message,
|
||||
});
|
||||
return Ok(RecoveryReport {
|
||||
classification: RecoveryClassification::IntegrityFailure,
|
||||
actions,
|
||||
quarantined_paths,
|
||||
verification,
|
||||
});
|
||||
}
|
||||
|
||||
match (head_before.as_ref(), events.last()) {
|
||||
(Some(head), Some(last))
|
||||
if head.event_id == last.event_id && head.event_sha256 == last.event_sha256 =>
|
||||
{
|
||||
let number =
|
||||
ledger::locate_event_segment(&paths, &last.event_id)?.ok_or_else(|| {
|
||||
CplError::new(
|
||||
"CPL_EVENT_NOT_FOUND",
|
||||
"The head event has no segment.",
|
||||
false,
|
||||
)
|
||||
})?;
|
||||
let active = ledger::active_segments(&paths)?.iter().any(|path| {
|
||||
path.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.is_some_and(|name| name == ledger::segment_filename(number))
|
||||
});
|
||||
let expected = format!(
|
||||
"provenance/ledger/{}/{}",
|
||||
if active { "active" } else { "sealed" },
|
||||
ledger::segment_filename(number)
|
||||
);
|
||||
if head.segment_number != number || head.segment_file != expected {
|
||||
ledger::advance_chain_head(&paths, project_id, last)?;
|
||||
actions.push(
|
||||
"Rebound the chain head to the recovered segment location after rotation."
|
||||
.to_owned(),
|
||||
);
|
||||
repaired = true;
|
||||
}
|
||||
}
|
||||
(Some(head), Some(last))
|
||||
if events.iter().any(|event| {
|
||||
event.event_id == head.event_id && event.event_sha256 == head.event_sha256
|
||||
}) =>
|
||||
{
|
||||
ledger::advance_chain_head(&paths, project_id, last)?;
|
||||
actions.push(format!(
|
||||
"Advanced the chain head from sequence {} to durable sequence {}.",
|
||||
head.event_sequence, last.event_sequence
|
||||
));
|
||||
repaired = true;
|
||||
}
|
||||
(None, Some(last)) => {
|
||||
ledger::advance_chain_head(&paths, project_id, last)?;
|
||||
actions.push(format!(
|
||||
"Reconstructed the missing chain head at sequence {}.",
|
||||
last.event_sequence
|
||||
));
|
||||
repaired = true;
|
||||
}
|
||||
(Some(_), None) => {
|
||||
let mut verification = verifier::verify_project(root, project_id)?;
|
||||
verification.status = VerificationStatus::Failed;
|
||||
return Ok(RecoveryReport {
|
||||
classification: RecoveryClassification::IntegrityFailure,
|
||||
actions: vec![
|
||||
"The chain head is ahead of the readable ledger; no event was invented."
|
||||
.to_owned(),
|
||||
],
|
||||
quarantined_paths,
|
||||
verification,
|
||||
});
|
||||
}
|
||||
(Some(_), Some(_)) => {
|
||||
let mut verification = verifier::verify_project(root, project_id)?;
|
||||
verification.status = VerificationStatus::Failed;
|
||||
return Ok(RecoveryReport { classification: RecoveryClassification::IntegrityFailure, actions: vec!["The chain head does not occur in the readable ledger; automatic recovery was refused.".to_owned()], quarantined_paths, verification });
|
||||
}
|
||||
(None, None) => {}
|
||||
}
|
||||
|
||||
let mut statement = database.prepare("SELECT intent_id,client_action_id,phase,record_paths_json FROM write_intents WHERE phase NOT IN ('COMPLETE','FAILED','QUARANTINED') ORDER BY intent_id")
|
||||
.map_err(super::writer::database_error)?;
|
||||
let intents = statement
|
||||
.query_map([], |row| {
|
||||
Ok((
|
||||
row.get::<_, String>(0)?,
|
||||
row.get::<_, String>(1)?,
|
||||
row.get::<_, String>(2)?,
|
||||
row.get::<_, String>(3)?,
|
||||
))
|
||||
})
|
||||
.map_err(super::writer::database_error)?
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.map_err(super::writer::database_error)?;
|
||||
drop(statement);
|
||||
|
||||
for (intent_id, client_action_id, phase, references_json) in intents {
|
||||
if events
|
||||
.iter()
|
||||
.any(|event| event.client_action_id == client_action_id)
|
||||
{
|
||||
set_intent_phase(&database, &intent_id, "COMPLETE")?;
|
||||
actions.push(format!(
|
||||
"Replayed committed action {client_action_id} into SQLite."
|
||||
));
|
||||
repaired = true;
|
||||
continue;
|
||||
}
|
||||
let references: Vec<super::records::RecordReference> =
|
||||
serde_json::from_str(&references_json)
|
||||
.map_err(|error| CplError::new("CPL_INTENT_INVALID", error.to_string(), false))?;
|
||||
let quarantine = root.join(".app/recovery/orphans").join(&intent_id);
|
||||
fs::create_dir_all(&quarantine)
|
||||
.map_err(|error| CplError::io("Could not create the recovery quarantine", error))?;
|
||||
let mut moved = false;
|
||||
for reference in references {
|
||||
let source = root.join(reference.path.replace('/', std::path::MAIN_SEPARATOR_STR));
|
||||
if source.exists() {
|
||||
let destination =
|
||||
unique_destination(&quarantine, source.file_name().unwrap_or_default());
|
||||
fs::rename(&source, &destination).map_err(|error| {
|
||||
CplError::io(
|
||||
"Could not quarantine an uncommitted immutable record",
|
||||
error,
|
||||
)
|
||||
})?;
|
||||
quarantined_paths.push(relative_display(root, &destination));
|
||||
moved = true;
|
||||
}
|
||||
}
|
||||
let staging = root.join(".app/temp/staging").join(&intent_id);
|
||||
if staging.exists() {
|
||||
for entry in fs::read_dir(&staging)
|
||||
.map_err(|error| CplError::io("Could not inspect abandoned CPL staging", error))?
|
||||
.filter_map(Result::ok)
|
||||
{
|
||||
let destination = unique_destination(&quarantine, &entry.file_name());
|
||||
fs::rename(entry.path(), &destination).map_err(|error| {
|
||||
CplError::io("Could not quarantine abandoned CPL staging", error)
|
||||
})?;
|
||||
quarantined_paths.push(relative_display(root, &destination));
|
||||
moved = true;
|
||||
}
|
||||
let _ = fs::remove_dir(&staging);
|
||||
}
|
||||
if moved {
|
||||
set_intent_phase(&database, &intent_id, "QUARANTINED")?;
|
||||
actions.push(format!(
|
||||
"Quarantined uncommitted files for {client_action_id} from phase {phase}."
|
||||
));
|
||||
} else {
|
||||
set_intent_phase(&database, &intent_id, "FAILED")?;
|
||||
actions.push(format!(
|
||||
"Closed abandoned intent {intent_id} with no durable authoritative files."
|
||||
));
|
||||
}
|
||||
repaired = true;
|
||||
}
|
||||
|
||||
rebuild_indexes(root, &events)?;
|
||||
let verification = verifier::verify_project(root, project_id)?;
|
||||
let classification = if matches!(
|
||||
verification.status,
|
||||
VerificationStatus::Failed | VerificationStatus::Unsafe
|
||||
) {
|
||||
RecoveryClassification::IntegrityFailure
|
||||
} else if repaired {
|
||||
RecoveryClassification::RecoverableAutomatically
|
||||
} else {
|
||||
RecoveryClassification::Clean
|
||||
};
|
||||
Ok(RecoveryReport {
|
||||
classification,
|
||||
actions,
|
||||
quarantined_paths,
|
||||
verification,
|
||||
})
|
||||
}
|
||||
|
||||
fn recover_database(
|
||||
root: &Path,
|
||||
actions: &mut Vec<String>,
|
||||
quarantined_paths: &mut Vec<String>,
|
||||
) -> CplResult<rusqlite::Connection> {
|
||||
match init_database(root) {
|
||||
Ok(database) => Ok(database),
|
||||
Err(original) => {
|
||||
let database_path = root.join(".app/state.sqlite");
|
||||
if !database_path.exists() {
|
||||
return Err(original);
|
||||
}
|
||||
let quarantine = root.join(".app/recovery/orphans").join(format!(
|
||||
"sqlite-{}",
|
||||
super::identifiers::sortable_id("recovery")?
|
||||
));
|
||||
fs::create_dir_all(&quarantine).map_err(|error| {
|
||||
CplError::io("Could not create corrupt-SQLite quarantine", error)
|
||||
})?;
|
||||
for suffix in ["", "-wal", "-shm"] {
|
||||
let source = PathBuf::from(format!("{}{}", database_path.display(), suffix));
|
||||
if source.exists() {
|
||||
let name = source.file_name().unwrap_or_default();
|
||||
let destination = quarantine.join(name);
|
||||
fs::rename(&source, &destination).map_err(|error| {
|
||||
CplError::io("Could not quarantine corrupt SQLite state", error)
|
||||
})?;
|
||||
quarantined_paths.push(relative_display(root, &destination));
|
||||
}
|
||||
}
|
||||
actions.push(format!("Quarantined unreadable SQLite state after {} and prepared a rebuild from the authoritative ledger.", original.message));
|
||||
init_database(root)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_complete_prefix(bytes: &[u8]) -> CplResult<Vec<CplEvent>> {
|
||||
bytes
|
||||
.split(|byte| *byte == b'\n')
|
||||
.filter(|line| !line.is_empty())
|
||||
.map(|line| {
|
||||
serde_json::from_slice(line)
|
||||
.map_err(|error| CplError::new("CPL_EVENT_INVALID", error.to_string(), false))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn validate_event_chain(events: &[CplEvent], project_id: &str) -> Result<(), String> {
|
||||
let mut previous = None;
|
||||
for (index, event) in events.iter().enumerate() {
|
||||
if event.project_id != project_id
|
||||
|| event.event_sequence != index as u64 + 1
|
||||
|| event.previous_event_sha256 != previous
|
||||
|| !event.verify_digest().map_err(|error| error.message)?
|
||||
{
|
||||
return Err(format!(
|
||||
"Authoritative event {} contradicts the expected contiguous chain.",
|
||||
event.event_id
|
||||
));
|
||||
}
|
||||
previous = Some(event.event_sha256.clone());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn unique_destination(directory: &Path, name: &std::ffi::OsStr) -> PathBuf {
|
||||
let direct = directory.join(name);
|
||||
if !direct.exists() {
|
||||
return direct;
|
||||
}
|
||||
let stem = Path::new(name)
|
||||
.file_stem()
|
||||
.and_then(|value| value.to_str())
|
||||
.unwrap_or("orphan");
|
||||
let extension = Path::new(name)
|
||||
.extension()
|
||||
.and_then(|value| value.to_str())
|
||||
.unwrap_or("bin");
|
||||
(1..)
|
||||
.map(|index| directory.join(format!("{stem}-{index}.{extension}")))
|
||||
.find(|path| !path.exists())
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn relative_display(root: &Path, path: &Path) -> String {
|
||||
path.strip_prefix(root)
|
||||
.unwrap_or(path)
|
||||
.to_string_lossy()
|
||||
.replace('\\', "/")
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
use super::{
|
||||
ledger::{self, LedgerConfig, LedgerPaths},
|
||||
writer::{init_database, CplService, WriterConfig},
|
||||
DurableBoundary, RecordInput, RecoveryClassification, VerificationStatus, WriteCommand,
|
||||
};
|
||||
use serde_json::json;
|
||||
use std::sync::Arc;
|
||||
|
||||
fn command(project_id: &str, action: &str, value: i64) -> WriteCommand {
|
||||
WriteCommand {
|
||||
client_action_id: action.to_owned(),
|
||||
project_id: project_id.to_owned(),
|
||||
event_type: "TEST_ACTION".to_owned(),
|
||||
actor: "system".to_owned(),
|
||||
metadata: json!({"value": value}),
|
||||
records: vec![RecordInput {
|
||||
record_type: "test-record".to_owned(),
|
||||
payload: json!({"value": value}),
|
||||
}],
|
||||
operational_state: Some(json!({"value": value})),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retries_are_idempotent_and_conflicts_are_rejected() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let service = CplService::new(temp.path(), "project_test");
|
||||
let first = service
|
||||
.write(command("project_test", "client_action_1", 1))
|
||||
.unwrap();
|
||||
let replay = service
|
||||
.write(command("project_test", "client_action_1", 1))
|
||||
.unwrap();
|
||||
assert_eq!(first.event.event_id, replay.event.event_id);
|
||||
assert!(replay.idempotent_replay);
|
||||
assert_eq!(
|
||||
ledger::read_all_events(&LedgerPaths::new(temp.path()))
|
||||
.unwrap()
|
||||
.len(),
|
||||
1
|
||||
);
|
||||
let conflict = service
|
||||
.write(command("project_test", "client_action_1", 2))
|
||||
.unwrap_err();
|
||||
assert_eq!(conflict.code, "CPL_IDEMPOTENCY_CONFLICT");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn os_writer_lock_serializes_concurrent_actions() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let root = Arc::new(temp.path().to_path_buf());
|
||||
std::thread::scope(|scope| {
|
||||
for index in 0..8 {
|
||||
let root = Arc::clone(&root);
|
||||
scope.spawn(move || {
|
||||
CplService::new(root.as_path(), "project_test")
|
||||
.write(command(
|
||||
"project_test",
|
||||
&format!("client_action_{index}"),
|
||||
index,
|
||||
))
|
||||
.unwrap();
|
||||
});
|
||||
}
|
||||
});
|
||||
let events = ledger::read_all_events(&LedgerPaths::new(temp.path())).unwrap();
|
||||
assert_eq!(events.len(), 8);
|
||||
assert_eq!(
|
||||
events
|
||||
.iter()
|
||||
.map(|event| event.event_sequence)
|
||||
.collect::<Vec<_>>(),
|
||||
(1..=8).collect::<Vec<_>>()
|
||||
);
|
||||
assert_eq!(
|
||||
CplService::new(temp.path(), "project_test")
|
||||
.verify()
|
||||
.unwrap()
|
||||
.status,
|
||||
VerificationStatus::Verified
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rotates_and_verifies_cross_segment_linkage() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let service = CplService::with_config(
|
||||
temp.path(),
|
||||
"project_test",
|
||||
WriterConfig {
|
||||
ledger: LedgerConfig {
|
||||
max_events_per_segment: 2,
|
||||
max_bytes_per_segment: u64::MAX,
|
||||
},
|
||||
},
|
||||
);
|
||||
for index in 0..5 {
|
||||
service
|
||||
.write(command(
|
||||
"project_test",
|
||||
&format!("rotation_action_{index}"),
|
||||
index,
|
||||
))
|
||||
.unwrap();
|
||||
}
|
||||
let paths = LedgerPaths::new(temp.path());
|
||||
assert_eq!(ledger::sealed_segments(&paths).unwrap().len(), 2);
|
||||
assert_eq!(ledger::active_segments(&paths).unwrap().len(), 1);
|
||||
assert_eq!(
|
||||
service.verify().unwrap().status,
|
||||
VerificationStatus::Verified
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_durable_boundary_recovers_and_retries_safely() {
|
||||
let boundaries = [
|
||||
DurableBoundary::IntentPrepared,
|
||||
DurableBoundary::FirstRecordStaged,
|
||||
DurableBoundary::RecordFlushed,
|
||||
DurableBoundary::RecordMoved,
|
||||
DurableBoundary::RecordDirectorySynced,
|
||||
DurableBoundary::LedgerAppendBeforeFlush,
|
||||
DurableBoundary::LedgerFlushed,
|
||||
DurableBoundary::ChainHeadTemporaryWritten,
|
||||
DurableBoundary::ChainHeadReplaced,
|
||||
DurableBoundary::ChainHeadDirectorySynced,
|
||||
DurableBoundary::SqliteApplied,
|
||||
DurableBoundary::Complete,
|
||||
];
|
||||
for boundary in boundaries {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let service = CplService::new(temp.path(), "project_test");
|
||||
let action = format!("fault_{boundary:?}");
|
||||
let failure = service
|
||||
.write_with_failure(command("project_test", &action, 7), boundary)
|
||||
.unwrap_err();
|
||||
assert_eq!(failure.code, "CPL_INJECTED_FAILURE", "{boundary:?}");
|
||||
let recovery = service.recover().unwrap();
|
||||
assert!(
|
||||
!matches!(
|
||||
recovery.classification,
|
||||
RecoveryClassification::IntegrityFailure
|
||||
),
|
||||
"{boundary:?}: {:?}",
|
||||
recovery.actions
|
||||
);
|
||||
let result = service.write(command("project_test", &action, 7)).unwrap();
|
||||
assert_eq!(result.event.event_sequence, 1, "{boundary:?}");
|
||||
let report = service.verify().unwrap();
|
||||
assert_eq!(
|
||||
report.status,
|
||||
VerificationStatus::Verified,
|
||||
"{boundary:?}: {:?}",
|
||||
report.findings
|
||||
);
|
||||
assert_eq!(report.event_count, 1, "{boundary:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recovery_rebuilds_sqlite_from_authoritative_events() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let service = CplService::new(temp.path(), "project_test");
|
||||
service
|
||||
.write(command("project_test", "rebuild_action", 4))
|
||||
.unwrap();
|
||||
let database = init_database(temp.path()).unwrap();
|
||||
database.execute("DELETE FROM cpl_events", []).unwrap();
|
||||
database.execute("DELETE FROM cpl_records", []).unwrap();
|
||||
database
|
||||
.execute("DELETE FROM cpl_action_receipts", [])
|
||||
.unwrap();
|
||||
drop(database);
|
||||
let recovery = service.recover().unwrap();
|
||||
assert!(!matches!(
|
||||
recovery.classification,
|
||||
RecoveryClassification::IntegrityFailure
|
||||
));
|
||||
assert_eq!(
|
||||
service
|
||||
.write(command("project_test", "rebuild_action", 4))
|
||||
.unwrap()
|
||||
.event
|
||||
.event_sequence,
|
||||
1
|
||||
);
|
||||
assert_eq!(
|
||||
service.verify().unwrap().status,
|
||||
VerificationStatus::Verified
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_segment_rotation_boundary_recovers_without_sequence_loss() {
|
||||
let boundaries = [
|
||||
DurableBoundary::SegmentFlushed,
|
||||
DurableBoundary::SegmentManifestFlushed,
|
||||
DurableBoundary::SegmentMoved,
|
||||
DurableBoundary::SegmentManifestMoved,
|
||||
DurableBoundary::NewActiveSegmentCreated,
|
||||
];
|
||||
for boundary in boundaries {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let service = CplService::with_config(
|
||||
temp.path(),
|
||||
"project_test",
|
||||
WriterConfig {
|
||||
ledger: LedgerConfig {
|
||||
max_events_per_segment: 1,
|
||||
max_bytes_per_segment: u64::MAX,
|
||||
},
|
||||
},
|
||||
);
|
||||
service
|
||||
.write(command("project_test", "rotation_seed", 1))
|
||||
.unwrap();
|
||||
let failure = service
|
||||
.write_with_failure(command("project_test", "rotation_retry", 2), boundary)
|
||||
.unwrap_err();
|
||||
assert_eq!(failure.code, "CPL_INJECTED_FAILURE", "{boundary:?}");
|
||||
let recovery = service.recover().unwrap();
|
||||
assert!(
|
||||
!matches!(
|
||||
recovery.classification,
|
||||
RecoveryClassification::IntegrityFailure
|
||||
),
|
||||
"{boundary:?}: {:?}",
|
||||
recovery.actions
|
||||
);
|
||||
service
|
||||
.write(command("project_test", "rotation_retry", 2))
|
||||
.unwrap();
|
||||
let events = ledger::read_all_events(&LedgerPaths::new(temp.path())).unwrap();
|
||||
assert_eq!(
|
||||
events
|
||||
.iter()
|
||||
.map(|event| event.event_sequence)
|
||||
.collect::<Vec<_>>(),
|
||||
vec![1, 2],
|
||||
"{boundary:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
service.verify().unwrap().status,
|
||||
VerificationStatus::Verified,
|
||||
"{boundary:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corrupt_sqlite_is_quarantined_and_rebuilt_from_canonical_records() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let service = CplService::new(temp.path(), "project_test");
|
||||
let mut rebuild_command = command("project_test", "sqlite_rebuild", 9);
|
||||
rebuild_command.records[0].record_type = "application-state-snapshot".to_owned();
|
||||
service.write(rebuild_command).unwrap();
|
||||
std::fs::write(
|
||||
temp.path().join(".app/state.sqlite"),
|
||||
b"not a sqlite database",
|
||||
)
|
||||
.unwrap();
|
||||
let recovery = service.recover().unwrap();
|
||||
assert_eq!(
|
||||
recovery.classification,
|
||||
RecoveryClassification::RecoverableAutomatically
|
||||
);
|
||||
assert!(recovery
|
||||
.actions
|
||||
.iter()
|
||||
.any(|action| action.contains("Quarantined unreadable SQLite")));
|
||||
assert!(!recovery.quarantined_paths.is_empty());
|
||||
let mut replay_command = command("project_test", "sqlite_rebuild", 9);
|
||||
replay_command.records[0].record_type = "application-state-snapshot".to_owned();
|
||||
let replay = service.write(replay_command).unwrap();
|
||||
assert!(replay.idempotent_replay);
|
||||
assert!(!replay.intent_id.is_empty());
|
||||
let database = init_database(temp.path()).unwrap();
|
||||
let state: String = database
|
||||
.query_row("SELECT json FROM project_state WHERE id=1", [], |row| {
|
||||
row.get(0)
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
serde_json::from_str::<serde_json::Value>(&state).unwrap(),
|
||||
json!({"value": 9})
|
||||
);
|
||||
assert_eq!(
|
||||
service.verify().unwrap().status,
|
||||
VerificationStatus::Verified
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stale_lock_file_artifact_does_not_block_the_os_managed_lock() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
std::fs::create_dir_all(temp.path().join(".app")).unwrap();
|
||||
std::fs::write(
|
||||
temp.path().join(".app/cpl.writer.lock"),
|
||||
b"stale process metadata",
|
||||
)
|
||||
.unwrap();
|
||||
let service = CplService::new(temp.path(), "project_test");
|
||||
service
|
||||
.write(command("project_test", "stale_lock_action", 1))
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
service.verify().unwrap().status,
|
||||
VerificationStatus::Verified
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,438 @@
|
||||
use super::{
|
||||
assertions,
|
||||
canonical::{canonicalize, sha256_digest},
|
||||
identifiers::{sortable_id, timestamp_millis},
|
||||
ledger::{self, LedgerPaths},
|
||||
records::{
|
||||
CplRecord, SegmentManifest, VerificationFinding, VerificationReport, VerificationStatus,
|
||||
},
|
||||
writer::init_database,
|
||||
CplError, CplResult,
|
||||
};
|
||||
use rusqlite::OptionalExtension;
|
||||
use std::{
|
||||
collections::HashSet,
|
||||
fs,
|
||||
path::{Component, Path},
|
||||
};
|
||||
|
||||
fn finding(
|
||||
code: &str,
|
||||
severity: &str,
|
||||
scope: impl Into<String>,
|
||||
message: impl Into<String>,
|
||||
) -> VerificationFinding {
|
||||
VerificationFinding {
|
||||
code: code.to_owned(),
|
||||
severity: severity.to_owned(),
|
||||
scope: scope.into(),
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_relative_path(path: &str) -> bool {
|
||||
if path.is_empty()
|
||||
|| path.contains('\\')
|
||||
|| path.contains(':')
|
||||
|| path.chars().any(char::is_control)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
Path::new(path)
|
||||
.components()
|
||||
.all(|component| matches!(component, Component::Normal(_)))
|
||||
}
|
||||
|
||||
pub fn verify_project(root: &Path, project_id: &str) -> CplResult<VerificationReport> {
|
||||
let report_id = sortable_id("report")?;
|
||||
let verified_at = timestamp_millis();
|
||||
let mut report = VerificationReport::empty(project_id, report_id, verified_at);
|
||||
let paths = LedgerPaths::new(root);
|
||||
if !paths.root.exists() {
|
||||
report.findings.push(finding(
|
||||
"CPL_LEDGER_MISSING",
|
||||
"ERROR",
|
||||
"ledger",
|
||||
"The native CPL ledger has not been initialized.",
|
||||
));
|
||||
return Ok(report);
|
||||
}
|
||||
|
||||
let mut errors = 0usize;
|
||||
let mut warnings = 0usize;
|
||||
let sealed = ledger::sealed_segments(&paths)?;
|
||||
let mut previous_segment_digest = None;
|
||||
for segment_path in &sealed {
|
||||
let number = segment_path
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.and_then(|name| name.strip_prefix("segment-"))
|
||||
.and_then(|name| name.strip_suffix(".jsonl"))
|
||||
.and_then(|name| name.parse::<u64>().ok())
|
||||
.ok_or_else(|| {
|
||||
CplError::new(
|
||||
"CPL_SEGMENT_NAME_INVALID",
|
||||
"A sealed segment filename is invalid.",
|
||||
false,
|
||||
)
|
||||
})?;
|
||||
let manifest_path = paths.sealed.join(ledger::segment_manifest_filename(number));
|
||||
let bytes = match fs::read(segment_path) {
|
||||
Ok(bytes) => bytes,
|
||||
Err(error) => {
|
||||
errors += 1;
|
||||
report.findings.push(finding(
|
||||
"CPL_SEALED_SEGMENT_UNREADABLE",
|
||||
"CRITICAL",
|
||||
segment_path.display().to_string(),
|
||||
error.to_string(),
|
||||
));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let manifest_bytes = match fs::read(&manifest_path) {
|
||||
Ok(bytes) => bytes,
|
||||
Err(error) => {
|
||||
errors += 1;
|
||||
report.findings.push(finding(
|
||||
"CPL_SEGMENT_MANIFEST_INVALID",
|
||||
"CRITICAL",
|
||||
manifest_path.display().to_string(),
|
||||
error.to_string(),
|
||||
));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let manifest: SegmentManifest = match serde_json::from_slice(&manifest_bytes) {
|
||||
Ok(manifest) => manifest,
|
||||
Err(error) => {
|
||||
errors += 1;
|
||||
report.findings.push(finding(
|
||||
"CPL_SEGMENT_MANIFEST_INVALID",
|
||||
"CRITICAL",
|
||||
manifest_path.display().to_string(),
|
||||
error.to_string(),
|
||||
));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if canonicalize(&serde_json::to_value(&manifest).map_err(|error| {
|
||||
CplError::new("CPL_SERIALIZATION_FAILED", error.to_string(), false)
|
||||
})?)?
|
||||
!= manifest_bytes
|
||||
{
|
||||
errors += 1;
|
||||
report.findings.push(finding(
|
||||
"CPL_SEGMENT_MANIFEST_NONCANONICAL",
|
||||
"CRITICAL",
|
||||
manifest_path.display().to_string(),
|
||||
"The sealed segment manifest is not canonical JSON.",
|
||||
));
|
||||
}
|
||||
let events = match ledger::read_segment(segment_path) {
|
||||
Ok(events) => events,
|
||||
Err(error) => {
|
||||
errors += 1;
|
||||
report.findings.push(finding(
|
||||
&error.code,
|
||||
"CRITICAL",
|
||||
segment_path.display().to_string(),
|
||||
error.message,
|
||||
));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let manifest_valid = !events.is_empty()
|
||||
&& manifest.segment_number == number
|
||||
&& manifest.previous_segment_file_sha256 == previous_segment_digest
|
||||
&& manifest.first_event_sha256 == events.first().unwrap().event_sha256
|
||||
&& manifest.final_event_sha256 == events.last().unwrap().event_sha256
|
||||
&& manifest.first_event_sequence == events.first().unwrap().event_sequence
|
||||
&& manifest.final_event_sequence == events.last().unwrap().event_sequence
|
||||
&& manifest.event_count == events.len() as u64
|
||||
&& manifest.byte_length == bytes.len() as u64
|
||||
&& manifest.segment_file_sha256 == sha256_digest(&bytes);
|
||||
if !manifest_valid {
|
||||
errors += 1;
|
||||
report.findings.push(finding(
|
||||
"CPL_SEGMENT_MANIFEST_MISMATCH",
|
||||
"CRITICAL",
|
||||
manifest_path.display().to_string(),
|
||||
"The sealed manifest does not bind the exact segment bytes and event range.",
|
||||
));
|
||||
}
|
||||
previous_segment_digest = Some(sha256_digest(&bytes));
|
||||
}
|
||||
|
||||
let events = match ledger::read_all_events(&paths) {
|
||||
Ok(events) => events,
|
||||
Err(error) => {
|
||||
report.status = VerificationStatus::Failed;
|
||||
report
|
||||
.findings
|
||||
.push(finding(&error.code, "CRITICAL", "ledger", error.message));
|
||||
return Ok(report);
|
||||
}
|
||||
};
|
||||
report.event_count = events.len() as u64;
|
||||
let mut previous = None;
|
||||
let mut record_count = 0u64;
|
||||
let mut event_ids = HashSet::new();
|
||||
let mut action_ids = HashSet::new();
|
||||
let mut record_ids = HashSet::new();
|
||||
for (index, event) in events.iter().enumerate() {
|
||||
if !event_ids.insert(&event.event_id) || !action_ids.insert(&event.client_action_id) {
|
||||
errors += 1;
|
||||
report.findings.push(finding(
|
||||
"CPL_EVENT_ID_DUPLICATE",
|
||||
"CRITICAL",
|
||||
event.event_id.clone(),
|
||||
"Event IDs and client action IDs must each be unique across the ledger.",
|
||||
));
|
||||
}
|
||||
let expected_sequence = index as u64 + 1;
|
||||
if event.project_id != project_id
|
||||
|| event.event_sequence != expected_sequence
|
||||
|| event.previous_event_sha256 != previous
|
||||
|| !event.verify_digest()?
|
||||
{
|
||||
errors += 1;
|
||||
report.findings.push(finding("CPL_EVENT_CHAIN_INVALID", "CRITICAL", event.event_id.clone(), format!("Event sequence {} failed project, sequence, previous-link, or digest verification.", event.event_sequence)));
|
||||
}
|
||||
let parsed = chrono::DateTime::parse_from_rfc3339(&event.timestamp).ok();
|
||||
let timestamp_valid = parsed
|
||||
.map(|value| {
|
||||
value.offset().local_minus_utc() == 0
|
||||
&& value.timestamp_subsec_millis() * 1_000_000 == value.timestamp_subsec_nanos()
|
||||
&& event.timestamp.ends_with('Z')
|
||||
&& event.timestamp.len() == 24
|
||||
})
|
||||
.unwrap_or(false);
|
||||
if !timestamp_valid {
|
||||
errors += 1;
|
||||
report.findings.push(finding(
|
||||
"CPL_TIMESTAMP_INVALID",
|
||||
"ERROR",
|
||||
event.event_id.clone(),
|
||||
"The event timestamp is not exact RFC 3339 UTC millisecond form.",
|
||||
));
|
||||
}
|
||||
for reference in &event.record_references {
|
||||
if !record_ids.insert(&reference.record_id) {
|
||||
errors += 1;
|
||||
report.findings.push(finding(
|
||||
"CPL_RECORD_ID_DUPLICATE",
|
||||
"CRITICAL",
|
||||
reference.record_id.clone(),
|
||||
"An immutable record ID is referenced more than once.",
|
||||
));
|
||||
}
|
||||
record_count += 1;
|
||||
if !validate_relative_path(&reference.path) {
|
||||
errors += 1;
|
||||
report.findings.push(finding(
|
||||
"CPL_RECORD_PATH_UNSAFE",
|
||||
"CRITICAL",
|
||||
reference.record_id.clone(),
|
||||
"The record path is not a safe project-relative forward-slash path.",
|
||||
));
|
||||
continue;
|
||||
}
|
||||
let path = root.join(reference.path.replace('/', std::path::MAIN_SEPARATOR_STR));
|
||||
let bytes = match fs::read(&path) {
|
||||
Ok(bytes) => bytes,
|
||||
Err(error) => {
|
||||
errors += 1;
|
||||
report.findings.push(finding(
|
||||
"CPL_RECORD_MISSING",
|
||||
"CRITICAL",
|
||||
reference.record_id.clone(),
|
||||
error.to_string(),
|
||||
));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let record: CplRecord = match serde_json::from_slice(&bytes) {
|
||||
Ok(record) => record,
|
||||
Err(error) => {
|
||||
errors += 1;
|
||||
report.findings.push(finding(
|
||||
"CPL_RECORD_INVALID",
|
||||
"CRITICAL",
|
||||
reference.record_id.clone(),
|
||||
error.to_string(),
|
||||
));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let canonical = canonicalize(&serde_json::to_value(&record).map_err(|error| {
|
||||
CplError::new("CPL_SERIALIZATION_FAILED", error.to_string(), false)
|
||||
})?)?;
|
||||
if canonical != bytes
|
||||
|| record.record_id != reference.record_id
|
||||
|| record.record_type != reference.record_type
|
||||
|| record.record_sha256 != reference.record_sha256
|
||||
|| !record.verify_digest()?
|
||||
{
|
||||
errors += 1;
|
||||
report.findings.push(finding("CPL_RECORD_DIGEST_INVALID", "CRITICAL", reference.record_id.clone(), "The immutable record bytes, identity, type, or digest do not match the event reference."));
|
||||
}
|
||||
if let Err(message) = assertions::validate_record(&record) {
|
||||
errors += 1;
|
||||
report.findings.push(finding(
|
||||
"CPL_ASSERTION_INVALID",
|
||||
"ERROR",
|
||||
reference.record_id.clone(),
|
||||
message,
|
||||
));
|
||||
}
|
||||
}
|
||||
previous = Some(event.event_sha256.clone());
|
||||
}
|
||||
report.record_count = record_count;
|
||||
|
||||
let head = ledger::read_chain_head(&paths)?;
|
||||
report.chain_head = head.clone();
|
||||
let expected_head_location = if let Some(last) = events.last() {
|
||||
let number = ledger::locate_event_segment(&paths, &last.event_id)?;
|
||||
if let Some(number) = number {
|
||||
let active = ledger::active_segments(&paths)?.iter().any(|path| {
|
||||
path.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.is_some_and(|name| name == ledger::segment_filename(number))
|
||||
});
|
||||
Some((
|
||||
number,
|
||||
format!(
|
||||
"provenance/ledger/{}/{}",
|
||||
if active { "active" } else { "sealed" },
|
||||
ledger::segment_filename(number)
|
||||
),
|
||||
))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
match (events.last(), head.as_ref()) {
|
||||
(Some(last), Some(head))
|
||||
if head.project_id == project_id
|
||||
&& head.event_id == last.event_id
|
||||
&& head.event_sequence == last.event_sequence
|
||||
&& head.event_sha256 == last.event_sha256
|
||||
&& expected_head_location
|
||||
.as_ref()
|
||||
.is_some_and(|(number, path)| {
|
||||
head.segment_number == *number && head.segment_file == *path
|
||||
}) => {}
|
||||
(None, None) => report.findings.push(finding(
|
||||
"CPL_LEDGER_EMPTY",
|
||||
"WARNING",
|
||||
"ledger",
|
||||
"The ledger is initialized but contains no events.",
|
||||
)),
|
||||
_ => {
|
||||
errors += 1;
|
||||
report.findings.push(finding(
|
||||
"CPL_CHAIN_HEAD_MISMATCH",
|
||||
"CRITICAL",
|
||||
"chain-head",
|
||||
"The chain head does not bind the final readable event.",
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
if let Ok(database) = init_database(root) {
|
||||
let indexed: Option<u64> = database
|
||||
.query_row("SELECT MAX(event_sequence) FROM cpl_events", [], |row| {
|
||||
row.get(0)
|
||||
})
|
||||
.optional()
|
||||
.unwrap_or(None)
|
||||
.flatten();
|
||||
let authoritative = events.last().map(|event| event.event_sequence);
|
||||
if indexed != authoritative {
|
||||
warnings += 1;
|
||||
report.findings.push(finding(
|
||||
"CPL_DERIVED_INDEX_STALE",
|
||||
"WARNING",
|
||||
"sqlite",
|
||||
"The rebuildable SQLite event index is stale.",
|
||||
));
|
||||
}
|
||||
} else {
|
||||
warnings += 1;
|
||||
report.findings.push(finding(
|
||||
"CPL_DERIVED_INDEX_UNAVAILABLE",
|
||||
"WARNING",
|
||||
"sqlite",
|
||||
"The rebuildable SQLite index could not be inspected.",
|
||||
));
|
||||
}
|
||||
|
||||
report.status = if errors > 0 {
|
||||
VerificationStatus::Failed
|
||||
} else if events.is_empty() {
|
||||
VerificationStatus::Incomplete
|
||||
} else if warnings > 0 {
|
||||
VerificationStatus::VerifiedWithWarnings
|
||||
} else {
|
||||
VerificationStatus::Verified
|
||||
};
|
||||
if errors == 0 && !events.is_empty() {
|
||||
report.findings.push(finding("CPL_INTEGRITY_VERIFIED", "INFO", "ledger", format!("Verified {} contiguous events and {} immutable record references against the retained chain head.", events.len(), record_count)));
|
||||
}
|
||||
Ok(report)
|
||||
}
|
||||
/// Enforces the release boundary against the authoritative native report.
|
||||
/// Warnings remain visible but do not make an otherwise verified chain unsafe
|
||||
/// to release. Every other terminal status blocks before release state changes.
|
||||
pub fn require_release_verification(report: &VerificationReport) -> CplResult<()> {
|
||||
match report.status {
|
||||
VerificationStatus::Verified | VerificationStatus::VerifiedWithWarnings => Ok(()),
|
||||
VerificationStatus::Incomplete
|
||||
| VerificationStatus::Failed
|
||||
| VerificationStatus::Unsafe => Err(CplError::new(
|
||||
"RELEASE_VERIFICATION_BLOCKED",
|
||||
format!(
|
||||
"Native CPL verification status {:?} does not permit release finalization.",
|
||||
report.status
|
||||
),
|
||||
true,
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod release_gate_tests {
|
||||
use super::*;
|
||||
|
||||
fn report(status: VerificationStatus) -> VerificationReport {
|
||||
let mut report = VerificationReport::empty(
|
||||
"project_test",
|
||||
"report_test".into(),
|
||||
"2026-07-19T00:00:00.000Z".into(),
|
||||
);
|
||||
report.status = status;
|
||||
report
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn release_gate_accepts_only_complete_safe_native_verification() {
|
||||
for status in [
|
||||
VerificationStatus::Verified,
|
||||
VerificationStatus::VerifiedWithWarnings,
|
||||
] {
|
||||
require_release_verification(&report(status)).unwrap();
|
||||
}
|
||||
for status in [
|
||||
VerificationStatus::Incomplete,
|
||||
VerificationStatus::Failed,
|
||||
VerificationStatus::Unsafe,
|
||||
] {
|
||||
let error = require_release_verification(&report(status)).unwrap_err();
|
||||
assert_eq!(error.code, "RELEASE_VERIFICATION_BLOCKED");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,678 @@
|
||||
use super::{
|
||||
canonical::{canonical_digest, canonicalize},
|
||||
identifiers::{sortable_id, timestamp_millis},
|
||||
ledger::{self, atomic_replace, sync_directory, LedgerConfig, LedgerPaths},
|
||||
records::{
|
||||
CplEvent, CplRecord, RecordReference, VerificationReport, WriteCommand, WriteResult,
|
||||
},
|
||||
recovery, verifier, CplError, CplResult, DurableBoundary, CPL_SCHEMA_VERSION,
|
||||
};
|
||||
use rusqlite::{params, Connection, OptionalExtension};
|
||||
use std::{
|
||||
fs::{self, File, OpenOptions},
|
||||
io::Write,
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct WriterConfig {
|
||||
pub ledger: LedgerConfig,
|
||||
}
|
||||
|
||||
impl Default for WriterConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
ledger: LedgerConfig::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct CplService {
|
||||
root: PathBuf,
|
||||
project_id: String,
|
||||
config: WriterConfig,
|
||||
}
|
||||
|
||||
impl CplService {
|
||||
pub fn new(root: impl Into<PathBuf>, project_id: impl Into<String>) -> Self {
|
||||
Self {
|
||||
root: root.into(),
|
||||
project_id: project_id.into(),
|
||||
config: WriterConfig::default(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn with_config(
|
||||
root: impl Into<PathBuf>,
|
||||
project_id: impl Into<String>,
|
||||
config: WriterConfig,
|
||||
) -> Self {
|
||||
Self {
|
||||
root: root.into(),
|
||||
project_id: project_id.into(),
|
||||
config,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn initialize(&self) -> CplResult<()> {
|
||||
LedgerPaths::new(&self.root).initialize()?;
|
||||
fs::create_dir_all(self.root.join("records"))
|
||||
.and_then(|_| fs::create_dir_all(self.root.join(".app/recovery/orphans")))
|
||||
.and_then(|_| fs::create_dir_all(self.root.join(".app/temp/staging")))
|
||||
.map_err(|error| CplError::io("Could not initialize native CPL storage", error))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn write(&self, command: WriteCommand) -> CplResult<WriteResult> {
|
||||
self.write_with_boundary(command, &mut |_| Ok(()))
|
||||
}
|
||||
|
||||
pub fn verify(&self) -> CplResult<VerificationReport> {
|
||||
verifier::verify_project(&self.root, &self.project_id)
|
||||
}
|
||||
|
||||
pub fn recover(&self) -> CplResult<super::RecoveryReport> {
|
||||
recovery::recover_project(&self.root, &self.project_id)
|
||||
}
|
||||
|
||||
fn write_with_boundary(
|
||||
&self,
|
||||
command: WriteCommand,
|
||||
boundary: &mut dyn FnMut(DurableBoundary) -> CplResult<()>,
|
||||
) -> CplResult<WriteResult> {
|
||||
let _lock = ProjectWriterLock::acquire(&self.root)?;
|
||||
self.initialize()?;
|
||||
recovery::recover_locked(&self.root, &self.project_id)?;
|
||||
self.write_locked(command, boundary)
|
||||
}
|
||||
|
||||
pub(crate) fn write_prepared(
|
||||
&self,
|
||||
client_action_id: &str,
|
||||
prepare: impl FnOnce() -> CplResult<Option<WriteCommand>>,
|
||||
) -> CplResult<WriteResult> {
|
||||
let _lock = ProjectWriterLock::acquire(&self.root)?;
|
||||
self.initialize()?;
|
||||
recovery::recover_locked(&self.root, &self.project_id)?;
|
||||
if let Some(command) = prepare()? {
|
||||
return self.write_locked(command, &mut |_| Ok(()));
|
||||
}
|
||||
let database = init_database(&self.root)?;
|
||||
let result_json = database
|
||||
.query_row(
|
||||
"SELECT result_json FROM cpl_action_receipts WHERE client_action_id=?1",
|
||||
params![client_action_id],
|
||||
|row| row.get::<_, String>(0),
|
||||
)
|
||||
.optional()
|
||||
.map_err(database_error)?
|
||||
.ok_or_else(|| {
|
||||
CplError::new(
|
||||
"CPL_RECEIPT_MISSING",
|
||||
"The committed composition action has no recoverable receipt.",
|
||||
false,
|
||||
)
|
||||
})?;
|
||||
let mut result: WriteResult = serde_json::from_str(&result_json)
|
||||
.map_err(|error| CplError::new("CPL_RECEIPT_INVALID", error.to_string(), false))?;
|
||||
result.idempotent_replay = true;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
fn write_locked(
|
||||
&self,
|
||||
command: WriteCommand,
|
||||
boundary: &mut dyn FnMut(DurableBoundary) -> CplResult<()>,
|
||||
) -> CplResult<WriteResult> {
|
||||
validate_command(&command, &self.project_id)?;
|
||||
let command_sha256 = command.digest()?;
|
||||
let mut database = init_database(&self.root)?;
|
||||
|
||||
if let Some((stored_digest, stored_result)) = database
|
||||
.query_row(
|
||||
"SELECT command_sha256, result_json FROM cpl_action_receipts WHERE client_action_id=?1",
|
||||
params![command.client_action_id],
|
||||
|row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)),
|
||||
)
|
||||
.optional()
|
||||
.map_err(database_error)?
|
||||
{
|
||||
if stored_digest != command_sha256 {
|
||||
return Err(CplError::new(
|
||||
"CPL_IDEMPOTENCY_CONFLICT",
|
||||
"The client_action_id was already committed with a different canonical command.",
|
||||
false,
|
||||
));
|
||||
}
|
||||
let mut result: WriteResult = serde_json::from_str(&stored_result).map_err(|error| {
|
||||
CplError::new("CPL_RECEIPT_INVALID", error.to_string(), false)
|
||||
})?;
|
||||
result.idempotent_replay = true;
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
let intent_id = sortable_id("intent")?;
|
||||
let event_id = sortable_id("event")?;
|
||||
let created_at = timestamp_millis();
|
||||
let (records, references) = prepare_records(&command, &intent_id, &created_at)?;
|
||||
let paths_json = serde_json::to_string(&references)
|
||||
.map_err(|error| CplError::new("CPL_SERIALIZATION_FAILED", error.to_string(), false))?;
|
||||
database
|
||||
.execute(
|
||||
"INSERT INTO write_intents(intent_id,client_action_id,command_sha256,phase,record_paths_json,event_id,updated_at) VALUES(?1,?2,?3,'PREPARED',?4,?5,?6)",
|
||||
params![intent_id, command.client_action_id, command_sha256, paths_json, event_id, created_at],
|
||||
)
|
||||
.map_err(database_error)?;
|
||||
boundary(DurableBoundary::IntentPrepared)?;
|
||||
|
||||
persist_records(&self.root, &intent_id, &records, &references, boundary)?;
|
||||
set_intent_phase(&database, &intent_id, "RECORDS_DURABLE")?;
|
||||
|
||||
let event = CplEvent {
|
||||
schema_version: CPL_SCHEMA_VERSION.to_owned(),
|
||||
event_id,
|
||||
project_id: command.project_id.clone(),
|
||||
event_sequence: 0,
|
||||
timestamp: created_at,
|
||||
event_type: command.event_type.clone(),
|
||||
actor: command.actor.clone(),
|
||||
client_action_id: command.client_action_id.clone(),
|
||||
command_sha256: command_sha256.clone(),
|
||||
record_references: references.clone(),
|
||||
metadata: command.metadata.clone(),
|
||||
previous_event_sha256: None,
|
||||
event_sha256: String::new(),
|
||||
};
|
||||
let (event, _) = ledger::append_event(
|
||||
&LedgerPaths::new(&self.root),
|
||||
self.config.ledger,
|
||||
event,
|
||||
boundary,
|
||||
)?;
|
||||
set_intent_phase(&database, &intent_id, "LEDGER_APPENDED")?;
|
||||
set_intent_phase(&database, &intent_id, "CHAIN_HEAD_ADVANCED")?;
|
||||
|
||||
let result = WriteResult {
|
||||
idempotent_replay: false,
|
||||
intent_id: intent_id.clone(),
|
||||
event: event.clone(),
|
||||
records: references.clone(),
|
||||
};
|
||||
apply_sqlite_state(
|
||||
&mut database,
|
||||
&command,
|
||||
&command_sha256,
|
||||
&intent_id,
|
||||
&event,
|
||||
&result,
|
||||
)?;
|
||||
boundary(DurableBoundary::SqliteApplied)?;
|
||||
set_intent_phase(&database, &intent_id, "COMPLETE")?;
|
||||
boundary(DurableBoundary::Complete)?;
|
||||
let staging = self.root.join(".app/temp/staging").join(&intent_id);
|
||||
if staging.exists() {
|
||||
let _ = fs::remove_dir(&staging);
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn write_with_failure(
|
||||
&self,
|
||||
command: WriteCommand,
|
||||
failure: DurableBoundary,
|
||||
) -> CplResult<WriteResult> {
|
||||
let mut fired = false;
|
||||
self.write_with_boundary(command, &mut |boundary| {
|
||||
if !fired && boundary == failure {
|
||||
fired = true;
|
||||
Err(super::injected_failure(boundary))
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_command(command: &WriteCommand, project_id: &str) -> CplResult<()> {
|
||||
if command.project_id != project_id {
|
||||
return Err(CplError::new(
|
||||
"CPL_PROJECT_MISMATCH",
|
||||
"The command targets another project.",
|
||||
false,
|
||||
));
|
||||
}
|
||||
for (label, value) in [
|
||||
("client_action_id", command.client_action_id.as_str()),
|
||||
("event_type", command.event_type.as_str()),
|
||||
("actor", command.actor.as_str()),
|
||||
] {
|
||||
if value.trim().is_empty() || value.chars().any(char::is_control) {
|
||||
return Err(CplError::new(
|
||||
"CPL_COMMAND_INVALID",
|
||||
format!("{label} is empty or contains control characters."),
|
||||
false,
|
||||
));
|
||||
}
|
||||
}
|
||||
if command.records.is_empty() {
|
||||
return Err(CplError::new(
|
||||
"CPL_RECORD_REQUIRED",
|
||||
"Every provenance mutation requires at least one immutable authoritative record.",
|
||||
false,
|
||||
));
|
||||
}
|
||||
for record in &command.records {
|
||||
if !safe_component(&record.record_type) {
|
||||
return Err(CplError::new(
|
||||
"CPL_RECORD_TYPE_INVALID",
|
||||
format!("Unsafe record type '{}'.", record.record_type),
|
||||
false,
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn safe_component(value: &str) -> bool {
|
||||
!value.is_empty()
|
||||
&& value.len() <= 80
|
||||
&& value.bytes().all(|byte| {
|
||||
byte.is_ascii_lowercase() || byte.is_ascii_digit() || matches!(byte, b'-' | b'_')
|
||||
})
|
||||
}
|
||||
|
||||
fn prepare_records(
|
||||
command: &WriteCommand,
|
||||
intent_id: &str,
|
||||
created_at: &str,
|
||||
) -> CplResult<(Vec<CplRecord>, Vec<RecordReference>)> {
|
||||
let mut records = Vec::with_capacity(command.records.len());
|
||||
let mut references = Vec::with_capacity(command.records.len());
|
||||
for input in &command.records {
|
||||
let record_id = sortable_id("record")?;
|
||||
let mut record = CplRecord {
|
||||
schema_version: CPL_SCHEMA_VERSION.to_owned(),
|
||||
record_id: record_id.clone(),
|
||||
record_type: input.record_type.clone(),
|
||||
project_id: command.project_id.clone(),
|
||||
intent_id: intent_id.to_owned(),
|
||||
client_action_id: command.client_action_id.clone(),
|
||||
created_at: created_at.to_owned(),
|
||||
payload: input.payload.clone(),
|
||||
record_sha256: String::new(),
|
||||
};
|
||||
record.record_sha256 = canonical_digest(&record.identity())?;
|
||||
references.push(RecordReference {
|
||||
record_id,
|
||||
record_type: input.record_type.clone(),
|
||||
path: format!("records/{}/{}.json", input.record_type, record.record_id),
|
||||
record_sha256: record.record_sha256.clone(),
|
||||
});
|
||||
records.push(record);
|
||||
}
|
||||
Ok((records, references))
|
||||
}
|
||||
|
||||
fn persist_records(
|
||||
root: &Path,
|
||||
intent_id: &str,
|
||||
records: &[CplRecord],
|
||||
references: &[RecordReference],
|
||||
boundary: &mut dyn FnMut(DurableBoundary) -> CplResult<()>,
|
||||
) -> CplResult<()> {
|
||||
let staging = root.join(".app/temp/staging").join(intent_id);
|
||||
fs::create_dir_all(&staging)
|
||||
.map_err(|error| CplError::io("Could not create same-filesystem CPL staging", error))?;
|
||||
for (index, (record, reference)) in records.iter().zip(references).enumerate() {
|
||||
let stage = staging.join(format!("{}.json", record.record_id));
|
||||
let bytes = canonicalize(&serde_json::to_value(record).map_err(|error| {
|
||||
CplError::new("CPL_SERIALIZATION_FAILED", error.to_string(), false)
|
||||
})?)?;
|
||||
let mut file = File::create(&stage)
|
||||
.map_err(|error| CplError::io("Could not stage an immutable CPL record", error))?;
|
||||
file.write_all(&bytes)
|
||||
.map_err(|error| CplError::io("Could not write a staged CPL record", error))?;
|
||||
if index == 0 {
|
||||
boundary(DurableBoundary::FirstRecordStaged)?;
|
||||
}
|
||||
file.sync_all()
|
||||
.map_err(|error| CplError::io("Could not flush a staged CPL record", error))?;
|
||||
boundary(DurableBoundary::RecordFlushed)?;
|
||||
drop(file);
|
||||
let final_path = root.join(reference.path.replace('/', std::path::MAIN_SEPARATOR_STR));
|
||||
let parent = final_path.parent().ok_or_else(|| {
|
||||
CplError::new("CPL_PATH_INVALID", "Record path has no parent.", false)
|
||||
})?;
|
||||
fs::create_dir_all(parent).map_err(|error| {
|
||||
CplError::io("Could not create the immutable record directory", error)
|
||||
})?;
|
||||
if final_path.exists() {
|
||||
return Err(CplError::new(
|
||||
"CPL_RECORD_COLLISION",
|
||||
format!("{} already exists.", final_path.display()),
|
||||
false,
|
||||
));
|
||||
}
|
||||
atomic_replace(&stage, &final_path)?;
|
||||
boundary(DurableBoundary::RecordMoved)?;
|
||||
sync_directory(parent)?;
|
||||
boundary(DurableBoundary::RecordDirectorySynced)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn apply_sqlite_state(
|
||||
database: &mut Connection,
|
||||
command: &WriteCommand,
|
||||
command_sha256: &str,
|
||||
intent_id: &str,
|
||||
event: &CplEvent,
|
||||
result: &WriteResult,
|
||||
) -> CplResult<()> {
|
||||
let transaction = database.transaction().map_err(database_error)?;
|
||||
if let Some(state) = &command.operational_state {
|
||||
let state = serde_json::to_string(state)
|
||||
.map_err(|error| CplError::new("CPL_SERIALIZATION_FAILED", error.to_string(), false))?;
|
||||
transaction.execute(
|
||||
"INSERT INTO project_state(id,json,updated_at) VALUES(1,?1,?2) ON CONFLICT(id) DO UPDATE SET json=excluded.json,updated_at=excluded.updated_at",
|
||||
params![state, event.timestamp],
|
||||
).map_err(database_error)?;
|
||||
}
|
||||
index_event(&transaction, event)?;
|
||||
let result_json = serde_json::to_string(result)
|
||||
.map_err(|error| CplError::new("CPL_SERIALIZATION_FAILED", error.to_string(), false))?;
|
||||
transaction.execute(
|
||||
"INSERT OR REPLACE INTO cpl_action_receipts(client_action_id,command_sha256,event_id,result_json,committed_at) VALUES(?1,?2,?3,?4,?5)",
|
||||
params![command.client_action_id, command_sha256, event.event_id, result_json, event.timestamp],
|
||||
).map_err(database_error)?;
|
||||
transaction.execute("UPDATE write_intents SET phase='SQLITE_APPLIED',result_json=?2,updated_at=?3 WHERE intent_id=?1", params![intent_id, result_json, event.timestamp]).map_err(database_error)?;
|
||||
transaction.commit().map_err(database_error)
|
||||
}
|
||||
|
||||
pub(crate) fn init_database(root: &Path) -> CplResult<Connection> {
|
||||
let state = root.join(".app");
|
||||
fs::create_dir_all(&state)
|
||||
.map_err(|error| CplError::io("Could not create CPL SQLite storage", error))?;
|
||||
let database = Connection::open(state.join("state.sqlite")).map_err(database_error)?;
|
||||
database.execute_batch(
|
||||
"PRAGMA journal_mode=WAL;
|
||||
PRAGMA synchronous=FULL;
|
||||
PRAGMA foreign_keys=ON;
|
||||
CREATE TABLE IF NOT EXISTS project_state (id INTEGER PRIMARY KEY CHECK(id=1), json TEXT NOT NULL, updated_at TEXT NOT NULL);
|
||||
CREATE TABLE IF NOT EXISTS write_intents (intent_id TEXT PRIMARY KEY, client_action_id TEXT NOT NULL, command_sha256 TEXT NOT NULL, phase TEXT NOT NULL CHECK(phase IN ('PREPARED','RECORDS_DURABLE','LEDGER_APPENDED','CHAIN_HEAD_ADVANCED','SQLITE_APPLIED','COMPLETE','QUARANTINED','FAILED')), record_paths_json TEXT NOT NULL, event_id TEXT NOT NULL, result_json TEXT, updated_at TEXT NOT NULL);
|
||||
CREATE TABLE IF NOT EXISTS cpl_events (event_sequence INTEGER PRIMARY KEY, event_id TEXT NOT NULL UNIQUE, client_action_id TEXT NOT NULL UNIQUE, event_type TEXT NOT NULL, event_sha256 TEXT NOT NULL, previous_event_sha256 TEXT, timestamp TEXT NOT NULL);
|
||||
CREATE TABLE IF NOT EXISTS cpl_records (record_id TEXT PRIMARY KEY, record_type TEXT NOT NULL, path TEXT NOT NULL UNIQUE, record_sha256 TEXT NOT NULL, event_id TEXT NOT NULL);
|
||||
CREATE TABLE IF NOT EXISTS cpl_action_receipts (client_action_id TEXT PRIMARY KEY, command_sha256 TEXT NOT NULL, event_id TEXT NOT NULL, result_json TEXT NOT NULL, committed_at TEXT NOT NULL);",
|
||||
).map_err(database_error)?;
|
||||
Ok(database)
|
||||
}
|
||||
|
||||
pub(crate) fn index_event(database: &Connection, event: &CplEvent) -> CplResult<()> {
|
||||
database.execute(
|
||||
"INSERT OR REPLACE INTO cpl_events(event_sequence,event_id,client_action_id,event_type,event_sha256,previous_event_sha256,timestamp) VALUES(?1,?2,?3,?4,?5,?6,?7)",
|
||||
params![event.event_sequence, event.event_id, event.client_action_id, event.event_type, event.event_sha256, event.previous_event_sha256, event.timestamp],
|
||||
).map_err(database_error)?;
|
||||
for record in &event.record_references {
|
||||
database.execute(
|
||||
"INSERT OR REPLACE INTO cpl_records(record_id,record_type,path,record_sha256,event_id) VALUES(?1,?2,?3,?4,?5)",
|
||||
params![record.record_id, record.record_type, record.path, record.record_sha256, event.event_id],
|
||||
).map_err(database_error)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn rebuild_indexes(root: &Path, events: &[CplEvent]) -> CplResult<()> {
|
||||
let phase1_project_id = events.iter().find_map(|event| {
|
||||
event
|
||||
.record_references
|
||||
.iter()
|
||||
.any(|record| record.record_type == "phase1-operation")
|
||||
.then(|| event.project_id.clone())
|
||||
});
|
||||
let composition_project_id = events.iter().find_map(|event| {
|
||||
event
|
||||
.record_references
|
||||
.iter()
|
||||
.any(|record| record.record_type == "composition-command")
|
||||
.then(|| event.project_id.clone())
|
||||
});
|
||||
let mut database = init_database(root)?;
|
||||
let transaction = database.transaction().map_err(database_error)?;
|
||||
transaction
|
||||
.execute("DELETE FROM cpl_records", [])
|
||||
.map_err(database_error)?;
|
||||
transaction
|
||||
.execute("DELETE FROM cpl_events", [])
|
||||
.map_err(database_error)?;
|
||||
transaction
|
||||
.execute("DELETE FROM cpl_action_receipts", [])
|
||||
.map_err(database_error)?;
|
||||
let mut latest_operational_state = None;
|
||||
for event in events {
|
||||
index_event(&transaction, event)?;
|
||||
for reference in &event.record_references {
|
||||
if reference.record_type == "application-state-snapshot" {
|
||||
let path = root.join(reference.path.replace('/', std::path::MAIN_SEPARATOR_STR));
|
||||
let record: CplRecord =
|
||||
serde_json::from_slice(&fs::read(&path).map_err(|error| {
|
||||
CplError::io(
|
||||
"Could not rebuild operational state from its canonical record",
|
||||
error,
|
||||
)
|
||||
})?)
|
||||
.map_err(|error| {
|
||||
CplError::new("CPL_RECORD_INVALID", error.to_string(), false)
|
||||
})?;
|
||||
latest_operational_state = Some((record.payload, event.timestamp.clone()));
|
||||
}
|
||||
}
|
||||
let first_record = event.record_references.first().ok_or_else(|| {
|
||||
CplError::new(
|
||||
"CPL_RECORD_REQUIRED",
|
||||
"Committed event has no authoritative record reference.",
|
||||
false,
|
||||
)
|
||||
})?;
|
||||
let first_path = root.join(
|
||||
first_record
|
||||
.path
|
||||
.replace('/', std::path::MAIN_SEPARATOR_STR),
|
||||
);
|
||||
let first_record: CplRecord =
|
||||
serde_json::from_slice(&fs::read(&first_path).map_err(|error| {
|
||||
CplError::io("Could not reconstruct the committed intent identity", error)
|
||||
})?)
|
||||
.map_err(|error| CplError::new("CPL_RECORD_INVALID", error.to_string(), false))?;
|
||||
let result = WriteResult {
|
||||
idempotent_replay: false,
|
||||
intent_id: first_record.intent_id,
|
||||
event: event.clone(),
|
||||
records: event.record_references.clone(),
|
||||
};
|
||||
let result_json = serde_json::to_string(&result)
|
||||
.map_err(|error| CplError::new("CPL_SERIALIZATION_FAILED", error.to_string(), false))?;
|
||||
transaction.execute(
|
||||
"INSERT INTO cpl_action_receipts(client_action_id,command_sha256,event_id,result_json,committed_at) VALUES(?1,?2,?3,?4,?5)",
|
||||
params![event.client_action_id, event.command_sha256, event.event_id, result_json, event.timestamp],
|
||||
).map_err(database_error)?;
|
||||
}
|
||||
if let Some((state, updated_at)) = latest_operational_state {
|
||||
let state = serde_json::to_string(&state)
|
||||
.map_err(|error| CplError::new("CPL_SERIALIZATION_FAILED", error.to_string(), false))?;
|
||||
transaction.execute(
|
||||
"INSERT INTO project_state(id,json,updated_at) VALUES(1,?1,?2) ON CONFLICT(id) DO UPDATE SET json=excluded.json,updated_at=excluded.updated_at",
|
||||
params![state, updated_at],
|
||||
).map_err(database_error)?;
|
||||
}
|
||||
transaction.commit().map_err(database_error)?;
|
||||
if let Some(project_id) = phase1_project_id {
|
||||
super::phase1::rebuild_projection_cache(root, &project_id, events)?;
|
||||
}
|
||||
if let Some(project_id) = composition_project_id {
|
||||
super::composition::rebuild_projection_cache(root, &project_id, events)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn set_intent_phase(
|
||||
database: &Connection,
|
||||
intent_id: &str,
|
||||
phase: &str,
|
||||
) -> CplResult<()> {
|
||||
database
|
||||
.execute(
|
||||
"UPDATE write_intents SET phase=?2,updated_at=?3 WHERE intent_id=?1",
|
||||
params![intent_id, phase, timestamp_millis()],
|
||||
)
|
||||
.map_err(database_error)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn database_error(error: rusqlite::Error) -> CplError {
|
||||
CplError::new("CPL_DATABASE_ERROR", error.to_string(), true)
|
||||
}
|
||||
|
||||
pub(crate) struct ProjectWriterLock {
|
||||
file: File,
|
||||
}
|
||||
|
||||
impl ProjectWriterLock {
|
||||
pub(crate) fn acquire(root: &Path) -> CplResult<Self> {
|
||||
let app = root.join(".app/locks");
|
||||
fs::create_dir_all(&app)
|
||||
.map_err(|error| CplError::io("Could not create the CPL lock directory", error))?;
|
||||
let file = OpenOptions::new()
|
||||
.create(true)
|
||||
.read(true)
|
||||
.write(true)
|
||||
.open(app.join("cpl.writer.lock"))
|
||||
.map_err(|error| CplError::io("Could not open the CPL writer lock", error))?;
|
||||
lock_file(&file)?;
|
||||
Ok(Self { file })
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for ProjectWriterLock {
|
||||
fn drop(&mut self) {
|
||||
let _ = unlock_file(&self.file);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn lock_file(file: &File) -> CplResult<()> {
|
||||
use std::os::windows::io::AsRawHandle;
|
||||
#[repr(C)]
|
||||
struct Overlapped {
|
||||
internal: usize,
|
||||
internal_high: usize,
|
||||
offset: u32,
|
||||
offset_high: u32,
|
||||
event: *mut std::ffi::c_void,
|
||||
}
|
||||
#[link(name = "Kernel32")]
|
||||
extern "system" {
|
||||
fn LockFileEx(
|
||||
file: *mut std::ffi::c_void,
|
||||
flags: u32,
|
||||
reserved: u32,
|
||||
low: u32,
|
||||
high: u32,
|
||||
overlapped: *mut Overlapped,
|
||||
) -> i32;
|
||||
}
|
||||
let mut overlapped = Overlapped {
|
||||
internal: 0,
|
||||
internal_high: 0,
|
||||
offset: 0,
|
||||
offset_high: 0,
|
||||
event: std::ptr::null_mut(),
|
||||
};
|
||||
let result = unsafe {
|
||||
LockFileEx(
|
||||
file.as_raw_handle(),
|
||||
0x2,
|
||||
0,
|
||||
u32::MAX,
|
||||
u32::MAX,
|
||||
&mut overlapped,
|
||||
)
|
||||
};
|
||||
if result == 0 {
|
||||
return Err(CplError::io(
|
||||
"Could not acquire the exclusive OS CPL writer lock",
|
||||
std::io::Error::last_os_error(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn unlock_file(file: &File) -> CplResult<()> {
|
||||
use std::os::windows::io::AsRawHandle;
|
||||
#[repr(C)]
|
||||
struct Overlapped {
|
||||
internal: usize,
|
||||
internal_high: usize,
|
||||
offset: u32,
|
||||
offset_high: u32,
|
||||
event: *mut std::ffi::c_void,
|
||||
}
|
||||
#[link(name = "Kernel32")]
|
||||
extern "system" {
|
||||
fn UnlockFileEx(
|
||||
file: *mut std::ffi::c_void,
|
||||
reserved: u32,
|
||||
low: u32,
|
||||
high: u32,
|
||||
overlapped: *mut Overlapped,
|
||||
) -> i32;
|
||||
}
|
||||
let mut overlapped = Overlapped {
|
||||
internal: 0,
|
||||
internal_high: 0,
|
||||
offset: 0,
|
||||
offset_high: 0,
|
||||
event: std::ptr::null_mut(),
|
||||
};
|
||||
let result =
|
||||
unsafe { UnlockFileEx(file.as_raw_handle(), 0, u32::MAX, u32::MAX, &mut overlapped) };
|
||||
if result == 0 {
|
||||
return Err(CplError::io(
|
||||
"Could not release the CPL writer lock",
|
||||
std::io::Error::last_os_error(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn lock_file(file: &File) -> CplResult<()> {
|
||||
use std::os::fd::AsRawFd;
|
||||
extern "C" {
|
||||
fn flock(fd: i32, operation: i32) -> i32;
|
||||
}
|
||||
if unsafe { flock(file.as_raw_fd(), 2) } != 0 {
|
||||
return Err(CplError::io(
|
||||
"Could not acquire the exclusive OS CPL writer lock",
|
||||
std::io::Error::last_os_error(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn unlock_file(file: &File) -> CplResult<()> {
|
||||
use std::os::fd::AsRawFd;
|
||||
extern "C" {
|
||||
fn flock(fd: i32, operation: i32) -> i32;
|
||||
}
|
||||
if unsafe { flock(file.as_raw_fd(), 8) } != 0 {
|
||||
return Err(CplError::io(
|
||||
"Could not release the CPL writer lock",
|
||||
std::io::Error::last_os_error(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Thinkloom",
|
||||
"version": "0.5.0",
|
||||
"version": "0.5.11",
|
||||
"identifier": "com.thinkloom.desktop",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
|
||||
Reference in New Issue
Block a user