1use sim_kernel::{CapabilityName, Error, Expr, Result, Symbol};
12
13use crate::{StreamCapability, TransportProfile};
14
15#[derive(Clone, Copy, Debug, PartialEq, Eq)]
21pub enum StreamSecurityCapability {
22 Open,
24 Read,
26 Push,
28 Cancel,
30 Stats,
32 RemotePreview,
34 RemoteRender,
36 LanMidi,
38 HostDevice,
40 RemoteNetwork,
42}
43
44impl StreamSecurityCapability {
45 pub fn wire_label(self) -> &'static str {
48 match self {
49 Self::Open => "stream.open",
50 Self::Read => "stream.read",
51 Self::Push => "stream.push",
52 Self::Cancel => "stream.cancel",
53 Self::Stats => "stream.stats",
54 Self::RemotePreview => "stream.remote.preview",
55 Self::RemoteRender => "stream.remote.render",
56 Self::LanMidi => "stream.lan.midi",
57 Self::HostDevice => "stream.host.device",
58 Self::RemoteNetwork => "stream.remote.network",
59 }
60 }
61
62 pub fn capability(self) -> CapabilityName {
64 CapabilityName::new(self.wire_label())
65 }
66
67 pub fn symbol(self) -> Symbol {
70 Symbol::qualified("stream/security-capability", self.wire_label())
71 }
72}
73
74pub fn stream_open_capability() -> CapabilityName {
76 StreamSecurityCapability::Open.capability()
77}
78
79pub fn stream_read_capability() -> CapabilityName {
81 StreamSecurityCapability::Read.capability()
82}
83
84pub fn stream_push_capability() -> CapabilityName {
86 StreamSecurityCapability::Push.capability()
87}
88
89pub fn stream_cancel_capability() -> CapabilityName {
91 StreamSecurityCapability::Cancel.capability()
92}
93
94pub fn stream_stats_capability() -> CapabilityName {
96 StreamSecurityCapability::Stats.capability()
97}
98
99pub fn stream_remote_preview_capability() -> CapabilityName {
101 StreamSecurityCapability::RemotePreview.capability()
102}
103
104pub fn stream_remote_render_capability() -> CapabilityName {
106 StreamSecurityCapability::RemoteRender.capability()
107}
108
109pub fn stream_lan_midi_capability() -> CapabilityName {
111 StreamSecurityCapability::LanMidi.capability()
112}
113
114pub fn stream_host_device_capability() -> CapabilityName {
116 StreamSecurityCapability::HostDevice.capability()
117}
118
119pub fn stream_remote_network_capability() -> CapabilityName {
121 StreamSecurityCapability::RemoteNetwork.capability()
122}
123
124pub fn stream_security_capabilities() -> [StreamSecurityCapability; 10] {
126 [
127 StreamSecurityCapability::Open,
128 StreamSecurityCapability::Read,
129 StreamSecurityCapability::Push,
130 StreamSecurityCapability::Cancel,
131 StreamSecurityCapability::Stats,
132 StreamSecurityCapability::RemotePreview,
133 StreamSecurityCapability::RemoteRender,
134 StreamSecurityCapability::LanMidi,
135 StreamSecurityCapability::HostDevice,
136 StreamSecurityCapability::RemoteNetwork,
137 ]
138}
139
140pub fn stream_security_capability_names() -> Vec<CapabilityName> {
142 stream_security_capabilities()
143 .into_iter()
144 .map(StreamSecurityCapability::capability)
145 .collect()
146}
147
148#[derive(Clone, Copy, Debug, PartialEq, Eq)]
153pub struct StreamRemoteLimits {
154 pub max_frame_payload_bytes: usize,
156 pub max_stream_frames: usize,
158 pub max_inflight_frames: usize,
160 pub max_duration_ms: u64,
162 pub max_rate_hz: u32,
164 pub max_binary_payload_bytes: usize,
166}
167
168impl Default for StreamRemoteLimits {
169 fn default() -> Self {
170 Self {
171 max_frame_payload_bytes: 1024 * 1024,
172 max_stream_frames: 1024,
173 max_inflight_frames: 64,
174 max_duration_ms: 60_000,
175 max_rate_hz: 120,
176 max_binary_payload_bytes: 256 * 1024,
177 }
178 }
179}
180
181impl StreamRemoteLimits {
182 pub fn validate(self) -> Result<()> {
186 if self.max_frame_payload_bytes == 0 {
187 return Err(Error::Eval(
188 "stream remote frame-size limit must be positive".to_owned(),
189 ));
190 }
191 if self.max_duration_ms == 0 {
192 return Err(Error::Eval(
193 "stream remote duration limit must be positive".to_owned(),
194 ));
195 }
196 if self.max_rate_hz == 0 {
197 return Err(Error::Eval(
198 "stream remote rate limit must be positive".to_owned(),
199 ));
200 }
201 if self.max_binary_payload_bytes == 0 {
202 return Err(Error::Eval(
203 "stream remote binary payload limit must be positive".to_owned(),
204 ));
205 }
206 Ok(())
207 }
208
209 pub fn validate_profile(self, profile: &TransportProfile) -> Result<()> {
215 self.validate()?;
216 if profile.has_capability(StreamCapability::Realtime)
217 && !profile.has_capability(StreamCapability::Preview)
218 {
219 return Err(Error::Eval(format!(
220 "stream profile {} requires local realtime transport",
221 profile.name()
222 )));
223 }
224 if profile.has_capability(StreamCapability::Remote)
225 && !profile.has_capability(StreamCapability::Bounded)
226 {
227 return Err(Error::Eval(format!(
228 "stream profile {} crosses a remote boundary without bounded limits",
229 profile.name()
230 )));
231 }
232 Ok(())
233 }
234
235 pub fn effective_frame_limit(self) -> usize {
238 let rate_duration = (self.max_duration_ms as u128)
239 .saturating_mul(self.max_rate_hz as u128)
240 .div_ceil(1000);
241 self.max_stream_frames
242 .min(rate_duration.max(1).min(usize::MAX as u128) as usize)
243 }
244
245 pub fn to_expr(self) -> Expr {
247 Expr::Map(vec![
248 field(
249 "max-frame-payload-bytes",
250 self.max_frame_payload_bytes.to_string(),
251 ),
252 field("max-stream-frames", self.max_stream_frames.to_string()),
253 field("max-inflight-frames", self.max_inflight_frames.to_string()),
254 field("max-duration-ms", self.max_duration_ms.to_string()),
255 field("max-rate-hz", self.max_rate_hz.to_string()),
256 field(
257 "max-binary-payload-bytes",
258 self.max_binary_payload_bytes.to_string(),
259 ),
260 ])
261 }
262}
263
264#[derive(Clone, Copy, Debug, PartialEq, Eq)]
266pub enum StreamRedactionFinding {
267 PrivatePath,
269 HostName,
271 AbsolutePath,
273 Credential,
275 PatchBankPayload,
277 LargeBinaryData,
279}
280
281impl StreamRedactionFinding {
282 pub fn wire_label(self) -> &'static str {
284 match self {
285 Self::PrivatePath => "private-path",
286 Self::HostName => "host-name",
287 Self::AbsolutePath => "absolute-path",
288 Self::Credential => "credential",
289 Self::PatchBankPayload => "patch-bank-payload",
290 Self::LargeBinaryData => "large-binary-data",
291 }
292 }
293
294 pub fn symbol(self) -> Symbol {
297 Symbol::qualified("stream/redaction", self.wire_label())
298 }
299}
300
301pub fn stream_redaction_finding_symbols() -> [Symbol; 6] {
303 [
304 StreamRedactionFinding::PrivatePath.symbol(),
305 StreamRedactionFinding::HostName.symbol(),
306 StreamRedactionFinding::AbsolutePath.symbol(),
307 StreamRedactionFinding::Credential.symbol(),
308 StreamRedactionFinding::PatchBankPayload.symbol(),
309 StreamRedactionFinding::LargeBinaryData.symbol(),
310 ]
311}
312
313#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
319pub struct StreamSecurityPolicy {
320 pub remote_limits: StreamRemoteLimits,
322}
323
324impl StreamSecurityPolicy {
325 pub fn validate_public_expr(self, expr: &Expr) -> Result<()> {
330 if let Some(finding) = self.finding_for_expr(expr) {
331 return Err(Error::Eval(format!(
332 "stream public payload contains {}",
333 finding.wire_label()
334 )));
335 }
336 Ok(())
337 }
338
339 pub fn finding_for_expr(self, expr: &Expr) -> Option<StreamRedactionFinding> {
342 match expr {
343 Expr::Symbol(symbol) | Expr::Local(symbol) => {
344 self.finding_for_text(&symbol.as_qualified_str())
345 }
346 Expr::String(value) => self.finding_for_text(value),
347 Expr::Bytes(bytes) if bytes.len() > self.remote_limits.max_binary_payload_bytes => {
348 Some(StreamRedactionFinding::LargeBinaryData)
349 }
350 Expr::List(items) | Expr::Vector(items) | Expr::Set(items) | Expr::Block(items) => {
351 items.iter().find_map(|item| self.finding_for_expr(item))
352 }
353 Expr::Map(entries) => entries.iter().find_map(|(key, value)| {
354 self.finding_for_expr(key)
355 .or_else(|| self.finding_for_expr(value))
356 }),
357 Expr::Call { operator, args } => self
358 .finding_for_expr(operator)
359 .or_else(|| args.iter().find_map(|arg| self.finding_for_expr(arg))),
360 Expr::Infix {
361 operator,
362 left,
363 right,
364 } => self
365 .finding_for_text(&operator.as_qualified_str())
366 .or_else(|| self.finding_for_expr(left))
367 .or_else(|| self.finding_for_expr(right)),
368 Expr::Prefix { operator, arg } | Expr::Postfix { operator, arg } => self
369 .finding_for_text(&operator.as_qualified_str())
370 .or_else(|| self.finding_for_expr(arg)),
371 Expr::Quote { expr, .. } => self.finding_for_expr(expr),
372 Expr::Annotated { expr, annotations } => self.finding_for_expr(expr).or_else(|| {
373 annotations.iter().find_map(|(key, value)| {
374 self.finding_for_text(&key.as_qualified_str())
375 .or_else(|| self.finding_for_expr(value))
376 })
377 }),
378 Expr::Extension { tag, payload } => self
379 .finding_for_text(&tag.as_qualified_str())
380 .or_else(|| self.finding_for_expr(payload)),
381 _ => None,
382 }
383 }
384
385 pub fn finding_for_text(self, value: &str) -> Option<StreamRedactionFinding> {
388 let lower = value.to_ascii_lowercase();
389 if contains_credential(&lower) {
390 return Some(StreamRedactionFinding::Credential);
391 }
392 if contains_patch_bank(&lower) {
393 return Some(StreamRedactionFinding::PatchBankPayload);
394 }
395 if contains_host_name(value, &lower) {
396 return Some(StreamRedactionFinding::HostName);
397 }
398 if contains_private_path(value, &lower) {
399 return Some(StreamRedactionFinding::PrivatePath);
400 }
401 if contains_absolute_path(value) {
402 return Some(StreamRedactionFinding::AbsolutePath);
403 }
404 None
405 }
406}
407
408fn contains_credential(lower: &str) -> bool {
409 lower.contains("api_key")
410 || lower.contains("apikey")
411 || lower.contains("auth-token")
412 || lower.contains("bearer ")
413 || lower.contains("credential")
414 || lower.contains("password")
415 || lower.contains("secret")
416 || lower.contains("token=")
417}
418
419fn contains_patch_bank(lower: &str) -> bool {
420 lower.contains("patch-bank")
421 || lower.contains("patch_bank")
422 || lower.contains("sysex-bank")
423 || lower.contains("sysex_bank")
424}
425
426fn contains_host_name(_value: &str, lower: &str) -> bool {
427 lower.contains("hostname=")
428 || lower.contains("host=")
429 || lower.contains("http://")
430 || lower.contains("https://")
431 || lower.contains("ws://")
432 || lower.contains("wss://")
433 || lower.contains(".local")
434 || lower.contains(".lan")
435}
436
437fn contains_private_path(value: &str, lower: &str) -> bool {
438 lower.contains("/home/")
439 || lower.contains("/users/")
440 || lower.contains("\\users\\")
441 || lower.contains("/private/")
442 || lower.contains("private/")
443 || lower.contains("private-path")
444 || value.starts_with('~')
445}
446
447fn contains_absolute_path(value: &str) -> bool {
448 value.starts_with('/') || looks_like_windows_absolute_path(value)
449}
450
451fn looks_like_windows_absolute_path(value: &str) -> bool {
452 let bytes = value.as_bytes();
453 bytes.len() > 2
454 && bytes[0].is_ascii_alphabetic()
455 && bytes[1] == b':'
456 && (bytes[2] == b'\\' || bytes[2] == b'/')
457}
458
459fn field(name: &str, value: String) -> (Expr, Expr) {
460 (Expr::Symbol(Symbol::new(name)), Expr::String(value))
461}