use sim_kernel::{CapabilityName, Error, Expr, Result, Symbol};
use crate::{StreamCapability, TransportProfile};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum StreamSecurityCapability {
Open,
Read,
Push,
Cancel,
Stats,
RemotePreview,
RemoteRender,
LanMidi,
HostDevice,
RemoteNetwork,
}
impl StreamSecurityCapability {
pub fn wire_label(self) -> &'static str {
match self {
Self::Open => "stream.open",
Self::Read => "stream.read",
Self::Push => "stream.push",
Self::Cancel => "stream.cancel",
Self::Stats => "stream.stats",
Self::RemotePreview => "stream.remote.preview",
Self::RemoteRender => "stream.remote.render",
Self::LanMidi => "stream.lan.midi",
Self::HostDevice => "stream.host.device",
Self::RemoteNetwork => "stream.remote.network",
}
}
pub fn capability(self) -> CapabilityName {
CapabilityName::new(self.wire_label())
}
pub fn symbol(self) -> Symbol {
Symbol::qualified("stream/security-capability", self.wire_label())
}
}
pub fn stream_open_capability() -> CapabilityName {
StreamSecurityCapability::Open.capability()
}
pub fn stream_read_capability() -> CapabilityName {
StreamSecurityCapability::Read.capability()
}
pub fn stream_push_capability() -> CapabilityName {
StreamSecurityCapability::Push.capability()
}
pub fn stream_cancel_capability() -> CapabilityName {
StreamSecurityCapability::Cancel.capability()
}
pub fn stream_stats_capability() -> CapabilityName {
StreamSecurityCapability::Stats.capability()
}
pub fn stream_remote_preview_capability() -> CapabilityName {
StreamSecurityCapability::RemotePreview.capability()
}
pub fn stream_remote_render_capability() -> CapabilityName {
StreamSecurityCapability::RemoteRender.capability()
}
pub fn stream_lan_midi_capability() -> CapabilityName {
StreamSecurityCapability::LanMidi.capability()
}
pub fn stream_host_device_capability() -> CapabilityName {
StreamSecurityCapability::HostDevice.capability()
}
pub fn stream_remote_network_capability() -> CapabilityName {
StreamSecurityCapability::RemoteNetwork.capability()
}
pub fn stream_security_capabilities() -> [StreamSecurityCapability; 10] {
[
StreamSecurityCapability::Open,
StreamSecurityCapability::Read,
StreamSecurityCapability::Push,
StreamSecurityCapability::Cancel,
StreamSecurityCapability::Stats,
StreamSecurityCapability::RemotePreview,
StreamSecurityCapability::RemoteRender,
StreamSecurityCapability::LanMidi,
StreamSecurityCapability::HostDevice,
StreamSecurityCapability::RemoteNetwork,
]
}
pub fn stream_security_capability_names() -> Vec<CapabilityName> {
stream_security_capabilities()
.into_iter()
.map(StreamSecurityCapability::capability)
.collect()
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct StreamRemoteLimits {
pub max_frame_payload_bytes: usize,
pub max_stream_frames: usize,
pub max_inflight_frames: usize,
pub max_duration_ms: u64,
pub max_rate_hz: u32,
pub max_binary_payload_bytes: usize,
}
impl Default for StreamRemoteLimits {
fn default() -> Self {
Self {
max_frame_payload_bytes: 1024 * 1024,
max_stream_frames: 1024,
max_inflight_frames: 64,
max_duration_ms: 60_000,
max_rate_hz: 120,
max_binary_payload_bytes: 256 * 1024,
}
}
}
impl StreamRemoteLimits {
pub fn validate(self) -> Result<()> {
if self.max_frame_payload_bytes == 0 {
return Err(Error::Eval(
"stream remote frame-size limit must be positive".to_owned(),
));
}
if self.max_duration_ms == 0 {
return Err(Error::Eval(
"stream remote duration limit must be positive".to_owned(),
));
}
if self.max_rate_hz == 0 {
return Err(Error::Eval(
"stream remote rate limit must be positive".to_owned(),
));
}
if self.max_binary_payload_bytes == 0 {
return Err(Error::Eval(
"stream remote binary payload limit must be positive".to_owned(),
));
}
Ok(())
}
pub fn validate_profile(self, profile: &TransportProfile) -> Result<()> {
self.validate()?;
if profile.has_capability(StreamCapability::Realtime)
&& !profile.has_capability(StreamCapability::Preview)
{
return Err(Error::Eval(format!(
"stream profile {} requires local realtime transport",
profile.name()
)));
}
if profile.has_capability(StreamCapability::Remote)
&& !profile.has_capability(StreamCapability::Bounded)
{
return Err(Error::Eval(format!(
"stream profile {} crosses a remote boundary without bounded limits",
profile.name()
)));
}
Ok(())
}
pub fn effective_frame_limit(self) -> usize {
let rate_duration = (self.max_duration_ms as u128)
.saturating_mul(self.max_rate_hz as u128)
.div_ceil(1000);
self.max_stream_frames
.min(rate_duration.max(1).min(usize::MAX as u128) as usize)
}
pub fn to_expr(self) -> Expr {
Expr::Map(vec![
field(
"max-frame-payload-bytes",
self.max_frame_payload_bytes.to_string(),
),
field("max-stream-frames", self.max_stream_frames.to_string()),
field("max-inflight-frames", self.max_inflight_frames.to_string()),
field("max-duration-ms", self.max_duration_ms.to_string()),
field("max-rate-hz", self.max_rate_hz.to_string()),
field(
"max-binary-payload-bytes",
self.max_binary_payload_bytes.to_string(),
),
])
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum StreamRedactionFinding {
PrivatePath,
HostName,
AbsolutePath,
Credential,
PatchBankPayload,
LargeBinaryData,
}
impl StreamRedactionFinding {
pub fn wire_label(self) -> &'static str {
match self {
Self::PrivatePath => "private-path",
Self::HostName => "host-name",
Self::AbsolutePath => "absolute-path",
Self::Credential => "credential",
Self::PatchBankPayload => "patch-bank-payload",
Self::LargeBinaryData => "large-binary-data",
}
}
pub fn symbol(self) -> Symbol {
Symbol::qualified("stream/redaction", self.wire_label())
}
}
pub fn stream_redaction_finding_symbols() -> [Symbol; 6] {
[
StreamRedactionFinding::PrivatePath.symbol(),
StreamRedactionFinding::HostName.symbol(),
StreamRedactionFinding::AbsolutePath.symbol(),
StreamRedactionFinding::Credential.symbol(),
StreamRedactionFinding::PatchBankPayload.symbol(),
StreamRedactionFinding::LargeBinaryData.symbol(),
]
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct StreamSecurityPolicy {
pub remote_limits: StreamRemoteLimits,
}
impl StreamSecurityPolicy {
pub fn validate_public_expr(self, expr: &Expr) -> Result<()> {
if let Some(finding) = self.finding_for_expr(expr) {
return Err(Error::Eval(format!(
"stream public payload contains {}",
finding.wire_label()
)));
}
Ok(())
}
pub fn finding_for_expr(self, expr: &Expr) -> Option<StreamRedactionFinding> {
match expr {
Expr::Symbol(symbol) | Expr::Local(symbol) => {
self.finding_for_text(&symbol.as_qualified_str())
}
Expr::String(value) => self.finding_for_text(value),
Expr::Bytes(bytes) if bytes.len() > self.remote_limits.max_binary_payload_bytes => {
Some(StreamRedactionFinding::LargeBinaryData)
}
Expr::List(items) | Expr::Vector(items) | Expr::Set(items) | Expr::Block(items) => {
items.iter().find_map(|item| self.finding_for_expr(item))
}
Expr::Map(entries) => entries.iter().find_map(|(key, value)| {
self.finding_for_expr(key)
.or_else(|| self.finding_for_expr(value))
}),
Expr::Call { operator, args } => self
.finding_for_expr(operator)
.or_else(|| args.iter().find_map(|arg| self.finding_for_expr(arg))),
Expr::Infix {
operator,
left,
right,
} => self
.finding_for_text(&operator.as_qualified_str())
.or_else(|| self.finding_for_expr(left))
.or_else(|| self.finding_for_expr(right)),
Expr::Prefix { operator, arg } | Expr::Postfix { operator, arg } => self
.finding_for_text(&operator.as_qualified_str())
.or_else(|| self.finding_for_expr(arg)),
Expr::Quote { expr, .. } => self.finding_for_expr(expr),
Expr::Annotated { expr, annotations } => self.finding_for_expr(expr).or_else(|| {
annotations.iter().find_map(|(key, value)| {
self.finding_for_text(&key.as_qualified_str())
.or_else(|| self.finding_for_expr(value))
})
}),
Expr::Extension { tag, payload } => self
.finding_for_text(&tag.as_qualified_str())
.or_else(|| self.finding_for_expr(payload)),
_ => None,
}
}
pub fn finding_for_text(self, value: &str) -> Option<StreamRedactionFinding> {
let lower = value.to_ascii_lowercase();
if contains_credential(&lower) {
return Some(StreamRedactionFinding::Credential);
}
if contains_patch_bank(&lower) {
return Some(StreamRedactionFinding::PatchBankPayload);
}
if contains_host_name(value, &lower) {
return Some(StreamRedactionFinding::HostName);
}
if contains_private_path(value, &lower) {
return Some(StreamRedactionFinding::PrivatePath);
}
if contains_absolute_path(value) {
return Some(StreamRedactionFinding::AbsolutePath);
}
None
}
}
fn contains_credential(lower: &str) -> bool {
lower.contains("api_key")
|| lower.contains("apikey")
|| lower.contains("auth-token")
|| lower.contains("bearer ")
|| lower.contains("credential")
|| lower.contains("password")
|| lower.contains("secret")
|| lower.contains("token=")
}
fn contains_patch_bank(lower: &str) -> bool {
lower.contains("patch-bank")
|| lower.contains("patch_bank")
|| lower.contains("sysex-bank")
|| lower.contains("sysex_bank")
}
fn contains_host_name(_value: &str, lower: &str) -> bool {
lower.contains("hostname=")
|| lower.contains("host=")
|| lower.contains("http://")
|| lower.contains("https://")
|| lower.contains("ws://")
|| lower.contains("wss://")
|| lower.contains(".local")
|| lower.contains(".lan")
}
fn contains_private_path(value: &str, lower: &str) -> bool {
lower.contains("/home/")
|| lower.contains("/users/")
|| lower.contains("\\users\\")
|| lower.contains("/private/")
|| lower.contains("private/")
|| lower.contains("private-path")
|| value.starts_with('~')
}
fn contains_absolute_path(value: &str) -> bool {
value.starts_with('/') || looks_like_windows_absolute_path(value)
}
fn looks_like_windows_absolute_path(value: &str) -> bool {
let bytes = value.as_bytes();
bytes.len() > 2
&& bytes[0].is_ascii_alphabetic()
&& bytes[1] == b':'
&& (bytes[2] == b'\\' || bytes[2] == b'/')
}
fn field(name: &str, value: String) -> (Expr, Expr) {
(Expr::Symbol(Symbol::new(name)), Expr::String(value))
}