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. # It is not intended for manual editing.
version = 4 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]] [[package]]
name = "lazaret-client" name = "lazaret-client"
version = "0.0.0" version = "0.0.0"
@ -12,6 +33,10 @@ dependencies = [
[[package]] [[package]]
name = "lazaret-core" name = "lazaret-core"
version = "0.0.0" version = "0.0.0"
dependencies = [
"postcard",
"serde",
]
[[package]] [[package]]
name = "lazaret-escalation-client" name = "lazaret-escalation-client"
@ -40,3 +65,100 @@ version = "0.0.0"
dependencies = [ dependencies = [
"lazaret-core", "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" rust-version = "1.90"
license = "Apache-2.0" 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] [workspace.lints.rust]
unsafe_code = "forbid" unsafe_code = "forbid"
unused = { level = "deny", priority = -1 } unused = { level = "deny", priority = -1 }

View file

@ -5,5 +5,9 @@ edition.workspace = true
rust-version.workspace = true rust-version.workspace = true
license.workspace = true license.workspace = true
[dependencies]
postcard.workspace = true
serde.workspace = true
[lints] [lints]
workspace = true 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."] #![doc = "Shared production interfaces for Lazaret processes."]
pub mod codec;
pub mod identity;
pub mod records;
pub const PROTOCOL_NAME: &str = "lazaret"; pub const PROTOCOL_NAME: &str = "lazaret";
#[cfg(test)] #[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 observations are attestation evidence but are not part of that fingerprint. The
Host Coordinator binds the accepted attestation and generation-bound reachability Host Coordinator binds the accepted attestation and generation-bound reachability
probe into Network Readiness Evidence before publication. 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"] symbols = ["TypeOK", "DomainGenerationBounds", "RuntimeRoleIdentitySeparation", "GenerationScopedIdentitiesDistinct"]
owners = ["IP-01", "IP-10", "IP-14", "IP-15", "IP-30"] owners = ["IP-01", "IP-10", "IP-14", "IP-15", "IP-30"]
levels = ["V1"] 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]] [[coverage]]
model = "Lazaret" model = "Lazaret"