Allow well-formed bread.command events on the emit bus
Docs already said any module or app could publish bread.command.<app>.<verb>, but command is reserved so both unsourced bread-emit and sourced App emit rejected the whole namespace. Keep command unclaimable as an app id; accept bread.command.<known-app>.<verb> (and let an app command another known app). Also ship bread-emit and bread-module-host, and give udev enumerate the same classification fields as a live add so boot-time devices are not all unknown.
This commit is contained in:
parent
a6973360bd
commit
d3517d1433
52 changed files with 26405 additions and 6811 deletions
|
|
@ -2,7 +2,7 @@ use std::os::unix::io::AsRawFd;
|
|||
|
||||
use anyhow::Result;
|
||||
use bread_shared::{now_unix_ms, AdapterSource, RawEvent};
|
||||
use serde_json::json;
|
||||
use serde_json::{json, Value};
|
||||
use tokio::sync::mpsc;
|
||||
use tracing::debug;
|
||||
|
||||
|
|
@ -19,20 +19,13 @@ impl UdevAdapter {
|
|||
}
|
||||
|
||||
pub async fn enumerate_existing(&self, tx: &mpsc::Sender<RawEvent>) -> Result<()> {
|
||||
let devices = enumerate_with_udev(&self.subsystems)?;
|
||||
for device in devices {
|
||||
tx.send(RawEvent {
|
||||
source: AdapterSource::Udev,
|
||||
kind: "udev.enumerate".to_string(),
|
||||
payload: json!({
|
||||
"action": "add",
|
||||
"id": device.id,
|
||||
"name": device.name,
|
||||
"subsystem": device.subsystem,
|
||||
}),
|
||||
timestamp: now_unix_ms(),
|
||||
})
|
||||
.await?;
|
||||
let mut enumerator = udev::Enumerator::new()?;
|
||||
for subsystem in &self.subsystems {
|
||||
enumerator.match_subsystem(subsystem)?;
|
||||
}
|
||||
for device in enumerator.scan_devices()? {
|
||||
tx.send(build_device_event(&device, "add", "udev.enumerate"))
|
||||
.await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -50,12 +43,6 @@ impl Adapter for UdevAdapter {
|
|||
}
|
||||
}
|
||||
|
||||
struct ScannedDevice {
|
||||
id: String,
|
||||
name: String,
|
||||
subsystem: String,
|
||||
}
|
||||
|
||||
// udev::MonitorSocket uses a non-blocking socket; calling iter().next() without
|
||||
// first polling the fd returns None immediately and exits the loop — which is
|
||||
// why the old code silently fell back to sysfs on every start. We use poll(2)
|
||||
|
|
@ -110,81 +97,157 @@ fn build_event(event: &udev::Event) -> RawEvent {
|
|||
.action()
|
||||
.map(|a| a.to_string_lossy().to_string())
|
||||
.unwrap_or_else(|| "change".to_string());
|
||||
let subsystem = event
|
||||
build_device_event(event, &action, "udev.change")
|
||||
}
|
||||
|
||||
/// Shared live/enumerate payload. `udev::Event` deref's to `Device`, so
|
||||
/// boot-time enumerate of an already-plugged device is equivalent to an
|
||||
/// `add` of that same device (same identity + classification fields
|
||||
/// `resolve_device` needs).
|
||||
fn build_device_event(device: &udev::Device, action: &str, kind: &str) -> RawEvent {
|
||||
let subsystem = device
|
||||
.subsystem()
|
||||
.map(|s| s.to_string_lossy().to_string())
|
||||
.unwrap_or_else(|| "unknown".to_string());
|
||||
let name = event
|
||||
let name = device
|
||||
.property_value("ID_MODEL")
|
||||
.or_else(|| event.property_value("NAME"))
|
||||
.or_else(|| device.property_value("NAME"))
|
||||
.map(|v| v.to_string_lossy().to_string())
|
||||
.or_else(|| event.devnode().map(|n| n.display().to_string()))
|
||||
.or_else(|| device.devnode().map(|n| n.display().to_string()))
|
||||
.unwrap_or_else(|| "unknown".to_string());
|
||||
let id = event.syspath().to_string_lossy().to_string();
|
||||
let id = device.syspath().to_string_lossy().to_string();
|
||||
|
||||
RawEvent {
|
||||
source: AdapterSource::Udev,
|
||||
kind: "udev.change".to_string(),
|
||||
payload: json!({
|
||||
"action": action,
|
||||
"id": id,
|
||||
"name": name,
|
||||
"subsystem": subsystem,
|
||||
"id_input_keyboard": prop_bool(event, "ID_INPUT_KEYBOARD"),
|
||||
"id_input_mouse": prop_bool(event, "ID_INPUT_MOUSE"),
|
||||
"id_input_joystick": prop_bool(event, "ID_INPUT_JOYSTICK"),
|
||||
"id_input_touchpad": prop_bool(event, "ID_INPUT_TOUCHPAD"),
|
||||
"id_input_tablet": prop_bool(event, "ID_INPUT_TABLET"),
|
||||
"id_usb_class": prop_str(event, "ID_USB_CLASS"),
|
||||
"id_usb_interfaces": prop_str(event, "ID_USB_INTERFACES"),
|
||||
"id_vendor": prop_str(event, "ID_VENDOR"),
|
||||
"id_model": prop_str(event, "ID_MODEL"),
|
||||
"vendor_id": prop_str(event, "ID_VENDOR_ID"),
|
||||
"product_id": prop_str(event, "ID_MODEL_ID"),
|
||||
}),
|
||||
kind: kind.to_string(),
|
||||
payload: udev_event_payload(
|
||||
action,
|
||||
&id,
|
||||
&name,
|
||||
&subsystem,
|
||||
UdevClassification::from_device(device),
|
||||
),
|
||||
timestamp: now_unix_ms(),
|
||||
}
|
||||
}
|
||||
|
||||
fn enumerate_with_udev(subsystems: &[String]) -> Result<Vec<ScannedDevice>> {
|
||||
let mut enumerator = udev::Enumerator::new()?;
|
||||
for subsystem in subsystems {
|
||||
enumerator.match_subsystem(subsystem)?;
|
||||
}
|
||||
|
||||
let mut out = Vec::new();
|
||||
for dev in enumerator.scan_devices()? {
|
||||
let subsystem = dev
|
||||
.subsystem()
|
||||
.map(|s| s.to_string_lossy().to_string())
|
||||
.unwrap_or_else(|| "unknown".to_string());
|
||||
let name = dev
|
||||
.property_value("ID_MODEL")
|
||||
.or_else(|| dev.property_value("NAME"))
|
||||
.map(|v| v.to_string_lossy().to_string())
|
||||
.or_else(|| dev.sysname().to_str().map(ToString::to_string))
|
||||
.unwrap_or_else(|| "unknown".to_string());
|
||||
let id = dev.syspath().to_string_lossy().to_string();
|
||||
out.push(ScannedDevice {
|
||||
id,
|
||||
name,
|
||||
subsystem,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(out)
|
||||
/// Classification / identity fields copied onto every udev payload so
|
||||
/// `resolve_device` can name a device after boot the same way it names a
|
||||
/// live plug-in.
|
||||
struct UdevClassification {
|
||||
id_input_keyboard: bool,
|
||||
id_input_mouse: bool,
|
||||
id_input_joystick: bool,
|
||||
id_input_touchpad: bool,
|
||||
id_input_tablet: bool,
|
||||
id_usb_class: Option<String>,
|
||||
id_usb_interfaces: Option<String>,
|
||||
id_vendor: Option<String>,
|
||||
id_model: Option<String>,
|
||||
vendor_id: Option<String>,
|
||||
product_id: Option<String>,
|
||||
}
|
||||
|
||||
fn prop_bool(event: &udev::Event, key: &str) -> bool {
|
||||
event
|
||||
impl UdevClassification {
|
||||
fn from_device(device: &udev::Device) -> Self {
|
||||
Self {
|
||||
id_input_keyboard: prop_bool(device, "ID_INPUT_KEYBOARD"),
|
||||
id_input_mouse: prop_bool(device, "ID_INPUT_MOUSE"),
|
||||
id_input_joystick: prop_bool(device, "ID_INPUT_JOYSTICK"),
|
||||
id_input_touchpad: prop_bool(device, "ID_INPUT_TOUCHPAD"),
|
||||
id_input_tablet: prop_bool(device, "ID_INPUT_TABLET"),
|
||||
id_usb_class: prop_str(device, "ID_USB_CLASS"),
|
||||
id_usb_interfaces: prop_str(device, "ID_USB_INTERFACES"),
|
||||
id_vendor: prop_str(device, "ID_VENDOR"),
|
||||
id_model: prop_str(device, "ID_MODEL"),
|
||||
vendor_id: prop_str(device, "ID_VENDOR_ID"),
|
||||
product_id: prop_str(device, "ID_MODEL_ID"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn udev_event_payload(
|
||||
action: &str,
|
||||
id: &str,
|
||||
name: &str,
|
||||
subsystem: &str,
|
||||
class: UdevClassification,
|
||||
) -> Value {
|
||||
json!({
|
||||
"action": action,
|
||||
"id": id,
|
||||
"name": name,
|
||||
"subsystem": subsystem,
|
||||
"id_input_keyboard": class.id_input_keyboard,
|
||||
"id_input_mouse": class.id_input_mouse,
|
||||
"id_input_joystick": class.id_input_joystick,
|
||||
"id_input_touchpad": class.id_input_touchpad,
|
||||
"id_input_tablet": class.id_input_tablet,
|
||||
"id_usb_class": class.id_usb_class,
|
||||
"id_usb_interfaces": class.id_usb_interfaces,
|
||||
"id_vendor": class.id_vendor,
|
||||
"id_model": class.id_model,
|
||||
"vendor_id": class.vendor_id,
|
||||
"product_id": class.product_id,
|
||||
})
|
||||
}
|
||||
|
||||
fn prop_bool(device: &udev::Device, key: &str) -> bool {
|
||||
device
|
||||
.property_value(key)
|
||||
.and_then(|v| v.to_str())
|
||||
.map(|v| v == "1")
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn prop_str(event: &udev::Event, key: &str) -> Option<String> {
|
||||
event
|
||||
fn prop_str(device: &udev::Device, key: &str) -> Option<String> {
|
||||
device
|
||||
.property_value(key)
|
||||
.map(|v| v.to_string_lossy().to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn enumerate_payload_includes_classification_fields() {
|
||||
// Boot-time enumerate used to send only {action,id,name,subsystem},
|
||||
// so resolve_device could never match vendor/product/input rules
|
||||
// and bread.state.devices stayed "unknown" until the next unplug.
|
||||
let payload = udev_event_payload(
|
||||
"add",
|
||||
"/sys/devices/pci0000:00/usb1/1-3",
|
||||
"Keychron K2",
|
||||
"usb",
|
||||
UdevClassification {
|
||||
id_input_keyboard: true,
|
||||
id_input_mouse: false,
|
||||
id_input_joystick: false,
|
||||
id_input_touchpad: false,
|
||||
id_input_tablet: false,
|
||||
id_usb_class: None,
|
||||
id_usb_interfaces: None,
|
||||
id_vendor: Some("Keychron".into()),
|
||||
id_model: Some("Keychron K2".into()),
|
||||
vendor_id: Some("3434".into()),
|
||||
product_id: Some("d030".into()),
|
||||
},
|
||||
);
|
||||
assert_eq!(payload["action"], "add");
|
||||
assert_eq!(payload["id"], "/sys/devices/pci0000:00/usb1/1-3");
|
||||
assert_eq!(payload["name"], "Keychron K2");
|
||||
assert_eq!(payload["subsystem"], "usb");
|
||||
assert_eq!(payload["vendor_id"], "3434");
|
||||
assert_eq!(payload["product_id"], "d030");
|
||||
assert_eq!(payload["id_vendor"], "Keychron");
|
||||
assert_eq!(payload["id_model"], "Keychron K2");
|
||||
assert_eq!(payload["id_input_keyboard"], true);
|
||||
assert_eq!(payload["id_input_mouse"], false);
|
||||
assert_eq!(payload["id_input_joystick"], false);
|
||||
assert_eq!(payload["id_input_touchpad"], false);
|
||||
assert_eq!(payload["id_input_tablet"], false);
|
||||
assert!(payload["id_usb_class"].is_null());
|
||||
assert!(payload["id_usb_interfaces"].is_null());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,10 @@
|
|||
use std::collections::HashMap;
|
||||
use std::sync::RwLock;
|
||||
|
||||
use bread_shared::{apps::validate_app_namespace, AdapterSource, BreadEvent, RawEvent};
|
||||
use bread_shared::{
|
||||
apps::{validate_app_namespace, validate_command_event},
|
||||
AdapterSource, BreadEvent, RawEvent,
|
||||
};
|
||||
use serde_json::{json, Value};
|
||||
|
||||
/// How many multiples of `dedup_window_ms` an entry must be idle before eviction.
|
||||
|
|
@ -208,9 +211,11 @@ impl EventNormalizer {
|
|||
"workspace" | "workspacev2" => {
|
||||
self.emit_hyprland_dual("bread.workspace.changed", raw.payload.clone(), raw)
|
||||
}
|
||||
"createworkspace" => {
|
||||
self.emit_hyprland_dual("bread.workspace.created", json!({ "workspace": data }), raw)
|
||||
}
|
||||
"createworkspace" => self.emit_hyprland_dual(
|
||||
"bread.workspace.created",
|
||||
json!({ "workspace": data }),
|
||||
raw,
|
||||
),
|
||||
"destroyworkspace" => self.emit_hyprland_dual(
|
||||
"bread.workspace.destroyed",
|
||||
json!({ "workspace": data }),
|
||||
|
|
@ -589,7 +594,10 @@ impl EventNormalizer {
|
|||
let AdapterSource::App(app) = &raw.source else {
|
||||
return vec![];
|
||||
};
|
||||
if !validate_app_namespace(app, &raw.kind) {
|
||||
// Own-namespace events plus well-formed commands to another known
|
||||
// app (the command bus). Anything else — including spoofed adapter
|
||||
// namespaces — is dropped here even if it somehow crossed IPC.
|
||||
if !validate_app_namespace(app, &raw.kind) && !validate_command_event(&raw.kind) {
|
||||
return vec![];
|
||||
}
|
||||
vec![BreadEvent {
|
||||
|
|
@ -997,7 +1005,11 @@ mod tests {
|
|||
1,
|
||||
);
|
||||
let out = n.normalize(&ev);
|
||||
assert_eq!(out.len(), 2, "kind {kind} should dual-emit exactly 2 events");
|
||||
assert_eq!(
|
||||
out.len(),
|
||||
2,
|
||||
"kind {kind} should dual-emit exactly 2 events"
|
||||
);
|
||||
assert!(
|
||||
out.iter().any(|e| &e.event == legacy_event),
|
||||
"kind {kind} missing legacy event {legacy_event}"
|
||||
|
|
@ -1006,7 +1018,12 @@ mod tests {
|
|||
out.iter().any(|e| &e.event == namespaced_event),
|
||||
"kind {kind} missing namespaced event {namespaced_event}"
|
||||
);
|
||||
let legacy_data = out.iter().find(|e| &e.event == legacy_event).unwrap().data.clone();
|
||||
let legacy_data = out
|
||||
.iter()
|
||||
.find(|e| &e.event == legacy_event)
|
||||
.unwrap()
|
||||
.data
|
||||
.clone();
|
||||
let namespaced_data = out
|
||||
.iter()
|
||||
.find(|e| &e.event == namespaced_event)
|
||||
|
|
@ -1452,6 +1469,65 @@ mod tests {
|
|||
}
|
||||
}
|
||||
|
||||
// ─── App / command bus ─────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn app_own_namespace_passes_through() {
|
||||
let n = EventNormalizer::new(0);
|
||||
let out = n.normalize(&raw(
|
||||
AdapterSource::App("clip".into()),
|
||||
"bread.clip.copied",
|
||||
json!({"len": 4}),
|
||||
1,
|
||||
));
|
||||
assert_eq!(out.len(), 1);
|
||||
assert_eq!(out[0].event, "bread.clip.copied");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn app_command_to_another_known_app_passes_through() {
|
||||
let n = EventNormalizer::new(0);
|
||||
let out = n.normalize(&raw(
|
||||
AdapterSource::App("cast".into()),
|
||||
"bread.command.clip.clear",
|
||||
json!({}),
|
||||
1,
|
||||
));
|
||||
assert_eq!(out.len(), 1);
|
||||
assert_eq!(out[0].event, "bread.command.clip.clear");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn app_wrong_namespace_is_dropped() {
|
||||
let n = EventNormalizer::new(0);
|
||||
let out = n.normalize(&raw(
|
||||
AdapterSource::App("cast".into()),
|
||||
"bread.clip.copied",
|
||||
json!({}),
|
||||
1,
|
||||
));
|
||||
assert!(out.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn app_command_to_unknown_or_reserved_target_is_dropped() {
|
||||
let n = EventNormalizer::new(0);
|
||||
let power = n.normalize(&raw(
|
||||
AdapterSource::App("cast".into()),
|
||||
"bread.command.power.off",
|
||||
json!({}),
|
||||
1,
|
||||
));
|
||||
assert!(power.is_empty());
|
||||
let spoof = n.normalize(&raw(
|
||||
AdapterSource::App("cast".into()),
|
||||
"bread.hyprland.workspace.changed",
|
||||
json!({}),
|
||||
1,
|
||||
));
|
||||
assert!(spoof.is_empty());
|
||||
}
|
||||
|
||||
// ─── Helper ────────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -8,7 +8,9 @@ use std::sync::Arc;
|
|||
use std::time::Instant;
|
||||
|
||||
use anyhow::{anyhow, Result};
|
||||
use bread_shared::apps::{event_domain, is_known_app, is_reserved_domain, validate_app_namespace};
|
||||
use bread_shared::apps::{
|
||||
event_domain, is_known_app, is_reserved_domain, validate_app_namespace, validate_command_event,
|
||||
};
|
||||
use bread_shared::{now_unix_ms, AdapterSource, BreadEvent, RawEvent};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
|
|
@ -34,7 +36,12 @@ mod module_host_bridge;
|
|||
/// *Since 1.6.0* — Workstream G's `module_host.*` methods (hello handshake
|
||||
/// plus the RPC bridge a `bread-module-host` child uses in place of direct
|
||||
/// in-process `bread.*` bindings).
|
||||
const API_VERSION: &str = "1.6.0";
|
||||
/// *Since 1.7.0* — well-formed `bread.command.<known-app>.<verb>` is an
|
||||
/// explicit exception to the reserved-domain reject on unsourced emit, and
|
||||
/// sourced `AdapterSource::App` emit may publish commands to another known
|
||||
/// app. `command` stays in `RESERVED_DOMAINS` so it cannot be claimed as
|
||||
/// an app id.
|
||||
const API_VERSION: &str = "1.7.0";
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Server {
|
||||
|
|
@ -325,13 +332,17 @@ impl Server {
|
|||
};
|
||||
// For a sibling-app source, `kind` is the full dotted event
|
||||
// name (e.g. "bread.clip.copied"), not a bare suffix — it
|
||||
// must live inside that app's own namespace.
|
||||
// must live inside that app's own namespace. Well-formed
|
||||
// `bread.command.<known-app>.<verb>` is the one exception:
|
||||
// an app may publish a command addressed to another known
|
||||
// app (see `validate_command_event`). Adapter namespaces
|
||||
// (`bread.power.*`, `bread.hyprland.*`, ...) stay rejected.
|
||||
if let AdapterSource::App(app) = &source {
|
||||
if !validate_app_namespace(app, kind) {
|
||||
if !validate_app_namespace(app, kind) && !validate_command_event(kind) {
|
||||
return Err((
|
||||
id,
|
||||
format!(
|
||||
"event '{kind}' is not in the '{app}' namespace (must start with 'bread.{app}.')"
|
||||
"event '{kind}' is not in the '{app}' namespace (must start with 'bread.{app}.') and is not a well-formed command event"
|
||||
),
|
||||
));
|
||||
}
|
||||
|
|
@ -363,7 +374,11 @@ impl Server {
|
|||
// `bread.hyprland.*`, ...) — otherwise this path would
|
||||
// let any same-UID process impersonate a real adapter
|
||||
// event with nothing downstream able to tell the
|
||||
// difference.
|
||||
// difference. Well-formed `bread.command.<known-app>.<verb>`
|
||||
// is the documented exception: the command bus is
|
||||
// supposed to be publishable by any module or
|
||||
// `bread-emit` caller. Other reserved domains, and
|
||||
// `bread.command.<not-an-app>.*`, stay rejected.
|
||||
let Some(event) = req.params.get("event").and_then(Value::as_str) else {
|
||||
return Err((id, "missing event name".to_string()));
|
||||
};
|
||||
|
|
@ -429,9 +444,9 @@ impl Server {
|
|||
/// impersonate a real adapter-owned event namespace.
|
||||
fn manual_emit(&self, event: &str, data: Value) -> std::result::Result<Value, String> {
|
||||
if let Some(domain) = event_domain(event) {
|
||||
if is_reserved_domain(domain) {
|
||||
if is_reserved_domain(domain) && !validate_command_event(event) {
|
||||
return Err(format!(
|
||||
"event '{event}' claims the reserved '{domain}' domain — manual emit cannot impersonate an adapter-owned event; use a custom event name, or a sourced emit if this should go through the normalizer"
|
||||
"event '{event}' claims the reserved '{domain}' domain — manual emit cannot impersonate an adapter-owned event; use a custom event name, a well-formed bread.command.<app>.<verb>, or a sourced emit if this should go through the normalizer"
|
||||
));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -381,6 +381,126 @@ async fn emit_with_app_source_rejects_wrong_namespace() -> Result<()> {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn emit_without_source_allows_well_formed_command_event() -> Result<()> {
|
||||
let harness = TestHarness::spawn()?;
|
||||
harness.wait_until_ready().await?;
|
||||
|
||||
// Unsourced `bread-emit bread.command.clip.clear` is the documented
|
||||
// command-bus path — `command` is reserved so it cannot be an app id,
|
||||
// but a well-formed command to a known app must still go through.
|
||||
let result = harness
|
||||
.send_request(
|
||||
"emit",
|
||||
json!({ "event": "bread.command.clip.clear", "data": {} }),
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
result.is_ok(),
|
||||
"unsourced well-formed command event must be accepted: {result:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
result.unwrap().get("emitted").and_then(Value::as_bool),
|
||||
Some(true)
|
||||
);
|
||||
|
||||
harness.shutdown();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn emit_without_source_rejects_command_to_non_app() -> Result<()> {
|
||||
let harness = TestHarness::spawn()?;
|
||||
harness.wait_until_ready().await?;
|
||||
|
||||
// `power` is reserved and is not a known app — this must not sneak
|
||||
// through the command-bus exception.
|
||||
let result = harness
|
||||
.send_request(
|
||||
"emit",
|
||||
json!({ "event": "bread.command.power.off", "data": {} }),
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"command to a reserved/non-app target must be rejected"
|
||||
);
|
||||
|
||||
harness.shutdown();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn emit_without_source_still_rejects_hyprland_namespace() -> Result<()> {
|
||||
let harness = TestHarness::spawn()?;
|
||||
harness.wait_until_ready().await?;
|
||||
|
||||
let result = harness
|
||||
.send_request(
|
||||
"emit",
|
||||
json!({ "event": "bread.hyprland.workspace.changed", "data": {} }),
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"unsourced emit must still reject adapter-owned hyprland events"
|
||||
);
|
||||
|
||||
harness.shutdown();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn emit_with_app_source_allows_command_to_another_app() -> Result<()> {
|
||||
let harness = TestHarness::spawn()?;
|
||||
harness.wait_until_ready().await?;
|
||||
|
||||
// An app may publish a command addressed to a different known app.
|
||||
let result = harness
|
||||
.send_request(
|
||||
"emit",
|
||||
json!({
|
||||
"source": "cast",
|
||||
"kind": "bread.command.clip.clear",
|
||||
"data": {}
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
result.is_ok(),
|
||||
"sourced command to another known app must be accepted: {result:?}"
|
||||
);
|
||||
|
||||
harness.shutdown();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn emit_with_app_source_still_rejects_foreign_app_namespace() -> Result<()> {
|
||||
let harness = TestHarness::spawn()?;
|
||||
harness.wait_until_ready().await?;
|
||||
|
||||
// `cast` must not be able to publish `bread.clip.*` events — only
|
||||
// commands to clip, not clip's own inbound namespace.
|
||||
let result = harness
|
||||
.send_request(
|
||||
"emit",
|
||||
json!({
|
||||
"source": "cast",
|
||||
"kind": "bread.clip.copied",
|
||||
"data": {}
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"sourced emit must still reject a foreign app namespace"
|
||||
);
|
||||
|
||||
harness.shutdown();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn state_get_returns_specific_subtree() -> Result<()> {
|
||||
let harness = TestHarness::spawn()?;
|
||||
|
|
@ -496,7 +616,10 @@ return M
|
|||
.await?;
|
||||
let entry = modules
|
||||
.as_array()
|
||||
.and_then(|arr| arr.iter().find(|m| m.get("name").and_then(Value::as_str) == Some("scoped-test")))
|
||||
.and_then(|arr| {
|
||||
arr.iter()
|
||||
.find(|m| m.get("name").and_then(Value::as_str) == Some("scoped-test"))
|
||||
})
|
||||
.cloned()
|
||||
.ok_or_else(|| anyhow!("scoped-test module not found in modules state; dump: {modules}"))?;
|
||||
|
||||
|
|
@ -511,7 +634,9 @@ return M
|
|||
"a module with a manifest that declares permissions must not be flagged ungated"
|
||||
);
|
||||
|
||||
let store = harness.trigger_and_await_result("test.scoped_result").await?;
|
||||
let store = harness
|
||||
.trigger_and_await_result("test.scoped_result")
|
||||
.await?;
|
||||
assert_eq!(store.get("state_get_ok"), Some(&json!(true)));
|
||||
assert_eq!(
|
||||
store.get("fs_present"),
|
||||
|
|
@ -525,8 +650,16 @@ return M
|
|||
);
|
||||
assert_eq!(store.get("exec_capture_present"), Some(&json!(false)));
|
||||
assert_eq!(store.get("bluetooth_present"), Some(&json!(false)));
|
||||
assert_eq!(store.get("json_present"), Some(&json!(true)), "baseline bread.json must still be present");
|
||||
assert_eq!(store.get("log_present"), Some(&json!(true)), "baseline bread.log must still be present");
|
||||
assert_eq!(
|
||||
store.get("json_present"),
|
||||
Some(&json!(true)),
|
||||
"baseline bread.json must still be present"
|
||||
);
|
||||
assert_eq!(
|
||||
store.get("log_present"),
|
||||
Some(&json!(true)),
|
||||
"baseline bread.log must still be present"
|
||||
);
|
||||
|
||||
harness.shutdown();
|
||||
Ok(())
|
||||
|
|
@ -568,7 +701,10 @@ return M
|
|||
.await?;
|
||||
let entry = modules
|
||||
.as_array()
|
||||
.and_then(|arr| arr.iter().find(|m| m.get("name").and_then(Value::as_str) == Some("legacy-test")))
|
||||
.and_then(|arr| {
|
||||
arr.iter()
|
||||
.find(|m| m.get("name").and_then(Value::as_str) == Some("legacy-test"))
|
||||
})
|
||||
.cloned()
|
||||
.ok_or_else(|| anyhow!("legacy-test module not found in modules state; dump: {modules}"))?;
|
||||
|
||||
|
|
@ -647,7 +783,9 @@ return M
|
|||
.find(|m| m.get("name").and_then(Value::as_str) == Some("empty-perms-test"))
|
||||
})
|
||||
.cloned()
|
||||
.ok_or_else(|| anyhow!("empty-perms-test module not found in modules state; dump: {modules}"))?;
|
||||
.ok_or_else(|| {
|
||||
anyhow!("empty-perms-test module not found in modules state; dump: {modules}")
|
||||
})?;
|
||||
|
||||
assert_eq!(entry.get("status").and_then(Value::as_str), Some("loaded"));
|
||||
assert_eq!(
|
||||
|
|
@ -656,7 +794,9 @@ return M
|
|||
"an explicit empty permissions list is a deliberate declaration, not 'undeclared'"
|
||||
);
|
||||
|
||||
let store = harness.trigger_and_await_result("test.empty_perms_result").await?;
|
||||
let store = harness
|
||||
.trigger_and_await_result("test.empty_perms_result")
|
||||
.await?;
|
||||
assert_eq!(store.get("fs_present"), Some(&json!(false)));
|
||||
assert_eq!(store.get("state_present"), Some(&json!(false)));
|
||||
|
||||
|
|
@ -969,7 +1109,10 @@ async fn event_causality_chain_threads_caused_by_across_handlers() -> Result<()>
|
|||
// Lua handler — its `caused_by` must be None. Everything downstream
|
||||
// (X, Y, Z) is emitted by `bread.emit()` from inside a running handler.
|
||||
harness
|
||||
.send_request("emit", json!({ "event": "bread.chain.trigger", "data": {} }))
|
||||
.send_request(
|
||||
"emit",
|
||||
json!({ "event": "bread.chain.trigger", "data": {} }),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let mut events: HashMap<String, Value> = HashMap::new();
|
||||
|
|
@ -1038,10 +1181,14 @@ async fn event_causality_chain_threads_caused_by_across_handlers() -> Result<()>
|
|||
"Z should be caused_by Y's id"
|
||||
);
|
||||
|
||||
let ids: std::collections::HashSet<&str> =
|
||||
[trigger_id.as_str(), x_id.as_str(), y_id.as_str(), z_id.as_str()]
|
||||
.into_iter()
|
||||
.collect();
|
||||
let ids: std::collections::HashSet<&str> = [
|
||||
trigger_id.as_str(),
|
||||
x_id.as_str(),
|
||||
y_id.as_str(),
|
||||
z_id.as_str(),
|
||||
]
|
||||
.into_iter()
|
||||
.collect();
|
||||
assert_eq!(
|
||||
ids.len(),
|
||||
4,
|
||||
|
|
@ -1169,7 +1316,10 @@ async fn rules_toml_absent_is_a_no_op() -> Result<()> {
|
|||
rules_mod.get("status").and_then(Value::as_str),
|
||||
Some("loaded")
|
||||
);
|
||||
assert!(rules_mod.get("last_error").and_then(Value::as_str).is_none());
|
||||
assert!(rules_mod
|
||||
.get("last_error")
|
||||
.and_then(Value::as_str)
|
||||
.is_none());
|
||||
|
||||
harness.shutdown();
|
||||
Ok(())
|
||||
|
|
@ -1610,7 +1760,9 @@ enabled = false
|
|||
// breadd itself would.
|
||||
let deadline = Instant::now() + Duration::from_secs(55);
|
||||
while Instant::now() < deadline {
|
||||
let modules = self.send_request("state.get", json!({"key": "modules"})).await?;
|
||||
let modules = self
|
||||
.send_request("state.get", json!({"key": "modules"}))
|
||||
.await?;
|
||||
if let Some(arr) = modules.as_array() {
|
||||
for m in arr {
|
||||
if m.get("name").and_then(Value::as_str) == Some(name) {
|
||||
|
|
@ -1626,7 +1778,9 @@ enabled = false
|
|||
}
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
}
|
||||
Err(anyhow!("module '{name}' did not reach Loaded within timeout"))
|
||||
Err(anyhow!(
|
||||
"module '{name}' did not reach Loaded within timeout"
|
||||
))
|
||||
}
|
||||
|
||||
/// Subscribe to `result_event`, send a `test.trigger` manual emit to
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue