From ba143e1ccdff849809e00bdaa4211df6a174724d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Thu, 22 Oct 2026 18:10:49 +0500 Subject: [PATCH] fix(control): MR2.1 fail-closed world-port resolution and ruleset invariant verification - Make world_port resolution fallible (Result) across LocalBackend, HostService, DirectRouteManager, and RelayLink without default fallbacks to 8085. - Enforce strict 4-way invariant: local_id ruleset == RealmAccess.mode == Db.realm() == RealmInfo.ruleset. Malformed or unknown suffixes fail closed. - If Wildcard is selected and world-port resolution fails (or simultaneous mode is disabled), Direct and Relay fail closed with actionable error and zero fallback to CoA. - Fix documentation drift in docs/WILDCARD.md and docs/MULTIRULESET_PORTABLE_AUDIT.md regarding simultaneous execution, distinct ports, and current vs historical baseline. --- crates/coa-core/src/control/backend.rs | 177 ++++++++++++++++-- crates/coa-core/src/control/direct_ingress.rs | 24 ++- crates/coa-core/src/control/direct_route.rs | 21 ++- crates/coa-core/src/control/host_manager.rs | 34 ++-- crates/coa-core/src/control/relay_link.rs | 14 +- crates/coa-core/src/control/service.rs | 21 ++- crates/coa-core/src/control/store.rs | 5 +- .../coa-core/src/portable/service/engine.rs | 11 +- .../src/portable/session/collection_tests.rs | 5 +- crates/coa-core/src/portable/session/fake.rs | 8 +- crates/coa-core/src/portable/session/tests.rs | 20 +- crates/coa-core/src/portable/store/tests.rs | 114 +++++++++-- crates/coa-core/src/realm_registry/advert.rs | 25 ++- crates/coa-core/src/realms.rs | 14 +- docs/MULTIRULESET_PORTABLE_AUDIT.md | 83 ++++---- docs/WILDCARD.md | 22 ++- 16 files changed, 463 insertions(+), 135 deletions(-) diff --git a/crates/coa-core/src/control/backend.rs b/crates/coa-core/src/control/backend.rs index f00b908..0861b1a 100644 --- a/crates/coa-core/src/control/backend.rs +++ b/crates/coa-core/src/control/backend.rs @@ -64,8 +64,31 @@ impl LocalBackend { } fn access(&self, local_id: &str) -> Result { - find_access(&self.descriptors, &(self.installs)(), local_id) - .ok_or_else(|| Error::Invalid("That realm is not known to this Manager.".into())) + let access = + find_access(&self.descriptors, &(self.installs)(), local_id).ok_or_else(|| { + Error::Invalid(format!( + "That realm is not known to this Manager: {local_id}" + )) + })?; + if let Some((_base, rs)) = local_id.split_once(':') { + let expected_mode = match rs { + "coa" => crate::realms::Mode::Coa, + "wildcard" => crate::realms::Mode::Wildcard, + "reborn" => crate::realms::Mode::Reborn, + _ => { + return Err(Error::Invalid(format!( + "Malformed or unknown ruleset suffix in local_id: {rs}" + ))) + } + }; + if access.mode != Some(expected_mode) { + return Err(Error::Invalid(format!( + "Realm access mode {:?} does not match expected ruleset in local_id {local_id}", + access.mode + ))); + } + } + Ok(access) } fn account(&self, local_id: &str, filter: &str) -> Result> { @@ -89,6 +112,32 @@ impl RealmBackend for LocalBackend { let access = self.access(local_id)?; let db = access.db()?; let ruleset = realm::ruleset_of(&db).map_err(|e| Error::Invalid(e.to_string()))?; + + // Explicit local-id -> ruleset invariant: + // local_id ruleset == RealmAccess.mode == Db.realm() == RealmInfo.ruleset + if let Some((_base, rs)) = local_id.split_once(':') { + let expected_ruleset: crate::portable::Ruleset = rs + .parse() + .map_err(|_| Error::Invalid(format!("Unknown ruleset in local_id: {rs}")))?; + if ruleset != expected_ruleset { + return Err(Error::Invalid(format!( + "Ruleset of realm DB ({ruleset:?}) does not match local_id {local_id}" + ))); + } + if access.mode.map(|m| m.name()) != Some(expected_ruleset.as_str()) { + return Err(Error::Invalid(format!( + "RealmAccess mode ({:?}) does not match local_id {local_id}", + access.mode + ))); + } + if db.realm().name() != expected_ruleset.as_str() { + return Err(Error::Invalid(format!( + "Db realm ({:?}) does not match local_id {local_id}", + db.realm() + ))); + } + } + Ok(RealmInfo { automatic: cfg.as_ref().is_none_or(|c| !c.existing_only), route: cfg.and_then(|c| c.route), @@ -256,11 +305,22 @@ impl RealmBackend for LocalBackend { } } let access = self.access(local_id)?; - let mode = access.mode.unwrap_or(crate::realms::Mode::Coa); + let mode = if let Some(m) = access.mode { + m + } else if let Some((_base, _rs)) = local_id.split_once(':') { + return Err(Error::Invalid(format!( + "Unresolved mode for ruleset suffix in {local_id}" + ))); + } else { + // Legacy unsuffixed CoA realm ONLY + crate::realms::Mode::Coa + }; if let Some(ref root) = access.root { crate::realms::world_port_for_mode(root, mode) } else { - Ok(8085) + Err(Error::Invalid(format!( + "No server installation root for {local_id} to resolve world port" + ))) } } } @@ -282,8 +342,8 @@ mod tests { fs::create_dir_all(&desc_dir).unwrap(); let portable_dir = dir.path().join("portable"); - let rt = PortableRuntime::start(&portable_dir, Box::new(|| vec![]), None).unwrap(); - let backend = LocalBackend::new(Arc::new(rt), &settings, &desc_dir, Arc::new(|| vec![])); + let rt = PortableRuntime::start(&portable_dir, Box::new(Vec::new), None).unwrap(); + let backend = LocalBackend::new(Arc::new(rt), &settings, &desc_dir, Arc::new(Vec::new)); // Unknown realm id must return Err, never Ok(RealmInfo { ruleset: Ruleset::Coa, .. }) assert!(backend.info("srv-unknown").is_err()); @@ -296,11 +356,7 @@ mod tests { let dir = tempdir().unwrap(); let root = dir.path().join("srv"); fs::create_dir_all(root.join("Settings")).unwrap(); - fs::write( - root.join("Settings/repack.json"), - r#"{"worldPort": 8085}"#, - ) - .unwrap(); + fs::write(root.join("Settings/repack.json"), r#"{"worldPort": 8085}"#).unwrap(); fs::write( root.join("Settings/realm-profile.json"), r#"{"active": "coa", "wildcard_created": true, "simultaneous": true, "secondary_world_port": 8086}"#, @@ -318,7 +374,7 @@ mod tests { let desc_dir = dir.path().join("descriptors"); let portable_dir = dir.path().join("portable"); - let rt = PortableRuntime::start(&portable_dir, Box::new(|| vec![]), None).unwrap(); + let rt = PortableRuntime::start(&portable_dir, Box::new(Vec::new), None).unwrap(); let root_clone = root.clone(); let backend = LocalBackend::new( Arc::new(rt), @@ -329,5 +385,102 @@ mod tests { assert_eq!(backend.world_port("srv-test1:coa").unwrap(), 8085); assert_eq!(backend.world_port("srv-test1:wildcard").unwrap(), 8086); + + // When simultaneous is false and active is CoA: + fs::write( + root.join("Settings/realm-profile.json"), + r#"{"active": "coa", "wildcard_created": true, "simultaneous": false}"#, + ) + .unwrap(); + + // CoA succeeds with 8085 + assert_eq!(backend.world_port("srv-test1:coa").unwrap(), 8085); + // Wildcard must FAIL CLOSED, never return 8085 + let err_wc_mode = + crate::realms::world_port_for_mode(&root, crate::realms::Mode::Wildcard).unwrap_err(); + assert!( + err_wc_mode.to_string().contains("not active"), + "Expected not active error, got: {err_wc_mode}" + ); + assert!(backend.world_port("srv-test1:wildcard").is_err()); + + // Reborn must FAIL CLOSED + let err_reb_mode = + crate::realms::world_port_for_mode(&root, crate::realms::Mode::Reborn).unwrap_err(); + assert!( + err_reb_mode.to_string().contains("not active"), + "Expected not active error, got: {err_reb_mode}" + ); + assert!(backend.world_port("srv-test1:reborn").is_err()); + + // Malformed and unknown suffixes must fail closed + assert!(backend.access("srv-test1:unknown").is_err()); + assert!(backend.access("srv-test1:garbage").is_err()); + assert!(backend.access("srv-test1:coa:extra").is_err()); + assert!(backend.world_port("srv-test1:unknown").is_err()); + assert!(backend.world_port("srv-test1:garbage").is_err()); + } + + #[test] + fn world_port_lookup_failure_causes_direct_and_relay_to_fail_closed() { + let dir = tempdir().unwrap(); + let root = dir.path().join("srv"); + fs::create_dir_all(root.join("Settings")).unwrap(); + fs::write(root.join("Settings/repack.json"), r#"{"worldPort": 8085}"#).unwrap(); + // Server only has active CoA, simultaneous is off + fs::write( + root.join("Settings/realm-profile.json"), + r#"{"active": "coa", "wildcard_created": false, "simultaneous": false}"#, + ) + .unwrap(); + + let desc_dir = dir.path().join("descriptors"); + let portable_dir = dir.path().join("portable"); + let rt = PortableRuntime::start(&portable_dir, Box::new(Vec::new), None).unwrap(); + let root_clone = root.clone(); + let backend = Arc::new(LocalBackend::new( + Arc::new(rt), + root.join("Settings"), + &desc_dir, + Arc::new(move || vec![("test1".to_string(), root_clone.clone())]), + )); + + let store = Arc::new(std::sync::Mutex::new( + crate::control::store::ControlStore::open_in_memory().unwrap(), + )); + let service = Arc::new(crate::control::service::HostService::new( + "srv-test1:wildcard", + coa_registry_proto::sign::RealmId::new(), + store, + backend, + )); + + // 1. HostService::local_world_port fails closed + assert!(service.local_world_port().is_err()); + + // 2. Direct route manager cannot find a direct route because world port resolution failed + let mut direct_mgr = + crate::control::direct_route::DirectRouteManager::start(None, service.clone()); + assert!(!direct_mgr.is_direct_ready()); + assert!(service.direct_route().is_none()); + direct_mgr.shutdown(); + + // 3. When relay link has no world port resolved, AllocateRelay refuses + let mut session = crate::control::service::Session::default(); + let player = uuid::Uuid::now_v7(); + let resp = service.handle( + &mut session, + &player, + "KEY", + coa_control_proto::app::Request::AllocateRelay { player_id: player }, + ); + assert!(matches!( + resp, + coa_control_proto::app::Response::Error { + code: coa_control_proto::app::AppError::NoAccount + | coa_control_proto::app::AppError::RelayUnavailable, + .. + } + )); } } diff --git a/crates/coa-core/src/control/direct_ingress.rs b/crates/coa-core/src/control/direct_ingress.rs index dee52f8..2f53097 100644 --- a/crates/coa-core/src/control/direct_ingress.rs +++ b/crates/coa-core/src/control/direct_ingress.rs @@ -33,7 +33,11 @@ impl DirectIngress { local_auth_port: u16, local_world_port: u16, ) -> Result { - let auth_host = if auth_bind_port == 0 { "127.0.0.1" } else { "0.0.0.0" }; + let auth_host = if auth_bind_port == 0 { + "127.0.0.1" + } else { + "0.0.0.0" + }; let auth_listener = TcpListener::bind(format!("{auth_host}:{auth_bind_port}")).map_err(|e| { Error::Invalid(format!( @@ -48,7 +52,11 @@ impl DirectIngress { .map_err(|e| Error::Invalid(e.to_string()))? .port(); - let world_host = if world_bind_port == 0 { "127.0.0.1" } else { "0.0.0.0" }; + let world_host = if world_bind_port == 0 { + "127.0.0.1" + } else { + "0.0.0.0" + }; let world_listener = TcpListener::bind(format!("{world_host}:{world_bind_port}")).map_err(|e| { Error::Invalid(format!( @@ -116,7 +124,9 @@ impl DirectIngress { std::thread::Builder::new() .name("direct-world-worker".into()) .spawn(move || { - if let Err(e) = handle_world_client(client_stream, local_world_port) { + if let Err(e) = + handle_world_client(client_stream, local_world_port) + { eprintln!("handle_world_client error: {e}"); } }) @@ -189,7 +199,9 @@ fn handle_auth_client( } } let mut server = server.ok_or_else(|| { - Error::Invalid(format!("Cannot connect to local authserver: {local_auth_port}")) + Error::Invalid(format!( + "Cannot connect to local authserver: {local_auth_port}" + )) })?; let _ = client.set_read_timeout(Some(Duration::from_millis(500))); @@ -287,7 +299,9 @@ fn handle_world_client(mut client: TcpStream, local_world_port: u16) -> Result<( } } let mut server = server.ok_or_else(|| { - Error::Invalid(format!("Cannot connect to local worldserver: {local_world_port}")) + Error::Invalid(format!( + "Cannot connect to local worldserver: {local_world_port}" + )) })?; let _ = client.set_nonblocking(false); diff --git a/crates/coa-core/src/control/direct_route.rs b/crates/coa-core/src/control/direct_route.rs index 350625b..dd3e939 100644 --- a/crates/coa-core/src/control/direct_route.rs +++ b/crates/coa-core/src/control/direct_route.rs @@ -304,10 +304,25 @@ fn attempt_route_discovery( .ok() .and_then(|p| p.parse().ok()) .unwrap_or(coa_control_proto::relay::LOCAL_AUTH_PORT); - let local_world = std::env::var("COA_OVERRIDE_LOCAL_WORLD_PORT") + let local_world = match std::env::var("COA_OVERRIDE_LOCAL_WORLD_PORT") .ok() .and_then(|p| p.parse().ok()) - .unwrap_or_else(|| service.local_world_port()); + { + Some(p) => p, + None => match service.local_world_port() { + Ok(p) => p, + Err(e) => { + set_status( + status, + RouteStatus::RelayOnly { + reason: format!("Cannot resolve local world port: {e}"), + }, + ); + service.set_direct_route(None); + return; + } + }, + }; let desired_auth_port: u16 = std::env::var("COA_DIRECT_AUTH_PORT") .ok() @@ -316,7 +331,7 @@ fn attempt_route_discovery( let desired_world_port: u16 = std::env::var("COA_DIRECT_WORLD_PORT") .ok() .and_then(|p| p.parse().ok()) - .unwrap_or(8085); + .unwrap_or(local_world); let ingress = match DirectIngress::start( desired_auth_port, diff --git a/crates/coa-core/src/control/host_manager.rs b/crates/coa-core/src/control/host_manager.rs index 66e17a0..ecdc860 100644 --- a/crates/coa-core/src/control/host_manager.rs +++ b/crates/coa-core/src/control/host_manager.rs @@ -95,19 +95,27 @@ impl HostControl { store.clone(), backend.clone(), )); - let relay_link = - crate::control::transport::relay_url(&base, "/relay/v1/host").map( - |r_url| { - let rl = crate::control::relay_link::RelayLink::start_with_ports( - r_url, - realm_id, - key.clone(), - Some(service.local_world_port()), - ); - service.set_relay(rl.clone()); - rl - }, - ); + let relay_link = match service.local_world_port() { + Ok(world_port) => { + crate::control::transport::relay_url(&base, "/relay/v1/host").map( + |r_url| { + let rl = + crate::control::relay_link::RelayLink::start_with_ports( + r_url, + realm_id, + key.clone(), + Some(world_port), + ); + service.set_relay(rl.clone()); + rl + }, + ) + } + Err(e) => { + eprintln!("Relay link unavailable for {local_id}: cannot resolve world port: {e}"); + None + } + }; let direct_route = Some(crate::control::direct_route::DirectRouteManager::start( Some(base.clone()), diff --git a/crates/coa-core/src/control/relay_link.rs b/crates/coa-core/src/control/relay_link.rs index e9e38e8..52097d0 100644 --- a/crates/coa-core/src/control/relay_link.rs +++ b/crates/coa-core/src/control/relay_link.rs @@ -319,9 +319,17 @@ fn session( // Strictly predefined local targets: Auth (3724) or World (8085 or bound realm world port) let local_port = match target { RelayTarget::Auth => target.local_port(), - RelayTarget::World => { - local_world_port.unwrap_or_else(|| target.local_port()) - } + RelayTarget::World => match local_world_port { + Some(p) => p, + None => { + eprintln!("RelayLink cannot connect to World: local_world_port was not provided (fail-closed)"); + let _ = ws_out_tx.send(TunnelMsg::ConnectErr { + stream_id, + error: "World port unresolved".into(), + }); + continue; + } + }, }; match TcpStream::connect(("127.0.0.1", local_port)) { Ok(tcp) => { diff --git a/crates/coa-core/src/control/service.rs b/crates/coa-core/src/control/service.rs index 26f68b4..32ac619 100644 --- a/crates/coa-core/src/control/service.rs +++ b/crates/coa-core/src/control/service.rs @@ -120,10 +120,8 @@ pub trait RealmBackend: Send + Sync { local_id: &str, params: RemoteImportParams, ) -> Result; - /// Local world port for this realm. - fn world_port(&self, _local_id: &str) -> Result { - Ok(8085) - } + /// Local world port for this realm. Must fail closed if the realm port cannot be resolved. + fn world_port(&self, local_id: &str) -> Result; } /// State of one channel. @@ -224,8 +222,8 @@ impl HostService { self.realm_id } - pub fn local_world_port(&self) -> u16 { - self.backend.world_port(&self.local_id).unwrap_or(8085) + pub fn local_world_port(&self) -> Result { + self.backend.world_port(&self.local_id) } /// The player's public key is the one recorded for this player id when there is a record; `None` when the player is new here. @@ -1172,6 +1170,7 @@ pub mod fake { pub seen_passwords: Mutex>, pub imported: Mutex>, pub incompatible: Mutex>, + pub world_port: Mutex>>, } impl FakeRealm { @@ -1363,6 +1362,16 @@ pub mod fake { notes: vec![], }) } + fn world_port(&self, _: &str) -> Result { + if let Some(res) = self.world_port.lock().unwrap().as_ref() { + match res { + Ok(p) => Ok(*p), + Err(e) => Err(Error::Invalid(e.to_string())), + } + } else { + Ok(8085) + } + } } } diff --git a/crates/coa-core/src/control/store.rs b/crates/coa-core/src/control/store.rs index fc336d3..3ca386b 100644 --- a/crates/coa-core/src/control/store.rs +++ b/crates/coa-core/src/control/store.rs @@ -201,7 +201,10 @@ impl ControlStore { .map_err(db)?; let _ = conn.pragma_update(None, "journal_mode", "WAL"); conn.execute_batch(SCHEMA).map_err(db)?; - let _ = conn.execute("ALTER TABLE host_transfer ADD COLUMN ruleset TEXT NOT NULL DEFAULT 'coa'", []); + let _ = conn.execute( + "ALTER TABLE host_transfer ADD COLUMN ruleset TEXT NOT NULL DEFAULT 'coa'", + [], + ); Ok(Self { conn }) } diff --git a/crates/coa-core/src/portable/service/engine.rs b/crates/coa-core/src/portable/service/engine.rs index cd59d5c..0af90b0 100644 --- a/crates/coa-core/src/portable/service/engine.rs +++ b/crates/coa-core/src/portable/service/engine.rs @@ -1652,13 +1652,16 @@ impl PortableService { if !super::super::session::protocol::carried_kind_for(kind, model.ruleset) { return Err(fail( "incompatible", - format!("Foreign collection kind {kind:?} in {} remote transfer", model.ruleset), + format!( + "Foreign collection kind {kind:?} in {} remote transfer", + model.ruleset + ), )); } if let Ok(set) = super::super::collection::IdSet::from_ids(ids.iter().copied()) { - let _ = - self.host - .merge_collection(self.host_profile, model.ruleset, kind, &set); + let _ = self + .host + .merge_collection(self.host_profile, model.ruleset, kind, &set); } } diff --git a/crates/coa-core/src/portable/session/collection_tests.rs b/crates/coa-core/src/portable/session/collection_tests.rs index 9b2c6ac..8ff9a23 100644 --- a/crates/coa-core/src/portable/session/collection_tests.rs +++ b/crates/coa-core/src/portable/session/collection_tests.rs @@ -812,7 +812,10 @@ fn wildcard_and_coa_collections_remain_strictly_isolated() { set: CompactSet::seal("wildcard:appearance", &wc_update).unwrap(), }; let ack = o.handle_collection(&to_json(&msg_wc).unwrap()).unwrap(); - assert!(matches!(ack.outcome, CollectionOutcome::Applied | CollectionOutcome::Unchanged)); + assert!(matches!( + ack.outcome, + CollectionOutcome::Applied | CollectionOutcome::Unchanged + )); // CoA remains exactly [10, 20, 30] let (_, s_coa_after) = o diff --git a/crates/coa-core/src/portable/session/fake.rs b/crates/coa-core/src/portable/session/fake.rs index a634f69..a7c3ee6 100644 --- a/crates/coa-core/src/portable/session/fake.rs +++ b/crates/coa-core/src/portable/session/fake.rs @@ -467,7 +467,9 @@ impl RealmBridge for FakeRealm { impl FakeRealm { fn rows(&self, kind: &str) -> Result<&IdSet> { match kind { - "coa:appearance" | "wildcard:appearance" | "reborn:appearance" => Ok(&self.appearance_rows), + "coa:appearance" | "wildcard:appearance" | "reborn:appearance" => { + Ok(&self.appearance_rows) + } "coa:vanity" | "wildcard:vanity" | "reborn:vanity" => Ok(&self.vanity_rows), other => Err(PortableError::Invalid(format!("unknown kind {other}"))), } @@ -475,7 +477,9 @@ impl FakeRealm { fn rows_mut(&mut self, kind: &str) -> Result<&mut IdSet> { match kind { - "coa:appearance" | "wildcard:appearance" | "reborn:appearance" => Ok(&mut self.appearance_rows), + "coa:appearance" | "wildcard:appearance" | "reborn:appearance" => { + Ok(&mut self.appearance_rows) + } "coa:vanity" | "wildcard:vanity" | "reborn:vanity" => Ok(&mut self.vanity_rows), other => Err(PortableError::Invalid(format!("unknown kind {other}"))), } diff --git a/crates/coa-core/src/portable/session/tests.rs b/crates/coa-core/src/portable/session/tests.rs index 72c2b4f..9e5686a 100644 --- a/crates/coa-core/src/portable/session/tests.rs +++ b/crates/coa-core/src/portable/session/tests.rs @@ -1100,8 +1100,12 @@ fn wildcard_session_offer_carrying_coa_snapshot_is_rejected_with_zero_mutation() let host_profile = host.default_profile().unwrap(); let coa_model = geared_level_eighty(); - let id = owner.create_character(profile, coa_model, "realm-a").unwrap(); - let mut offer = OwnerService::new(&mut owner).offer(id, "srv-1:wildcard").unwrap(); + let id = owner + .create_character(profile, coa_model, "realm-a") + .unwrap(); + let mut offer = OwnerService::new(&mut owner) + .offer(id, "srv-1:wildcard") + .unwrap(); // Tamper offer: claim it is Wildcard while snapshot is CoA offer.ruleset = Ruleset::Wildcard; @@ -1199,9 +1203,13 @@ fn wildcard_session_using_coa_server_id_is_rejected() { let mut model = geared_level_eighty(); model.ruleset = Ruleset::Wildcard; - let id = owner.create_character(profile, model, "wildcard-origin").unwrap(); + let id = owner + .create_character(profile, model, "wildcard-origin") + .unwrap(); // Offer for srv-1:coa - let offer = OwnerService::new(&mut owner).offer(id, "srv-1:coa").unwrap(); + let offer = OwnerService::new(&mut owner) + .offer(id, "srv-1:coa") + .unwrap(); // Host configured for Wildcard realm let mut h = HostService::new(&mut host, "srv-1:wildcard", HostConfig::default()); @@ -1216,7 +1224,9 @@ fn same_character_id_with_foreign_ruleset_is_rejected() { let mut model = geared_level_eighty(); model.ruleset = Ruleset::Wildcard; - let id = owner.create_character(profile, model.clone(), "srv-1:wildcard").unwrap(); + let id = owner + .create_character(profile, model.clone(), "srv-1:wildcard") + .unwrap(); // Tampered snapshot with same ID but Ruleset::Coa let mut tampered = model.clone(); diff --git a/crates/coa-core/src/portable/store/tests.rs b/crates/coa-core/src/portable/store/tests.rs index 2586ceb..8d9b010 100644 --- a/crates/coa-core/src/portable/store/tests.rs +++ b/crates/coa-core/src/portable/store/tests.rs @@ -2190,36 +2190,75 @@ fn three_space_isolation_fixture_proves_wildcard_activity_leaves_coa_and_reborn_ let mut model_a = fixtures::geared_level_eighty(); model_a.identity.name = "Hero".into(); model_a.ruleset = Ruleset::Coa; - let char_a = store.create_character(profile, model_a, "srv-1:coa").unwrap(); + let char_a = store + .create_character(profile, model_a, "srv-1:coa") + .unwrap(); // Character B: Wildcard let mut model_b = fixtures::geared_level_eighty(); model_b.identity.name = "Hero".into(); model_b.ruleset = Ruleset::Wildcard; - let char_b = store.create_character(profile, model_b, "srv-1:wildcard").unwrap(); + let char_b = store + .create_character(profile, model_b, "srv-1:wildcard") + .unwrap(); // Character C: Reborn let mut model_c = fixtures::geared_level_eighty(); model_c.identity.name = "Hero".into(); model_c.ruleset = Ruleset::Reborn; - let char_c = store.create_character(profile, model_c, "srv-1:reborn").unwrap(); + let char_c = store + .create_character(profile, model_c, "srv-1:reborn") + .unwrap(); // Initial identical mappings (local_guid = 100, item_guid = 200) - store.bind_server(char_a, "srv-1:coa", 100, 1, MappingState::Active).unwrap(); - store.bind_server(char_b, "srv-1:wildcard", 100, 1, MappingState::Active).unwrap(); - store.bind_server(char_c, "srv-1:reborn", 100, 1, MappingState::Active).unwrap(); + store + .bind_server(char_a, "srv-1:coa", 100, 1, MappingState::Active) + .unwrap(); + store + .bind_server(char_b, "srv-1:wildcard", 100, 1, MappingState::Active) + .unwrap(); + store + .bind_server(char_c, "srv-1:reborn", 100, 1, MappingState::Active) + .unwrap(); let item_a = PortableItemId::new(); let item_b = PortableItemId::new(); let item_c = PortableItemId::new(); - store.reconcile_item_mappings(char_a, "srv-1:coa", 1, &[obs(item_a, 200, 10, "e1")]).unwrap(); - store.reconcile_item_mappings(char_b, "srv-1:wildcard", 1, &[obs(item_b, 200, 10, "e2")]).unwrap(); - store.reconcile_item_mappings(char_c, "srv-1:reborn", 1, &[obs(item_c, 200, 10, "e3")]).unwrap(); + store + .reconcile_item_mappings(char_a, "srv-1:coa", 1, &[obs(item_a, 200, 10, "e1")]) + .unwrap(); + store + .reconcile_item_mappings(char_b, "srv-1:wildcard", 1, &[obs(item_b, 200, 10, "e2")]) + .unwrap(); + store + .reconcile_item_mappings(char_c, "srv-1:reborn", 1, &[obs(item_c, 200, 10, "e3")]) + .unwrap(); // Initial collections - store.merge_collection(profile, Ruleset::Coa, "coa:appearance", &IdSet::from_ids([1, 2, 3]).unwrap()).unwrap(); - store.merge_collection(profile, Ruleset::Wildcard, "wildcard:appearance", &IdSet::from_ids([1, 2, 4]).unwrap()).unwrap(); - store.merge_collection(profile, Ruleset::Reborn, "reborn:appearance", &IdSet::from_ids([1, 2, 5]).unwrap()).unwrap(); + store + .merge_collection( + profile, + Ruleset::Coa, + "coa:appearance", + &IdSet::from_ids([1, 2, 3]).unwrap(), + ) + .unwrap(); + store + .merge_collection( + profile, + Ruleset::Wildcard, + "wildcard:appearance", + &IdSet::from_ids([1, 2, 4]).unwrap(), + ) + .unwrap(); + store + .merge_collection( + profile, + Ruleset::Reborn, + "reborn:appearance", + &IdSet::from_ids([1, 2, 5]).unwrap(), + ) + .unwrap(); // Record baseline hashes and revisions let hash_a_before = store.load_snapshot(char_a, 1).unwrap(); @@ -2229,26 +2268,60 @@ fn three_space_isolation_fixture_proves_wildcard_activity_leaves_coa_and_reborn_ let mut model_b_mut = store.load_current(char_b).unwrap(); model_b_mut.progression.money += 100_000; model_b_mut.progression.level = 80; - store.commit_snapshot(char_b, 1, model_b_mut, "srv-1:wildcard", Some("wildcard gameplay")).unwrap(); + store + .commit_snapshot( + char_b, + 1, + model_b_mut, + "srv-1:wildcard", + Some("wildcard gameplay"), + ) + .unwrap(); - store.merge_collection(profile, Ruleset::Wildcard, "wildcard:appearance", &IdSet::from_ids([1, 2, 4, 999]).unwrap()).unwrap(); + store + .merge_collection( + profile, + Ruleset::Wildcard, + "wildcard:appearance", + &IdSet::from_ids([1, 2, 4, 999]).unwrap(), + ) + .unwrap(); let new_item_b = PortableItemId::new(); - store.reconcile_item_mappings(char_b, "srv-1:wildcard", 2, &[obs(item_b, 200, 10, "e2"), obs(new_item_b, 201, 10, "e2_new")]).unwrap(); + store + .reconcile_item_mappings( + char_b, + "srv-1:wildcard", + 2, + &[ + obs(item_b, 200, 10, "e2"), + obs(new_item_b, 201, 10, "e2_new"), + ], + ) + .unwrap(); // ---- ASSERT WILDCARD B ADVANCED ---- let rec_b = store.character(char_b).unwrap(); assert_eq!(rec_b.revision, 2); - let (_, coll_b) = store.collection(profile, Ruleset::Wildcard, "wildcard:appearance").unwrap().unwrap(); + let (_, coll_b) = store + .collection(profile, Ruleset::Wildcard, "wildcard:appearance") + .unwrap() + .unwrap(); assert_eq!(coll_b.ids(), &[1, 2, 4, 999]); - assert_eq!(store.item_mappings(char_b, "srv-1:wildcard").unwrap().len(), 2); + assert_eq!( + store.item_mappings(char_b, "srv-1:wildcard").unwrap().len(), + 2 + ); // ---- ASSERT COA A IS 100% UNTOUCHED ---- let rec_a = store.character(char_a).unwrap(); assert_eq!(rec_a.revision, 1); let hash_a_after = store.load_snapshot(char_a, 1).unwrap(); assert_eq!(hash_a_before, hash_a_after); - let (_, coll_a) = store.collection(profile, Ruleset::Coa, "coa:appearance").unwrap().unwrap(); + let (_, coll_a) = store + .collection(profile, Ruleset::Coa, "coa:appearance") + .unwrap() + .unwrap(); assert_eq!(coll_a.ids(), &[1, 2, 3]); let items_a = store.item_mappings(char_a, "srv-1:coa").unwrap(); assert_eq!(items_a.len(), 1); @@ -2260,7 +2333,10 @@ fn three_space_isolation_fixture_proves_wildcard_activity_leaves_coa_and_reborn_ assert_eq!(rec_c.revision, 1); let hash_c_after = store.load_snapshot(char_c, 1).unwrap(); assert_eq!(hash_c_before, hash_c_after); - let (_, coll_c) = store.collection(profile, Ruleset::Reborn, "reborn:appearance").unwrap().unwrap(); + let (_, coll_c) = store + .collection(profile, Ruleset::Reborn, "reborn:appearance") + .unwrap() + .unwrap(); assert_eq!(coll_c.ids(), &[1, 2, 5]); let items_c = store.item_mappings(char_c, "srv-1:reborn").unwrap(); assert_eq!(items_c.len(), 1); diff --git a/crates/coa-core/src/realm_registry/advert.rs b/crates/coa-core/src/realm_registry/advert.rs index f1b427e..b00b93c 100644 --- a/crates/coa-core/src/realm_registry/advert.rs +++ b/crates/coa-core/src/realm_registry/advert.rs @@ -130,12 +130,12 @@ pub fn find_access( ) -> Option { let (base_id, explicit_mode) = if let Some((base, rs)) = local_id.split_once(':') { let m = match rs { - "coa" => Some(crate::realms::Mode::Coa), - "wildcard" => Some(crate::realms::Mode::Wildcard), - "reborn" => Some(crate::realms::Mode::Reborn), - _ => None, + "coa" => crate::realms::Mode::Coa, + "wildcard" => crate::realms::Mode::Wildcard, + "reborn" => crate::realms::Mode::Reborn, + _ => return None, }; - (base, m) + (base, Some(m)) } else { (local_id, None) }; @@ -145,6 +145,21 @@ pub fn find_access( { let mut access = RealmAccess::from_install(id, root); if let Some(m) = explicit_mode { + if let Ok(st) = crate::realms::state(root) { + let supported = match m { + crate::realms::Mode::Coa => { + st.active == crate::realms::Mode::Coa || st.simultaneous + } + crate::realms::Mode::Wildcard => { + st.active == crate::realms::Mode::Wildcard + || (st.simultaneous && st.wildcard_created) + } + crate::realms::Mode::Reborn => st.active == crate::realms::Mode::Reborn, + }; + if !supported { + return None; + } + } access.id = format!("srv-{id}:{}", m.name()); access.name = format!("{} — {}", access.name, m.label()); access.mode = Some(m); diff --git a/crates/coa-core/src/realms.rs b/crates/coa-core/src/realms.rs index 4e8b111..0683360 100644 --- a/crates/coa-core/src/realms.rs +++ b/crates/coa-core/src/realms.rs @@ -749,11 +749,21 @@ pub fn world_port_for_mode(root: &Path, mode: Mode) -> Result { (Mode::Coa, Mode::Wildcard) => second, (Mode::Wildcard, Mode::Wildcard) => ports.world, (Mode::Wildcard, Mode::Coa) => second, - (_, _) => ports.world, + _ => { + return Err(Error::Invalid(format!( + "Unsupported mode {mode:?} for simultaneous hosting (active: {:?})", + s.active + ))) + } }; Ok(port) - } else { + } else if mode == s.active { Ok(ports.world) + } else { + Err(Error::Invalid(format!( + "Realm mode {mode:?} is not active (active: {:?}) and simultaneous mode is disabled.", + s.active + ))) } } diff --git a/docs/MULTIRULESET_PORTABLE_AUDIT.md b/docs/MULTIRULESET_PORTABLE_AUDIT.md index ece7920..2458731 100644 --- a/docs/MULTIRULESET_PORTABLE_AUDIT.md +++ b/docs/MULTIRULESET_PORTABLE_AUDIT.md @@ -53,9 +53,41 @@ Status: Updated for MR2 Gate (Wildcard Vertical Slice Completed) --- -## 2. Inventory of Current Subsystem Locations & Representation +## 2. Current Implementation Architecture & Historical Baseline Inventory -### 2.1 Realm Profiles & Databases (`crates/coa-core/src/realms.rs`, `db.rs`) +### 2.1 Current Implementation State (As of MR1, MR1.1, MR2, MR2.1) +- **Authoritative Ruleset Representation:** + - `crates/coa-core/src/portable/model.rs`: `enum Ruleset { Coa, Wildcard, Reborn }` (strict parsing and schema routing without fallbacks). + - `crates/coa-registry-proto/src/caps.rs`: `enum Ruleset { Coa, Wildcard, Reborn }`. + - `services/coa-registry/src/pg.rs`: `ruleset: match ruleset.as_str() { "coa" => Ruleset::Coa, "wildcard" => Ruleset::Wildcard, "reborn" => Ruleset::Reborn, ... }`. + - `src/lib/browse.ts`: `export type Ruleset = "coa" | "wildcard" | "reborn";`. + - `crates/coa-core/src/realms.rs`: `enum Mode { Coa, Wildcard, Reborn }`. +- **Wire Protocols & Ruleset Requirements:** + - **Control Protocol 2:** `CONTROL_PROTOCOL_VERSION = 2`. `Request::TransferOffer` requires explicit, non-default `ruleset: String`. `Response::Claimed` returns explicit `ruleset: String`. `HostTransfer` records ruleset. + - **Session Protocol 3:** `SESSION_PROTOCOL_VERSION = 3`. `SessionOffer`, `PortableSessionStarted`, `PortableCheckpoint`, and `OwnerAck` require explicit, mandatory `ruleset: Ruleset`. + - **Collection Protocol 2:** `COLLECTION_PROTOCOL_VERSION = 2`. `CollectionObserved`, `CollectionState`, and `CollectionAck` require explicit, mandatory `ruleset: Ruleset`. +- **Database Routing & Fail-Closed Invariants:** + - `LocalBackend::info()` and `LocalBackend::access()` strictly enforce: + $$\text{local\_id ruleset} == \text{RealmAccess.mode} == \text{Db.realm()} == \text{RealmInfo.ruleset}$$ + Any mismatch or malformed/unknown ruleset suffix returns an immediate error. + - `LocalBackend::world_port()` and `crates/coa-core/src/realms::world_port_for_mode()` return fallible `Result` without silent fallbacks to 8085. If a realm is not running or not simultaneous, port resolution fails closed and disables Direct and Relay routing for that realm. +- **Canonical Storage (`portable.db`):** + - Transactional SQLite Migration 009 upgraded `character` table constraint to `CHECK (ruleset IN ('coa', 'wildcard', 'reborn'))` while preserving rows byte-for-byte. + - `collection` table is partitioned by `(profile_id, ruleset, kind)`. + - Mappings (`character_server_mapping`, `item_mapping`, `pet_mapping`, sessions) isolate distinct server IDs (`srv-X:coa`, `srv-X:wildcard`, `srv-X:reborn`). + +### 2.2 Historical Baseline Inventory (Pre-Implementation State as of v0.7.0 Master) +> [!NOTE] +> The following findings reflect the legacy v0.7.0 baseline discovered before MR1/MR2 implementation and are preserved for historical reference: +- *Historical:* `Ruleset` enum in `model.rs` and `caps.rs` lacked `Reborn`. +- *Historical:* `character` table had `CHECK (ruleset IN ('coa', 'wildcard'))`, causing SQLite constraint failures on Reborn. +- *Historical:* `collection` table was keyed only by `(profile_id, kind)` without `ruleset`. +- *Historical:* `TransferOffer`, `PortableSessionStarted`, `PortableCheckpoint`, and `CollectionState` lacked explicit protocol-level `ruleset` fields. +- *Historical:* `LocalBackend::info()` had an unsafe fallback `ruleset_of(db).unwrap_or(Ruleset::Coa)`. +- *Historical:* `world_port_for_mode()` fell back to `ports.world` (8085) even when Wildcard was inactive. +- *Historical:* `Ruleset::schema(kind)` had `_ => "acore_characters"`. + +### 2.3 Realm Profiles & Databases (`crates/coa-core/src/realms.rs`, `db.rs`) | Ruleset / Mode | Realm ID | Character Database | World Database | Auth Database | Configuration Profile | |---|---|---|---|---|---| | **CoA** | `1` | `acore_characters` | `acore_world` | `acore_auth` | `Settings/realm-profiles/coa.json` | @@ -70,52 +102,11 @@ Status: Updated for MR2 Gate (Wildcard Vertical Slice Completed) - Wildcard detected by byte pattern: `b"Wildcard synergy settings"`. - Reborn detected by byte pattern: `b"Warcraft Reborn class spells are unavailable"`. -### 2.2 Ruleset Enumeration & Protocols -- `crates/coa-core/src/realms.rs`: `enum Mode { Coa, Wildcard, Reborn }` -- `crates/coa-core/src/portable/model.rs`: `enum Ruleset { Coa, Wildcard }` *(Lacks `Reborn`)* -- `crates/coa-registry-proto/src/caps.rs`: `enum Ruleset { Coa, Wildcard }` *(Lacks `Reborn`)* -- `services/coa-registry/src/pg.rs`: `ruleset: match ruleset.as_str() { "coa" => Ruleset::Coa, "wildcard" => Ruleset::Wildcard }` *(Lacks `Reborn`)* -- `src/lib/browse.ts`: `export type Ruleset = "coa" | "wildcard";` *(Lacks `reborn`)* - -### 2.3 Public Registry Model & Publication +### 2.4 Public Registry Model & Multi-Realm Publication - `crates/coa-core/src/realm_registry/settings.rs`: `RegistrySettings.realms` is a `BTreeMap`. - `src/screens/PublishCard.tsx`: Uses `localId = 'srv-' + serverId`. -- **Host vs Published Realm Identity Issue:** - One installation (`InstallMeta.id`) hosting multiple rulesets currently shares `localId = srv-`. If the host switches active realm from CoA to Wildcard, or publishes both under simultaneous mode, `srv-` cannot represent both published realms distinctly. - Each published realm must possess its own distinct local realm identity (`srv--` or `srv-:`), unique `RealmId` (UUIDv7), and independent Ed25519 signing key in `KeyStore`. - -### 2.4 Canonical Portable Storage (`crates/coa-core/src/portable/store.rs`) -- SQLite schema (`%LOCALAPPDATA%\CoAServerManager\portable\portable.db`): - - `character`: `ruleset TEXT NOT NULL CHECK (ruleset IN ('coa', 'wildcard'))` -> Must be updated to allow `'reborn'`. - - `collection`: `PRIMARY KEY (profile_id, kind)` -> Unpartitioned by ruleset! Must become `(profile_id, ruleset, kind)`. - - `character_server_mapping`: `PRIMARY KEY (character_id, server_id), UNIQUE (server_id, local_guid)`. - - `item_mapping`: `PRIMARY KEY (character_id, server_id, portable_item_id)`. - - `pet_mapping`: `PRIMARY KEY (character_id, server_id, portable_pet_id)`. - - `owner_session`: `character_id`, `server_id`, `c0_revision`, `b0_hash`. - - `host_session`: `character_id`, `server_id`, `local_guid`. - -### 2.5 Remote Transfer & Control Protocol (`crates/coa-control-proto/src/app.rs`) -- `Request::TransferOffer`: - `{ transfer_id, character_id, canonical_revision, content_hash, total_size, collections }` - -> **Missing `ruleset`**. An offer must bind `ruleset: Ruleset` so the host can reject before chunk transport or preflight if target realm mismatch occurs. -- `Request::TransferCommit`: - Currently relies on cached transfer metadata. -- `Response::TransferCommitted`: - Returns session parameters. - -### 2.6 Runtime Sessions & Checkpoints (`crates/coa-core/src/portable/session/protocol.rs`) -- `PortableSessionStarted`: `{ protocol_version, session_id, character_id, server_id, base_canonical_revision, baseline_generation, b0, content_hash, progression }` - -> **Missing `ruleset`**. -- `PortableCheckpoint`: `{ protocol_version, session_id, character_id, server_id, base_canonical_revision, sequence, final_checkpoint, realm_snapshot, content_hash, pin }` - -> **Missing `ruleset`**. -- `OwnerAck`: `{ protocol_version, session_id, sequence, outcome, canonical_revision, canonical_hash, owned_items, owned_pets, canonical, next_session, pin }` - -> Must verify ruleset match in owner session state before applying 3-way merge. - -### 2.7 Direct & Relay Routing -- **Direct Ingress Proxy (`crates/coa-core/src/control/direct_ingress.rs`):** - Listens on direct auth and world ports, intercepts `CMD_REALM_LIST` from the Auth server, and rewrites the realm entry address to the public direct world IP and port. When multiple realms are active (simultaneous CoA + Wildcard), each published realm must target its respective local world port (`ports.world` vs `s.secondary_world_port`). -- **Relay Hub & Allocations (`services/coa-relay/`, `crates/coa-core/src/control/relay_link.rs`):** - Relay allocation requests are authenticated by Ed25519 realm key. Allocations bind a short-lived token to an isolated auth/world stream pair. +- **Multi-Realm Publication on One Host:** + Each published realm possesses its own distinct local realm identity (`srv-:`), unique `RealmId` (UUIDv7), and independent Ed25519 signing key in `KeyStore`. Direct Ingress proxy and Relay links dynamically route to the respective realm's active world port. --- diff --git a/docs/WILDCARD.md b/docs/WILDCARD.md index 143e877..bc5119e 100644 --- a/docs/WILDCARD.md +++ b/docs/WILDCARD.md @@ -4,10 +4,16 @@ Overview offers CoA and Wildcard. The installed worldserver must contain Wildcar already have its Wildcard migrations. The Manager refuses an unsupported binary, an occupied realm ID 2 or an existing destination database rather than overwrite them. -The first switch copies the CoA world into `acore_world_wildcard` and creates `acore_characters_wildcard` from -the character schema and neutral server seed tables. Existing characters, card collections and progress are not -copied. Accounts remain in shared `acore_auth`. Realm IDs 1 and 2 share the world port; only the selected world -runs, and the inactive realm is listed offline. A switch while running stops the server gracefully and restarts it. +CoA and Wildcard have separate character and world databases (`acore_characters` / `acore_world` for CoA, and +`acore_characters_wildcard` / `acore_world_wildcard` for Wildcard). Accounts remain in shared `acore_auth`. + +When simultaneous realm execution is enabled in configuration (`simultaneous: true`), both CoA and Wildcard +realms can run concurrently: +- Primary and secondary world ports (`ports.world` and `secondary_world_port`, e.g. 8085 and 8086) and RA ports + are distinct. +- Remote routing (both Direct Ingress and Relay links) is strictly bound to the specific ruleset realm selected: + routing to a Wildcard realm targets the Wildcard world port and never advertises or connects to the CoA port. +- When simultaneous execution is disabled, only the currently active realm runs and provides world service. Configuration profiles are saved under `Settings/realm-profiles`. An interrupted switch can restore its previous configuration from the journal. CoA companion bots cannot be enabled on Wildcard. Content Scaling and the other @@ -21,12 +27,12 @@ adapter before the next start. ## Portable Characters on Wildcard -As of v0.7.1 (MR2), Wildcard realms fully participate in the Portable Characters subsystem with strict multi-ruleset isolation: +As of v0.7.1, Wildcard realms participate in the Portable Characters subsystem with strict multi-ruleset isolation: - **Canonical Model:** Wildcard portable characters carry `ruleset: "wildcard"` and `content_namespace: "wildcard"`. -- **Claim & Export:** Real characters played on a Wildcard realm (`acore_characters_wildcard`) can be claimed and exported into canonical portable snapshots. Wildcard character builds—including spells, talents, skills, action bars, settings, inventory, pets, and currencies—are fully captured. -- **Import & Session Runtime:** A Wildcard portable character can be transferred to a remote Wildcard host, imported into its `acore_characters_wildcard` database, armed in a runtime session (Session Protocol 3), and played live with automatic checkpoints and logout synchronization. +- **Claim & Export:** Characters played on a Wildcard realm (`acore_characters_wildcard`) can be claimed and exported into canonical portable snapshots. Wildcard character builds—including spells, talents, skills, action bars, settings, inventory, pets, and currencies—are preserved. +- **Import & Session Runtime:** A Wildcard portable character can be transferred to a remote Wildcard host, imported into its `acore_characters_wildcard` database, armed in a runtime session (Session Protocol 3), and tracked through checkpoints and logout synchronization. - **Collections:** Wildcard collections (`wildcard:appearance` and `wildcard:vanity`) are partitioned separately from CoA collections. Unlocks on a Wildcard realm union into the profile's Wildcard collections and never touch CoA collections. -- **Fail-Closed Protection:** Any attempt to transfer or import a CoA character into a Wildcard realm, or vice versa, is rejected at the protocol and database boundaries with zero mutation. +- **Fail-Closed Protection:** Any attempt to transfer or import a CoA character into a Wildcard realm, or vice versa, is rejected at the protocol and database boundaries with zero mutation. If world-port resolution fails or a realm mode is inactive, Direct and Relay routing fail closed. ![Wildcard overview](screenshots/wildcard-demo/overview.jpg)