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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 0 additions & 26 deletions crates/graphforge-api/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -302,7 +302,6 @@ pub use graphforge_core::storage_receipt::{
};
/// Finite portable export budgets.
pub type PortableV2ExportLimits = PortableV2Limits;
pub use graphforge_storage::{ProjectVerifyReport, VerifyCategoryCounts};
pub use graphforge_storage::{
SemanticMigrationOperation, WorkspaceOntologyComposition, WorkspacePortableOntologyStaging,
};
Expand Down Expand Up @@ -761,20 +760,6 @@ impl GraphForge {
graphforge_storage::storage_attribution_receipt_from_snapshot(&self.storage_attribution()?)
}

/// Explicitly re-authenticate the retained store on demand.
///
/// This is the read-only, administrative check #1384 moved "is the
/// store still intact" behind, so it never runs as part of ingest or
/// construction. It re-reads and re-hashes the selected generation's
/// manifest, every declared participant, and every retained
/// content-addressed graph payload object, reporting what was checked,
/// what passed, and what failed. It never mutates, repairs, or
/// publishes anything.
pub fn verify_project_store(&self) -> Result<graphforge_storage::ProjectVerifyReport, GfError> {
let generation = self.generation_for_read()?;
graphforge_storage::verify_project_store(&generation)
}

/// Create a new in-memory (`None`) or Parquet-backed (`Some(path)`) instance.
///
/// For a persistent instance, the directory may be absent when its parent
Expand Down Expand Up @@ -2673,17 +2658,6 @@ mod tests {
assert!(!encoded.contains("sha256"));
assert!(!encoded.contains(project.path().to_string_lossy().as_ref()));
}

#[test]
fn public_verify_project_store_reports_a_clean_fresh_project() {
let project = tempfile::tempdir().unwrap();
let graph = GraphForge::new(project.path().to_str()).unwrap();
let report = graph.verify_project_store().unwrap();
assert!(report.ok);
assert_eq!(report.catalog_and_participants.objects_failed, 0);
assert_eq!(report.content_addressed_objects.objects_checked, 0);
assert_eq!(report.content_addressed_objects.objects_failed, 0);
}
}

#[cfg(feature = "portable")]
Expand Down
2 changes: 1 addition & 1 deletion crates/graphforge-api/src/resumable_construction.rs
Original file line number Diff line number Diff line change
Expand Up @@ -711,7 +711,7 @@ mod tests {
bytes[0] ^= 1;
std::fs::write(&object, bytes).unwrap();
let error = GraphForge::new(Some(path)).unwrap_err();
assert!(error.to_string().contains("digest"), "{error}");
assert!(error.to_string().contains("XXH64 checksum"), "{error}");
}

#[test]
Expand Down
82 changes: 34 additions & 48 deletions crates/graphforge-api/src/workspace_hydration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

use super::{
CompositionBindingContext, CompositionBindingLimits, GfError, GraphForge, OntologyDoc,
OntologyHandle, OntologyMode, ResolvedProjectGeneration, RuntimeCatalog, graph_snapshot,
OntologyHandle, OntologyMode, ResolvedProjectGeneration, RuntimeCatalog,
};
use graphforge_ontology::OntologyCompiler;
use sha2::{Digest, Sha256};
Expand Down Expand Up @@ -281,8 +281,8 @@ pub(super) fn property_and_graph_inventory_for_hydrated_generation(
.is_empty(),
None => false,
};
// Snapshot-only generations have no graph-files inventory; hydration has
// authenticated their snapshot payload into this private workspace.
// Empty initial generations have no graph-files inventory; replayed
// generations require a fresh inventory of their effective workspace.
let (admitted, effective) = match inventory {
Some(inventory) if !has_deltas => (
graphforge_storage::AuthenticatedPropertyInventory::from_resolved_generation(
Expand Down Expand Up @@ -382,19 +382,14 @@ pub(super) fn hydrate_graph_workspace(
graphforge_storage::GRAPH_CAPABILITY_ID,
graphforge_storage::GRAPH_FILES_FAMILY,
)?;
let snapshot = generation.participant_snapshot("graph", "snapshot")?;
if files.is_some() && snapshot.is_some() {
return Err(GfError::Validation(
"graph generation cannot declare both snapshot and files participants".into(),
));
}
reject_legacy_graph_snapshot(generation)?;

if let Some(files) = files {
validate_graph_files_snapshot(&files)?;
if matches!(
files.record_version,
graphforge_storage::GRAPH_FILES_V2_RECORD_VERSION
| graphforge_storage::GRAPH_FILES_MAPPED_ROOT_RECORD_VERSION
graphforge_storage::GRAPH_FILES_CHECKSUM_ROOT_RECORD_VERSION
| graphforge_storage::GRAPH_FILES_MAPPED_CHECKSUM_ROOT_RECORD_VERSION
) {
let inventory = generation
.graph_files_inventory()?
Expand Down Expand Up @@ -441,37 +436,35 @@ pub(super) fn hydrate_graph_workspace(
}

let workspace = create_graph_workspace(generation)?;
let mut evidence = graphforge_storage::GraphFilesOpenEvidence {
let evidence = graphforge_storage::GraphFilesOpenEvidence {
strategy: graphforge_storage::GraphFilesOpenStrategy::Empty,
..graphforge_storage::GraphFilesOpenEvidence::default()
};
if let Some(snapshot) = snapshot {
if snapshot.capability_version != 1
|| snapshot.record_version != 1
|| snapshot.encoding != "arrow"
{
return Err(GfError::Validation(
"unsupported graph snapshot participant contract".into(),
));
}
graph_snapshot::hydrate(&snapshot.bytes, workspace.path())?;
evidence.strategy = graphforge_storage::GraphFilesOpenStrategy::LegacySnapshotHydrate;
evidence.bytes_copied = u64::try_from(snapshot.bytes.len()).unwrap_or(u64::MAX);
evidence.files_copied = 1;
}
Ok((workspace.path().to_path_buf(), workspace, evidence))
}

fn reject_legacy_graph_snapshot(generation: &ResolvedProjectGeneration) -> Result<(), GfError> {
if generation
.participant_snapshot("graph", "snapshot")?
.is_some()
{
return Err(GfError::Validation(
"unsupported legacy graph snapshot format; recreate the pre-v1 project".into(),
));
}
Ok(())
}

fn validate_graph_files_snapshot(
files: &graphforge_storage::ProjectParticipantSnapshot,
) -> Result<(), GfError> {
if files.capability_version != graphforge_storage::GRAPH_CAPABILITY_VERSION
|| !matches!(
files.record_version,
graphforge_storage::GRAPH_FILES_RECORD_VERSION
| graphforge_storage::GRAPH_FILES_V2_RECORD_VERSION
| graphforge_storage::GRAPH_FILES_MAPPED_RECORD_VERSION
| graphforge_storage::GRAPH_FILES_MAPPED_ROOT_RECORD_VERSION
graphforge_storage::GRAPH_FILES_CHECKSUM_RECORD_VERSION
| graphforge_storage::GRAPH_FILES_MAPPED_CHECKSUM_RECORD_VERSION
| graphforge_storage::GRAPH_FILES_CHECKSUM_ROOT_RECORD_VERSION
| graphforge_storage::GRAPH_FILES_MAPPED_CHECKSUM_ROOT_RECORD_VERSION
)
|| files.encoding != "json"
{
Expand Down Expand Up @@ -580,6 +573,14 @@ pub(crate) fn rematerialize_graph_workspace(
generation: &ResolvedProjectGeneration,
target: &std::path::Path,
) -> Result<(), GfError> {
reject_legacy_graph_snapshot(generation)?;
let files = generation.participant_snapshot(
graphforge_storage::GRAPH_CAPABILITY_ID,
graphforge_storage::GRAPH_FILES_FAMILY,
)?;
if let Some(files) = &files {
validate_graph_files_snapshot(files)?;
}
if target.exists() {
for entry in std::fs::read_dir(target).map_err(|error| {
GfError::Storage(format!(
Expand All @@ -606,15 +607,11 @@ pub(crate) fn rematerialize_graph_workspace(
}
}
}
if let Some(files) = generation.participant_snapshot(
graphforge_storage::GRAPH_CAPABILITY_ID,
graphforge_storage::GRAPH_FILES_FAMILY,
)? {
validate_graph_files_snapshot(&files)?;
if let Some(files) = files {
if matches!(
files.record_version,
graphforge_storage::GRAPH_FILES_V2_RECORD_VERSION
| graphforge_storage::GRAPH_FILES_MAPPED_ROOT_RECORD_VERSION
graphforge_storage::GRAPH_FILES_CHECKSUM_ROOT_RECORD_VERSION
| graphforge_storage::GRAPH_FILES_MAPPED_CHECKSUM_ROOT_RECORD_VERSION
) {
let inventory = generation
.graph_files_inventory()?
Expand All @@ -630,17 +627,6 @@ pub(crate) fn rematerialize_graph_workspace(
}
return Ok(());
}
if let Some(snapshot) = generation.participant_snapshot("graph", "snapshot")? {
if snapshot.capability_version != 1
|| snapshot.record_version != 1
|| snapshot.encoding != "arrow"
{
return Err(GfError::Validation(
"unsupported graph snapshot participant contract".into(),
));
}
graph_snapshot::hydrate(&snapshot.bytes, target)?;
}
Ok(())
}

Expand Down
55 changes: 52 additions & 3 deletions crates/graphforge-api/src/workspace_hydration/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@ fn publish_compact_graph_workspace(project: &Path, workspace: &Path) {
let lease = graphforge_storage::begin_graph_object_publication(project).unwrap();
let mut state = graphforge_storage::GraphManifestState::empty();
let (inventory, _) = graphforge_storage::capture_graph_files(workspace).unwrap();
let mapped = inventory.format_version == graphforge_storage::GRAPH_FILES_MAPPED_RECORD_VERSION;
let mapped =
inventory.format_version == graphforge_storage::GRAPH_FILES_MAPPED_CHECKSUM_RECORD_VERSION;
let paths = inventory
.files
.into_iter()
Expand All @@ -23,7 +24,7 @@ fn publish_compact_graph_workspace(project: &Path, workspace: &Path) {
graphforge_storage::append_graph_files_v2(&lease, workspace, &mut state, &paths, &[])
.unwrap();
if mapped {
root.format_version = graphforge_storage::GRAPH_FILES_MAPPED_ROOT_RECORD_VERSION;
root.format_version = graphforge_storage::GRAPH_FILES_MAPPED_CHECKSUM_ROOT_RECORD_VERSION;
}
let participant = ProjectParticipant {
capability_id: graphforge_storage::GRAPH_CAPABILITY_ID.into(),
Expand All @@ -34,7 +35,7 @@ fn publish_compact_graph_workspace(project: &Path, workspace: &Path) {
schema_fingerprint: fingerprint(
CanonicalDomain::Schema,
CANONICAL_CONTRACT_VERSION,
if mapped { b"graphforge-graph-files-root/4|root_node_sha256|logical_file_count|logical_byte_length|semantic-routes/1" } else { b"graphforge-graph-files-root/2|root_node_sha256|logical_file_count|logical_byte_length" },
if mapped { b"graphforge-graph-files-root/8|root_node_sha256|logical_file_count|logical_byte_length|xxh64/1|semantic-routes/1" } else { b"graphforge-graph-files-root/6|root_node_sha256|logical_file_count|logical_byte_length|xxh64/1" },
)
.unwrap(),
row_count: root.logical_file_count,
Expand Down Expand Up @@ -145,6 +146,54 @@ fn assert_same_inode_graph_object_corruption_is_refused(
std::fs::set_permissions(&object, original_metadata.permissions()).unwrap();
}

#[test]
fn persisted_graph_snapshot_is_rejected_before_publication() {
use graphforge_storage::{
ProjectCapability, ProjectGenerationRequest, stage_project_generation,
};

let project = tempfile::tempdir().unwrap();
let parent = graphforge_storage::open_or_initialize_project(project.path()).unwrap();
let workspace = tempfile::tempdir().unwrap();
std::fs::write(
workspace.path().join("graph-content.bin"),
b"legacy payload",
)
.unwrap();
let snapshot = crate::graph_snapshot::capture(workspace.path()).unwrap();
let mut participants = graphforge_storage::empty_workspace_participants().unwrap();
participants.insert(0, snapshot);
let request = ProjectGenerationRequest {
transaction_uuid: uuid::Uuid::new_v4(),
generation_uuid: uuid::Uuid::new_v4(),
capabilities: vec![
ProjectCapability {
capability_id: "graph".into(),
capability_version: 1,
},
ProjectCapability {
capability_id: "workspace".into(),
capability_version: 1,
},
],
participants,
};
let error = match stage_project_generation(project.path(), &request) {
Ok(_) => panic!("legacy persisted graph snapshots must not stage"),
Err(error) => error,
};
let message = error.to_string();
assert!(message.contains("unsupported"), "{message}");
assert!(message.contains("snapshot"), "{message}");
assert_eq!(
graphforge_storage::resolve_project_generation(project.path())
.unwrap()
.generation_uuid(),
parent.generation_uuid(),
"legacy refusal must retain the prior committed generation"
);
}

#[test]
fn compact_graph_root_reopens_through_ordinary_api_and_rematerializes() {
let project = tempfile::tempdir().unwrap();
Expand Down
Loading
Loading