Make codec shape authority compile-time and fixed

This commit is contained in:
ms2k 2026-07-19 04:33:43 +09:00
commit 54cc167f81
7 changed files with 322 additions and 52 deletions

View file

@ -5,6 +5,8 @@ use std::fmt;
use serde::Serialize;
use serde::de::DeserializeOwned;
use crate::records::SchemaType;
pub const MAX_SCHEMA_RECORD_BYTES: usize = 1024 * 1024;
#[derive(Debug)]
@ -45,7 +47,7 @@ impl std::error::Error for CodecError {
/// # 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> {
pub fn encode<T: SchemaType + 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 {
@ -63,7 +65,7 @@ pub fn encode<T: Serialize>(record: &T) -> Result<Vec<u8>, CodecError> {
/// 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,
T: DeserializeOwned + SchemaType + Serialize,
{
if encoded.len() > MAX_SCHEMA_RECORD_BYTES {
return Err(CodecError::RecordTooLarge {

View file

@ -2,9 +2,20 @@ use std::ffi::{OsStr, OsString};
use crate::codec::{CodecError, encode};
use crate::identity::{ClientId, RequestId, SessionId};
use crate::records::GenerationIdentity;
use crate::records::{GenerationIdentity, SchemaType};
use serde::Serialize;
use sha2::{Digest, Sha256};
#[derive(Serialize)]
pub(crate) struct ClientEnrollmentBindingV1<'a> {
generation_identity: &'a GenerationIdentity,
cli_id: ClientId,
}
impl SchemaType for ClientEnrollmentBindingV1<'_> {
const NAME: &'static str = "ClientEnrollmentBindingV1";
}
fn identifier_hex(bytes: &[u8]) -> String {
use std::fmt::Write as _;
@ -22,7 +33,10 @@ pub fn client_enrollment_binding(
generation_identity: &GenerationIdentity,
cli_id: ClientId,
) -> Result<String, CodecError> {
let encoded = encode(&(generation_identity, cli_id))?;
let encoded = encode(&ClientEnrollmentBindingV1 {
generation_identity,
cli_id,
})?;
Ok(format!(
"lazaret-client-{}",
identifier_hex(&Sha256::digest(encoded))
@ -83,3 +97,27 @@ pub fn result_chunk_file_name(request_id: RequestId, sequence: u64) -> OsString
pub fn is_ready_record(name: &OsStr) -> bool {
name.as_encoded_bytes().ends_with(b".ready")
}
#[cfg(test)]
mod tests {
use super::*;
use crate::identity::{
ConfigurationFingerprint, CoordinatorId, DomainId, GenerationId, SandboxId,
};
#[test]
fn client_enrollment_binding_vector_is_stable() {
let identity = GenerationIdentity {
agent_profile: "trusted".into(),
domain_id: DomainId::new([1; 32]),
generation_id: GenerationId::new(2),
configuration_fingerprint: ConfigurationFingerprint::new([4; 32]),
coordinator_id: CoordinatorId::new([3; 32]),
sandbox_id: SandboxId::new([5; 32]),
};
assert_eq!(
client_enrollment_binding(&identity, ClientId::new([6; 32])).unwrap(),
"lazaret-client-63c1e3cd11c3acb917053499be99acc57b3e67fdbb332dbf6d03a7b1540d478d"
);
}
}

View file

@ -5,7 +5,7 @@ use serde::{Deserialize, Serialize};
use crate::codec;
use crate::identity::{ClientId, ConfigurationFingerprint, DomainId, SessionAnchorId, SessionId};
use crate::records::ProtocolVersion;
use crate::records::{ProtocolVersion, SchemaType};
pub const BOOTSTRAP_WAITER_SOCKET_NAME: &str = "wait.sock";
pub const PRIVATE_CLIENT_BOOTSTRAP_ROLE: &str = "--lazaret-private-client-bootstrap-v1";
@ -44,7 +44,7 @@ impl std::error::Error for BootstrapWaiterFrameError {}
///
/// # Errors
/// Refuses oversized or unencodable records and stream write failures.
pub fn write_waiter_frame<T: Serialize>(
pub fn write_waiter_frame<T: SchemaType + Serialize>(
stream: &mut impl Write,
record: &T,
) -> Result<(), BootstrapWaiterFrameError> {
@ -64,7 +64,7 @@ pub fn write_waiter_frame<T: Serialize>(
/// Refuses oversized, malformed, or incomplete frames and stream read failures.
pub fn read_waiter_frame<T>(stream: &mut impl Read) -> Result<T, BootstrapWaiterFrameError>
where
T: serde::de::DeserializeOwned + Serialize,
T: SchemaType + serde::de::DeserializeOwned + Serialize,
{
let mut length = [0_u8; 4];
stream.read_exact(&mut length).map_err(failure)?;

View file

@ -2,29 +2,30 @@ use super::{
AddressFamily, AddressFamilyDisposition, ArtifactLease, ArtifactState, ClientEnrollmentRequest,
ClientEnrollmentResponse, ConfigurationFingerprintRecordV1, ControlRequest, ControlResponse,
DefaultAction, DeniedDestinationObservation, DerivedNetworkPolicy, DeveloperDecision,
DeveloperDecisionKind, DnsObservation, Domain, DomainStore, EnvironmentReference,
EscalationOutcome, Generation, GenerationIdentity, GitRoutingOwnership, GuestProcessIdentity,
HelperOwnership, HostProcessIdentity, HostProcessOwnershipSlot, InteractiveSessionBatch,
InteractiveSessionEvent, InteractiveSessionMessage, InteractiveSessionRequest, Mailbox,
MailboxCancellation, MailboxRequest, MailboxResult, MailboxResultAck, MailboxResultChunk,
MountIdentity, NamespaceIdentity, NetworkObservation, NetworkOwnership,
NetworkReachabilityProbe, NetworkReadinessAttestation, NetworkReadinessEvidence,
NetworkReadinessIdentity, NonInteractiveOutput, NonInteractiveSessionMessage,
NonInteractiveSessionRequest, Participant, ParticipantOwnership, ParticipantState,
PermittedDestinationObservation, PersistedHostProcessOwnership, PidfsProcessAuthorityV1,
PidfsProcessIdentityV1, PolicyAction, PolicyDelivery, PrefixRule, PrepareOperation,
PrepareStageState, PreparedArtifact, PreparedLowerOwnership, ProcessOwnershipCommitAck,
ProcessOwnershipMessageVersion, ProtocolCredential, ProtocolCredentialRole,
ProtocolPublicationIdentity, PtyIdentity, PublicationOwnership, ResolvedAutoApprovalRule,
ResolvedNetworkReadinessV1, ResolvedVolume, Resolver, ResultStream, SandboxCredentialBundle,
SandboxOwnership, Session, SessionBinding, SessionClose, SessionExit, SessionExitOutcome,
SessionRegistrationRequest, SessionRegistrationResponse, SessionRequestCapability,
SessionTerminal, SessionTerminalEvent, SessionTerminalMessage, SessionTerminalState,
SignedProtocolRecord, StagedProcessOwnership, StateAnchorOwnership, StateAnchorReleaseEvidence,
StorageCatalog, StoreMetadata, TerminalInput, TerminalOutput, TerminalPrompt, TerminalResize,
TerminalSignal, TerminalSignalKind, TlsCredential, TlsRole, TrustManifest,
TrustedHostConfigurationV1,
DeveloperDecisionKind, DnsObservation, Domain, DomainStore, DomainStoreMetadata,
EnvironmentReference, EscalationOutcome, Generation, GenerationIdentity, GitRoutingOwnership,
GuestProcessIdentity, HelperOwnership, HostProcessIdentity, HostProcessOwnershipSlot,
InteractiveSessionBatch, InteractiveSessionEvent, InteractiveSessionMessage,
InteractiveSessionRequest, Mailbox, MailboxCancellation, MailboxRequest, MailboxResult,
MailboxResultAck, MailboxResultChunk, MountIdentity, NamespaceIdentity, NetworkObservation,
NetworkOwnership, NetworkReachabilityProbe, NetworkReadinessAttestation,
NetworkReadinessEvidence, NetworkReadinessIdentity, NonInteractiveOutput,
NonInteractiveSessionMessage, NonInteractiveSessionRequest, Participant, ParticipantOwnership,
ParticipantState, PermittedDestinationObservation, PersistedHostProcessOwnership,
PidfsProcessAuthorityV1, PidfsProcessIdentityV1, PolicyAction, PolicyDelivery, PrefixRule,
PrepareOperation, PrepareStageState, PreparedArtifact, PreparedLowerOwnership,
ProcessOwnershipCommitAck, ProcessOwnershipMessageVersion, ProtocolCredential,
ProtocolCredentialRole, ProtocolPublicationIdentity, PtyIdentity, PublicationOwnership,
ResolvedAutoApprovalRule, ResolvedNetworkReadinessV1, ResolvedVolume, Resolver, ResultStream,
SandboxCredentialBundle, SandboxOwnership, Session, SessionBinding, SessionClose, SessionExit,
SessionExitOutcome, SessionRegistrationRequest, SessionRegistrationResponse,
SessionRequestCapability, SessionTerminal, SessionTerminalEvent, SessionTerminalMessage,
SessionTerminalState, SignedProtocolRecord, StagedProcessOwnership, StateAnchorOwnership,
StateAnchorReleaseEvidence, StorageCatalog, StorageCatalogMetadata, StoreMetadata,
TerminalInput, TerminalOutput, TerminalPrompt, TerminalResize, TerminalSignal,
TerminalSignalKind, TlsCredential, TlsRole, TrustManifest, TrustedHostConfigurationV1,
};
use crate::protocol::{BootstrapWaitOutcome, BootstrapWaitRequest};
pub trait SchemaType {
const NAME: &'static str;
@ -44,6 +45,8 @@ macro_rules! schema_types {
schema_types!(
"StoreMetadata" => StoreMetadata,
"DomainStoreMetadata" => DomainStoreMetadata,
"StorageCatalogMetadata" => StorageCatalogMetadata,
"Domain" => Domain,
"Generation" => Generation,
"Participant" => Participant,
@ -146,4 +149,6 @@ schema_types!(
"DomainStore" => DomainStore,
"StorageCatalog" => StorageCatalog,
"PrepareStageState" => PrepareStageState,
"BootstrapWaitRequest" => BootstrapWaitRequest,
"BootstrapWaitOutcome" => BootstrapWaitOutcome,
);

View file

@ -15,9 +15,39 @@ use lazaret_core::identity::{
SessionAnchorId, SessionId, SourceIdentity, StreamDigest, TrustManifestDigest,
};
use lazaret_core::network::readiness_fingerprint as derive_readiness_fingerprint;
use lazaret_core::protocol::{BootstrapWaitOutcome, BootstrapWaitRequest};
use lazaret_core::records::*;
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
#[derive(Default)]
struct SchemaCorpus {
names: HashSet<&'static str>,
digest: Sha256,
}
impl SchemaCorpus {
fn insert(&mut self, name: &'static str, encoded: &[u8]) -> bool {
self.digest.update(name.len().to_be_bytes());
self.digest.update(name.as_bytes());
self.digest.update(encoded.len().to_be_bytes());
self.digest.update(encoded);
self.names.insert(name)
}
fn len(&self) -> usize {
self.names.len()
}
fn contains(&self, name: &&str) -> bool {
self.names.contains(name)
}
fn digest(&self) -> [u8; 32] {
self.digest.clone().finalize().into()
}
}
#[derive(Deserialize, Serialize)]
struct RawParticipant {
@ -168,6 +198,39 @@ struct RawTrustedHostConfigurationV1 {
profiles: BTreeMap<String, ProfileConfigurationV1>,
}
#[derive(Serialize)]
struct OversizedRecord(Vec<u8>);
macro_rules! test_schema_types {
($($record:ty),+ $(,)?) => {
$(
impl SchemaType for $record {
const NAME: &'static str = concat!("test::", stringify!($record));
}
)+
};
}
test_schema_types!(
RawParticipant,
RawTlsCredential,
RawProtocolCredential,
RawSandboxCredentialBundle,
RawMetadata,
MissingVersion,
RawDomainStore,
RawStorageCatalog,
RawTerminalResize,
RawSessionTerminal,
InvalidSessionExit,
RawDerivedNetworkPolicy,
RawNetworkReadinessAttestation,
RawNetworkReadinessIdentity,
RawMailboxRequest,
RawTrustedHostConfigurationV1,
OversizedRecord,
);
fn opaque(byte: u8) -> [u8; 32] {
[byte; 32]
}
@ -604,12 +667,16 @@ fn participant_edge_snapshot(
.unwrap()
}
fn verify_shape<T>(record: T, seen: &mut HashSet<&'static str>)
fn verify_shape<T>(record: T, seen: &mut SchemaCorpus)
where
T: Clone + Debug + DeserializeOwned + Eq + SchemaType + Serialize,
{
assert!(seen.insert(T::NAME), "duplicate test for {}", T::NAME);
let encoded = encode(&record).expect("representative record encodes");
assert!(
seen.insert(T::NAME, &encoded),
"duplicate test for {}",
T::NAME
);
assert_eq!(encode(&record).unwrap(), encoded);
assert_eq!(decode::<T>(&encoded).unwrap(), record);

View file

@ -223,11 +223,11 @@ fn zero_terminal_dimensions_are_rejected() {
fn generic_schema_record_limit_is_enforced_before_decode() {
let oversized = vec![0; MAX_SCHEMA_RECORD_BYTES + 1];
assert!(matches!(
decode::<Vec<u8>>(&oversized),
decode::<StoreMetadata>(&oversized),
Err(CodecError::RecordTooLarge { .. })
));
assert!(matches!(
encode(&oversized),
encode(&OversizedRecord(oversized)),
Err(CodecError::RecordTooLarge { .. })
));
}

View file

@ -84,9 +84,11 @@ fn every_schema_shape_round_trips_and_rejects_malformed_or_unknown_versions() {
inode: InodeId::new(47),
state: ArtifactState::Available,
};
let mut seen = HashSet::new();
let mut seen = SchemaCorpus::default();
verify_shape(metadata.clone(), &mut seen);
verify_shape(DomainStoreMetadata::v1(), &mut seen);
verify_shape(StorageCatalogMetadata::v1(), &mut seen);
verify_shape(domain.clone(), &mut seen);
verify_shape(generation.clone(), &mut seen);
verify_shape(participant.clone(), &mut seen);
@ -669,6 +671,18 @@ image = "example.invalid/agent:v1"
},
&mut seen,
);
verify_shape(
BootstrapWaitRequest {
protocol_version: ProtocolVersion::V1,
domain_id: domain_id(),
configuration_fingerprint: ConfigurationFingerprint::new(opaque(6)),
cli_id: ClientId::new(opaque(13)),
session_id: session_id(),
session_anchor_id: SessionAnchorId::new(opaque(38)),
},
&mut seen,
);
verify_shape(BootstrapWaitOutcome::Published, &mut seen);
assert_eq!(seen.len(), SERIALIZED_RECORD_TYPE_NAMES.len());
assert!(
@ -676,11 +690,18 @@ image = "example.invalid/agent:v1"
.iter()
.all(|name| seen.contains(name))
);
assert_eq!(
seen.digest(),
[
255, 220, 6, 56, 49, 12, 147, 182, 148, 61, 218, 228, 124, 211, 136, 128, 186, 186,
138, 239, 246, 197, 75, 0, 75, 85, 27, 244, 21, 21, 246, 41,
]
);
}
fn assert_unknown_protocol_version<T>(record: &T)
where
T: DeserializeOwned + Serialize,
T: DeserializeOwned + SchemaType + Serialize,
{
let mut encoded = encode(record).unwrap();
encoded[0] = u8::MAX;