|
14 | 14 | use std::collections::{HashMap, HashSet}; |
15 | 15 | use std::fs; |
16 | 16 | use std::io::Read; |
17 | | -use std::path::{Path, PathBuf}; |
| 17 | +use std::path::{Component, Path, PathBuf}; |
18 | 18 | use std::sync::atomic::{AtomicU64, Ordering}; |
19 | 19 | use std::time::{SystemTime, UNIX_EPOCH}; |
20 | 20 |
|
@@ -314,19 +314,65 @@ pub fn archive_dir(agent_dir: &Path) -> PathBuf { |
314 | 314 | agent_dir.join("resources").join("archive") |
315 | 315 | } |
316 | 316 |
|
317 | | -/// Resolve an inbox by stable identity. A proven catalog-less root retains the legacy flat bus. |
318 | | -/// Inside a catalog, an absent identity fails closed unless a real flat inbox was explicitly |
319 | | -/// provisioned, as eval does for its external requester. |
| 317 | +/// Eval-owned authority for one external flat requester mailbox. General catalog routing remains |
| 318 | +/// declaration-only; possessing this value is the explicit exception at message call sites. |
| 319 | +#[derive(Debug, Clone, PartialEq, Eq)] |
| 320 | +pub struct ExternalInbox { |
| 321 | + root: PathBuf, |
| 322 | + identity: String, |
| 323 | + inbox: PathBuf, |
| 324 | +} |
| 325 | + |
| 326 | +impl ExternalInbox { |
| 327 | + pub fn new(root: &Path, identity: &str) -> anyhow::Result<Self> { |
| 328 | + let mut components = Path::new(identity).components(); |
| 329 | + let safe = matches!(components.next(), Some(Component::Normal(component)) if component == identity) |
| 330 | + && components.next().is_none(); |
| 331 | + anyhow::ensure!(safe, "external requester identity must be one non-empty relative path component"); |
| 332 | + Ok(Self { |
| 333 | + root: root.to_path_buf(), |
| 334 | + identity: identity.to_owned(), |
| 335 | + inbox: root.join(identity).join("inbox"), |
| 336 | + }) |
| 337 | + } |
| 338 | + |
| 339 | + pub fn provision(root: &Path, identity: &str) -> anyhow::Result<Self> { |
| 340 | + let external = Self::new(root, identity)?; |
| 341 | + fs::create_dir_all(&external.inbox).map_err(|error| { |
| 342 | + anyhow::anyhow!( |
| 343 | + "provisioning external requester {identity:?} inbox {}: {error}", |
| 344 | + external.inbox.display() |
| 345 | + ) |
| 346 | + })?; |
| 347 | + Ok(external) |
| 348 | + } |
| 349 | +} |
| 350 | + |
| 351 | +/// Resolve an inbox by stable identity. A proven catalog-less root retains the legacy flat bus; |
| 352 | +/// inside a catalog an absent identity always fails closed. |
320 | 353 | pub fn resolve_inbox(root: &Path, id: &str, host: &str) -> anyhow::Result<PathBuf> { |
321 | | - match resolve_list_box(root, id, host, false, false) { |
| 354 | + resolve_list_box(root, id, host, false, false) |
| 355 | +} |
| 356 | + |
| 357 | +/// Resolve a normal declared inbox or one exact eval-owned external requester capability. |
| 358 | +pub fn resolve_inbox_with_external( |
| 359 | + root: &Path, |
| 360 | + id: &str, |
| 361 | + host: &str, |
| 362 | + external: Option<&ExternalInbox>, |
| 363 | +) -> anyhow::Result<PathBuf> { |
| 364 | + match resolve_inbox(root, id, host) { |
322 | 365 | Ok(inbox) => Ok(inbox), |
323 | | - Err(error) => { |
324 | | - let flat = root.join(id).join("inbox"); |
325 | | - match fs::symlink_metadata(&flat) { |
326 | | - Ok(metadata) if metadata.file_type().is_dir() => Ok(flat), |
327 | | - _ => Err(error), |
| 366 | + Err(error) => match external { |
| 367 | + Some(external) |
| 368 | + if external.root == root |
| 369 | + && external.identity == id |
| 370 | + && external.inbox.is_dir() => |
| 371 | + { |
| 372 | + Ok(external.inbox.clone()) |
328 | 373 | } |
329 | | - } |
| 374 | + _ => Err(error), |
| 375 | + }, |
330 | 376 | } |
331 | 377 | } |
332 | 378 |
|
@@ -675,8 +721,27 @@ mod tests { |
675 | 721 | ); |
676 | 722 | assert!(resolve_inbox(root, "Shared Worker", "h").is_err()); |
677 | 723 |
|
| 724 | + let external = ExternalInbox::new(root, "requester").unwrap(); |
| 725 | + assert!(resolve_inbox_with_external(root, "requester", "h", Some(&external)).is_err()); |
| 726 | + |
678 | 727 | let requester = root.join("requester").join("inbox"); |
679 | 728 | std::fs::create_dir_all(&requester).unwrap(); |
680 | | - assert_eq!(resolve_inbox(root, "requester", "h").unwrap(), requester); |
| 729 | + assert!(resolve_inbox(root, "requester", "h").is_err()); |
| 730 | + assert_eq!( |
| 731 | + resolve_inbox_with_external(root, "requester", "h", Some(&external)).unwrap(), |
| 732 | + requester |
| 733 | + ); |
| 734 | + assert!(resolve_inbox_with_external(root, "missing", "h", Some(&external)).is_err()); |
| 735 | + } |
| 736 | + |
| 737 | + #[test] |
| 738 | + fn external_inbox_rejects_unsafe_or_nested_identities() { |
| 739 | + let tmp = tempfile::tempdir().unwrap(); |
| 740 | + for identity in ["", ".", "..", "nested/requester", "../requester", "/requester"] { |
| 741 | + assert!( |
| 742 | + ExternalInbox::new(tmp.path(), identity).is_err(), |
| 743 | + "accepted unsafe external identity {identity:?}" |
| 744 | + ); |
| 745 | + } |
681 | 746 | } |
682 | 747 | } |
0 commit comments