Add typed schema records and strict version checks

This commit is contained in:
ms2k 2026-07-13 04:00:50 +09:00
commit 16defc768a
10 changed files with 3164 additions and 1 deletions

122
Cargo.lock generated
View file

@ -2,6 +2,27 @@
# It is not intended for manual editing.
version = 4
[[package]]
name = "cobs"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1"
dependencies = [
"thiserror",
]
[[package]]
name = "embedded-io"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ef1a6892d9eef45c8fa6b9e0086428a2cca8491aca8f787c534a3d6d0bcb3ced"
[[package]]
name = "embedded-io"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d"
[[package]]
name = "lazaret-client"
version = "0.0.0"
@ -12,6 +33,10 @@ dependencies = [
[[package]]
name = "lazaret-core"
version = "0.0.0"
dependencies = [
"postcard",
"serde",
]
[[package]]
name = "lazaret-escalation-client"
@ -40,3 +65,100 @@ version = "0.0.0"
dependencies = [
"lazaret-core",
]
[[package]]
name = "postcard"
version = "1.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6764c3b5dd454e283a30e6dfe78e9b31096d9e32036b5d1eaac7a6119ccb9a24"
dependencies = [
"cobs",
"embedded-io 0.4.0",
"embedded-io 0.6.1",
"serde",
]
[[package]]
name = "proc-macro2"
version = "1.0.106"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368"
dependencies = [
"proc-macro2",
]
[[package]]
name = "serde"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
dependencies = [
"serde_core",
"serde_derive",
]
[[package]]
name = "serde_core"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "syn"
version = "2.0.118"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "thiserror"
version = "2.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
dependencies = [
"thiserror-impl",
]
[[package]]
name = "thiserror-impl"
version = "2.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "unicode-ident"
version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"

View file

@ -15,6 +15,10 @@ edition = "2024"
rust-version = "1.90"
license = "Apache-2.0"
[workspace.dependencies]
postcard = { version = "=1.1.3", default-features = false, features = ["use-std"] }
serde = { version = "=1.0.228", features = ["derive"] }
[workspace.lints.rust]
unsafe_code = "forbid"
unused = { level = "deny", priority = -1 }

View file

@ -5,5 +5,9 @@ edition.workspace = true
rust-version.workspace = true
license.workspace = true
[dependencies]
postcard.workspace = true
serde.workspace = true
[lints]
workspace = true

View file

@ -0,0 +1,85 @@
//! Strict bounded Postcard structure checks shared by schema-record persistence and tests.
use std::fmt;
use serde::Serialize;
use serde::de::DeserializeOwned;
pub const MAX_SCHEMA_RECORD_BYTES: usize = 1024 * 1024;
#[derive(Debug)]
pub enum CodecError {
RecordTooLarge { length: usize, maximum: usize },
TrailingPayload,
NonMinimalPayload,
MalformedPayload(postcard::Error),
}
impl fmt::Display for CodecError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::RecordTooLarge { length, maximum } => {
write!(formatter, "schema record length {length} exceeds {maximum}")
}
Self::TrailingPayload => {
formatter.write_str("schema record has trailing payload bytes")
}
Self::NonMinimalPayload => formatter
.write_str("schema record does not use the minimal Postcard representation"),
Self::MalformedPayload(error) => write!(formatter, "malformed schema payload: {error}"),
}
}
}
impl std::error::Error for CodecError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::MalformedPayload(error) => Some(error),
_ => None,
}
}
}
/// Encodes one schema value using Postcard's minimal representation.
///
/// # Errors
///
/// Returns an error when serialization fails or the encoded value exceeds the generic limit.
pub fn encode<T: Serialize>(record: &T) -> Result<Vec<u8>, CodecError> {
let encoded = postcard::to_stdvec(record).map_err(CodecError::MalformedPayload)?;
if encoded.len() > MAX_SCHEMA_RECORD_BYTES {
return Err(CodecError::RecordTooLarge {
length: encoded.len(),
maximum: MAX_SCHEMA_RECORD_BYTES,
});
}
Ok(encoded)
}
/// Decodes one complete, minimally encoded schema value after enforcing the generic size limit.
///
/// # Errors
///
/// Returns an error for oversized, malformed, trailing, or non-minimal Postcard input.
pub fn decode<T>(encoded: &[u8]) -> Result<T, CodecError>
where
T: DeserializeOwned + Serialize,
{
if encoded.len() > MAX_SCHEMA_RECORD_BYTES {
return Err(CodecError::RecordTooLarge {
length: encoded.len(),
maximum: MAX_SCHEMA_RECORD_BYTES,
});
}
let (record, remainder) =
postcard::take_from_bytes(encoded).map_err(CodecError::MalformedPayload)?;
if !remainder.is_empty() {
return Err(CodecError::TrailingPayload);
}
let minimal = postcard::to_stdvec(&record).map_err(CodecError::MalformedPayload)?;
if minimal != encoded {
return Err(CodecError::NonMinimalPayload);
}
Ok(record)
}

View file

@ -0,0 +1,191 @@
use serde::{Deserialize, Serialize};
pub const OPAQUE_ID_BYTES: usize = 32;
pub const SHA256_BYTES: usize = 32;
macro_rules! opaque_identity {
($($name:ident),+ $(,)?) => {
$(
#[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
pub struct $name([u8; OPAQUE_ID_BYTES]);
impl $name {
#[must_use]
pub const fn new(bytes: [u8; OPAQUE_ID_BYTES]) -> Self {
Self(bytes)
}
#[must_use]
pub const fn as_bytes(&self) -> &[u8; OPAQUE_ID_BYTES] {
&self.0
}
}
)+
};
}
macro_rules! digest_identity {
($($name:ident),+ $(,)?) => {
$(
#[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
pub struct $name([u8; SHA256_BYTES]);
impl $name {
#[must_use]
pub const fn new(bytes: [u8; SHA256_BYTES]) -> Self {
Self(bytes)
}
#[must_use]
pub const fn as_bytes(&self) -> &[u8; SHA256_BYTES] {
&self.0
}
}
)+
};
}
macro_rules! numeric_identity {
($($name:ident($inner:ty)),+ $(,)?) => {
$(
#[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
pub struct $name($inner);
impl $name {
#[must_use]
pub const fn new(value: $inner) -> Self {
Self(value)
}
#[must_use]
pub const fn get(self) -> $inner {
self.0
}
}
)+
};
}
opaque_identity!(
DomainId,
CoordinatorId,
NetworkSidecarId,
SandboxId,
ClientId,
SessionId,
SessionAnchorId,
RequestId,
PreparedArtifactId,
PrepareOperationId,
ProtocolCredentialId,
CapabilityId,
ContainerId,
PolicyId,
);
digest_identity!(
ConfigurationFingerprint,
DerivedPolicyDigest,
ReadinessFingerprint,
BundleDigest,
SelectedManifestDigest,
IdMapDigest,
CertificateDigest,
TrustManifestDigest,
RequestDigest,
StreamDigest,
);
numeric_identity!(
GenerationId(u64),
ProcessId(u32),
ProcessStartTime(u64),
GuestProcessGroupId(u32),
FileDescriptor(i32),
DeviceId(u64),
InodeId(u64),
MountId(u64),
);
#[derive(Clone, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
pub struct HostPath(Vec<u8>);
impl HostPath {
#[must_use]
pub const fn new(bytes: Vec<u8>) -> Self {
Self(bytes)
}
#[must_use]
pub fn as_bytes(&self) -> &[u8] {
&self.0
}
}
#[derive(Clone, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
pub struct SourceIdentity(Vec<u8>);
impl SourceIdentity {
#[must_use]
pub const fn new(bytes: Vec<u8>) -> Self {
Self(bytes)
}
#[must_use]
pub fn as_bytes(&self) -> &[u8] {
&self.0
}
}
#[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)]
pub struct ProcessIdentity {
pub pid: ProcessId,
pub start_time: ProcessStartTime,
}
#[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)]
pub struct NamespaceIdentity {
pub device: DeviceId,
pub inode: InodeId,
}
#[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)]
pub struct MountIdentity {
pub mount_id: MountId,
pub device: DeviceId,
pub inode: InodeId,
}
#[cfg(test)]
mod tests {
use std::any::TypeId;
use std::collections::HashSet;
use super::{
ContainerId, CoordinatorId, DomainId, GenerationId, MountIdentity, NamespaceIdentity,
NetworkSidecarId, PreparedArtifactId, ProcessIdentity, RequestId, SandboxId, SessionId,
};
#[test]
fn identity_categories_are_nominally_distinct() {
let identities = [
TypeId::of::<DomainId>(),
TypeId::of::<GenerationId>(),
TypeId::of::<CoordinatorId>(),
TypeId::of::<NetworkSidecarId>(),
TypeId::of::<SandboxId>(),
TypeId::of::<SessionId>(),
TypeId::of::<RequestId>(),
TypeId::of::<PreparedArtifactId>(),
TypeId::of::<ContainerId>(),
TypeId::of::<ProcessIdentity>(),
TypeId::of::<NamespaceIdentity>(),
TypeId::of::<MountIdentity>(),
];
assert_eq!(
identities.into_iter().collect::<HashSet<_>>().len(),
identities.len()
);
}
}

View file

@ -1,5 +1,9 @@
#![doc = "Shared production interfaces for Lazaret processes."]
pub mod codec;
pub mod identity;
pub mod records;
pub const PROTOCOL_NAME: &str = "lazaret";
#[cfg(test)]

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -768,3 +768,16 @@ identity with a schema-specific domain separator. Process identities and network
observations are attestation evidence but are not part of that fingerprint. The
Host Coordinator binds the accepted attestation and generation-bound reachability
probe into Network Readiness Evidence before publication.
`NetworkReadinessAttestation.schema_id` is exactly
`lazaret-network-attestation-v1`.
`NetworkReadinessIdentity.schema_id` is exactly
`lazaret-network-readiness-identity-v1`. They are independent version-dispatch
domains: the v1 attestation projects only to the v1 readiness identity, and any
other identifier or version is refused without fallback or cross-domain
interpretation.
The readiness-fingerprint domain separator is the UTF-8 bytes of
`lazaret-network-readiness-identity-v1` followed by exactly one zero byte. This
fixes the domain-separation context only; it does not select the byte encoding of
the canonical readiness-identity fields.

View file

@ -74,7 +74,7 @@ kind = "invariant"
symbols = ["TypeOK", "DomainGenerationBounds", "RuntimeRoleIdentitySeparation", "GenerationScopedIdentitiesDistinct"]
owners = ["IP-01", "IP-10", "IP-14", "IP-15", "IP-30"]
levels = ["V1"]
verifier = "Type/property tests and lifecycle state assertions"
verifier = "IP-01 strict schema-codec, malformed-version, record-invariant, and nominal identity tests plus later lifecycle state assertions"
[[coverage]]
model = "Lazaret"