Require explicit canonical network readiness targets
This commit is contained in:
parent
c8acf116d6
commit
fbd332c31f
6 changed files with 339 additions and 12 deletions
|
|
@ -12,8 +12,10 @@ use sha2::{Digest, Sha256};
|
|||
|
||||
use crate::identity::{ConfigurationFingerprint, DeviceId, DomainId, HostPath, InodeId, MountId};
|
||||
use crate::records::{
|
||||
ConfigurationFingerprintRecordV1, EnvironmentReference, MailboxRequest, ProfileConfigurationV1,
|
||||
ResolvedAutoApprovalRule, ResolvedVolume, TRUSTED_HOST_CONFIGURATION_SCHEMA_ID,
|
||||
ConfigurationFingerprintRecordV1, EnvironmentReference, MailboxRequest,
|
||||
NetworkProbeTargetConfigurationV1, NetworkReadinessConfigurationV1, ProfileConfigurationV1,
|
||||
ResolvedAutoApprovalRule, ResolvedDnsProbeTargetV1, ResolvedNetworkProbeTargetV1,
|
||||
ResolvedNetworkReadinessV1, ResolvedVolume, TRUSTED_HOST_CONFIGURATION_SCHEMA_ID,
|
||||
TrustedHostConfigurationV1, VolumeAccess, VolumeKind,
|
||||
};
|
||||
|
||||
|
|
@ -656,6 +658,11 @@ impl ResolvedDomainConfiguration {
|
|||
&self.effective_deny_prefixes
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn network_readiness(&self) -> &ResolvedNetworkReadinessV1 {
|
||||
&self.fingerprint_record.network_readiness
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn ephemeral_environment(&self) -> &BTreeMap<String, EnvironmentValue> {
|
||||
&self.ephemeral_environment
|
||||
|
|
@ -843,6 +850,11 @@ pub fn load_trusted_configuration(
|
|||
)?;
|
||||
let (effective_allow_prefixes, effective_deny_prefixes) =
|
||||
resolve_network(&configuration, selected)?;
|
||||
let network_readiness = resolve_network_readiness(
|
||||
&selected.readiness,
|
||||
&effective_allow_prefixes,
|
||||
&effective_deny_prefixes,
|
||||
)?;
|
||||
let environment_references = resolve_environment_references(selected)?;
|
||||
|
||||
let mut volumes = Vec::with_capacity(selected.volumes.len());
|
||||
|
|
@ -1045,6 +1057,7 @@ pub fn load_trusted_configuration(
|
|||
.iter()
|
||||
.map(|prefix| (prefix.address, prefix.prefix_length))
|
||||
.collect(),
|
||||
network_readiness,
|
||||
escalation_enabled: selected.escalation.enabled,
|
||||
auto_approval_rules: resolved_auto_approval_records,
|
||||
};
|
||||
|
|
@ -1134,6 +1147,34 @@ pub fn derive_configuration_fingerprint(
|
|||
.iter()
|
||||
.map(|prefix| Ok([prefix.0.as_slice(), &[prefix.1]].concat())),
|
||||
)?;
|
||||
push_sorted_list(
|
||||
&mut preimage,
|
||||
record.network_readiness.denied.iter().map(|target| {
|
||||
Ok([
|
||||
target.address.as_slice(),
|
||||
target.port.to_be_bytes().as_slice(),
|
||||
]
|
||||
.concat())
|
||||
}),
|
||||
)?;
|
||||
push_sorted_list(
|
||||
&mut preimage,
|
||||
record.network_readiness.permitted.iter().map(|target| {
|
||||
Ok([
|
||||
target.address.as_slice(),
|
||||
target.port.to_be_bytes().as_slice(),
|
||||
]
|
||||
.concat())
|
||||
}),
|
||||
)?;
|
||||
push_sorted_list(
|
||||
&mut preimage,
|
||||
record.network_readiness.dns.iter().map(|target| {
|
||||
let mut item = target.resolver.to_vec();
|
||||
push_bytes(&mut item, target.query.as_bytes())?;
|
||||
Ok(item)
|
||||
}),
|
||||
)?;
|
||||
push_bool(&mut preimage, record.escalation_enabled);
|
||||
push_sorted_list(
|
||||
&mut preimage,
|
||||
|
|
@ -1739,6 +1780,173 @@ fn resolve_network(
|
|||
Ok((allow.into_iter().collect(), deny.into_iter().collect()))
|
||||
}
|
||||
|
||||
const MAX_READINESS_TARGETS: usize = 32;
|
||||
fn resolve_network_readiness(
|
||||
readiness: &NetworkReadinessConfigurationV1,
|
||||
allow: &[Ipv4Prefix],
|
||||
deny: &[Ipv4Prefix],
|
||||
) -> Result<ResolvedNetworkReadinessV1, ConfigurationError> {
|
||||
if readiness.denied.is_empty() || readiness.permitted.is_empty() || readiness.dns.is_empty() {
|
||||
return Err(ConfigurationError::new(
|
||||
ConfigurationErrorKind::InvalidNetwork,
|
||||
"profile readiness requires denied, permitted, and DNS targets",
|
||||
));
|
||||
}
|
||||
if readiness.denied.len() > MAX_READINESS_TARGETS
|
||||
|| readiness.permitted.len() > MAX_READINESS_TARGETS
|
||||
|| readiness.dns.len() > MAX_READINESS_TARGETS
|
||||
{
|
||||
return Err(ConfigurationError::new(
|
||||
ConfigurationErrorKind::InvalidNetwork,
|
||||
"profile readiness target count exceeds 32",
|
||||
));
|
||||
}
|
||||
|
||||
let denied = resolve_tcp_readiness_targets(&readiness.denied, allow, deny, false)?;
|
||||
let permitted = resolve_tcp_readiness_targets(&readiness.permitted, allow, deny, true)?;
|
||||
|
||||
let dns = readiness
|
||||
.dns
|
||||
.iter()
|
||||
.map(|target| {
|
||||
Ok(ResolvedDnsProbeTargetV1 {
|
||||
resolver: parse_probe_address(&target.resolver)?,
|
||||
query: normalize_dns_query(&target.query)?,
|
||||
})
|
||||
})
|
||||
.collect::<Result<BTreeSet<_>, ConfigurationError>>()?;
|
||||
if dns.len() != readiness.dns.len() {
|
||||
return Err(ConfigurationError::new(
|
||||
ConfigurationErrorKind::InvalidNetwork,
|
||||
"profile readiness contains duplicate DNS targets",
|
||||
));
|
||||
}
|
||||
|
||||
Ok(ResolvedNetworkReadinessV1 {
|
||||
denied: denied.into_iter().collect(),
|
||||
permitted: permitted.into_iter().collect(),
|
||||
dns: dns.into_iter().collect(),
|
||||
})
|
||||
}
|
||||
|
||||
fn resolve_tcp_readiness_targets(
|
||||
targets: &[NetworkProbeTargetConfigurationV1],
|
||||
allow: &[Ipv4Prefix],
|
||||
deny: &[Ipv4Prefix],
|
||||
expected_allowed: bool,
|
||||
) -> Result<BTreeSet<ResolvedNetworkProbeTargetV1>, ConfigurationError> {
|
||||
let resolved = targets
|
||||
.iter()
|
||||
.map(|target| {
|
||||
if target.port == 0 {
|
||||
return Err(ConfigurationError::new(
|
||||
ConfigurationErrorKind::InvalidNetwork,
|
||||
"readiness TCP target has port zero",
|
||||
));
|
||||
}
|
||||
let address = parse_probe_address(&target.address)?;
|
||||
if is_effectively_allowed(address, allow, deny) != expected_allowed {
|
||||
return Err(ConfigurationError::new(
|
||||
ConfigurationErrorKind::InvalidNetwork,
|
||||
"readiness TCP target disagrees with effective profile policy",
|
||||
));
|
||||
}
|
||||
Ok(ResolvedNetworkProbeTargetV1 {
|
||||
address,
|
||||
port: target.port,
|
||||
})
|
||||
})
|
||||
.collect::<Result<BTreeSet<_>, _>>()?;
|
||||
if resolved.len() != targets.len() {
|
||||
return Err(ConfigurationError::new(
|
||||
ConfigurationErrorKind::InvalidNetwork,
|
||||
"profile readiness contains duplicate TCP targets",
|
||||
));
|
||||
}
|
||||
if resolved
|
||||
.iter()
|
||||
.map(|target| target.address)
|
||||
.collect::<BTreeSet<_>>()
|
||||
.len()
|
||||
!= resolved.len()
|
||||
{
|
||||
return Err(ConfigurationError::new(
|
||||
ConfigurationErrorKind::InvalidNetwork,
|
||||
"readiness TCP targets repeat an observation address",
|
||||
));
|
||||
}
|
||||
Ok(resolved)
|
||||
}
|
||||
|
||||
fn parse_probe_address(value: &str) -> Result<[u8; 4], ConfigurationError> {
|
||||
let address = value.parse::<std::net::Ipv4Addr>().map_err(|_| {
|
||||
ConfigurationError::new(
|
||||
ConfigurationErrorKind::InvalidNetwork,
|
||||
"readiness target address is not IPv4",
|
||||
)
|
||||
})?;
|
||||
if address.is_unspecified() || address.is_multicast() || address.is_broadcast() {
|
||||
return Err(ConfigurationError::new(
|
||||
ConfigurationErrorKind::InvalidNetwork,
|
||||
"readiness target address is not unicast",
|
||||
));
|
||||
}
|
||||
Ok(address.octets())
|
||||
}
|
||||
|
||||
fn normalize_dns_query(value: &str) -> Result<String, ConfigurationError> {
|
||||
let query = value.strip_suffix('.').unwrap_or(value);
|
||||
if query.is_empty() || query.len() > 253 || !query.is_ascii() {
|
||||
return Err(ConfigurationError::new(
|
||||
ConfigurationErrorKind::InvalidNetwork,
|
||||
"DNS readiness query name is invalid",
|
||||
));
|
||||
}
|
||||
for label in query.split('.') {
|
||||
if label.is_empty()
|
||||
|| label.len() > 63
|
||||
|| label.starts_with('-')
|
||||
|| label.ends_with('-')
|
||||
|| !label
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_alphanumeric() || byte == b'-')
|
||||
{
|
||||
return Err(ConfigurationError::new(
|
||||
ConfigurationErrorKind::InvalidNetwork,
|
||||
"DNS readiness query name is invalid",
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(query.to_ascii_lowercase())
|
||||
}
|
||||
|
||||
fn is_effectively_allowed(address: [u8; 4], allow: &[Ipv4Prefix], deny: &[Ipv4Prefix]) -> bool {
|
||||
let best_allow = allow
|
||||
.iter()
|
||||
.filter(|prefix| prefix_contains(**prefix, address))
|
||||
.map(|prefix| prefix.prefix_length)
|
||||
.max();
|
||||
let best_deny = deny
|
||||
.iter()
|
||||
.filter(|prefix| prefix_contains(**prefix, address))
|
||||
.map(|prefix| prefix.prefix_length)
|
||||
.max();
|
||||
match (best_allow, best_deny) {
|
||||
(Some(allowed), Some(denied)) => allowed > denied,
|
||||
(Some(_) | None, None) => true,
|
||||
(None, Some(_)) => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn prefix_contains(prefix: Ipv4Prefix, address: [u8; 4]) -> bool {
|
||||
let mask = if prefix.prefix_length == 0 {
|
||||
0
|
||||
} else {
|
||||
u32::MAX << (32 - prefix.prefix_length)
|
||||
};
|
||||
u32::from_be_bytes(address) & mask == u32::from_be_bytes(prefix.address)
|
||||
}
|
||||
|
||||
fn parse_ipv4_prefix(value: &str) -> Result<Ipv4Prefix, ConfigurationError> {
|
||||
let (address, prefix) = value.split_once('/').ok_or_else(|| {
|
||||
ConfigurationError::new(
|
||||
|
|
|
|||
|
|
@ -22,6 +22,31 @@ pub struct NetworkConfigurationV1 {
|
|||
pub default_deny: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct NetworkReadinessConfigurationV1 {
|
||||
#[serde(default)]
|
||||
pub denied: Vec<NetworkProbeTargetConfigurationV1>,
|
||||
#[serde(default)]
|
||||
pub permitted: Vec<NetworkProbeTargetConfigurationV1>,
|
||||
#[serde(default)]
|
||||
pub dns: Vec<DnsProbeTargetConfigurationV1>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct NetworkProbeTargetConfigurationV1 {
|
||||
pub address: String,
|
||||
pub port: u16,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct DnsProbeTargetConfigurationV1 {
|
||||
pub resolver: String,
|
||||
pub query: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum VolumeKind {
|
||||
|
|
@ -81,6 +106,8 @@ pub struct ProfileConfigurationV1 {
|
|||
#[serde(default)]
|
||||
pub deny_egress: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub readiness: NetworkReadinessConfigurationV1,
|
||||
#[serde(default)]
|
||||
pub env: BTreeMap<String, String>,
|
||||
#[serde(default)]
|
||||
pub volumes: Vec<VolumeConfigurationV1>,
|
||||
|
|
@ -194,6 +221,26 @@ pub struct ConfigurationFingerprintRecordV1 {
|
|||
pub volumes: Vec<ResolvedVolume>,
|
||||
pub effective_allow_prefixes: Vec<([u8; 4], u8)>,
|
||||
pub effective_deny_prefixes: Vec<([u8; 4], u8)>,
|
||||
pub network_readiness: ResolvedNetworkReadinessV1,
|
||||
pub escalation_enabled: bool,
|
||||
pub auto_approval_rules: Vec<ResolvedAutoApprovalRule>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||
pub struct ResolvedNetworkReadinessV1 {
|
||||
pub denied: Vec<ResolvedNetworkProbeTargetV1>,
|
||||
pub permitted: Vec<ResolvedNetworkProbeTargetV1>,
|
||||
pub dns: Vec<ResolvedDnsProbeTargetV1>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
|
||||
pub struct ResolvedNetworkProbeTargetV1 {
|
||||
pub address: [u8; 4],
|
||||
pub port: u16,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
|
||||
pub struct ResolvedDnsProbeTargetV1 {
|
||||
pub resolver: [u8; 4],
|
||||
pub query: String,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,13 +13,14 @@ use super::{
|
|||
PolicyDelivery, PrefixRule, PrepareOperation, PrepareStageState, PreparedArtifact,
|
||||
PreparedLowerOwnership, ProcessOwnershipCommitAck, ProcessOwnershipMessageVersion,
|
||||
ProtocolCredential, ProtocolCredentialRole, ProtocolPublicationIdentity, PtyIdentity,
|
||||
PublicationOwnership, ResolvedAutoApprovalRule, ResolvedVolume, Resolver, ResultStream,
|
||||
SandboxCredentialBundle, SandboxOwnership, Session, SessionBinding, SessionClose, SessionExit,
|
||||
SessionExitOutcome, SessionRegistrationRequest, SessionRegistrationResponse,
|
||||
SessionRequestCapability, SessionTerminal, SessionTerminalEvent, SessionTerminalMessage,
|
||||
SessionTerminalState, SignedProtocolRecord, StagedProcessOwnership, StateAnchorOwnership,
|
||||
StorageCatalog, StoreMetadata, TerminalInput, TerminalOutput, TerminalResize, TerminalSignal,
|
||||
TerminalSignalKind, TlsCredential, TlsRole, TrustManifest, TrustedHostConfigurationV1,
|
||||
PublicationOwnership, ResolvedAutoApprovalRule, ResolvedNetworkReadinessV1, ResolvedVolume,
|
||||
Resolver, ResultStream, SandboxCredentialBundle, SandboxOwnership, Session, SessionBinding,
|
||||
SessionClose, SessionExit, SessionExitOutcome, SessionRegistrationRequest,
|
||||
SessionRegistrationResponse, SessionRequestCapability, SessionTerminal, SessionTerminalEvent,
|
||||
SessionTerminalMessage, SessionTerminalState, SignedProtocolRecord, StagedProcessOwnership,
|
||||
StateAnchorOwnership, StorageCatalog, StoreMetadata, TerminalInput, TerminalOutput,
|
||||
TerminalResize, TerminalSignal, TerminalSignalKind, TlsCredential, TlsRole, TrustManifest,
|
||||
TrustedHostConfigurationV1,
|
||||
};
|
||||
|
||||
pub trait SchemaType {
|
||||
|
|
@ -133,6 +134,7 @@ schema_types!(
|
|||
"EnvironmentReference" => EnvironmentReference,
|
||||
"ResolvedVolume" => ResolvedVolume,
|
||||
"ResolvedAutoApprovalRule" => ResolvedAutoApprovalRule,
|
||||
"ResolvedNetworkReadinessV1" => ResolvedNetworkReadinessV1,
|
||||
"DomainStore" => DomainStore,
|
||||
"StorageCatalog" => StorageCatalog,
|
||||
"PrepareStageState" => PrepareStageState;
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ use lazaret_core::configuration::{
|
|||
use lazaret_core::identity::HostPath;
|
||||
use lazaret_core::records::{
|
||||
ConfigurationFingerprintRecordV1, EnvironmentReference, ResolvedAutoApprovalRule,
|
||||
ResolvedDnsProbeTargetV1, ResolvedNetworkProbeTargetV1, ResolvedNetworkReadinessV1,
|
||||
ResolvedVolume, VolumeAccess, VolumeKind,
|
||||
};
|
||||
use static_assertions::assert_not_impl_any;
|
||||
|
|
@ -107,6 +108,11 @@ volumes = [
|
|||
{{ host = "{{profile_dir}}/agent-state", container = "/root/.local/state/agent", kind = "directory", access = "read-write", create = true }},
|
||||
]
|
||||
|
||||
[profiles.default.readiness]
|
||||
denied = [{{ address = "10.2.3.4", port = 443 }}]
|
||||
permitted = [{{ address = "192.0.2.4", port = 443 }}]
|
||||
dns = [{{ resolver = "198.51.100.53", query = "example.invalid" }}]
|
||||
|
||||
[profiles.default.escalation]
|
||||
enabled = false
|
||||
"#,
|
||||
|
|
@ -698,6 +704,20 @@ fn digest_vectors_are_framed_and_order_independent() {
|
|||
}],
|
||||
effective_allow_prefixes: vec![([8, 8, 8, 8], 32), ([1, 1, 1, 1], 32)],
|
||||
effective_deny_prefixes: vec![([10, 0, 0, 0], 8)],
|
||||
network_readiness: ResolvedNetworkReadinessV1 {
|
||||
denied: vec![ResolvedNetworkProbeTargetV1 {
|
||||
address: [10, 0, 0, 1],
|
||||
port: 9,
|
||||
}],
|
||||
permitted: vec![ResolvedNetworkProbeTargetV1 {
|
||||
address: [1, 1, 1, 1],
|
||||
port: 443,
|
||||
}],
|
||||
dns: vec![ResolvedDnsProbeTargetV1 {
|
||||
resolver: [8, 8, 8, 8],
|
||||
query: "example.invalid".into(),
|
||||
}],
|
||||
},
|
||||
escalation_enabled: true,
|
||||
auto_approval_rules: vec![ResolvedAutoApprovalRule {
|
||||
canonical_executable: HostPath::new(b"/usr/bin/true".to_vec()),
|
||||
|
|
@ -709,9 +729,9 @@ fn digest_vectors_are_framed_and_order_independent() {
|
|||
assert_eq!(
|
||||
first.as_bytes(),
|
||||
&[
|
||||
0xd3, 0xba, 0xef, 0x91, 0x21, 0x0f, 0x98, 0x31, 0x6c, 0xea, 0x34, 0xfb, 0x41, 0xbd,
|
||||
0x6c, 0xa5, 0x07, 0xf0, 0xb8, 0x9b, 0x90, 0x77, 0xaf, 0x0a, 0x0e, 0xd0, 0x6a, 0xd6,
|
||||
0x46, 0xc7, 0x2d, 0xbf,
|
||||
0x22, 0x57, 0x11, 0xe7, 0xc9, 0x11, 0xba, 0x8e, 0x08, 0x11, 0xae, 0x56, 0x5b, 0x7d,
|
||||
0x89, 0xb0, 0x73, 0x48, 0xaa, 0x73, 0x87, 0x19, 0x5e, 0xcd, 0xf8, 0xfa, 0xd1, 0x48,
|
||||
0xc1, 0x5b, 0x90, 0x57,
|
||||
]
|
||||
);
|
||||
record.persistent_dirs.reverse();
|
||||
|
|
|
|||
|
|
@ -175,6 +175,7 @@ fn trusted_host_configuration_postcard_dispatch_rejects_unknown_schema() {
|
|||
persistent_dirs: vec![],
|
||||
allow_egress: vec![],
|
||||
deny_egress: vec![],
|
||||
readiness: NetworkReadinessConfigurationV1::default(),
|
||||
env: BTreeMap::new(),
|
||||
volumes: vec![],
|
||||
escalation: EscalationConfigurationV1::default(),
|
||||
|
|
|
|||
|
|
@ -514,6 +514,20 @@ image = "example.invalid/agent:v1"
|
|||
}],
|
||||
effective_allow_prefixes: vec![([1, 1, 1, 1], 32)],
|
||||
effective_deny_prefixes: vec![([10, 0, 0, 0], 8)],
|
||||
network_readiness: ResolvedNetworkReadinessV1 {
|
||||
denied: vec![ResolvedNetworkProbeTargetV1 {
|
||||
address: [10, 0, 0, 1],
|
||||
port: 9,
|
||||
}],
|
||||
permitted: vec![ResolvedNetworkProbeTargetV1 {
|
||||
address: [1, 1, 1, 1],
|
||||
port: 443,
|
||||
}],
|
||||
dns: vec![ResolvedDnsProbeTargetV1 {
|
||||
resolver: [8, 8, 8, 8],
|
||||
query: "example.invalid".into(),
|
||||
}],
|
||||
},
|
||||
escalation_enabled: true,
|
||||
auto_approval_rules: vec![ResolvedAutoApprovalRule {
|
||||
canonical_executable: HostPath::new(b"/usr/bin/true".to_vec()),
|
||||
|
|
@ -548,6 +562,23 @@ image = "example.invalid/agent:v1"
|
|||
},
|
||||
&mut seen,
|
||||
);
|
||||
verify_shape(
|
||||
ResolvedNetworkReadinessV1 {
|
||||
denied: vec![ResolvedNetworkProbeTargetV1 {
|
||||
address: [10, 0, 0, 1],
|
||||
port: 9,
|
||||
}],
|
||||
permitted: vec![ResolvedNetworkProbeTargetV1 {
|
||||
address: [1, 1, 1, 1],
|
||||
port: 443,
|
||||
}],
|
||||
dns: vec![ResolvedDnsProbeTargetV1 {
|
||||
resolver: [8, 8, 8, 8],
|
||||
query: "example.invalid".into(),
|
||||
}],
|
||||
},
|
||||
&mut seen,
|
||||
);
|
||||
|
||||
assert_eq!(seen.len(), IMPLEMENTED_SERIALIZED_SCHEMA_TYPE_NAMES.len());
|
||||
assert!(
|
||||
|
|
|
|||
Loading…
Reference in a new issue