diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9db5918..07f5c90 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -189,6 +189,11 @@ jobs: run: cargo test --features test-utils - name: cargo test (no default features) run: cargo test --no-default-features + # The manifest crate embeds and resolves bundle paths, so its path + # handling is OS-sensitive; the dedicated ubuntu lane covers its + # feature matrix while this step covers the other OSes. + - name: cargo test -p ordvec-manifest + run: cargo test -p ordvec-manifest - name: cargo build --release --features bench-utils --example bench_rank run: cargo build --release --features bench-utils --example bench_rank diff --git a/CHANGELOG.md b/CHANGELOG.md index 1888c61..f3f7397 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,56 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 _No unreleased changes._ +## 0.7.0 - 2026-07-13 + +### Added + +- **`ordvec-manifest`: typed verification classification.** Every + verification issue code is now a named `pub const` in a `codes` module + (zero bare literals at emit sites), with a `#[non_exhaustive]` + `VerificationCode` enum and `ReportIssue::classification()` so + downstream integrity handling can branch on typed values instead of + comparing strings. A missing primary artifact or row-identity file is + reported with a dedicated `artifact_missing` / `row_identity_missing` + code (classified `ArtifactMissing` / `RowIdentityMissing`) only when the + file is genuinely absent; permission or I/O failures keep the generic + `*_path_unavailable` code and classify as `Unknown`, so a consumer never + mistakes an unreadable file for a missing one. `ReportIssue` gains optional structured mismatch + detail — artifact name plus expected/actual SHA-256 and sizes — at the + artifact, auxiliary, and row-identity mismatch sites, for lossless + downstream error construction. Reports without the new detail + serialize byte-identically to before (regression-tested). +- **`ordvec-manifest`: shared hash helpers.** New `sha256_bytes` and + bounded `sha256_reader` share `sha256_file_bounded`'s bounded/EINTR + read core; the sqlite registry's private duplicate hasher is deduped + onto the public helper. + +### Changed + +- **BREAKING (`ordvec-manifest`): deterministic manifest schema v2.** The + manifest schema version is now `ordvec.index_manifest.v2`. `manifest_id` + and `created_at` are removed from `IndexManifest`, creation omits the + optional `build` field (serialized as absent, not `null`), and auxiliary + artifact entries are sorted by + `(name, path)`, so identical bundle content serializes to byte-identical + manifests and `sha256(manifest.json)` is the bundle's content address. + Existing v1 manifests no longer parse; loading one fails with an error + naming both schema versions (zero back-compat, pre-release). Embedded + paths must now be canonical — bundle-relative, forward slashes, no `.`, + `..`, or empty segments — enforced both at creation (non-embeddable + inputs fail `create` instead of minting a manifest that fails its own + verification) and at verification (`*_path_not_canonical` codes, now + also covering calibration and encoder-distortion profile refs). Absolute + paths and escaping `..` paths remain available behind the existing + `allow_absolute_paths` / `allow_path_escape` opt-ins. The + `write_manifest_file` serialization form is documented as the single + canonical byte form: hashing and signing operate on stored bytes, and any + serializer change is a schema-version event. The sqlite report registry + drops its `manifest_id` column: the cached `verification_reports` table is + migrated in place on open (rows preserved, under one atomic transaction), + while the rebuildable `active_manifest` pointer is reset and must be + re-activated. + ## 0.6.0 - 2026-07-04 ### Performance diff --git a/Cargo.lock b/Cargo.lock index c505790..fda6c77 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -844,7 +844,7 @@ checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" [[package]] name = "ordvec" -version = "0.6.0" +version = "0.7.0" dependencies = [ "rand 0.10.2", "rand_chacha 0.10.0", @@ -854,14 +854,14 @@ dependencies = [ [[package]] name = "ordvec-ffi" -version = "0.6.0" +version = "0.7.0" dependencies = [ "ordvec", ] [[package]] name = "ordvec-manifest" -version = "0.6.0" +version = "0.7.0" dependencies = [ "chrono", "clap", @@ -877,7 +877,7 @@ dependencies = [ [[package]] name = "ordvec-manifest-python" -version = "0.6.0" +version = "0.7.0" dependencies = [ "ordvec-manifest", "pyo3", @@ -887,7 +887,7 @@ dependencies = [ [[package]] name = "ordvec-python" -version = "0.6.0" +version = "0.7.0" dependencies = [ "numpy", "ordvec", diff --git a/Cargo.toml b/Cargo.toml index 4c3ee3f..5b9395c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ordvec" -version = "0.6.0" +version = "0.7.0" edition = "2021" rust-version = "1.89" # AVX-512 intrinsics stabilized in 1.89.0; also clears the 1.87 floor from u64::is_multiple_of description = "Training-free ordinal & sign quantization for vector retrieval" diff --git a/README.md b/README.md index 0442cd9..0ee8b28 100644 --- a/README.md +++ b/README.md @@ -212,7 +212,7 @@ Details in [`docs/RANK_MODES.md`](docs/RANK_MODES.md). ```toml [dependencies] -ordvec = "0.6" +ordvec = "0.7" # Or, to track unreleased `main`, use a git dependency instead: # ordvec = { git = "https://github.com/Project-Navi/ordvec" } diff --git a/THREAT_MODEL.md b/THREAT_MODEL.md index a096f29..9f02937 100644 --- a/THREAT_MODEL.md +++ b/THREAT_MODEL.md @@ -1,6 +1,6 @@ # Threat Model — `ordvec` -> **Status:** v0.6.0 (pre-1.0), 2026-06-15. This is the maintained threat model +> **Status:** v0.7.0 (pre-1.0), 2026-06-15. This is the maintained threat model > for the `ordvec` Rust crate, C ABI, Go wrapper, PyO3/maturin Python bindings, > and the `ordvec-manifest` sidecar verifier. It is reviewed when the > attack surface changes (new persistence formats, new `unsafe` kernels, new diff --git a/fuzz/Cargo.lock b/fuzz/Cargo.lock index ecb390e..39b43de 100644 --- a/fuzz/Cargo.lock +++ b/fuzz/Cargo.lock @@ -231,7 +231,7 @@ checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" [[package]] name = "ordvec" -version = "0.6.0" +version = "0.7.0" dependencies = [ "rayon", ] diff --git a/ordvec-ffi/Cargo.toml b/ordvec-ffi/Cargo.toml index 177a92b..756c076 100644 --- a/ordvec-ffi/Cargo.toml +++ b/ordvec-ffi/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ordvec-ffi" -version = "0.6.0" +version = "0.7.0" edition = "2021" rust-version = "1.89" publish = false diff --git a/ordvec-manifest-python/Cargo.toml b/ordvec-manifest-python/Cargo.toml index 490ef70..5eddd45 100644 --- a/ordvec-manifest-python/Cargo.toml +++ b/ordvec-manifest-python/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ordvec-manifest-python" -version = "0.6.0" +version = "0.7.0" edition = "2021" rust-version = "1.89" description = "Python bindings for ordvec-manifest index provenance verification" diff --git a/ordvec-manifest-python/pyproject.toml b/ordvec-manifest-python/pyproject.toml index 1e2f099..30e0dd7 100644 --- a/ordvec-manifest-python/pyproject.toml +++ b/ordvec-manifest-python/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "maturin" [project] name = "ordvec-manifest" -version = "0.6.0" +version = "0.7.0" description = "Python bindings for ordvec index manifest verification" readme = "README.md" requires-python = ">=3.10" diff --git a/ordvec-manifest-python/python/ordvec_manifest/__init__.py b/ordvec-manifest-python/python/ordvec_manifest/__init__.py index 4d9790c..1cfae05 100644 --- a/ordvec-manifest-python/python/ordvec_manifest/__init__.py +++ b/ordvec-manifest-python/python/ordvec_manifest/__init__.py @@ -50,4 +50,4 @@ "create_manifest", ] -__version__ = "0.6.0" +__version__ = "0.7.0" diff --git a/ordvec-manifest-python/tests/test_manifest_bindings.py b/ordvec-manifest-python/tests/test_manifest_bindings.py index 0ec0ce7..3955466 100644 --- a/ordvec-manifest-python/tests/test_manifest_bindings.py +++ b/ordvec-manifest-python/tests/test_manifest_bindings.py @@ -26,8 +26,6 @@ def write_unloadable_manifest(tmp_path): digest = hashlib.sha256(artifact.read_bytes()).hexdigest() manifest = { "schema_version": ordvec_manifest.SCHEMA_VERSION, - "manifest_id": "urn:uuid:7c66ad6e-bdde-49a8-b420-f1136d04f5bd", - "created_at": "2026-06-09T00:00:00Z", "artifact": { "path": artifact.name, "sha256": digest, diff --git a/ordvec-manifest/Cargo.toml b/ordvec-manifest/Cargo.toml index 88c6de0..fdd69a6 100644 --- a/ordvec-manifest/Cargo.toml +++ b/ordvec-manifest/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ordvec-manifest" -version = "0.6.0" +version = "0.7.0" edition = "2021" rust-version = "1.89" license = "MIT OR Apache-2.0" @@ -29,7 +29,7 @@ required-features = ["cli"] chrono = { version = "0.4.44", default-features = false, features = ["clock", "std"] } clap = { version = "4.6.1", features = ["derive"], optional = true } hex = "0.4.3" -ordvec = { version = "0.6.0", path = ".." } +ordvec = { version = "0.7.0", path = ".." } rusqlite = { version = "0.40.0", optional = true } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" diff --git a/ordvec-manifest/README.md b/ordvec-manifest/README.md index 6a58c54..87da107 100644 --- a/ordvec-manifest/README.md +++ b/ordvec-manifest/README.md @@ -30,7 +30,12 @@ ordvec-manifest verify --manifest path/to/index.manifest.json From a workspace checkout, prefix the same commands with `cargo run -p ordvec-manifest --`. -The schema version is `ordvec.index_manifest.v1`. Relative paths resolve from +The schema version is `ordvec.index_manifest.v2`. The v2 schema is +deterministic: identical bundle content serializes to identical manifest +bytes, so `sha256(manifest.json)` is the bundle's content address. Manifests +carry no `manifest_id` or `created_at`, auxiliary artifact entries are sorted +by `(name, path)`, and embedded paths must be canonical (bundle-relative, +forward slashes, no `.`, `..`, or empty segments). Relative paths resolve from the manifest file's directory, absolute paths are rejected by default, and relative paths may not escape the manifest directory unless explicitly allowed. `create` follows the same policy: by default it emits only paths that should @@ -229,11 +234,11 @@ A consuming database can keep the ordvec row identity as `RowIdentity::RowIdIdentity { row_count }` and declare its ID sidecar file as a required auxiliary artifact (e.g. `app.ids`). That makes the vector row count an ordvec invariant while leaving the caller's `u64` document IDs as caller-owned -sidecar bytes. Do not encode the ID sidecar as `RowIdentity::Jsonl`: v1 JSONL +sidecar bytes. Do not encode the ID sidecar as `RowIdentity::Jsonl`: JSONL row identity is UUID-oriented (`id_kind = "uuid"`), and generic row-map ID formats are intentionally deferred to [#145](https://github.com/Project-Navi/ordvec/issues/145). The reserved -`row_identity.db` metadata block is rejected in v1 because it is not byte-bound +`row_identity.db` metadata block is rejected because it is not byte-bound or path-checked. Stable row-identity boundary codes: @@ -273,6 +278,7 @@ Stable sidecar states: | `failed` | Code-specific | Path policy, hashing, size, digest, or limit validation failed. | Common `failed` reason codes include `auxiliary_artifact_path_empty`, +`auxiliary_artifact_path_not_canonical`, `auxiliary_artifact_base_dir_unavailable`, `auxiliary_artifact_path_unavailable`, `auxiliary_artifact_path_escape_rejected`, @@ -287,7 +293,6 @@ and records checks that were intentionally not run, such as { "ok": true, "checked_at": "2026-06-03T17:20:00Z", - "manifest_id": "urn:uuid:11111111-1111-4111-8111-111111111111", "artifact": { "manifest_path": "index.ovrq", "observed_path": "index.ovrq", @@ -346,7 +351,6 @@ read and absent when the file is missing: { "ok": false, "checked_at": "2026-06-03T17:21:00Z", - "manifest_id": "urn:uuid:11111111-1111-4111-8111-111111111111", "artifact": { "manifest_path": "index.ovrq", "observed_path": "index.ovrq", diff --git a/ordvec-manifest/src/lib.rs b/ordvec-manifest/src/lib.rs index 5a5ec4d..180b7c0 100644 --- a/ordvec-manifest/src/lib.rs +++ b/ordvec-manifest/src/lib.rs @@ -28,7 +28,7 @@ use std::io::{self, BufRead, BufReader, Read}; use std::path::{Component, Path, PathBuf}; use uuid::Uuid; -pub const SCHEMA_VERSION: &str = "ordvec.index_manifest.v1"; +pub const SCHEMA_VERSION: &str = "ordvec.index_manifest.v2"; pub const CALIBRATION_SCHEMA_VERSION: &str = "ordvec.calibration.v1"; pub const ENCODER_DISTORTION_SCHEMA_VERSION: &str = "ordvec.encoder_distortion.v1"; pub const DEFAULT_MAX_MANIFEST_BYTES: u64 = 1024 * 1024; @@ -119,10 +119,24 @@ pub fn load_manifest_file_with_options( let manifest_bytes = read_bounded_file( path, options.limits.max_manifest_bytes, - "manifest_file_too_large", + codes::MANIFEST_FILE_TOO_LARGE, "manifest file", )?; - let manifest: IndexManifest = serde_json::from_slice(&manifest_bytes)?; + let manifest: IndexManifest = serde_json::from_slice(&manifest_bytes) + .map_err(|err| manifest_parse_error(&manifest_bytes, err))?; + // A genuine v1 manifest fails the parse above (its required `manifest_id` + // / `created_at` fields trip `deny_unknown_fields`), but a document that is + // v2-shaped yet labels itself an unsupported `schema_version` would + // otherwise load and only be caught at verify. Enforce the version at load + // so loading any non-current schema fails here, as documented. + if manifest.schema_version != SCHEMA_VERSION { + return Err(ManifestError::invalid(format!( + "manifest declares schema_version {:?} but this build supports \ + {SCHEMA_VERSION:?}; the manifest was written by an older or newer \ + manifest schema", + manifest.schema_version + ))); + } let base_dir = path .parent() .filter(|p| !p.as_os_str().is_empty()) @@ -135,6 +149,30 @@ pub fn load_manifest_file_with_options( }) } +/// Wraps a manifest parse failure with schema-version context. Old or new +/// schema generations fail the strict `deny_unknown_fields` parse before the +/// `schema_version` field is ever validated, so a targeted probe of that one +/// field is needed to say *why* the document does not parse. +fn manifest_parse_error(manifest_bytes: &[u8], err: serde_json::Error) -> ManifestError { + #[derive(Deserialize)] + struct SchemaVersionProbe { + schema_version: Option, + } + if let Ok(SchemaVersionProbe { + schema_version: Some(version), + }) = serde_json::from_slice::(manifest_bytes) + { + if version != SCHEMA_VERSION { + return ManifestError::invalid(format!( + "manifest declares schema_version {version:?} but this build supports \ + {SCHEMA_VERSION:?}; the manifest was written by an older or newer \ + manifest schema: {err}" + )); + } + } + ManifestError::Json(err) +} + fn read_bounded_file( path: &Path, max_bytes: u64, @@ -237,8 +275,8 @@ fn verify_manifest_with_path_capture( options: VerifyOptions, ) -> (VerificationReport, VerificationPathCapture) { let mut paths = VerificationPathCapture::default(); - let mut report = VerificationReport::new(Some(document.manifest.manifest_id.clone())); - validate_manifest_shape(&document.manifest, &options.limits, &mut report); + let mut report = VerificationReport::new(); + validate_manifest_shape(&document.manifest, &options, &mut report); let artifact_display_path = document.manifest.artifact.path.clone(); report.artifact.manifest_path = Some(artifact_display_path.clone()); @@ -253,7 +291,7 @@ fn verify_manifest_with_path_capture( &artifact_path, &document.base_dir, &options, - "artifact", + &ARTIFACT_PATH_ISSUES, &mut report.errors, ) { paths.artifact_path = Some(resolved.canonical_path.clone()); @@ -268,34 +306,46 @@ fn verify_manifest_with_path_capture( .artifact .file_size_bytes .min(options.limits.max_index_artifact_bytes), - "artifact_file_too_large", + codes::ARTIFACT_FILE_TOO_LARGE, "index artifact", ) { Ok(hash) => { report.artifact.sha256 = Some(hash.sha256.clone()); report.artifact.size_bytes = Some(hash.size_bytes); if !hex_digest_eq(&hash.sha256, &document.manifest.artifact.sha256) { - report.error( - "artifact_sha256_mismatch", - format!( - "artifact SHA-256 was {}, manifest declares {}", - hash.sha256, document.manifest.artifact.sha256 + report.errors.push( + ReportIssue::new( + codes::ARTIFACT_SHA256_MISMATCH, + format!( + "artifact SHA-256 was {}, manifest declares {}", + hash.sha256, document.manifest.artifact.sha256 + ), + ) + .with_sha256_detail( + document.manifest.artifact.sha256.as_str(), + hash.sha256.as_str(), ), ); } if hash.size_bytes != document.manifest.artifact.file_size_bytes { - report.error( - "artifact_file_size_mismatch", - format!( - "artifact size was {}, manifest declares {}", - hash.size_bytes, document.manifest.artifact.file_size_bytes + report.errors.push( + ReportIssue::new( + codes::ARTIFACT_FILE_SIZE_MISMATCH, + format!( + "artifact size was {}, manifest declares {}", + hash.size_bytes, document.manifest.artifact.file_size_bytes + ), + ) + .with_size_detail( + document.manifest.artifact.file_size_bytes, + hash.size_bytes, ), ); } } Err(ManifestError::LimitExceeded { code, message }) => report.error(code, message), Err(err) => report.error( - "artifact_hash_failed", + codes::ARTIFACT_HASH_FAILED, format!("failed to hash artifact: {err}"), ), } @@ -308,7 +358,7 @@ fn verify_manifest_with_path_capture( compare_artifact_metadata(&document.manifest.artifact, &metadata, &mut report); } Err(err) => report.error( - "artifact_probe_failed", + codes::ARTIFACT_PROBE_FAILED, format!("failed to probe artifact metadata: {err}"), ), } @@ -327,57 +377,64 @@ fn verify_manifest_with_path_capture( fn validate_manifest_shape( manifest: &IndexManifest, - limits: &ResourceLimits, + options: &VerifyOptions, report: &mut VerificationReport, ) { if manifest.schema_version != SCHEMA_VERSION { report.error( - "schema_version_unsupported", + codes::SCHEMA_VERSION_UNSUPPORTED, format!( "schema_version must be {SCHEMA_VERSION}, got {}", manifest.schema_version ), ); } - if manifest.manifest_id.trim().is_empty() { - report.error("manifest_id_empty", "manifest_id must be non-empty"); - } - if DateTime::parse_from_rfc3339(&manifest.created_at).is_err() { - report.error("created_at_invalid", "created_at must parse as RFC3339"); - } if manifest.embedding.model.trim().is_empty() { - report.error("embedding_model_empty", "embedding.model must be non-empty"); + report.error( + codes::EMBEDDING_MODEL_EMPTY, + "embedding.model must be non-empty", + ); } if manifest.embedding.dim == 0 { report.error( - "embedding_dim_zero", + codes::EMBEDDING_DIM_ZERO, "embedding.dim must be greater than zero", ); } if manifest.artifact.path.trim().is_empty() { - report.error("artifact_path_empty", "artifact.path must be non-empty"); + report.error( + codes::ARTIFACT_PATH_EMPTY, + "artifact.path must be non-empty", + ); + } else if !is_manifest_path_absolute(&manifest.artifact.path) + && !is_canonical_manifest_path(&manifest.artifact.path, options.allow_path_escape) + { + report.error( + codes::ARTIFACT_PATH_NOT_CANONICAL, + "artifact.path must use forward slashes with no `.`, `..`, or empty segments", + ); } if !is_sha256_hex(&manifest.artifact.sha256) { report.error( - "artifact_sha256_invalid", + codes::ARTIFACT_SHA256_INVALID, "artifact.sha256 must be a lowercase 64-character hex SHA-256 digest", ); } if manifest.artifact.file_size_bytes == 0 { report.error( - "artifact_file_size_zero", + codes::ARTIFACT_FILE_SIZE_ZERO, "artifact.file_size_bytes must be greater than zero", ); } if manifest.artifact.bytes_per_vec == 0 { report.error( - "artifact_bytes_per_vec_zero", + codes::ARTIFACT_BYTES_PER_VEC_ZERO, "artifact.bytes_per_vec must be greater than zero", ); } if manifest.artifact.dim != manifest.embedding.dim { report.error( - "artifact_embedding_dim_mismatch", + codes::ARTIFACT_EMBEDDING_DIM_MISMATCH, format!( "artifact.dim {} does not match embedding.dim {}", manifest.artifact.dim, manifest.embedding.dim @@ -386,7 +443,7 @@ fn validate_manifest_shape( } if !artifact_kind_matches_params(manifest.artifact.kind, &manifest.artifact.params) { report.error( - "artifact_params_kind_mismatch", + codes::ARTIFACT_PARAMS_KIND_MISMATCH, "artifact.params discriminator does not match artifact.kind", ); } @@ -394,7 +451,7 @@ fn validate_manifest_shape( let row_count = manifest.row_identity.row_count(); if manifest.artifact.vector_count != row_count { report.error( - "artifact_row_count_mismatch", + codes::ARTIFACT_ROW_COUNT_MISMATCH, format!( "artifact.vector_count {} does not match row_identity.row_count {}", manifest.artifact.vector_count, row_count @@ -411,64 +468,71 @@ fn validate_manifest_shape( { if path.trim().is_empty() { report.error( - "row_identity_path_empty", + codes::ROW_IDENTITY_PATH_EMPTY, "row_identity.path must be non-empty", ); + } else if !is_manifest_path_absolute(path) + && !is_canonical_manifest_path(path, options.allow_path_escape) + { + report.error( + codes::ROW_IDENTITY_PATH_NOT_CANONICAL, + "row_identity.path must use forward slashes with no `.`, `..`, or empty segments", + ); } if !is_sha256_hex(sha256) { report.error( - "row_identity_sha256_invalid", + codes::ROW_IDENTITY_SHA256_INVALID, "row_identity.sha256 must be a lowercase 64-character hex SHA-256 digest", ); } if id_kind != "uuid" { report.error( - "row_identity_id_kind_unsupported", + codes::ROW_IDENTITY_ID_KIND_UNSUPPORTED, "row_identity.id_kind must be uuid in v1", ); } if db.is_some() { report.error( - "row_identity_db_unsupported", + codes::ROW_IDENTITY_DB_UNSUPPORTED, "row_identity.db is reserved for a future schema and is not verified in v1", ); } } - validate_auxiliary_artifact_shape(manifest, limits, report); + validate_auxiliary_artifact_shape(manifest, options, report); validate_optional_non_empty( - "embedding_model_revision_empty", + codes::EMBEDDING_MODEL_REVISION_EMPTY, "embedding.model_revision must be non-empty when present", manifest.embedding.model_revision.as_deref(), report, ); validate_optional_non_empty( - "embedding_tokenizer_revision_empty", + codes::EMBEDDING_TOKENIZER_REVISION_EMPTY, "embedding.tokenizer_revision must be non-empty when present", manifest.embedding.tokenizer_revision.as_deref(), report, ); validate_optional_non_empty( - "embedding_pooling_empty", + codes::EMBEDDING_POOLING_EMPTY, "embedding.pooling must be non-empty when present", manifest.embedding.pooling.as_deref(), report, ); validate_optional_sha256( - "embedding_corpus_digest_invalid", + codes::EMBEDDING_CORPUS_DIGEST_INVALID, "embedding.corpus_digest must be a lowercase 64-character hex SHA-256 digest", manifest.embedding.corpus_digest.as_deref(), report, ); validate_optional_sha256( - "embedding_matrix_digest_invalid", + codes::EMBEDDING_MATRIX_DIGEST_INVALID, "embedding.embedding_matrix_digest must be a lowercase 64-character hex SHA-256 digest", manifest.embedding.embedding_matrix_digest.as_deref(), report, ); validate_optional_non_empty( - "embedding_normalization_empty", + codes::EMBEDDING_NORMALIZATION_EMPTY, "embedding.normalization must be non-empty when present", manifest.embedding.normalization.as_deref(), report, @@ -477,7 +541,7 @@ fn validate_manifest_shape( if let Some(build) = &manifest.build { if build.invocation_id.trim().is_empty() { report.error( - "build_invocation_id_empty", + codes::BUILD_INVOCATION_ID_EMPTY, "build.invocation_id must be non-empty", ); } @@ -487,30 +551,30 @@ fn validate_manifest_shape( .is_some_and(|builder_id| builder_id.trim().is_empty()) { report.error( - "build_builder_id_empty", + codes::BUILD_BUILDER_ID_EMPTY, "build.builder_id must be non-empty", ); } validate_optional_non_empty( - "build_source_repo_empty", + codes::BUILD_SOURCE_REPO_EMPTY, "build.source_repo must be non-empty when present", build.source_repo.as_deref(), report, ); validate_optional_non_empty( - "build_source_commit_empty", + codes::BUILD_SOURCE_COMMIT_EMPTY, "build.source_commit must be non-empty when present", build.source_commit.as_deref(), report, ); validate_optional_non_empty( - "build_ci_provider_empty", + codes::BUILD_CI_PROVIDER_EMPTY, "build.ci_provider must be non-empty when present", build.ci_provider.as_deref(), report, ); validate_optional_non_empty( - "build_ci_run_id_empty", + codes::BUILD_CI_RUN_ID_EMPTY, "build.ci_run_id must be non-empty when present", build.ci_run_id.as_deref(), report, @@ -520,7 +584,7 @@ fn validate_manifest_shape( for key in manifest.extensions.keys() { if !extension_key_is_namespaced(key) { report.error( - "extension_key_not_namespaced", + codes::EXTENSION_KEY_NOT_NAMESPACED, format!("extension key {key:?} must be namespaced"), ); } @@ -529,10 +593,10 @@ fn validate_manifest_shape( fn validate_auxiliary_artifact_shape( manifest: &IndexManifest, - limits: &ResourceLimits, + options: &VerifyOptions, report: &mut VerificationReport, ) { - if !check_auxiliary_artifact_count(manifest, limits, report) { + if !check_auxiliary_artifact_count(manifest, &options.limits, report) { return; } let mut names = HashSet::new(); @@ -540,32 +604,41 @@ fn validate_auxiliary_artifact_shape( let name = artifact.name.trim(); if name.is_empty() { report.error( - "auxiliary_artifact_name_empty", + codes::AUXILIARY_ARTIFACT_NAME_EMPTY, "auxiliary artifact name must be non-empty", ); } else if artifact.name != name { report.error( - "auxiliary_artifact_name_not_trimmed", + codes::AUXILIARY_ARTIFACT_NAME_NOT_TRIMMED, format!( "auxiliary artifact name {name:?} must not have leading or trailing whitespace" ), ); } else if !names.insert(name.to_string()) { report.error( - "auxiliary_artifact_name_duplicate", + codes::AUXILIARY_ARTIFACT_NAME_DUPLICATE, format!("auxiliary artifact name {name:?} is duplicated"), ); } if artifact.path.trim().is_empty() { report.error( - "auxiliary_artifact_path_empty", + codes::AUXILIARY_ARTIFACT_PATH_EMPTY, format!("auxiliary artifact {name:?} path must be non-empty"), ); + } else if !is_manifest_path_absolute(&artifact.path) + && !is_canonical_manifest_path(&artifact.path, options.allow_path_escape) + { + report.error( + codes::AUXILIARY_ARTIFACT_PATH_NOT_CANONICAL, + format!( + "auxiliary artifact {name:?} path must use forward slashes with no `.`, `..`, or empty segments" + ), + ); } if !is_sha256_hex(&artifact.sha256) { report.error( - "auxiliary_artifact_sha256_invalid", + codes::AUXILIARY_ARTIFACT_SHA256_INVALID, format!( "auxiliary artifact {name:?} sha256 must be a lowercase 64-character hex SHA-256 digest" ), @@ -576,7 +649,7 @@ fn validate_auxiliary_artifact_shape( // only required declarations must carry a real size. if artifact.required && artifact.file_size_bytes == 0 { report.error( - "auxiliary_artifact_file_size_zero", + codes::AUXILIARY_ARTIFACT_FILE_SIZE_ZERO, format!( "required auxiliary artifact {name:?} file_size_bytes must be greater than zero" ), @@ -686,7 +759,7 @@ fn compare_artifact_metadata( Ok(observed_kind) => { if artifact.kind != observed_kind { report.error( - "artifact_kind_mismatch", + codes::ARTIFACT_KIND_MISMATCH, format!( "artifact kind was {:?}, manifest declares {:?}", observed_kind, artifact.kind @@ -700,7 +773,7 @@ fn compare_artifact_metadata( Ok(observed_params) => { if artifact.params != observed_params { report.error( - "artifact_params_mismatch", + codes::ARTIFACT_PARAMS_MISMATCH, format!( "artifact params were {:?}, manifest declares {:?}", observed_params, artifact.params @@ -712,7 +785,7 @@ fn compare_artifact_metadata( } if artifact.format_version != metadata.format_version { report.error( - "artifact_format_version_mismatch", + codes::ARTIFACT_FORMAT_VERSION_MISMATCH, format!( "artifact format_version was {}, manifest declares {}", metadata.format_version, artifact.format_version @@ -721,7 +794,7 @@ fn compare_artifact_metadata( } if artifact.dim != metadata.dim { report.error( - "artifact_dim_mismatch", + codes::ARTIFACT_DIM_MISMATCH, format!( "artifact dim was {}, manifest declares {}", metadata.dim, artifact.dim @@ -730,7 +803,7 @@ fn compare_artifact_metadata( } if artifact.vector_count != metadata.vector_count { report.error( - "artifact_vector_count_mismatch", + codes::ARTIFACT_VECTOR_COUNT_MISMATCH, format!( "artifact vector_count was {}, manifest declares {}", metadata.vector_count, artifact.vector_count @@ -739,7 +812,7 @@ fn compare_artifact_metadata( } if artifact.bytes_per_vec != metadata.bytes_per_vec { report.error( - "artifact_bytes_per_vec_mismatch", + codes::ARTIFACT_BYTES_PER_VEC_MISMATCH, format!( "artifact bytes_per_vec was {}, manifest declares {}", metadata.bytes_per_vec, artifact.bytes_per_vec @@ -748,7 +821,7 @@ fn compare_artifact_metadata( } if artifact.file_size_bytes != metadata.file_size_bytes { report.error( - "artifact_metadata_file_size_mismatch", + codes::ARTIFACT_METADATA_FILE_SIZE_MISMATCH, format!( "artifact metadata file_size_bytes was {}, manifest declares {}", metadata.file_size_bytes, artifact.file_size_bytes @@ -779,7 +852,7 @@ fn verify_row_identity( report.row_identity.row_count = Some(*row_count); if *row_count > options.limits.max_row_identity_rows { report.error( - "row_identity_row_count_limit_exceeded", + codes::ROW_IDENTITY_ROW_COUNT_LIMIT_EXCEEDED, format!( "row_identity.row_count {row_count} exceeds max_row_identity_rows={}", options.limits.max_row_identity_rows @@ -792,7 +865,7 @@ fn verify_row_identity( &row_path, &document.base_dir, options, - "row_identity", + &ROW_IDENTITY_PATH_ISSUES, &mut report.errors, ) { paths.row_identity_path = Some(resolved.canonical_path.clone()); @@ -810,11 +883,14 @@ fn verify_row_identity( if let Some(hash) = &stats.sha256 { report.row_identity.sha256 = Some(hash.clone()); if !hex_digest_eq(hash, sha256) { - report.error( - "row_identity_sha256_mismatch", - format!( - "row_identity SHA-256 was {hash}, manifest declares {sha256}" - ), + report.errors.push( + ReportIssue::new( + codes::ROW_IDENTITY_SHA256_MISMATCH, + format!( + "row_identity SHA-256 was {hash}, manifest declares {sha256}" + ), + ) + .with_sha256_detail(sha256.as_str(), hash.as_str()), ); } } @@ -822,7 +898,7 @@ fn verify_row_identity( && !report .errors .iter() - .any(|issue| issue.code == "row_identity_row_count_mismatch") + .any(|issue| issue.code == codes::ROW_IDENTITY_ROW_COUNT_MISMATCH) { let observed_rows = if stats.sha256.is_some() { stats.row_count.to_string() @@ -830,7 +906,7 @@ fn verify_row_identity( format!("at least {}", stats.row_count) }; report.error( - "row_identity_row_count_mismatch", + codes::ROW_IDENTITY_ROW_COUNT_MISMATCH, format!( "row identity file has {observed_rows} rows, manifest declares {row_count}" ), @@ -838,7 +914,7 @@ fn verify_row_identity( } } Err(err) => report.error( - "row_identity_read_failed", + codes::ROW_IDENTITY_READ_FAILED, format!("failed to read row identity file: {err}"), ), } @@ -882,7 +958,7 @@ fn validate_encoder_distortion_shape( ) { if profile.schema_version != ENCODER_DISTORTION_SCHEMA_VERSION { report.error( - "encoder_distortion_schema_version_unsupported", + codes::ENCODER_DISTORTION_SCHEMA_VERSION_UNSUPPORTED, format!( "encoder_distortion.schema_version must be {ENCODER_DISTORTION_SCHEMA_VERSION}, got {}", profile.schema_version @@ -891,7 +967,7 @@ fn validate_encoder_distortion_shape( } if profile.profile_id.trim().is_empty() { report.error( - "encoder_distortion_profile_id_empty", + codes::ENCODER_DISTORTION_PROFILE_ID_EMPTY, "encoder_distortion.profile_id must be non-empty", ); } @@ -901,42 +977,42 @@ fn validate_encoder_distortion_shape( .is_some_and(|created_at| DateTime::parse_from_rfc3339(created_at).is_err()) { report.error( - "encoder_distortion_created_at_invalid", + codes::ENCODER_DISTORTION_CREATED_AT_INVALID, "encoder_distortion.created_at must parse as RFC3339 when present", ); } if profile.encoder.model.trim().is_empty() { report.error( - "encoder_distortion_encoder_model_empty", + codes::ENCODER_DISTORTION_ENCODER_MODEL_EMPTY, "encoder_distortion.encoder.model must be non-empty", ); } if profile.encoder.dim == 0 { report.error( - "encoder_distortion_encoder_dim_zero", + codes::ENCODER_DISTORTION_ENCODER_DIM_ZERO, "encoder_distortion.encoder.dim must be greater than zero", ); } validate_optional_non_empty( - "encoder_distortion_encoder_model_revision_empty", + codes::ENCODER_DISTORTION_ENCODER_MODEL_REVISION_EMPTY, "encoder_distortion.encoder.model_revision must be non-empty when present", profile.encoder.model_revision.as_deref(), report, ); validate_optional_non_empty( - "encoder_distortion_encoder_normalization_empty", + codes::ENCODER_DISTORTION_ENCODER_NORMALIZATION_EMPTY, "encoder_distortion.encoder.normalization must be non-empty when present", profile.encoder.normalization.as_deref(), report, ); validate_optional_non_empty( - "encoder_distortion_tokenizer_revision_empty", + codes::ENCODER_DISTORTION_TOKENIZER_REVISION_EMPTY, "encoder_distortion.tokenizer_revision must be non-empty when present", profile.tokenizer_revision.as_deref(), report, ); validate_optional_non_empty( - "encoder_distortion_pooling_empty", + codes::ENCODER_DISTORTION_POOLING_EMPTY, "encoder_distortion.pooling must be non-empty when present", profile.pooling.as_deref(), report, @@ -950,7 +1026,7 @@ fn validate_encoder_distortion_encoder( ) { if profile.encoder.model != embedding.model { report.error( - "encoder_distortion_encoder_model_mismatch", + codes::ENCODER_DISTORTION_ENCODER_MODEL_MISMATCH, format!( "encoder_distortion model {:?} does not match embedding.model {:?}", profile.encoder.model, embedding.model @@ -959,7 +1035,7 @@ fn validate_encoder_distortion_encoder( } if profile.encoder.dim != embedding.dim { report.error( - "encoder_distortion_encoder_dim_mismatch", + codes::ENCODER_DISTORTION_ENCODER_DIM_MISMATCH, format!( "encoder_distortion dim {} does not match embedding.dim {}", profile.encoder.dim, embedding.dim @@ -967,7 +1043,7 @@ fn validate_encoder_distortion_encoder( ); } compare_optional_encoder_identity( - "encoder_distortion_encoder_model_revision_mismatch", + codes::ENCODER_DISTORTION_ENCODER_MODEL_REVISION_MISMATCH, "encoder_distortion encoder", "model_revision", embedding.model_revision.as_deref(), @@ -975,7 +1051,7 @@ fn validate_encoder_distortion_encoder( report, ); compare_optional_encoder_identity( - "encoder_distortion_encoder_normalization_mismatch", + codes::ENCODER_DISTORTION_ENCODER_NORMALIZATION_MISMATCH, "encoder_distortion encoder", "normalization", embedding.normalization.as_deref(), @@ -983,7 +1059,7 @@ fn validate_encoder_distortion_encoder( report, ); compare_optional_encoder_identity( - "encoder_distortion_tokenizer_revision_mismatch", + codes::ENCODER_DISTORTION_TOKENIZER_REVISION_MISMATCH, "encoder_distortion", "tokenizer_revision", embedding.tokenizer_revision.as_deref(), @@ -991,7 +1067,7 @@ fn validate_encoder_distortion_encoder( report, ); compare_optional_encoder_identity( - "encoder_distortion_pooling_mismatch", + codes::ENCODER_DISTORTION_POOLING_MISMATCH, "encoder_distortion", "pooling", embedding.pooling.as_deref(), @@ -1006,31 +1082,58 @@ fn validate_encoder_distortion_metrics( ) { validate_metric_spec( "encoder_distortion_source_metric", + &SOURCE_METRIC_ISSUES, &profile.source_metric, report, ); validate_metric_spec( "encoder_distortion_embedding_metric", + &EMBEDDING_METRIC_ISSUES, &profile.embedding_metric, report, ); } -fn validate_metric_spec(prefix: &str, metric: &MetricSpec, report: &mut VerificationReport) { +/// Per-metric issue codes for [`validate_metric_spec`], so every emitted +/// code stays a named constant in [`codes`]. +struct MetricSpecIssueCodes { + name_empty: &'static str, + version_empty: &'static str, + digest_invalid: &'static str, +} + +const SOURCE_METRIC_ISSUES: MetricSpecIssueCodes = MetricSpecIssueCodes { + name_empty: codes::ENCODER_DISTORTION_SOURCE_METRIC_NAME_EMPTY, + version_empty: codes::ENCODER_DISTORTION_SOURCE_METRIC_VERSION_EMPTY, + digest_invalid: codes::ENCODER_DISTORTION_SOURCE_METRIC_DIGEST_INVALID, +}; + +const EMBEDDING_METRIC_ISSUES: MetricSpecIssueCodes = MetricSpecIssueCodes { + name_empty: codes::ENCODER_DISTORTION_EMBEDDING_METRIC_NAME_EMPTY, + version_empty: codes::ENCODER_DISTORTION_EMBEDDING_METRIC_VERSION_EMPTY, + digest_invalid: codes::ENCODER_DISTORTION_EMBEDDING_METRIC_DIGEST_INVALID, +}; + +fn validate_metric_spec( + prefix: &str, + issue_codes: &MetricSpecIssueCodes, + metric: &MetricSpec, + report: &mut VerificationReport, +) { if metric.name.trim().is_empty() { report.error( - format!("{prefix}_name_empty"), + issue_codes.name_empty, format!("{prefix}.name must be non-empty"), ); } validate_optional_non_empty( - &format!("{prefix}_version_empty"), + issue_codes.version_empty, &format!("{prefix}.version must be non-empty when present"), metric.version.as_deref(), report, ); validate_optional_sha256_uri( - &format!("{prefix}_digest_invalid"), + issue_codes.digest_invalid, &format!("{prefix}.digest must be sha256: when present"), metric.digest.as_deref(), report, @@ -1046,43 +1149,43 @@ fn validate_encoder_distortion_bounds(bounds: &DistortionBounds, report: &mut Ve && bounds.quantile_observed_violation.is_none() { report.error( - "encoder_distortion_bounds_empty", + codes::ENCODER_DISTORTION_BOUNDS_EMPTY, "encoder_distortion.bounds must declare at least one bound or observed violation statistic", ); } validate_optional_positive_f64( - "encoder_distortion_lower_bound_invalid", + codes::ENCODER_DISTORTION_LOWER_BOUND_INVALID, "encoder_distortion.bounds.declared_lower_bound must be finite and greater than zero", bounds.declared_lower_bound, report, ); validate_optional_positive_f64( - "encoder_distortion_upper_bound_invalid", + codes::ENCODER_DISTORTION_UPPER_BOUND_INVALID, "encoder_distortion.bounds.declared_upper_bound must be finite and greater than zero", bounds.declared_upper_bound, report, ); validate_optional_positive_f64( - "encoder_distortion_estimated_distortion_invalid", + codes::ENCODER_DISTORTION_ESTIMATED_DISTORTION_INVALID, "encoder_distortion.bounds.estimated_distortion must be finite and greater than zero", bounds.estimated_distortion, report, ); validate_optional_probability( - "encoder_distortion_violation_rate_invalid", + codes::ENCODER_DISTORTION_VIOLATION_RATE_INVALID, "encoder_distortion.bounds.violation_rate must be finite and within [0, 1]", bounds.violation_rate, report, ); validate_optional_nonnegative_f64( - "encoder_distortion_max_observed_violation_invalid", + codes::ENCODER_DISTORTION_MAX_OBSERVED_VIOLATION_INVALID, "encoder_distortion.bounds.max_observed_violation must be finite and non-negative", bounds.max_observed_violation, report, ); validate_optional_nonnegative_f64( - "encoder_distortion_quantile_observed_violation_invalid", + codes::ENCODER_DISTORTION_QUANTILE_OBSERVED_VIOLATION_INVALID, "encoder_distortion.bounds.quantile_observed_violation must be finite and non-negative", bounds.quantile_observed_violation, report, @@ -1091,7 +1194,7 @@ fn validate_encoder_distortion_bounds(bounds: &DistortionBounds, report: &mut Ve if let (Some(lower), Some(upper)) = (bounds.declared_lower_bound, bounds.declared_upper_bound) { if lower.is_finite() && upper.is_finite() && lower > upper { report.error( - "encoder_distortion_bounds_order_invalid", + codes::ENCODER_DISTORTION_BOUNDS_ORDER_INVALID, "encoder_distortion.bounds.declared_lower_bound must be less than or equal to declared_upper_bound", ); } @@ -1100,14 +1203,14 @@ fn validate_encoder_distortion_bounds(bounds: &DistortionBounds, report: &mut Ve let expected = upper / lower; if !expected.is_finite() { report.error( - "encoder_distortion_distortion_mismatch", + codes::ENCODER_DISTORTION_DISTORTION_MISMATCH, "encoder_distortion.bounds.declared_upper_bound / declared_lower_bound must be finite", ); } else { let tolerance = 1e-9_f64.max(expected.abs() * 1e-9); if estimated.is_finite() && (estimated - expected).abs() > tolerance { report.error( - "encoder_distortion_distortion_mismatch", + codes::ENCODER_DISTORTION_DISTORTION_MISMATCH, format!( "encoder_distortion.bounds.estimated_distortion {} does not match declared_upper_bound / declared_lower_bound {}", estimated, expected @@ -1122,31 +1225,31 @@ fn validate_encoder_distortion_bounds(bounds: &DistortionBounds, report: &mut Ve fn validate_encoder_distortion_scope(scope: &DistortionScope, report: &mut VerificationReport) { validate_optional_sha256_uri( - "encoder_distortion_scope_corpus_digest_invalid", + codes::ENCODER_DISTORTION_SCOPE_CORPUS_DIGEST_INVALID, "encoder_distortion.scope.corpus_digest must be sha256: when present", scope.corpus_digest.as_deref(), report, ); validate_optional_sha256_uri( - "encoder_distortion_scope_query_set_digest_invalid", + codes::ENCODER_DISTORTION_SCOPE_QUERY_SET_DIGEST_INVALID, "encoder_distortion.scope.query_set_digest must be sha256: when present", scope.query_set_digest.as_deref(), report, ); validate_optional_sha256_uri( - "encoder_distortion_scope_pair_sample_digest_invalid", + codes::ENCODER_DISTORTION_SCOPE_PAIR_SAMPLE_DIGEST_INVALID, "encoder_distortion.scope.pair_sample_digest must be sha256: when present", scope.pair_sample_digest.as_deref(), report, ); validate_optional_non_empty( - "encoder_distortion_scope_domain_empty", + codes::ENCODER_DISTORTION_SCOPE_DOMAIN_EMPTY, "encoder_distortion.scope.domain must be non-empty when present", scope.domain.as_deref(), report, ); validate_optional_non_empty( - "encoder_distortion_scope_estimator_version_empty", + codes::ENCODER_DISTORTION_SCOPE_ESTIMATOR_VERSION_EMPTY, "encoder_distortion.scope.estimator_version must be non-empty when present", scope.estimator_version.as_deref(), report, @@ -1156,18 +1259,18 @@ fn validate_encoder_distortion_scope(scope: &DistortionScope, report: &mut Verif .is_some_and(|sample_size| sample_size == 0) { report.error( - "encoder_distortion_scope_sample_size_zero", + codes::ENCODER_DISTORTION_SCOPE_SAMPLE_SIZE_ZERO, "encoder_distortion.scope.sample_size must be greater than zero when present", ); } validate_optional_probability( - "encoder_distortion_scope_confidence_invalid", + codes::ENCODER_DISTORTION_SCOPE_CONFIDENCE_INVALID, "encoder_distortion.scope.confidence must be finite and within [0, 1]", scope.confidence, report, ); validate_optional_probability( - "encoder_distortion_scope_coverage_invalid", + codes::ENCODER_DISTORTION_SCOPE_COVERAGE_INVALID, "encoder_distortion.scope.coverage must be finite and within [0, 1]", scope.coverage, report, @@ -1181,13 +1284,13 @@ fn validate_encoder_distortion_evidence( report: &mut VerificationReport, ) { validate_optional_non_empty( - "encoder_distortion_evidence_estimator_id_empty", + codes::ENCODER_DISTORTION_EVIDENCE_ESTIMATOR_ID_EMPTY, "encoder_distortion.evidence.estimator_id must be non-empty when present", profile.evidence.estimator_id.as_deref(), report, ); validate_optional_sha256_uri( - "encoder_distortion_evidence_estimator_hash_invalid", + codes::ENCODER_DISTORTION_EVIDENCE_ESTIMATOR_HASH_INVALID, "encoder_distortion.evidence.estimator_hash must be sha256: when present", profile.evidence.estimator_hash.as_deref(), report, @@ -1196,7 +1299,7 @@ fn validate_encoder_distortion_evidence( if profile.profile.is_none() && profile.evidence.kind != DistortionEvidenceKind::CallerAsserted { report.error( - "encoder_distortion_profile_required", + codes::ENCODER_DISTORTION_PROFILE_REQUIRED, "non-caller-asserted encoder distortion evidence requires a profile artifact", ); return; @@ -1216,30 +1319,37 @@ fn validate_encoder_distortion_profile_artifact( report.encoder_distortion.profile_manifest_path = Some(profile.path.clone()); if profile.path.trim().is_empty() { report.error( - "encoder_distortion_profile_path_empty", + codes::ENCODER_DISTORTION_PROFILE_PATH_EMPTY, "encoder_distortion.profile.path must be non-empty", ); + } else if !is_manifest_path_absolute(&profile.path) + && !is_canonical_manifest_path(&profile.path, options.allow_path_escape) + { + report.error( + codes::ENCODER_DISTORTION_PROFILE_PATH_NOT_CANONICAL, + "encoder_distortion.profile.path must use forward slashes with no `.`, `..`, or empty segments", + ); } if !is_sha256_hex(&profile.sha256) { report.error( - "encoder_distortion_profile_sha256_invalid", + codes::ENCODER_DISTORTION_PROFILE_SHA256_INVALID, "encoder_distortion.profile.sha256 must be a lowercase 64-character hex SHA-256 digest", ); } if profile.file_size_bytes == 0 { report.error( - "encoder_distortion_profile_file_size_zero", + codes::ENCODER_DISTORTION_PROFILE_FILE_SIZE_ZERO, "encoder_distortion.profile.file_size_bytes must be greater than zero", ); } if profile.format.trim().is_empty() { report.error( - "encoder_distortion_profile_format_empty", + codes::ENCODER_DISTORTION_PROFILE_FORMAT_EMPTY, "encoder_distortion.profile.format must be non-empty", ); } validate_optional_sha256_uri( - "encoder_distortion_profile_source_digest_invalid", + codes::ENCODER_DISTORTION_PROFILE_SOURCE_DIGEST_INVALID, "encoder_distortion.profile.source_digest must be sha256: when present", profile.source_digest.as_deref(), report, @@ -1251,7 +1361,7 @@ fn validate_encoder_distortion_profile_artifact( &path, base_dir, options, - "encoder_distortion_profile", + &ENCODER_DISTORTION_PROFILE_PATH_ISSUES, &mut report.errors, ) { report.encoder_distortion.profile_canonical_path = @@ -1261,7 +1371,7 @@ fn validate_encoder_distortion_profile_artifact( profile .file_size_bytes .min(options.limits.max_encoder_distortion_profile_bytes), - "encoder_distortion_profile_too_large", + codes::ENCODER_DISTORTION_PROFILE_TOO_LARGE, "encoder distortion profile", ) { Ok(hash) => { @@ -1269,7 +1379,7 @@ fn validate_encoder_distortion_profile_artifact( report.encoder_distortion.profile_size_bytes = Some(hash.size_bytes); if !hex_digest_eq(&hash.sha256, &profile.sha256) { report.error( - "encoder_distortion_profile_sha256_mismatch", + codes::ENCODER_DISTORTION_PROFILE_SHA256_MISMATCH, format!( "encoder distortion profile SHA-256 was {}, manifest declares {}", hash.sha256, profile.sha256 @@ -1278,7 +1388,7 @@ fn validate_encoder_distortion_profile_artifact( } if hash.size_bytes != profile.file_size_bytes { report.error( - "encoder_distortion_profile_file_size_mismatch", + codes::ENCODER_DISTORTION_PROFILE_FILE_SIZE_MISMATCH, format!( "encoder distortion profile size was {}, manifest declares {}", hash.size_bytes, profile.file_size_bytes @@ -1288,7 +1398,7 @@ fn validate_encoder_distortion_profile_artifact( } Err(ManifestError::LimitExceeded { code, message }) => report.error(code, message), Err(err) => report.error( - "encoder_distortion_profile_hash_failed", + codes::ENCODER_DISTORTION_PROFILE_HASH_FAILED, format!("failed to hash encoder distortion profile: {err}"), ), } @@ -1306,21 +1416,21 @@ fn validate_encoder_distortion_calibration( }; if calibration_profile_id.trim().is_empty() { report.error( - "encoder_distortion_calibration_profile_id_empty", + codes::ENCODER_DISTORTION_CALIBRATION_PROFILE_ID_EMPTY, "encoder_distortion.calibration_profile_id must be non-empty when present", ); return; } if calibration_profile_id.trim() != calibration_profile_id { report.error( - "encoder_distortion_calibration_profile_id_whitespace", + codes::ENCODER_DISTORTION_CALIBRATION_PROFILE_ID_WHITESPACE, "encoder_distortion.calibration_profile_id must not contain leading or trailing whitespace", ); return; } let Some(calibration) = calibration else { report.error( - "encoder_distortion_calibration_missing", + codes::ENCODER_DISTORTION_CALIBRATION_MISSING, "encoder_distortion.calibration_profile_id requires a calibration block", ); return; @@ -1328,7 +1438,7 @@ fn validate_encoder_distortion_calibration( // Calibration profile ids are manifest identifiers; keep matching exact. if calibration.profile_id != *calibration_profile_id { report.error( - "encoder_distortion_calibration_profile_mismatch", + codes::ENCODER_DISTORTION_CALIBRATION_PROFILE_MISMATCH, format!( "encoder_distortion.calibration_profile_id {:?} does not match calibration.profile_id {:?}", calibration_profile_id, calibration.profile_id @@ -1372,7 +1482,7 @@ fn validate_calibration_shape( ) { if calibration.schema_version != CALIBRATION_SCHEMA_VERSION { report.error( - "calibration_schema_version_unsupported", + codes::CALIBRATION_SCHEMA_VERSION_UNSUPPORTED, format!( "calibration.schema_version must be {CALIBRATION_SCHEMA_VERSION}, got {}", calibration.schema_version @@ -1381,7 +1491,7 @@ fn validate_calibration_shape( } if calibration.profile_id.trim().is_empty() { report.error( - "calibration_profile_id_empty", + codes::CALIBRATION_PROFILE_ID_EMPTY, "calibration.profile_id must be non-empty", ); } @@ -1391,56 +1501,56 @@ fn validate_calibration_shape( .is_some_and(|created_at| DateTime::parse_from_rfc3339(created_at).is_err()) { report.error( - "calibration_created_at_invalid", + codes::CALIBRATION_CREATED_AT_INVALID, "calibration.created_at must parse as RFC3339 when present", ); } if calibration.calibrated_for.model.trim().is_empty() { report.error( - "calibration_encoder_model_empty", + codes::CALIBRATION_ENCODER_MODEL_EMPTY, "calibration.calibrated_for.model must be non-empty", ); } if calibration.calibrated_for.dim == 0 { report.error( - "calibration_encoder_dim_zero", + codes::CALIBRATION_ENCODER_DIM_ZERO, "calibration.calibrated_for.dim must be greater than zero", ); } validate_optional_non_empty( - "calibration_encoder_model_revision_empty", + codes::CALIBRATION_ENCODER_MODEL_REVISION_EMPTY, "calibration.calibrated_for.model_revision must be non-empty when present", calibration.calibrated_for.model_revision.as_deref(), report, ); validate_optional_non_empty( - "calibration_encoder_normalization_empty", + codes::CALIBRATION_ENCODER_NORMALIZATION_EMPTY, "calibration.calibrated_for.normalization must be non-empty when present", calibration.calibrated_for.normalization.as_deref(), report, ); if calibration.ordinalization.dim() == 0 { report.error( - "calibration_ordinalization_dim_zero", + codes::CALIBRATION_ORDINALIZATION_DIM_ZERO, "calibration.ordinalization.dim must be greater than zero", ); } match &calibration.ordinalization { CalibrationOrdinalization::TopK { k, .. } if *k == 0 => { report.error( - "calibration_ordinalization_artifact_mismatch", + codes::CALIBRATION_ORDINALIZATION_ARTIFACT_MISMATCH, "calibration top_k.k must be greater than zero", ); } CalibrationOrdinalization::Bucket { bits, .. } if !matches!(*bits, 1 | 2 | 4) => { report.error( - "calibration_ordinalization_artifact_mismatch", + codes::CALIBRATION_ORDINALIZATION_ARTIFACT_MISMATCH, "calibration bucket.bits must be 1, 2, or 4", ); } CalibrationOrdinalization::CallerDefined { name, .. } if name.trim().is_empty() => { report.error( - "calibration_ordinalization_artifact_mismatch", + codes::CALIBRATION_ORDINALIZATION_ARTIFACT_MISMATCH, "calibration caller_defined.name must be non-empty", ); } @@ -1449,7 +1559,7 @@ fn validate_calibration_shape( match &calibration.null_model { NullModelSpec::EmpiricalTailTable { statistic } if statistic.trim().is_empty() => { report.error( - "calibration_null_statistic_empty", + codes::CALIBRATION_NULL_STATISTIC_EMPTY, "calibration.null_model.statistic must be non-empty", ); } @@ -1459,12 +1569,12 @@ fn validate_calibration_shape( } => { if name.trim().is_empty() { report.error( - "calibration_null_name_empty", + codes::CALIBRATION_NULL_NAME_EMPTY, "calibration.null_model.name must be non-empty", ); } validate_optional_non_empty( - "calibration_null_parameterization_empty", + codes::CALIBRATION_NULL_PARAMETERIZATION_EMPTY, "calibration.null_model.parameterization must be non-empty when present", parameterization.as_deref(), report, @@ -1481,7 +1591,7 @@ fn validate_calibration_encoder( ) { if calibration.calibrated_for.model != embedding.model { report.error( - "calibration_encoder_model_mismatch", + codes::CALIBRATION_ENCODER_MODEL_MISMATCH, format!( "calibration model {:?} does not match embedding.model {:?}", calibration.calibrated_for.model, embedding.model @@ -1490,7 +1600,7 @@ fn validate_calibration_encoder( } if calibration.calibrated_for.dim != embedding.dim { report.error( - "calibration_encoder_dim_mismatch", + codes::CALIBRATION_ENCODER_DIM_MISMATCH, format!( "calibration dim {} does not match embedding.dim {}", calibration.calibrated_for.dim, embedding.dim @@ -1498,7 +1608,7 @@ fn validate_calibration_encoder( ); } compare_optional_identity( - "calibration_encoder_model_revision_mismatch", + codes::CALIBRATION_ENCODER_MODEL_REVISION_MISMATCH, "calibration encoder", "model_revision", embedding.model_revision.as_deref(), @@ -1506,7 +1616,7 @@ fn validate_calibration_encoder( report, ); compare_optional_identity( - "calibration_encoder_normalization_mismatch", + codes::CALIBRATION_ENCODER_NORMALIZATION_MISMATCH, "calibration encoder", "normalization", embedding.normalization.as_deref(), @@ -1558,7 +1668,7 @@ fn validate_calibration_ordinalization( ) { if calibration.ordinalization.dim() != artifact.dim { report.error( - "calibration_ordinalization_dim_mismatch", + codes::CALIBRATION_ORDINALIZATION_DIM_MISMATCH, format!( "calibration ordinalization dim {} does not match artifact.dim {}", calibration.ordinalization.dim(), @@ -1597,7 +1707,7 @@ fn validate_calibration_ordinalization( if !compatible { report.error( - "calibration_ordinalization_artifact_mismatch", + codes::CALIBRATION_ORDINALIZATION_ARTIFACT_MISMATCH, "calibration.ordinalization is incompatible with artifact.kind/artifact.params", ); } @@ -1621,7 +1731,7 @@ fn validate_calibration_null_model_ordinalization( NullModelSpec::UniformHypergeometric ) { report.error( - "calibration_null_model_ordinalization_mismatch", + codes::CALIBRATION_NULL_MODEL_ORDINALIZATION_MISMATCH, "uniform_hypergeometric calibration requires top_k ordinalization", ); } @@ -1640,7 +1750,7 @@ fn validate_calibration_profile( ) { if calibration.profile.is_some() { report.error( - "calibration_profile_unexpected", + codes::CALIBRATION_PROFILE_UNEXPECTED, "uniform_hypergeometric calibration must not include a profile artifact", ); } @@ -1649,7 +1759,7 @@ fn validate_calibration_profile( let Some(profile) = &calibration.profile else { report.error( - "calibration_profile_required", + codes::CALIBRATION_PROFILE_REQUIRED, "non-uniform calibration requires a profile artifact", ); return; @@ -1658,25 +1768,32 @@ fn validate_calibration_profile( report.calibration.profile_manifest_path = Some(profile.path.clone()); if profile.path.trim().is_empty() { report.error( - "calibration_profile_path_empty", + codes::CALIBRATION_PROFILE_PATH_EMPTY, "calibration.profile.path must be non-empty", ); + } else if !is_manifest_path_absolute(&profile.path) + && !is_canonical_manifest_path(&profile.path, options.allow_path_escape) + { + report.error( + codes::CALIBRATION_PROFILE_PATH_NOT_CANONICAL, + "calibration.profile.path must use forward slashes with no `.`, `..`, or empty segments", + ); } if !is_sha256_hex(&profile.sha256) { report.error( - "calibration_profile_sha256_invalid", + codes::CALIBRATION_PROFILE_SHA256_INVALID, "calibration.profile.sha256 must be a lowercase 64-character hex SHA-256 digest", ); } if profile.file_size_bytes == 0 { report.error( - "calibration_profile_file_size_zero", + codes::CALIBRATION_PROFILE_FILE_SIZE_ZERO, "calibration.profile.file_size_bytes must be greater than zero", ); } if profile.dim != artifact.dim { report.error( - "calibration_profile_dim_mismatch", + codes::CALIBRATION_PROFILE_DIM_MISMATCH, format!( "calibration profile dim {} does not match artifact.dim {}", profile.dim, artifact.dim @@ -1685,7 +1802,7 @@ fn validate_calibration_profile( } if profile.sample_count == 0 { report.error( - "calibration_profile_sample_count_zero", + codes::CALIBRATION_PROFILE_SAMPLE_COUNT_ZERO, "calibration.profile.sample_count must be greater than zero", ); } @@ -1699,7 +1816,7 @@ fn validate_calibration_profile( &path, base_dir, options, - "calibration_profile", + &CALIBRATION_PROFILE_PATH_ISSUES, &mut report.errors, ) { report.calibration.profile_canonical_path = @@ -1709,7 +1826,7 @@ fn validate_calibration_profile( profile .file_size_bytes .min(options.limits.max_calibration_profile_bytes), - "calibration_profile_too_large", + codes::CALIBRATION_PROFILE_TOO_LARGE, "calibration profile", ) { Ok(hash) => { @@ -1717,7 +1834,7 @@ fn validate_calibration_profile( report.calibration.profile_size_bytes = Some(hash.size_bytes); if !hex_digest_eq(&hash.sha256, &profile.sha256) { report.error( - "calibration_profile_sha256_mismatch", + codes::CALIBRATION_PROFILE_SHA256_MISMATCH, format!( "calibration profile SHA-256 was {}, manifest declares {}", hash.sha256, profile.sha256 @@ -1726,7 +1843,7 @@ fn validate_calibration_profile( } if hash.size_bytes != profile.file_size_bytes { report.error( - "calibration_profile_file_size_mismatch", + codes::CALIBRATION_PROFILE_FILE_SIZE_MISMATCH, format!( "calibration profile size was {}, manifest declares {}", hash.size_bytes, profile.file_size_bytes @@ -1736,7 +1853,7 @@ fn validate_calibration_profile( } Err(ManifestError::LimitExceeded { code, message }) => report.error(code, message), Err(err) => report.error( - "calibration_profile_hash_failed", + codes::CALIBRATION_PROFILE_HASH_FAILED, format!("failed to hash calibration profile: {err}"), ), } @@ -1750,14 +1867,14 @@ fn validate_optional_source_digest(value: Option<&str>, report: &mut Verificatio }; let Some(digest) = value.strip_prefix("sha256:") else { report.error( - "calibration_profile_source_digest_invalid", + codes::CALIBRATION_PROFILE_SOURCE_DIGEST_INVALID, "calibration.profile.source_digest must be sha256:", ); return; }; if !is_sha256_hex(digest) { report.error( - "calibration_profile_source_digest_invalid", + codes::CALIBRATION_PROFILE_SOURCE_DIGEST_INVALID, "calibration.profile.source_digest must be sha256:", ); } @@ -1773,7 +1890,7 @@ fn validate_calibration_parameterization( if *parameterization != profile.parameterization => { report.error( - "calibration_null_parameterization_mismatch", + codes::CALIBRATION_NULL_PARAMETERIZATION_MISMATCH, format!( "null_model parameterization {:?} does not match profile parameterization {:?}", parameterization, profile.parameterization @@ -1784,7 +1901,7 @@ fn validate_calibration_parameterization( if profile.parameterization != ProfileParameterization::EmpiricalTailTable => { report.error( - "calibration_null_parameterization_mismatch", + codes::CALIBRATION_NULL_PARAMETERIZATION_MISMATCH, "empirical_tail_table null_model requires empirical_tail_table profile parameterization", ); } @@ -1795,7 +1912,7 @@ fn validate_calibration_parameterization( &calibration.ordinalization, ) { report.error( - "calibration_profile_parameterization_ordinalization_mismatch", + codes::CALIBRATION_PROFILE_PARAMETERIZATION_ORDINALIZATION_MISMATCH, "calibration profile parameterization is incompatible with calibration ordinalization", ); } @@ -1835,7 +1952,7 @@ fn validate_calibration_profile_shape( ) { if profile.format.trim().is_empty() { report.error( - "calibration_profile_format_empty", + codes::CALIBRATION_PROFILE_FORMAT_EMPTY, "calibration.profile.format must be non-empty", ); } @@ -1847,7 +1964,7 @@ fn validate_calibration_profile_shape( if let Some(expected) = expected_profile_shape(profile.parameterization, ordinalization) { if profile.shape != expected { report.error( - "calibration_profile_shape_mismatch", + codes::CALIBRATION_PROFILE_SHAPE_MISMATCH, format!( "calibration profile shape {:?} does not match expected {:?}", profile.shape, expected @@ -1870,21 +1987,21 @@ fn validate_calibration_profile_shape( .try_fold(1u64, |acc, value| acc.checked_mul(*value as u64)) else { report.error( - "calibration_profile_shape_mismatch", + codes::CALIBRATION_PROFILE_SHAPE_MISMATCH, "calibration.profile.shape product overflows u64", ); return; }; let Some(expected_bytes) = values.checked_mul(bytes_per_value) else { report.error( - "calibration_profile_shape_mismatch", + codes::CALIBRATION_PROFILE_SHAPE_MISMATCH, "calibration.profile.shape byte size overflows u64", ); return; }; if profile.file_size_bytes != expected_bytes { report.error( - "calibration_profile_file_size_mismatch", + codes::CALIBRATION_PROFILE_FILE_SIZE_MISMATCH, format!( "calibration profile size {} does not match shape/format size {}", profile.file_size_bytes, expected_bytes @@ -1932,10 +2049,13 @@ fn verify_auxiliary_artifacts( for artifact in artifacts { let mut entry = auxiliary_artifact_report_entry(artifact, &document.base_dir); if artifact.path.trim().is_empty() { - mark_auxiliary_artifact_failed(&mut entry, "auxiliary_artifact_path_empty"); + mark_auxiliary_artifact_failed( + &mut entry, + codes::AUXILIARY_ARTIFACT_PATH_EMPTY, + ); } else { report.error( - "auxiliary_artifact_base_dir_unavailable", + codes::AUXILIARY_ARTIFACT_BASE_DIR_UNAVAILABLE, format!( "failed to canonicalize base_dir {} for auxiliary artifact {:?}: {err}", document.base_dir.display(), @@ -1944,7 +2064,7 @@ fn verify_auxiliary_artifacts( ); mark_auxiliary_artifact_failed( &mut entry, - "auxiliary_artifact_base_dir_unavailable", + codes::AUXILIARY_ARTIFACT_BASE_DIR_UNAVAILABLE, ); } report.auxiliary_artifacts.push(entry); @@ -1959,7 +2079,7 @@ fn verify_auxiliary_artifacts( let mut captured_path = None; if artifact.path.trim().is_empty() { - mark_auxiliary_artifact_failed(&mut entry, "auxiliary_artifact_path_empty"); + mark_auxiliary_artifact_failed(&mut entry, codes::AUXILIARY_ARTIFACT_PATH_EMPTY); report.auxiliary_artifacts.push(entry); paths.auxiliary_artifact_paths.push(None); continue; @@ -1983,7 +2103,7 @@ fn verify_auxiliary_artifacts( artifact .file_size_bytes .min(options.limits.max_auxiliary_artifact_bytes), - "auxiliary_artifact_file_too_large", + codes::AUXILIARY_ARTIFACT_FILE_TOO_LARGE, "auxiliary artifact", ) { Ok(hash) => { @@ -1992,27 +2112,38 @@ fn verify_auxiliary_artifacts( if !hex_digest_eq(&hash.sha256, &artifact.sha256) { mark_auxiliary_artifact_failed( &mut entry, - "auxiliary_artifact_sha256_mismatch", + codes::AUXILIARY_ARTIFACT_SHA256_MISMATCH, ); - report.error( - "auxiliary_artifact_sha256_mismatch", - format!( - "auxiliary artifact {:?} SHA-256 was {}, manifest declares {}", - artifact.name, hash.sha256, artifact.sha256 + report.errors.push( + ReportIssue::new( + codes::AUXILIARY_ARTIFACT_SHA256_MISMATCH, + format!( + "auxiliary artifact {:?} SHA-256 was {}, manifest declares {}", + artifact.name, hash.sha256, artifact.sha256 + ), + ) + .with_artifact_name(artifact.name.as_str()) + .with_sha256_detail( + artifact.sha256.as_str(), + hash.sha256.as_str(), ), ); } if hash.size_bytes != artifact.file_size_bytes { mark_auxiliary_artifact_failed( &mut entry, - "auxiliary_artifact_file_size_mismatch", + codes::AUXILIARY_ARTIFACT_FILE_SIZE_MISMATCH, ); - report.error( - "auxiliary_artifact_file_size_mismatch", - format!( - "auxiliary artifact {:?} size was {}, manifest declares {}", - artifact.name, hash.size_bytes, artifact.file_size_bytes - ), + report.errors.push( + ReportIssue::new( + codes::AUXILIARY_ARTIFACT_FILE_SIZE_MISMATCH, + format!( + "auxiliary artifact {:?} size was {}, manifest declares {}", + artifact.name, hash.size_bytes, artifact.file_size_bytes + ), + ) + .with_artifact_name(artifact.name.as_str()) + .with_size_detail(artifact.file_size_bytes, hash.size_bytes), ); } if entry.reason_code.is_none() { @@ -2020,7 +2151,7 @@ fn verify_auxiliary_artifacts( } } Err(err) => { - let code = err.code().unwrap_or("auxiliary_artifact_hash_failed"); + let code = err.code().unwrap_or(codes::AUXILIARY_ARTIFACT_HASH_FAILED); mark_auxiliary_artifact_failed(&mut entry, code); let message = if err.code().is_some() { err.to_string() @@ -2036,11 +2167,11 @@ fn verify_auxiliary_artifacts( } AuxiliaryPathResolution::OptionalAbsent => { entry.state = AuxiliaryArtifactState::OptionalAbsent; - entry.reason_code = Some("auxiliary_artifact_optional_absent".to_string()); + entry.reason_code = Some(codes::AUXILIARY_ARTIFACT_OPTIONAL_ABSENT.to_string()); } AuxiliaryPathResolution::MissingRequired => { entry.state = AuxiliaryArtifactState::MissingRequired; - entry.reason_code = Some("auxiliary_artifact_missing_required".to_string()); + entry.reason_code = Some(codes::AUXILIARY_ARTIFACT_MISSING_REQUIRED.to_string()); } AuxiliaryPathResolution::Failed(code) => { entry.state = AuxiliaryArtifactState::Failed; @@ -2091,12 +2222,12 @@ fn check_auxiliary_artifact_count( if !report .errors .iter() - .any(|issue| issue.code == "auxiliary_artifact_count_limit_exceeded") + .any(|issue| issue.code == codes::AUXILIARY_ARTIFACT_COUNT_LIMIT_EXCEEDED) { push_report_issue_bounded( &mut report.errors, limits, - "auxiliary_artifact_count_limit_exceeded", + codes::AUXILIARY_ARTIFACT_COUNT_LIMIT_EXCEEDED, format!( "auxiliary_artifacts has {count} entries, exceeding max_auxiliary_artifacts={}", limits.max_auxiliary_artifacts @@ -2132,9 +2263,13 @@ fn resolve_auxiliary_artifact_path( report: &mut VerificationReport, ) -> AuxiliaryPathResolution { let path = Path::new(&artifact.path); - if path.is_absolute() && !options.allow_absolute_paths { + // Same classification rule as `resolve_existing_path`: the policy gate + // and the mismatch rejection below keep the platform-independent manifest + // classification and this platform's resolution semantics aligned. + let manifest_absolute = is_manifest_path_absolute(&artifact.path); + if manifest_absolute && !options.allow_absolute_paths { report.error( - "auxiliary_artifact_absolute_path_rejected", + codes::AUXILIARY_ARTIFACT_ABSOLUTE_PATH_REJECTED, format!( "absolute auxiliary artifact path {} for {:?} is rejected by default", path.display(), @@ -2142,13 +2277,27 @@ fn resolve_auxiliary_artifact_path( ), ); return AuxiliaryPathResolution::Failed( - "auxiliary_artifact_absolute_path_rejected".to_string(), + codes::AUXILIARY_ARTIFACT_ABSOLUTE_PATH_REJECTED.to_string(), + ); + } + + if manifest_absolute != path.is_absolute() { + report.error( + codes::AUXILIARY_ARTIFACT_ABSOLUTE_PATH_UNRESOLVABLE, + format!( + "auxiliary artifact path {} for {:?} is classified absolute by manifest policy but cannot resolve as absolute on this platform; refusing to resolve it against the manifest base", + path.display(), + artifact.name + ), + ); + return AuxiliaryPathResolution::Failed( + codes::AUXILIARY_ARTIFACT_ABSOLUTE_PATH_UNRESOLVABLE.to_string(), ); } if !path.is_absolute() && !options.allow_path_escape && has_lexical_escape(path) { report.error( - "auxiliary_artifact_path_escape_rejected", + codes::AUXILIARY_ARTIFACT_PATH_ESCAPE_REJECTED, format!( "relative auxiliary artifact path {} for {:?} escapes the manifest base", path.display(), @@ -2156,7 +2305,7 @@ fn resolve_auxiliary_artifact_path( ), ); return AuxiliaryPathResolution::Failed( - "auxiliary_artifact_path_escape_rejected".to_string(), + codes::AUXILIARY_ARTIFACT_PATH_ESCAPE_REJECTED.to_string(), ); } @@ -2167,19 +2316,22 @@ fn resolve_auxiliary_artifact_path( return AuxiliaryPathResolution::OptionalAbsent; } Err(err) if err.kind() == io::ErrorKind::NotFound => { - report.error( - "auxiliary_artifact_missing_required", - format!( - "required auxiliary artifact {:?} is missing at {}", - artifact.name, - resolved_path.display() - ), + report.errors.push( + ReportIssue::new( + codes::AUXILIARY_ARTIFACT_MISSING_REQUIRED, + format!( + "required auxiliary artifact {:?} is missing at {}", + artifact.name, + resolved_path.display() + ), + ) + .with_artifact_name(artifact.name.as_str()), ); return AuxiliaryPathResolution::MissingRequired; } Err(err) => { report.error( - "auxiliary_artifact_path_unavailable", + codes::AUXILIARY_ARTIFACT_PATH_UNAVAILABLE, format!( "failed to canonicalize auxiliary artifact {:?} at {}: {err}", artifact.name, @@ -2187,7 +2339,7 @@ fn resolve_auxiliary_artifact_path( ), ); return AuxiliaryPathResolution::Failed( - "auxiliary_artifact_path_unavailable".to_string(), + codes::AUXILIARY_ARTIFACT_PATH_UNAVAILABLE.to_string(), ); } }; @@ -2195,7 +2347,7 @@ fn resolve_auxiliary_artifact_path( if let Some(base_canonical) = base_canonical { if !canonical_path.starts_with(base_canonical) { report.error( - "auxiliary_artifact_path_escape_rejected", + codes::AUXILIARY_ARTIFACT_PATH_ESCAPE_REJECTED, format!( "canonical auxiliary artifact path {} for {:?} is outside manifest base {}", canonical_path.display(), @@ -2204,7 +2356,7 @@ fn resolve_auxiliary_artifact_path( ), ); return AuxiliaryPathResolution::Failed( - "auxiliary_artifact_path_escape_rejected".to_string(), + codes::AUXILIARY_ARTIFACT_PATH_ESCAPE_REJECTED.to_string(), ); } } @@ -2250,7 +2402,7 @@ fn verify_attestations(manifest: &IndexManifest, report: &mut VerificationReport .map(ToOwned::to_owned); if predicate_type.is_none() { report.error( - "attestation_predicate_type_missing", + codes::ATTESTATION_PREDICATE_TYPE_MISSING, format!("attestation {idx} has no predicateType"), ); } @@ -2282,7 +2434,7 @@ fn verify_attestations(manifest: &IndexManifest, report: &mut VerificationReport if !any_subject_match { report.error( - "attestation_subject_sha256_mismatch", + codes::ATTESTATION_SUBJECT_SHA256_MISMATCH, "no supplied attestation subject digest matches the artifact SHA-256", ); } @@ -2344,26 +2496,98 @@ struct VerificationPathCapture { auxiliary_artifact_paths: Vec>, } +/// Per-context issue codes for [`resolve_existing_path`], so every emitted +/// code stays a named constant in [`codes`]. +struct PathIssueCodes { + absolute_path_rejected: &'static str, + absolute_path_unresolvable: &'static str, + base_dir_unavailable: &'static str, + path_escape_rejected: &'static str, + path_unavailable: &'static str, + /// Code for a `NotFound` canonicalize error, when this context wants that + /// distinguished from other I/O failures (permission denied, symlink + /// loop, …). `None` keeps a single `path_unavailable` code for every + /// error kind — used where a missing referenced file is not classified + /// distinctly downstream. + path_missing: Option<&'static str>, +} + +const ARTIFACT_PATH_ISSUES: PathIssueCodes = PathIssueCodes { + absolute_path_rejected: codes::ARTIFACT_ABSOLUTE_PATH_REJECTED, + absolute_path_unresolvable: codes::ARTIFACT_ABSOLUTE_PATH_UNRESOLVABLE, + base_dir_unavailable: codes::ARTIFACT_BASE_DIR_UNAVAILABLE, + path_escape_rejected: codes::ARTIFACT_PATH_ESCAPE_REJECTED, + path_unavailable: codes::ARTIFACT_PATH_UNAVAILABLE, + path_missing: Some(codes::ARTIFACT_MISSING), +}; + +const ROW_IDENTITY_PATH_ISSUES: PathIssueCodes = PathIssueCodes { + absolute_path_rejected: codes::ROW_IDENTITY_ABSOLUTE_PATH_REJECTED, + absolute_path_unresolvable: codes::ROW_IDENTITY_ABSOLUTE_PATH_UNRESOLVABLE, + base_dir_unavailable: codes::ROW_IDENTITY_BASE_DIR_UNAVAILABLE, + path_escape_rejected: codes::ROW_IDENTITY_PATH_ESCAPE_REJECTED, + path_unavailable: codes::ROW_IDENTITY_PATH_UNAVAILABLE, + path_missing: Some(codes::ROW_IDENTITY_MISSING), +}; + +const ENCODER_DISTORTION_PROFILE_PATH_ISSUES: PathIssueCodes = PathIssueCodes { + absolute_path_rejected: codes::ENCODER_DISTORTION_PROFILE_ABSOLUTE_PATH_REJECTED, + absolute_path_unresolvable: codes::ENCODER_DISTORTION_PROFILE_ABSOLUTE_PATH_UNRESOLVABLE, + base_dir_unavailable: codes::ENCODER_DISTORTION_PROFILE_BASE_DIR_UNAVAILABLE, + path_escape_rejected: codes::ENCODER_DISTORTION_PROFILE_PATH_ESCAPE_REJECTED, + path_unavailable: codes::ENCODER_DISTORTION_PROFILE_PATH_UNAVAILABLE, + path_missing: None, +}; + +const CALIBRATION_PROFILE_PATH_ISSUES: PathIssueCodes = PathIssueCodes { + absolute_path_rejected: codes::CALIBRATION_PROFILE_ABSOLUTE_PATH_REJECTED, + absolute_path_unresolvable: codes::CALIBRATION_PROFILE_ABSOLUTE_PATH_UNRESOLVABLE, + base_dir_unavailable: codes::CALIBRATION_PROFILE_BASE_DIR_UNAVAILABLE, + path_escape_rejected: codes::CALIBRATION_PROFILE_PATH_ESCAPE_REJECTED, + path_unavailable: codes::CALIBRATION_PROFILE_PATH_UNAVAILABLE, + path_missing: None, +}; + fn resolve_existing_path( path: &Path, base_dir: &Path, options: &VerifyOptions, - context: &str, + issue_codes: &PathIssueCodes, errors: &mut Vec, ) -> Option { - if path.is_absolute() && !options.allow_absolute_paths { + // Policy classification uses the platform-independent manifest rule, not + // `Path::is_absolute`, so an absolute-for-policy path can never dodge the + // `allow_absolute_paths` gate on a platform whose native semantics would + // read it as relative (e.g. `C:/...` or UNC on Unix, `/...` on Windows). + let manifest_absolute = is_manifest_path_absolute(&path.to_string_lossy()); + if manifest_absolute && !options.allow_absolute_paths { errors.push(ReportIssue::new( - format!("{context}_absolute_path_rejected"), + issue_codes.absolute_path_rejected, format!("absolute path {} is rejected by default", path.display()), )); return None; } + // A path classified absolute for policy purposes must never silently + // resolve relative to the manifest base (or vice versa): when the + // manifest classification and this platform's semantics disagree, reject + // outright instead of resolving inconsistently across OSes. + if manifest_absolute != path.is_absolute() { + errors.push(ReportIssue::new( + issue_codes.absolute_path_unresolvable, + format!( + "path {} is classified absolute by manifest policy but cannot resolve as absolute on this platform; refusing to resolve it against the manifest base", + path.display() + ), + )); + return None; + } + let base_canonical = match fs::canonicalize(base_dir) { Ok(path) => path, Err(err) => { errors.push(ReportIssue::new( - format!("{context}_base_dir_unavailable"), + issue_codes.base_dir_unavailable, format!( "failed to canonicalize base_dir {}: {err}", base_dir.display() @@ -2375,7 +2599,7 @@ fn resolve_existing_path( if !path.is_absolute() && !options.allow_path_escape && has_lexical_escape(path) { errors.push(ReportIssue::new( - format!("{context}_path_escape_rejected"), + issue_codes.path_escape_rejected, format!("relative path {} escapes the manifest base", path.display()), )); return None; @@ -2389,8 +2613,16 @@ fn resolve_existing_path( let canonical_path = match fs::canonicalize(&resolved_path) { Ok(path) => path, Err(err) => { + // Distinguish an absent file (NotFound) from other canonicalize + // failures (permission denied, symlink loop, I/O) when this + // context asks for it, so a downstream consumer branching on the + // typed code never reads a permission error as a missing file. + let code = match issue_codes.path_missing { + Some(missing) if err.kind() == io::ErrorKind::NotFound => missing, + _ => issue_codes.path_unavailable, + }; errors.push(ReportIssue::new( - format!("{context}_path_unavailable"), + code, format!("failed to canonicalize {}: {err}", resolved_path.display()), )); return None; @@ -2399,7 +2631,7 @@ fn resolve_existing_path( if !options.allow_path_escape && !canonical_path.starts_with(&base_canonical) { errors.push(ReportIssue::new( - format!("{context}_path_escape_rejected"), + issue_codes.path_escape_rejected, format!( "canonical path {} is outside manifest base {}", canonical_path.display(), @@ -2442,8 +2674,6 @@ fn is_true(value: &bool) -> bool { #[serde(deny_unknown_fields)] pub struct IndexManifest { pub schema_version: String, - pub manifest_id: String, - pub created_at: String, pub artifact: Artifact, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub auxiliary_artifacts: Vec, @@ -2841,10 +3071,10 @@ enum UnsupportedCoreMetadata { impl UnsupportedCoreMetadata { fn code(self) -> &'static str { match self { - Self::Kind(_) => "artifact_kind_unsupported", - Self::Params(_) => "artifact_params_unsupported", - Self::RegistryMissing(_) => "artifact_format_registry_missing", - Self::ManifestNotCovered { .. } => "artifact_manifest_coverage_unsupported", + Self::Kind(_) => codes::ARTIFACT_KIND_UNSUPPORTED, + Self::Params(_) => codes::ARTIFACT_PARAMS_UNSUPPORTED, + Self::RegistryMissing(_) => codes::ARTIFACT_FORMAT_REGISTRY_MISSING, + Self::ManifestNotCovered { .. } => codes::ARTIFACT_MANIFEST_COVERAGE_UNSUPPORTED, } } @@ -3269,7 +3499,6 @@ fn report_issue_summary(errors: &[ReportIssue]) -> String { pub struct VerificationReport { pub ok: bool, pub checked_at: String, - pub manifest_id: Option, pub artifact: ArtifactReport, #[serde(default)] pub auxiliary_artifacts: Vec, @@ -3284,11 +3513,10 @@ pub struct VerificationReport { } impl VerificationReport { - fn new(manifest_id: Option) -> Self { + fn new() -> Self { Self { ok: false, checked_at: Utc::now().to_rfc3339_opts(SecondsFormat::Nanos, true), - manifest_id, artifact: ArtifactReport::default(), auxiliary_artifacts: Vec::new(), row_identity: RowIdentityReport::default(), @@ -3414,10 +3642,338 @@ pub struct AttestationShapeCheck { pub subject_sha256_matched: bool, } +/// Stable machine-readable issue codes. +/// +/// Every code emitted through [`crate::ReportIssue`] (including +/// [`crate::AuxiliaryArtifactReport`] reason codes and +/// [`crate::ManifestError::LimitExceeded`]) is named here so downstream +/// consumers can branch on constants instead of retyping string literals. +pub mod codes { + pub const ARTIFACT_ABSOLUTE_PATH_REJECTED: &str = "artifact_absolute_path_rejected"; + pub const ARTIFACT_ABSOLUTE_PATH_UNRESOLVABLE: &str = "artifact_absolute_path_unresolvable"; + pub const ARTIFACT_BASE_DIR_UNAVAILABLE: &str = "artifact_base_dir_unavailable"; + pub const ARTIFACT_BYTES_PER_VEC_MISMATCH: &str = "artifact_bytes_per_vec_mismatch"; + pub const ARTIFACT_BYTES_PER_VEC_ZERO: &str = "artifact_bytes_per_vec_zero"; + pub const ARTIFACT_DIM_MISMATCH: &str = "artifact_dim_mismatch"; + pub const ARTIFACT_EMBEDDING_DIM_MISMATCH: &str = "artifact_embedding_dim_mismatch"; + pub const ARTIFACT_FILE_SIZE_MISMATCH: &str = "artifact_file_size_mismatch"; + pub const ARTIFACT_FILE_SIZE_ZERO: &str = "artifact_file_size_zero"; + pub const ARTIFACT_FILE_TOO_LARGE: &str = "artifact_file_too_large"; + pub const ARTIFACT_FORMAT_REGISTRY_MISSING: &str = "artifact_format_registry_missing"; + pub const ARTIFACT_FORMAT_VERSION_MISMATCH: &str = "artifact_format_version_mismatch"; + pub const ARTIFACT_HASH_FAILED: &str = "artifact_hash_failed"; + pub const ARTIFACT_KIND_MISMATCH: &str = "artifact_kind_mismatch"; + pub const ARTIFACT_KIND_UNSUPPORTED: &str = "artifact_kind_unsupported"; + pub const ARTIFACT_MANIFEST_COVERAGE_UNSUPPORTED: &str = + "artifact_manifest_coverage_unsupported"; + pub const ARTIFACT_METADATA_FILE_SIZE_MISMATCH: &str = "artifact_metadata_file_size_mismatch"; + pub const ARTIFACT_MISSING: &str = "artifact_missing"; + pub const ARTIFACT_PARAMS_KIND_MISMATCH: &str = "artifact_params_kind_mismatch"; + pub const ARTIFACT_PARAMS_MISMATCH: &str = "artifact_params_mismatch"; + pub const ARTIFACT_PARAMS_UNSUPPORTED: &str = "artifact_params_unsupported"; + pub const ARTIFACT_PATH_EMPTY: &str = "artifact_path_empty"; + pub const ARTIFACT_PATH_ESCAPE_REJECTED: &str = "artifact_path_escape_rejected"; + pub const ARTIFACT_PATH_NOT_CANONICAL: &str = "artifact_path_not_canonical"; + pub const ARTIFACT_PATH_UNAVAILABLE: &str = "artifact_path_unavailable"; + pub const ARTIFACT_PROBE_FAILED: &str = "artifact_probe_failed"; + pub const ARTIFACT_ROW_COUNT_MISMATCH: &str = "artifact_row_count_mismatch"; + pub const ARTIFACT_SHA256_INVALID: &str = "artifact_sha256_invalid"; + pub const ARTIFACT_SHA256_MISMATCH: &str = "artifact_sha256_mismatch"; + pub const ARTIFACT_VECTOR_COUNT_MISMATCH: &str = "artifact_vector_count_mismatch"; + pub const ATTESTATION_PREDICATE_TYPE_MISSING: &str = "attestation_predicate_type_missing"; + pub const ATTESTATION_SUBJECT_SHA256_MISMATCH: &str = "attestation_subject_sha256_mismatch"; + pub const AUXILIARY_ARTIFACT_ABSOLUTE_PATH_REJECTED: &str = + "auxiliary_artifact_absolute_path_rejected"; + pub const AUXILIARY_ARTIFACT_ABSOLUTE_PATH_UNRESOLVABLE: &str = + "auxiliary_artifact_absolute_path_unresolvable"; + pub const AUXILIARY_ARTIFACT_BASE_DIR_UNAVAILABLE: &str = + "auxiliary_artifact_base_dir_unavailable"; + pub const AUXILIARY_ARTIFACT_COUNT_LIMIT_EXCEEDED: &str = + "auxiliary_artifact_count_limit_exceeded"; + pub const AUXILIARY_ARTIFACT_FILE_SIZE_MISMATCH: &str = "auxiliary_artifact_file_size_mismatch"; + pub const AUXILIARY_ARTIFACT_FILE_SIZE_ZERO: &str = "auxiliary_artifact_file_size_zero"; + pub const AUXILIARY_ARTIFACT_FILE_TOO_LARGE: &str = "auxiliary_artifact_file_too_large"; + pub const AUXILIARY_ARTIFACT_HASH_FAILED: &str = "auxiliary_artifact_hash_failed"; + pub const AUXILIARY_ARTIFACT_MISSING_REQUIRED: &str = "auxiliary_artifact_missing_required"; + pub const AUXILIARY_ARTIFACT_NAME_DUPLICATE: &str = "auxiliary_artifact_name_duplicate"; + pub const AUXILIARY_ARTIFACT_NAME_EMPTY: &str = "auxiliary_artifact_name_empty"; + pub const AUXILIARY_ARTIFACT_NAME_NOT_TRIMMED: &str = "auxiliary_artifact_name_not_trimmed"; + pub const AUXILIARY_ARTIFACT_OPTIONAL_ABSENT: &str = "auxiliary_artifact_optional_absent"; + pub const AUXILIARY_ARTIFACT_PATH_EMPTY: &str = "auxiliary_artifact_path_empty"; + pub const AUXILIARY_ARTIFACT_PATH_ESCAPE_REJECTED: &str = + "auxiliary_artifact_path_escape_rejected"; + pub const AUXILIARY_ARTIFACT_PATH_NOT_CANONICAL: &str = "auxiliary_artifact_path_not_canonical"; + pub const AUXILIARY_ARTIFACT_PATH_UNAVAILABLE: &str = "auxiliary_artifact_path_unavailable"; + pub const AUXILIARY_ARTIFACT_SHA256_INVALID: &str = "auxiliary_artifact_sha256_invalid"; + pub const AUXILIARY_ARTIFACT_SHA256_MISMATCH: &str = "auxiliary_artifact_sha256_mismatch"; + pub const BUILD_BUILDER_ID_EMPTY: &str = "build_builder_id_empty"; + pub const BUILD_CI_PROVIDER_EMPTY: &str = "build_ci_provider_empty"; + pub const BUILD_CI_RUN_ID_EMPTY: &str = "build_ci_run_id_empty"; + pub const BUILD_INVOCATION_ID_EMPTY: &str = "build_invocation_id_empty"; + pub const BUILD_SOURCE_COMMIT_EMPTY: &str = "build_source_commit_empty"; + pub const BUILD_SOURCE_REPO_EMPTY: &str = "build_source_repo_empty"; + pub const CALIBRATION_CREATED_AT_INVALID: &str = "calibration_created_at_invalid"; + pub const CALIBRATION_ENCODER_DIM_MISMATCH: &str = "calibration_encoder_dim_mismatch"; + pub const CALIBRATION_ENCODER_DIM_ZERO: &str = "calibration_encoder_dim_zero"; + pub const CALIBRATION_ENCODER_MODEL_EMPTY: &str = "calibration_encoder_model_empty"; + pub const CALIBRATION_ENCODER_MODEL_MISMATCH: &str = "calibration_encoder_model_mismatch"; + pub const CALIBRATION_ENCODER_MODEL_REVISION_EMPTY: &str = + "calibration_encoder_model_revision_empty"; + pub const CALIBRATION_ENCODER_MODEL_REVISION_MISMATCH: &str = + "calibration_encoder_model_revision_mismatch"; + pub const CALIBRATION_ENCODER_NORMALIZATION_EMPTY: &str = + "calibration_encoder_normalization_empty"; + pub const CALIBRATION_ENCODER_NORMALIZATION_MISMATCH: &str = + "calibration_encoder_normalization_mismatch"; + pub const CALIBRATION_NULL_MODEL_ORDINALIZATION_MISMATCH: &str = + "calibration_null_model_ordinalization_mismatch"; + pub const CALIBRATION_NULL_NAME_EMPTY: &str = "calibration_null_name_empty"; + pub const CALIBRATION_NULL_PARAMETERIZATION_EMPTY: &str = + "calibration_null_parameterization_empty"; + pub const CALIBRATION_NULL_PARAMETERIZATION_MISMATCH: &str = + "calibration_null_parameterization_mismatch"; + pub const CALIBRATION_NULL_STATISTIC_EMPTY: &str = "calibration_null_statistic_empty"; + pub const CALIBRATION_ORDINALIZATION_ARTIFACT_MISMATCH: &str = + "calibration_ordinalization_artifact_mismatch"; + pub const CALIBRATION_ORDINALIZATION_DIM_MISMATCH: &str = + "calibration_ordinalization_dim_mismatch"; + pub const CALIBRATION_ORDINALIZATION_DIM_ZERO: &str = "calibration_ordinalization_dim_zero"; + pub const CALIBRATION_PROFILE_ABSOLUTE_PATH_REJECTED: &str = + "calibration_profile_absolute_path_rejected"; + pub const CALIBRATION_PROFILE_ABSOLUTE_PATH_UNRESOLVABLE: &str = + "calibration_profile_absolute_path_unresolvable"; + pub const CALIBRATION_PROFILE_BASE_DIR_UNAVAILABLE: &str = + "calibration_profile_base_dir_unavailable"; + pub const CALIBRATION_PROFILE_DIM_MISMATCH: &str = "calibration_profile_dim_mismatch"; + pub const CALIBRATION_PROFILE_FILE_SIZE_MISMATCH: &str = + "calibration_profile_file_size_mismatch"; + pub const CALIBRATION_PROFILE_FILE_SIZE_ZERO: &str = "calibration_profile_file_size_zero"; + pub const CALIBRATION_PROFILE_FORMAT_EMPTY: &str = "calibration_profile_format_empty"; + pub const CALIBRATION_PROFILE_HASH_FAILED: &str = "calibration_profile_hash_failed"; + pub const CALIBRATION_PROFILE_ID_EMPTY: &str = "calibration_profile_id_empty"; + pub const CALIBRATION_PROFILE_PARAMETERIZATION_ORDINALIZATION_MISMATCH: &str = + "calibration_profile_parameterization_ordinalization_mismatch"; + pub const CALIBRATION_PROFILE_PATH_EMPTY: &str = "calibration_profile_path_empty"; + pub const CALIBRATION_PROFILE_PATH_ESCAPE_REJECTED: &str = + "calibration_profile_path_escape_rejected"; + pub const CALIBRATION_PROFILE_PATH_NOT_CANONICAL: &str = + "calibration_profile_path_not_canonical"; + pub const CALIBRATION_PROFILE_PATH_UNAVAILABLE: &str = "calibration_profile_path_unavailable"; + pub const CALIBRATION_PROFILE_REQUIRED: &str = "calibration_profile_required"; + pub const CALIBRATION_PROFILE_SAMPLE_COUNT_ZERO: &str = "calibration_profile_sample_count_zero"; + pub const CALIBRATION_PROFILE_SHA256_INVALID: &str = "calibration_profile_sha256_invalid"; + pub const CALIBRATION_PROFILE_SHA256_MISMATCH: &str = "calibration_profile_sha256_mismatch"; + pub const CALIBRATION_PROFILE_SHAPE_MISMATCH: &str = "calibration_profile_shape_mismatch"; + pub const CALIBRATION_PROFILE_SOURCE_DIGEST_INVALID: &str = + "calibration_profile_source_digest_invalid"; + pub const CALIBRATION_PROFILE_TOO_LARGE: &str = "calibration_profile_too_large"; + pub const CALIBRATION_PROFILE_UNEXPECTED: &str = "calibration_profile_unexpected"; + pub const CALIBRATION_SCHEMA_VERSION_UNSUPPORTED: &str = + "calibration_schema_version_unsupported"; + pub const EMBEDDING_CORPUS_DIGEST_INVALID: &str = "embedding_corpus_digest_invalid"; + pub const EMBEDDING_DIM_ZERO: &str = "embedding_dim_zero"; + pub const EMBEDDING_MATRIX_DIGEST_INVALID: &str = "embedding_matrix_digest_invalid"; + pub const EMBEDDING_MODEL_EMPTY: &str = "embedding_model_empty"; + pub const EMBEDDING_MODEL_REVISION_EMPTY: &str = "embedding_model_revision_empty"; + pub const EMBEDDING_NORMALIZATION_EMPTY: &str = "embedding_normalization_empty"; + pub const EMBEDDING_POOLING_EMPTY: &str = "embedding_pooling_empty"; + pub const EMBEDDING_TOKENIZER_REVISION_EMPTY: &str = "embedding_tokenizer_revision_empty"; + pub const ENCODER_DISTORTION_BOUNDS_EMPTY: &str = "encoder_distortion_bounds_empty"; + pub const ENCODER_DISTORTION_BOUNDS_ORDER_INVALID: &str = + "encoder_distortion_bounds_order_invalid"; + pub const ENCODER_DISTORTION_CALIBRATION_MISSING: &str = + "encoder_distortion_calibration_missing"; + pub const ENCODER_DISTORTION_CALIBRATION_PROFILE_ID_EMPTY: &str = + "encoder_distortion_calibration_profile_id_empty"; + pub const ENCODER_DISTORTION_CALIBRATION_PROFILE_ID_WHITESPACE: &str = + "encoder_distortion_calibration_profile_id_whitespace"; + pub const ENCODER_DISTORTION_CALIBRATION_PROFILE_MISMATCH: &str = + "encoder_distortion_calibration_profile_mismatch"; + pub const ENCODER_DISTORTION_CREATED_AT_INVALID: &str = "encoder_distortion_created_at_invalid"; + pub const ENCODER_DISTORTION_DISTORTION_MISMATCH: &str = + "encoder_distortion_distortion_mismatch"; + pub const ENCODER_DISTORTION_EMBEDDING_METRIC_DIGEST_INVALID: &str = + "encoder_distortion_embedding_metric_digest_invalid"; + pub const ENCODER_DISTORTION_EMBEDDING_METRIC_NAME_EMPTY: &str = + "encoder_distortion_embedding_metric_name_empty"; + pub const ENCODER_DISTORTION_EMBEDDING_METRIC_VERSION_EMPTY: &str = + "encoder_distortion_embedding_metric_version_empty"; + pub const ENCODER_DISTORTION_ENCODER_DIM_MISMATCH: &str = + "encoder_distortion_encoder_dim_mismatch"; + pub const ENCODER_DISTORTION_ENCODER_DIM_ZERO: &str = "encoder_distortion_encoder_dim_zero"; + pub const ENCODER_DISTORTION_ENCODER_MODEL_EMPTY: &str = + "encoder_distortion_encoder_model_empty"; + pub const ENCODER_DISTORTION_ENCODER_MODEL_MISMATCH: &str = + "encoder_distortion_encoder_model_mismatch"; + pub const ENCODER_DISTORTION_ENCODER_MODEL_REVISION_EMPTY: &str = + "encoder_distortion_encoder_model_revision_empty"; + pub const ENCODER_DISTORTION_ENCODER_MODEL_REVISION_MISMATCH: &str = + "encoder_distortion_encoder_model_revision_mismatch"; + pub const ENCODER_DISTORTION_ENCODER_NORMALIZATION_EMPTY: &str = + "encoder_distortion_encoder_normalization_empty"; + pub const ENCODER_DISTORTION_ENCODER_NORMALIZATION_MISMATCH: &str = + "encoder_distortion_encoder_normalization_mismatch"; + pub const ENCODER_DISTORTION_ESTIMATED_DISTORTION_INVALID: &str = + "encoder_distortion_estimated_distortion_invalid"; + pub const ENCODER_DISTORTION_EVIDENCE_ESTIMATOR_HASH_INVALID: &str = + "encoder_distortion_evidence_estimator_hash_invalid"; + pub const ENCODER_DISTORTION_EVIDENCE_ESTIMATOR_ID_EMPTY: &str = + "encoder_distortion_evidence_estimator_id_empty"; + pub const ENCODER_DISTORTION_LOWER_BOUND_INVALID: &str = + "encoder_distortion_lower_bound_invalid"; + pub const ENCODER_DISTORTION_MAX_OBSERVED_VIOLATION_INVALID: &str = + "encoder_distortion_max_observed_violation_invalid"; + pub const ENCODER_DISTORTION_POOLING_EMPTY: &str = "encoder_distortion_pooling_empty"; + pub const ENCODER_DISTORTION_POOLING_MISMATCH: &str = "encoder_distortion_pooling_mismatch"; + pub const ENCODER_DISTORTION_PROFILE_ABSOLUTE_PATH_REJECTED: &str = + "encoder_distortion_profile_absolute_path_rejected"; + pub const ENCODER_DISTORTION_PROFILE_ABSOLUTE_PATH_UNRESOLVABLE: &str = + "encoder_distortion_profile_absolute_path_unresolvable"; + pub const ENCODER_DISTORTION_PROFILE_BASE_DIR_UNAVAILABLE: &str = + "encoder_distortion_profile_base_dir_unavailable"; + pub const ENCODER_DISTORTION_PROFILE_FILE_SIZE_MISMATCH: &str = + "encoder_distortion_profile_file_size_mismatch"; + pub const ENCODER_DISTORTION_PROFILE_FILE_SIZE_ZERO: &str = + "encoder_distortion_profile_file_size_zero"; + pub const ENCODER_DISTORTION_PROFILE_FORMAT_EMPTY: &str = + "encoder_distortion_profile_format_empty"; + pub const ENCODER_DISTORTION_PROFILE_HASH_FAILED: &str = + "encoder_distortion_profile_hash_failed"; + pub const ENCODER_DISTORTION_PROFILE_ID_EMPTY: &str = "encoder_distortion_profile_id_empty"; + pub const ENCODER_DISTORTION_PROFILE_PATH_EMPTY: &str = "encoder_distortion_profile_path_empty"; + pub const ENCODER_DISTORTION_PROFILE_PATH_ESCAPE_REJECTED: &str = + "encoder_distortion_profile_path_escape_rejected"; + pub const ENCODER_DISTORTION_PROFILE_PATH_NOT_CANONICAL: &str = + "encoder_distortion_profile_path_not_canonical"; + pub const ENCODER_DISTORTION_PROFILE_PATH_UNAVAILABLE: &str = + "encoder_distortion_profile_path_unavailable"; + pub const ENCODER_DISTORTION_PROFILE_REQUIRED: &str = "encoder_distortion_profile_required"; + pub const ENCODER_DISTORTION_PROFILE_SHA256_INVALID: &str = + "encoder_distortion_profile_sha256_invalid"; + pub const ENCODER_DISTORTION_PROFILE_SHA256_MISMATCH: &str = + "encoder_distortion_profile_sha256_mismatch"; + pub const ENCODER_DISTORTION_PROFILE_SOURCE_DIGEST_INVALID: &str = + "encoder_distortion_profile_source_digest_invalid"; + pub const ENCODER_DISTORTION_PROFILE_TOO_LARGE: &str = "encoder_distortion_profile_too_large"; + pub const ENCODER_DISTORTION_QUANTILE_OBSERVED_VIOLATION_INVALID: &str = + "encoder_distortion_quantile_observed_violation_invalid"; + pub const ENCODER_DISTORTION_SCHEMA_VERSION_UNSUPPORTED: &str = + "encoder_distortion_schema_version_unsupported"; + pub const ENCODER_DISTORTION_SCOPE_CONFIDENCE_INVALID: &str = + "encoder_distortion_scope_confidence_invalid"; + pub const ENCODER_DISTORTION_SCOPE_CORPUS_DIGEST_INVALID: &str = + "encoder_distortion_scope_corpus_digest_invalid"; + pub const ENCODER_DISTORTION_SCOPE_COVERAGE_INVALID: &str = + "encoder_distortion_scope_coverage_invalid"; + pub const ENCODER_DISTORTION_SCOPE_DOMAIN_EMPTY: &str = "encoder_distortion_scope_domain_empty"; + pub const ENCODER_DISTORTION_SCOPE_ESTIMATOR_VERSION_EMPTY: &str = + "encoder_distortion_scope_estimator_version_empty"; + pub const ENCODER_DISTORTION_SCOPE_PAIR_SAMPLE_DIGEST_INVALID: &str = + "encoder_distortion_scope_pair_sample_digest_invalid"; + pub const ENCODER_DISTORTION_SCOPE_QUERY_SET_DIGEST_INVALID: &str = + "encoder_distortion_scope_query_set_digest_invalid"; + pub const ENCODER_DISTORTION_SCOPE_SAMPLE_SIZE_ZERO: &str = + "encoder_distortion_scope_sample_size_zero"; + pub const ENCODER_DISTORTION_SOURCE_METRIC_DIGEST_INVALID: &str = + "encoder_distortion_source_metric_digest_invalid"; + pub const ENCODER_DISTORTION_SOURCE_METRIC_NAME_EMPTY: &str = + "encoder_distortion_source_metric_name_empty"; + pub const ENCODER_DISTORTION_SOURCE_METRIC_VERSION_EMPTY: &str = + "encoder_distortion_source_metric_version_empty"; + pub const ENCODER_DISTORTION_TOKENIZER_REVISION_EMPTY: &str = + "encoder_distortion_tokenizer_revision_empty"; + pub const ENCODER_DISTORTION_TOKENIZER_REVISION_MISMATCH: &str = + "encoder_distortion_tokenizer_revision_mismatch"; + pub const ENCODER_DISTORTION_UPPER_BOUND_INVALID: &str = + "encoder_distortion_upper_bound_invalid"; + pub const ENCODER_DISTORTION_VIOLATION_RATE_INVALID: &str = + "encoder_distortion_violation_rate_invalid"; + pub const EXTENSION_KEY_NOT_NAMESPACED: &str = "extension_key_not_namespaced"; + pub const MANIFEST_FILE_TOO_LARGE: &str = "manifest_file_too_large"; + pub const ROW_IDENTITY_ABSOLUTE_PATH_REJECTED: &str = "row_identity_absolute_path_rejected"; + pub const ROW_IDENTITY_ABSOLUTE_PATH_UNRESOLVABLE: &str = + "row_identity_absolute_path_unresolvable"; + pub const ROW_IDENTITY_BASE_DIR_UNAVAILABLE: &str = "row_identity_base_dir_unavailable"; + pub const ROW_IDENTITY_DB_ID_CONTAINS_NUL: &str = "row_identity_db_id_contains_nul"; + pub const ROW_IDENTITY_DB_ID_EMPTY: &str = "row_identity_db_id_empty"; + pub const ROW_IDENTITY_DB_ID_INVALID_UUID: &str = "row_identity_db_id_invalid_uuid"; + pub const ROW_IDENTITY_DB_UNSUPPORTED: &str = "row_identity_db_unsupported"; + pub const ROW_IDENTITY_DUPLICATE_DB_ID: &str = "row_identity_duplicate_db_id"; + pub const ROW_IDENTITY_DUPLICATE_TRACKING_LIMIT_EXCEEDED: &str = + "row_identity_duplicate_tracking_limit_exceeded"; + pub const ROW_IDENTITY_ID_KIND_UNSUPPORTED: &str = "row_identity_id_kind_unsupported"; + pub const ROW_IDENTITY_JSONL_INVALID_JSON: &str = "row_identity_jsonl_invalid_json"; + pub const ROW_IDENTITY_LINE_TOO_LARGE: &str = "row_identity_line_too_large"; + pub const ROW_IDENTITY_MISSING: &str = "row_identity_missing"; + pub const ROW_IDENTITY_PARENT_ID_CONTAINS_NUL: &str = "row_identity_parent_id_contains_nul"; + pub const ROW_IDENTITY_PARENT_ID_EMPTY: &str = "row_identity_parent_id_empty"; + pub const ROW_IDENTITY_PARENT_ID_INVALID_UUID: &str = "row_identity_parent_id_invalid_uuid"; + pub const ROW_IDENTITY_PATH_EMPTY: &str = "row_identity_path_empty"; + pub const ROW_IDENTITY_PATH_ESCAPE_REJECTED: &str = "row_identity_path_escape_rejected"; + pub const ROW_IDENTITY_PATH_NOT_CANONICAL: &str = "row_identity_path_not_canonical"; + pub const ROW_IDENTITY_PATH_UNAVAILABLE: &str = "row_identity_path_unavailable"; + pub const ROW_IDENTITY_READ_FAILED: &str = "row_identity_read_failed"; + pub const ROW_IDENTITY_ROW_COUNT_LIMIT_EXCEEDED: &str = "row_identity_row_count_limit_exceeded"; + pub const ROW_IDENTITY_ROW_COUNT_MISMATCH: &str = "row_identity_row_count_mismatch"; + pub const ROW_IDENTITY_ROW_ID_MISMATCH: &str = "row_identity_row_id_mismatch"; + pub const ROW_IDENTITY_SHA256_INVALID: &str = "row_identity_sha256_invalid"; + pub const ROW_IDENTITY_SHA256_MISMATCH: &str = "row_identity_sha256_mismatch"; + pub const SCHEMA_VERSION_UNSUPPORTED: &str = "schema_version_unsupported"; + pub const SQLITE_ACTIVATION_FORCED: &str = "sqlite_activation_forced"; + pub const SQLITE_CACHED_REPORT_TOO_LARGE: &str = "sqlite_cached_report_too_large"; + pub const VERIFICATION_REPORT_ISSUE_LIMIT_EXCEEDED: &str = + "verification_report_issue_limit_exceeded"; +} + +/// Typed classification of [`ReportIssue::code`] values so downstream +/// security code can branch on enum variants instead of string compares. +/// +/// The integrity-mismatch, missing-mandatory-file, schema-version, and +/// resource-limit code families are classified into typed variants. Every +/// other code maps to [`VerificationCode::Unknown`] — including the +/// path-policy rejections (absolute / escape / non-canonical) and the +/// diagnostic I/O failures (`*_path_unavailable`, `*_hash_failed`), which are +/// deliberately not distinguished as typed variants. The enum is +/// [`#[non_exhaustive]`](https://doc.rust-lang.org/reference/attributes/type_system.html), +/// so treat `Unknown` as the required catch-all. Manifest parse failures never +/// reach a report (they surface as [`ManifestError`]), so the schema family +/// covers only the in-report [`codes::SCHEMA_VERSION_UNSUPPORTED`] check. +#[non_exhaustive] +#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)] +pub enum VerificationCode { + ArtifactSha256Mismatch, + ArtifactFileSizeMismatch, + ArtifactMissing, + AuxiliarySha256Mismatch, + AuxiliaryFileSizeMismatch, + AuxiliaryMissingRequired, + RowIdentitySha256Mismatch, + RowIdentityRowCountMismatch, + RowIdentityMissing, + ManifestSchema, + ResourceLimit, + Unknown, +} + #[derive(Clone, Debug, Serialize, Deserialize)] pub struct ReportIssue { pub code: String, pub message: String, + /// Auxiliary artifact name the issue refers to, when one applies. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub artifact_name: Option, + /// Manifest-declared SHA-256 for mismatch issues. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub expected_sha256: Option, + /// Observed SHA-256 for mismatch issues. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub actual_sha256: Option, + /// Manifest-declared byte size for mismatch issues. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub expected_size_bytes: Option, + /// Observed byte size for mismatch issues. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub actual_size_bytes: Option, } impl ReportIssue { @@ -3425,6 +3981,64 @@ impl ReportIssue { Self { code: code.into(), message: message.into(), + artifact_name: None, + expected_sha256: None, + actual_sha256: None, + expected_size_bytes: None, + actual_size_bytes: None, + } + } + + pub fn with_artifact_name(mut self, name: impl Into) -> Self { + self.artifact_name = Some(name.into()); + self + } + + pub fn with_sha256_detail( + mut self, + expected: impl Into, + actual: impl Into, + ) -> Self { + self.expected_sha256 = Some(expected.into()); + self.actual_sha256 = Some(actual.into()); + self + } + + pub fn with_size_detail(mut self, expected: u64, actual: u64) -> Self { + self.expected_size_bytes = Some(expected); + self.actual_size_bytes = Some(actual); + self + } + + /// Maps this issue's code onto the typed [`VerificationCode`] families. + pub fn classification(&self) -> VerificationCode { + match self.code.as_str() { + codes::ARTIFACT_SHA256_MISMATCH => VerificationCode::ArtifactSha256Mismatch, + codes::ARTIFACT_FILE_SIZE_MISMATCH => VerificationCode::ArtifactFileSizeMismatch, + codes::ARTIFACT_MISSING => VerificationCode::ArtifactMissing, + codes::AUXILIARY_ARTIFACT_SHA256_MISMATCH => VerificationCode::AuxiliarySha256Mismatch, + codes::AUXILIARY_ARTIFACT_FILE_SIZE_MISMATCH => { + VerificationCode::AuxiliaryFileSizeMismatch + } + codes::AUXILIARY_ARTIFACT_MISSING_REQUIRED => { + VerificationCode::AuxiliaryMissingRequired + } + codes::ROW_IDENTITY_SHA256_MISMATCH => VerificationCode::RowIdentitySha256Mismatch, + codes::ROW_IDENTITY_ROW_COUNT_MISMATCH => VerificationCode::RowIdentityRowCountMismatch, + codes::ROW_IDENTITY_MISSING => VerificationCode::RowIdentityMissing, + codes::SCHEMA_VERSION_UNSUPPORTED => VerificationCode::ManifestSchema, + codes::MANIFEST_FILE_TOO_LARGE + | codes::ARTIFACT_FILE_TOO_LARGE + | codes::AUXILIARY_ARTIFACT_FILE_TOO_LARGE + | codes::AUXILIARY_ARTIFACT_COUNT_LIMIT_EXCEEDED + | codes::CALIBRATION_PROFILE_TOO_LARGE + | codes::ENCODER_DISTORTION_PROFILE_TOO_LARGE + | codes::ROW_IDENTITY_LINE_TOO_LARGE + | codes::ROW_IDENTITY_ROW_COUNT_LIMIT_EXCEEDED + | codes::ROW_IDENTITY_DUPLICATE_TRACKING_LIMIT_EXCEEDED + | codes::SQLITE_CACHED_REPORT_TOO_LARGE + | codes::VERIFICATION_REPORT_ISSUE_LIMIT_EXCEEDED => VerificationCode::ResourceLimit, + _ => VerificationCode::Unknown, } } } @@ -3442,14 +4056,14 @@ fn push_report_issue_bounded( } if errors .iter() - .any(|issue| issue.code == "verification_report_issue_limit_exceeded") + .any(|issue| issue.code == codes::VERIFICATION_REPORT_ISSUE_LIMIT_EXCEEDED) { return; } let detail_limit = limit.saturating_sub(1); errors.truncate(detail_limit); errors.push(ReportIssue::new( - "verification_report_issue_limit_exceeded", + codes::VERIFICATION_REPORT_ISSUE_LIMIT_EXCEEDED, format!("verification report issue count exceeded max_report_issues={limit}"), )); } @@ -3459,11 +4073,11 @@ fn enforce_report_issue_limit(errors: &mut Vec, limits: &ResourceLi if errors.len() <= limit { return; } - errors.retain(|issue| issue.code != "verification_report_issue_limit_exceeded"); + errors.retain(|issue| issue.code != codes::VERIFICATION_REPORT_ISSUE_LIMIT_EXCEEDED); let detail_limit = limit.saturating_sub(1); errors.truncate(detail_limit); errors.push(ReportIssue::new( - "verification_report_issue_limit_exceeded", + codes::VERIFICATION_REPORT_ISSUE_LIMIT_EXCEEDED, format!("verification report issue count exceeded max_report_issues={limit}"), )); } @@ -3497,27 +4111,33 @@ pub fn sha256_file(path: impl AsRef) -> io::Result { }) } -pub fn sha256_file_bounded( - path: impl AsRef, - max_bytes: u64, - code: &'static str, - context: &'static str, -) -> Result { - let path = path.as_ref(); - // Refuse non-regular files BEFORE opening: opening a FIFO read-only - // blocks until a writer connects, and a device node would stream - // forever under a large declared-size bound. Regular files terminate - // at EOF and are post-checked against the declaration. (A path swapped - // to a special file after this check is local-actor mutation, out of - // scope per the threat model.) - let metadata = fs::metadata(path)?; - if !metadata.is_file() { - return Err(ManifestError::limit_exceeded( - code, - format!("{context} is not a regular file: {}", path.display()), - )); +/// Hashes an in-memory byte slice with the same digest form as [`sha256_file`]. +pub fn sha256_bytes(bytes: &[u8]) -> FileHash { + let mut hasher = Sha256::new(); + hasher.update(bytes); + FileHash { + sha256: hex::encode(hasher.finalize()), + size_bytes: bytes.len() as u64, } - let mut file = File::open(path)?; +} + +/// Hashes a reader, refusing inputs larger than `max_bytes`. +/// +/// Exceeding the bound fails with [`io::ErrorKind::InvalidData`]; inputs of +/// exactly `max_bytes` succeed. +pub fn sha256_reader(mut reader: R, max_bytes: u64) -> io::Result { + match sha256_read_bounded(&mut reader, max_bytes)? { + Some(hash) => Ok(hash), + None => Err(io::Error::new( + io::ErrorKind::InvalidData, + format!("input exceeds {max_bytes} bytes"), + )), + } +} + +/// Bounded hashing core shared by [`sha256_file_bounded`] and +/// [`sha256_reader`]. Returns `Ok(None)` when the input exceeds `max_bytes`. +fn sha256_read_bounded(reader: &mut R, max_bytes: u64) -> io::Result> { let mut hasher = Sha256::new(); let mut size_bytes = 0u64; let mut buf = [0u8; 64 * 1024]; @@ -3529,30 +4149,57 @@ pub fn sha256_file_bounded( break; } let want = allowance.min(buf.len() as u64) as usize; - let n = match file.read(&mut buf[..want]) { + let n = match reader.read(&mut buf[..want]) { Ok(n) => n, Err(err) if err.kind() == io::ErrorKind::Interrupted => continue, - Err(err) => return Err(err.into()), + Err(err) => return Err(err), }; if n == 0 { break; } size_bytes += n as u64; if size_bytes > max_bytes { - return Err(ManifestError::limit_exceeded( - code, - format!( - "{context} exceeds {max_bytes} bytes while reading {}", - path.display() - ), - )); + return Ok(None); } hasher.update(&buf[..n]); } - Ok(FileHash { + Ok(Some(FileHash { sha256: hex::encode(hasher.finalize()), size_bytes, - }) + })) +} + +pub fn sha256_file_bounded( + path: impl AsRef, + max_bytes: u64, + code: &'static str, + context: &'static str, +) -> Result { + let path = path.as_ref(); + // Refuse non-regular files BEFORE opening: opening a FIFO read-only + // blocks until a writer connects, and a device node would stream + // forever under a large declared-size bound. Regular files terminate + // at EOF and are post-checked against the declaration. (A path swapped + // to a special file after this check is local-actor mutation, out of + // scope per the threat model.) + let metadata = fs::metadata(path)?; + if !metadata.is_file() { + return Err(ManifestError::limit_exceeded( + code, + format!("{context} is not a regular file: {}", path.display()), + )); + } + let mut file = File::open(path)?; + match sha256_read_bounded(&mut file, max_bytes)? { + Some(hash) => Ok(hash), + None => Err(ManifestError::limit_exceeded( + code, + format!( + "{context} exceeds {max_bytes} bytes while reading {}", + path.display() + ), + )), + } } #[derive(Clone, Debug)] @@ -3613,7 +4260,7 @@ pub fn create_manifest_for_index_with_options( metadata .file_size_bytes .min(options.limits.max_index_artifact_bytes), - "artifact_file_too_large", + codes::ARTIFACT_FILE_TOO_LARGE, "index artifact", )?; // One consistent snapshot: the manifest records the byte count that was @@ -3696,11 +4343,8 @@ pub fn create_manifest_for_index_with_options( let auxiliary_artifacts = create_auxiliary_artifacts(&options.auxiliary_artifacts, out_base, &options)?; - let invocation_id = format!("urn:uuid:{}", Uuid::new_v4()); Ok(IndexManifest { schema_version: SCHEMA_VERSION.to_string(), - manifest_id: format!("urn:uuid:{}", Uuid::new_v4()), - created_at: Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true), artifact, auxiliary_artifacts, embedding: Embedding { @@ -3716,14 +4360,7 @@ pub fn create_manifest_for_index_with_options( encoder_distortion: None, calibration: None, row_identity, - build: Some(BuildInfo { - invocation_id, - builder_id: Some("ordvec-manifest".to_string()), - source_repo: None, - source_commit: None, - ci_provider: None, - ci_run_id: None, - }), + build: None, attestations: Vec::new(), extensions: BTreeMap::new(), }) @@ -3737,7 +4374,7 @@ fn create_auxiliary_artifacts( let count = artifacts.len(); if count > options.limits.max_auxiliary_artifacts { return Err(ManifestError::limit_exceeded( - "auxiliary_artifact_count_limit_exceeded", + codes::AUXILIARY_ARTIFACT_COUNT_LIMIT_EXCEEDED, format!( "auxiliary_artifacts has {count} entries, exceeding max_auxiliary_artifacts={}", options.limits.max_auxiliary_artifacts @@ -3768,7 +4405,7 @@ fn create_auxiliary_artifacts( let hash = sha256_file_bounded( &artifact.path, observed_len.min(options.limits.max_auxiliary_artifact_bytes), - "auxiliary_artifact_file_too_large", + codes::AUXILIARY_ARTIFACT_FILE_TOO_LARGE, "auxiliary artifact", )?; manifest_artifacts.push(AuxiliaryArtifact { @@ -3784,9 +4421,28 @@ fn create_auxiliary_artifacts( required: artifact.required, }); } + // Deterministic manifest bytes: entry order must not depend on + // declaration order. + manifest_artifacts.sort_by(|a, b| { + (a.name.as_str(), a.path.as_str()).cmp(&(b.name.as_str(), b.path.as_str())) + }); Ok(manifest_artifacts) } +/// Writes the manifest in its single canonical serialization: serde_json +/// pretty-printing, struct-declaration field order, BTreeMap-sorted map keys. +/// Content hashing and signing operate on the stored bytes, so changing the +/// serializer or its settings changes every manifest's identity and is a +/// schema-version event, not a cosmetic change. +/// +/// The canonical bytes depend on `serde_json`'s default feature set. Nested +/// [`serde_json::Value`] maps carried in `extensions` / `attestations` are +/// key-sorted only while `serde_json` is built without `preserve_order`; a +/// consumer whose dependency graph enables `serde_json/preserve_order` (or +/// `arbitrary_precision`) via feature unification would serialize those maps +/// in insertion order, changing the content address. Do not enable those +/// features in a build that produces content-addressed manifests. See +/// . pub fn write_manifest_file( manifest: &IndexManifest, path: impl AsRef, @@ -3842,7 +4498,7 @@ fn validate_jsonl_rows( push_report_issue_bounded( errors, limits, - "row_identity_row_count_limit_exceeded", + codes::ROW_IDENTITY_ROW_COUNT_LIMIT_EXCEEDED, format!( "row identity file has more than max_row_identity_rows={} rows", limits.max_row_identity_rows @@ -3856,7 +4512,7 @@ fn validate_jsonl_rows( push_report_issue_bounded( errors, limits, - "row_identity_row_count_mismatch", + codes::ROW_IDENTITY_ROW_COUNT_MISMATCH, format!( "row identity file has more than declared row_count={expected_row_count}" ), @@ -3869,7 +4525,7 @@ fn validate_jsonl_rows( push_report_issue_bounded( errors, limits, - "row_identity_line_too_large", + codes::ROW_IDENTITY_LINE_TOO_LARGE, format!( "line {line_idx} exceeds max_row_identity_jsonl_line_bytes={}", limits.max_row_identity_jsonl_line_bytes @@ -3884,7 +4540,7 @@ fn validate_jsonl_rows( push_report_issue_bounded( errors, limits, - "row_identity_jsonl_invalid_json", + codes::ROW_IDENTITY_JSONL_INVALID_JSON, format!("line {line_idx} is not a strict row object: {err}"), ); continue; @@ -3894,13 +4550,20 @@ fn validate_jsonl_rows( push_report_issue_bounded( errors, limits, - "row_identity_row_id_mismatch", + codes::ROW_IDENTITY_ROW_ID_MISMATCH, format!("line {line_idx} has row_id {}", row.row_id), ); } - validate_row_id_string("db_id", &row.db_id, line_idx, limits, errors); + validate_row_id_string("db_id", &DB_ID_ISSUES, &row.db_id, line_idx, limits, errors); if let Some(parent_id) = &row.parent_id { - validate_row_id_string("parent_id", parent_id, line_idx, limits, errors); + validate_row_id_string( + "parent_id", + &PARENT_ID_ISSUES, + parent_id, + line_idx, + limits, + errors, + ); } validated_rows += 1; if !allow_duplicate_db_ids { @@ -3908,7 +4571,7 @@ fn validate_jsonl_rows( push_report_issue_bounded( errors, limits, - "row_identity_duplicate_db_id", + codes::ROW_IDENTITY_DUPLICATE_DB_ID, format!("line {line_idx} repeats db_id"), ); } else { @@ -3918,7 +4581,7 @@ fn validate_jsonl_rows( push_report_issue_bounded( errors, limits, - "row_identity_duplicate_tracking_limit_exceeded", + codes::ROW_IDENTITY_DUPLICATE_TRACKING_LIMIT_EXCEEDED, format!( "tracked db_id bytes exceed max_row_identity_tracked_db_id_bytes={}", limits.max_row_identity_tracked_db_id_bytes @@ -3990,8 +4653,29 @@ fn trim_jsonl_terminator(line: &mut Vec) { } } +/// Per-field issue codes for [`validate_row_id_string`], so every emitted +/// code stays a named constant in [`codes`]. +struct RowIdIssueCodes { + empty: &'static str, + contains_nul: &'static str, + invalid_uuid: &'static str, +} + +const DB_ID_ISSUES: RowIdIssueCodes = RowIdIssueCodes { + empty: codes::ROW_IDENTITY_DB_ID_EMPTY, + contains_nul: codes::ROW_IDENTITY_DB_ID_CONTAINS_NUL, + invalid_uuid: codes::ROW_IDENTITY_DB_ID_INVALID_UUID, +}; + +const PARENT_ID_ISSUES: RowIdIssueCodes = RowIdIssueCodes { + empty: codes::ROW_IDENTITY_PARENT_ID_EMPTY, + contains_nul: codes::ROW_IDENTITY_PARENT_ID_CONTAINS_NUL, + invalid_uuid: codes::ROW_IDENTITY_PARENT_ID_INVALID_UUID, +}; + fn validate_row_id_string( field: &str, + issue_codes: &RowIdIssueCodes, value: &str, line_idx: usize, limits: &ResourceLimits, @@ -4003,7 +4687,7 @@ fn validate_row_id_string( push_report_issue_bounded( errors, limits, - format!("row_identity_{field}_empty"), + issue_codes.empty, format!("line {line_idx} has empty {field}"), ); } @@ -4012,7 +4696,7 @@ fn validate_row_id_string( push_report_issue_bounded( errors, limits, - format!("row_identity_{field}_contains_nul"), + issue_codes.contains_nul, format!("line {line_idx} {field} contains NUL"), ); } @@ -4020,7 +4704,7 @@ fn validate_row_id_string( push_report_issue_bounded( errors, limits, - format!("row_identity_{field}_invalid_uuid"), + issue_codes.invalid_uuid, format!("line {line_idx} {field} must be a UUID in v1"), ); } @@ -4029,12 +4713,12 @@ fn validate_row_id_string( fn is_limit_issue_code(code: &str) -> bool { matches!( code, - "row_identity_line_too_large" - | "row_identity_row_count_limit_exceeded" - | "row_identity_duplicate_tracking_limit_exceeded" - | "calibration_profile_too_large" - | "encoder_distortion_profile_too_large" - | "verification_report_issue_limit_exceeded" + codes::ROW_IDENTITY_LINE_TOO_LARGE + | codes::ROW_IDENTITY_ROW_COUNT_LIMIT_EXCEEDED + | codes::ROW_IDENTITY_DUPLICATE_TRACKING_LIMIT_EXCEEDED + | codes::CALIBRATION_PROFILE_TOO_LARGE + | codes::ENCODER_DISTORTION_PROFILE_TOO_LARGE + | codes::VERIFICATION_REPORT_ISSUE_LIMIT_EXCEEDED ) } @@ -4046,34 +4730,40 @@ fn manifest_path_for_create( ) -> Result { let canonical_path = fs::canonicalize(path)?; let canonical_base = fs::canonicalize(base_dir)?; - if let Ok(relative) = canonical_path.strip_prefix(&canonical_base) { - if !relative.as_os_str().is_empty() { - return Ok(path_to_manifest_string(relative)); + let value = if let Ok(relative) = canonical_path.strip_prefix(&canonical_base) { + if relative.as_os_str().is_empty() { + ".".to_string() + } else { + path_to_manifest_string(relative) } - return Ok(".".to_string()); - } - - if !options.allow_path_escape { + } else if !options.allow_path_escape { return Err(ManifestError::invalid(format!( "{context} path {} is outside manifest directory {}; use --allow-path-escape to create a manifest that requires non-default verification policy", canonical_path.display(), canonical_base.display() ))); - } - - if let Some(relative) = relative_path_between(&canonical_base, &canonical_path) { - return Ok(path_to_manifest_string(&relative)); - } + } else if let Some(relative) = relative_path_between(&canonical_base, &canonical_path) { + path_to_manifest_string(&relative) + } else if options.allow_absolute_paths { + path_to_manifest_string(&canonical_path) + } else { + return Err(ManifestError::invalid(format!( + "{context} path {} cannot be expressed relative to manifest directory {}; use --allow-absolute-paths with --allow-path-escape", + canonical_path.display(), + canonical_base.display() + ))); + }; - if options.allow_absolute_paths { - return Ok(path_to_manifest_string(&canonical_path)); + // Never embed a path the manifest's own validation would reject: what + // create writes, verify (under the same policy flags) must accept. + if !is_manifest_path_absolute(&value) + && !is_canonical_manifest_path(&value, options.allow_path_escape) + { + return Err(ManifestError::invalid(format!( + "{context} path {value:?} cannot be embedded canonically (bundle-relative, forward slashes, no `.`, `..`, or empty segments); rename the file or move it into the manifest directory" + ))); } - - Err(ManifestError::invalid(format!( - "{context} path {} cannot be expressed relative to manifest directory {}; use --allow-absolute-paths with --allow-path-escape", - canonical_path.display(), - canonical_base.display() - ))) + Ok(value) } fn relative_path_between(base: &Path, target: &Path) -> Option { @@ -4108,9 +4798,54 @@ fn relative_path_between(base: &Path, target: &Path) -> Option { Some(relative) } +/// A canonical manifest path is bundle-relative, uses forward slashes only, +/// and contains no `.`, `..`, or empty segments, so identical bundle content +/// always embeds identical path strings. `..` segments are only accepted +/// under `allow_path_escape`; absolute paths are excluded before this check +/// and remain governed by the `allow_absolute_paths` policy at resolution. +fn is_canonical_manifest_path(path: &str, allow_path_escape: bool) -> bool { + if path.is_empty() || path.contains('\\') { + return false; + } + path.split('/').all(|segment| { + !segment.is_empty() && segment != "." && (segment != ".." || allow_path_escape) + }) +} + +/// Detects absolute manifest path strings on any platform: POSIX (`/...`), +/// Windows drive (`C:/...` or `C:\...`), and UNC/verbatim (`\\...`, `//...`). +/// A single leading backslash is NOT absolute: on Unix it is an ordinary +/// file-name byte, so treating it as absolute here while resolution treats +/// it as relative would let `\evil` skip the canonical-form check and still +/// resolve inside the bundle. It stays non-absolute and is rejected by the +/// canonical-form check instead (backslashes are never canonical). The +/// canonical-form check skips absolute paths; the `allow_absolute_paths` +/// policy at path resolution accepts or rejects them. +fn is_manifest_path_absolute(path: &str) -> bool { + if path.starts_with('/') || path.starts_with(r"\\") { + return true; + } + let bytes = path.as_bytes(); + bytes.len() >= 3 + && bytes[0].is_ascii_alphabetic() + && bytes[1] == b':' + && (bytes[2] == b'/' || bytes[2] == b'\\') +} + fn path_to_manifest_string(path: &Path) -> String { if path.is_absolute() { - return path.display().to_string().replace('\\', "/"); + let display = path.display().to_string(); + // fs::canonicalize returns verbatim paths on Windows; strip the + // verbatim prefix so the embedded string round-trips through + // PathBuf::from at verification time. + let display = if let Some(rest) = display.strip_prefix(r"\\?\UNC\") { + format!(r"\\{rest}") + } else if let Some(rest) = display.strip_prefix(r"\\?\") { + rest.to_string() + } else { + display + }; + return display.replace('\\', "/"); } let parts = path .components() @@ -4176,6 +4911,39 @@ fn hex_digest_eq(a: &str, b: &str) -> bool { mod tests { use super::*; + #[test] + fn canonical_manifest_path_form_is_policy_aware() { + assert!(is_canonical_manifest_path("index.ovrq", false)); + assert!(is_canonical_manifest_path("sub/dir/index.ovrq", false)); + assert!(!is_canonical_manifest_path("", false)); + assert!(!is_canonical_manifest_path("./index.ovrq", false)); + assert!(!is_canonical_manifest_path("a//b", false)); + assert!(!is_canonical_manifest_path("back\\slash.bin", false)); + assert!(!is_canonical_manifest_path("a/../index.ovrq", false)); + assert!(!is_canonical_manifest_path("../index.ovrq", false)); + assert!(is_canonical_manifest_path("a/../index.ovrq", true)); + assert!(is_canonical_manifest_path("../index.ovrq", true)); + assert!(!is_canonical_manifest_path("back\\slash.bin", true)); + } + + #[test] + fn absolute_manifest_path_detection_covers_all_platform_forms() { + assert!(is_manifest_path_absolute("/srv/index.ovrq")); + assert!(is_manifest_path_absolute("C:/bundles/index.ovrq")); + assert!(is_manifest_path_absolute("C:\\bundles\\index.ovrq")); + assert!(is_manifest_path_absolute("\\\\server\\share\\index.ovrq")); + assert!(is_manifest_path_absolute("//?/C:/bundles/index.ovrq")); + assert!(is_manifest_path_absolute("\\\\?\\C:\\bundles\\index.ovrq")); + assert!(!is_manifest_path_absolute("index.ovrq")); + assert!(!is_manifest_path_absolute("sub/index.ovrq")); + assert!(!is_manifest_path_absolute("C:")); + // A single leading backslash is not absolute: it must fall through to + // the canonical-form check (which rejects backslashes) instead of + // skipping it and then resolving relative on Unix. + assert!(!is_manifest_path_absolute("\\evil")); + assert!(!is_manifest_path_absolute("\\")); + } + #[test] fn manifest_kind_conversion_uses_format_registry_coverage() { for spec in FORMATS { diff --git a/ordvec-manifest/src/main.rs b/ordvec-manifest/src/main.rs index 02df85c..f4188e4 100644 --- a/ordvec-manifest/src/main.rs +++ b/ordvec-manifest/src/main.rs @@ -304,7 +304,6 @@ fn run() -> Result { if as_json { print_json(&document.manifest)?; } else { - println!("manifest_id: {}", document.manifest.manifest_id); println!("schema_version: {}", document.manifest.schema_version); println!("artifact: {}", document.manifest.artifact.path); println!( @@ -486,13 +485,7 @@ fn emit_report( if as_json { print_json(report)?; } else if report.ok { - println!( - "verified {}", - report - .manifest_id - .as_deref() - .unwrap_or("") - ); + println!("verified"); } else { for issue in &report.errors { eprintln!("{}: {}", issue.code, issue.message); diff --git a/ordvec-manifest/src/sqlite.rs b/ordvec-manifest/src/sqlite.rs index 6606c10..c49a43e 100644 --- a/ordvec-manifest/src/sqlite.rs +++ b/ordvec-manifest/src/sqlite.rs @@ -1,12 +1,12 @@ use crate::{ - resolve_existing_path, sha256_file_bounded, validate_jsonl_rows, verify_auxiliary_artifacts, - verify_manifest, AuxiliaryArtifactState, ManifestDocument, ManifestError, ReportIssue, - ResourceLimits, RowIdentity, VerificationPathCapture, VerificationReport, VerifyOptions, + codes, resolve_existing_path, sha256_bytes, sha256_file_bounded, validate_jsonl_rows, + verify_auxiliary_artifacts, verify_manifest, AuxiliaryArtifactState, ManifestDocument, + ManifestError, ReportIssue, ResourceLimits, RowIdentity, VerificationPathCapture, + VerificationReport, VerifyOptions, }; use chrono::{SecondsFormat, Utc}; -use rusqlite::{params, Connection, OptionalExtension}; +use rusqlite::{params, Connection, OptionalExtension, TransactionBehavior}; use serde::Serialize; -use sha2::{Digest, Sha256}; use std::fs; use std::path::{Path, PathBuf}; @@ -18,15 +18,10 @@ pub fn verify_with_registry( use_cache: bool, ) -> Result { let mut conn = Connection::open(db_path).map_err(sqlite_err)?; - init(&conn)?; + init(&mut conn)?; if use_cache { if let Some(cache_key) = current_cache_key(document, manifest_path.as_ref(), &options)? { - if let Some(report) = load_cached_report( - &conn, - &document.manifest.manifest_id, - &cache_key, - &options.limits, - )? { + if let Some(report) = load_cached_report(&conn, &cache_key, &options.limits)? { return Ok(report); } } @@ -38,7 +33,6 @@ pub fn verify_with_registry( cache_key_from_report(manifest_path.as_ref(), &report, document, &store_options)?; store_report( &mut conn, - document, manifest_path.as_ref(), &report, cache_key.as_ref(), @@ -55,12 +49,12 @@ pub fn activate( force: bool, ) -> Result { let mut conn = Connection::open(db_path).map_err(sqlite_err)?; - init(&conn)?; + init(&mut conn)?; let store_options = options.clone(); let mut report = verify_manifest(document, options); if !report.ok && force { report.warnings.push(ReportIssue::new( - "sqlite_activation_forced", + codes::SQLITE_ACTIVATION_FORCED, "sqlite activation was forced even though verification failed", )); } @@ -71,7 +65,6 @@ pub fn activate( }; store_report( &mut conn, - document, manifest_path.as_ref(), &report, cache_key.as_ref(), @@ -82,15 +75,13 @@ pub fn activate( } conn.execute( - "INSERT INTO active_manifest(id, manifest_id, manifest_path, activated_at, forced) - VALUES(1, ?1, ?2, ?3, ?4) + "INSERT INTO active_manifest(id, manifest_path, activated_at, forced) + VALUES(1, ?1, ?2, ?3) ON CONFLICT(id) DO UPDATE SET - manifest_id=excluded.manifest_id, manifest_path=excluded.manifest_path, activated_at=excluded.activated_at, forced=excluded.forced", params![ - document.manifest.manifest_id, manifest_path.as_ref().display().to_string(), Utc::now().to_rfc3339_opts(SecondsFormat::Nanos, true), i64::from(force), @@ -100,39 +91,65 @@ pub fn activate( Ok(report) } -fn init(conn: &Connection) -> Result<(), ManifestError> { +fn init(conn: &mut Connection) -> Result<(), ManifestError> { if verification_reports_needs_migration(conn)? { - conn.execute_batch( - "ALTER TABLE verification_reports RENAME TO verification_reports_old; - CREATE TABLE verification_reports( - report_id INTEGER PRIMARY KEY AUTOINCREMENT, - manifest_id TEXT NOT NULL, - manifest_path TEXT NOT NULL, - checked_at TEXT NOT NULL, - ok INTEGER NOT NULL, - manifest_location_sha256 TEXT, - manifest_sha256 TEXT, - options_sha256 TEXT, - artifact_sha256 TEXT, - row_identity_sha256 TEXT, - calibration_profile_sha256 TEXT, - auxiliary_artifacts_sha256 TEXT, - encoder_distortion_profile_sha256 TEXT, - report_json TEXT NOT NULL - ); - INSERT INTO verification_reports( - manifest_id, manifest_path, checked_at, ok, report_json - ) - SELECT manifest_id, manifest_path, checked_at, ok, report_json - FROM verification_reports_old; - DROP TABLE verification_reports_old;", - ) - .map_err(sqlite_err)?; + // Migrate atomically. `execute_batch` runs each statement in its own + // implicit transaction, so a crash between the RENAME and the DROP + // (or two processes racing this path) would leave a stray + // `verification_reports_old` behind — after which every future open + // fails, because the RENAME target already exists. Run the whole + // migration inside one IMMEDIATE transaction (write lock taken up + // front), drop any leftover `_old` from a prior interrupted attempt, + // and re-check need under the lock so a process that lost the race + // commits a no-op instead of re-migrating an already-v2 table. + let tx = conn + .transaction_with_behavior(TransactionBehavior::Immediate) + .map_err(sqlite_err)?; + if verification_reports_needs_migration(&tx)? { + tx.execute_batch( + "DROP TABLE IF EXISTS verification_reports_old; + ALTER TABLE verification_reports RENAME TO verification_reports_old; + CREATE TABLE verification_reports( + report_id INTEGER PRIMARY KEY AUTOINCREMENT, + manifest_path TEXT NOT NULL, + checked_at TEXT NOT NULL, + ok INTEGER NOT NULL, + manifest_location_sha256 TEXT, + manifest_sha256 TEXT, + options_sha256 TEXT, + artifact_sha256 TEXT, + row_identity_sha256 TEXT, + calibration_profile_sha256 TEXT, + auxiliary_artifacts_sha256 TEXT, + encoder_distortion_profile_sha256 TEXT, + report_json TEXT NOT NULL + ); + INSERT INTO verification_reports( + manifest_path, checked_at, ok, report_json + ) + SELECT manifest_path, checked_at, ok, report_json + FROM verification_reports_old; + DROP TABLE verification_reports_old;", + ) + .map_err(sqlite_err)?; + } + tx.commit().map_err(sqlite_err)?; } + // Schema v2 dropped active_manifest's manifest_id column, and `CREATE + // TABLE IF NOT EXISTS` below would leave such a stale table in place, + // making activate()'s INSERT fail at runtime on its NOT NULL column. The + // registry is rebuildable cache/pointer state — cached verification + // reports and the active-manifest pointer, never source of truth — so a + // table whose live schema mismatches the current one is dropped and + // recreated empty rather than migrated. + drop_registry_table_on_schema_mismatch( + conn, + "active_manifest", + &["id", "manifest_path", "activated_at", "forced"], + )?; conn.execute_batch( "CREATE TABLE IF NOT EXISTS verification_reports( report_id INTEGER PRIMARY KEY AUTOINCREMENT, - manifest_id TEXT NOT NULL, manifest_path TEXT NOT NULL, checked_at TEXT NOT NULL, ok INTEGER NOT NULL, @@ -148,7 +165,6 @@ fn init(conn: &Connection) -> Result<(), ManifestError> { ); CREATE INDEX IF NOT EXISTS verification_reports_cache_idx ON verification_reports( - manifest_id, manifest_location_sha256, manifest_sha256, options_sha256, @@ -161,7 +177,6 @@ fn init(conn: &Connection) -> Result<(), ManifestError> { ); CREATE TABLE IF NOT EXISTS active_manifest( id INTEGER PRIMARY KEY CHECK(id = 1), - manifest_id TEXT NOT NULL, manifest_path TEXT NOT NULL, activated_at TEXT NOT NULL, forced INTEGER NOT NULL @@ -173,7 +188,6 @@ fn init(conn: &Connection) -> Result<(), ManifestError> { fn store_report( conn: &mut Connection, - document: &ManifestDocument, manifest_path: &Path, report: &VerificationReport, cache_key: Option<&CacheKey>, @@ -183,7 +197,7 @@ fn store_report( let report_json = serde_json::to_string(report)?; if report_json.len() as u64 > limits.max_cached_report_bytes { return Err(ManifestError::limit_exceeded( - "sqlite_cached_report_too_large", + codes::SQLITE_CACHED_REPORT_TOO_LARGE, format!( "cached report is {} bytes, exceeding max_cached_report_bytes={}", report_json.len(), @@ -193,7 +207,6 @@ fn store_report( } tx.execute( "INSERT INTO verification_reports( - manifest_id, manifest_path, checked_at, ok, @@ -206,9 +219,8 @@ fn store_report( auxiliary_artifacts_sha256, encoder_distortion_profile_sha256, report_json - ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)", + ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)", params![ - document.manifest.manifest_id, manifest_path.display().to_string(), report.checked_at, i64::from(report.ok), @@ -230,7 +242,6 @@ fn store_report( fn load_cached_report( conn: &Connection, - manifest_id: &str, cache_key: &CacheKey, limits: &ResourceLimits, ) -> Result, ManifestError> { @@ -238,31 +249,29 @@ fn load_cached_report( .query_row( "SELECT report_id, length(CAST(report_json AS BLOB)) FROM verification_reports - WHERE manifest_id = ?1 - AND manifest_location_sha256 = ?2 - AND manifest_sha256 = ?3 - AND options_sha256 = ?4 - AND artifact_sha256 = ?5 + WHERE manifest_location_sha256 = ?1 + AND manifest_sha256 = ?2 + AND options_sha256 = ?3 + AND artifact_sha256 = ?4 AND ( - (row_identity_sha256 IS NULL AND ?6 IS NULL) - OR row_identity_sha256 = ?6 + (row_identity_sha256 IS NULL AND ?5 IS NULL) + OR row_identity_sha256 = ?5 ) AND ( - (calibration_profile_sha256 IS NULL AND ?7 IS NULL) - OR calibration_profile_sha256 = ?7 + (calibration_profile_sha256 IS NULL AND ?6 IS NULL) + OR calibration_profile_sha256 = ?6 ) AND ( - (auxiliary_artifacts_sha256 IS NULL AND ?8 IS NULL) - OR auxiliary_artifacts_sha256 = ?8 + (auxiliary_artifacts_sha256 IS NULL AND ?7 IS NULL) + OR auxiliary_artifacts_sha256 = ?7 ) AND ( - (encoder_distortion_profile_sha256 IS NULL AND ?9 IS NULL) - OR encoder_distortion_profile_sha256 = ?9 + (encoder_distortion_profile_sha256 IS NULL AND ?8 IS NULL) + OR encoder_distortion_profile_sha256 = ?8 ) ORDER BY report_id DESC LIMIT 1", params![ - manifest_id, cache_key.manifest_location_sha256.as_str(), cache_key.manifest_sha256.as_str(), cache_key.options_sha256.as_str(), @@ -281,7 +290,7 @@ fn load_cached_report( }; if report_len as u64 > limits.max_cached_report_bytes { return Err(ManifestError::limit_exceeded( - "sqlite_cached_report_too_large", + codes::SQLITE_CACHED_REPORT_TOO_LARGE, format!( "cached report is {report_len} bytes, exceeding max_cached_report_bytes={}", limits.max_cached_report_bytes @@ -360,7 +369,7 @@ fn manifest_location_sha256( base_dir: hex::encode(base_dir.as_os_str().as_encoded_bytes()), }; let json = serde_json::to_vec(&material)?; - Ok(Some(sha256_bytes(&json))) + Ok(Some(sha256_bytes(&json).sha256)) } fn current_cache_key( @@ -371,7 +380,7 @@ fn current_cache_key( let manifest_sha256 = match sha256_file_bounded( manifest_path, options.limits.max_manifest_bytes, - "manifest_file_too_large", + codes::MANIFEST_FILE_TOO_LARGE, "manifest file", ) { Ok(hash) => hash.sha256, @@ -381,7 +390,7 @@ fn current_cache_key( return Ok(None); }; let options_json = serde_json::to_vec(&CacheableVerifyOptions::from_options(options))?; - let options_sha256 = sha256_bytes(&options_json); + let options_sha256 = sha256_bytes(&options_json).sha256; let artifact_path = options .index_override @@ -393,7 +402,7 @@ fn current_cache_key( &artifact_path, &document.base_dir, options, - "artifact", + &crate::ARTIFACT_PATH_ISSUES, &mut path_errors, ) else { return Ok(None); @@ -407,7 +416,7 @@ fn current_cache_key( .artifact .file_size_bytes .min(options.limits.max_index_artifact_bytes), - "artifact_file_too_large", + codes::ARTIFACT_FILE_TOO_LARGE, "index artifact", ) { Ok(hash) => hash.sha256, @@ -427,7 +436,7 @@ fn current_cache_key( &row_path, &document.base_dir, options, - "row_identity", + &crate::ROW_IDENTITY_PATH_ISSUES, &mut path_errors, ) else { return Ok(None); @@ -475,7 +484,7 @@ fn cache_key_from_report( let manifest_sha256 = match sha256_file_bounded( manifest_path, options.limits.max_manifest_bytes, - "manifest_file_too_large", + codes::MANIFEST_FILE_TOO_LARGE, "manifest file", ) { Ok(hash) => hash.sha256, @@ -485,7 +494,7 @@ fn cache_key_from_report( return Ok(None); }; let options_json = serde_json::to_vec(&CacheableVerifyOptions::from_options(options))?; - let options_sha256 = sha256_bytes(&options_json); + let options_sha256 = sha256_bytes(&options_json).sha256; let Some(artifact_sha256) = report.artifact.sha256.clone() else { return Ok(None); }; @@ -546,7 +555,7 @@ fn current_auxiliary_artifacts_sha256( if document.manifest.auxiliary_artifacts.is_empty() { return Ok(None); } - let mut report = VerificationReport::new(None); + let mut report = VerificationReport::new(); let mut paths = VerificationPathCapture::default(); verify_auxiliary_artifacts(document, options, &mut report, &mut paths); auxiliary_artifacts_sha256_from_report(document, &report) @@ -589,7 +598,7 @@ fn auxiliary_artifacts_sha256_from_report( } let json = serde_json::to_vec(&entries)?; - Ok(Some(sha256_bytes(&json))) + Ok(Some(sha256_bytes(&json).sha256)) } #[derive(Serialize)] @@ -621,7 +630,7 @@ fn current_calibration_profile_sha256( &path, &document.base_dir, options, - "calibration_profile", + &crate::CALIBRATION_PROFILE_PATH_ISSUES, &mut path_errors, ) else { return Ok(None); @@ -631,7 +640,7 @@ fn current_calibration_profile_sha256( profile .file_size_bytes .min(options.limits.max_calibration_profile_bytes), - "calibration_profile_too_large", + codes::CALIBRATION_PROFILE_TOO_LARGE, "calibration profile", ) { Ok(hash) => Ok(Some(hash.sha256)), @@ -657,7 +666,7 @@ fn current_encoder_distortion_profile_sha256( &path, &document.base_dir, options, - "encoder_distortion_profile", + &crate::ENCODER_DISTORTION_PROFILE_PATH_ISSUES, &mut path_errors, ) else { return Ok(None); @@ -667,7 +676,7 @@ fn current_encoder_distortion_profile_sha256( profile .file_size_bytes .min(options.limits.max_encoder_distortion_profile_bytes), - "encoder_distortion_profile_too_large", + codes::ENCODER_DISTORTION_PROFILE_TOO_LARGE, "encoder distortion profile", ) { Ok(hash) => Ok(Some(hash.sha256)), @@ -675,12 +684,6 @@ fn current_encoder_distortion_profile_sha256( } } -fn sha256_bytes(bytes: &[u8]) -> String { - let mut hasher = Sha256::new(); - hasher.update(bytes); - hex::encode(hasher.finalize()) -} - fn verification_reports_needs_migration(conn: &Connection) -> Result { let exists: Option = conn .query_row( @@ -706,7 +709,6 @@ fn verification_reports_needs_migration(conn: &Connection) -> Result Result bool { .all(|required| columns.iter().any(|column| column == required)) } +/// Drops `table` when its live column set differs from `expected_columns`. +/// The sqlite registry is rebuildable cache/pointer state, so a stale-schema +/// table from an older build is dropped here and recreated empty by the +/// `CREATE TABLE IF NOT EXISTS` statements that `init` runs immediately +/// afterwards — legacy rows are never migrated. Idempotent: once the table +/// matches the current schema this is a no-op, and an absent table is left +/// for `CREATE TABLE IF NOT EXISTS` to create. +fn drop_registry_table_on_schema_mismatch( + conn: &Connection, + table: &str, + expected_columns: &[&str], +) -> Result<(), ManifestError> { + let mut stmt = conn + .prepare(&format!("PRAGMA table_info({table})")) + .map_err(sqlite_err)?; + let columns = stmt + .query_map([], |row| row.get::<_, String>(1)) + .map_err(sqlite_err)? + .collect::, _>>() + .map_err(sqlite_err)?; + if columns.is_empty() { + return Ok(()); + } + let matches_current = + columns.len() == expected_columns.len() && has_required_columns(&columns, expected_columns); + if !matches_current { + conn.execute_batch(&format!("DROP TABLE {table}")) + .map_err(sqlite_err)?; + } + Ok(()) +} + fn sqlite_err(err: rusqlite::Error) -> ManifestError { ManifestError::invalid(format!("sqlite error: {err}")) } diff --git a/ordvec-manifest/tests/deterministic.rs b/ordvec-manifest/tests/deterministic.rs new file mode 100644 index 0000000..a57bfc3 --- /dev/null +++ b/ordvec-manifest/tests/deterministic.rs @@ -0,0 +1,351 @@ +use ordvec::RankQuant; +use ordvec_manifest::{ + create_manifest_for_index, create_manifest_for_index_with_options, load_manifest_file, + sha256_file, verify_manifest_with_base, write_manifest_file, CreateAuxiliaryArtifact, + CreateManifestOptions, CreateRowIdentity, VerifyOptions, SCHEMA_VERSION, +}; +use serde_json::json; +use std::fs; +use std::path::{Path, PathBuf}; + +fn write_index(dir: &Path) -> PathBuf { + let path = dir.join("index.ovrq"); + let mut index = RankQuant::new(16, 2); + let docs: Vec = (0..32).map(|i| i as f32 - 12.0).collect(); + index.add(&docs); + index.write(&path).unwrap(); + path +} + +fn aux_input(dir: &Path, name: &str, contents: &[u8]) -> CreateAuxiliaryArtifact { + let path = dir.join(name); + fs::write(&path, contents).unwrap(); + CreateAuxiliaryArtifact { + name: name.to_string(), + path, + required: true, + } +} + +/// Builds the fixed synthetic bundle used by the determinism tests and +/// returns the serialized manifest bytes. +fn build_manifest_bytes(dir: &Path, aux_names: &[&str]) -> Vec { + let index = write_index(dir); + let manifest_path = dir.join("manifest.json"); + let auxiliary_artifacts = aux_names + .iter() + .map(|name| aux_input(dir, name, name.as_bytes())) + .collect(); + let manifest = create_manifest_for_index_with_options( + &index, + CreateRowIdentity::RowIdIdentity, + "test-embedding", + &manifest_path, + CreateManifestOptions { + auxiliary_artifacts, + ..CreateManifestOptions::default() + }, + ) + .unwrap(); + write_manifest_file(&manifest, &manifest_path).unwrap(); + fs::read(&manifest_path).unwrap() +} + +#[test] +fn identical_inputs_produce_byte_identical_manifests() { + let temp_a = tempfile::tempdir().unwrap(); + let temp_b = tempfile::tempdir().unwrap(); + let bytes_a = build_manifest_bytes(temp_a.path(), &["aux-a.bin", "aux-b.bin"]); + let bytes_b = build_manifest_bytes(temp_b.path(), &["aux-a.bin", "aux-b.bin"]); + assert_eq!(bytes_a, bytes_b); + assert_eq!( + sha256_file(temp_a.path().join("manifest.json")) + .unwrap() + .sha256, + sha256_file(temp_b.path().join("manifest.json")) + .unwrap() + .sha256, + ); +} + +#[test] +fn manifest_bytes_match_checked_in_golden() { + let temp = tempfile::tempdir().unwrap(); + let bytes = build_manifest_bytes(temp.path(), &["aux-a.bin", "aux-b.bin"]); + let golden = include_bytes!("golden/manifest.v2.json"); + assert_eq!( + bytes, golden, + "checked-in golden manifest bytes changed. The canonical byte form is \ + the bundle's content address, so this is deliberate only if you changed \ + the manifest serializer (a schema-version event) or the ordvec index \ + encoding the fixture embeds (an .ovrq format_version event). If instead \ + an editor reflowed or newline-normalized golden/manifest.v2.json, revert \ + that — the fixture is intentionally byte-exact with no trailing newline." + ); +} + +#[test] +fn manifest_bytes_change_when_artifact_content_changes() { + let temp_a = tempfile::tempdir().unwrap(); + let temp_b = tempfile::tempdir().unwrap(); + let bytes_a = build_manifest_bytes(temp_a.path(), &[]); + + let index = temp_b.path().join("index.ovrq"); + let mut altered = RankQuant::new(16, 2); + // Different rank order per vector, so the encoded index bytes (and the + // manifest-embedded sha256) actually change. + let docs: Vec = (0..32).map(|i| ((i * 17) % 31) as f32).collect(); + altered.add(&docs); + altered.write(&index).unwrap(); + let manifest_path = temp_b.path().join("manifest.json"); + let manifest = create_manifest_for_index( + &index, + CreateRowIdentity::RowIdIdentity, + "test-embedding", + &manifest_path, + ) + .unwrap(); + write_manifest_file(&manifest, &manifest_path).unwrap(); + let bytes_b = fs::read(&manifest_path).unwrap(); + + assert_ne!(bytes_a, bytes_b); +} + +#[test] +fn manifest_bytes_change_when_auxiliary_entry_added_or_removed() { + let temp_a = tempfile::tempdir().unwrap(); + let temp_b = tempfile::tempdir().unwrap(); + let temp_c = tempfile::tempdir().unwrap(); + let without_aux = build_manifest_bytes(temp_a.path(), &[]); + let with_one = build_manifest_bytes(temp_b.path(), &["aux-a.bin"]); + let with_two = build_manifest_bytes(temp_c.path(), &["aux-a.bin", "aux-b.bin"]); + assert_ne!(without_aux, with_one); + assert_ne!(with_one, with_two); +} + +#[test] +fn auxiliary_declaration_order_does_not_change_manifest_bytes() { + let temp_a = tempfile::tempdir().unwrap(); + let temp_b = tempfile::tempdir().unwrap(); + let bytes_a = build_manifest_bytes(temp_a.path(), &["aux-a.bin", "aux-b.bin"]); + let bytes_b = build_manifest_bytes(temp_b.path(), &["aux-b.bin", "aux-a.bin"]); + assert_eq!(bytes_a, bytes_b); +} + +#[test] +fn old_schema_manifest_fails_with_clear_schema_version_error() { + let temp = tempfile::tempdir().unwrap(); + let index = write_index(temp.path()); + let manifest_path = temp.path().join("manifest.json"); + let manifest = create_manifest_for_index( + &index, + CreateRowIdentity::RowIdIdentity, + "test-embedding", + &manifest_path, + ) + .unwrap(); + let mut value = serde_json::to_value(&manifest).unwrap(); + let object = value.as_object_mut().unwrap(); + object.insert( + "schema_version".to_string(), + json!("ordvec.index_manifest.v1"), + ); + object.insert( + "manifest_id".to_string(), + json!("urn:uuid:11111111-1111-4111-8111-111111111111"), + ); + object.insert("created_at".to_string(), json!("2026-06-09T00:00:00Z")); + fs::write( + &manifest_path, + serde_json::to_string_pretty(&value).unwrap(), + ) + .unwrap(); + + let message = load_manifest_file(&manifest_path).unwrap_err().to_string(); + assert!(message.contains("ordvec.index_manifest.v1"), "{message}"); + assert!(message.contains(SCHEMA_VERSION), "{message}"); + assert!(message.contains("older or newer"), "{message}"); +} + +#[test] +fn current_shape_with_wrong_schema_version_fails_at_load() { + // A document that is otherwise valid v2 but claims an unsupported + // schema_version has no unknown fields, so `deny_unknown_fields` accepts + // it — the loader must reject it on the version alone, not defer to verify. + let temp = tempfile::tempdir().unwrap(); + let index = write_index(temp.path()); + let manifest_path = temp.path().join("manifest.json"); + let manifest = create_manifest_for_index( + &index, + CreateRowIdentity::RowIdIdentity, + "test-embedding", + &manifest_path, + ) + .unwrap(); + let mut value = serde_json::to_value(&manifest).unwrap(); + value.as_object_mut().unwrap().insert( + "schema_version".to_string(), + json!("ordvec.index_manifest.v1"), + ); + fs::write( + &manifest_path, + serde_json::to_string_pretty(&value).unwrap(), + ) + .unwrap(); + + let message = load_manifest_file(&manifest_path).unwrap_err().to_string(); + assert!(message.contains("ordvec.index_manifest.v1"), "{message}"); + assert!(message.contains(SCHEMA_VERSION), "{message}"); + assert!(message.contains("older or newer"), "{message}"); +} + +#[test] +fn unknown_fields_on_current_schema_keep_the_parse_error() { + let temp = tempfile::tempdir().unwrap(); + let index = write_index(temp.path()); + let manifest_path = temp.path().join("manifest.json"); + let manifest = create_manifest_for_index( + &index, + CreateRowIdentity::RowIdIdentity, + "test-embedding", + &manifest_path, + ) + .unwrap(); + let mut value = serde_json::to_value(&manifest).unwrap(); + value + .as_object_mut() + .unwrap() + .insert("unknown".to_string(), json!(true)); + fs::write( + &manifest_path, + serde_json::to_string_pretty(&value).unwrap(), + ) + .unwrap(); + + let message = load_manifest_file(&manifest_path).unwrap_err().to_string(); + assert!(message.contains("unknown"), "{message}"); + assert!(!message.contains("older or newer"), "{message}"); +} + +#[test] +fn non_canonical_manifest_paths_are_rejected_at_validation() { + let temp = tempfile::tempdir().unwrap(); + let index = write_index(temp.path()); + let manifest_path = temp.path().join("manifest.json"); + let manifest = create_manifest_for_index( + &index, + CreateRowIdentity::RowIdIdentity, + "test-embedding", + &manifest_path, + ) + .unwrap(); + + let mut dotted = manifest.clone(); + dotted.artifact.path = "./index.ovrq".to_string(); + let report = verify_manifest_with_base(dotted, temp.path(), VerifyOptions::default()); + assert!(report + .errors + .iter() + .any(|issue| issue.code == "artifact_path_not_canonical")); + + let mut backslashed = manifest; + backslashed.artifact.path = "sub\\index.ovrq".to_string(); + let report = verify_manifest_with_base(backslashed, temp.path(), VerifyOptions::default()); + assert!(report + .errors + .iter() + .any(|issue| issue.code == "artifact_path_not_canonical")); +} + +#[test] +fn contained_parent_dir_segments_are_not_canonical_by_default() { + let temp = tempfile::tempdir().unwrap(); + write_index(temp.path()); + fs::create_dir(temp.path().join("a")).unwrap(); + let manifest_path = temp.path().join("manifest.json"); + let manifest = create_manifest_for_index( + temp.path().join("index.ovrq"), + CreateRowIdentity::RowIdIdentity, + "test-embedding", + &manifest_path, + ) + .unwrap(); + + // `a/../index.ovrq` resolves to the same file as `index.ovrq` without + // ever escaping the bundle, so it slips past escape/containment checks; + // canonicality must reject it or one bundle has many verified identities. + let mut aliased = manifest; + aliased.artifact.path = "a/../index.ovrq".to_string(); + let report = verify_manifest_with_base(aliased.clone(), temp.path(), VerifyOptions::default()); + assert!(report + .errors + .iter() + .any(|issue| issue.code == "artifact_path_not_canonical")); + + // `..` segments remain available under the explicit escape policy. + let report = verify_manifest_with_base( + aliased, + temp.path(), + VerifyOptions { + allow_path_escape: true, + ..VerifyOptions::default() + }, + ); + assert!(report.ok, "{:?}", report.errors); +} + +#[test] +fn absolute_path_strings_are_policy_governed_not_canonicality_errors() { + let temp = tempfile::tempdir().unwrap(); + write_index(temp.path()); + let manifest_path = temp.path().join("manifest.json"); + let manifest = create_manifest_for_index( + temp.path().join("index.ovrq"), + CreateRowIdentity::RowIdIdentity, + "test-embedding", + &manifest_path, + ) + .unwrap(); + + // Windows-style absolute strings must fall to the allow_absolute_paths + // policy at resolution, not to the canonical-form check, so the retained + // absolute-path opt-in keeps working across platforms. + for absolute in ["C:/bundles/index.ovrq", "//?/C:/bundles/index.ovrq"] { + let mut manifest = manifest.clone(); + manifest.artifact.path = absolute.to_string(); + let report = verify_manifest_with_base(manifest, temp.path(), VerifyOptions::default()); + assert!(!report.ok); + assert!( + report + .errors + .iter() + .all(|issue| issue.code != "artifact_path_not_canonical"), + "{absolute} must be governed by path policy, got {:?}", + report.errors + ); + } +} + +#[cfg(unix)] +#[test] +fn create_rejects_paths_it_cannot_embed_canonically() { + let temp = tempfile::tempdir().unwrap(); + let index = write_index(temp.path()); + let manifest_path = temp.path().join("manifest.json"); + // A legal Unix filename containing a backslash cannot be embedded without + // aliasing the manifest path separator; creation must fail instead of + // minting a manifest that fails its own default verification. + let aux = aux_input(temp.path(), "back\\slash.bin", b"aux"); + let err = create_manifest_for_index_with_options( + &index, + CreateRowIdentity::RowIdIdentity, + "test-embedding", + &manifest_path, + CreateManifestOptions { + auxiliary_artifacts: vec![aux], + ..CreateManifestOptions::default() + }, + ) + .unwrap_err(); + assert!(err.to_string().contains("cannot be embedded"), "{err}"); +} diff --git a/ordvec-manifest/tests/golden/.gitattributes b/ordvec-manifest/tests/golden/.gitattributes new file mode 100644 index 0000000..f5e71d7 --- /dev/null +++ b/ordvec-manifest/tests/golden/.gitattributes @@ -0,0 +1,3 @@ +# Golden fixtures are byte-compared (include_bytes! in tests/deterministic.rs). +# Never let checkout eol conversion (e.g. Windows autocrlf) rewrite them. +* -text diff --git a/ordvec-manifest/tests/golden/manifest.v2.json b/ordvec-manifest/tests/golden/manifest.v2.json new file mode 100644 index 0000000..d0d9fbd --- /dev/null +++ b/ordvec-manifest/tests/golden/manifest.v2.json @@ -0,0 +1,39 @@ +{ + "schema_version": "ordvec.index_manifest.v2", + "artifact": { + "path": "index.ovrq", + "sha256": "4f9f638545a184edc0e4d40505ec4ccc8d01f08a3b7b3802e8416f8137f2c911", + "kind": "rank_quant", + "format_version": 1, + "dim": 16, + "vector_count": 2, + "bytes_per_vec": 4, + "params": { + "kind": "rank_quant", + "bits": 2 + }, + "file_size_bytes": 22 + }, + "auxiliary_artifacts": [ + { + "name": "aux-a.bin", + "path": "aux-a.bin", + "sha256": "68625473e92b147c590f83c5609bda60f2f431851e4db12a311279a7657e0b0c", + "file_size_bytes": 9 + }, + { + "name": "aux-b.bin", + "path": "aux-b.bin", + "sha256": "7483d93e519a65e9398791421d0fa3303b8ebf865fc2e78f88339e1e751e7c02", + "file_size_bytes": 9 + } + ], + "embedding": { + "model": "test-embedding", + "dim": 16 + }, + "row_identity": { + "kind": "row_id_identity", + "row_count": 2 + } +} \ No newline at end of file diff --git a/ordvec-manifest/tests/hash_helpers.rs b/ordvec-manifest/tests/hash_helpers.rs new file mode 100644 index 0000000..99c0034 --- /dev/null +++ b/ordvec-manifest/tests/hash_helpers.rs @@ -0,0 +1,53 @@ +use std::io::{Cursor, ErrorKind}; + +use ordvec_manifest::{sha256_bytes, sha256_file, sha256_file_bounded, sha256_reader}; + +const CONTENT: &[u8] = b"ordinal geometry is rank, not distance"; + +#[test] +fn sha256_helpers_agree_on_identical_content() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("content.bin"); + std::fs::write(&path, CONTENT).expect("write content"); + + let from_file = sha256_file(&path).expect("sha256_file"); + let from_bytes = sha256_bytes(CONTENT); + let from_reader = + sha256_reader(Cursor::new(CONTENT), CONTENT.len() as u64).expect("sha256_reader"); + let from_bounded_file = sha256_file_bounded( + &path, + CONTENT.len() as u64, + "artifact_file_too_large", + "test artifact", + ) + .expect("sha256_file_bounded"); + + assert_eq!(from_file.sha256, from_bytes.sha256); + assert_eq!(from_file.sha256, from_reader.sha256); + assert_eq!(from_file.sha256, from_bounded_file.sha256); + assert_eq!(from_file.size_bytes, CONTENT.len() as u64); + assert_eq!(from_bytes.size_bytes, CONTENT.len() as u64); + assert_eq!(from_reader.size_bytes, CONTENT.len() as u64); +} + +#[test] +fn sha256_reader_accepts_input_of_exactly_max_bytes() { + let hash = sha256_reader(Cursor::new(CONTENT), CONTENT.len() as u64) + .expect("input at the bound must hash"); + assert_eq!(hash.size_bytes, CONTENT.len() as u64); +} + +#[test] +fn sha256_reader_rejects_input_larger_than_max_bytes() { + let err = sha256_reader(Cursor::new(CONTENT), CONTENT.len() as u64 - 1) + .expect_err("input past the bound must fail"); + assert_eq!(err.kind(), ErrorKind::InvalidData); + assert!(err.to_string().contains("exceeds")); +} + +#[test] +fn sha256_reader_handles_empty_input() { + let hash = sha256_reader(Cursor::new(&[][..]), 0).expect("empty input hashes under a 0 bound"); + assert_eq!(hash.size_bytes, 0); + assert_eq!(hash.sha256, sha256_bytes(&[]).sha256); +} diff --git a/ordvec-manifest/tests/manifest.rs b/ordvec-manifest/tests/manifest.rs index 71f9b57..28207de 100644 --- a/ordvec-manifest/tests/manifest.rs +++ b/ordvec-manifest/tests/manifest.rs @@ -8,8 +8,8 @@ use ordvec_manifest::{ DistortionEvidenceKind, DistortionProfileArtifactRef, DistortionScope, EncoderDistortionProfileRef, EncoderSpec, ManifestIndexKind, ManifestIndexParams, MetricSpec, NullModelSpec, ProfileArtifactRef, ProfileParameterization, RequireAuxiliaryError, - ResourceLimits, RowIdentity, VerifiedLoadPlanError, VerifyOptions, CALIBRATION_SCHEMA_VERSION, - ENCODER_DISTORTION_SCHEMA_VERSION, + ResourceLimits, RowIdentity, VerificationCode, VerifiedLoadPlanError, VerifyOptions, + CALIBRATION_SCHEMA_VERSION, ENCODER_DISTORTION_SCHEMA_VERSION, }; use serde_json::json; use std::fs; @@ -499,7 +499,17 @@ fn create_manifest_rejects_invalid_auxiliary_artifact_declarations() { }, ) .unwrap_err(); - assert!(err.to_string().contains("No such file") || err.to_string().contains("not found")); + // The missing-file io error text is platform-worded: unix says "No such + // file or directory", Windows says "The system cannot find the file + // specified."; both raw io displays end with "(os error 2)". + let message = err.to_string(); + assert!( + message.contains("No such file") + || message.contains("not found") + || message.contains("cannot find the file") + || message.contains("os error 2"), + "{message}" + ); let outside = root.path().join("outside.bin"); fs::write(&outside, b"outside").unwrap(); @@ -590,7 +600,7 @@ fn manifest_loader_enforces_size_limit_with_exact_boundary_success() { }, ) .unwrap(); - assert_eq!(loaded.manifest.manifest_id, manifest.manifest_id); + assert_eq!(loaded.manifest.artifact.sha256, manifest.artifact.sha256); } #[test] @@ -1013,7 +1023,14 @@ fn schema_enforces_lowercase_sha256_and_optional_field_shapes() { manifest.embedding.corpus_digest = Some("A".repeat(64)); manifest.embedding.embedding_matrix_digest = Some("not-a-digest".to_string()); manifest.embedding.normalization = Some("".to_string()); - manifest.build.as_mut().unwrap().source_repo = Some("".to_string()); + manifest.build = Some(ordvec_manifest::BuildInfo { + invocation_id: "urn:uuid:7c66ad6e-bdde-49a8-b420-f1136d04f5bd".to_string(), + builder_id: None, + source_repo: Some("".to_string()), + source_commit: None, + ci_provider: None, + ci_run_id: None, + }); let report = verify_manifest_with_base(manifest, temp.path(), VerifyOptions::default()); for code in [ @@ -1391,6 +1408,52 @@ fn encoder_distortion_profile_artifact_checks_are_enforced() { assert!(error_codes(&report).contains(&"encoder_distortion_profile_absolute_path_rejected")); } +#[test] +fn profile_ref_paths_must_be_canonical() { + let temp = tempfile::tempdir().unwrap(); + let profile_dir = temp.path().join("profiles"); + fs::create_dir(&profile_dir).unwrap(); + let index = write_index_kind(temp.path(), FixtureKind::RankQuant); + let manifest_path = temp.path().join("manifest.json"); + let distortion_hash = write_profile(&profile_dir.join("distortion.json"), 128); + let bucket_hash = write_profile(&temp.path().join("bucket.f64"), 16 * 4 * 8); + let mut manifest = create_manifest_for_index( + &index, + CreateRowIdentity::RowIdIdentity, + "test-embedding", + &manifest_path, + ) + .unwrap(); + manifest.encoder_distortion = Some(distortion_profile( + &manifest, + Some("profiles/./distortion.json".to_string()), + Some(distortion_hash), + DistortionEvidenceKind::EmpiricalSample, + )); + manifest.calibration = Some(weighted_calibration( + &manifest, + "a/../bucket.f64", + bucket_hash, + CalibrationOrdinalization::Bucket { + dim: manifest.artifact.dim, + bits: 2, + }, + ProfileParameterization::BucketFrequency, + vec![manifest.artifact.dim, 4], + )); + let report = verify_manifest_with_base(manifest, temp.path(), VerifyOptions::default()); + for code in [ + "encoder_distortion_profile_path_not_canonical", + "calibration_profile_path_not_canonical", + ] { + assert!( + error_codes(&report).contains(&code), + "missing {code}: {:?}", + report.errors + ); + } +} + #[test] fn encoder_distortion_can_bind_to_calibration_profile_id() { let temp = tempfile::tempdir().unwrap(); @@ -1927,10 +1990,41 @@ fn missing_artifact_and_row_count_mismatch_are_reported() { manifest.row_identity = RowIdentity::RowIdIdentity { row_count: 2 }; fs::remove_file(temp.path().join(&manifest.artifact.path)).unwrap(); let report = verify_manifest_with_base(manifest, temp.path(), VerifyOptions::default()); - assert!(report + // An absent primary artifact reports the NotFound-specific `artifact_missing` + // code, which classifies as ArtifactMissing — not the generic + // `artifact_path_unavailable` (reserved for permission/I/O failures). + let missing = report .errors .iter() - .any(|issue| issue.code == "artifact_path_unavailable")); + .find(|issue| issue.code == "artifact_missing") + .expect("missing artifact must report artifact_missing"); + assert_eq!(missing.classification(), VerificationCode::ArtifactMissing); +} + +#[test] +fn missing_row_identity_jsonl_classifies_as_row_identity_missing() { + let root = tempfile::tempdir().unwrap(); + let (temp, mut manifest, _manifest_path) = identity_manifest(root.path()); + // The artifact stays present; only the row-identity JSONL is absent, so a + // consumer sees the row-identity file distinctly as missing rather than as + // an unclassified path failure. + manifest.row_identity = RowIdentity::Jsonl { + path: "rows.jsonl".to_string(), + sha256: "0".repeat(64), + row_count: 1, + id_kind: "uuid".to_string(), + db: None, + }; + let report = verify_manifest_with_base(manifest, temp.path(), VerifyOptions::default()); + let missing = report + .errors + .iter() + .find(|issue| issue.code == "row_identity_missing") + .expect("missing row-identity file must report row_identity_missing"); + assert_eq!( + missing.classification(), + VerificationCode::RowIdentityMissing + ); } #[test] @@ -1988,6 +2082,96 @@ fn path_policy_rejects_escapes_and_absolute_paths_by_default() { assert!(report.ok, "{:?}", report.errors); } +#[cfg(unix)] +#[test] +fn single_backslash_artifact_path_fails_canonical_check_under_all_policies() { + let temp = tempfile::tempdir().unwrap(); + let index = write_index(temp.path()); + let manifest_path = temp.path().join("manifest.json"); + let mut manifest = create_manifest_for_index( + &index, + CreateRowIdentity::RowIdIdentity, + "test-embedding", + &manifest_path, + ) + .unwrap(); + + // On Unix a backslash is an ordinary file-name byte, so a crafted bundle + // can carry a matching artifact literally named `\evil`. Classifying a + // single leading backslash as absolute skipped the canonical-form check + // while resolution still treated the path as relative, so this manifest + // verified successfully before the fix. The lint misreads the backslash + // as a path separator; on Unix this join produces a child file name. + #[allow(clippy::join_absolute_paths)] + fs::copy(&index, temp.path().join("\\evil")).unwrap(); + manifest.artifact.path = "\\evil".to_string(); + + let report = verify_manifest_with_base(manifest.clone(), temp.path(), VerifyOptions::default()); + assert!(!report.ok); + assert!(report + .errors + .iter() + .any(|issue| issue.code == "artifact_path_not_canonical")); + + let report = verify_manifest_with_base( + manifest, + temp.path(), + VerifyOptions { + allow_absolute_paths: true, + allow_path_escape: true, + ..VerifyOptions::default() + }, + ); + assert!(!report.ok); + assert!(report + .errors + .iter() + .any(|issue| issue.code == "artifact_path_not_canonical")); +} + +#[cfg(unix)] +#[test] +fn unc_artifact_path_stays_policy_gated_and_never_resolves_relative() { + let temp = tempfile::tempdir().unwrap(); + let index = write_index(temp.path()); + let manifest_path = temp.path().join("manifest.json"); + let mut manifest = create_manifest_for_index( + &index, + CreateRowIdentity::RowIdIdentity, + "test-embedding", + &manifest_path, + ) + .unwrap(); + manifest.artifact.path = "\\\\server\\share\\index.ovrq".to_string(); + + // UNC paths remain classified absolute, so the default policy rejects + // them outright. + let report = verify_manifest_with_base(manifest.clone(), temp.path(), VerifyOptions::default()); + assert!(!report.ok); + assert!(report + .errors + .iter() + .any(|issue| issue.code == "artifact_absolute_path_rejected")); + + // Even with absolute paths allowed, a path that is absolute for policy + // purposes must never silently resolve relative to the manifest base on + // a platform (Unix) that cannot resolve it as absolute. + let report = verify_manifest_with_base( + manifest, + temp.path(), + VerifyOptions { + allow_absolute_paths: true, + allow_path_escape: true, + ..VerifyOptions::default() + }, + ); + assert!(!report.ok); + assert!(report + .errors + .iter() + .any(|issue| issue.code == "artifact_absolute_path_unresolvable")); +} + #[cfg(unix)] #[test] fn symlink_escape_reports_observed_canonical_path() { @@ -2176,7 +2360,10 @@ fn verify_for_load_returns_row_map_path_and_optional_absent_auxiliary() { assert_eq!(sidecar_plan.path(), None); } -#[cfg(unix)] +// Linux-only, not cfg(unix): macOS APFS rejects non-UTF-8 filenames with +// EILSEQ at creation, so the non-UTF-8 base path this exercises cannot +// exist there. +#[cfg(target_os = "linux")] #[test] fn verify_for_load_preserves_non_utf8_base_paths() { use std::ffi::OsString; @@ -2283,6 +2470,21 @@ fn verify_for_load_fails_closed_with_report_for_corrupted_artifact() { panic!("expected verification failure"); }; assert!(error_codes(&report).contains(&"artifact_sha256_mismatch")); + let issue = report + .errors + .iter() + .find(|issue| issue.code == "artifact_sha256_mismatch") + .expect("sha256 mismatch issue is reported"); + assert_eq!( + issue.classification(), + VerificationCode::ArtifactSha256Mismatch + ); + assert_eq!( + issue.expected_sha256.as_deref(), + Some(manifest.artifact.sha256.as_str()) + ); + assert!(issue.actual_sha256.is_some()); + assert_ne!(issue.actual_sha256, issue.expected_sha256); } #[test] @@ -3223,6 +3425,77 @@ fn sqlite_migrates_legacy_verification_reports_by_required_column_names() { assert!(columns.contains(&"report_id".to_string())); assert!(columns.contains(&"manifest_sha256".to_string())); assert!(!columns.contains(&"extra".to_string())); + assert!(!columns.contains(&"manifest_id".to_string())); +} + +#[cfg(feature = "sqlite")] +#[test] +fn sqlite_migration_recovers_from_leftover_old_table() { + use rusqlite::Connection; + + // A prior migration that was interrupted after the RENAME leaves a stray + // `verification_reports_old`. The migration must drop it and still succeed, + // not wedge every future open on "table verification_reports_old already + // exists". + let temp = tempfile::tempdir().unwrap(); + let index = write_index(temp.path()); + let manifest_path = temp.path().join("manifest.json"); + let manifest = create_manifest_for_index( + &index, + CreateRowIdentity::RowIdIdentity, + "test-embedding", + &manifest_path, + ) + .unwrap(); + fs::write( + &manifest_path, + serde_json::to_string_pretty(&manifest).unwrap(), + ) + .unwrap(); + let document = load_manifest_file(&manifest_path).unwrap(); + let db = temp.path().join("wedged.sqlite"); + let conn = Connection::open(&db).unwrap(); + conn.execute( + "CREATE TABLE verification_reports( + report_json TEXT, checked_at TEXT, ok INTEGER, + manifest_path TEXT, manifest_id TEXT + )", + [], + ) + .unwrap(); + conn.execute("CREATE TABLE verification_reports_old(stale TEXT)", []) + .unwrap(); + drop(conn); + + let report = ordvec_manifest::sqlite::verify_with_registry( + &db, + &document, + &manifest_path, + VerifyOptions::default(), + true, + ) + .unwrap(); + assert!(report.ok, "{:?}", report.errors); + + let conn = Connection::open(&db).unwrap(); + let leftover: i64 = conn + .query_row( + "SELECT COUNT(*) FROM sqlite_master + WHERE type='table' AND name='verification_reports_old'", + [], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(leftover, 0, "stray _old table must be gone after migration"); + let columns = conn + .prepare("PRAGMA table_info(verification_reports)") + .unwrap() + .query_map([], |row| row.get::<_, String>(1)) + .unwrap() + .collect::, _>>() + .unwrap(); + assert!(columns.contains(&"manifest_sha256".to_string())); + assert!(!columns.contains(&"manifest_id".to_string())); } #[cfg(feature = "sqlite")] @@ -3361,6 +3634,94 @@ fn sqlite_cache_is_explicit_and_activation_reverifies_by_default() { } } +#[cfg(feature = "sqlite")] +#[test] +fn sqlite_activate_recreates_legacy_active_manifest_schema() { + use rusqlite::Connection; + + let temp = tempfile::tempdir().unwrap(); + let index = write_index(temp.path()); + let manifest_path = temp.path().join("manifest.json"); + let manifest = create_manifest_for_index( + &index, + CreateRowIdentity::RowIdIdentity, + "test-embedding", + &manifest_path, + ) + .unwrap(); + fs::write( + &manifest_path, + serde_json::to_string_pretty(&manifest).unwrap(), + ) + .unwrap(); + let document = load_manifest_file(&manifest_path).unwrap(); + + let db = temp.path().join("registry.sqlite"); + { + let conn = Connection::open(&db).unwrap(); + // Legacy pre-schema-v2 registry: activate() no longer supplies + // manifest_id, so this NOT NULL column must trigger the + // drop-and-recreate on init instead of failing the INSERT. + conn.execute_batch( + "CREATE TABLE active_manifest( + id INTEGER PRIMARY KEY CHECK(id = 1), + manifest_id TEXT NOT NULL, + manifest_path TEXT NOT NULL, + activated_at TEXT NOT NULL, + forced INTEGER NOT NULL + ); + INSERT INTO active_manifest(id, manifest_id, manifest_path, activated_at, forced) + VALUES(1, 'urn:uuid:legacy', 'legacy-manifest.json', '2026-01-01T00:00:00Z', 0);", + ) + .unwrap(); + } + + let report = ordvec_manifest::sqlite::activate( + &db, + &document, + &manifest_path, + VerifyOptions::default(), + false, + ) + .unwrap(); + assert!(report.ok, "{:?}", report.errors); + + // Re-activating against the recreated table must stay idempotent. + let second = ordvec_manifest::sqlite::activate( + &db, + &document, + &manifest_path, + VerifyOptions::default(), + false, + ) + .unwrap(); + assert!(second.ok, "{:?}", second.errors); + + let conn = Connection::open(&db).unwrap(); + let columns = { + let mut stmt = conn.prepare("PRAGMA table_info(active_manifest)").unwrap(); + let columns = stmt + .query_map([], |row| row.get::<_, String>(1)) + .unwrap() + .collect::, _>>() + .unwrap(); + columns + }; + assert_eq!( + columns, + vec!["id", "manifest_path", "activated_at", "forced"] + ); + let (active_path, forced): (String, i64) = conn + .query_row( + "SELECT manifest_path, forced FROM active_manifest WHERE id = 1", + [], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .unwrap(); + assert_eq!(active_path, manifest_path.display().to_string()); + assert_eq!(forced, 0); +} + #[cfg(feature = "sqlite")] #[test] fn sqlite_combined_verified_load_matrix_respects_limits_paths_and_cache() { diff --git a/ordvec-manifest/tests/verification_codes.rs b/ordvec-manifest/tests/verification_codes.rs new file mode 100644 index 0000000..8da35c3 --- /dev/null +++ b/ordvec-manifest/tests/verification_codes.rs @@ -0,0 +1,405 @@ +use ordvec_manifest::codes; + +/// Security-relevant code values are load-bearing for downstream consumers +/// (they branch on these strings via the consts). A silent rename must break +/// this test, not downstream security decisions. +#[test] +fn security_relevant_code_values_are_locked() { + let locked: &[(&str, &str)] = &[ + (codes::ARTIFACT_SHA256_MISMATCH, "artifact_sha256_mismatch"), + ( + codes::ARTIFACT_FILE_SIZE_MISMATCH, + "artifact_file_size_mismatch", + ), + (codes::ARTIFACT_MISSING, "artifact_missing"), + ( + codes::ARTIFACT_PATH_UNAVAILABLE, + "artifact_path_unavailable", + ), + ( + codes::ARTIFACT_ABSOLUTE_PATH_REJECTED, + "artifact_absolute_path_rejected", + ), + ( + codes::ARTIFACT_ABSOLUTE_PATH_UNRESOLVABLE, + "artifact_absolute_path_unresolvable", + ), + ( + codes::ARTIFACT_PATH_ESCAPE_REJECTED, + "artifact_path_escape_rejected", + ), + ( + codes::AUXILIARY_ARTIFACT_SHA256_MISMATCH, + "auxiliary_artifact_sha256_mismatch", + ), + ( + codes::AUXILIARY_ARTIFACT_FILE_SIZE_MISMATCH, + "auxiliary_artifact_file_size_mismatch", + ), + ( + codes::AUXILIARY_ARTIFACT_MISSING_REQUIRED, + "auxiliary_artifact_missing_required", + ), + ( + codes::AUXILIARY_ARTIFACT_ABSOLUTE_PATH_REJECTED, + "auxiliary_artifact_absolute_path_rejected", + ), + ( + codes::AUXILIARY_ARTIFACT_ABSOLUTE_PATH_UNRESOLVABLE, + "auxiliary_artifact_absolute_path_unresolvable", + ), + ( + codes::AUXILIARY_ARTIFACT_PATH_ESCAPE_REJECTED, + "auxiliary_artifact_path_escape_rejected", + ), + ( + codes::ROW_IDENTITY_SHA256_MISMATCH, + "row_identity_sha256_mismatch", + ), + ( + codes::ROW_IDENTITY_ROW_COUNT_MISMATCH, + "row_identity_row_count_mismatch", + ), + (codes::ROW_IDENTITY_MISSING, "row_identity_missing"), + ( + codes::SCHEMA_VERSION_UNSUPPORTED, + "schema_version_unsupported", + ), + (codes::MANIFEST_FILE_TOO_LARGE, "manifest_file_too_large"), + (codes::ARTIFACT_FILE_TOO_LARGE, "artifact_file_too_large"), + ( + codes::AUXILIARY_ARTIFACT_FILE_TOO_LARGE, + "auxiliary_artifact_file_too_large", + ), + ( + codes::AUXILIARY_ARTIFACT_COUNT_LIMIT_EXCEEDED, + "auxiliary_artifact_count_limit_exceeded", + ), + ( + codes::CALIBRATION_PROFILE_TOO_LARGE, + "calibration_profile_too_large", + ), + ( + codes::ENCODER_DISTORTION_PROFILE_TOO_LARGE, + "encoder_distortion_profile_too_large", + ), + ( + codes::ROW_IDENTITY_LINE_TOO_LARGE, + "row_identity_line_too_large", + ), + ( + codes::ROW_IDENTITY_ROW_COUNT_LIMIT_EXCEEDED, + "row_identity_row_count_limit_exceeded", + ), + ( + codes::ROW_IDENTITY_DUPLICATE_TRACKING_LIMIT_EXCEEDED, + "row_identity_duplicate_tracking_limit_exceeded", + ), + ( + codes::VERIFICATION_REPORT_ISSUE_LIMIT_EXCEEDED, + "verification_report_issue_limit_exceeded", + ), + ]; + for (actual, expected) in locked { + assert_eq!(actual, expected); + } +} + +const LIB_RS: &str = include_str!("../src/lib.rs"); + +/// Emit sites must reference `codes::` constants, never bare string literals: +/// scans src/lib.rs for issue-emitting calls whose code argument is a literal. +#[test] +fn emit_sites_reference_code_consts_not_literals() { + // (call pattern, zero-based index of the code argument) + let emitters: &[(&str, usize)] = &[ + (".error(", 0), + ("push_report_issue_bounded(", 2), + ("mark_auxiliary_artifact_failed(", 1), + ("ReportIssue::new(", 0), + ]; + let mut violations = Vec::new(); + for (pattern, code_arg) in emitters { + for line in literal_code_arg_lines(LIB_RS, pattern, *code_arg) { + violations.push(format!("src/lib.rs:{line}: {pattern}\"...\"")); + } + } + assert!( + violations.is_empty(), + "bare string literals at emit sites (use ordvec_manifest::codes consts):\n{}", + violations.join("\n") + ); +} + +/// Returns 1-based line numbers of `pattern` call sites whose argument at +/// `code_arg` (zero-based, at call depth) is a bare string literal. +fn literal_code_arg_lines(src: &str, pattern: &str, code_arg: usize) -> Vec { + let mut lines = Vec::new(); + let mut search_from = 0; + while let Some(found) = src[search_from..].find(pattern) { + let call_site = search_from + found; + let args_start = call_site + pattern.len(); + if let Some(arg) = nth_call_arg(&src[args_start..], code_arg) { + if arg.trim_start().starts_with('"') { + lines.push(src[..call_site].bytes().filter(|b| *b == b'\n').count() + 1); + } + } + search_from = args_start; + } + lines +} + +/// Extracts the `n`th comma-separated argument at depth 1 of a call whose +/// opening parenthesis directly precedes `rest`. Tracks string literals and +/// nested brackets so embedded commas do not split arguments. +fn nth_call_arg(rest: &str, n: usize) -> Option { + let mut depth = 1usize; + let mut arg_index = 0usize; + let mut current = String::new(); + let mut in_string = false; + let mut escaped = false; + for c in rest.chars() { + if in_string { + if escaped { + escaped = false; + } else if c == '\\' { + escaped = true; + } else if c == '"' { + in_string = false; + } + current.push(c); + continue; + } + match c { + '"' => { + in_string = true; + current.push(c); + } + '(' | '[' | '{' => { + depth += 1; + current.push(c); + } + ')' | ']' | '}' => { + depth -= 1; + if depth == 0 { + return (arg_index == n).then_some(current); + } + current.push(c); + } + ',' if depth == 1 => { + if arg_index == n { + return Some(current); + } + arg_index += 1; + current.clear(); + } + _ => current.push(c), + } + } + None +} + +use ordvec_manifest::{ + ArtifactReport, CalibrationReport, EncoderDistortionReport, ReportIssue, RowIdentityReport, + VerificationCode, VerificationReport, +}; + +#[test] +fn classification_round_trips_every_mapped_variant() { + let expected: &[(&str, VerificationCode)] = &[ + ( + codes::ARTIFACT_SHA256_MISMATCH, + VerificationCode::ArtifactSha256Mismatch, + ), + ( + codes::ARTIFACT_FILE_SIZE_MISMATCH, + VerificationCode::ArtifactFileSizeMismatch, + ), + (codes::ARTIFACT_MISSING, VerificationCode::ArtifactMissing), + ( + codes::AUXILIARY_ARTIFACT_SHA256_MISMATCH, + VerificationCode::AuxiliarySha256Mismatch, + ), + ( + codes::AUXILIARY_ARTIFACT_FILE_SIZE_MISMATCH, + VerificationCode::AuxiliaryFileSizeMismatch, + ), + ( + codes::AUXILIARY_ARTIFACT_MISSING_REQUIRED, + VerificationCode::AuxiliaryMissingRequired, + ), + ( + codes::ROW_IDENTITY_SHA256_MISMATCH, + VerificationCode::RowIdentitySha256Mismatch, + ), + ( + codes::ROW_IDENTITY_ROW_COUNT_MISMATCH, + VerificationCode::RowIdentityRowCountMismatch, + ), + ( + codes::ROW_IDENTITY_MISSING, + VerificationCode::RowIdentityMissing, + ), + ( + codes::SCHEMA_VERSION_UNSUPPORTED, + VerificationCode::ManifestSchema, + ), + ( + codes::MANIFEST_FILE_TOO_LARGE, + VerificationCode::ResourceLimit, + ), + ( + codes::ARTIFACT_FILE_TOO_LARGE, + VerificationCode::ResourceLimit, + ), + ( + codes::AUXILIARY_ARTIFACT_FILE_TOO_LARGE, + VerificationCode::ResourceLimit, + ), + ( + codes::AUXILIARY_ARTIFACT_COUNT_LIMIT_EXCEEDED, + VerificationCode::ResourceLimit, + ), + ( + codes::CALIBRATION_PROFILE_TOO_LARGE, + VerificationCode::ResourceLimit, + ), + ( + codes::ENCODER_DISTORTION_PROFILE_TOO_LARGE, + VerificationCode::ResourceLimit, + ), + ( + codes::ROW_IDENTITY_LINE_TOO_LARGE, + VerificationCode::ResourceLimit, + ), + ( + codes::ROW_IDENTITY_ROW_COUNT_LIMIT_EXCEEDED, + VerificationCode::ResourceLimit, + ), + ( + codes::ROW_IDENTITY_DUPLICATE_TRACKING_LIMIT_EXCEEDED, + VerificationCode::ResourceLimit, + ), + ( + codes::SQLITE_CACHED_REPORT_TOO_LARGE, + VerificationCode::ResourceLimit, + ), + ( + codes::VERIFICATION_REPORT_ISSUE_LIMIT_EXCEEDED, + VerificationCode::ResourceLimit, + ), + ]; + for (code, variant) in expected { + assert_eq!( + ReportIssue::new(*code, "message").classification(), + *variant, + "code {code:?} must classify as {variant:?}" + ); + } +} + +#[test] +fn unmapped_and_unknown_codes_classify_as_unknown() { + for code in [ + "not_a_known_code", + "", + codes::EMBEDDING_MODEL_EMPTY, + codes::ARTIFACT_PATH_EMPTY, + // A non-NotFound canonicalize failure (permission denied, symlink + // loop, I/O) surfaces as *_path_unavailable and must NOT be + // classified as a missing file — only the NotFound-specific + // *_missing codes carry the "missing" meaning. + codes::ARTIFACT_PATH_UNAVAILABLE, + codes::ROW_IDENTITY_PATH_UNAVAILABLE, + ] { + assert_eq!( + ReportIssue::new(code, "message").classification(), + VerificationCode::Unknown + ); + } +} + +/// The `code` field stays a plain string and issues without structured detail +/// serialize exactly as before the typed-classification change. +#[test] +fn verification_report_json_is_byte_stable() { + let report = VerificationReport { + ok: false, + checked_at: "2026-07-05T00:00:00.000000000Z".to_string(), + artifact: ArtifactReport::default(), + auxiliary_artifacts: Vec::new(), + row_identity: RowIdentityReport::default(), + encoder_distortion: EncoderDistortionReport::default(), + calibration: CalibrationReport::default(), + attestation_shape_checks: Vec::new(), + errors: vec![ReportIssue::new( + codes::ARTIFACT_SHA256_MISMATCH, + "artifact SHA-256 was aa, manifest declares bb", + )], + warnings: Vec::new(), + skipped_checks: vec!["attestations_absent".to_string()], + }; + let golden = concat!( + "{\"ok\":false,\"checked_at\":\"2026-07-05T00:00:00.000000000Z\",", + "\"artifact\":{\"manifest_path\":null,\"observed_path\":null,", + "\"canonical_path\":null,\"sha256\":null,\"size_bytes\":null,", + "\"metadata\":null},\"auxiliary_artifacts\":[],", + "\"row_identity\":{\"kind\":null,\"manifest_path\":null,", + "\"canonical_path\":null,\"sha256\":null,\"row_count\":null,", + "\"validated_rows\":null},\"encoder_distortion\":{\"present\":false,", + "\"schema_version\":null,\"profile_id\":null,\"evidence_kind\":null,", + "\"source_metric\":null,\"embedding_metric\":null,", + "\"profile_manifest_path\":null,\"profile_canonical_path\":null,", + "\"profile_sha256\":null,\"profile_size_bytes\":null},", + "\"calibration\":{\"present\":false,\"schema_version\":null,", + "\"profile_id\":null,\"calibrated_for_model\":null,", + "\"ordinalization\":null,\"null_model\":null,", + "\"profile_manifest_path\":null,\"profile_canonical_path\":null,", + "\"profile_sha256\":null,\"profile_size_bytes\":null},", + "\"attestation_shape_checks\":[],", + "\"errors\":[{\"code\":\"artifact_sha256_mismatch\",", + "\"message\":\"artifact SHA-256 was aa, manifest declares bb\"}],", + "\"warnings\":[],\"skipped_checks\":[\"attestations_absent\"]}", + ); + assert_eq!(serde_json::to_string(&report).unwrap(), golden); +} + +#[test] +fn detailed_issue_serializes_structured_fields_and_round_trips() { + let issue = ReportIssue::new(codes::AUXILIARY_ARTIFACT_SHA256_MISMATCH, "msg") + .with_artifact_name("ordinaldb.ids") + .with_sha256_detail("aa", "bb") + .with_size_detail(1, 2); + let golden = concat!( + "{\"code\":\"auxiliary_artifact_sha256_mismatch\",\"message\":\"msg\",", + "\"artifact_name\":\"ordinaldb.ids\",\"expected_sha256\":\"aa\",", + "\"actual_sha256\":\"bb\",\"expected_size_bytes\":1,", + "\"actual_size_bytes\":2}", + ); + let json = serde_json::to_string(&issue).unwrap(); + assert_eq!(json, golden); + + let parsed: ReportIssue = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed.artifact_name.as_deref(), Some("ordinaldb.ids")); + assert_eq!(parsed.expected_sha256.as_deref(), Some("aa")); + assert_eq!(parsed.actual_sha256.as_deref(), Some("bb")); + assert_eq!(parsed.expected_size_bytes, Some(1)); + assert_eq!(parsed.actual_size_bytes, Some(2)); + assert_eq!( + parsed.classification(), + VerificationCode::AuxiliarySha256Mismatch + ); +} + +/// Pre-change report JSON (no structured fields) still deserializes. +#[test] +fn legacy_issue_json_still_parses() { + let parsed: ReportIssue = + serde_json::from_str("{\"code\":\"artifact_sha256_mismatch\",\"message\":\"m\"}").unwrap(); + assert_eq!(parsed.code, codes::ARTIFACT_SHA256_MISMATCH); + assert!(parsed.artifact_name.is_none()); + assert!(parsed.expected_sha256.is_none()); + assert!(parsed.actual_sha256.is_none()); + assert!(parsed.expected_size_bytes.is_none()); + assert!(parsed.actual_size_bytes.is_none()); +} diff --git a/ordvec-python/Cargo.toml b/ordvec-python/Cargo.toml index fb0a3cd..9e67885 100644 --- a/ordvec-python/Cargo.toml +++ b/ordvec-python/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ordvec-python" -version = "0.6.0" +version = "0.7.0" edition = "2021" rust-version = "1.89" # inherits ordvec's AVX-512 MSRV floor description = "Python bindings for ordvec — training-free ordinal & sign vector quantization" diff --git a/ordvec-python/pyproject.toml b/ordvec-python/pyproject.toml index 79b26f7..918213c 100644 --- a/ordvec-python/pyproject.toml +++ b/ordvec-python/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "maturin" [project] name = "ordvec" -version = "0.6.0" +version = "0.7.0" description = "Training-free ordinal & sign quantization for compressed vector retrieval" readme = "README.md" requires-python = ">=3.10" diff --git a/ordvec-python/python/ordvec/__init__.py b/ordvec-python/python/ordvec/__init__.py index 5cfa291..3788968 100644 --- a/ordvec-python/python/ordvec/__init__.py +++ b/ordvec-python/python/ordvec/__init__.py @@ -115,4 +115,4 @@ "SignBitmapIndex", ] -__version__ = "0.6.0" +__version__ = "0.7.0"