diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9fb6a29..cabc45a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,7 +32,10 @@ jobs: with: version: "36.2" repo-token: ${{ secrets.GITHUB_TOKEN }} - - run: cargo install cargo-nextest --locked + - uses: taiki-e/install-action@86f7a0ffb25f40114606501a311ebcfaf882ea32 # v2 + with: + tool: nextest + fallback: none - run: corepack enable - run: yarn install --immutable - run: yarn playwright install --with-deps chromium @@ -56,7 +59,10 @@ jobs: with: version: "36.2" repo-token: ${{ secrets.GITHUB_TOKEN }} - - run: cargo install cargo-nextest --locked + - uses: taiki-e/install-action@86f7a0ffb25f40114606501a311ebcfaf882ea32 # v2 + with: + tool: nextest + fallback: none - run: cargo build --locked -p omloom-observe - run: cargo clippy --locked -p omloom-observe --all-targets -- -D warnings - run: cargo nextest run --locked -p omloom-observe diff --git a/Cargo.lock b/Cargo.lock index 6dedf05..ed368c3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -143,6 +143,15 @@ dependencies = [ "syn 3.0.9", ] +[[package]] +name = "atomic-polyfill" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cf2bce30dfe09ef0bfaef228b9d414faaf7e563035494d7fe092dba54b300f4" +dependencies = [ + "critical-section", +] + [[package]] name = "atomic-waker" version = "1.1.2" @@ -485,6 +494,15 @@ version = "0.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" +[[package]] +name = "cobs" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1" +dependencies = [ + "thiserror", +] + [[package]] name = "colorchoice" version = "1.0.5" @@ -510,7 +528,7 @@ checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" [[package]] name = "coordinode-cluster" version = "0.9.0" -source = "git+https://github.com/structured-world/coordinode?rev=1862962a3da20e5e57d7ab5126e69bd5817d7c61#1862962a3da20e5e57d7ab5126e69bd5817d7c61" +source = "git+https://github.com/structured-world/coordinode?rev=1d0e00f714d669c092879fd3494a8b67f361556f#1d0e00f714d669c092879fd3494a8b67f361556f" dependencies = [ "coordinode-storage", "coordinode-workspace-hack", @@ -525,7 +543,7 @@ dependencies = [ [[package]] name = "coordinode-core" version = "0.9.0" -source = "git+https://github.com/structured-world/coordinode?rev=1862962a3da20e5e57d7ab5126e69bd5817d7c61#1862962a3da20e5e57d7ab5126e69bd5817d7c61" +source = "git+https://github.com/structured-world/coordinode?rev=1d0e00f714d669c092879fd3494a8b67f361556f#1d0e00f714d669c092879fd3494a8b67f361556f" dependencies = [ "atomic-waker", "bytes", @@ -546,7 +564,7 @@ dependencies = [ [[package]] name = "coordinode-embed" version = "0.9.0" -source = "git+https://github.com/structured-world/coordinode?rev=1862962a3da20e5e57d7ab5126e69bd5817d7c61#1862962a3da20e5e57d7ab5126e69bd5817d7c61" +source = "git+https://github.com/structured-world/coordinode?rev=1d0e00f714d669c092879fd3494a8b67f361556f#1d0e00f714d669c092879fd3494a8b67f361556f" dependencies = [ "coordinode-core", "coordinode-lsm-tree", @@ -597,7 +615,7 @@ dependencies = [ [[package]] name = "coordinode-modality" version = "0.9.0" -source = "git+https://github.com/structured-world/coordinode?rev=1862962a3da20e5e57d7ab5126e69bd5817d7c61#1862962a3da20e5e57d7ab5126e69bd5817d7c61" +source = "git+https://github.com/structured-world/coordinode?rev=1d0e00f714d669c092879fd3494a8b67f361556f#1d0e00f714d669c092879fd3494a8b67f361556f" dependencies = [ "coordinode-core", "coordinode-lsm-tree", @@ -615,7 +633,7 @@ dependencies = [ [[package]] name = "coordinode-query" version = "0.9.0" -source = "git+https://github.com/structured-world/coordinode?rev=1862962a3da20e5e57d7ab5126e69bd5817d7c61#1862962a3da20e5e57d7ab5126e69bd5817d7c61" +source = "git+https://github.com/structured-world/coordinode?rev=1d0e00f714d669c092879fd3494a8b67f361556f#1d0e00f714d669c092879fd3494a8b67f361556f" dependencies = [ "chrono", "coordinode-cluster", @@ -648,7 +666,7 @@ dependencies = [ [[package]] name = "coordinode-search" version = "0.9.0" -source = "git+https://github.com/structured-world/coordinode?rev=1862962a3da20e5e57d7ab5126e69bd5817d7c61#1862962a3da20e5e57d7ab5126e69bd5817d7c61" +source = "git+https://github.com/structured-world/coordinode?rev=1d0e00f714d669c092879fd3494a8b67f361556f#1d0e00f714d669c092879fd3494a8b67f361556f" dependencies = [ "aes-gcm", "coordinode-core", @@ -668,7 +686,7 @@ dependencies = [ [[package]] name = "coordinode-storage" version = "0.9.0" -source = "git+https://github.com/structured-world/coordinode?rev=1862962a3da20e5e57d7ab5126e69bd5817d7c61#1862962a3da20e5e57d7ab5126e69bd5817d7c61" +source = "git+https://github.com/structured-world/coordinode?rev=1d0e00f714d669c092879fd3494a8b67f361556f#1d0e00f714d669c092879fd3494a8b67f361556f" dependencies = [ "byteorder-lite", "bytes", @@ -701,7 +719,7 @@ dependencies = [ [[package]] name = "coordinode-vector" version = "0.9.0" -source = "git+https://github.com/structured-world/coordinode?rev=1862962a3da20e5e57d7ab5126e69bd5817d7c61#1862962a3da20e5e57d7ab5126e69bd5817d7c61" +source = "git+https://github.com/structured-world/coordinode?rev=1d0e00f714d669c092879fd3494a8b67f361556f#1d0e00f714d669c092879fd3494a8b67f361556f" dependencies = [ "coordinode-core", "coordinode-storage", @@ -721,7 +739,7 @@ dependencies = [ [[package]] name = "coordinode-workspace-hack" version = "0.1.0" -source = "git+https://github.com/structured-world/coordinode?rev=1862962a3da20e5e57d7ab5126e69bd5817d7c61#1862962a3da20e5e57d7ab5126e69bd5817d7c61" +source = "git+https://github.com/structured-world/coordinode?rev=1d0e00f714d669c092879fd3494a8b67f361556f#1d0e00f714d669c092879fd3494a8b67f361556f" dependencies = [ "aead", "aes", @@ -896,7 +914,7 @@ dependencies = [ "rayon-core", "regex", "regex-automata", - "regex-syntax", + "regex-syntax 0.8.11", "rmp", "rmp-serde", "rmpv", @@ -1087,6 +1105,12 @@ dependencies = [ "itertools 0.13.0", ] +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + [[package]] name = "crossbeam-channel" version = "0.5.17" @@ -1184,9 +1208,9 @@ dependencies = [ [[package]] name = "ctutils" -version = "0.4.6" +version = "0.4.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe3e9d21d39e6f39ff46e03d5b621424f88ee23d8a1672c6bfc9ccc7f2da4f6a" +checksum = "a2376ffd6f96d8a9db59875b4b6b0237d6900b5baf64279714e05493ce64828a" dependencies = [ "cmov", ] @@ -1299,6 +1323,18 @@ version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be" +[[package]] +name = "embedded-io" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef1a6892d9eef45c8fa6b9e0086428a2cca8491aca8f787c534a3d6d0bcb3ced" + +[[package]] +name = "embedded-io" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d" + [[package]] name = "endian-type" version = "0.1.2" @@ -1594,6 +1630,15 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "hash32" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0c35f58762feb77d74ebe43bdbc3210f09be9fe6742234d573bacc26ed92b67" +dependencies = [ + "byteorder", +] + [[package]] name = "hashbrown" version = "0.14.5" @@ -1631,6 +1676,20 @@ dependencies = [ "foldhash 0.2.0", ] +[[package]] +name = "heapless" +version = "0.7.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdc6457c0eb62c71aac4bc17216026d8410337c4126773b9c5daba343f17964f" +dependencies = [ + "atomic-polyfill", + "hash32", + "rustc_version", + "serde", + "spin 0.9.9", + "stable_deref_trait", +] + [[package]] name = "heck" version = "0.5.0" @@ -2044,6 +2103,12 @@ version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" +[[package]] +name = "linked-hash-map" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f" + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -2093,6 +2158,15 @@ dependencies = [ "hashbrown 0.16.1", ] +[[package]] +name = "lru-cache" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31e24f1ad8321ca0e8a1e0ac13f23cb668e6f5466c2c57319f6a5cf1cc8e3b1c" +dependencies = [ + "linked-hash-map", +] + [[package]] name = "lz4_flex" version = "0.13.1" @@ -2340,6 +2414,7 @@ dependencies = [ "sha2 0.10.9", "tempfile", "tokio", + "uuid", ] [[package]] @@ -2351,11 +2426,18 @@ dependencies = [ "coordinode-storage", "criterion", "csv", + "idna", "ipnet", "omloom-contracts", + "phonenumber", "prost", + "serde", + "serde_json", "sha2 0.10.9", + "structured-email-address", + "structured-public-domains", "tempfile", + "url", ] [[package]] @@ -2370,6 +2452,12 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "oncemutex" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d11de466f4a3006fe8a5e7ec84e93b79c70cb992ae0aa0eb631ad2df8abfe2" + [[package]] name = "oneshot" version = "0.1.13" @@ -2498,6 +2586,26 @@ dependencies = [ "indexmap", ] +[[package]] +name = "phonenumber" +version = "0.3.10+9.0.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66d85d3cc5477bfe2f357939e7f02a5a566632508633af1a2c37577d0bef87d9" +dependencies = [ + "either", + "fnv", + "nom", + "once_cell", + "postcard", + "quick-xml", + "regex", + "regex-cache", + "serde", + "serde_derive", + "strum", + "thiserror", +] + [[package]] name = "pin-project" version = "1.1.13" @@ -2569,6 +2677,19 @@ version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" +[[package]] +name = "postcard" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6764c3b5dd454e283a30e6dfe78e9b31096d9e32036b5d1eaac7a6119ccb9a24" +dependencies = [ + "cobs", + "embedded-io 0.4.0", + "embedded-io 0.6.1", + "heapless", + "serde", +] + [[package]] name = "potential_utf" version = "0.1.6" @@ -2645,7 +2766,7 @@ dependencies = [ "num-traits", "rand 0.10.3", "rand_xorshift", - "regex-syntax", + "regex-syntax 0.8.11", "rusty-fork", "tempfile", "unarray", @@ -2745,6 +2866,15 @@ version = "1.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" +[[package]] +name = "quick-xml" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1" +dependencies = [ + "memchr", +] + [[package]] name = "quote" version = "1.0.47" @@ -2896,7 +3026,7 @@ dependencies = [ "aho-corasick", "memchr", "regex-automata", - "regex-syntax", + "regex-syntax 0.8.11", ] [[package]] @@ -2907,9 +3037,27 @@ checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" dependencies = [ "aho-corasick", "memchr", - "regex-syntax", + "regex-syntax 0.8.11", +] + +[[package]] +name = "regex-cache" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f7b62d69743b8b94f353b6b7c3deb4c5582828328bcb8d5fedf214373808793" +dependencies = [ + "lru-cache", + "oncemutex", + "regex", + "regex-syntax 0.6.29", ] +[[package]] +name = "regex-syntax" +version = "0.6.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f162c6dd7b008981e4d40210aca20b4bd0f9b60ca9271061b07f78537722f2e1" + [[package]] name = "regex-syntax" version = "0.8.11" @@ -3448,6 +3596,23 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "structured-email-address" +version = "0.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cd9d82dea2875345f37db1dccda765879e29600938ba69fba976ab422fd8c87" +dependencies = [ + "idna", + "unicode-normalization", + "unicode-security", +] + +[[package]] +name = "structured-public-domains" +version = "0.0.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c99dffabd80db54de9cbf761bd1a4d39e980f8d5f1d10003511c62436ce2662" + [[package]] name = "structured-stemmers" version = "1.2.0" @@ -3467,6 +3632,27 @@ dependencies = [ "twox-hash", ] +[[package]] +name = "strum" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "subtle" version = "2.6.1" @@ -3613,7 +3799,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d60769b80ad7953d8a7b2c70cdfe722bbcdcac6bccc8ac934c40c034d866fc18" dependencies = [ "byteorder", - "regex-syntax", + "regex-syntax 0.8.11", "utf8-ranges", ] @@ -4196,6 +4382,22 @@ dependencies = [ "tinyvec", ] +[[package]] +name = "unicode-script" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "383ad40bb927465ec0ce7720e033cb4ca06912855fc35db31b5755d0de75b1ee" + +[[package]] +name = "unicode-security" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e4ddba1535dd35ed8b61c52166b7155d7f4e4b8847cec6f48e71dc66d8b5e50" +dependencies = [ + "unicode-normalization", + "unicode-script", +] + [[package]] name = "universal-hash" version = "0.6.1" diff --git a/crates/omloom-contracts/build.rs b/crates/omloom-contracts/build.rs index c118b6f..fd5893d 100644 --- a/crates/omloom-contracts/build.rs +++ b/crates/omloom-contracts/build.rs @@ -21,6 +21,7 @@ fn main() -> Result<(), Box> { .boxed(".omloom.workspace.v1.Address.allocation") .boxed(".omloom.workspace.v1.Address.interface") .boxed(".omloom.workspace.v1.Reply.result.detail") + .boxed(".omloom.workspace.v1.Reply.result.vm_placement_review") .boxed(".omloom.workspace.v1.HostImport.report") .file_descriptor_set_path(&descriptor) .compile_protos(&[workspace], &["../../proto"])?; diff --git a/crates/omloom-observe/Cargo.toml b/crates/omloom-observe/Cargo.toml index 2dbd1c6..42bc5f8 100644 --- a/crates/omloom-observe/Cargo.toml +++ b/crates/omloom-observe/Cargo.toml @@ -10,6 +10,7 @@ publish = false [dependencies] omloom-contracts = { path = "../omloom-contracts" } prost.workspace = true +uuid.workspace = true serde_json = "1" sha2 = "0.10" tokio = { version = "1", features = ["macros", "rt", "process", "io-util", "time"] } diff --git a/crates/omloom-observe/src/command/tests.rs b/crates/omloom-observe/src/command/tests.rs index fc1c6f0..3d300f5 100644 --- a/crates/omloom-observe/src/command/tests.rs +++ b/crates/omloom-observe/src/command/tests.rs @@ -80,12 +80,12 @@ async fn oversized_output_refunds_only_unused_pipe_capacity() { #[cfg(windows)] let result = runner .run( - "powershell.exe", + "cmd.exe", &[ - "-NoProfile", - "-NonInteractive", - "-Command", - "[Console]::Out.Write('12345678901234567890123456789012')", + "/D", + "/Q", + "/C", + " Result<()> { async fn collect() -> Result { let mut runner = command::Runner::new(); - let (platform, source, boot, name) = native::identity(&mut runner).await?; + let identity = native::identity(&mut runner).await?; + let native::Identity { + platform, + source, + boot, + name, + hardware, + installation: installation_claim, + } = identity; let observed_at = SystemTime::now() .duration_since(UNIX_EPOCH) .map_err(|_| "Clock predates Unix epoch")? @@ -91,10 +99,9 @@ async fn collect() -> Result { observed_at, ..Default::default() }; - add( - &mut report, - finding("hardware", wire::AssetKind::Unspecified, None, &name), - )?; + let mut host = finding("hardware", wire::AssetKind::Unspecified, None, &name); + host.identity.extend(hardware); + add(&mut report, host)?; let mut installation = finding( "installation", wire::AssetKind::SoftwareInstallation, @@ -105,6 +112,7 @@ async fn collect() -> Result { "installation_kind", wire::InstallationKind::OperatingSystem.as_str_name(), )); + installation.identity.push(installation_claim); add(&mut report, installation)?; coverage( &mut report, @@ -120,8 +128,11 @@ async fn collect() -> Result { }); native::collect(&mut runner, &mut report).await?; runtime::collect(&mut runner, &mut report).await?; - let (_, after_source, after_boot, _) = native::identity(&mut runner).await?; - check_identity(&source, &boot, &after_source, &after_boot)?; + let after = native::identity(&mut runner).await?; + check_identity(&source, &boot, &after.source, &after.boot)?; + if report.findings.first().and_then(|v| v.identity.first()) != after.hardware.as_ref() { + return Err("Hardware evidence changed during collection; output not written".into()); + } Ok(report) } diff --git a/crates/omloom-observe/src/native.rs b/crates/omloom-observe/src/native.rs index 5edbe92..53f71d7 100644 --- a/crates/omloom-observe/src/native.rs +++ b/crates/omloom-observe/src/native.rs @@ -1,6 +1,8 @@ use super::command::Runner; use super::{MAX_FACTS, Result, add, api, coverage, finding, local, wire}; use serde_json::Value; +#[cfg(test)] +mod tests; pub(super) fn json(input: &str) -> Result { // Preflight limits both structural fan-out and stack use before serde builds @@ -113,9 +115,46 @@ async fn ps(runner: &mut Runner, script: &str) -> Result { .await .map(|v| v.text) } -pub(super) async fn identity( - runner: &mut Runner, -) -> Result<(&'static str, String, String, String)> { +pub(super) struct Identity { + pub platform: &'static str, + pub source: String, + pub boot: String, + pub name: String, + pub hardware: Option, + pub installation: api::HostIdentityClaim, +} + +fn identity_claim( + kind: api::HostIdentityKind, + namespace: &str, + raw: &str, +) -> Result { + let raw = raw.trim(); + if raw.is_empty() || raw.len() > 128 || raw.chars().any(char::is_control) { + return Err("Invalid native identity claim".into()); + } + Ok(api::HostIdentityClaim { + kind: kind as i32, + namespace: namespace.into(), + digest: super::digest(&raw.to_ascii_lowercase()), + quality: api::HostIdentityQuality::Reported as i32, + }) +} + +fn hardware_claim(namespace: &str, raw: &str) -> Option { + let uuid = uuid::Uuid::parse_str(raw.trim()).ok()?; + if uuid.is_nil() || uuid == uuid::Uuid::max() { + return None; + } + identity_claim( + api::HostIdentityKind::HardwareUuid, + namespace, + &uuid.to_string(), + ) + .ok() +} + +pub(super) async fn identity(runner: &mut Runner) -> Result { match std::env::consts::OS { "macos" => { let device = runner @@ -141,12 +180,18 @@ pub(super) async fn identity( .run("/usr/sbin/sysctl", &["-n", "hw.model"]) .await? .text; - Ok(( - "macos", - format!("{hardware}/{installation}"), - boot.trim().into(), - model.trim().into(), - )) + Ok(Identity { + platform: "macos", + source: format!("{hardware}/{installation}"), + boot: boot.trim().into(), + name: model.trim().into(), + hardware: hardware_claim("apple/platform-uuid", hardware), + installation: identity_claim( + api::HostIdentityKind::InstallationId, + "macos/apfs-volume", + installation, + )?, + }) } "linux" => { let source = runner.run("cat", &["/etc/machine-id"]).await?.text; @@ -155,22 +200,43 @@ pub(super) async fn identity( .await? .text; let name = runner.run("uname", &["-n"]).await?.text; - Ok(( - "linux", - source.trim().into(), - boot.trim().into(), - name.trim().into(), - )) + // DMI may be unreadable without root. Missing hardware evidence + // does not prevent collecting the installation's current facts. + let hardware = match runner.run("cat", &["/sys/class/dmi/id/product_uuid"]).await { + Ok(value) => hardware_claim("smbios/system-uuid", &value.text), + Err(_) => None, + }; + Ok(Identity { + platform: "linux", + source: source.trim().into(), + boot: boot.trim().into(), + name: name.trim().into(), + hardware, + installation: identity_claim( + api::HostIdentityKind::InstallationId, + "linux/machine-id", + &source, + )?, + }) } "windows" => { - let result = ps(runner, "$ErrorActionPreference='Stop'; [Console]::OutputEncoding=[Text.UTF8Encoding]::new($false); $os=Get-CimInstance Win32_OperatingSystem; $cs=Get-CimInstance Win32_ComputerSystemProduct; @{source=(Get-ItemProperty 'HKLM:\\SOFTWARE\\Microsoft\\Cryptography').MachineGuid; boot=$os.LastBootUpTime.ToUniversalTime().ToString('o'); name=$cs.Name} | ConvertTo-Json -Compress").await?; + let result = ps(runner, "$ErrorActionPreference='Stop'; [Console]::OutputEncoding=[Text.UTF8Encoding]::new($false); $os=Get-CimInstance Win32_OperatingSystem -Property LastBootUpTime; $cs=Get-CimInstance Win32_ComputerSystemProduct -Property Name,UUID; @{source=(Get-ItemProperty 'HKLM:\\SOFTWARE\\Microsoft\\Cryptography').MachineGuid; boot=$os.LastBootUpTime.ToUniversalTime().ToString('o'); name=$cs.Name; hardware=$cs.UUID} | ConvertTo-Json -Compress").await?; let value = json(&result)?; - Ok(( - "windows", - string(&value, "source")?.into(), - string(&value, "boot")?.into(), - string(&value, "name")?.into(), - )) + Ok(Identity { + platform: "windows", + source: string(&value, "source")?.into(), + boot: string(&value, "boot")?.into(), + name: string(&value, "name")?.into(), + hardware: value + .get("hardware") + .and_then(Value::as_str) + .and_then(|v| hardware_claim("smbios/system-uuid", v)), + installation: identity_claim( + api::HostIdentityKind::InstallationId, + "windows/machine-guid", + string(&value, "source")?, + )?, + }) } _ => Err("Native platform is not supported".into()), } @@ -598,7 +664,7 @@ async fn sockets(runner: &mut Runner, report: &mut api::HostReport) -> Result<() network.sockets_complete = complete; } "windows" => { - let output=ps(runner,"$ErrorActionPreference='Stop'; [Console]::OutputEncoding=[Text.UTF8Encoding]::new($false); $rows=@(); foreach($s in Get-NetTCPConnection -State Listen){$rows+=@{protocol='tcp';bind=$s.LocalAddress;port=$s.LocalPort;pid=$s.OwningProcess}}; foreach($s in Get-NetUDPEndpoint){$rows+=@{protocol='udp';bind=$s.LocalAddress;port=$s.LocalPort;pid=$s.OwningProcess}}; ConvertTo-Json -InputObject @($rows) -Depth 4 -Compress").await?; + let output=ps(runner,"$ErrorActionPreference='Stop'; [Console]::OutputEncoding=[Text.UTF8Encoding]::new($false); $rows=@(foreach($s in Get-NetTCPConnection -State Listen){@{protocol='tcp';bind=$s.LocalAddress;port=$s.LocalPort;pid=$s.OwningProcess}}; foreach($s in Get-NetUDPEndpoint){@{protocol='udp';bind=$s.LocalAddress;port=$s.LocalPort;pid=$s.OwningProcess}}); ConvertTo-Json -InputObject @($rows) -Depth 4 -Compress").await?; let values = json(&output)?; for row in array(&values)? { let ip = string(row, "bind")?; diff --git a/crates/omloom-observe/src/native/tests.rs b/crates/omloom-observe/src/native/tests.rs new file mode 100644 index 0000000..39107bb --- /dev/null +++ b/crates/omloom-observe/src/native/tests.rs @@ -0,0 +1,81 @@ +use super::*; + +// Exercise the native OS source on each qualified host, without assuming that +// an unreadable or unset hardware UUID exists or inventing one from an address. +#[tokio::test] +async fn native_identity_observation_keeps_layers_separate() -> Result<()> { + let value = identity(&mut Runner::new()).await?; + let namespace = match std::env::consts::OS { + "macos" => "macos/apfs-volume", + "linux" => "linux/machine-id", + "windows" => "windows/machine-guid", + _ => return Err("Unsupported native identity platform".into()), + }; + assert!(!value.source.is_empty()); + assert!(!value.boot.is_empty()); + assert_eq!(value.installation.namespace, namespace); + assert_eq!( + value.installation.kind, + api::HostIdentityKind::InstallationId as i32 + ); + assert_eq!(value.installation.digest.len(), 64); + if let Some(hardware) = value.hardware { + assert_eq!(hardware.kind, api::HostIdentityKind::HardwareUuid as i32); + assert_eq!(hardware.quality, api::HostIdentityQuality::Reported as i32); + assert_eq!(hardware.digest.len(), 64); + assert_ne!(hardware.namespace, value.installation.namespace); + } + Ok(()) +} + +// Normalized claims compare across boots, while distinct hardware UUIDs stay distinct. +// A MAC, IP or hostname is never substituted for missing hardware evidence. +#[test] +fn hardware_claims_are_scoped_normalized_and_explicitly_untrusted() { + let first = hardware_claim("smbios/system-uuid", "8D60C19A-489A-40C9-B381-D97E5D568DF8") + .expect("source UUID"); + let second = hardware_claim("smbios/system-uuid", "8d60c19a-489a-40c9-b381-d97e5d568df8") + .expect("source UUID"); + assert_eq!(first, second); + let other = hardware_claim("smbios/system-uuid", "11111111-2222-4333-8444-555555555555") + .expect("distinct source UUID"); + assert_ne!(first.digest, other.digest); + assert_eq!(first.quality, api::HostIdentityQuality::Reported as i32); + assert_eq!(first.digest.len(), 64); + assert!(!first.digest.contains("8d60c19a")); + assert_ne!(first.namespace, "apple/platform-uuid"); + for unknown in [ + "", + "192.0.2.1", + "To be filled by OEM", + "00000000-0000-0000-0000-000000000000", + "ffffffff-ffff-ffff-ffff-ffffffffffff", + ] { + assert!(hardware_claim("smbios/system-uuid", unknown).is_none()); + } +} + +// An installation ID remains a distinct namespace and layer even when a source +// happens to report the same bytes as its hardware UUID. +#[test] +fn installation_claims_do_not_collapse_into_hardware_identity() { + let raw = "8d60c19a-489a-40c9-b381-d97e5d568df8"; + let hardware = hardware_claim("smbios/system-uuid", raw).expect("UUID"); + let installation = identity_claim( + api::HostIdentityKind::InstallationId, + "windows/machine-guid", + raw, + ) + .expect("installation"); + assert_eq!(hardware.digest, installation.digest); + assert_ne!(hardware.namespace, installation.namespace); + assert_ne!(hardware.kind, installation.kind); + assert!( + identity_claim( + api::HostIdentityKind::InstallationId, + "linux/machine-id", + "\n" + ) + .is_err() + ); +} diff --git a/crates/omloom-observe/src/runtime.rs b/crates/omloom-observe/src/runtime.rs index c5b639f..018782a 100644 --- a/crates/omloom-observe/src/runtime.rs +++ b/crates/omloom-observe/src/runtime.rs @@ -162,7 +162,16 @@ async fn docker_at( report: &mut api::HostReport, ) -> Result<()> { local_docker_endpoint(&report.platform, endpoint)?; - let info=runner.read(&["--host",endpoint,"info","--format","{\"id\":{{json .ID}},\"os\":{{json .OperatingSystem}},\"type\":{{json .OSType}},\"version\":{{json .ServerVersion}}}"]).await.map_err(|e|docker_step("daemon inspection",e))?; + let info = runner + .read(&[ + "--host", + endpoint, + "info", + "--format", + "{\"id\":{{json .ID}},\"os\":{{json .OperatingSystem}},\"type\":{{json .OSType}}}", + ]) + .await + .map_err(|e| docker_step("daemon inspection", e))?; let info = json(&info)?; if info.get("id").and_then(Value::as_str) == Some("") { return Err("Docker daemon identity unavailable".into()); @@ -188,16 +197,48 @@ async fn docker_at( .read(&["--host", endpoint, "ps", "-q", "--no-trunc"]) .await .map_err(|e| docker_step("container listing", e))?; - if ids.lines().count() > 32 { + let count = ids.lines().count(); + if count > 32 { return Err("Selected Docker instance count limit exceeded".into()); } for id in ids.lines() { if id.len() != 64 || !id.bytes().all(|v| v.is_ascii_hexdigit()) { return Err("Invalid Docker container identity".into()); } - let result=runner.read(&["--host",endpoint,"inspect",id,"--format","{\"id\":{{json .Id}},\"name\":{{json .Name}},\"created\":{{json .Created}},\"image\":{{json .Image}},\"project\":{{json (index .Config.Labels \"com.docker.compose.project\")}},\"service\":{{json (index .Config.Labels \"com.docker.compose.service\")}},\"working_directory\":{{json (index .Config.Labels \"com.docker.compose.project.working_dir\")}},\"networks\":{{json .NetworkSettings.Networks}}}"]).await.map_err(|e|docker_step("container inspection",e))?; - let value = json(&result)?; - if string(&value, "id")? != id { + } + let mut selected: std::collections::BTreeSet<_> = ids.lines().collect(); + if selected.len() != count { + return Err("Repeated Docker container identity".into()); + } + // Docker applies the template to each requested object. Select only fields + // with report consumers, and validate the complete returned identity set. + let inspected = if selected.is_empty() { + String::new() + } else { + let template = r#"{"id":{{json .Id}},"name":{{json .Name}},"created":{{json .Created}},"image":{{json .Image}},"project":{{json (index .Config.Labels "com.docker.compose.project")}},"service":{{json (index .Config.Labels "com.docker.compose.service")}},"working_directory":{{json (index .Config.Labels "com.docker.compose.project.working_dir")}},"networks":{ {{$first := true}}{{range $name,$net := .NetworkSettings.Networks}}{{if not $first}},{{end}}{{$first = false}}{{json $name}}:{"EndpointID":{{json $net.EndpointID}},"IPAddress":{{json $net.IPAddress}},"IPPrefixLen":{{json $net.IPPrefixLen}},"GlobalIPv6Address":{{json $net.GlobalIPv6Address}},"GlobalIPv6PrefixLen":{{json $net.GlobalIPv6PrefixLen}}}{{end}} }}"#; + let mut args = Vec::with_capacity(7 + count); + args.extend_from_slice(&[ + "--host", + endpoint, + "inspect", + "--type", + "container", + "--format", + template, + ]); + args.extend(selected.iter().copied()); + runner + .read(&args) + .await + .map_err(|e| docker_step("container inspection", e))? + }; + if inspected.lines().count() != count { + return Err("Container set changed during inspection".into()); + } + for row in inspected.lines() { + let value = json(row)?; + let id = string(&value, "id")?; + if !selected.remove(id) { return Err("Container changed during inspection".into()); } let name = string(&value, "name")? diff --git a/crates/omloom-observe/src/runtime/tests.rs b/crates/omloom-observe/src/runtime/tests.rs index 7b7cdbe..60d1a40 100644 --- a/crates/omloom-observe/src/runtime/tests.rs +++ b/crates/omloom-observe/src/runtime/tests.rs @@ -15,9 +15,13 @@ fn context_selection_does_not_read_an_overridden_host() { assert!(matches!(selected, DockerSelection::Context(name) if name == "selected")); } -struct Commands(std::collections::VecDeque<(String, String)>); +struct Commands( + std::collections::VecDeque<(String, String)>, + Vec>, +); impl DockerCommands for Commands { async fn read(&mut self, args: &[&str]) -> Result { + self.1.push(args.iter().map(|s| (*s).to_owned()).collect()); let (expected, result) = self.0.pop_front().expect("expected Docker read"); assert_eq!(args[0], "--host"); assert_eq!(args[1], "npipe:////./pipe/docker_engine"); @@ -40,6 +44,7 @@ async fn changed_daemon_identity_invalidates_the_batch() { ("info".into(), r#""second""#.into()), ] .into(), + Vec::new(), ); let mut report = api::HostReport { platform: "windows".into(), @@ -63,6 +68,7 @@ async fn absent_daemon_identity_is_reported_as_unavailable() { r#"{"id":"","os":"","type":"","version":""}"#.into(), )] .into(), + Vec::new(), ); let mut report = api::HostReport { platform: "windows".into(), @@ -94,6 +100,7 @@ async fn local_named_pipe_preserves_container_and_interface_context() { ("info".into(), r#""first""#.into()), ] .into(), + Vec::new(), ); let mut report = api::HostReport { platform: "windows".into(), @@ -120,6 +127,69 @@ async fn local_named_pipe_preserves_container_and_interface_context() { assert!(report.binds.is_empty()); } +// One bounded inspect covers all selected containers, including out-of-order +// results. Missing, repeated or unexpected identities cannot form a complete batch. +#[tokio::test] +async fn docker_inspection_batches_selected_identities_without_unused_payloads() { + let ids = ["a".repeat(64), "b".repeat(64)]; + let row = |id: &str| { + serde_json::json!({"id":id,"name":"/web","created":"2026-10-30T00:00:00Z","image":"sha256:test","project":null,"service":null,"working_directory":null,"networks":{}}).to_string() + }; + for (output, accepted) in [ + (format!("{}\n{}", row(&ids[1]), row(&ids[0])), true), + (row(&ids[0]), false), + (format!("{}\n{}", row(&ids[0]), row(&ids[0])), false), + (format!("{}\n{}", row(&ids[0]), row(&"c".repeat(64))), false), + ] { + let mut commands = Commands( + [ + ( + "info".into(), + r#"{"id":"first","os":"Windows","type":"windows"}"#.into(), + ), + ("ps".into(), ids.join("\n")), + ("inspect".into(), output), + ("info".into(), r#""first""#.into()), + ] + .into(), + Vec::new(), + ); + let mut report = api::HostReport { + platform: "windows".into(), + ..Default::default() + }; + assert_eq!( + docker_at(&mut commands, "npipe:////./pipe/docker_engine", &mut report) + .await + .is_ok(), + accepted + ); + let inspections: Vec<_> = commands + .1 + .iter() + .filter(|args| args[2] == "inspect") + .collect(); + assert_eq!(inspections.len(), 1); + assert!(inspections[0].contains(&ids[0]) && inspections[0].contains(&ids[1])); + assert!( + !commands.1[0] + .last() + .expect("info format") + .contains("ServerVersion") + ); + let template = inspections[0] + .iter() + .position(|v| v == "--format") + .expect("format") + + 1; + assert!(!inspections[0][template].contains("json .NetworkSettings.Networks")); + if accepted { + assert_eq!(report.findings.len(), 5); + assert!(commands.0.is_empty()); + } + } +} + // Named pipes can address another Windows machine. Only the local pipe syntax // qualifies for host collection; a transport scheme alone cannot prove scope. #[test] diff --git a/crates/omloom-store/Cargo.toml b/crates/omloom-store/Cargo.toml index 6e42720..e12db72 100644 --- a/crates/omloom-store/Cargo.toml +++ b/crates/omloom-store/Cargo.toml @@ -9,20 +9,27 @@ repository.workspace = true [features] default = ["std"] -std = ["dep:coordinode-embed", "dep:coordinode-core", "dep:omloom-contracts", "dep:sha2", "dep:prost", "dep:csv", "dep:ipnet"] +std = ["dep:coordinode-embed", "dep:coordinode-core", "dep:omloom-contracts", "dep:sha2", "dep:prost", "dep:csv", "dep:ipnet", "dep:serde", "dep:serde_json", "dep:structured-email-address", "dep:structured-public-domains", "dep:phonenumber", "dep:idna", "dep:url"] diagnostics = ["std"] [dependencies] -coordinode-embed = { git = "https://github.com/structured-world/coordinode", rev = "1862962a3da20e5e57d7ab5126e69bd5817d7c61", optional = true } -coordinode-core = { git = "https://github.com/structured-world/coordinode", rev = "1862962a3da20e5e57d7ab5126e69bd5817d7c61", optional = true } +coordinode-embed = { git = "https://github.com/structured-world/coordinode", rev = "1d0e00f714d669c092879fd3494a8b67f361556f", optional = true } +coordinode-core = { git = "https://github.com/structured-world/coordinode", rev = "1d0e00f714d669c092879fd3494a8b67f361556f", optional = true } omloom-contracts = { path = "../omloom-contracts", optional = true } sha2 = { version = "0.10", optional = true } prost = { workspace = true, optional = true } csv = { version = "1.4", optional = true } ipnet = { version = "2.12", optional = true } +serde = { version = "1", features = ["derive"], optional = true } +serde_json = { version = "1", optional = true } +structured-email-address = { version = "0.0.22", default-features = false, features = ["alloc"], optional = true } +structured-public-domains = { version = "0.0.44", default-features = false, features = ["alloc"], optional = true } +phonenumber = { version = "0.3.10", optional = true } +idna = { version = "1.1", optional = true } +url = { version = "2.5", optional = true } [dev-dependencies] -coordinode-storage = { git = "https://github.com/structured-world/coordinode", rev = "1862962a3da20e5e57d7ab5126e69bd5817d7c61" } +coordinode-storage = { git = "https://github.com/structured-world/coordinode", rev = "1d0e00f714d669c092879fd3494a8b67f361556f" } tempfile = "3" prost.workspace = true criterion = "0.8" diff --git a/crates/omloom-store/src/disk.rs b/crates/omloom-store/src/disk.rs index 79dcb99..ae2a737 100644 --- a/crates/omloom-store/src/disk.rs +++ b/crates/omloom-store/src/disk.rs @@ -22,8 +22,9 @@ use std::{ type Properties = BTreeMap; type Result = std::result::Result; const ROOT: &str = "installation"; -const SCHEMA_VERSION: u64 = 10; +const SCHEMA_VERSION: u64 = 13; const MAX_CHILDREN: usize = 2048; +mod deltas; mod inventory; mod lifecycle; mod operations; @@ -65,7 +66,8 @@ impl From for Error { match e { DatabaseError::RevisionMismatch { .. } | DatabaseError::UniqueViolation { .. } - | DatabaseError::TransactionConflict { .. } => Self::Conflict, + | DatabaseError::TransactionConflict { .. } + | DatabaseError::InvariantRefused { .. } => Self::Conflict, DatabaseError::Budget(coordinode_core::budget::BudgetStop::Memory { .. }) => { Self::Limit } @@ -80,6 +82,8 @@ impl From for Error { pub struct Store { db: Database, policy: Policy, + #[cfg(feature = "diagnostics")] + query_check: Option, } #[derive(Debug, Clone, PartialEq)] @@ -434,6 +438,9 @@ impl Store { } for name in [ "schema", + "contact_kind", + "sender_kind", + "recovery_route_kind", "revision", "class", "size", @@ -519,7 +526,7 @@ impl Store { let previous_version = root.rows.first().and_then(|row| row.get("version")); if !matches!( root.rows.first().and_then(|row| row.get("version")), - Some(Value::Int(2..=8)) + Some(Value::Int(version)) if (2..SCHEMA_VERSION as i64).contains(version) ) || current.mode != schema.mode || current.placement != schema.placement || current.temporal != schema.temporal @@ -536,6 +543,9 @@ impl Store { "issuer", "subject", "contact", + "contact_kind", + "sender_kind", + "recovery_route_kind", "source_relation", "facet", "mode", @@ -662,6 +672,7 @@ impl Store { ("omloom_group", "group"), ("omloom_party_lookup", "party_lookup"), ("omloom_request_account", "request_account"), + ("omloom_claim", "claim"), ] { match db.execute_cypher(&format!("CREATE INDEX {name} ON :OmRow({property})")) { Ok(_) => {} @@ -672,7 +683,12 @@ impl Store { Err(error) => return Err(error.into()), } } - let store = Self { db, policy }; + let store = Self { + db, + policy, + #[cfg(feature = "diagnostics")] + query_check: None, + }; if store.read(ROOT)?.is_none() { if previous.is_some() { return Err(Error::Missing); @@ -717,6 +733,9 @@ impl Store { if version < 8 { workspace::upgrade_asset_names(&store)?; } + if version < 12 { + workspace::host_identity::upgrade(&store)?; + } if version < SCHEMA_VERSION { // Publish the format only after all resumable projections are ready. let mut tx = store.begin()?; @@ -741,6 +760,7 @@ impl Store { "MATCH (n:OmRow {group:'probe'}) RETURN n.key AS key LIMIT 1", "MATCH (n:OmRow {party_lookup:'probe'}) RETURN n.key AS key LIMIT 1", "MATCH (n:OmRow {request_account:'probe'}) RETURN n.key AS key LIMIT 1", + "MATCH (n:OmRow {claim:'probe'}) RETURN n.key AS key LIMIT 1", ] { store .db @@ -823,6 +843,7 @@ impl Store { id: Some(tx), root, changed: false, + inverse: None, }) })(); if result.is_err() { @@ -843,6 +864,11 @@ impl Store { pub fn diagnostic_database(&self) -> &Database { &self.db } + /// Inspect actual executed statements without retaining inputs or results. + #[cfg(feature = "diagnostics")] + pub fn set_diagnostic_query_check(&mut self, check: fn(&Database, &str)) { + self.query_check = Some(check); + } } fn usage(root: &Properties) -> Result { @@ -874,6 +900,7 @@ struct Tx<'a> { id: Option, root: Properties, changed: bool, + inverse: Option, } impl Tx<'_> { fn query(&self, q: &str, p: Option>) -> Result> { @@ -895,6 +922,10 @@ impl Tx<'_> { .db .execute_in_transaction(self.id.ok_or(Error::Corrupt)?, q, p)?; #[cfg(feature = "diagnostics")] + if let Some(check) = self.store.query_check { + check(&self.store.db, q); + } + #[cfg(feature = "diagnostics")] if let Some(started) = started { eprintln!( "query timing {:?}, rows={}: {q}", @@ -1015,6 +1046,9 @@ impl Tx<'_> { if old == Some(&data) { return Ok(false); } + if let Some(capture) = &mut self.inverse { + capture.record(key, old, class)?; + } self.counters(old, Some(&data))?; let query = if old.is_some() { "MATCH (n:OmRow {key:$key}) SET n = $p" @@ -1037,6 +1071,9 @@ impl Tx<'_> { } fn delete_projection(&mut self, key: &str) -> Result<()> { if let Some(old) = self.get(key)? { + if let Some(capture) = &mut self.inverse { + capture.record(key, Some(&old), Class::Inventory)?; + } self.counters(Some(&old), None)?; self.store.db.execute_in_transaction( self.id.ok_or(Error::Corrupt)?, diff --git a/crates/omloom-store/src/disk/deltas.rs b/crates/omloom-store/src/disk/deltas.rs new file mode 100644 index 0000000..2aa0664 --- /dev/null +++ b/crates/omloom-store/src/disk/deltas.rs @@ -0,0 +1,68 @@ +//! Bounded inverse custody for an explicit inventory correction. Only changed +//! rows are captured, never a subtree, grants or external operation state. +use super::*; +use serde::{Deserialize, Serialize}; + +pub(super) const ROWS: usize = 128; +pub(super) const BYTES: usize = 256 * 1024; + +#[derive(Default, Serialize, Deserialize)] +pub(super) struct Capture { + pub rows: BTreeMap>, + #[serde(skip)] + bytes: usize, +} +impl Capture { + pub fn record(&mut self, key: &str, old: Option<&Properties>, class: Class) -> Result<()> { + let class = old + .map(|p| number(p, "class")) + .transpose()? + .unwrap_or(class as u64); + if ![Class::Inventory as u64, Class::Checkpoint as u64].contains(&class) + || self.rows.contains_key(key) + { + return Ok(()); + } + let size = old.map(|p| number(p, "size")).transpose()?.unwrap_or(0); + let charge = usize::try_from(size) + .map_err(|_| Error::Limit)? + .checked_mul(6) + .and_then(|v| v.checked_add(key.len() + 256)) + .ok_or(Error::Limit)?; + let next = self.bytes.checked_add(charge).ok_or(Error::Limit)?; + if self.rows.len() == ROWS || next > BYTES { + return Err(Error::Limit); + } + self.bytes = next; + self.rows.insert(key.into(), old.cloned()); + Ok(()) + } + pub fn encode(&self) -> Result> { + struct Output(Vec); + impl std::io::Write for Output { + fn write(&mut self, bytes: &[u8]) -> std::io::Result { + if bytes.len() > BYTES - self.0.len() { + return Err(std::io::ErrorKind::OutOfMemory.into()); + } + self.0.extend_from_slice(bytes); + Ok(bytes.len()) + } + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } + } + let mut output = Output(Vec::with_capacity(BYTES)); + serde_json::to_writer(&mut output, self).map_err(|_| Error::Limit)?; + Ok(output.0) + } + pub fn decode(bytes: &[u8]) -> Result { + if bytes.len() > BYTES { + return Err(Error::Corrupt); + } + let value: Self = serde_json::from_slice(bytes).map_err(|_| Error::Corrupt)?; + if value.rows.len() > ROWS || value.bytes > BYTES { + return Err(Error::Corrupt); + } + Ok(value) + } +} diff --git a/crates/omloom-store/src/disk/inventory.rs b/crates/omloom-store/src/disk/inventory.rs index 6ef8de1..d60c14d 100644 --- a/crates/omloom-store/src/disk/inventory.rs +++ b/crates/omloom-store/src/disk/inventory.rs @@ -629,6 +629,25 @@ impl Tx<'_> { expected: Option, actor: &str, now: u64, + ) -> Result { + self.save_relation_inner(relation, expected, actor, now, false) + } + pub(super) fn save_rebound_relation( + &mut self, + relation: &wire::Relation, + expected: u64, + actor: &str, + now: u64, + ) -> Result { + self.save_relation_inner(relation, Some(expected), actor, now, true) + } + fn save_relation_inner( + &mut self, + relation: &wire::Relation, + expected: Option, + actor: &str, + now: u64, + detached: bool, ) -> Result { let from = AssetRef::try_from(relation.from.as_ref().ok_or(Error::Invalid)?) .map_err(|_| Error::Invalid)?; @@ -664,7 +683,7 @@ impl Tx<'_> { return Err(Error::Conflict); } if let Some(old) = &old { - self.check_software_relation_replacement(old, from, from_kind, to, kind)?; + self.check_software_relation_replacement(old, from, from_kind, to, kind, detached)?; } let placement = match kind { wire::RelationKind::InstalledIn => Some((from, to)), @@ -674,7 +693,7 @@ impl Tx<'_> { if let Some((child, parent)) = placement { self.check_physical_parent(child, parent, &key)?; } else if kind == wire::RelationKind::RunsOn { - self.check_placement_cycle(from, to, &key)?; + self.check_placement_cycle(from, to, Some(&key))?; } if kind == wire::RelationKind::Contains && from_kind == wire::AssetKind::Interface @@ -690,24 +709,31 @@ impl Tx<'_> { ("kind", Value::Int(relation.kind.into())), ("authority", text(&relation.authority_id)), ])); - if old - .as_ref() - .is_some_and(|prior| data.iter().all(|(k, v)| prior.get(k) == Some(v))) + if !detached + && old + .as_ref() + .is_some_and(|prior| data.iter().all(|(k, v)| prior.get(k) == Some(v))) { return expected.ok_or(Error::Corrupt); } let revision = expected.unwrap_or(0).checked_add(1).ok_or(Error::Limit)?; + if kind == wire::RelationKind::RunsOn && from_kind == wire::AssetKind::VirtualMachine { + self.require_no_pending_operations(from)?; + } if kind == wire::RelationKind::RunsOn && from_kind == wire::AssetKind::Container { self.bind_container_runtime(from, Some(to))?; } data.insert("revision".into(), int(revision)?); - let source_changed = old.as_ref().is_none_or(|p| { - p.get("asset") != data.get("asset") || p.get("incarnation") != data.get("incarnation") - }); - let target_changed = old.as_ref().is_none_or(|p| { - p.get("to_asset") != data.get("to_asset") - || p.get("to_incarnation") != data.get("to_incarnation") - }); + let source_changed = detached + || old.as_ref().is_none_or(|p| { + p.get("asset") != data.get("asset") + || p.get("incarnation") != data.get("incarnation") + }); + let target_changed = detached + || old.as_ref().is_none_or(|p| { + p.get("to_asset") != data.get("to_asset") + || p.get("to_incarnation") != data.get("to_incarnation") + }); self.put_against(&key, "relations", Class::Inventory, data, old.as_ref())?; if source_changed { if old.is_some() { @@ -736,16 +762,16 @@ impl Tx<'_> { self.audit(&key, actor, "relation.accepted", revision, now)?; Ok(revision) } - fn physical_parent(&self, child: AssetRef, skip: &str) -> Result> { + fn physical_parent(&self, child: AssetRef, skip: Option<&str>) -> Result> { let mut parents = Vec::with_capacity(2); for (query, kinds, incoming) in [ ( - "MATCH (a:OmRow {key:$asset})-[:OmRelationFrom]->(r:OmRow) WHERE r.kind IN $kinds AND r.key<>$skip RETURN properties(r) AS p LIMIT 2", + "MATCH (a:OmRow {key:$asset})-[:OmRelationFrom]->(r:OmRow) WHERE r.kind IN $kinds AND ($skip IS NULL OR r.key<>$skip) RETURN properties(r) AS p LIMIT 2", vec![wire::RelationKind::InstalledIn], false, ), ( - "MATCH (a:OmRow {key:$asset})<-[:OmRelationTo]-(r:OmRow) WHERE r.kind IN $kinds AND r.key<>$skip RETURN properties(r) AS p LIMIT 2", + "MATCH (a:OmRow {key:$asset})<-[:OmRelationTo]-(r:OmRow) WHERE r.kind IN $kinds AND ($skip IS NULL OR r.key<>$skip) RETURN properties(r) AS p LIMIT 2", vec![wire::RelationKind::Contains, wire::RelationKind::HasPort], true, ), @@ -754,7 +780,7 @@ impl Tx<'_> { query, params([ ("asset", text(asset_key(child))), - ("skip", text(skip)), + ("skip", skip.map_or(Value::Null, text)), ( "kinds", Value::Array( @@ -788,14 +814,19 @@ impl Tx<'_> { Ok(parents.pop()) } fn check_physical_parent(&self, child: AssetRef, parent: AssetRef, skip: &str) -> Result<()> { - if self.physical_parent(child, skip)?.is_some() { + if self.physical_parent(child, Some(skip))?.is_some() { return Err(Error::Conflict); } - self.check_placement_cycle(child, parent, skip) + self.check_placement_cycle(child, parent, Some(skip)) } - fn check_placement_cycle(&self, child: AssetRef, parent: AssetRef, skip: &str) -> Result<()> { + pub(super) fn check_placement_cycle( + &self, + child: AssetRef, + parent: AssetRef, + skip: Option<&str>, + ) -> Result<()> { // Execution placement and containment have opposite stored arrows but share ancestry. - // Multiple execution parents are traversed without imposing physical-parent uniqueness. + // Legacy multiple hosts are still traversed; None excludes no existing relation. let mut pending = vec![parent]; let mut seen = std::collections::BTreeSet::from([parent]); while let Some(node) = pending.pop() { @@ -804,8 +835,8 @@ impl Tx<'_> { } let physical = self.physical_parent(node, skip)?; let hosts = self.query( - "MATCH (a:OmRow {key:$asset})-[:OmRelationFrom]->(r:OmRow)-[:OmRelationTo]->(p:OmRow) WHERE r.kind=$kind AND r.key<>$skip RETURN properties(p) AS p LIMIT 129", - params([("asset", text(asset_key(node))), ("skip", text(skip)), ("kind", Value::Int(wire::RelationKind::RunsOn as i32 as i64))]), + "MATCH (a:OmRow {key:$asset})-[:OmRelationFrom]->(r:OmRow)-[:OmRelationTo]->(p:OmRow) WHERE r.kind=$kind AND ($skip IS NULL OR r.key<>$skip) RETURN properties(p) AS p LIMIT 129", + params([("asset", text(asset_key(node))), ("skip", skip.map_or(Value::Null, text)), ("kind", Value::Int(wire::RelationKind::RunsOn as i32 as i64))]), )?; if hosts.len() > 128 { return Err(Error::Limit); diff --git a/crates/omloom-store/src/disk/operations.rs b/crates/omloom-store/src/disk/operations.rs index 163bb5a..8158756 100644 --- a/crates/omloom-store/src/disk/operations.rs +++ b/crates/omloom-store/src/disk/operations.rs @@ -1,5 +1,15 @@ use super::*; +impl Tx<'_> { + pub(super) fn require_no_pending_operations(&self, target: AssetRef) -> Result<()> { + let pending=self.query("MATCH (n:OmRow) WHERE n.parent='operations' AND n.class=$class AND n.asset=$asset AND n.incarnation=$incarnation RETURN properties(n) AS p LIMIT 1",params([("class",int(Class::Pending as u64)?),("asset",text(target.asset.to_string())),("incarnation",text(target.incarnation.to_string()))]))?; + if !pending.is_empty() { + return Err(Error::UnsafeTransition); + } + Ok(()) + } +} + enum Change<'a> { Dispatch, Evidence { diff --git a/crates/omloom-store/src/disk/tests.rs b/crates/omloom-store/src/disk/tests.rs index 6e56ccf..55c5e51 100644 --- a/crates/omloom-store/src/disk/tests.rs +++ b/crates/omloom-store/src/disk/tests.rs @@ -48,6 +48,8 @@ fn capacity_increase_resumes_quota_bound_name_migration() let legacy = Store { db: store.db, policy: previous.clone(), + #[cfg(feature = "diagnostics")] + query_check: None, }; let mut tx = legacy.begin()?; tx.root diff --git a/crates/omloom-store/src/disk/workspace.rs b/crates/omloom-store/src/disk/workspace.rs index e705510..6785a70 100644 --- a/crates/omloom-store/src/disk/workspace.rs +++ b/crates/omloom-store/src/disk/workspace.rs @@ -8,9 +8,11 @@ use std::collections::BTreeSet; mod accounts; mod addresses; mod assets; +mod correction; mod current_facts; mod definitions; mod exchange; +pub(super) mod host_identity; mod host_import; mod interface_facts; mod network; @@ -18,7 +20,10 @@ pub(super) mod network_facts; mod projection; mod responsibility; mod software; +mod source_layout; +mod typed_values; mod views; +mod vm_placement; mod working; pub const MAX_PAGE: usize = 100; @@ -306,6 +311,32 @@ impl Store { working.as_ref().ok_or(Error::Invalid)?, now, ), + C::CompareHostIdentity(value) => self.workspace_compare_host_identity( + context, + value, + working.as_ref().ok_or(Error::Invalid)?, + ), + C::CorrectIdentity(value) => self.workspace_correct_identity( + context, + value, + working.as_ref().ok_or(Error::Invalid)?, + now, + ), + C::FindIdentityCandidates(value) => self.workspace_identity_candidates( + context, + value, + working.as_ref().ok_or(Error::Invalid)?, + ), + C::ResolveIdentity(value) => self.workspace_resolve_identity( + context, + value, + working.as_ref().ok_or(Error::Invalid)?, + ), + C::PrepareVmPlacement(value) => self.workspace_prepare_vm_placement( + context, + value, + working.as_ref().ok_or(Error::Invalid)?, + ), C::Export(export) => self.workspace_export(context, export), C::Network(network) => self.workspace_network( context, diff --git a/crates/omloom-store/src/disk/workspace/accounts.rs b/crates/omloom-store/src/disk/workspace/accounts.rs index 3b67203..f089011 100644 --- a/crates/omloom-store/src/disk/workspace/accounts.rs +++ b/crates/omloom-store/src/disk/workspace/accounts.rs @@ -42,6 +42,22 @@ fn link_key(account: AssetRef, kind: LinkKind, target: AssetRef) -> String { ) } fn metadata_props(metadata: &api::AccountMetadata) -> Result { + if !matches!( + api::LocatorKind::try_from(metadata.sender_kind), + Ok(api::LocatorKind::Unspecified | api::LocatorKind::Email | api::LocatorKind::Domain) + ) { + return Err(Error::Invalid); + } + for (value, kind) in [ + (&metadata.sender, metadata.sender_kind), + (&metadata.recovery_route, metadata.recovery_route_kind), + ] { + if let Some(value) = value { + typed_values::locator(kind, value)?; + } else if kind != api::LocatorKind::Unspecified as i32 { + return Err(Error::Invalid); + } + } responsibility::bounded_value(&metadata.issuer, 256, true)?; responsibility::bounded_value(&metadata.subject, 256, true)?; if !matches!( @@ -57,6 +73,15 @@ fn metadata_props(metadata: &api::AccountMetadata) -> Result { ("account_kind", int(metadata.kind as u64)?), ("delegation", int(metadata.delegation as u64)?), ]); + if metadata.sender_kind != api::LocatorKind::Unspecified as i32 { + data.insert("sender_kind".into(), int(metadata.sender_kind as u64)?); + } + if metadata.recovery_route_kind != api::LocatorKind::Unspecified as i32 { + data.insert( + "recovery_route_kind".into(), + int(metadata.recovery_route_kind as u64)?, + ); + } for (name, value, max) in [ ("login", &metadata.login, 256), ("sender", &metadata.sender, 256), @@ -154,13 +179,20 @@ impl Store { let key = account_key(account); let old = tx.get(&key)?; for field in METADATA_FIELDS { + let discriminator = match field { + "sender" => Some("sender_kind"), + "recovery_route" => Some("recovery_route_kind"), + _ => None, + }; if !data.contains_key(field) && old.as_ref().is_some_and(|row| row.contains_key(field)) && !permitted(context, account, field, false) { return Err(Error::Denied); } - if old.as_ref().and_then(|r| r.get(field)) != data.get(field) + if (old.as_ref().and_then(|r| r.get(field)) != data.get(field) + || discriminator + .is_some_and(|key| old.as_ref().and_then(|r| r.get(key)) != data.get(key))) && !permitted(context, account, field, true) { return Err(Error::Denied); @@ -535,6 +567,13 @@ impl Tx<'_> { *value = data.get(name).and_then(Value::as_str).map(str::to_owned); } } + if visible.sender.is_some() { + visible.sender_kind = typed_values::read_kind(&data, "sender_kind")?; + } + if visible.recovery_route.is_some() { + visible.recovery_route_kind = + typed_values::read_kind(&data, "recovery_route_kind")?; + } if METADATA_FIELDS .iter() .any(|field| permitted(context, account, field, false)) diff --git a/crates/omloom-store/src/disk/workspace/assets.rs b/crates/omloom-store/src/disk/workspace/assets.rs index 92e6e1c..d2187ad 100644 --- a/crates/omloom-store/src/disk/workspace/assets.rs +++ b/crates/omloom-store/src/disk/workspace/assets.rs @@ -13,11 +13,10 @@ fn parameter(values: &mut HashMap, value: Value) -> String { format!("${name}") } -// Use the same typed ownership rules as creation; peer links never form levels. -pub(super) fn ownership_clause( +fn ownership_groups( reverse_only: Option, child_kinds: &[wire::AssetKind], -) -> String { +) -> BTreeMap<(i32, bool, i32), Vec> { use wire::AssetKind as K; let kinds = [ K::Site, @@ -54,6 +53,15 @@ pub(super) fn ownership_clause( } } } + groups +} + +// Use the same typed ownership rules as creation; peer links never form levels. +pub(super) fn ownership_clause( + reverse_only: Option, + child_kinds: &[wire::AssetKind], +) -> String { + let groups = ownership_groups(reverse_only, child_kinds); let mut terms = Vec::new(); for ((relation, reverse, parent), children) in groups { let owner = if reverse { @@ -95,7 +103,7 @@ fn browse_clause( kinds: &[wire::AssetKind], ) -> String { format!( - "p.parent='assets' AND p.key<>a.key AND p.adoption={} AND {} AND {} AND {}", + "r.parent='relations' AND p.parent='assets' AND p.key<>a.key AND p.adoption={} AND {} AND {} AND {}", wire::Adoption::Adopted as i32, visible_clause_for(context, Some("relations"), values, "p"), visible_clause_for(context, Some("relations"), values, "a"), @@ -568,15 +576,17 @@ impl Store { clauses.join(" AND ") )); clauses.clear(); - // Probe indexed relation endpoints directly; repeating a resource - // probe adds a join before reaching the same adjacent relation. - for (reverse, path) in [ - ( - false, - "(r:OmRow {to_asset:a.asset})<-[:OmRelationFrom]-(p:OmRow)", - ), - (true, "(r:OmRow {asset:a.asset})-[:OmRelationTo]->(p:OmRow)"), - ] { + // Endpoints and adjacency projections commit together. Resolve + // parent keys directly, rejecting ineligible relations first. + for (reverse, endpoint, parent_endpoint) in + [(false, "to_asset", "asset"), (true, "asset", "to_asset")] + { + let relation_kinds = ownership_groups(Some(reverse), &child_kinds) + .keys() + .map(|(kind, _, _)| *kind) + .collect::>() + .into_iter() + .collect::>(); let mut owner = browse_clause(context, &mut values, Some(reverse), &child_kinds); if view == api::View::Virtualization { @@ -610,11 +620,17 @@ impl Store { "forward_owned" }; predicate_joins.push(format!( - " OPTIONAL MATCH {path} WHERE {owner} WITH {}, p IS NOT NULL AS {flag}", + " WITH {}, EXISTS {{ MATCH (r:OmRow {{{endpoint}:a.asset}}), (p:OmRow {{key:CASE WHEN r.parent='relations' AND r.kind IN {relation_kinds:?} THEN 'asset/'+r.{parent_endpoint} ELSE null END}}) WHERE {owner} }} AS {flag}", bindings.join(", ") )); - bindings.push(flag.into()); - clauses.push(format!("NOT {flag}")); + if reverse { + bindings.push(flag.into()); + clauses.push(format!("NOT {flag}")); + } else { + // Owned components cannot become roots in the reverse + // branch. Reject them before performing another probe. + predicate_joins.push(format!(" WHERE NOT {flag}")); + } } } else { let owner = browse_clause(context, &mut values, None, &child_kinds); @@ -735,6 +751,7 @@ impl Store { "name".to_owned(), "description".to_owned(), "serial".to_owned(), + "dns_name".to_owned(), ]; searchable.extend( definitions @@ -1286,6 +1303,11 @@ impl Store { current_addresses, relations_next, network_next: None, + identity: if read.relations_cursor.is_none() { + tx.host_identity(context, target, asset.kind)? + } else { + Vec::new() + }, }; if read.relations_cursor.is_none() { tx.read_network_page(context, target, asset.kind, working, None, &mut detail)?; @@ -1536,6 +1558,13 @@ impl Store { } tx.check_relationship(context, &selection, &existing)?; if request.remove { + if existing.kind == wire::RelationKind::RunsOn as i32 { + let guest = parse_target(existing.from.as_ref())?; + if number(&tx.asset(guest)?, "kind")? == wire::AssetKind::VirtualMachine as u64 + { + tx.require_no_pending_operations(guest)?; + } + } if existing.kind == wire::RelationKind::ReleaseOf as i32 && tx.release_is_installed(parse_target(existing.from.as_ref())?)? { @@ -1775,7 +1804,7 @@ impl Tx<'_> { } } } - fn workspace_remove( + pub(super) fn workspace_remove( &mut self, context: &Context, target: AssetRef, @@ -1788,10 +1817,7 @@ impl Tx<'_> { let key = asset_key(target); // Existing operation/retirement code owns uncertain effects and identity fences. self.require_no_account_references(target)?; - let pending = self.query("MATCH (n:OmRow) WHERE n.parent='operations' AND n.class=$class AND n.asset=$asset AND n.incarnation=$incarnation RETURN properties(n) AS p LIMIT 1",params([("class", int(Class::Pending as u64)?), ("asset", text(target.asset.to_string())), ("incarnation", text(target.incarnation.to_string()))]))?; - if !pending.is_empty() { - return Err(Error::UnsafeTransition); - } + self.require_no_pending_operations(target)?; let linked = self.query("MATCH (n:OmRow) WHERE n.parent='relations' AND (n.asset=$asset OR n.to_asset=$asset) RETURN properties(n) AS p LIMIT 1",params([("asset", text(target.asset.to_string()))]))?; if !linked.is_empty() { return Err(Error::Conflict); @@ -1829,7 +1855,7 @@ impl Tx<'_> { self.delete(&group_key)?; } self.retire_current_facts(target)?; - let mappings = self.query("MATCH (n:OmRow {asset:$asset}) WHERE n.parent='csv-mappings' AND n.incarnation=$incarnation RETURN properties(n) AS p LIMIT 2049", params([("asset", text(target.asset.to_string())), ("incarnation", text(target.incarnation.to_string()))]))?; + let mappings = self.query("MATCH (n:OmRow {asset:$asset}) WHERE n.parent='csv-mappings' AND n.incarnation=$incarnation AND n.class=$class RETURN properties(n) AS p LIMIT 2049", params([("asset", text(target.asset.to_string())), ("incarnation", text(target.incarnation.to_string())), ("class", Value::Int(Class::Inventory as i64))]))?; if mappings.len() > MAX_CHILDREN { return Err(Error::Limit); } @@ -1903,6 +1929,7 @@ pub(super) fn validate_column(name: &str, definitions: &[api::Definition]) -> Re "name", "description", "serial", + "dns_name", "virtualization_role", "vlan_tag", "cidr", diff --git a/crates/omloom-store/src/disk/workspace/correction.rs b/crates/omloom-store/src/disk/workspace/correction.rs new file mode 100644 index 0000000..ebe28db --- /dev/null +++ b/crates/omloom-store/src/disk/workspace/correction.rs @@ -0,0 +1,584 @@ +//! Reviewed corrections reuse inventory validation, transaction CAS, audit and +//! finite review/detail custody. No operation or account authority is retargeted. +use super::*; +mod sources; +mod undo; + +fn pair(value: &api::IdentityMapping) -> Result<(AssetRef, AssetRef)> { + Ok(( + parse_target(value.duplicate.as_ref())?, + parse_target(value.retained.as_ref())?, + )) +} +fn strip(mut value: Properties) -> Properties { + operations::strip_envelope(&mut value); + value +} +impl Tx<'_> { + fn correction_guard( + &self, + context: &Context, + target: AssetRef, + working: &api::WorkingContext, + ) -> Result { + let row = self.visible_asset(context, target)?; + let kind = number(&row, "kind")? as i32; + if !context.field(target, kind, "relations", false) { + return Err(Error::Denied); + } + self.selection(context, working, false)? + .contains(target, self)?; + for field in ["identity_correction", "relations", "remove"] { + if !context.field(target, kind, field, true) { + return Err(Error::Denied); + } + } + self.require_no_account_references(target)?; + self.require_no_ipam_state(target)?; + self.require_no_pending_operations(target)?; + Ok(row) + } + fn correction_fields( + &mut self, + context: &Context, + mapping: &api::IdentityMapping, + kind: i32, + working: &api::WorkingContext, + now: u64, + ) -> Result<()> { + let (from, to) = pair(mapping)?; + let rows = self.fact_rows(&asset_key(from), MAX_FIELDS + 5)?; + let choices: BTreeMap<_, _> = mapping + .fields + .iter() + .map(|v| (v.name.as_str(), v.use_duplicate)) + .collect(); + if choices.len() != mapping.fields.len() || choices.len() > MAX_FIELDS { + return Err(Error::Invalid); + } + let mut used = BTreeSet::new(); + let mut changes = Vec::new(); + for row in rows { + if row.get("layer") == Some(&text("intent")) { + let name = string(&row, "field")?; + let take = choices.get(name.as_str()).ok_or(Error::Conflict)?; + for target in [from, to] { + if !context.field(target, kind, &name, false) + || ((target == from || *take) && !context.field(target, kind, &name, true)) + { + return Err(Error::Denied); + } + } + used.insert(name); + if *take { + changes.push(inventory::read_field(&row)?); + } + } else if row.contains_key("facet") { + // Declarations are not transferred. An inherited binding is + // recomputed through its changed relation; explicit conflicts + // must first be resolved in the responsibility editor. + if number(&row, "facet")? != api::ResponsibilityFacet::Creator as u64 + && number(&row, "mode")? != api::AssignmentMode::Inherit as u64 + { + return Err(Error::Conflict); + } + } else { + return Err(Error::Conflict); + } + } + if used.len() != choices.len() { + return Err(Error::Invalid); + } + if !changes.is_empty() { + self.context_write( + context, + api::Write { + target: Some(target_wire(to)), + expected: Some(number(&self.asset(to)?, "revision")?), + kind, + changes, + ..Default::default() + }, + working, + now, + )?; + } + Ok(()) + } + fn apply_correction( + &mut self, + context: &Context, + request: &api::IdentityCorrection, + working: &api::WorkingContext, + now: u64, + ) -> Result> { + use api::IdentityDecision as D; + let decision = D::try_from(request.decision).map_err(|_| Error::Invalid)?; + if request.mappings.is_empty() + || request.mappings.len() > 8 + || !matches!( + decision, + D::DistinctInstallations | D::DuplicateInstallation + ) + { + return Err(Error::Invalid); + } + let mut mappings = BTreeMap::new(); + let mut kinds = BTreeMap::new(); + let mut relations = BTreeMap::new(); + for mapping in &request.mappings { + let (from, to) = pair(mapping)?; + if from == to || mappings.insert(from, to).is_some() { + return Err(Error::Invalid); + } + let old = self.correction_guard(context, from, working)?; + let retained = self.correction_guard(context, to, working)?; + let kind = number(&old, "kind")? as i32; + if kind != number(&retained, "kind")? as i32 + || !matches!( + wire::AssetKind::try_from(kind), + Ok(wire::AssetKind::Device + | wire::AssetKind::VirtualMachine + | wire::AssetKind::SoftwareInstallation + | wire::AssetKind::Interface) + ) + { + return Err(Error::Invalid); + } + if decision == D::DistinctInstallations + && !matches!( + wire::AssetKind::try_from(kind), + Ok(wire::AssetKind::Device | wire::AssetKind::VirtualMachine) + ) + { + return Err(Error::Invalid); + } + kinds.insert(from, kind); + for relation in self.working_relations(from)? { + if relations.len() == 128 { + return Err(Error::Limit); + } + relations.insert(relation.relation_id.clone(), relation); + } + } + if mappings.values().any(|v| mappings.contains_key(v)) { + return Err(Error::Invalid); + } + let mut attachments = BTreeSet::new(); + for mapping in &request.mappings { + if mapping.attachments.len() > 128 { + return Err(Error::Limit); + } + for id in &mapping.attachments { + if !relations.contains_key(id) || !attachments.insert(id.as_str()) { + return Err(Error::Invalid); + } + } + } + for relation in relations.values() { + if matches!( + wire::RelationKind::try_from(relation.kind), + Ok(wire::RelationKind::HasPort + | wire::RelationKind::Carries + | wire::RelationKind::InstalledIn + | wire::RelationKind::ConnectedTo + | wire::RelationKind::On) + ) && !attachments.contains(relation.relation_id.as_str()) + { + return Err(Error::Conflict); + } + } + let selection = self.selection(context, working, false)?; + let authority = string( + &self.get("workspace/authority")?.ok_or(Error::Missing)?, + "authority", + )?; + let mut impacts = Vec::new(); + // Remove old attachments before validating the final graph, so an + // intermediate exclusive-parent conflict cannot choose the result. + for before in relations.values() { + let (from, to) = self.check_relationship(context, &selection, before)?; + for endpoint in [from, to] { + let kind = number(&self.asset(endpoint)?, "kind")? as i32; + if !context.field(endpoint, kind, "relations", false) { + return Err(Error::Denied); + } + } + if before.authority_id != authority { + return Err(Error::Conflict); + } + } + for mapping in &request.mappings { + let (from, _) = pair(mapping)?; + self.correction_fields(context, mapping, kinds[&from], working, now)?; + } + for before in relations.values() { + self.unlink_correction_relation(&before.relation_id)?; + } + // Detached rows still participate in uniqueness checks. Remove final + // duplicates before validating any survivor, independently of ID order. + let mut destinations = BTreeSet::new(); + let mut redundant_ids = BTreeSet::new(); + for before in relations.values() { + let from = parse_target(before.from.as_ref())?; + let to = parse_target(before.to.as_ref())?; + let final_from = *mappings.get(&from).unwrap_or(&from); + let final_to = *mappings.get(&to).unwrap_or(&to); + if !destinations.insert((final_from, final_to, before.kind)) { + redundant_ids.insert(before.relation_id.clone()); + self.delete(&format!("relation/{}", before.relation_id))?; + } + } + let mut redundant_relations = Vec::with_capacity(redundant_ids.len()); + for before in relations.into_values() { + if redundant_ids.contains(&before.relation_id) { + redundant_relations.push(before); + continue; + } + let from = parse_target(before.from.as_ref())?; + let to = parse_target(before.to.as_ref())?; + let mut after = before.clone(); + after.from = Some(target_wire(*mappings.get(&from).unwrap_or(&from))); + after.to = Some(target_wire(*mappings.get(&to).unwrap_or(&to))); + if after.from == after.to { + // Only an internal containment edge can become redundant. + if !matches!( + wire::RelationKind::try_from(after.kind), + Ok(wire::RelationKind::Contains) + ) { + return Err(Error::Conflict); + } + self.delete(&format!("relation/{}", before.relation_id))?; + impacts.push(api::IdentityImpact { + before: Some(before), + after: None, + }); + continue; + } + // Every original endpoint and mapped destination was authorized + // before detachment. Intermediate topology cannot redefine scope. + let redundant = self + .working_relations(parse_target(after.from.as_ref())?)? + .into_iter() + .find(|r| r.from == after.from && r.to == after.to && r.kind == after.kind); + if let Some(retained) = redundant { + self.rebind_correction_responsibility(context, &before, &retained)?; + self.delete(&format!("relation/{}", before.relation_id))?; + impacts.push(api::IdentityImpact { + before: Some(before), + after: Some(retained), + }); + continue; + } + after.revision = before.revision.checked_add(1).ok_or(Error::Limit)?; + self.save_rebound_relation(&after, before.revision, &context.actor, now)?; + self.rebind_correction_responsibility(context, &before, &after)?; + impacts.push(api::IdentityImpact { + before: Some(before), + after: Some(after), + }); + } + for before in redundant_relations { + let from = parse_target(before.from.as_ref())?; + let to = parse_target(before.to.as_ref())?; + let from = *mappings.get(&from).unwrap_or(&from); + let to = *mappings.get(&to).unwrap_or(&to); + let from_wire = target_wire(from); + let to_wire = target_wire(to); + let retained = self.working_relations(from)?.into_iter().find(|r| { + r.from.as_ref() == Some(&from_wire) + && r.to.as_ref() == Some(&to_wire) + && r.kind == before.kind + }); + if let Some(retained) = &retained { + self.rebind_correction_responsibility(context, &before, retained)?; + } else if from != to || before.kind != wire::RelationKind::Contains as i32 { + return Err(Error::Corrupt); + } + impacts.push(api::IdentityImpact { + before: Some(before), + after: retained, + }); + } + for mapping in &request.mappings { + let (from, to) = pair(mapping)?; + self.move_correction_sources(context, mapping, from, to, kinds[&from])?; + // Native and explicitly selected field state have moved. The + // generic delete guard still owns all remaining bindings. + let old = self.asset(from)?; + self.workspace_remove(context, from, &old, now)?; + let key = format!("removed/{}", from.asset); + let mut fence = target_props(from); + fence.insert("to_asset".into(), text(to.asset.to_string())); + fence.insert("to_incarnation".into(), text(to.incarnation.to_string())); + self.put(&key, "removed", Class::Checkpoint, fence)?; + self.workspace_advance(to, &context.actor, "identity.corrected", now)?; + } + Ok(impacts) + } + fn unlink_correction_relation(&mut self, id: &str) -> Result<()> { + let key = format!("relation/{id}"); + for query in [ + "MATCH (r:OmRow {key:$key})<-[e:OmRelationFrom]-() DELETE e", + "MATCH (r:OmRow {key:$key})-[e:OmRelationTo]->() DELETE e", + ] { + self.store.db.execute_in_transaction( + self.id.ok_or(Error::Corrupt)?, + query, + params([("key", text(&key))]), + )?; + } + Ok(()) + } + fn rebind_correction_responsibility( + &mut self, + context: &Context, + before: &wire::Relation, + after: &wire::Relation, + ) -> Result<()> { + for endpoint in [&before.from, &before.to].into_iter().flatten() { + let target = parse_target(Some(endpoint))?; + for mut row in self.fact_rows(&asset_key(target), MAX_FIELDS + 5)? { + if row.get("source_relation") != Some(&text(&before.relation_id)) { + continue; + } + if number(&row, "source_binding_revision")? != before.revision { + continue; + } + let facet = number(&row, "facet")?; + let field = match api::ResponsibilityFacet::try_from(facet as i32) { + Ok(api::ResponsibilityFacet::ClaimedOwner) => "claimed_owner", + Ok(api::ResponsibilityFacet::Operator) => "operator", + _ => return Err(Error::Conflict), + }; + if !context.field( + target, + number(&self.asset(target)?, "kind")? as i32, + field, + true, + ) { + return Err(Error::Denied); + } + let key = string(&row, "key")?; + row = strip(row); + row.insert("source_binding_revision".into(), int(after.revision)?); + row.insert("source_relation".into(), text(&after.relation_id)); + self.put(&key, &asset_key(target), Class::Inventory, row)?; + } + } + Ok(()) + } +} + +impl Store { + pub(super) fn workspace_correct_identity( + &self, + context: &Context, + request: api::IdentityCorrection, + working: &api::WorkingContext, + now: u64, + ) -> Result { + if let Some(id) = &request.undo { + if request.preview.is_some() || request.decision != 0 || !request.mappings.is_empty() { + return Err(Error::Invalid); + } + return self.undo_identity_correction(context, id, working, now); + } + if let Some(id) = &request.preview { + id.parse::().map_err(|_| Error::Invalid)?; + if request.decision != 0 || !request.mappings.is_empty() { + return Err(Error::Invalid); + } + let mut tx = self.begin()?; + let key = format!("preview/{id}"); + let row = match tx.get(&key)? { + Some(row) => row, + None => { + drop(tx); + return self.identity_correction_receipt(context, id, working); + } + }; + if string(&row, "owner")? != context.actor + || number(&row, "source_binding_revision")? != context.authority_revision + { + return Err(Error::Denied); + } + if number(&row, "expires")? <= now { + return Err(Error::Expired); + } + if number(&row, "revision")? != number(&tx.root, "revision")? { + return Err(Error::Conflict); + } + let mut selector = working.clone(); + selector.expected = None; + if row.get("object_kind") != Some(&text("identity-correction")) + || binary(&row, "working")? != selector.encode_to_vec() + { + return Err(Error::Conflict); + } + let input = api::IdentityCorrection::decode(binary(&row, "payload")?) + .map_err(|_| Error::Corrupt)?; + tx.inverse = Some(deltas::Capture::default()); + tx.apply_correction(context, &input, working, now)?; + let inverse = tx.inverse.take().ok_or(Error::Corrupt)?; + let until = now + .checked_add(self.policy.detail_age_ms) + .ok_or(Error::Limit)?; + let revision = number(&tx.root, "revision")? + .checked_add(1) + .ok_or(Error::Limit)?; + tx.put( + &format!("correction/{id}"), + "detail/identity-corrections", + Class::Detail, + props([ + ("owner", text(&context.actor)), + ("at", int(now)?), + ("expires", int(until)?), + ("revision", int(revision)?), + ("source_binding_revision", int(context.authority_revision)?), + ("payload", Value::Binary(inverse.encode()?)), + ("query", Value::Binary(input.encode_to_vec())), + ]), + )?; + tx.delete(&key)?; + let retained = input + .mappings + .iter() + .filter_map(|v| v.retained.clone()) + .collect(); + let revision = tx.commit()?; + return Ok(reply(api::reply::Result::IdentityCorrected( + api::IdentityCorrectionApplied { + revision, + id: id.clone(), + undo_until: until, + retained, + }, + ))); + } + let mut tx = self.begin()?; + if request.decision == api::IdentityDecision::KeepSeparate as i32 { + if !request.mappings.is_empty() { + return Err(Error::Invalid); + } + return Ok(mutation(tx.commit()?, Vec::new())); + } + let base = number(&tx.root, "revision")?; + if working.expected != Some(base) { + return Err(Error::Conflict); + } + drop(tx); + let relations = { + let mut dry = self.begin()?; + dry.inverse = Some(deltas::Capture::default()); + let result = dry.apply_correction(context, &request, working, now)?; + dry.inverse.as_ref().ok_or(Error::Corrupt)?.encode()?; + result + }; + tx = self.begin()?; + if number(&tx.root, "revision")? != base { + return Err(Error::Conflict); + } + let payload = request.encode_to_vec(); + let mut selector = working.clone(); + selector.expected = None; + // One replaceable preview per actor, shared with ordinary import. + let previous=tx.query("MATCH (n:OmRow {parent:'workspace-previews'}) WHERE n.owner=$owner RETURN properties(n) AS p LIMIT 5",params([("owner",text(&context.actor))]))?; + if previous.len() > 4 { + return Err(Error::Limit); + } + for old in previous { + let key = string(&old, "key")?; + if old.get("object_kind") == Some(&text("identity-correction")) + && binary(&old, "payload")? == payload + && number(&old, "revision")? == base + && number(&old, "expires")? > now + && number(&old, "source_binding_revision")? == context.authority_revision + && binary(&old, "working")? == selector.encode_to_vec() + { + return Ok(reply(api::reply::Result::IdentityCorrection( + api::IdentityCorrectionView { + id: key.strip_prefix("preview/").ok_or(Error::Corrupt)?.into(), + revision: base, + mappings: request.mappings, + relations, + expires_at: number(&old, "expires")?, + }, + ))); + } + for child in tx.children(&key)? { + tx.delete(&string(&child, "key")?)?; + } + tx.delete(&key)?; + } + let id = AuthorityId::generate().to_string(); + let revision = base.checked_add(1).ok_or(Error::Limit)?; + let expires_at = now + .checked_add(self.policy.review_age_ms) + .ok_or(Error::Limit)?; + tx.put( + &format!("preview/{id}"), + "workspace-previews", + Class::Review, + props([ + ("owner", text(&context.actor)), + ("revision", int(revision)?), + ("expires", int(expires_at)?), + ("source_binding_revision", int(context.authority_revision)?), + ("object_kind", text("identity-correction")), + ("working", Value::Binary(selector.encode_to_vec())), + ("payload", Value::Binary(payload)), + ]), + )?; + tx.commit()?; + Ok(reply(api::reply::Result::IdentityCorrection( + api::IdentityCorrectionView { + id, + revision, + mappings: request.mappings, + relations, + expires_at, + }, + ))) + } + fn identity_correction_receipt( + &self, + context: &Context, + id: &str, + working: &api::WorkingContext, + ) -> Result { + let tx = self.begin()?; + let row = tx.get(&format!("correction/{id}"))?.ok_or(Error::Expired)?; + if string(&row, "owner")? != context.actor + || number(&row, "source_binding_revision")? != context.authority_revision + { + return Err(Error::Denied); + } + let receipt = if row.get("retired") == Some(&Value::Bool(true)) { + api::IdentityCorrectionApplied::decode(binary(&row, "payload")?) + .map_err(|_| Error::Corrupt)? + } else { + let input = api::IdentityCorrection::decode(binary(&row, "query")?) + .map_err(|_| Error::Corrupt)?; + api::IdentityCorrectionApplied { + revision: number(&row, "revision")?, + id: id.into(), + undo_until: number(&row, "expires")?, + retained: input + .mappings + .into_iter() + .filter_map(|v| v.retained) + .collect(), + } + }; + let selection = tx.selection(context, working, false)?; + for reference in &receipt.retained { + let target = parse_target(Some(reference))?; + tx.visible_asset(context, target)?; + selection.contains(target, &tx)?; + } + tx.commit()?; + Ok(reply(api::reply::Result::IdentityCorrected(receipt))) + } +} diff --git a/crates/omloom-store/src/disk/workspace/correction/sources.rs b/crates/omloom-store/src/disk/workspace/correction/sources.rs new file mode 100644 index 0000000..3a160b1 --- /dev/null +++ b/crates/omloom-store/src/disk/workspace/correction/sources.rs @@ -0,0 +1,166 @@ +use super::super::source_layout::{Family, Relocation}; +use super::*; + +#[cfg(test)] +mod tests; + +fn renamed(mapping: &api::IdentityMapping, name: &str) -> String { + if let Some(choice) = mapping.sources.iter().find(|choice| name == choice.before) { + return choice.after.clone(); + } + let mut longest: Option<(&api::IdentitySourceChoice, &str)> = None; + for choice in &mapping.sources { + if let Some((previous, _)) = longest + && previous.before.len() >= choice.before.len() + { + continue; + } + if let Some(part) = name + .strip_prefix(choice.before.as_str()) + .and_then(|rest| rest.strip_prefix(" / ")) + { + longest = Some((choice, part)); + } + } + match longest { + Some((choice, part)) => format!("{} / {part}", choice.after), + None => name.into(), + } +} +fn require(context: &Context, from: AssetRef, to: AssetRef, kind: i32, field: &str) -> Result<()> { + for target in [from, to] { + if !context.field(target, kind, field, false) || !context.field(target, kind, field, true) { + return Err(Error::Denied); + } + } + Ok(()) +} +impl Tx<'_> { + pub(super) fn move_correction_sources( + &mut self, + context: &Context, + mapping: &api::IdentityMapping, + from: AssetRef, + to: AssetRef, + kind: i32, + ) -> Result<()> { + if mapping.sources.len() > 8 { + return Err(Error::Limit); + } + let mut choices = BTreeSet::new(); + for choice in &mapping.sources { + if choice.before.is_empty() + || choice.after.is_empty() + || choice.before.len() > 100 + || choice.after.len() > 80 + || choice + .before + .chars() + .chain(choice.after.chars()) + .any(char::is_control) + || !choices.insert(choice.before.as_str()) + { + return Err(Error::Invalid); + } + } + let identities = self.fact_rows(&host_identity::parent(from), 32)?; + if identities.len() + self.fact_rows(&host_identity::parent(to), 32)?.len() > 32 { + return Err(Error::Limit); + } + for row in identities { + require(context, from, to, kind, "identity_evidence")?; + let key = string(&row, "key")?; + let mut data = strip(row); + data.extend(target_props(to)); + data.insert( + "source".into(), + text(renamed(mapping, &string(&data, "source")?)), + ); + self.put(&key, &host_identity::parent(to), Class::Inventory, data)?; + } + let host_parent = format!("{}/host-mappings", asset_key(to)); + let mappings = + self.fact_rows(&format!("{}/host-mappings", asset_key(from)), MAX_CHILDREN)?; + if mappings.len() + self.fact_rows(&host_parent, MAX_CHILDREN)?.len() > MAX_CHILDREN { + return Err(Error::Limit); + } + for row in mappings { + require(context, from, to, kind, "identity_evidence")?; + let key = string(&row, "key")?; + let source = row + .get("namespace") + .and_then(Value::as_str) + .map(str::to_owned) + .unwrap_or(string(&row, "source")?); + let mut data = strip(row); + data.extend(target_props(to)); + let label = renamed(mapping, &source); + if label != string(&data, "source")? { + data.insert("namespace".into(), text(label)); + } else { + data.remove("namespace"); + } + self.put(&key, &host_parent, Class::Inventory, data)?; + } + let csv=self.query("MATCH (n:OmRow {asset:$asset}) WHERE n.incarnation=$inc AND n.parent='csv-mappings' RETURN properties(n) AS p LIMIT 129",params([("asset",text(from.asset.to_string())),("inc",text(from.incarnation.to_string()))]))?; + if csv.len() > 128 { + return Err(Error::Limit); + } + for row in csv { + let key = string(&row, "key")?; + // Preserve the personal source's original target as a replay fence. + // It neither authorizes the retained identity nor creates a duplicate. + self.put(&key, "csv-mappings", Class::Checkpoint, target_props(from))?; + } + for (family, field) in [ + (Family::Addresses, "current_addresses"), + (Family::Network, "network_evidence"), + ] { + let old_sources = self.fact_rows(&family.parent(from), 8)?; + if old_sources.len() + self.fact_rows(&family.parent(to), 8)?.len() > 8 { + return Err(Error::Limit); + } + for source in old_sources { + require(context, from, to, kind, field)?; + let old = string(&source, "key")?; + let name = renamed(mapping, &string(&source, "source")?); + let binding = source.get("binding").and_then(Value::as_str); + let new = family.key(to, &name, binding); + self.relocate_source( + Some(context), + Relocation { + from, + to, + old: &old, + new: &new, + name: &name, + binding, + family, + }, + )?; + } + let summaries = self.fact_rows(&family.parent(to), 8)?; + let count = summaries.iter().try_fold(0u64, |sum, row| { + sum.checked_add(number(row, "count")?).ok_or(Error::Limit) + })?; + if count + > if family == Family::Addresses { + 64 + } else { + current_facts::MAX_FACTS as u64 + } + { + return Err(Error::Limit); + } + if family == Family::Network { + let bytes = summaries.iter().try_fold(0u64, |sum, row| { + sum.checked_add(number(row, "bytes")?).ok_or(Error::Limit) + })?; + if bytes > current_facts::MAX_BYTES as u64 { + return Err(Error::Limit); + } + } + } + Ok(()) + } +} diff --git a/crates/omloom-store/src/disk/workspace/correction/sources/tests.rs b/crates/omloom-store/src/disk/workspace/correction/sources/tests.rs new file mode 100644 index 0000000..d73303a --- /dev/null +++ b/crates/omloom-store/src/disk/workspace/correction/sources/tests.rs @@ -0,0 +1,53 @@ +use super::*; + +// An explicitly reviewed label wins over a broader prefix, in either order; +// unrelated descendants retain the existing prefix rewrite behavior. +#[test] +fn exact_source_choice_wins_over_earlier_prefix() { + let mut mapping = api::IdentityMapping { + sources: vec![ + api::IdentitySourceChoice { + before: "Survey".into(), + after: "A".into(), + }, + api::IdentitySourceChoice { + before: "Survey / routes".into(), + after: "B".into(), + }, + ], + ..Default::default() + }; + for _ in 0..2 { + assert_eq!(renamed(&mapping, "Survey / routes"), "B"); + assert_eq!(renamed(&mapping, "Survey / interfaces"), "A / interfaces"); + assert_eq!(renamed(&mapping, "Other survey"), "Other survey"); + mapping.sources.reverse(); + } +} + +// Nested label groups use the most specific reviewed prefix independently of +// request order, while partial segment names must not match that prefix. +#[test] +fn nested_source_uses_longest_matching_prefix_in_either_order() { + let mut mapping = api::IdentityMapping { + sources: vec![ + api::IdentitySourceChoice { + before: "Survey".into(), + after: "A".into(), + }, + api::IdentitySourceChoice { + before: "Survey / routes".into(), + after: "B".into(), + }, + ], + ..Default::default() + }; + for _ in 0..2 { + assert_eq!(renamed(&mapping, "Survey / routes / leaf"), "B / leaf"); + assert_eq!( + renamed(&mapping, "Survey / routes-extra / leaf"), + "A / routes-extra / leaf" + ); + mapping.sources.reverse(); + } +} diff --git a/crates/omloom-store/src/disk/workspace/correction/undo.rs b/crates/omloom-store/src/disk/workspace/correction/undo.rs new file mode 100644 index 0000000..cf34df3 --- /dev/null +++ b/crates/omloom-store/src/disk/workspace/correction/undo.rs @@ -0,0 +1,226 @@ +use super::*; + +impl Store { + pub(super) fn undo_identity_correction( + &self, + context: &Context, + id: &str, + working: &api::WorkingContext, + now: u64, + ) -> Result { + id.parse::().map_err(|_| Error::Invalid)?; + let mut tx = self.begin()?; + let key = format!("correction/{id}"); + let row = tx.get(&key)?.ok_or(Error::Expired)?; + if string(&row, "owner")? != context.actor + || number(&row, "source_binding_revision")? != context.authority_revision + { + return Err(Error::Denied); + } + if row.get("retired") == Some(&Value::Bool(true)) { + drop(tx); + return self.identity_correction_receipt(context, id, working); + } + if number(&row, "expires")? <= now { + return Err(Error::Expired); + } + if number(&row, "revision")? != number(&tx.root, "revision")? { + return Err(Error::Conflict); + } + let input = + api::IdentityCorrection::decode(binary(&row, "query")?).map_err(|_| Error::Corrupt)?; + let inverse = deltas::Capture::decode(binary(&row, "payload")?)?; + for mapping in &input.mappings { + let (from, to) = pair(mapping)?; + let original = inverse + .rows + .get(&asset_key(from)) + .and_then(Option::as_ref) + .ok_or(Error::Corrupt)?; + let kind = number(original, "kind")? as i32; + for target in [from, to] { + if !context.readable(target, kind) { + return Err(Error::Denied); + } + for field in ["identity_correction", "relations", "remove"] { + if !context.field(target, kind, field, true) { + return Err(Error::Denied); + } + } + for choice in &mapping.fields { + if !context.field(target, kind, &choice.name, false) + || ((target == from || choice.use_duplicate) + && !context.field(target, kind, &choice.name, true)) + { + return Err(Error::Denied); + } + } + } + for (field, parent) in [ + ("identity_evidence", host_identity::parent(from)), + ( + "identity_evidence", + format!("{}/host-mappings", asset_key(from)), + ), + ( + "current_addresses", + source_layout::Family::Addresses.parent(from), + ), + ( + "network_evidence", + source_layout::Family::Network.parent(from), + ), + ] { + if inverse + .rows + .values() + .flatten() + .any(|p| p.get("parent") == Some(&text(&parent))) + { + for target in [from, to] { + if !context.field(target, kind, field, false) + || !context.field(target, kind, field, true) + { + return Err(Error::Denied); + } + } + } + } + let fence = tx + .get(&format!("removed/{}", from.asset))? + .ok_or(Error::Conflict)?; + if super::super::super::target(&fence)? != from + || string(&fence, "to_asset")? != to.asset.to_string() + || string(&fence, "to_incarnation")? != to.incarnation.to_string() + { + return Err(Error::Conflict); + } + tx.correction_guard(context, to, &api::WorkingContext::default())?; + } + for before in inverse.rows.values().flatten() { + if string(before, "parent")?.ends_with("/sockets") { + let bind = api::SocketBind::decode(binary(before, "payload")?) + .map_err(|_| Error::Corrupt)?; + let service = parse_target(bind.service.as_ref())?; + for write in [false, true] { + if !context.field( + service, + wire::AssetKind::Service as i32, + "network_evidence", + write, + ) { + return Err(Error::Denied); + } + } + } + } + for item in inverse + .rows + .keys() + .filter_map(|key| key.strip_prefix("relation/")) + { + tx.unlink_correction_relation(item)?; + } + for (key, before) in &inverse.rows { + if key.starts_with("relation/") { + continue; + } + match before { + None => tx.delete(key)?, + Some(before) => { + let parent = string(before, "parent")?; + let class = match number(before, "class")? { + 0 => Class::Inventory, + 5 => Class::Checkpoint, + _ => return Err(Error::Corrupt), + }; + let mut data = strip(before.clone()); + if parent == "assets" { + let prior = number(before, "revision")?; + let revision = match tx.get(key)? { + Some(current) => { + number(¤t, "revision")?.max(prior).checked_add(1) + } + None => prior.checked_add(2), + } + .ok_or(Error::Limit)?; + data.insert("revision".into(), int(revision)?); + } + tx.put(key, &parent, class, data)?; + } + } + } + for (key, before) in &inverse.rows { + if !key.starts_with("relation/") { + continue; + } + let Some(before) = before else { + tx.delete(key)?; + continue; + }; + let original = inventory::relation_projection(before)?; + let current = tx.get(key)?; + let expected = if let Some(current) = current { + number(¤t, "revision")? + } else { + tx.put(key, "relations", Class::Inventory, strip(before.clone()))?; + original.revision + }; + let mut restored = original.clone(); + restored.revision = expected.checked_add(1).ok_or(Error::Limit)?; + tx.save_rebound_relation(&restored, expected, &context.actor, now)?; + tx.rebind_correction_responsibility(context, &original, &restored)?; + } + // Validate the fully restored topology, including retired roots, before + // committing. A partial restoration is never exposed to another reader. + let selection = tx.selection(context, working, false)?; + for mapping in &input.mappings { + let (from, to) = pair(mapping)?; + for target in [from, to] { + tx.visible_asset(context, target)?; + selection.contains(target, &tx)?; + } + } + for (key, before) in &inverse.rows { + if key.starts_with("relation/") + && let Some(before) = before + { + tx.check_relationship( + context, + &selection, + &inventory::relation_projection(before)?, + )?; + } + } + // Reversal has its own audit event. Drop bulky inverse custody now; + // the small receipt is sufficient for an exact retry until cleanup. + let receipt = api::IdentityCorrectionApplied { + revision: number(&tx.root, "revision")? + .checked_add(1) + .ok_or(Error::Limit)?, + id: id.into(), + undo_until: 0, + retained: input + .mappings + .into_iter() + .filter_map(|v| v.duplicate) + .collect(), + }; + tx.put( + &key, + "detail/identity-corrections", + Class::Detail, + props([ + ("owner", text(&context.actor)), + ("at", int(now)?), + ("expires", int(now)?), + ("source_binding_revision", int(context.authority_revision)?), + ("retired", Value::Bool(true)), + ("payload", Value::Binary(receipt.encode_to_vec())), + ]), + )?; + let revision = tx.commit()?; + debug_assert_eq!(revision, receipt.revision); + Ok(reply(api::reply::Result::IdentityCorrected(receipt))) + } +} diff --git a/crates/omloom-store/src/disk/workspace/current_facts.rs b/crates/omloom-store/src/disk/workspace/current_facts.rs index 8ab2066..24dd994 100644 --- a/crates/omloom-store/src/disk/workspace/current_facts.rs +++ b/crates/omloom-store/src/disk/workspace/current_facts.rs @@ -17,6 +17,9 @@ pub(super) fn source_key(parent: &str, source: &str) -> String { // reattesting a value changes only its small freshness row, never its payload. impl Tx<'_> { pub(in crate::disk) fn retire_current_facts(&mut self, target: AssetRef) -> Result<()> { + for row in self.fact_rows(&super::host_identity::parent(target), 32)? { + self.delete(&string(&row, "key")?)?; + } for row in self.fact_rows( &format!("{}/host-mappings", asset_key(target)), MAX_CHILDREN, diff --git a/crates/omloom-store/src/disk/workspace/definitions.rs b/crates/omloom-store/src/disk/workspace/definitions.rs index 6d7c299..a22103b 100644 --- a/crates/omloom-store/src/disk/workspace/definitions.rs +++ b/crates/omloom-store/src/disk/workspace/definitions.rs @@ -505,6 +505,11 @@ impl Tx<'_> { return software::validate_field(kind, value); } match value.name.as_str() { + "dns_name" if kind == wire::AssetKind::Domain as i32 => match value.value.as_ref() { + Some(field::Value::Text(value)) => typed_values::dns_name(value), + Some(field::Value::Clear(EXPLICIT_CLEAR)) => Ok(()), + _ => Err(Error::Invalid), + }, "vlan_tag" => match (wire::AssetKind::try_from(kind), value.value.as_ref()) { ( Ok(wire::AssetKind::AddressReference), diff --git a/crates/omloom-store/src/disk/workspace/exchange.rs b/crates/omloom-store/src/disk/workspace/exchange.rs index 5591cde..92585ce 100644 --- a/crates/omloom-store/src/disk/workspace/exchange.rs +++ b/crates/omloom-store/src/disk/workspace/exchange.rs @@ -236,8 +236,8 @@ impl Store { Ok((external, change)) })(); match parsed { - Ok((external, change)) => rows.push((external, api::ImportRow { row: row_number, change: Some(change), diagnostic: String::new() })), - Err(error) => rows.push((String::new(), api::ImportRow { row: row_number, change: None, diagnostic: format!("Row {row_number}, {diagnostic}: {error}. Review the mapping, declared type and current scope.") })), + Ok((external, change)) => rows.push((external, api::ImportRow { row: row_number, change: Some(change), diagnostic: String::new(), addresses: None })), + Err(error) => rows.push((String::new(), api::ImportRow { row: row_number, change: None, diagnostic: format!("Row {row_number}, {diagnostic}: {error}. Review the mapping, declared type and current scope."), addresses: None })), } } if rows.is_empty() { diff --git a/crates/omloom-store/src/disk/workspace/host_identity.rs b/crates/omloom-store/src/disk/workspace/host_identity.rs new file mode 100644 index 0000000..ff56825 --- /dev/null +++ b/crates/omloom-store/src/disk/workspace/host_identity.rs @@ -0,0 +1,490 @@ +//! Current layer-specific source claims. Equality supplies comparison evidence, +//! never an identity decision, a grant or permission to retarget an operation. +use super::*; + +const MAX_CLAIMS: usize = 4; +const FIELD: &str = "identity_evidence"; +fn lookup(claim: &api::HostIdentityClaim) -> String { + format!("{}/{}/{}", claim.kind, claim.namespace, claim.digest) +} +pub(in crate::disk) fn upgrade(store: &Store) -> Result<()> { + let mut after = String::new(); + loop { + let mut tx = store.begin()?; + let rows=tx.query("MATCH (n:OmRow {class:0}) WHERE n.key>$after AND n.parent ENDS WITH '/identity-evidence' RETURN properties(n) AS p ORDER BY n.key LIMIT 128",params([("after",text(&after))]))?; + if rows.is_empty() { + tx.commit()?; + return Ok(()); + } + for old in rows { + let key = string(&old, "key")?; + let parent = string(&old, "parent")?; + let claim = api::HostIdentityClaim::decode(binary(&old, "payload")?) + .map_err(|_| Error::Corrupt)?; + let mut data = old.clone(); + operations::strip_envelope(&mut data); + data.insert("claim".into(), text(lookup(&claim))); + tx.put_projection(&key, &parent, Class::Inventory, data, Some(&old))?; + after = key; + } + tx.commit()?; + } +} + +pub(super) fn parent(target: AssetRef) -> String { + format!( + "{}/{}/identity-evidence", + asset_key(target), + target.incarnation + ) +} + +pub(super) struct Source<'a> { + pub mapping: &'a str, + pub name: &'a str, + pub observed: u64, + pub boot: &'a str, +} + +pub(super) fn validate(node: &api::HostFinding) -> Result<()> { + use api::HostIdentityKind as I; + use wire::AssetKind as K; + if node.identity.len() > MAX_CLAIMS { + return Err(Error::Limit); + } + let mut keys = BTreeSet::new(); + for claim in &node.identity { + let kind = I::try_from(claim.kind).map_err(|_| Error::Invalid)?; + let subject = K::try_from(node.kind).map_err(|_| Error::Invalid)?; + let applicable = match kind { + I::HardwareUuid => { + matches!(subject, K::Device | K::VirtualMachine) + || (subject == K::Unspecified && node.key == "hardware") + } + I::InstallationId => subject == K::SoftwareInstallation, + I::InterfaceId | I::FactoryMac => subject == K::Interface, + I::Unspecified => false, + }; + if !applicable + || claim.namespace.is_empty() + || claim.namespace.len() > 80 + || !claim + .namespace + .bytes() + .all(|v| v.is_ascii_alphanumeric() || b"/-_.".contains(&v)) + || claim.digest.len() != 64 + || !claim + .digest + .bytes() + .all(|v| v.is_ascii_digit() || (b'a'..=b'f').contains(&v)) + || !matches!( + api::HostIdentityQuality::try_from(claim.quality), + Ok(api::HostIdentityQuality::Reported + | api::HostIdentityQuality::Movable + | api::HostIdentityQuality::Unreliable) + ) + || !keys.insert((claim.kind, claim.namespace.as_str())) + { + return Err(Error::Invalid); + } + } + Ok(()) +} + +impl Tx<'_> { + pub(super) fn accept_host_identity( + &mut self, + context: &Context, + target: AssetRef, + kind: i32, + source: Source<'_>, + claims: &[api::HostIdentityClaim], + ) -> Result<()> { + // An empty old-format report supplies no statement about identity. + // It cannot erase claims collected by a newer, selected source profile. + if claims.is_empty() { + return Ok(()); + } + if !context.field(target, kind, FIELD, true) { + return Err(Error::Denied); + } + let parent = parent(target); + let rows = self.fact_rows(&parent, MAX_CLAIMS * current_facts::MAX_SOURCES)?; + let mut count = rows.len(); + for claim in claims { + let slot = format!("{}/{}/{}", source.mapping, claim.kind, claim.namespace); + let key = format!("host-identity/{}", current_facts::digest(slot.as_bytes())); + let old = self.get(&key)?; + let payload = claim.encode_to_vec(); + if let Some(old) = &old { + let observed = number(old, "observed")?; + if observed > source.observed + || (observed == source.observed && binary(old, "payload")? != payload) + { + return Err(Error::Conflict); + } + } + if old.is_none() { + if count == MAX_CLAIMS * current_facts::MAX_SOURCES { + return Err(Error::Limit); + } + count += 1; + } + let mut data = target_props(target); + data.extend(props([ + ("source", text(source.name)), + ("binding", text(source.mapping)), + ("observed", int(source.observed)?), + ("vtext", text(source.boot)), + ("payload", Value::Binary(payload)), + ("claim", text(lookup(claim))), + ])); + self.put_projection(&key, &parent, Class::Inventory, data, old.as_ref())?; + } + Ok(()) + } + + pub(super) fn host_identity( + &self, + context: &Context, + target: AssetRef, + kind: i32, + ) -> Result> { + if !context.field(target, kind, FIELD, false) { + return Ok(Vec::new()); + } + self.fact_rows(&parent(target), MAX_CLAIMS * current_facts::MAX_SOURCES)? + .into_iter() + .map(|row| { + Ok(api::HostIdentityObservation { + claim: Some( + api::HostIdentityClaim::decode(binary(&row, "payload")?) + .map_err(|_| Error::Corrupt)?, + ), + source: string(&row, "source")?, + observed_at: number(&row, "observed")?, + boot_basis: string(&row, "vtext")?, + }) + }) + .collect() + } + + fn identity_subject( + &self, + context: &Context, + target: AssetRef, + ) -> Result { + let row = self.visible_asset(context, target)?; + let kind = number(&row, "kind")? as i32; + if !matches!( + wire::AssetKind::try_from(kind), + Ok(wire::AssetKind::Device + | wire::AssetKind::VirtualMachine + | wire::AssetKind::SoftwareInstallation + | wire::AssetKind::Interface) + ) { + return Err(Error::Invalid); + } + let rows = self.query( + "MATCH (n:OmRow {parent:$parent}) WHERE n.layer='intent' RETURN properties(n) AS p LIMIT 33", + params([("parent", text(asset_key(target)))]), + )?; + if rows.len() > MAX_FIELDS { + return Err(Error::Limit); + } + let mut fields = Vec::with_capacity(rows.len()); + for row in rows { + let name = string(&row, "field")?; + if context.field(target, kind, &name, false) { + fields.push(inventory::read_field(&row)?); + } + } + Ok(api::HostIdentitySubject { + item: Some(api::Item { + target: Some(target_wire(target)), + kind, + revision: number(&row, "revision")?, + fields, + }), + identity: self.host_identity(context, target, kind)?, + sources: Vec::new(), + relations: Vec::new(), + }) + } +} + +impl Store { + pub(super) fn workspace_resolve_identity( + &self, + context: &Context, + request: api::ResolveIdentity, + working: &api::WorkingContext, + ) -> Result { + let original = parse_target(request.original.as_ref())?; + let mut current = original; + let tx = self.begin()?; + let mut seen = BTreeSet::new(); + for _ in 0..8 { + if !seen.insert(current) { + return Err(Error::Corrupt); + } + if let Some(row) = tx.get(&asset_key(current))? { + if super::super::target(&row)? != current { + return Err(Error::Conflict); + } + let mut selector = working.clone(); + for root in &mut selector.roots { + if root == &target_wire(original) { + *root = target_wire(current); + } + } + if selector.focus.as_ref() == Some(&target_wire(original)) { + selector.focus = Some(target_wire(current)); + } + tx.selection(context, &selector, false)? + .contains(current, &tx)?; + let item = tx.identity_subject(context, current)?.item; + if original != current + && !context.readable(original, item.as_ref().ok_or(Error::Corrupt)?.kind) + { + return Err(Error::Denied); + } + tx.commit()?; + return Ok(reply(api::reply::Result::ResolvedIdentity( + api::ResolvedIdentity { + original: Some(target_wire(original)), + canonical: item, + }, + ))); + } + let fence = tx + .get(&format!("removed/{}", current.asset))? + .ok_or(Error::Missing)?; + if !fence.contains_key("to_asset") { + return Err(Error::Missing); + } + if super::super::target(&fence)? != current { + return Err(Error::Conflict); + } + current = AssetRef { + asset: string(&fence, "to_asset")? + .parse() + .map_err(|_| Error::Corrupt)?, + incarnation: string(&fence, "to_incarnation")? + .parse() + .map_err(|_| Error::Corrupt)?, + }; + } + Err(Error::Limit) + } + pub(super) fn workspace_identity_candidates( + &self, + context: &Context, + request: api::FindIdentityCandidates, + working: &api::WorkingContext, + ) -> Result { + let tx = self.begin()?; + let selection = tx.selection(context, working, false)?; + let (target, kind, claims) = if let Some(reference) = &request.target { + if !request.claims.is_empty() || request.kind != 0 { + return Err(Error::Invalid); + } + let target = parse_target(Some(reference))?; + selection.contains(target, &tx)?; + let row = tx.visible_asset(context, target)?; + let kind = number(&row, "kind")? as i32; + ( + Some(target), + kind, + tx.host_identity(context, target, kind)? + .into_iter() + .filter_map(|v| v.claim) + .collect::>(), + ) + } else { + if request.claims.is_empty() + || !matches!( + wire::AssetKind::try_from(request.kind), + Ok(wire::AssetKind::Device + | wire::AssetKind::VirtualMachine + | wire::AssetKind::SoftwareInstallation + | wire::AssetKind::Interface) + ) + { + return Err(Error::Invalid); + } + validate(&api::HostFinding { + kind: request.kind, + identity: request.claims.clone(), + ..Default::default() + })?; + (None, request.kind, request.claims) + }; + if claims.is_empty() { + tx.commit()?; + return Ok(reply(api::reply::Result::IdentityCandidates( + api::IdentityCandidates { items: Vec::new() }, + ))); + } + let mut values = HashMap::from([ + ( + "claims".into(), + Value::Array(claims.iter().map(|v| text(lookup(v))).collect()), + ), + ( + "excluded".into(), + Value::Array(target.into_iter().map(|v| text(asset_key(v))).collect()), + ), + ("kind".into(), Value::Int(kind as i64)), + ( + "adopted".into(), + Value::Int(wire::Adoption::Adopted as i32 as i64), + ), + ]); + let visible = assets::visible_clause_for(context, Some(FIELD), &mut values, "a"); + let readable = assets::visible_clause_for(context, None, &mut values, "a"); + let members = if let Some(focus) = selection.focus { + let mut members = BTreeSet::from([focus]); + members.extend(tx.local_children(context, focus)?); + Some(members) + } else if !working.roots.is_empty() { + Some(tx.working_scope(context, &working.roots)?) + } else { + None + }; + let scope = if let Some(members) = members { + values.insert( + "members".into(), + Value::Array(members.into_iter().map(|t| text(asset_key(t))).collect()), + ); + "a.key IN $members" + } else { + "true" + }; + let rows=tx.query(&format!("UNWIND $claims AS claim MATCH (n:OmRow {{claim:claim}}) MATCH (a:OmRow {{key:'asset/'+n.asset}}) WHERE NOT a.key IN $excluded AND a.kind=$kind AND a.incarnation=n.incarnation AND n.parent=a.key+'/'+a.incarnation+'/identity-evidence' AND a.adoption=$adopted AND {visible} AND {readable} AND {scope} RETURN DISTINCT properties(a) AS p LIMIT 129"),Some(values))?; + if rows.len() > 128 { + return Err(Error::Limit); + } + let mut items = Vec::new(); + for row in rows { + let candidate = super::super::target(&row)?; + match selection.contains(candidate, &tx) { + Ok(()) => {} + Err(Error::Denied) => continue, + Err(error) => return Err(error), + } + let item = tx + .identity_subject(context, candidate)? + .item + .ok_or(Error::Corrupt)?; + if items.len() == 25 { + return Err(Error::Limit); + } + items.push(item); + } + tx.commit()?; + Ok(reply(api::reply::Result::IdentityCandidates( + api::IdentityCandidates { items }, + ))) + } + pub(super) fn workspace_compare_host_identity( + &self, + context: &Context, + value: api::CompareHostIdentity, + working: &api::WorkingContext, + ) -> Result { + let left = parse_target(value.left.as_ref())?; + let right = parse_target(value.right.as_ref())?; + if left == right { + return Err(Error::Invalid); + } + let tx = self.begin()?; + let revision = number(&tx.root, "revision")?; + let selection = tx.selection(context, working, false)?; + selection.contains(left, &tx)?; + selection.contains(right, &tx)?; + let mut comparison = api::HostIdentityComparison { + revision, + left: Some(tx.identity_subject(context, left)?), + right: Some(tx.identity_subject(context, right)?), + }; + if value.include_relations { + for subject in [&mut comparison.left, &mut comparison.right] + .into_iter() + .flatten() + { + let item = subject.item.as_ref().ok_or(Error::Corrupt)?; + let target = parse_target(item.target.as_ref())?; + let mut sources = BTreeSet::new(); + if context.field(target, item.kind, "identity_evidence", false) { + for row in tx.fact_rows(&format!("{}/host-mappings", asset_key(target)), 128)? { + sources.insert( + row.get("namespace") + .and_then(Value::as_str) + .unwrap_or(&string(&row, "source")?) + .to_owned(), + ); + } + } + for (family, field) in [ + ("address-sources", "current_addresses"), + ("network-sources", "network_evidence"), + ] { + if context.field(target, item.kind, field, false) { + for row in tx.fact_rows( + &format!("{}/{}/{family}", asset_key(target), target.incarnation), + 8, + )? { + sources.insert(string(&row, "source")?); + } + } + } + subject.sources = sources + .iter() + .filter(|source| { + !sources + .iter() + .any(|base| source.starts_with(&format!("{base} / "))) + }) + .cloned() + .collect(); + if context.field(target, item.kind, "relations", false) { + for relation in tx.working_relations(target)? { + let from = parse_target(relation.from.as_ref())?; + let to = parse_target(relation.to.as_ref())?; + let a = tx.asset(from)?; + let b = tx.asset(to)?; + if !context.field(from, number(&a, "kind")? as i32, "relations", false) + || !context.field(to, number(&b, "kind")? as i32, "relations", false) + || !context.readable(from, number(&a, "kind")? as i32) + || !context.readable(to, number(&b, "kind")? as i32) + { + continue; + } + let mut in_scope = true; + for endpoint in [from, to] { + match selection.contains(endpoint, &tx) { + Ok(()) => {} + Err(Error::Denied) => { + in_scope = false; + break; + } + Err(error) => return Err(error), + } + } + if in_scope { + subject.relations.push(relation); + } + } + } + } + } + tx.commit()?; + if self.usage()?.revision != revision { + return Err(Error::Expired); + } + Ok(reply(api::reply::Result::HostIdentityComparison( + comparison, + ))) + } +} diff --git a/crates/omloom-store/src/disk/workspace/host_import.rs b/crates/omloom-store/src/disk/workspace/host_import.rs index b33a20c..fe212ba 100644 --- a/crates/omloom-store/src/disk/workspace/host_import.rs +++ b/crates/omloom-store/src/disk/workspace/host_import.rs @@ -14,6 +14,14 @@ fn mapping(actor: &str, source: &str, basis: &str, key: &str) -> String { } format!("host-mapping/{:x}", hash.finalize()) } +fn network_suffix(part: i32) -> Result<&'static str> { + match api::HostNetworkPart::try_from(part).map_err(|_| Error::Invalid)? { + api::HostNetworkPart::Sockets => Ok("sockets"), + api::HostNetworkPart::Routes => Ok("routes"), + api::HostNetworkPart::ApplicationFirewall => Ok("application-fw"), + _ => Err(Error::Invalid), + } +} fn validate(report: &api::HostReport) -> Result<()> { if report.version != 1 || !matches!(report.platform.as_str(), "macos" | "linux" | "windows") @@ -34,6 +42,7 @@ fn validate(report: &api::HostReport) -> Result<()> { } let mut keys = BTreeMap::new(); for node in &report.findings { + super::host_identity::validate(node)?; if node.key.is_empty() || node.key.len() > 256 || node.key.chars().any(char::is_control) @@ -187,6 +196,12 @@ impl Tx<'_> { } } let prior = old.as_ref().map(super::super::target).transpose()?; + let projection_source = old + .as_ref() + .and_then(|row| row.get("namespace")) + .and_then(Value::as_str) + .unwrap_or(&request.source) + .to_owned(); let declared_kind = explicit.kind; if declared_kind.is_some() && (node.kind != wire::AssetKind::Unspecified as i32 || explicit.target.is_some()) @@ -311,20 +326,54 @@ impl Tx<'_> { )?; } } + let mut addresses = None; if node.kind == wire::AssetKind::Interface as i32 { if !context.field(target, node.kind, "current_addresses", true) { return Err(Error::Denied); } - self.accept_addresses(&api::InterfaceAddresses { - interface: Some(target_wire(target)), - source: request.source.clone(), - observed_at: report.observed_at, - addresses: node.addresses.clone(), - complete: node.addresses_complete, - })?; + addresses = self.accept_native_addresses( + context, + &api::InterfaceAddresses { + interface: Some(target_wire(target)), + source: projection_source.clone(), + observed_at: report.observed_at, + addresses: node.addresses.clone(), + complete: node.addresses_complete, + }, + &key, + )?; + } + if node.key == "installation" && report.network.is_some() { + // Legacy provenance must match the previous mapping window, + // before this report advances the checkpoint below. + for part in &request.network_parts { + let suffix = network_suffix(*part)?; + self.native_source( + target, + &format!("{projection_source} / {suffix}"), + &format!("{key}/{suffix}"), + &key, + source_layout::Family::Network, + )?; + } } + self.accept_host_identity( + context, + target, + kind, + super::host_identity::Source { + mapping: &key, + name: &projection_source, + observed: report.observed_at, + boot: &report.boot_basis, + }, + &node.identity, + )?; let mut data = target_props(target); data.insert("source".into(), text(&request.source)); + if projection_source != request.source { + data.insert("namespace".into(), text(&projection_source)); + } // One current checkpoint fences stale replay and same-instant // equivocation; it does not retain another report or field history. data.insert("observed".into(), int(report.observed_at)?); @@ -341,6 +390,7 @@ impl Tx<'_> { rows.push(api::ImportRow { row: rows.len() as u32 + 1, change: Some(change), + addresses, diagnostic: if keep_fields { "Keep previously accepted identity and local fields" } else if existing.is_some() { @@ -354,6 +404,18 @@ impl Tx<'_> { if let Some(installation) = resolved.get("installation") && let Some(source) = &report.network { + let metadata = self + .get(&mapping( + &context.actor, + &request.source, + &report.source_basis, + "installation", + ))? + .ok_or(Error::Corrupt)?; + let projection_source = metadata + .get("namespace") + .and_then(Value::as_str) + .unwrap_or(&request.source); let mut network = source.clone(); network.installation = Some(target_wire(*installation)); network.observed_at = report.observed_at; @@ -397,13 +459,8 @@ impl Tx<'_> { for part in &request.network_parts { let mut selected = network.clone(); let part = api::HostNetworkPart::try_from(*part).map_err(|_| Error::Invalid)?; - let suffix = match part { - api::HostNetworkPart::Sockets => "sockets", - api::HostNetworkPart::Routes => "routes", - api::HostNetworkPart::ApplicationFirewall => "application-fw", - _ => return Err(Error::Invalid), - }; - selected.source = format!("{} / {suffix}", request.source); + let suffix = network_suffix(part as i32)?; + selected.source = format!("{projection_source} / {suffix}"); if part != api::HostNetworkPart::Sockets { selected.sockets.clear(); selected.sockets_complete = false; @@ -427,7 +484,13 @@ impl Tx<'_> { "Not selected for this projection source".into(); } network_facts::validate(&selected)?; - self.accept_network(&selected)?; + let base = mapping( + &context.actor, + &request.source, + &report.source_basis, + "installation", + ); + self.accept_native_network(&selected, &format!("{base}/{suffix}"))?; } } Ok(rows) @@ -565,8 +628,14 @@ impl Store { && number(&row, "revision")? == base && number(&row, "expires")? > now { - let preview = - api::Preview::decode(binary(&row, "query")?).map_err(|_| Error::Corrupt)?; + // Reuse custody, not an old disclosure decision. The dry run + // above derived these rows under the caller's current rights. + let key = string(&row, "key")?; + let preview = api::Preview { + id: Some(key.strip_prefix("preview/").ok_or(Error::Corrupt)?.into()), + revision: base, + rows, + }; return Ok(reply(api::reply::Result::Preview(preview))); } let key = string(&row, "key")?; @@ -598,7 +667,6 @@ impl Store { ("working", Value::Binary(selector.encode_to_vec())), ("digest", Value::Binary(fingerprint.into_bytes())), ("payload", Value::Binary(bytes)), - ("query", Value::Binary(preview.encode_to_vec())), ]), )?; tx.commit()?; diff --git a/crates/omloom-store/src/disk/workspace/interface_facts.rs b/crates/omloom-store/src/disk/workspace/interface_facts.rs index e30a244..442582c 100644 --- a/crates/omloom-store/src/disk/workspace/interface_facts.rs +++ b/crates/omloom-store/src/disk/workspace/interface_facts.rs @@ -42,10 +42,89 @@ pub(super) fn validate_address(value: &api::CurrentAddress) -> Result<()> { } impl Tx<'_> { + pub(super) fn native_address_delta( + &self, + context: &Context, + value: &api::InterfaceAddresses, + binding: &str, + ) -> Result> { + self.address_delta_owned( + context, + parse_target(value.interface.as_ref())?, + &value.source, + &value.addresses, + value.complete, + Some(binding), + ) + } + fn address_delta_owned( + &self, + context: &Context, + target: AssetRef, + source: &str, + incoming: &[api::CurrentAddress], + complete: bool, + binding: Option<&str>, + ) -> Result> { + if !context.field(target, wire::AssetKind::Interface as i32, FIELD, false) { + return Ok(None); + } + let key = source_layout::Family::Addresses.key(target, source, binding); + let mut prior = BTreeMap::new(); + for row in self.fact_rows(&format!("{key}/values"), LIMIT)? { + let value = api::CurrentAddress::decode(binary(&row, "payload")?) + .map_err(|_| Error::Corrupt)?; + prior.insert(value.address.clone(), value); + } + let mut delta = api::AddressDelta { + source: source.into(), + complete, + ..Default::default() + }; + for value in incoming { + match prior.remove(&value.address) { + Some(old) if old != *value => delta.updated.push(api::AddressAttributeChange { + before: Some(old), + after: Some(value.clone()), + }), + Some(_) => {} + None => delta.added.push(value.clone()), + } + } + if complete { + delta.removed.extend(prior.into_values()); + } + Ok(Some(delta)) + } + pub(super) fn accept_addresses(&mut self, value: &api::InterfaceAddresses) -> Result<()> { + self.accept_addresses_owned(value, None) + } + pub(super) fn accept_native_addresses( + &mut self, + context: &Context, + value: &api::InterfaceAddresses, + binding: &str, + ) -> Result> { + self.native_source( + parse_target(value.interface.as_ref())?, + &value.source, + binding, + binding, + source_layout::Family::Addresses, + )?; + let delta = self.native_address_delta(context, value, binding)?; + self.accept_addresses_owned(value, Some(binding))?; + Ok(delta) + } + fn accept_addresses_owned( + &mut self, + value: &api::InterfaceAddresses, + binding: Option<&str>, + ) -> Result<()> { let target = parse_target(value.interface.as_ref())?; let parent = source_parent(target); - let key = source_key(target, &value.source); + let key = source_layout::Family::Addresses.key(target, &value.source, binding); let sources = self.fact_rows(&parent, current_facts::MAX_SOURCES)?; let old = self.get(&key)?; if old.is_none() && sources.len() >= current_facts::MAX_SOURCES { @@ -102,20 +181,18 @@ impl Tx<'_> { if other + count as u64 > LIMIT as u64 { return Err(Error::Limit); } - self.put_projection( - &key, - &parent, - Class::Inventory, - props([ - ("layer", text("address-source")), - ("source", text(&value.source)), - ("observed", int(value.observed_at)?), - ("vflag", Value::Bool(value.complete)), - ("count", int(count as u64)?), - ("digest", Value::Binary(digest)), - ]), - old.as_ref(), - )?; + let mut metadata = props([ + ("layer", text("address-source")), + ("source", text(&value.source)), + ("observed", int(value.observed_at)?), + ("vflag", Value::Bool(value.complete)), + ("count", int(count as u64)?), + ("digest", Value::Binary(digest)), + ]); + if let Some(binding) = binding { + metadata.insert("binding".into(), text(binding)); + } + self.put_projection(&key, &parent, Class::Inventory, metadata, old.as_ref())?; Ok(()) } @@ -154,6 +231,9 @@ impl Tx<'_> { .cmp(&b.value.as_ref().map(|a| &a.address)) }); result.push(api::InterfaceAddressView { + source_id: string(&meta, "key")?, + binding_unresolved: meta.get("source_revision") + == Some(&text("unresolved-native-binding")), coverage: Some(api::InterfaceAddresses { interface: Some(target_wire(target)), source: string(&meta, "source")?, diff --git a/crates/omloom-store/src/disk/workspace/network_facts.rs b/crates/omloom-store/src/disk/workspace/network_facts.rs index afa985c..21518e1 100644 --- a/crates/omloom-store/src/disk/workspace/network_facts.rs +++ b/crates/omloom-store/src/disk/workspace/network_facts.rs @@ -88,7 +88,7 @@ pub(super) fn validate(value: &api::HostNetwork) -> Result<()> { Ok(()) } -fn sections(value: &api::HostNetwork) -> Result<[BTreeMap; 4]> { +pub(super) fn sections(value: &api::HostNetwork) -> Result<[BTreeMap; 4]> { let mut sections: [BTreeMap; 4] = Default::default(); for s in &value.sockets { let identity = format!( @@ -176,9 +176,51 @@ pub(super) fn socket_layer(service: AssetRef) -> String { service.incarnation ) } + +pub(super) fn source_digest( + coverage: &api::HostNetwork, + sections: &[BTreeMap; 4], +) -> Result> { + let mut hash = Sha256::new(); + hash.update(b"omloom/network-evidence/1"); + for section in sections { + hash.update((section.len() as u32).to_le_bytes()); + for (id, data) in section { + hash.update((id.len() as u32).to_le_bytes()); + hash.update(id.as_bytes()); + let payload = binary(data, "payload")?; + hash.update((payload.len() as u32).to_le_bytes()); + hash.update(payload); + } + } + hash.update(coverage.encode_to_vec()); + Ok(hash.finalize().to_vec()) +} +pub(super) fn coverage(value: &api::HostNetwork) -> api::HostNetwork { + api::HostNetwork { + installation: value.installation.clone(), + source: value.source.clone(), + observed_at: value.observed_at, + basis: value.basis.clone(), + socket_coverage: value.socket_coverage.clone(), + route_coverage: value.route_coverage.clone(), + application_coverage: value.application_coverage.clone(), + packet_filter_coverage: value.packet_filter_coverage.clone(), + firewall_enabled: value.firewall_enabled, + block_all: value.block_all, + allow_builtin_signed: value.allow_builtin_signed, + allow_downloaded_signed: value.allow_downloaded_signed, + sockets_complete: value.sockets_complete, + routes_complete: value.routes_complete, + rules_complete: value.rules_complete, + ..Default::default() + } +} const PARTS: [&str; 4] = ["sockets", "routes", "rules", "policies"]; struct NetworkSource { + id: String, + binding_unresolved: bool, coverage: Option, observations: Vec, // None marks a known label already moved into the output page. @@ -239,6 +281,8 @@ impl Tx<'_> { .iter_mut() .map(|source| { let view = api::HostNetworkView { + source_id: source.id.clone(), + binding_unresolved: source.binding_unresolved, coverage: source.coverage.take(), ..Default::default() }; @@ -354,47 +398,31 @@ impl Tx<'_> { Ok(labels) } pub(super) fn accept_network(&mut self, value: &api::HostNetwork) -> Result<()> { + self.accept_network_owned(value, None) + } + pub(super) fn accept_native_network( + &mut self, + value: &api::HostNetwork, + binding: &str, + ) -> Result<()> { + self.accept_network_owned(value, Some(binding)) + } + fn accept_network_owned( + &mut self, + value: &api::HostNetwork, + binding: Option<&str>, + ) -> Result<()> { let target = parse_target(value.installation.as_ref())?; let parent = source_parent(target); - let key = current_facts::source_key(&parent, &value.source); + let key = source_layout::Family::Network.key(target, &value.source, binding); let sources = self.fact_rows(&parent, current_facts::MAX_SOURCES)?; let old = self.get(&key)?; if old.is_none() && sources.len() >= current_facts::MAX_SOURCES { return Err(Error::Limit); } let sections = sections(value)?; - let mut hash = Sha256::new(); - hash.update(b"omloom/network-evidence/1"); - let mut coverage = api::HostNetwork { - installation: value.installation.clone(), - source: value.source.clone(), - observed_at: value.observed_at, - basis: value.basis.clone(), - socket_coverage: value.socket_coverage.clone(), - route_coverage: value.route_coverage.clone(), - application_coverage: value.application_coverage.clone(), - packet_filter_coverage: value.packet_filter_coverage.clone(), - firewall_enabled: value.firewall_enabled, - block_all: value.block_all, - allow_builtin_signed: value.allow_builtin_signed, - allow_downloaded_signed: value.allow_downloaded_signed, - sockets_complete: value.sockets_complete, - routes_complete: value.routes_complete, - rules_complete: value.rules_complete, - ..Default::default() - }; - for section in §ions { - hash.update((section.len() as u32).to_le_bytes()); - for (id, props) in section { - hash.update((id.len() as u32).to_le_bytes()); - hash.update(id.as_bytes()); - let payload = binary(props, "payload")?; - hash.update((payload.len() as u32).to_le_bytes()); - hash.update(payload); - } - } - hash.update(coverage.encode_to_vec()); - let digest = hash.finalize().to_vec(); + let mut coverage = coverage(value); + let digest = source_digest(&coverage, §ions)?; if let Some(old) = &old { let observed = number(old, "observed")?; if observed > value.observed_at @@ -446,20 +474,18 @@ impl Tx<'_> { if count > current_facts::MAX_FACTS || bytes > current_facts::MAX_BYTES { return Err(Error::Limit); } - self.put_projection( - &key, - &parent, - Class::Inventory, - props([ - ("layer", text("network-source")), - ("source", text(&value.source)), - ("observed", int(value.observed_at)?), - ("count", int(own_count as u64)?), - ("bytes", int(own_bytes as u64)?), - ("digest", Value::Binary(digest)), - ]), - old.as_ref(), - )?; + let mut metadata = props([ + ("layer", text("network-source")), + ("source", text(&value.source)), + ("observed", int(value.observed_at)?), + ("count", int(own_count as u64)?), + ("bytes", int(own_bytes as u64)?), + ("digest", Value::Binary(digest)), + ]); + if let Some(binding) = binding { + metadata.insert("binding".into(), text(binding)); + } + self.put_projection(&key, &parent, Class::Inventory, metadata, old.as_ref())?; Ok(()) } @@ -580,6 +606,9 @@ impl Tx<'_> { coverage.packet_filter_coverage.clear(); } result.push(NetworkSource { + id: key, + binding_unresolved: source.get("source_revision") + == Some(&text("unresolved-native-binding")), coverage: Some(coverage), observations, services, diff --git a/crates/omloom-store/src/disk/workspace/responsibility.rs b/crates/omloom-store/src/disk/workspace/responsibility.rs index 71e445a..a0426a8 100644 --- a/crates/omloom-store/src/disk/workspace/responsibility.rs +++ b/crates/omloom-store/src/disk/workspace/responsibility.rs @@ -210,8 +210,13 @@ fn assignment_props(value: &api::ResponsibilityAssignment) -> Result _ => return Err(Error::Invalid), } if let Some(contact) = &value.contact { - bounded_value(contact, 512, true)?; + typed_values::locator(value.contact_kind, contact)?; row.insert("contact".into(), text(contact)); + if value.contact_kind != api::LocatorKind::Unspecified as i32 { + row.insert("contact_kind".into(), int(value.contact_kind as u64)?); + } + } else if value.contact_kind != api::LocatorKind::Unspecified as i32 { + return Err(Error::Invalid); } Ok(row) } @@ -242,6 +247,7 @@ fn read_assignment(row: &Properties) -> Result { .get("contact") .and_then(Value::as_str) .map(str::to_owned), + contact_kind: typed_values::read_kind(row, "contact_kind")?, }) } @@ -466,7 +472,9 @@ impl Tx<'_> { { return Err(Error::Denied); } - if old.as_ref().and_then(|row| row.get("contact")) != data.get("contact") + if (old.as_ref().and_then(|row| row.get("contact")) != data.get("contact") + || old.as_ref().and_then(|row| row.get("contact_kind")) + != data.get("contact_kind")) && !context.field(target, kind, &format!("{field}.contact"), true) { return Err(Error::Denied); @@ -485,6 +493,45 @@ impl Tx<'_> { } Ok(kind) } + pub(super) fn placement_inheritance( + &self, + context: &Context, + target: AssetRef, + relation: &wire::Relation, + ) -> Result> { + let mut mask = 0; + for facet in [Facet::ClaimedOwner, Facet::Operator] { + if context.field( + target, + wire::AssetKind::VirtualMachine as i32, + field_name(facet)?, + false, + ) { + mask |= 1 << (facet as i32 - 1); + } + } + let graph = graph::Graph::load(self, [(target, mask)].into())?; + let mut affected = Vec::new(); + for facet in [Facet::ClaimedOwner, Facet::Operator] { + if mask & (1 << (facet as i32 - 1)) != 0 + && let Some(assignment) = graph.assignment(target, facet) + && assignment.mode == Mode::Inherit as i32 + && assignment.source_relation.as_deref() == Some(relation.relation_id.as_str()) + && assignment.source_revision == Some(relation.revision) + { + let mut value = graph.resolve(context, target, facet)?; + // Placement review needs the affected party, not its contact + // route or independently addressable source metadata. + value.contact = None; + value.contact_kind = api::LocatorKind::Unspecified as i32; + value.source = None; + value.source_revision = None; + affected.push(value); + } + } + Ok(affected) + } + fn responsibility_state( &self, context: &Context, @@ -521,6 +568,7 @@ impl Tx<'_> { let mut declaration = local.clone(); if !context.field(target, kind, &format!("{field}.contact"), false) { declaration.contact = None; + declaration.contact_kind = api::LocatorKind::Unspecified as i32; } if let Some(id) = &declaration.source_relation && (!context.field(target, kind, "relations", false) diff --git a/crates/omloom-store/src/disk/workspace/responsibility/graph.rs b/crates/omloom-store/src/disk/workspace/responsibility/graph.rs index fc9405a..02601d5 100644 --- a/crates/omloom-store/src/disk/workspace/responsibility/graph.rs +++ b/crates/omloom-store/src/disk/workspace/responsibility/graph.rs @@ -85,7 +85,7 @@ impl Graph { ]) }; let query = if DETAILS { - "UNWIND $keys AS key MATCH (n:OmRow {parent:key}) WHERE n.facet IS NOT NULL RETURN {parent:n.parent,facet:n.facet,mode:n.mode,issuer:n.issuer,subject:n.subject,party_kind:n.party_kind,name:n.name,contact:n.contact,source_relation:n.source_relation,source_binding_revision:n.source_binding_revision} AS p LIMIT $limit" + "UNWIND $keys AS key MATCH (n:OmRow {parent:key}) WHERE n.facet IS NOT NULL RETURN {parent:n.parent,facet:n.facet,mode:n.mode,issuer:n.issuer,subject:n.subject,party_kind:n.party_kind,name:n.name,contact:n.contact,contact_kind:n.contact_kind,source_relation:n.source_relation,source_binding_revision:n.source_binding_revision} AS p LIMIT $limit" } else { "UNWIND $keys AS key MATCH (n:OmRow {parent:key}) WHERE n.facet=$facet RETURN {parent:n.parent,facet:n.facet,mode:n.mode,issuer:n.issuer,subject:n.subject,party_kind:n.party_kind,name:n.name,source_relation:n.source_relation,source_binding_revision:n.source_binding_revision} AS p LIMIT $limit" }; @@ -355,6 +355,7 @@ impl Graph { result.party = assignment.party.clone(); if contact_visible { result.contact = assignment.contact.clone(); + result.contact_kind = assignment.contact_kind; } if DETAILS && (current == target || source_visible) diff --git a/crates/omloom-store/src/disk/workspace/software.rs b/crates/omloom-store/src/disk/workspace/software.rs index 3e145e7..00237a0 100644 --- a/crates/omloom-store/src/disk/workspace/software.rs +++ b/crates/omloom-store/src/disk/workspace/software.rs @@ -288,7 +288,8 @@ impl Tx<'_> { ) -> Result<()> { use wire::RelationKind as R; if !matches!(kind, R::ReleaseOf | R::InstalledRelease) - && !(kind == R::RunsOn && matches!(from_kind, K::Container | K::Workload)) + && !(kind == R::RunsOn + && matches!(from_kind, K::Container | K::Workload | K::VirtualMachine)) { return Ok(()); } @@ -307,6 +308,7 @@ impl Tx<'_> { from_kind: K, to: AssetRef, kind: wire::RelationKind, + rebound: bool, ) -> Result<()> { use wire::RelationKind as R; let old_kind = R::try_from(number(old, "kind")? as i32).map_err(|_| Error::Corrupt)?; @@ -314,12 +316,21 @@ impl Tx<'_> { return Ok(()); } let old_from = stored_reference(old, "asset", "incarnation")?; - let container = old_kind == R::RunsOn - && if old_from == from { - from_kind == K::Container - } else { - number(&self.asset(old_from)?, "kind")? == K::Container as u64 - }; + let old_from_kind = if old_from == from { + from_kind + } else { + K::try_from(number(&self.asset(old_from)?, "kind")? as i32) + .map_err(|_| Error::Corrupt)? + }; + if old_kind == R::RunsOn && old_from_kind == K::VirtualMachine { + if !rebound && (from != old_from || kind != old_kind) { + return Err(Error::Conflict); + } + if from != old_from || kind != old_kind { + self.require_no_pending_operations(old_from)?; + } + } + let container = old_kind == R::RunsOn && old_from_kind == K::Container; if matches!(old_kind, R::ReleaseOf | R::InstalledRelease) || container { if from != old_from || kind != old_kind { return Err(Error::Conflict); diff --git a/crates/omloom-store/src/disk/workspace/source_layout.rs b/crates/omloom-store/src/disk/workspace/source_layout.rs new file mode 100644 index 0000000..970b99b --- /dev/null +++ b/crates/omloom-store/src/disk/workspace/source_layout.rs @@ -0,0 +1,331 @@ +//! Source identity is a qualified binding, independent of its display label. +//! Relocation preserves individual observation windows and current-only state. +use super::*; +#[cfg(test)] +mod tests; + +#[derive(Clone, Copy, PartialEq, Eq)] +pub(super) enum Family { + Addresses, + Network, +} +impl Family { + pub fn name(self) -> &'static str { + match self { + Self::Addresses => "address-sources", + Self::Network => "network-sources", + } + } + fn parts(self) -> &'static [&'static str] { + match self { + Self::Addresses => &["values"], + Self::Network => &["sockets", "routes", "rules", "policies"], + } + } + pub fn parent(self, target: AssetRef) -> String { + format!( + "{}/{}/{}", + asset_key(target), + target.incarnation, + self.name() + ) + } + pub fn key(self, target: AssetRef, name: &str, binding: Option<&str>) -> String { + match binding { + Some(binding) => current_facts::source_key(&format!("native/{}", self.name()), binding), + None => current_facts::source_key(&self.parent(target), name), + } + } +} +pub(super) struct Relocation<'a> { + pub from: AssetRef, + pub to: AssetRef, + pub old: &'a str, + pub new: &'a str, + pub name: &'a str, + pub binding: Option<&'a str>, + pub family: Family, +} +fn data(mut row: Properties) -> Properties { + operations::strip_envelope(&mut row); + row +} + +impl Tx<'_> { + pub(super) fn native_source( + &mut self, + target: AssetRef, + name: &str, + binding: &str, + base_binding: &str, + family: Family, + ) -> Result { + let key = family.key(target, name, Some(binding)); + if let Some(current) = self.get(&key)? { + if string(¤t, "parent")? != family.parent(target) + || current.get("binding") != Some(&text(binding)) + { + return Err(Error::Conflict); + } + return Ok(key); + } + let legacy = family.key(target, name, None); + let Some(old) = self.get(&legacy)? else { + return Ok(key); + }; + let suffix = if family == Family::Network { + Some( + binding + .strip_prefix(&format!("{base_binding}/")) + .ok_or(Error::Invalid)?, + ) + } else { + None + }; + let mappings = self + .fact_rows(&format!("{}/host-mappings", asset_key(target)), 128)? + .into_iter() + .filter(|p| { + p.get("namespace") + .or_else(|| p.get("source")) + .and_then(Value::as_str) + .is_some_and(|label| match suffix { + Some(suffix) => name == format!("{label} / {suffix}"), + None => name == label, + }) + }) + .collect::>(); + if mappings.len() == 1 + && string(&mappings[0], "key")? == base_binding + && number(&mappings[0], "observed")? == number(&old, "observed")? + { + self.relocate_source( + None, + Relocation { + from: target, + to: target, + old: &legacy, + new: &key, + name, + binding: Some(binding), + family, + }, + )?; + } else if !mappings.is_empty() { + let mut marked = data(old); + marked.insert("source_revision".into(), text("unresolved-native-binding")); + self.put(&legacy, &family.parent(target), Class::Inventory, marked)?; + } + Ok(key) + } + pub(super) fn relocate_source( + &mut self, + context: Option<&Context>, + move_: Relocation<'_>, + ) -> Result<()> { + let Relocation { + from, + to, + old, + new, + name, + binding, + family, + } = move_; + let source = self.get(old)?.ok_or(Error::Missing)?; + if string(&source, "parent")? != family.parent(from) { + return Err(Error::Conflict); + } + if old != new && self.get(new)?.is_some() { + return Err(Error::Conflict); + } + if context.is_none() && (from != to || name != string(&source, "source")?) { + return Err(Error::Invalid); + } + let observed = number(&source, "observed")?; + let mut input = api::HostNetwork::default(); + if family == Family::Network { + let body = self + .get(&format!("{old}/coverage"))? + .ok_or(Error::Corrupt)?; + input = + api::HostNetwork::decode(binary(&body, "payload")?).map_err(|_| Error::Corrupt)?; + input.installation = Some(target_wire(to)); + input.source = name.into(); + input.observed_at = observed; + let mut coverage = network_facts::coverage(&input); + coverage.observed_at = 0; + self.put( + &format!("{new}/coverage"), + new, + Class::Inventory, + props([("payload", Value::Binary(coverage.encode_to_vec()))]), + )?; + if old != new { + self.delete(&format!("{old}/coverage"))?; + } + } + let mut addresses = Vec::new(); + for (index, part) in family.parts().iter().enumerate() { + let parent = format!("{new}/{part}"); + for mut row in self.fact_rows( + &format!("{old}/{part}"), + if family == Family::Addresses { + 64 + } else { + current_facts::MAX_FACTS + }, + )? { + let key = string(&row, "key")?; + let window = self + .get(&format!("{key}/freshness"))? + .ok_or(Error::Corrupt)?; + let latest = window.get("observed") == Some(&int(observed)?); + let identity = if family == Family::Addresses { + let value = api::CurrentAddress::decode(binary(&row, "payload")?) + .map_err(|_| Error::Corrupt)?; + let id = value.address.clone(); + if latest { + addresses.push(value); + } + id + } else { + let mut one = api::HostNetwork { + installation: Some(target_wire(to)), + ..Default::default() + }; + match index { + 0 => { + let value = api::SocketBind::decode(binary(&row, "payload")?) + .map_err(|_| Error::Corrupt)?; + if let Some(context) = context { + let service = parse_target(value.service.as_ref())?; + if !context.field( + service, + wire::AssetKind::Service as i32, + "network_evidence", + false, + ) || !context.field( + service, + wire::AssetKind::Service as i32, + "network_evidence", + true, + ) { + return Err(Error::Denied); + } + } + one.sockets.push(value.clone()); + if latest { + input.sockets.push(value); + } + row.extend(target_props(to)); + } + 1 => { + let value = api::LocalRoute::decode(binary(&row, "payload")?) + .map_err(|_| Error::Corrupt)?; + one.routes.push(value.clone()); + if latest { + input.routes.push(value); + } + } + 2 => { + let value = api::ApplicationRule::decode(binary(&row, "payload")?) + .map_err(|_| Error::Corrupt)?; + one.rules.push(value.clone()); + if latest { + input.rules.push(value); + } + } + 3 => { + let value = api::FirewallSetting::decode(binary(&row, "payload")?) + .map_err(|_| Error::Corrupt)?; + match api::firewall_setting::Kind::try_from(value.kind) { + Ok(api::firewall_setting::Kind::Enabled) => { + one.firewall_enabled = Some(value.enabled); + if latest { + input.firewall_enabled = Some(value.enabled); + } + } + Ok(api::firewall_setting::Kind::BlockAll) => { + one.block_all = Some(value.enabled); + if latest { + input.block_all = Some(value.enabled); + } + } + Ok(api::firewall_setting::Kind::AllowBuiltinSigned) => { + one.allow_builtin_signed = Some(value.enabled); + if latest { + input.allow_builtin_signed = Some(value.enabled); + } + } + Ok(api::firewall_setting::Kind::AllowDownloadedSigned) => { + one.allow_downloaded_signed = Some(value.enabled); + if latest { + input.allow_downloaded_signed = Some(value.enabled); + } + } + _ => return Err(Error::Corrupt), + } + } + _ => return Err(Error::Corrupt), + } + network_facts::sections(&one)?[index] + .keys() + .next() + .cloned() + .ok_or(Error::Corrupt)? + }; + let next = format!( + "fact/{}", + current_facts::digest(format!("{parent}/{identity}").as_bytes()) + ); + row = data(row); + if family == Family::Addresses { + row.insert("owner".into(), text(asset_key(to))); + row.insert("layer".into(), text(interface_facts::address_layer(to))); + } + self.put(&next, &parent, Class::Inventory, row)?; + self.put( + &format!("{next}/freshness"), + &next, + Class::Inventory, + data(window), + )?; + if next != key { + self.delete(&format!("{key}/freshness"))?; + self.delete(&key)?; + } + } + } + let mut source = data(source); + source.insert("source".into(), text(name)); + if let Some(binding) = binding { + source.insert("binding".into(), text(binding)); + } + let digest = if family == Family::Addresses { + addresses.sort_by(|a, b| a.address.cmp(&b.address)); + Sha256::digest( + api::InterfaceAddresses { + interface: Some(target_wire(to)), + source: name.into(), + observed_at: observed, + addresses, + complete: flag(&source, "vflag")?, + } + .encode_to_vec(), + ) + .to_vec() + } else { + network_facts::source_digest( + &network_facts::coverage(&input), + &network_facts::sections(&input)?, + )? + }; + source.insert("digest".into(), Value::Binary(digest)); + self.put(new, &family.parent(to), Class::Inventory, source)?; + if old != new { + self.delete(old)?; + } + Ok(()) + } +} diff --git a/crates/omloom-store/src/disk/workspace/source_layout/tests.rs b/crates/omloom-store/src/disk/workspace/source_layout/tests.rs new file mode 100644 index 0000000..05726b2 --- /dev/null +++ b/crates/omloom-store/src/disk/workspace/source_layout/tests.rs @@ -0,0 +1,270 @@ +use super::*; +type TestResult = std::result::Result<(), Box>; + +// An upgraded native import must qualify the previous source window before +// advancing its mapping, preserving omitted facts without a second source slot. +#[test] +fn newer_host_import_qualifies_legacy_network_before_checkpoint() -> TestResult { + let dir = tempfile::tempdir()?; + let store = Store::open(dir.path(), policy())?; + let context = Context { + actor: "test".into(), + fixture: "operator".into(), + grants: vec![Grant { + scope: Scope::All, + read: Fields::All, + write: Fields::All, + writable: true, + }], + teams: Vec::new(), + recipients: Vec::new(), + schema_admin: false, + authority_revision: 1, + }; + let mut request = api::HostImport { + source: "Collector".into(), + report: Some(Box::new(api::HostReport { + version: 1, + platform: "linux".into(), + source_basis: "a".repeat(64), + boot_basis: "b".repeat(64), + observed_at: 100, + findings: vec![api::HostFinding { + key: "installation".into(), + kind: wire::AssetKind::SoftwareInstallation as i32, + fields: vec![wire::Field { + name: "name".into(), + value: Some(wire::field::Value::Text("Synthetic OS".into())), + }], + ..Default::default() + }], + network: Some(api::HostNetwork { + route_coverage: "verified".into(), + routes_complete: true, + routes: vec![api::LocalRoute { + destination: "192.0.2.0/24".into(), + interface: "eth0".into(), + family: "IPv4".into(), + ..Default::default() + }], + ..Default::default() + }), + ..Default::default() + })), + selection: vec![api::HostSelection { + key: "installation".into(), + ..Default::default() + }], + network_parts: vec![api::HostNetworkPart::Routes as i32], + ..Default::default() + }; + let import = |request: api::HostImport| -> Result { + let working = api::WorkingContext { + expected: Some(store.usage()?.revision), + ..Default::default() + }; + let preview = store.workspace_host_import(&context, request, &working, 100)?; + let Some(api::reply::Result::Preview(preview)) = preview.result else { + return Err(Error::Corrupt); + }; + store.workspace_host_import( + &context, + api::HostImport { + preview: preview.id, + ..Default::default() + }, + &api::WorkingContext { + expected: Some(store.usage()?.revision), + ..working + }, + 100, + ) + }; + import(request.clone())?; + let mut tx = store.begin()?; + let mappings = tx.query("MATCH (n:OmRow {source:'Collector'}) WHERE n.key STARTS WITH 'host-mapping/' RETURN properties(n) AS p", None)?; + assert_eq!(mappings.len(), 1); + let target = super::super::super::target(&mappings[0])?; + let name = "Collector / routes"; + let binding = format!("{}/routes", string(&mappings[0], "key")?); + let native = Family::Network.key(target, name, Some(&binding)); + let legacy = Family::Network.key(target, name, None); + tx.relocate_source( + None, + Relocation { + from: target, + to: target, + old: &native, + new: &legacy, + name, + binding: None, + family: Family::Network, + }, + )?; + let mut row = data(tx.get(&legacy)?.ok_or(Error::Missing)?); + row.remove("binding"); + tx.put( + &legacy, + &Family::Network.parent(target), + Class::Inventory, + row, + )?; + tx.commit()?; + let report = request.report.as_mut().ok_or(Error::Invalid)?; + report.observed_at = 200; + let network = report.network.as_mut().ok_or(Error::Invalid)?; + network.routes_complete = false; + network.routes[0].destination = "198.51.100.0/24".into(); + import(request)?; + let tx = store.begin()?; + assert!( + tx.get(&legacy)?.is_none(), + "proven legacy source must be relocated" + ); + assert_eq!(tx.fact_rows(&Family::Network.parent(target), 8)?.len(), 1); + let facts = tx.fact_rows(&format!("{native}/routes"), 64)?; + assert_eq!(facts.len(), 2); + let mut windows = Vec::new(); + for fact in facts { + let freshness = tx + .get(&format!("{}/freshness", string(&fact, "key")?))? + .ok_or(Error::Corrupt)?; + windows.push(number(&freshness, "observed")?); + } + windows.sort(); + assert_eq!(windows, vec![100, 200]); + Ok(()) +} +fn policy() -> Policy { + Policy { + classes: [crate::Budget { + rows: 10000, + bytes: 16 * 1024 * 1024, + }; CLASSES], + total_bytes: 128 * 1024 * 1024, + detail_age_ms: 1000, + review_age_ms: 500, + review_per_source: crate::Budget { + rows: 512, + bytes: 1024 * 1024, + }, + batch_rows: 128, + } +} + +// Qualifying a proven legacy binding preserves older omitted facts and their +// collection windows. Repeating the same partial input must remain a no-op. +#[test] +fn legacy_source_qualification_preserves_partial_windows_and_replay() -> TestResult { + let dir = tempfile::tempdir()?; + let store = Store::open(dir.path(), policy())?; + let target = new_target(); + let mut value = api::InterfaceAddresses { + interface: Some(target_wire(target)), + source: "Collector".into(), + observed_at: 100, + addresses: vec![api::CurrentAddress { + address: "192.0.2.1".into(), + stability: api::AddressStability::Stable as i32, + state: api::AddressState::Preferred as i32, + ..Default::default() + }], + complete: true, + }; + let binding = "host-mapping/proof"; + let mut tx = store.begin()?; + tx.accept_addresses(&value)?; + value.observed_at = 150; + value.complete = false; + value.addresses[0].address = "192.0.2.2".into(); + tx.accept_addresses(&value)?; + tx.put( + binding, + &format!("{}/host-mappings", asset_key(target)), + Class::Inventory, + props([("source", text(&value.source)), ("observed", int(150)?)]), + )?; + tx.commit()?; + let mut tx = store.begin()?; + let old = Family::Addresses.key(target, &value.source, None); + let key = tx.native_source(target, &value.source, binding, binding, Family::Addresses)?; + assert!(tx.get(&old)?.is_none()); + let facts = tx.fact_rows(&format!("{key}/values"), 64)?; + assert_eq!(facts.len(), 2); + let mut windows = Vec::new(); + for fact in facts { + let row = tx + .get(&format!("{}/freshness", string(&fact, "key")?))? + .ok_or(Error::Corrupt)?; + windows.push(number(&row, "observed")?); + } + windows.sort(); + assert_eq!(windows, vec![100, 150]); + tx.commit()?; + let context = Context { + actor: "test".into(), + fixture: "operator".into(), + grants: vec![Grant { + scope: Scope::All, + read: Fields::All, + write: Fields::All, + writable: true, + }], + teams: Vec::new(), + recipients: Vec::new(), + schema_admin: false, + authority_revision: 1, + }; + let before = store.usage()?; + let mut tx = store.begin()?; + tx.accept_native_addresses(&context, &value, binding)?; + tx.commit()?; + assert_eq!(store.usage()?, before); + Ok(()) +} + +// Restartable claim backfill uses the regular accounting path and does not +// release a restore fence or alter observation freshness. +#[test] +fn identity_lookup_upgrade_preserves_restore_fence_and_evidence() -> TestResult { + let dir = tempfile::tempdir()?; + let store = Store::open(dir.path(), policy())?; + let target = new_target(); + let claim = api::HostIdentityClaim { + kind: api::HostIdentityKind::HardwareUuid as i32, + namespace: "smbios/system-uuid".into(), + digest: "a".repeat(64), + quality: api::HostIdentityQuality::Reported as i32, + }; + let mut tx = store.begin()?; + let mut data = target_props(target); + data.insert("payload".into(), Value::Binary(claim.encode_to_vec())); + data.insert("observed".into(), int(100)?); + tx.put( + "host-identity/legacy", + &host_identity::parent(target), + Class::Inventory, + data, + )?; + tx.root.insert("schema".into(), int(11)?); + tx.root.insert("restored".into(), Value::Bool(true)); + tx.changed = true; + tx.commit()?; + drop(store); + let store = Store::open(dir.path(), policy())?; + let tx = store.begin()?; + assert_eq!(tx.root.get("restored"), Some(&Value::Bool(true))); + assert_eq!(number(&tx.root, "schema")?, 13); + let row = tx.get("host-identity/legacy")?.ok_or(Error::Corrupt)?; + assert_eq!(number(&row, "observed")?, 100); + assert_eq!( + string(&row, "claim")?, + format!( + "{}/smbios/system-uuid/{}", + api::HostIdentityKind::HardwareUuid as i32, + "a".repeat(64) + ) + ); + tx.commit()?; + Ok(()) +} diff --git a/crates/omloom-store/src/disk/workspace/typed_values.rs b/crates/omloom-store/src/disk/workspace/typed_values.rs new file mode 100644 index 0000000..75fc994 --- /dev/null +++ b/crates/omloom-store/src/disk/workspace/typed_values.rs @@ -0,0 +1,65 @@ +//! Explicit syntax validation never proves delivery, registration or account control. +use super::*; +#[cfg(test)] +mod tests; + +pub(super) fn dns_name(value: &str) -> Result<()> { + responsibility::bounded_value(value, 254, true)?; + // RFC 1034 section 3.1: an absolute name's final dot denotes the root label. + let dns = value.strip_suffix('.').unwrap_or(value); + let ascii = idna::domain_to_ascii_strict(dns).map_err(|_| Error::Invalid)?; + let name = ascii.as_str(); + if name.is_empty() || name.parse::().is_ok() { + return Err(Error::Invalid); + } + // PSL parsing must not turn DNS syntax into a registration requirement: + // private zones and public suffix nodes are valid DNS names too. + structured_public_domains::lookup(name).ok_or(Error::Invalid)?; + Ok(()) +} +pub(super) fn locator(kind: i32, value: &str) -> Result<()> { + responsibility::bounded_value(value, 512, true)?; + match api::LocatorKind::try_from(kind).map_err(|_| Error::Invalid)? { + api::LocatorKind::Unspecified => Ok(()), + api::LocatorKind::Email => { + if structured_email_address::is_smtp_mailbox(value) { + Ok(()) + } else { + Err(Error::Invalid) + } + } + api::LocatorKind::Phone => { + let number = phonenumber::parse(None, value).map_err(|_| Error::Invalid)?; + if phonenumber::is_valid(&number) + && number.format().mode(phonenumber::Mode::E164).to_string() == value + { + Ok(()) + } else { + Err(Error::Invalid) + } + } + api::LocatorKind::Web => { + let url = url::Url::parse(value).map_err(|_| Error::Invalid)?; + if matches!(url.scheme(), "http" | "https") + && url.host_str().is_some() + && url.username().is_empty() + && url.password().is_none() + { + Ok(()) + } else { + Err(Error::Invalid) + } + } + api::LocatorKind::Domain => dns_name(value), + } +} + +pub(super) fn read_kind(row: &Properties, name: &str) -> Result { + let kind = match row.get(name) { + None | Some(Value::Null) => api::LocatorKind::Unspecified as i32, + Some(Value::Int(value)) => i32::try_from(*value).map_err(|_| Error::Corrupt)?, + _ => return Err(Error::Corrupt), + }; + api::LocatorKind::try_from(kind).map_err(|_| Error::Corrupt)?; + Ok(kind) +} diff --git a/crates/omloom-store/src/disk/workspace/typed_values/tests.rs b/crates/omloom-store/src/disk/workspace/typed_values/tests.rs new file mode 100644 index 0000000..98b3304 --- /dev/null +++ b/crates/omloom-store/src/disk/workspace/typed_values/tests.rs @@ -0,0 +1,119 @@ +use super::*; + +// Syntax and explicit types cannot be replaced with length-only checks or registration policy. +#[test] +fn typed_locators_validate_syntax_without_normalizing_identity() { + use api::LocatorKind as K; + for (kind, valid, invalid) in [ + (K::Email, "User+tag@example.test", "not an email"), + (K::Email, "\"a b\"@localhost", "Alice "), + (K::Phone, "+442079460018", "02079460018"), + (K::Phone, "+12025550123", "+123"), + ( + K::Web, + "https://example.test/support", + "javascript:alert(1)", + ), + ( + K::Web, + "http://localhost/support", + "https://user:password@example.test/", + ), + (K::Domain, "münchen.de", "bad domain.com"), + (K::Domain, "internal.test", "a..test"), + (K::Domain, "co.uk", "example.com.."), + (K::Domain, "example.com.", "192.0.2.1"), + ] { + assert!(locator(kind as i32, valid).is_ok(), "{kind:?}: {valid}"); + assert!( + matches!(locator(kind as i32, invalid), Err(Error::Invalid)), + "{kind:?}: {invalid}" + ); + } + assert!(locator(K::Unspecified as i32, "Ask the account owner").is_ok()); + assert!(locator(999, "anything").is_err()); +} + +// Existing format-12 databases and backups acquire optional discriminator +// columns without reclassifying legacy routes or rewriting their payloads. +#[test] +fn typed_values_upgrade_format_twelve_schema_and_backup() +-> std::result::Result<(), Box> { + let policy = Policy { + classes: [crate::Budget { + rows: 1000, + bytes: 1_048_576, + }; CLASSES], + total_bytes: 8 * 1_048_576, + detail_age_ms: 1000, + review_age_ms: 500, + review_per_source: crate::Budget { + rows: 128, + bytes: 1_048_576, + }, + batch_rows: 128, + }; + let dir = tempfile::tempdir()?; + let store = Store::open(dir.path(), policy.clone())?; + let mut tx = store.begin()?; + tx.put( + "legacy-route", + "fixture", + Class::Inventory, + props([("contact", text("Ask the owner"))]), + )?; + tx.root.insert("schema".into(), int(12)?); + tx.changed = true; + tx.commit()?; + let mut schema = store + .db + .label_schemas()? + .into_iter() + .find(|s| s.name == "OmRow") + .ok_or("schema")?; + for name in ["contact_kind", "sender_kind", "recovery_route_kind"] { + schema.properties.remove(name); + } + let expected_revision = schema.schema_revision; + let mut declaration = LabelSchema::new_node_id("OmRow"); + declaration.properties = schema.properties; + let publication = store.db.publish_schema(SchemaPackage { + labels: vec![LabelDeclaration { + intent: Intent::Change { expected_revision }, + schema: declaration, + }], + ..Default::default() + })?; + assert!( + store + .db + .await_schema(&publication, Duration::from_secs(20))? + .all_ready() + ); + let mut backup = Vec::new(); + store.export(&mut backup)?; + drop(store); + let opened = Store::open(dir.path(), policy.clone())?; + let restored_dir = tempfile::tempdir()?; + let restored = Store::restore(restored_dir.path(), &backup, policy, backup.len() as u64)?; + for store in [opened, restored] { + assert_eq!(number(&store.read(ROOT)?.ok_or("root")?, "schema")?, 13); + let legacy = store.read("legacy-route")?.ok_or("route")?; + assert_eq!(string(&legacy, "contact")?, "Ask the owner"); + assert_eq!( + read_kind(&legacy, "contact_kind")?, + api::LocatorKind::Unspecified as i32 + ); + assert!(!legacy.contains_key("contact_kind")); + let schema = store + .db + .label_schemas()? + .into_iter() + .find(|s| s.name == "OmRow") + .ok_or("schema")?; + for name in ["contact_kind", "sender_kind", "recovery_route_kind"] { + assert_eq!(schema.properties[name].property_type, PropertyType::Int); + } + } + Ok(()) +} diff --git a/crates/omloom-store/src/disk/workspace/vm_placement.rs b/crates/omloom-store/src/disk/workspace/vm_placement.rs new file mode 100644 index 0000000..f31f7f2 --- /dev/null +++ b/crates/omloom-store/src/disk/workspace/vm_placement.rs @@ -0,0 +1,131 @@ +//! Placement review reuses conditional relationship mutation. No move preview, +//! address history or second authoritative inventory is persisted. +use super::*; + +impl Store { + pub(super) fn workspace_prepare_vm_placement( + &self, + context: &Context, + request: api::PrepareVmPlacement, + working: &api::WorkingContext, + ) -> Result { + let vm = parse_target(request.vm.as_ref())?; + let tx = self.begin()?; + let selection = tx.selection(context, working, false)?; + selection.contains(vm, &tx)?; + let item = tx.placement_item(context, vm)?; + if item.kind != wire::AssetKind::VirtualMachine as i32 { + return Err(Error::Invalid); + } + if !context.field(vm, item.kind, "relations", false) + || !context.field(vm, item.kind, "relations", true) + { + return Err(Error::Denied); + } + // Do not filter hidden placements into a false "unplaced" answer. + let rows = tx.query( + "MATCH (r:OmRow {asset:$asset}) WHERE r.parent='relations' AND r.incarnation=$inc AND r.kind=$kind RETURN properties(r) AS p LIMIT 2", + params([ + ("asset", text(vm.asset.to_string())), + ("inc", text(vm.incarnation.to_string())), + ("kind", int(wire::RelationKind::RunsOn as u64)?), + ]), + )?; + if rows.len() > 1 { + return Err(Error::Conflict); + } + let mut review = api::VmPlacementReview { + revision: number(&tx.root, "revision")?, + vm: Some(item), + ..Default::default() + }; + if let Some(row) = rows.first() { + let relation = inventory::relation_projection(row)?; + let (_, old_host) = tx.check_relationship(context, &selection, &relation)?; + let host = tx.placement_item(context, old_host)?; + if !context.field(old_host, host.kind, "relations", false) { + return Err(Error::Denied); + } + review.current_host = Some(host); + review.current = Some(relation); + } + if let Some(destination) = request.destination.as_ref() { + let destination = parse_target(Some(destination))?; + let host = tx.placement_item(context, destination)?; + if host.kind != wire::AssetKind::SoftwareInstallation as i32 { + return Err(Error::Invalid); + } + if !context.field(destination, host.kind, "virtualization_role", false) { + return Err(Error::Denied); + } + let row = tx.asset(destination)?; + if row.get("virtualization_role").and_then(Value::as_str) + != Some(api::VirtualizationRole::Hypervisor.as_str_name()) + { + return Err(Error::Invalid); + } + let proposed = wire::Relation { + from: Some(target_wire(vm)), + to: Some(target_wire(destination)), + kind: wire::RelationKind::RunsOn as i32, + ..Default::default() + }; + tx.check_relationship(context, &selection, &proposed)?; + let unchanged = review.current.as_ref().is_some_and(|r| r.to == proposed.to); + if !unchanged { + tx.require_no_pending_operations(vm)?; + let key = review + .current + .as_ref() + .map(|r| format!("relation/{}", r.relation_id)); + tx.check_placement_cycle(vm, destination, key.as_deref())?; + if let Some(current) = review.current.as_ref() { + review.invalidated_inheritance = + tx.placement_inheritance(context, vm, current)?; + } + } + review.destination = Some(host); + } + let hosts: BTreeSet<_> = review + .current_host + .iter() + .chain(review.destination.iter()) + .map(|item| parse_target(item.target.as_ref())) + .collect::>()?; + let rows = hosts + .into_iter() + .map(|host| tx.asset(host)) + .collect::>>()?; + review.contexts = tx.item_contexts( + context, + &rows, + &api::Query { + roots: working.roots.clone(), + ..Default::default() + }, + )?; + tx.commit()?; + Ok(reply(api::reply::Result::VmPlacementReview(Box::new( + review, + )))) + } +} + +impl Tx<'_> { + fn placement_item(&self, context: &Context, target: AssetRef) -> Result { + let row = self.visible_asset(context, target)?; + let kind = number(&row, "kind")? as i32; + let mut fields = Vec::new(); + if context.field(target, kind, "name", false) + && let Some(field) = self.get(&format!("{}/intent/name", asset_key(target)))? + { + fields.push(inventory::read_field(&field)?); + } + Ok(api::Item { + target: Some(target_wire(target)), + kind, + revision: number(&row, "revision")?, + fields, + }) + } +} diff --git a/crates/omloom-store/src/disk/workspace/working.rs b/crates/omloom-store/src/disk/workspace/working.rs index aa8c19d..65ad630 100644 --- a/crates/omloom-store/src/disk/workspace/working.rs +++ b/crates/omloom-store/src/disk/workspace/working.rs @@ -32,6 +32,8 @@ fn writable_fields( "vlan_tag", "description", ] + } else if kind == K::Domain { + &["name", "description", "dns_name"] } else if kind == K::SoftwareInstallation { &["name", "description", "virtualization_role"] } else if matches!(kind, K::Device | K::Rack | K::Port | K::Cable) { diff --git a/crates/omloom-store/tests/disk.rs b/crates/omloom-store/tests/disk.rs index b963770..e99c984 100644 --- a/crates/omloom-store/tests/disk.rs +++ b/crates/omloom-store/tests/disk.rs @@ -702,11 +702,11 @@ fn typed_relations_validate_endpoints_and_reopen() -> TestResult { fn relation_moves_preserve_parallel_edges() -> TestResult { let dir = tempfile::tempdir()?; let store = Store::open(dir.path(), policy())?; - let mut vm = asset(); - vm.kind = omloom_contracts::wire::AssetKind::VirtualMachine as i32; + let mut service = asset(); + service.kind = omloom_contracts::wire::AssetKind::Service as i32; let first = asset(); let second = asset(); - for object in [&vm, &first, &second] { + for object in [&service, &first, &second] { store.save_asset( &admit(record::Body::Asset(object.clone())), None, @@ -716,9 +716,9 @@ fn relation_moves_preserve_parallel_edges() -> TestResult { } let mut relation = omloom_contracts::wire::Relation { relation_id: RelationId::generate().to_string(), - from: vm.target.clone(), + from: service.target.clone(), to: first.target.clone(), - kind: omloom_contracts::wire::RelationKind::RunsOn as i32, + kind: omloom_contracts::wire::RelationKind::DependsOn as i32, revision: 1, authority_id: AuthorityId::generate().to_string(), }; @@ -744,13 +744,16 @@ fn relation_moves_preserve_parallel_edges() -> TestResult { "admin", 4, )?; - assert_eq!(store.relations(target(&vm), 10)?.len(), 2); + assert_eq!(store.relations(target(&service), 10)?.len(), 2); assert_eq!(store.relations(target(&first), 10)?, vec![parallel]); assert_eq!(store.relations(target(&second), 10)?, vec![relation]); - assert!(matches!(store.relations(target(&vm), 1), Err(Error::Limit))); + assert!(matches!( + store.relations(target(&service), 1), + Err(Error::Limit) + )); drop(store); let store = Store::open(dir.path(), policy())?; - assert_eq!(store.relations(target(&vm), 10)?.len(), 2); + assert_eq!(store.relations(target(&service), 10)?.len(), 2); assert_eq!(store.relations(target(&first), 10)?.len(), 1); assert_eq!(store.relations(target(&second), 10)?.len(), 1); Ok(()) diff --git a/crates/omloom-store/tests/workspace.rs b/crates/omloom-store/tests/workspace.rs index d6922dc..329eba4 100644 --- a/crates/omloom-store/tests/workspace.rs +++ b/crates/omloom-store/tests/workspace.rs @@ -10,6 +10,8 @@ use support::*; mod accounts; #[path = "workspace/contexts.rs"] mod contexts; +#[path = "workspace/correction.rs"] +mod correction; #[path = "workspace/definitions.rs"] mod definitions; #[path = "workspace/exchange.rs"] @@ -26,8 +28,12 @@ mod projections; mod responsibility; #[path = "workspace/software.rs"] mod software; +#[path = "workspace/typed_values.rs"] +mod typed_values; #[path = "workspace/views.rs"] mod views; +#[path = "workspace/vm_placement.rs"] +mod vm_placement; fn operator() -> Context { Context { diff --git a/crates/omloom-store/tests/workspace/contexts.rs b/crates/omloom-store/tests/workspace/contexts.rs index 25b0ee1..e8621a8 100644 --- a/crates/omloom-store/tests/workspace/contexts.rs +++ b/crates/omloom-store/tests/workspace/contexts.rs @@ -318,9 +318,10 @@ fn hypervisor_ownership_cycles_are_rejected_in_both_insertion_orders() -> TestRe Ok(()) } -// Shared acyclic execution ancestors are valid; a later edge cannot close a multi-installation cycle. +// A move between installations on the same hardware remains valid; a later +// placement replacement cannot close a multi-installation cycle. #[test] -fn shared_hypervisor_ancestors_are_valid_but_indirect_cycles_are_not() -> TestResult { +fn shared_hypervisor_ancestry_survives_moves_but_indirect_cycles_do_not() -> TestResult { let dir = tempfile::tempdir()?; let store = Store::open(dir.path(), policy())?; let actor = operator(); @@ -343,20 +344,37 @@ fn shared_hypervisor_ancestors_are_valid_but_indirect_cycles_are_not() -> TestRe )?)); } let guest = create(&store, &actor, K::VirtualMachine, "Shared guest")?; + let mut placement = wire::Relation { + from: Some(guest.clone()), + kind: wire::RelationKind::RunsOn as i32, + ..Default::default() + }; for host in &hosts { + placement.to = Some(host.clone()); call( &store, &actor, Command::Relation(api::Relationship { - relation: Some(wire::Relation { - from: Some(guest.clone()), - to: Some(host.clone()), - kind: wire::RelationKind::RunsOn as i32, - ..Default::default() - }), + relation: Some(placement.clone()), remove: false, }), )?; + let Reply::Detail(current) = call( + &store, + &actor, + Command::Read(api::Read { + target: Some(guest.clone()), + ..Default::default() + }), + )? + else { + return Err("detail".into()); + }; + placement = current + .relations + .into_iter() + .find(|r| r.kind == wire::RelationKind::RunsOn as i32) + .ok_or("placement")?; } let vm = create(&store, &actor, K::VirtualMachine, "Nested guest")?; let nested = changed(within( @@ -401,18 +419,14 @@ fn shared_hypervisor_ancestors_are_valid_but_indirect_cycles_are_not() -> TestRe }), )?); let before = store.usage()?; + placement.to = Some(inner); // Guest -> nested OS -> nested guest -> inner OS -> guest would close the cycle. assert!(matches!( call( &store, &actor, Command::Relation(api::Relationship { - relation: Some(wire::Relation { - from: Some(guest), - to: Some(inner), - kind: wire::RelationKind::RunsOn as i32, - ..Default::default() - }), + relation: Some(placement), remove: false }) ), diff --git a/crates/omloom-store/tests/workspace/correction.rs b/crates/omloom-store/tests/workspace/correction.rs new file mode 100644 index 0000000..375cfe5 --- /dev/null +++ b/crates/omloom-store/tests/workspace/correction.rs @@ -0,0 +1,551 @@ +use super::*; +use api::{reply::Result as Reply, request::Command}; +use omloom_store::Class; +#[path = "correction/continuity.rs"] +mod continuity; +#[path = "correction/safety.rs"] +mod safety; + +fn child( + store: &Store, + parent: &wire::AssetRef, + kind: K, + name: &str, +) -> Result { + let value = store.workspace( + &operator(), + api::Request { + working: Some(api::WorkingContext { + focus: Some(parent.clone()), + expected: Some(store.usage()?.revision), + ..Default::default() + }), + command: Some(Command::Write(api::Write { + kind: kind as i32, + parent: Some(parent.clone()), + changes: vec![text_field("name", name)], + ..Default::default() + })), + }, + 100, + )?; + let Some(Reply::Mutation(value)) = value.result else { + return Err(Error::Corrupt); + }; + value.targets.into_iter().next().ok_or(Error::Corrupt) +} +fn mapping(from: &wire::AssetRef, to: &wire::AssetRef) -> api::IdentityMapping { + api::IdentityMapping { + duplicate: Some(from.clone()), + retained: Some(to.clone()), + fields: vec![api::IdentityFieldChoice { + name: "name".into(), + use_duplicate: false, + }], + ..Default::default() + } +} +fn prepare( + store: &Store, + mappings: Vec, + decision: api::IdentityDecision, +) -> Result { + let Some(Reply::IdentityCorrection(value)) = store + .workspace( + &operator(), + api::Request { + working: Some(api::WorkingContext { + expected: Some(store.usage()?.revision), + ..Default::default() + }), + command: Some(Command::CorrectIdentity(api::IdentityCorrection { + decision: decision as i32, + mappings, + ..Default::default() + })), + }, + 100, + )? + .result + else { + return Err(Error::Corrupt); + }; + Ok(value) +} +fn apply(store: &Store, id: &str) -> Result { + let Reply::IdentityCorrected(value) = call( + store, + &operator(), + Command::CorrectIdentity(api::IdentityCorrection { + preview: Some(id.into()), + ..Default::default() + }), + )? + else { + return Err(Error::Corrupt); + }; + Ok(value) +} +fn read(store: &Store, target: &wire::AssetRef) -> Result { + let Reply::Detail(value) = call( + store, + &operator(), + Command::Read(api::Read { + target: Some(target.clone()), + ..Default::default() + }), + )? + else { + return Err(Error::Corrupt); + }; + Ok(*value) +} + +// Reversal must restore adjacency as well as the row of a redundant relation. +#[test] +fn undo_restores_deleted_relation_adjacency() -> TestResult { + let dir = tempfile::tempdir()?; + let store = Store::open(dir.path(), policy())?; + let retained = create(&store, &operator(), K::Device, "Retained")?; + let duplicate = create(&store, &operator(), K::Device, "Duplicate")?; + let retained_os = child(&store, &retained, K::SoftwareInstallation, "Retained OS")?; + let os = child(&store, &duplicate, K::SoftwareInstallation, "OS")?; + let plan = prepare( + &store, + vec![mapping(&duplicate, &retained), mapping(&os, &retained_os)], + api::IdentityDecision::DuplicateInstallation, + )?; + let relation_id = read(&store, &os)? + .relations + .into_iter() + .find(|r| r.from.as_ref() == Some(&duplicate)) + .ok_or("relation before correction")? + .relation_id; + let applied = apply(&store, &plan.id)?; + assert!(matches!(read(&store, &os), Err(Error::Missing))); + assert!( + !read(&store, &retained_os)? + .relations + .iter() + .any(|r| r.relation_id == relation_id) + ); + assert!( + !read(&store, &retained)? + .relations + .iter() + .any(|r| r.relation_id == relation_id) + ); + call( + &store, + &operator(), + Command::CorrectIdentity(api::IdentityCorrection { + undo: Some(applied.id), + ..Default::default() + }), + )?; + assert!( + read(&store, &duplicate)? + .relations + .iter() + .any(|r| r.relation_id == relation_id + && r.from.as_ref() == Some(&duplicate) + && r.to.as_ref() == Some(&os)) + ); + assert!( + read(&store, &os)? + .relations + .iter() + .any(|r| r.relation_id == relation_id + && r.from.as_ref() == Some(&duplicate) + && r.to.as_ref() == Some(&os)) + ); + Ok(()) +} + +// Membership is evaluated on an intact graph, including a child being moved. +#[test] +fn scoped_correction_keeps_original_child_membership() -> TestResult { + let dir = tempfile::tempdir()?; + let store = Store::open(dir.path(), policy())?; + let retained = create(&store, &operator(), K::Device, "Retained")?; + let duplicate = create(&store, &operator(), K::Device, "Duplicate")?; + let os = child(&store, &duplicate, K::SoftwareInstallation, "OS")?; + let mut working = api::WorkingContext { + roots: vec![retained.clone(), duplicate.clone()], + expected: Some(store.usage()?.revision), + ..Default::default() + }; + let Some(Reply::IdentityCorrection(preview)) = store + .workspace( + &operator(), + api::Request { + working: Some(working.clone()), + command: Some(Command::CorrectIdentity(api::IdentityCorrection { + decision: api::IdentityDecision::DistinctInstallations as i32, + mappings: vec![mapping(&duplicate, &retained)], + ..Default::default() + })), + }, + 100, + )? + .result + else { + return Err("preview".into()); + }; + working.expected = Some(preview.revision); + store.workspace( + &operator(), + api::Request { + working: Some(working), + command: Some(Command::CorrectIdentity(api::IdentityCorrection { + preview: Some(preview.id), + ..Default::default() + })), + }, + 100, + )?; + assert!( + read(&store, &os)? + .relations + .iter() + .any(|r| r.from.as_ref() == Some(&retained)) + ); + Ok(()) +} + +// One chassis can keep both OS installations, their services and their own +// interface addresses. Explicit reversal restores only the correction delta. +#[test] +fn dual_boot_correction_is_reviewed_atomic_reopenable_and_reversible() -> TestResult { + let dir = tempfile::tempdir()?; + let store = Store::open(dir.path(), policy())?; + let retained = create(&store, &operator(), K::Device, "Retained chassis")?; + let duplicate = create(&store, &operator(), K::Device, "Duplicate chassis")?; + let linux = child(&store, &retained, K::SoftwareInstallation, "Linux")?; + let windows = child(&store, &duplicate, K::SoftwareInstallation, "Windows")?; + let service = child(&store, &windows, K::Service, "SMTP")?; + let before = store.usage()?; + let plan = prepare( + &store, + vec![mapping(&duplicate, &retained)], + api::IdentityDecision::DistinctInstallations, + )?; + assert_eq!(plan.relations.len(), 1); + assert!( + read(&store, &duplicate).is_ok(), + "preview does not retire a subject" + ); + assert_eq!( + store.usage()?.rows[Class::Inventory as usize], + before.rows[Class::Inventory as usize] + ); + let result = apply(&store, &plan.id)?; + let before_retry = store.usage()?; + assert_eq!( + apply(&store, &plan.id)?, + result, + "lost-response retry returns the same accepted receipt" + ); + assert_eq!(store.usage()?, before_retry); + assert_eq!(result.retained, vec![retained.clone()]); + assert!(matches!(read(&store, &duplicate), Err(Error::Missing))); + assert!(read(&store, &linux).is_ok()); + assert!(read(&store, &windows).is_ok()); + assert!(read(&store, &service).is_ok()); + let relation = read(&store, &windows)? + .relations + .into_iter() + .find(|r| r.kind == wire::RelationKind::Contains as i32 && r.to.as_ref() == Some(&windows)) + .ok_or("OS placement")?; + assert_eq!(relation.from.as_ref(), Some(&retained)); + drop(store); + let store = Store::open(dir.path(), policy())?; + let Some(Reply::IdentityCorrected(inverse)) = store + .workspace( + &operator(), + api::Request { + working: Some(api::WorkingContext { + roots: vec![retained.clone(), duplicate.clone()], + ..Default::default() + }), + command: Some(Command::CorrectIdentity(api::IdentityCorrection { + undo: Some(result.id.clone()), + ..Default::default() + })), + }, + 100, + )? + .result + else { + return Err("inverse".into()); + }; + assert_eq!(inverse.undo_until, 0); + let before_retry = store.usage()?; + let repeated = call( + &store, + &operator(), + Command::CorrectIdentity(api::IdentityCorrection { + undo: Some(result.id), + ..Default::default() + }), + )?; + assert_eq!(repeated, Reply::IdentityCorrected(inverse.clone())); + assert_eq!(store.usage()?, before_retry); + assert!(read(&store, &duplicate)?.item.as_ref().unwrap().revision > 1); + let relation = read(&store, &windows)? + .relations + .into_iter() + .find(|r| r.kind == wire::RelationKind::Contains as i32 && r.to.as_ref() == Some(&windows)) + .ok_or("restored placement")?; + assert_eq!(relation.from.as_ref(), Some(&duplicate)); + assert!(read(&store, &service).is_ok()); + Ok(()) +} + +// Exact lookup exposes only permitted candidates. A reviewed three-layer +// merge preserves independent sources and makes old reports repeat-safe. +#[test] +fn duplicate_installation_reconciles_sources_without_resurrection_or_grant_union() -> TestResult { + let dir = tempfile::tempdir()?; + let store = Store::open(dir.path(), policy())?; + let mut first = super::host_import::draft(); + first.report.as_mut().unwrap().findings[0] + .identity + .push(api::HostIdentityClaim { + kind: api::HostIdentityKind::HardwareUuid as i32, + namespace: "smbios/system-uuid".into(), + digest: "a".repeat(64), + quality: api::HostIdentityQuality::Reported as i32, + }); + let mut second = first.clone(); + second.report.as_mut().unwrap().source_basis = "d".repeat(64); + second.report.as_mut().unwrap().findings[2].addresses[0].address = "192.0.2.19".into(); + let a = + super::host_import::accept(&store, &super::host_import::preview(&store, first)?)?.targets; + let b = super::host_import::accept( + &store, + &super::host_import::preview(&store, second.clone())?, + )? + .targets; + let before = store.usage()?; + let Reply::IdentityCandidates(candidates) = call( + &store, + &operator(), + Command::FindIdentityCandidates(api::FindIdentityCandidates { + target: Some(a[0].clone()), + ..Default::default() + }), + )? + else { + return Err("candidates".into()); + }; + assert_eq!(candidates.items.len(), 1); + assert_eq!(candidates.items[0].target.as_ref(), Some(&b[0])); + assert_eq!(store.usage()?, before); + let mut scoped = operator(); + scoped.grants[0].scope = Scope::Targets(vec![AssetRef::try_from(&a[0])?]); + let Reply::IdentityCandidates(hidden) = call( + &store, + &scoped, + Command::FindIdentityCandidates(api::FindIdentityCandidates { + target: Some(a[0].clone()), + ..Default::default() + }), + )? + else { + return Err("hidden candidates".into()); + }; + assert!(hidden.items.is_empty()); + let mappings = vec![ + mapping(&b[0], &a[0]), + mapping(&b[1], &a[1]), + mapping(&b[2], &a[2]), + ]; + let plan = prepare( + &store, + mappings.clone(), + api::IdentityDecision::DuplicateInstallation, + )?; + let before = store.usage()?; + assert_eq!( + prepare( + &store, + mappings, + api::IdentityDecision::DuplicateInstallation + )? + .id, + plan.id + ); + assert_eq!( + store.usage()?, + before, + "an unchanged preview writes nothing" + ); + apply(&store, &plan.id)?; + let addresses = read(&store, &a[2])?.current_addresses; + assert_eq!(addresses.len(), 2); + assert!( + addresses + .iter() + .all(|v| v.coverage.as_ref().unwrap().source == "Native fixture") + ); + assert_ne!( + addresses[0].source_id, addresses[1].source_id, + "display labels do not identify collectors" + ); + assert!( + addresses + .iter() + .any(|v| v.observations[0].value.as_ref().unwrap().address == "192.0.2.19") + ); + // Undo must not restore source evidence after that field right is revoked. + let mut restricted = operator(); + restricted.grants[0].write = Fields::Selected(vec![ + "name".into(), + "identity_correction".into(), + "relations".into(), + "remove".into(), + "identity_evidence".into(), + "claimed_owner".into(), + "operator".into(), + ]); + let unchanged = store.usage()?; + assert!(matches!( + call( + &store, + &restricted, + Command::CorrectIdentity(api::IdentityCorrection { + undo: Some(plan.id.clone()), + ..Default::default() + }) + ), + Err(Error::Denied) + )); + assert_eq!(store.usage()?, unchanged); + let before = store.usage()?; + assert!(matches!( + call(&store, &operator(), Command::HostImport(second))?, + Reply::Mutation(_) + )); + assert_eq!( + store.usage()?, + before, + "old reviewed source mappings resolve to retained identities without re-adoption" + ); + let Reply::ResolvedIdentity(alias) = call( + &store, + &operator(), + Command::ResolveIdentity(api::ResolveIdentity { + original: Some(b[0].clone()), + }), + )? + else { + return Err("alias".into()); + }; + assert_eq!(alias.original.as_ref(), Some(&b[0])); + assert_eq!(alias.canonical.unwrap().target.as_ref(), Some(&a[0])); + let mut retired_only = operator(); + retired_only.grants[0].scope = Scope::Targets(vec![AssetRef::try_from(&b[0])?]); + assert!(matches!( + call( + &store, + &retired_only, + Command::ResolveIdentity(api::ResolveIdentity { + original: Some(b[0].clone()) + }) + ), + Err(Error::Denied) + )); + let mut canonical_only = operator(); + canonical_only.grants[0].scope = Scope::Targets(vec![AssetRef::try_from(&a[0])?]); + assert!(matches!( + call( + &store, + &canonical_only, + Command::ResolveIdentity(api::ResolveIdentity { + original: Some(b[0].clone()) + }) + ), + Err(Error::Denied) + )); + assert_eq!( + store.usage()?, + before, + "reading aliases does not write or grant access" + ); + Ok(()) +} + +// Stale preview/undo and revoked field rights must fail before any domain +// change. No inverse may overwrite edits accepted after the correction. +#[test] +fn identity_correction_rechecks_revision_fields_and_inverse_dependencies() -> TestResult { + let dir = tempfile::tempdir()?; + let store = Store::open(dir.path(), policy())?; + let a = create(&store, &operator(), K::Device, "A")?; + let b = create(&store, &operator(), K::Device, "B")?; + let plan = prepare( + &store, + vec![mapping(&a, &b)], + api::IdentityDecision::DistinctInstallations, + )?; + let unchanged = store.usage()?; + assert!(matches!( + store.workspace( + &operator(), + api::Request { + working: Some(api::WorkingContext { + roots: vec![a.clone(), b.clone()], + ..Default::default() + }), + command: Some(Command::CorrectIdentity(api::IdentityCorrection { + preview: Some(plan.id.clone()), + ..Default::default() + })) + }, + 100 + ), + Err(Error::Conflict) + )); + assert_eq!( + store.usage()?, + unchanged, + "a preview is tied to the reviewed working context" + ); + let mut denied = operator(); + denied.grants[0].write = Fields::Selected(vec![ + "relations".into(), + "remove".into(), + "identity_correction".into(), + ]); + let before = store.usage()?; + assert!(matches!( + call( + &store, + &denied, + Command::CorrectIdentity(api::IdentityCorrection { + preview: Some(plan.id.clone()), + ..Default::default() + }) + ), + Err(Error::Denied) + )); + assert_eq!(store.usage()?, before); + let result = apply(&store, &plan.id)?; + create(&store, &operator(), K::Device, "Concurrent edit")?; + let before = store.usage()?; + assert!(matches!( + call( + &store, + &operator(), + Command::CorrectIdentity(api::IdentityCorrection { + undo: Some(result.id), + ..Default::default() + }) + ), + Err(Error::Conflict) + )); + assert_eq!(store.usage()?, before); + assert!(matches!(read(&store, &a), Err(Error::Missing))); + Ok(()) +} diff --git a/crates/omloom-store/tests/workspace/correction/continuity.rs b/crates/omloom-store/tests/workspace/correction/continuity.rs new file mode 100644 index 0000000..d973014 --- /dev/null +++ b/crates/omloom-store/tests/workspace/correction/continuity.rs @@ -0,0 +1,328 @@ +use super::*; + +// Another actor's personal CSV approval cannot become approval for a new target. +#[test] +fn correction_preserves_personal_csv_target_and_undo_binding() -> TestResult { + let dir = tempfile::tempdir()?; + let store = Store::open(dir.path(), policy())?; + let mut importer = operator(); + importer.actor = "personal-importer".into(); + let input = api::Import { + source: "Personal source".into(), + kind: K::Device as i32, + csv: "external_id,name\nsource-id,Duplicate\n".into(), + mappings: vec![api::Mapping { + source: "name".into(), + field: "name".into(), + }], + ..Default::default() + }; + let Reply::Preview(preview) = call(&store, &importer, Command::Import(input.clone()))? else { + return Err("preview".into()); + }; + let Reply::Mutation(created) = call( + &store, + &importer, + Command::Import(api::Import { + preview: preview.id, + ..Default::default() + }), + )? + else { + return Err("import".into()); + }; + let duplicate = &created.targets[0]; + let retained = create(&store, &operator(), K::Device, "Retained")?; + let preview = prepare( + &store, + vec![mapping(duplicate, &retained)], + api::IdentityDecision::DistinctInstallations, + )?; + let applied = apply(&store, &preview.id)?; + let before = store.usage()?; + let Reply::Preview(retry) = call(&store, &importer, Command::Import(input.clone()))? else { + return Err("retry".into()); + }; + assert!( + retry.id.is_none(), + "personal approval must not silently retarget: {retry:?}" + ); + assert!( + retry + .rows + .iter() + .all(|row| row.change.is_none() && !row.diagnostic.is_empty()) + ); + assert_eq!( + store.usage()?, + before, + "a retired binding cannot create a new inventory identity" + ); + call( + &store, + &operator(), + Command::CorrectIdentity(api::IdentityCorrection { + undo: Some(applied.id), + ..Default::default() + }), + )?; + let Reply::Preview(restored) = call(&store, &importer, Command::Import(input))? else { + return Err("restored".into()); + }; + assert_eq!( + restored.rows[0] + .change + .as_ref() + .and_then(|change| change.target.as_ref()), + Some(duplicate) + ); + Ok(()) +} + +// Unrelated matching claims cannot consume another working scope's allowance. +#[test] +fn candidate_limit_counts_only_current_scope() -> TestResult { + let dir = tempfile::tempdir()?; + let store = Store::open(dir.path(), policy())?; + let claim = api::HostIdentityClaim { + kind: api::HostIdentityKind::HardwareUuid as i32, + namespace: "apple/platform-uuid".into(), + digest: "c".repeat(64), + quality: api::HostIdentityQuality::Reported as i32, + }; + let mut targets = Vec::new(); + for batch in 0..2 { + let mut input = super::super::host_import::draft(); + let report = input.report.as_mut().ok_or("report")?; + report.source_basis = format!("{:064x}", batch + 10); + report.findings = (0..65) + .map(|index| api::HostFinding { + key: format!("hardware/{index}"), + kind: K::Device as i32, + fields: vec![text_field("name", &format!("Candidate {batch}/{index}"))], + identity: vec![claim.clone()], + ..Default::default() + }) + .collect(); + input.selection = report + .findings + .iter() + .map(|finding| api::HostSelection { + key: finding.key.clone(), + ..Default::default() + }) + .collect(); + targets.extend( + super::super::host_import::accept( + &store, + &super::super::host_import::preview(&store, input)?, + )? + .targets, + ); + } + assert_eq!(targets.len(), 130); + let before = store.usage()?; + let started = std::time::Instant::now(); + let result = store.workspace( + &operator(), + api::Request { + working: Some(api::WorkingContext { + roots: vec![targets[0].clone()], + ..Default::default() + }), + command: Some(Command::FindIdentityCandidates( + api::FindIdentityCandidates { + kind: K::Device as i32, + claims: vec![claim], + ..Default::default() + }, + )), + }, + 100, + ); + eprintln!( + "130 matching claims, one scoped candidate: {:?}; inventory usage: {:?}", + started.elapsed(), + before + ); + let Some(Reply::IdentityCandidates(value)) = result?.result else { + return Err("candidates".into()); + }; + assert_eq!(value.items.len(), 1); + assert_eq!(value.items[0].target.as_ref(), Some(&targets[0])); + assert_eq!(store.usage()?, before, "candidate reads must not write"); + // No readable claims require neither candidate expansion nor persisted state. + let mut limited = operator(); + limited.grants[0].read = Fields::Selected(vec!["name".into(), "relations".into()]); + let Some(Reply::IdentityCandidates(empty)) = store + .workspace( + &limited, + api::Request { + working: Some(api::WorkingContext { + roots: vec![targets[0].clone()], + ..Default::default() + }), + command: Some(Command::FindIdentityCandidates( + api::FindIdentityCandidates { + target: Some(targets[0].clone()), + ..Default::default() + }, + )), + }, + 100, + )? + .result + else { + return Err("empty candidates".into()); + }; + assert!(empty.items.is_empty()); + assert_eq!(store.usage()?, before); + Ok(()) +} + +// Reboot/address refresh follows only an existing source binding. Reinstall, +// cloned UUIDs/machine IDs and a movable NIC remain separate until reviewed. +#[test] +fn identity_evidence_never_collapses_reinstall_clone_or_movable_interface() -> TestResult { + let dir = tempfile::tempdir()?; + let store = Store::open(dir.path(), policy())?; + let mut input = super::super::host_import::draft(); + let report = input.report.as_mut().ok_or("report")?; + for (index, kind, namespace, quality) in [ + ( + 0, + api::HostIdentityKind::HardwareUuid, + "smbios/system-uuid", + api::HostIdentityQuality::Reported, + ), + ( + 1, + api::HostIdentityKind::InstallationId, + "linux/machine-id", + api::HostIdentityQuality::Reported, + ), + ( + 2, + api::HostIdentityKind::FactoryMac, + "ethernet/permanent", + api::HostIdentityQuality::Movable, + ), + ] { + report.findings[index] + .identity + .push(api::HostIdentityClaim { + kind: kind as i32, + namespace: namespace.into(), + digest: "c".repeat(64), + quality: quality as i32, + }); + } + let original = super::super::host_import::accept( + &store, + &super::super::host_import::preview(&store, input.clone())?, + )? + .targets; + let mut reboot = input.clone(); + let report = reboot.report.as_mut().ok_or("report")?; + report.observed_at = 101; + report.boot_basis = "e".repeat(64); + report.findings[2].addresses[0].address = "192.0.2.99".into(); + let refresh = super::super::host_import::accept( + &store, + &super::super::host_import::preview(&store, reboot)?, + )?; + assert_eq!(refresh.targets, original); + assert_eq!( + read(&store, &original[2])?.current_addresses[0].observations[0] + .value + .as_ref() + .ok_or("value")? + .address, + "192.0.2.99" + ); + // A new source can discover candidates before adopting anything. + let claims = input.report.as_ref().ok_or("report")?.findings[0] + .identity + .clone(); + let before = store.usage()?; + let plan=store.diagnostic_database().explain_cypher("UNWIND $claims AS claim MATCH (n:OmRow {claim:claim}) MATCH (a:OmRow {key:'asset/'+n.asset}) WHERE a.kind=$kind RETURN DISTINCT properties(a) AS p LIMIT 129")?; + assert!(plan.contains("omloom_claim"), "{plan}"); + let started = std::time::Instant::now(); + let Reply::IdentityCandidates(candidates) = call( + &store, + &operator(), + Command::FindIdentityCandidates(api::FindIdentityCandidates { + claims: claims.clone(), + kind: K::Device as i32, + ..Default::default() + }), + )? + else { + return Err("candidates".into()); + }; + assert_eq!(candidates.items.len(), 1); + eprintln!( + "Scoped native candidate lookup: {:?}; plan: {plan}", + started.elapsed() + ); + assert_eq!(candidates.items[0].target.as_ref(), Some(&original[0])); + assert_eq!(store.usage()?, before); + let mut wrong = api::FindIdentityCandidates { + claims, + kind: K::Interface as i32, + ..Default::default() + }; + assert!(matches!( + call( + &store, + &operator(), + Command::FindIdentityCandidates(wrong.clone()) + ), + Err(Error::Invalid) + )); + wrong.kind = K::Device as i32; + wrong.target = Some(original[0].clone()); + assert!(matches!( + call(&store, &operator(), Command::FindIdentityCandidates(wrong)), + Err(Error::Invalid) + )); + let mut reinstall = input.clone(); + let report = reinstall.report.as_mut().ok_or("report")?; + report.source_basis = "d".repeat(64); + report.findings[1].identity[0].digest = "d".repeat(64); + let second = super::super::host_import::accept( + &store, + &super::super::host_import::preview(&store, reinstall)?, + )? + .targets; + let mut cloned = input; + cloned.report.as_mut().ok_or("report")?.source_basis = "f".repeat(64); + let third = super::super::host_import::accept( + &store, + &super::super::host_import::preview(&store, cloned)?, + )? + .targets; + assert_ne!(second[0], original[0]); + assert_ne!(third[0], original[0]); + assert_ne!(third[0], second[0]); + for subject in [&original[0], &second[0], &third[0]] { + assert!(read(&store, subject).is_ok()); + } + let Reply::IdentityCandidates(candidates) = call( + &store, + &operator(), + Command::FindIdentityCandidates(api::FindIdentityCandidates { + target: Some(original[2].clone()), + ..Default::default() + }), + )? + else { + return Err("NIC candidates".into()); + }; + assert_eq!( + candidates.items.len(), + 2, + "movable evidence supports review without merging either NIC" + ); + Ok(()) +} diff --git a/crates/omloom-store/tests/workspace/correction/safety.rs b/crates/omloom-store/tests/workspace/correction/safety.rs new file mode 100644 index 0000000..fafb713 --- /dev/null +++ b/crates/omloom-store/tests/workspace/correction/safety.rs @@ -0,0 +1,683 @@ +use super::*; + +// Final duplicate placements must reconcile identically in either relation-ID order. +#[test] +fn correction_deduplicates_vm_placements_before_exclusivity_checks() -> TestResult { + use super::super::vm_placement::hypervisor; + for duplicate_first in [true, false] { + let dir = tempfile::tempdir()?; + let store = Store::open(dir.path(), policy())?; + let a = create(&store, &operator(), K::VirtualMachine, "Duplicate VM")?; + let b = create(&store, &operator(), K::VirtualMachine, "Retained VM")?; + let h1 = hypervisor(&store, "Original hypervisor")?; + let h2 = hypervisor(&store, "Retained hypervisor")?; + for vm in if duplicate_first { [&a, &b] } else { [&b, &a] } { + call( + &store, + &operator(), + Command::Relation(api::Relationship { + relation: Some(wire::Relation { + from: Some(vm.clone()), + to: Some(h1.clone()), + kind: wire::RelationKind::RunsOn as i32, + ..Default::default() + }), + remove: false, + }), + )?; + } + let mut host = mapping(&h1, &h2); + host.fields.push(api::IdentityFieldChoice { + name: "virtualization_role".into(), + use_duplicate: false, + }); + let plan = prepare( + &store, + vec![mapping(&a, &b), host], + api::IdentityDecision::DuplicateInstallation, + )?; + let applied = apply(&store, &plan.id)?; + let placements = read(&store, &b)? + .relations + .into_iter() + .filter(|r| r.kind == wire::RelationKind::RunsOn as i32) + .collect::>(); + assert_eq!(placements.len(), 1); + assert_eq!(placements[0].to.as_ref(), Some(&h2)); + call( + &store, + &operator(), + Command::CorrectIdentity(api::IdentityCorrection { + undo: Some(applied.id), + ..Default::default() + }), + )?; + for vm in [&a, &b] { + let placements = read(&store, vm)? + .relations + .into_iter() + .filter(|r| r.kind == wire::RelationKind::RunsOn as i32) + .collect::>(); + assert_eq!(placements.len(), 1); + assert_eq!(placements[0].to.as_ref(), Some(&h1)); + } + } + Ok(()) +} + +// Keeping a retained field must not require permission to rewrite that unchanged field on undo. +#[test] +fn correction_undo_keeps_retained_fields_without_write_permission() -> TestResult { + let dir = tempfile::tempdir()?; + let store = Store::open(dir.path(), policy())?; + let retained = create(&store, &operator(), K::Device, "Retained")?; + let duplicate = create(&store, &operator(), K::Device, "Duplicate")?; + let mut actor = operator(); + actor.grants[0].write = Fields::Selected(vec![ + "identity_correction".into(), + "relations".into(), + "remove".into(), + ]); + actor.grants.push(Grant { + scope: Scope::Targets(vec![AssetRef::try_from(&duplicate)?]), + read: Fields::All, + write: Fields::All, + writable: true, + }); + let correct = |command| -> Result { + store + .workspace( + &actor, + api::Request { + working: Some(api::WorkingContext { + expected: Some(store.usage()?.revision), + ..Default::default() + }), + command: Some(command), + }, + 100, + )? + .result + .ok_or(Error::Corrupt) + }; + let Reply::IdentityCorrection(plan) = + correct(Command::CorrectIdentity(api::IdentityCorrection { + decision: api::IdentityDecision::DuplicateInstallation as i32, + mappings: vec![mapping(&duplicate, &retained)], + ..Default::default() + }))? + else { + return Err("preview".into()); + }; + let Reply::IdentityCorrected(applied) = + correct(Command::CorrectIdentity(api::IdentityCorrection { + preview: Some(plan.id), + ..Default::default() + }))? + else { + return Err("correction".into()); + }; + correct(Command::CorrectIdentity(api::IdentityCorrection { + undo: Some(applied.id), + ..Default::default() + }))?; + assert_eq!( + read(&store, &retained)? + .item + .ok_or("retained")? + .fields + .into_iter() + .find(|f| f.name == "name") + .ok_or("retained name")? + .value, + Some(wire::field::Value::Text("Retained".into())) + ); + assert_eq!( + read(&store, &duplicate)? + .item + .ok_or("duplicate")? + .fields + .into_iter() + .find(|f| f.name == "name") + .ok_or("duplicate name")? + .value, + Some(wire::field::Value::Text("Duplicate".into())) + ); + Ok(()) +} + +// Correcting a migrated host cannot reactivate inheritance tied to the old placement. +#[test] +fn correction_and_undo_keep_stale_placement_inheritance_unavailable() -> TestResult { + use super::super::vm_placement::{hypervisor, responsibility}; + use api::{AssignmentMode as M, ResponsibilityFacet as F, ResponsibilityStatus as S}; + let dir = tempfile::tempdir()?; + let store = Store::open(dir.path(), policy())?; + let guest = create(&store, &operator(), K::VirtualMachine, "Guest")?; + let first = hypervisor(&store, "Original host")?; + let duplicate = hypervisor(&store, "Migrated host")?; + let retained = hypervisor(&store, "Retained host")?; + responsibility( + &store, + &first, + vec![api::ResponsibilityAssignment { + facet: F::ClaimedOwner as i32, + mode: M::Explicit as i32, + party: Some(api::PartyRef { + issuer: "company.example".into(), + subject: "original".into(), + name: "Original team".into(), + kind: api::PartyKind::Team as i32, + }), + ..Default::default() + }], + )?; + responsibility( + &store, + &retained, + vec![api::ResponsibilityAssignment { + facet: F::ClaimedOwner as i32, + mode: M::Explicit as i32, + party: Some(api::PartyRef { + issuer: "company.example".into(), + subject: "operations".into(), + name: "New team".into(), + kind: api::PartyKind::Team as i32, + }), + ..Default::default() + }], + )?; + call( + &store, + &operator(), + Command::Relation(api::Relationship { + relation: Some(wire::Relation { + from: Some(guest.clone()), + to: Some(first), + kind: wire::RelationKind::RunsOn as i32, + ..Default::default() + }), + remove: false, + }), + )?; + let mut placement = read(&store, &guest)? + .relations + .into_iter() + .find(|r| r.kind == wire::RelationKind::RunsOn as i32) + .ok_or("placement")?; + responsibility( + &store, + &guest, + vec![api::ResponsibilityAssignment { + facet: F::ClaimedOwner as i32, + mode: M::Inherit as i32, + source_relation: Some(placement.relation_id.clone()), + source_revision: Some(placement.revision), + ..Default::default() + }], + )?; + let inherited = responsibility(&store, &guest, Vec::new())?; + assert_eq!( + inherited + .effective + .iter() + .find(|v| v.facet == F::ClaimedOwner as i32) + .and_then(|v| v.party.as_ref()) + .ok_or("inherited owner before migration")? + .subject, + "original" + ); + placement.to = Some(duplicate.clone()); + call( + &store, + &operator(), + Command::Relation(api::Relationship { + relation: Some(placement), + remove: false, + }), + )?; + let unavailable = |store: &Store| -> TestResult { + let state = responsibility(store, &guest, Vec::new())?; + let owner = state + .effective + .iter() + .find(|v| v.facet == F::ClaimedOwner as i32) + .ok_or("owner")?; + assert_eq!(owner.status, S::SourceUnavailable as i32); + assert!(owner.party.is_none()); + Ok(()) + }; + unavailable(&store)?; + let mut choice = mapping(&duplicate, &retained); + choice.fields.push(api::IdentityFieldChoice { + name: "virtualization_role".into(), + use_duplicate: false, + }); + let preview = prepare( + &store, + vec![choice], + api::IdentityDecision::DuplicateInstallation, + )?; + let applied = apply(&store, &preview.id)?; + unavailable(&store)?; + call( + &store, + &operator(), + Command::CorrectIdentity(api::IdentityCorrection { + undo: Some(applied.id), + ..Default::default() + }), + )?; + unavailable(&store)?; + Ok(()) +} + +// Blind write permission cannot disclose either a mapped root's or its child's relations. +#[test] +fn correction_requires_relation_read_on_all_endpoints() -> TestResult { + let dir = tempfile::tempdir()?; + let store = Store::open(dir.path(), policy())?; + let retained = create(&store, &operator(), K::Device, "Retained")?; + let duplicate = create(&store, &operator(), K::Device, "Duplicate")?; + let os = child(&store, &duplicate, K::SoftwareInstallation, "OS")?; + for permitted in [ + Vec::new(), + vec![retained.clone(), duplicate.clone()], + vec![duplicate.clone(), os.clone()], + ] { + let mut actor = operator(); + actor.grants[0].read = Fields::Selected(vec!["name".into(), "identity_evidence".into()]); + actor.grants.push(Grant { + scope: Scope::Targets( + permitted + .iter() + .map(AssetRef::try_from) + .collect::>()?, + ), + read: Fields::Selected(vec!["relations".into()]), + write: Fields::Selected(Vec::new()), + writable: false, + }); + let before = store.usage()?; + let result = store.workspace( + &actor, + api::Request { + working: Some(api::WorkingContext { + expected: Some(before.revision), + ..Default::default() + }), + command: Some(Command::CorrectIdentity(api::IdentityCorrection { + decision: api::IdentityDecision::DistinctInstallations as i32, + mappings: vec![mapping(&duplicate, &retained)], + ..Default::default() + })), + }, + 100, + ); + assert!( + matches!(result, Err(Error::Denied)), + "unreadable impact must not escape: {result:?}" + ); + assert_eq!(store.usage()?, before); + } + Ok(()) +} + +fn native_pair(store: &Store) -> Result<(Vec, Vec), Error> { + let mut first = super::super::host_import::draft(); + let a = super::super::host_import::accept( + store, + &super::super::host_import::preview(store, first.clone())?, + )? + .targets; + first.report.as_mut().ok_or(Error::Corrupt)?.source_basis = "e".repeat(64); + let b = super::super::host_import::accept( + store, + &super::super::host_import::preview(store, first)?, + )? + .targets; + Ok((a, b)) +} + +// Unlike qualified native bindings, two explicit manual namespaces with the +// same label need a reviewed rename. No rejected preview may overwrite either. +#[test] +fn manual_sources_collide_explicitly_and_keep_individual_windows() -> TestResult { + let dir = tempfile::tempdir()?; + let store = Store::open(dir.path(), policy())?; + let (a, b) = native_pair(&store)?; + for (target, address, time) in [(&a[2], "192.0.2.30", 50), (&b[2], "192.0.2.31", 60)] { + call( + &store, + &operator(), + Command::InterfaceAddresses(api::InterfaceAddresses { + interface: Some(target.clone()), + source: "Manual survey".into(), + observed_at: time, + addresses: vec![api::CurrentAddress { + address: address.into(), + stability: api::AddressStability::Stable as i32, + state: api::AddressState::Preferred as i32, + ..Default::default() + }], + complete: true, + }), + )?; + } + let mut mappings = vec![ + mapping(&b[0], &a[0]), + mapping(&b[1], &a[1]), + mapping(&b[2], &a[2]), + ]; + let before = store.usage()?; + assert!(matches!( + prepare( + &store, + mappings.clone(), + api::IdentityDecision::DuplicateInstallation + ), + Err(Error::Conflict) + )); + assert_eq!(store.usage()?, before); + mappings[2].sources.push(api::IdentitySourceChoice { + before: "Manual survey".into(), + after: "Separate survey".into(), + }); + let prepared = prepare( + &store, + mappings, + api::IdentityDecision::DuplicateInstallation, + )?; + apply(&store, &prepared.id)?; + let addresses = read(&store, &a[2])?.current_addresses; + assert_eq!(addresses.len(), 4); + for (name, address, time) in [ + ("Manual survey", "192.0.2.30", 50), + ("Separate survey", "192.0.2.31", 60), + ] { + let view = addresses + .iter() + .find(|v| v.coverage.as_ref().is_some_and(|c| c.source == name)) + .ok_or("source")?; + assert_eq!(view.observations[0].observed_at, Some(time)); + assert_eq!( + view.observations[0].value.as_ref().ok_or("value")?.address, + address + ); + } + Ok(()) +} + +// Hardware equality cannot silently carry physical port/cable attachments. +// Individual confirmation still passes through ordinary relation validation. +#[test] +fn hardware_correction_requires_each_physical_attachment_confirmation() -> TestResult { + let dir = tempfile::tempdir()?; + let store = Store::open(dir.path(), policy())?; + let a = create(&store, &operator(), K::Device, "Retained")?; + let b = create(&store, &operator(), K::Device, "Duplicate")?; + let port = child(&store, &b, K::Port, "Physical socket")?; + let relations = read(&store, &b)?.relations; + let physical = relations + .iter() + .find(|r| r.kind == wire::RelationKind::HasPort as i32) + .ok_or("physical attachment")?; + let mut selected = mapping(&b, &a); + let before = store.usage()?; + assert!(matches!( + prepare( + &store, + vec![selected.clone()], + api::IdentityDecision::DistinctInstallations + ), + Err(Error::Conflict) + )); + assert_eq!(store.usage()?, before); + selected.attachments.push(physical.relation_id.clone()); + let preview = prepare( + &store, + vec![selected], + api::IdentityDecision::DistinctInstallations, + )?; + apply(&store, &preview.id)?; + assert!( + read(&store, &port)? + .relations + .iter() + .any(|r| r.relation_id == physical.relation_id && r.from.as_ref() == Some(&a)) + ); + Ok(()) +} + +// Finite review and inverse custody expire independently of minimal retirement +// fences. Bounded cleanup removes detail without resurrecting a retired subject. +#[test] +fn correction_custody_expires_and_cleanup_preserves_alias_fences() -> TestResult { + let dir = tempfile::tempdir()?; + let limits = policy(); + let store = Store::open(dir.path(), limits.clone())?; + let a = create(&store, &operator(), K::Device, "A")?; + let b = create(&store, &operator(), K::Device, "B")?; + let preview = prepare( + &store, + vec![mapping(&b, &a)], + api::IdentityDecision::DistinctInstallations, + )?; + let before = store.usage()?; + let request = api::Request { + working: Some(api::WorkingContext::default()), + command: Some(Command::CorrectIdentity(api::IdentityCorrection { + preview: Some(preview.id.clone()), + ..Default::default() + })), + }; + assert!(matches!( + store.workspace(&operator(), request, preview.expires_at), + Err(Error::Expired) + )); + assert_eq!(store.usage()?, before); + let applied = apply(&store, &preview.id)?; + assert!(matches!( + store.workspace( + &operator(), + api::Request { + working: Some(api::WorkingContext::default()), + command: Some(Command::CorrectIdentity(api::IdentityCorrection { + undo: Some(applied.id), + ..Default::default() + })), + }, + applied.undo_until + ), + Err(Error::Expired) + )); + let mut cursor = None; + loop { + let result = store.cleanup(10_000, cursor.as_deref())?; + cursor = result.cursor; + if cursor.is_none() { + break; + } + } + assert_eq!(store.usage()?.rows[Class::Detail as usize], 0); + assert!(matches!(read(&store, &b), Err(Error::Missing))); + let Reply::ResolvedIdentity(alias) = call( + &store, + &operator(), + Command::ResolveIdentity(api::ResolveIdentity { original: Some(b) }), + )? + else { + return Err("alias".into()); + }; + assert_eq!(alias.canonical.ok_or("canonical")?.target, Some(a)); + Ok(()) +} + +// Captured inverse storage is admitted before accepting an irreversible domain +// correction. A full detail quota leaves both subjects and the preview intact. +#[test] +fn inverse_quota_failure_rolls_back_the_complete_correction() -> TestResult { + let dir = tempfile::tempdir()?; + let mut limits = policy(); + // Two creation audits plus two correction audits fit; inverse custody does not. + limits.classes[Class::Detail as usize].rows = 4; + let store = Store::open(dir.path(), limits)?; + let a = create(&store, &operator(), K::Device, "A")?; + let b = create(&store, &operator(), K::Device, "B")?; + let preview = prepare( + &store, + vec![mapping(&b, &a)], + api::IdentityDecision::DistinctInstallations, + )?; + let before = store.usage()?; + assert!(matches!(apply(&store, &preview.id), Err(Error::Limit))); + assert_eq!(store.usage()?, before); + assert!(read(&store, &a).is_ok()); + assert!(read(&store, &b).is_ok()); + Ok(()) +} + +// A backup after an accepted correction preserves canonical resolution and the +// retired source fence; restore must not turn old source identities into assets. +#[test] +fn restored_correction_keeps_canonical_identity_and_retirement() -> TestResult { + let dir = tempfile::tempdir()?; + let store = Store::open(dir.path(), policy())?; + let a = create(&store, &operator(), K::Device, "A")?; + let b = create(&store, &operator(), K::Device, "B")?; + let preview = prepare( + &store, + vec![mapping(&b, &a)], + api::IdentityDecision::DistinctInstallations, + )?; + apply(&store, &preview.id)?; + let mut archive = Vec::new(); + store.export(&mut archive)?; + let dest = tempfile::tempdir()?; + let restored = Store::restore(dest.path(), &archive, policy(), archive.len() as u64)?; + assert!(matches!(read(&restored, &b), Err(Error::Missing))); + let Reply::ResolvedIdentity(alias) = call( + &restored, + &operator(), + Command::ResolveIdentity(api::ResolveIdentity { original: Some(b) }), + )? + else { + return Err("alias".into()); + }; + assert_eq!(alias.canonical.ok_or("canonical")?.target, Some(a)); + Ok(()) +} + +// An unresolved effect blocks correction. A proven terminal intent stays on +// its original incarnation and must not prevent ordinary inventory correction. +#[test] +fn terminal_operations_are_preserved_without_retargeting() -> TestResult { + let dir = tempfile::tempdir()?; + let store = Store::open(dir.path(), policy())?; + let a = create(&store, &operator(), K::VirtualMachine, "Retained VM")?; + let b = create(&store, &operator(), K::VirtualMachine, "Duplicate VM")?; + let target = AssetRef::try_from(&b)?; + let id = store.accept(&plan(target, &[]), 100)?; + assert!(matches!( + prepare( + &store, + vec![mapping(&b, &a)], + api::IdentityDecision::DistinctInstallations + ), + Err(Error::UnsafeTransition) + )); + let lease = store.claim(id, "worker", 101, 200)?; + store.reconcile( + &lease, + 1, + wire::OperationState::Cancelled, + wire::EffectOutcome::NotSent, + None, + 102, + )?; + let preview = prepare( + &store, + vec![mapping(&b, &a)], + api::IdentityDecision::DistinctInstallations, + )?; + apply(&store, &preview.id)?; + assert_eq!(store.operation(id)?.target, target); + assert!(store.pending(None)?.is_empty()); + Ok(()) +} + +// Interface equivalence cannot choose between two still-independent OS parents, +// and a cyclic mapping cannot create an alias loop or partial relation changes. +#[test] +fn incomplete_layer_mapping_and_alias_cycles_are_atomic_conflicts() -> TestResult { + let dir = tempfile::tempdir()?; + let store = Store::open(dir.path(), policy())?; + let (a, b) = native_pair(&store)?; + let before = store.usage()?; + assert!(matches!( + prepare( + &store, + vec![mapping(&b[2], &a[2])], + api::IdentityDecision::DuplicateInstallation + ), + Err(Error::Conflict) + )); + assert_eq!(store.usage()?, before); + assert!(matches!( + prepare( + &store, + vec![mapping(&b[0], &a[0]), mapping(&a[0], &b[0])], + api::IdentityDecision::DuplicateInstallation + ), + Err(Error::Invalid) + )); + assert_eq!(store.usage()?, before); + Ok(()) +} + +// Concurrent acceptance of one reviewed change yields one canonical effect. +// A losing CAS can retry the exact receipt without another retirement/audit. +#[test] +fn concurrent_correction_acceptance_is_repeat_safe() -> TestResult { + let dir = tempfile::tempdir()?; + let store = Store::open(dir.path(), policy())?; + let a = create(&store, &operator(), K::Device, "A")?; + let b = create(&store, &operator(), K::Device, "B")?; + let preview = prepare( + &store, + vec![mapping(&b, &a)], + api::IdentityDecision::DistinctInstallations, + )?; + let gate = std::sync::Barrier::new(2); + let outcomes = std::thread::scope(|scope| { + let first = scope.spawn(|| { + gate.wait(); + apply(&store, &preview.id) + }); + let second = scope.spawn(|| { + gate.wait(); + apply(&store, &preview.id) + }); + [ + first.join().expect("first correction writer"), + second.join().expect("second correction writer"), + ] + }); + let accepted = outcomes + .iter() + .find_map(|v| v.as_ref().ok()) + .ok_or("no correction accepted")?; + for outcome in &outcomes { + match outcome { + Ok(value) => assert_eq!(value, accepted), + Err(Error::Conflict) => {} + Err(error) => return Err(error.to_string().into()), + } + } + let before = store.usage()?; + assert_eq!(apply(&store, &preview.id)?, *accepted); + assert_eq!(store.usage()?, before); + assert!(matches!(read(&store, &b), Err(Error::Missing))); + assert!(read(&store, &a).is_ok()); + Ok(()) +} diff --git a/crates/omloom-store/tests/workspace/host_import.rs b/crates/omloom-store/tests/workspace/host_import.rs index 74e6f74..347136a 100644 --- a/crates/omloom-store/tests/workspace/host_import.rs +++ b/crates/omloom-store/tests/workspace/host_import.rs @@ -1,7 +1,7 @@ use super::*; use api::{reply::Result as Reply, request::Command}; -fn draft() -> api::HostImport { +pub(super) fn draft() -> api::HostImport { api::HostImport { source: "Native fixture".into(), report: Some(Box::new(api::HostReport { @@ -53,13 +53,362 @@ fn draft() -> api::HostImport { network_parts: Vec::new(), } } -fn preview(store: &Store, request: api::HostImport) -> Result { + +fn identity_claim( + kind: api::HostIdentityKind, + namespace: &str, + digest: char, +) -> api::HostIdentityClaim { + api::HostIdentityClaim { + kind: kind as i32, + namespace: namespace.into(), + digest: digest.to_string().repeat(64), + quality: api::HostIdentityQuality::Reported as i32, + } +} + +// Identity equality is layer-specific evidence, not a host/OS merge. Missing +// later claims retain their exact old collection window rather than freshening. +#[test] +fn host_identity_evidence_is_current_scoped_and_not_freshened_by_omission() -> TestResult { + let dir = tempfile::tempdir()?; + let store = Store::open(dir.path(), policy())?; + let mut request = draft(); + let report = request.report.as_mut().unwrap(); + report.findings[0].identity.push(identity_claim( + api::HostIdentityKind::HardwareUuid, + "apple/platform-uuid", + 'a', + )); + report.findings[1].identity.push(identity_claim( + api::HostIdentityKind::InstallationId, + "macos/apfs-volume", + 'a', + )); + let accepted = accept(&store, &preview(&store, request.clone())?)?; + let host = accepted.targets[0].clone(); + let installation = accepted.targets[1].clone(); + let read = |target: wire::AssetRef| -> Result { + let Reply::Detail(value) = call( + &store, + &operator(), + Command::Read(api::Read { + target: Some(target), + ..Default::default() + }), + )? + else { + return Err(Error::Corrupt); + }; + Ok(*value) + }; + let first = read(host.clone())?; + let os = read(installation)?; + assert_eq!(first.identity.len(), 1); + assert_eq!(os.identity.len(), 1); + assert_ne!( + first.identity[0].claim.as_ref().unwrap().kind, + os.identity[0].claim.as_ref().unwrap().kind + ); + assert_eq!(first.identity[0].observed_at, 100); + let before = store.usage()?; + assert!(matches!( + call(&store, &operator(), Command::HostImport(request.clone()))?, + Reply::Mutation(_) + )); + assert_eq!( + before, + store.usage()?, + "identical claim input writes nothing" + ); + request.report.as_mut().unwrap().observed_at = 200; + request.report.as_mut().unwrap().findings[0] + .identity + .clear(); + accept(&store, &preview(&store, request)?)?; + let current = read(host)?; + assert_eq!( + first.identity, current.identity, + "omission does not reattest identity" + ); + let before = store.usage()?; + read(accepted.targets[0].clone())?; + assert_eq!( + before, + store.usage()?, + "reading claims creates no history or touches" + ); + Ok(()) +} + +// A source cannot change a claim at the same instant or place installation +// evidence on a hardware identity, even through a forged native report. +#[test] +fn host_identity_rejects_layer_forgery_and_same_instant_equivocation() -> TestResult { + let dir = tempfile::tempdir()?; + let store = Store::open(dir.path(), policy())?; + let mut request = draft(); + request.report.as_mut().unwrap().findings[0] + .identity + .push(identity_claim( + api::HostIdentityKind::InstallationId, + "linux/machine-id", + 'a', + )); + let before = store.usage()?; + assert!(matches!( + preview(&store, request.clone()), + Err(Error::Invalid) + )); + assert_eq!(before, store.usage()?); + request.report.as_mut().unwrap().findings[0].identity[0].kind = + api::HostIdentityKind::HardwareUuid as i32; + accept(&store, &preview(&store, request.clone())?)?; + request.report.as_mut().unwrap().findings[0].identity[0].digest = "b".repeat(64); + let before = store.usage()?; + assert!(matches!(preview(&store, request), Err(Error::Conflict))); + assert_eq!(before, store.usage()?); + Ok(()) +} + +// Pair comparison reads only the two selected current subjects, applies each +// field's actual rights and cannot freshen evidence or change inventory. +#[test] +fn host_identity_comparison_is_read_only_and_checks_both_subjects() -> TestResult { + let dir = tempfile::tempdir()?; + let store = Store::open(dir.path(), policy())?; + let mut request = draft(); + request.report.as_mut().unwrap().findings[0] + .identity + .push(identity_claim( + api::HostIdentityKind::HardwareUuid, + "apple/platform-uuid", + 'a', + )); + let accepted = accept(&store, &preview(&store, request)?)?; + let pair = api::CompareHostIdentity { + left: Some(accepted.targets[0].clone()), + right: Some(accepted.targets[1].clone()), + include_relations: false, + }; + let before = store.usage()?; + let Reply::HostIdentityComparison(value) = call( + &store, + &operator(), + Command::CompareHostIdentity(pair.clone()), + )? + else { + return Err("comparison".into()); + }; + assert_eq!(value.left.as_ref().unwrap().identity.len(), 1); + assert!(value.right.as_ref().unwrap().identity.is_empty()); + assert_eq!(value.revision, before.revision); + assert_eq!(before, store.usage()?); + let mut restricted = operator(); + restricted.grants[0].read = Fields::Selected(vec!["name".into()]); + let Reply::HostIdentityComparison(value) = call( + &store, + &restricted, + Command::CompareHostIdentity(pair.clone()), + )? + else { + return Err("restricted comparison".into()); + }; + assert!( + value.left.unwrap().identity.is_empty(), + "hidden claims are not disclosed on a readable host" + ); + restricted.grants[0].scope = Scope::Targets(vec![AssetRef::try_from(&accepted.targets[0])?]); + assert!(matches!( + call( + &store, + &restricted, + Command::CompareHostIdentity(pair.clone()) + ), + Err(Error::Denied) + )); + assert_eq!(before, store.usage()?); + let unrelated = create(&store, &operator(), K::Device, "Outside selected scope")?; + let before = store.usage()?; + let result = store.workspace( + &operator(), + api::Request { + working: Some(api::WorkingContext { + roots: vec![unrelated], + ..Default::default() + }), + command: Some(Command::CompareHostIdentity(pair)), + }, + 100, + ); + assert!( + matches!(result, Err(Error::Denied)), + "a broader actor grant cannot silently widen the working context" + ); + assert_eq!(before, store.usage()?); + Ok(()) +} + +// Address replacement follows the existing reviewed mapping, affects only its +// source and cannot move a service's literal bind to the new interface address. +#[test] +fn host_address_preview_is_a_source_local_delta_without_identity_changes() -> TestResult { + fn detail(store: &Store, target: &wire::AssetRef) -> Result { + let Reply::Detail(value) = call( + store, + &operator(), + Command::Read(api::Read { + target: Some(target.clone()), + ..Default::default() + }), + )? + else { + return Err(Error::Corrupt); + }; + Ok(*value) + } + let dir = tempfile::tempdir()?; + let store = Store::open(dir.path(), policy())?; + let mut request = draft(); + request + .report + .as_mut() + .unwrap() + .findings + .push(api::HostFinding { + key: "service".into(), + kind: K::Service as i32, + parent: Some("installation".into()), + fields: vec![text_field("name", "Literal listener")], + ..Default::default() + }); + request.selection.push(api::HostSelection { + key: "service".into(), + ..Default::default() + }); + let initial = preview(&store, request.clone()).map_err(|e| format!("initial preview: {e}"))?; + let accepted = accept(&store, &initial).map_err(|e| format!("initial acceptance: {e}"))?; + let interface = &accepted.targets[2]; + let service = &accepted.targets[3]; + let address = |value: &str| api::CurrentAddress { + address: value.into(), + prefix_length: Some(24), + stability: api::AddressStability::Stable as i32, + state: api::AddressState::Preferred as i32, + }; + call( + &store, + &operator(), + Command::InterfaceAddresses(api::InterfaceAddresses { + interface: Some(interface.clone()), + source: "Independent source".into(), + observed_at: 100, + addresses: vec![address("192.0.2.8")], + complete: true, + }), + )?; + store.workspace( + &operator(), + api::Request { + working: Some(api::WorkingContext { + focus: Some(accepted.targets[1].clone()), + expected: Some(store.usage()?.revision), + ..Default::default() + }), + command: Some(Command::HostNetwork(api::HostNetwork { + installation: Some(accepted.targets[1].clone()), + source: "Listener source".into(), + observed_at: 100, + basis: "b".repeat(64), + sockets: vec![api::SocketBind { + service: Some(service.clone()), + protocol: "tcp".into(), + family: "IPv4".into(), + bind: "192.0.2.7".into(), + port: 443, + ..Default::default() + }], + socket_coverage: "verified".into(), + route_coverage: "not collected".into(), + application_coverage: "not collected".into(), + packet_filter_coverage: "not collected".into(), + ..Default::default() + })), + }, + 100, + )?; + let listener = detail(&store, service) + .map_err(|e| format!("read listener: {e}"))? + .host_network; + assert!(!listener.is_empty()); + let report = request.report.as_mut().unwrap(); + report.observed_at = 200; + report.findings[2].addresses = vec![address("192.0.2.19")]; + let plan = preview(&store, request.clone()).map_err(|e| format!("replacement preview: {e}"))?; + let delta = plan.rows[2].addresses.as_ref().ok_or("address delta")?; + assert_eq!(delta.source, "Native fixture"); + assert_eq!(delta.added, vec![address("192.0.2.19")]); + assert_eq!(delta.removed, vec![address("192.0.2.7")]); + assert!(delta.updated.is_empty()); + assert!(delta.complete); + let mut restricted = operator(); + restricted.grants[0].read = Fields::Selected(vec!["name".into(), "relations".into()]); + let before_restricted_review = store.usage()?; + let Reply::Preview(hidden) = call(&store, &restricted, Command::HostImport(request.clone())) + .map_err(|e| format!("restricted review: {e}"))? + else { + return Err("restricted preview".into()); + }; + assert_eq!( + hidden.id, plan.id, + "repeating the same review does not store another copy" + ); + assert!( + hidden.rows.iter().all(|row| row.addresses.is_none()), + "a cached review cannot disclose previously readable addresses after revocation" + ); + assert_eq!(store.usage()?, before_restricted_review); + assert_eq!(accept(&store, &plan)?.targets, accepted.targets); + assert_eq!(detail(&store, service)?.host_network, listener); + let current = detail(&store, interface)?.current_addresses; + assert_eq!(current.len(), 2); + assert!(current.iter().any( + |s| s.coverage.as_ref().unwrap().source == "Independent source" + && s.observations[0].value.as_ref().unwrap().address == "192.0.2.8" + )); + let report = request.report.as_mut().unwrap(); + report.observed_at = 300; + report.findings[2].addresses = vec![address("192.0.2.20")]; + report.findings[2].addresses_complete = false; + let plan = preview(&store, request)?; + let delta = plan.rows[2].addresses.as_ref().ok_or("partial delta")?; + assert_eq!(delta.added, vec![address("192.0.2.20")]); + assert!(delta.removed.is_empty()); + assert!(!delta.complete); + accept(&store, &plan)?; + let current = detail(&store, interface)?.current_addresses; + let native = current + .iter() + .find(|s| s.coverage.as_ref().unwrap().source == "Native fixture") + .ok_or("native source")?; + assert_eq!( + native + .observations + .iter() + .map(|v| (v.value.as_ref().unwrap().address.as_str(), v.observed_at)) + .collect::>(), + vec![("192.0.2.19", Some(200)), ("192.0.2.20", Some(300))] + ); + assert_eq!(detail(&store, service)?.host_network, listener); + Ok(()) +} +pub(super) fn preview(store: &Store, request: api::HostImport) -> Result { let Reply::Preview(value) = call(store, &operator(), Command::HostImport(request))? else { return Err(Error::Corrupt); }; Ok(value) } -fn accept(store: &Store, preview: &api::Preview) -> Result { +pub(super) fn accept(store: &Store, preview: &api::Preview) -> Result { let Reply::Mutation(value) = call( store, &operator(), diff --git a/crates/omloom-store/tests/workspace/projections.rs b/crates/omloom-store/tests/workspace/projections.rs index e63305c..3560826 100644 --- a/crates/omloom-store/tests/workspace/projections.rs +++ b/crates/omloom-store/tests/workspace/projections.rs @@ -758,12 +758,133 @@ fn level_browse_returns_containers_then_direct_children() -> TestResult { Ok(()) } +// Root filtering must keep an authorized child of hidden parents, but exclude +// it when either typed direction has a visible parent, including mixed cases. +#[test] +fn level_browse_preserves_hidden_and_mixed_parent_semantics() -> TestResult { + let dir = tempfile::tempdir()?; + let store = Store::open(dir.path(), policy())?; + let actor = operator(); + let visible_os = create(&store, &actor, K::SoftwareInstallation, "Visible OS")?; + let hidden_os = create(&store, &actor, K::SoftwareInstallation, "Hidden OS")?; + let visible_vm = create(&store, &actor, K::VirtualMachine, "Visible VM")?; + let hidden_vm = create(&store, &actor, K::VirtualMachine, "Hidden VM")?; + let forward_owned = child(&store, &actor, &visible_os, K::Service, "Forward owned")?; + let reverse_owned = child(&store, &actor, &hidden_os, K::Service, "Reverse owned")?; + let hidden_owned = child(&store, &actor, &hidden_os, K::Service, "Hidden owners only")?; + let orphan = create(&store, &actor, K::Service, "Unplaced service")?; + for (service, host) in [ + (&forward_owned, &hidden_vm), + (&reverse_owned, &visible_vm), + (&hidden_owned, &hidden_vm), + ] { + call( + &store, + &actor, + Command::Relation(api::Relationship { + relation: Some(wire::Relation { + from: Some(service.clone()), + to: Some(host.clone()), + kind: wire::RelationKind::RunsOn as i32, + ..Default::default() + }), + remove: false, + }), + )?; + } + let targets = [ + &visible_os, + &visible_vm, + &forward_owned, + &reverse_owned, + &hidden_owned, + &orphan, + ]; + let mut restricted = actor.clone(); + restricted.grants[0].scope = Scope::Targets( + targets + .into_iter() + .map(AssetRef::try_from) + .collect::>()?, + ); + let usage = store.usage()?; + let Reply::Page(page) = call( + &store, + &restricted, + Command::Query(api::Query { + level_only: true, + ..query() + }), + )? + else { + return Err("page".into()); + }; + let actual: std::collections::BTreeSet<_> = page + .items + .iter() + .map(|i| { + i.target + .as_ref() + .map(|t| t.asset_id.as_str()) + .ok_or("target") + }) + .collect::>()?; + let expected: std::collections::BTreeSet<_> = + [&visible_os, &visible_vm, &hidden_owned, &orphan] + .map(|t| t.asset_id.as_str()) + .into(); + assert_eq!(actual, expected); + assert_eq!(page.total, 4); + assert_eq!(store.usage()?, usage); + Ok(()) +} + // A small estate must not re-expand the estate for every candidate's parent probe. // The wall-clock ceiling is a required regression gate; indexed plans alone do not bound latency. #[test] fn level_browse_small_estate_has_bounded_latency() -> TestResult { let dir = tempfile::tempdir()?; - let store = Store::open(dir.path(), policy())?; + let mut store = Store::open(dir.path(), policy())?; + store.set_diagnostic_query_check(|db, statement| { + if !statement.contains("forward_owned") { + return; + } + // The outer EXPLAIN abbreviates expression subqueries. Inspect the + // actual statement's optimized subplans, not its normalized fingerprint. + let mut planned = db.explain_plan(statement, None).expect("browse plan"); + let mut nested = Vec::new(); + planned.root.for_each_expr_mut(&mut |expr| { + expr.for_each_subplan_mut(&mut |subplan| { + assert!( + format!("{:?}", subplan.root).contains("Case {"), + "parent lookup must gate ineligible relation kinds" + ); + nested.push(subplan.explain()); + }); + }); + assert_eq!(nested.len(), 2, "both ownership probes must be checked"); + for plan in &nested { + assert!( + !plan.contains("Traverse("), + "parent endpoint must use its key: {plan}" + ); + assert_eq!( + plan.matches("IndexScan(p:OmRow ON omloom_key(key))") + .count(), + 1, + "each probe must look up its exact parent: {plan}" + ); + } + let plan = nested.join("\n"); + for index in ["omloom_asset(asset)", "omloom_to_asset(to_asset)"] { + assert_eq!( + plan.matches(&format!("IndexScan(r:OmRow ON {index})")) + .count(), + 1, + "each parent probe must use its endpoint index: {plan}" + ); + } + }); let actor = operator(); for index in 0..16 { let host = create(&store, &actor, K::Device, &format!("host {index}"))?; @@ -795,19 +916,22 @@ fn level_browse_small_estate_has_bounded_latency() -> TestResult { for query in db.query_registry().top_by_impact(32) { if query.canonical_query.contains("forward_owned") { browse_plans += 1; - let plan = query.last_plan.ok_or("missing browse plan")?; - for index in ["omloom_asset(asset)", "omloom_to_asset(to_asset)"] { - assert_eq!( - plan.matches(&format!("IndexScan(r:OmRow ON {index})")) - .count(), - 1, - "each parent probe must use its endpoint index: {plan}" - ); - } + assert_eq!(query.count, 1, "browse execution must not repeat"); + assert!( + !query + .canonical_query + .to_ascii_lowercase() + .contains("collect("), + "page selection must not collect every matching key" + ); + query.last_plan.ok_or("missing browse plan")?; eprintln!("browse statement: {} us", query.total_time_us); } } - assert_eq!(browse_plans, 2, "count and page plans must both be checked"); + assert_eq!( + browse_plans, 2, + "count and bounded page plans must both be checked" + ); eprintln!("level browse elapsed: {elapsed:?}"); assert_eq!(page.total, 16); assert_eq!(page.items.len(), 16); @@ -819,6 +943,62 @@ fn level_browse_small_estate_has_bounded_latency() -> TestResult { Ok(()) } +// Count/page projections preserve empty results, duplicate sort values, +// descending order and cursor continuation without incidental writes. +#[test] +fn total_and_page_keep_empty_results_and_stable_ties() -> TestResult { + let dir = tempfile::tempdir()?; + let store = Store::open(dir.path(), policy())?; + let actor = operator(); + let mut expected = Vec::new(); + for name in ["beta", "alpha", "beta"] { + let target = create(&store, &actor, K::Device, name)?; + expected.push((name.to_owned(), target.asset_id)); + } + expected.sort_by(|a, b| b.0.cmp(&a.0).then_with(|| a.1.cmp(&b.1))); + let before = store.usage()?; + let mut q = query(); + q.level_only = true; + q.view = api::View::Hardware as i32; + q.limit = 1; + q.descending = true; + let mut actual = Vec::new(); + loop { + let Reply::Page(page) = call(&store, &actor, Command::Query(q.clone()))? else { + return Err("page".into()); + }; + assert_eq!(page.total, 3); + assert_eq!(page.items.len(), 1); + actual.push( + page.items[0] + .target + .as_ref() + .ok_or("target")? + .asset_id + .clone(), + ); + q.cursor = page.next; + if q.cursor.is_none() { + break; + } + assert!(actual.len() < 3, "cursor must advance"); + } + assert_eq!( + actual, + expected.into_iter().map(|(_, id)| id).collect::>() + ); + q.search = "no resource matches this text".into(); + q.cursor = None; + let Reply::Page(empty) = call(&store, &actor, Command::Query(q))? else { + return Err("empty page".into()); + }; + assert_eq!(empty.total, 0); + assert!(empty.items.is_empty()); + assert!(empty.next.is_none()); + assert_eq!(store.usage()?, before); + Ok(()) +} + // One hundred hosts must still return one bounded page without their two hundred ports. #[test] fn level_browse_hundred_hosts_only_projects_one_page() -> TestResult { diff --git a/crates/omloom-store/tests/workspace/responsibility.rs b/crates/omloom-store/tests/workspace/responsibility.rs index 30e82c2..fc120ca 100644 --- a/crates/omloom-store/tests/workspace/responsibility.rs +++ b/crates/omloom-store/tests/workspace/responsibility.rs @@ -923,7 +923,8 @@ fn responsibility_global_filter_excludes_unselected_hosting_sources() -> TestRes let actor = operator(); let first = create(&store, &actor, K::Device, "Administrative host")?; let second = create(&store, &actor, K::Device, "Placement host")?; - let guest = create(&store, &actor, K::VirtualMachine, "Unrelated guest")?; + // Services can have multiple hosting relations with one selected administrative source. + let guest = create(&store, &actor, K::Service, "Unrelated service")?; responsibility(&store, &actor, &first, Action::Apply, vec![owner("other")])?; responsibility( &store, @@ -987,7 +988,7 @@ fn responsibility_global_filter_excludes_unselected_hosting_sources() -> TestRes ..query() }, api::Query { - view: api::View::Services as i32, + view: api::View::Virtualization as i32, ..query() }, ] { diff --git a/crates/omloom-store/tests/workspace/typed_values.rs b/crates/omloom-store/tests/workspace/typed_values.rs new file mode 100644 index 0000000..0b5fa63 --- /dev/null +++ b/crates/omloom-store/tests/workspace/typed_values.rs @@ -0,0 +1,308 @@ +use super::*; +use api::{LocatorKind as L, reply::Result as Reply, request::Command}; +use omloom_store::Class; + +fn request(store: &Store, actor: &Context, command: Command) -> Result { + store + .workspace( + actor, + api::Request { + working: Some(api::WorkingContext { + expected: Some(store.usage()?.revision), + ..Default::default() + }), + command: Some(command), + }, + 100, + )? + .result + .ok_or(Error::Corrupt) +} + +// All writes and imports use the same DNS validator; labels stay independent of DNS syntax. +#[test] +fn domain_dns_validation_covers_write_bulk_and_csv() -> TestResult { + let dir = tempfile::tempdir()?; + let store = Store::open(dir.path(), policy())?; + let actor = operator(); + let domain = create(&store, &actor, K::Domain, "Corporate domain label")?; + let revision = store.asset(AssetRef::try_from(&domain)?)?.revision; + for invalid in ["bad domain", "-bad.test", "192.0.2.1", "a..test"] { + let before = store.usage()?; + assert!(matches!( + request( + &store, + &actor, + Command::Write(api::Write { + target: Some(domain.clone()), + kind: K::Domain as i32, + expected: Some(revision), + changes: vec![text_field("dns_name", invalid)], + ..Default::default() + }) + ), + Err(Error::Invalid) + )); + assert!(matches!( + request( + &store, + &actor, + Command::Bulk(api::Bulk { + targets: vec![domain.clone()], + expected: before.revision, + changes: vec![text_field("dns_name", invalid)], + ..Default::default() + }) + ), + Err(Error::Invalid) + )); + assert_eq!(store.usage()?, before); + } + request( + &store, + &actor, + Command::Write(api::Write { + target: Some(domain.clone()), + kind: K::Domain as i32, + expected: Some(revision), + changes: vec![text_field("dns_name", "münchen.de")], + ..Default::default() + }), + )?; + let Reply::Page(page) = request( + &store, + &actor, + Command::Query(api::Query { + search: "münchen.de".into(), + columns: vec!["name".into(), "dns_name".into()], + limit: 25, + ..Default::default() + }), + )? + else { + return Err("page".into()); + }; + assert_eq!(page.total, 1); + assert_eq!(page.items[0].target.as_ref(), Some(&domain)); + let before = store.usage()?; + let Reply::Preview(preview) = request( + &store, + &actor, + Command::Import(api::Import { + source: "DNS fixture".into(), + kind: K::Domain as i32, + csv: "external_id,name,dns\n1,Separate label,bad domain\n".into(), + mappings: vec![ + api::Mapping { + source: "name".into(), + field: "name".into(), + }, + api::Mapping { + source: "dns".into(), + field: "dns_name".into(), + }, + ], + ..Default::default() + }), + )? + else { + return Err("preview".into()); + }; + assert!(preview.rows[0].change.is_none()); + assert!(!preview.rows[0].diagnostic.is_empty()); + assert_eq!( + store.usage()?.rows[Class::Inventory as usize], + before.rows[Class::Inventory as usize] + ); + Ok(()) +} + +// Direct callers cannot bypass typed contacts, change their kind without authority, +// or discover a hidden contact's type. Reopen preserves the declared type and bytes. +#[test] +fn typed_contacts_share_value_permissions_and_survive_reopen() -> TestResult { + let dir = tempfile::tempdir()?; + let store = Store::open(dir.path(), policy())?; + let actor = operator(); + let target = create(&store, &actor, K::Device, "Contact owner")?; + let assignment = api::ResponsibilityAssignment { + facet: api::ResponsibilityFacet::ClaimedOwner as i32, + mode: api::AssignmentMode::Explicit as i32, + party: Some(api::PartyRef { + issuer: "opaque namespace".into(), + subject: "team".into(), + name: "Team".into(), + kind: api::PartyKind::Team as i32, + }), + contact: Some("User+tag@example.test".into()), + contact_kind: L::Email as i32, + ..Default::default() + }; + let apply = |actor: &Context, change| { + request( + &store, + actor, + Command::Responsibility(api::Responsibility { + target: Some(target.clone()), + expected: Some( + store + .asset(AssetRef::try_from(&target).map_err(|_| Error::Invalid)?)? + .revision, + ), + action: api::ResponsibilityAction::Apply as i32, + changes: vec![change], + ..Default::default() + }), + ) + }; + for (kind, value) in [ + (L::Email, "bad email"), + (L::Phone, "+123"), + (L::Domain, "bad domain"), + (L::Web, "javascript:alert(1)"), + ] { + let before = store.usage()?; + let mut change = assignment.clone(); + change.contact_kind = kind as i32; + change.contact = Some(value.into()); + assert!(matches!(apply(&actor, change), Err(Error::Invalid))); + assert_eq!(store.usage()?, before); + } + apply(&actor, assignment.clone())?; + let before = store.usage()?; + apply(&actor, assignment.clone())?; + assert_eq!(store.usage()?, before); + let mut limited = actor.clone(); + limited.grants[0].write = Fields::Selected(vec!["claimed_owner".into()]); + let mut changed = assignment.clone(); + changed.contact_kind = L::Unspecified as i32; + assert!(matches!(apply(&limited, changed), Err(Error::Denied))); + assert_eq!(store.usage()?, before); + limited.grants[0].read = Fields::Selected(vec!["name".into(), "claimed_owner".into()]); + let Reply::Responsibility(hidden) = request( + &store, + &limited, + Command::Responsibility(api::Responsibility { + target: Some(target.clone()), + action: api::ResponsibilityAction::Read as i32, + ..Default::default() + }), + )? + else { + return Err("hidden state".into()); + }; + assert!(hidden.assignments[0].contact.is_none()); + assert_eq!(hidden.assignments[0].contact_kind, L::Unspecified as i32); + assert!( + hidden + .effective + .iter() + .all(|v| v.contact.is_none() && v.contact_kind == L::Unspecified as i32) + ); + drop(store); + let store = Store::open(dir.path(), policy())?; + let Reply::Responsibility(saved) = request( + &store, + &actor, + Command::Responsibility(api::Responsibility { + target: Some(target), + action: api::ResponsibilityAction::Read as i32, + ..Default::default() + }), + )? + else { + return Err("saved state".into()); + }; + assert_eq!(saved.assignments[0].contact, assignment.contact); + assert_eq!(saved.assignments[0].contact_kind, L::Email as i32); + Ok(()) +} + +// Account IDs remain opaque; sender/recovery syntax and discriminator changes +// share their existing field permissions, including redaction and repeat ingestion. +#[test] +fn typed_account_values_preserve_provider_identity_and_field_authority() -> TestResult { + let dir = tempfile::tempdir()?; + let store = Store::open(dir.path(), policy())?; + let actor = operator(); + let account = create(&store, &actor, K::ProviderAccount, "Registrar account")?; + let identity = AssetRef::try_from(&account)?; + let metadata = api::AccountMetadata { + issuer: "opaque provider namespace".into(), + subject: "personal ID / 7".into(), + kind: api::ExternalAccountKind::Programmatic as i32, + sender: Some("User+tag@example.test".into()), + sender_kind: L::Email as i32, + recovery_route: Some("+442079460018".into()), + recovery_route_kind: L::Phone as i32, + ..Default::default() + }; + let save = |actor: &Context, value| { + request( + &store, + actor, + Command::ExternalAccount(api::ExternalAccount { + target: Some(account.clone()), + expected: Some(store.asset(identity)?.revision), + metadata: Some(value), + ..Default::default() + }), + ) + }; + for (kind, value) in [ + (L::Email, "not email"), + (L::Domain, "bad domain"), + (L::Phone, "+442079460018"), + ] { + let before = store.usage()?; + let mut invalid = metadata.clone(); + invalid.sender = Some(value.into()); + invalid.sender_kind = kind as i32; + assert!(matches!(save(&actor, invalid), Err(Error::Invalid))); + assert_eq!(store.usage()?, before); + } + save(&actor, metadata.clone())?; + let before = store.usage()?; + save(&actor, metadata.clone())?; + assert_eq!(store.usage()?, before); + let mut limited = actor.clone(); + limited.grants[0].write = Fields::Selected(vec![ + "issuer".into(), + "subject".into(), + "account_kind".into(), + "delegation".into(), + "login".into(), + "recovery_route".into(), + ]); + let mut changed = metadata.clone(); + changed.sender_kind = L::Unspecified as i32; + assert!(matches!(save(&limited, changed), Err(Error::Denied))); + assert_eq!(store.usage()?, before); + limited.grants[0].read = Fields::Selected(vec![ + "name".into(), + "issuer".into(), + "subject".into(), + "account_kind".into(), + "delegation".into(), + "recovery_route".into(), + ]); + let Reply::ExternalAccount(hidden) = request( + &store, + &limited, + Command::ExternalAccount(api::ExternalAccount { + target: Some(account), + ..Default::default() + }), + )? + else { + return Err("account".into()); + }; + let saved = hidden.metadata.ok_or("metadata")?; + assert!(saved.sender.is_none()); + assert_eq!(saved.sender_kind, L::Unspecified as i32); + assert_eq!(saved.issuer, metadata.issuer); + assert_eq!(saved.subject, metadata.subject); + assert_eq!(saved.recovery_route, metadata.recovery_route); + assert_eq!(saved.recovery_route_kind, L::Phone as i32); + Ok(()) +} diff --git a/crates/omloom-store/tests/workspace/vm_placement.rs b/crates/omloom-store/tests/workspace/vm_placement.rs new file mode 100644 index 0000000..a2a72b3 --- /dev/null +++ b/crates/omloom-store/tests/workspace/vm_placement.rs @@ -0,0 +1,670 @@ +use super::*; +use api::{reply::Result as Reply, request::Command}; +use wire::RelationKind as R; + +pub(super) fn hypervisor(store: &Store, name: &str) -> Result { + let Reply::Mutation(result) = call( + store, + &operator(), + Command::Write(api::Write { + kind: K::SoftwareInstallation as i32, + changes: vec![ + text_field("name", name), + text_field("virtualization_role", "VIRTUALIZATION_ROLE_HYPERVISOR"), + ], + ..Default::default() + }), + )? + else { + return Err(Error::Corrupt); + }; + result.targets.into_iter().next().ok_or(Error::Corrupt) +} + +fn place(store: &Store, guest: &wire::AssetRef, host: &wire::AssetRef) -> Result { + call( + store, + &operator(), + Command::Relation(api::Relationship { + relation: Some(wire::Relation { + from: Some(guest.clone()), + to: Some(host.clone()), + kind: R::RunsOn as i32, + ..Default::default() + }), + remove: false, + }), + ) +} + +fn placement(store: &Store, guest: &wire::AssetRef) -> Result { + let Reply::Detail(detail) = call( + store, + &operator(), + Command::Read(api::Read { + target: Some(guest.clone()), + ..Default::default() + }), + )? + else { + return Err(Error::Corrupt); + }; + detail + .relations + .into_iter() + .find(|r| r.kind == R::RunsOn as i32) + .ok_or(Error::Missing) +} + +fn prepare( + store: &Store, + actor: &Context, + guest: &wire::AssetRef, + host: Option<&wire::AssetRef>, +) -> Result { + let Reply::VmPlacementReview(value) = call( + store, + actor, + Command::PrepareVmPlacement(api::PrepareVmPlacement { + vm: Some(guest.clone()), + destination: host.cloned(), + }), + )? + else { + return Err(Error::Corrupt); + }; + Ok(*value) +} +fn record( + store: &Store, + actor: &Context, + review: &api::VmPlacementReview, +) -> Result { + let mut relation = review.current.clone().ok_or(Error::Missing)?; + relation.to = review.destination.as_ref().and_then(|i| i.target.clone()); + store.workspace( + actor, + api::Request { + working: Some(api::WorkingContext { + expected: Some(review.revision), + ..Default::default() + }), + command: Some(Command::Relation(api::Relationship { + relation: Some(relation), + remove: false, + })), + }, + 100, + ) +} + +// An unplaced VM uses the reviewed create-relation path, with the same root CAS. +#[test] +fn first_placement_is_reviewed_read_only_and_revision_checked() -> TestResult { + let dir = tempfile::tempdir()?; + let store = Store::open(dir.path(), policy())?; + let guest = create(&store, &operator(), K::VirtualMachine, "Unplaced guest")?; + let host = hypervisor(&store, "Destination")?; + let before = store.usage()?; + let review = prepare(&store, &operator(), &guest, Some(&host))?; + assert!(review.current.is_none()); + assert!(review.current_host.is_none()); + assert_eq!( + review + .destination + .as_ref() + .and_then(|item| item.target.as_ref()), + Some(&host) + ); + assert_eq!(store.usage()?, before); + let submit = |view: &api::VmPlacementReview| { + store.workspace( + &operator(), + api::Request { + working: Some(api::WorkingContext { + roots: vec![guest.clone(), host.clone()], + expected: Some(view.revision), + ..Default::default() + }), + command: Some(Command::Relation(api::Relationship { + relation: Some(wire::Relation { + from: Some(guest.clone()), + to: Some(host.clone()), + kind: R::RunsOn as i32, + ..Default::default() + }), + remove: false, + })), + }, + 100, + ) + }; + create(&store, &operator(), K::Device, "Concurrent change")?; + assert!(matches!(submit(&review), Err(Error::Conflict))); + assert!(read(&store, &guest)?.relations.is_empty()); + let fresh = prepare(&store, &operator(), &guest, Some(&host))?; + submit(&fresh)?; + let current = placement(&store, &guest)?; + assert_eq!(current.from.as_ref(), Some(&guest)); + assert_eq!(current.to.as_ref(), Some(&host)); + assert_eq!(current.revision, 1); + assert_eq!( + prepare(&store, &operator(), &guest, None)?.current, + Some(current) + ); + Ok(()) +} +fn child(store: &Store, parent: &wire::AssetRef, kind: K) -> Result { + let Reply::Mutation(value) = call( + store, + &operator(), + Command::Write(api::Write { + parent: Some(parent.clone()), + kind: kind as i32, + changes: vec![text_field("name", "Guest component")], + ..Default::default() + }), + )? + else { + return Err(Error::Corrupt); + }; + value.targets.into_iter().next().ok_or(Error::Corrupt) +} +fn address(store: &Store, interface: &wire::AssetRef, value: &str, time: u64) -> Result<(), Error> { + call( + store, + &operator(), + Command::InterfaceAddresses(api::InterfaceAddresses { + interface: Some(interface.clone()), + source: "Guest report".into(), + observed_at: time, + complete: true, + addresses: vec![api::CurrentAddress { + address: value.into(), + stability: api::AddressStability::Stable as i32, + state: api::AddressState::Preferred as i32, + ..Default::default() + }], + }), + )?; + Ok(()) +} +fn read(store: &Store, target: &wire::AssetRef) -> Result { + let Reply::Detail(value) = call( + store, + &operator(), + Command::Read(api::Read { + target: Some(target.clone()), + ..Default::default() + }), + )? + else { + return Err(Error::Corrupt); + }; + Ok(*value) +} + +pub(super) fn responsibility( + store: &Store, + target: &wire::AssetRef, + changes: Vec, +) -> Result { + let request = api::Responsibility { + target: Some(target.clone()), + expected: (!changes.is_empty()) + .then(|| { + store + .asset(AssetRef::try_from(target).map_err(|_| Error::Invalid)?) + .map(|a| a.revision) + }) + .transpose()?, + action: if changes.is_empty() { + api::ResponsibilityAction::Read + } else { + api::ResponsibilityAction::Apply + } as i32, + changes, + ..Default::default() + }; + let value = store + .workspace( + &operator(), + api::Request { + working: Some(api::WorkingContext { + expected: Some(store.usage()?.revision), + ..Default::default() + }), + command: Some(Command::Responsibility(request)), + }, + 100, + )? + .result + .ok_or(Error::Corrupt)?; + let Reply::Responsibility(value) = value else { + return Err(Error::Corrupt); + }; + Ok(value) +} + +// Explicit placement-bound inheritance must become unavailable, rather than +// silently taking the new host's owner. Independent direct declarations survive. +#[test] +fn migration_previews_only_permitted_inheritance_and_never_transfers_ownership() -> TestResult { + use api::{AssignmentMode as M, ResponsibilityFacet as F, ResponsibilityStatus as S}; + let dir = tempfile::tempdir()?; + let store = Store::open(dir.path(), policy())?; + let guest = create(&store, &operator(), K::VirtualMachine, "Guest")?; + let first = hypervisor(&store, "First host")?; + let second = hypervisor(&store, "Second host")?; + let assigned = |facet: F, subject: &str| api::ResponsibilityAssignment { + facet: facet as i32, + mode: M::Explicit as i32, + party: Some(api::PartyRef { + issuer: "company.example".into(), + subject: subject.into(), + name: subject.into(), + kind: api::PartyKind::Team as i32, + }), + ..Default::default() + }; + responsibility( + &store, + &first, + vec![assigned(F::ClaimedOwner, "Original team")], + )?; + responsibility( + &store, + &second, + vec![assigned(F::ClaimedOwner, "Other team")], + )?; + place(&store, &guest, &first)?; + let old = placement(&store, &guest)?; + responsibility( + &store, + &guest, + vec![ + api::ResponsibilityAssignment { + facet: F::ClaimedOwner as i32, + mode: M::Inherit as i32, + source_relation: Some(old.relation_id.clone()), + source_revision: Some(old.revision), + ..Default::default() + }, + assigned(F::Operator, "Guest operators"), + ], + )?; + let before = responsibility(&store, &guest, Vec::new())?; + assert_eq!( + before + .effective + .iter() + .find(|v| v.facet == F::ClaimedOwner as i32) + .ok_or("owner")? + .party + .as_ref() + .ok_or("party")? + .subject, + "Original team" + ); + let review = prepare(&store, &operator(), &guest, Some(&second))?; + assert_eq!(review.invalidated_inheritance.len(), 1); + assert!(review.invalidated_inheritance[0].contact.is_none()); + assert!(review.invalidated_inheritance[0].source.is_none()); + assert!(review.invalidated_inheritance[0].source_revision.is_none()); + assert_eq!( + review.invalidated_inheritance[0] + .party + .as_ref() + .ok_or("party")? + .subject, + "Original team" + ); + let mut hidden = operator(); + hidden.grants[0].read = Fields::Selected(vec![ + "name".into(), + "relations".into(), + "virtualization_role".into(), + ]); + assert!( + prepare(&store, &hidden, &guest, Some(&second))? + .invalidated_inheritance + .is_empty() + ); + record(&store, &operator(), &review)?; + let after = responsibility(&store, &guest, Vec::new())?; + // The read projection hides the no-longer-resolvable source reference, + // while preserving its INHERIT declaration and every independent facet. + let mut expected = before.assignments.clone(); + for assignment in &mut expected { + if assignment.source_relation.as_deref() == Some(old.relation_id.as_str()) { + assignment.source_relation = None; + assignment.source_revision = None; + } + } + assert_eq!(after.assignments, expected); + let owner = after + .effective + .iter() + .find(|v| v.facet == F::ClaimedOwner as i32) + .ok_or("owner")?; + assert_eq!(owner.status, S::SourceUnavailable as i32); + assert!(owner.party.is_none()); + assert_eq!( + after + .effective + .iter() + .find(|v| v.facet == F::Operator as i32) + .ok_or("operator")?, + before + .effective + .iter() + .find(|v| v.facet == F::Operator as i32) + .ok_or("operator")? + ); + Ok(()) +} + +// Placement is one conditional relation change. Guest identity/subtree and +// source-qualified addresses survive it; a later address report is independent. +#[test] +fn migration_keeps_guest_components_and_accepts_a_separate_ip_change() -> TestResult { + let dir = tempfile::tempdir()?; + let store = Store::open(dir.path(), policy())?; + let guest = create(&store, &operator(), K::VirtualMachine, "Guest")?; + let first = hypervisor(&store, "First host")?; + let second = hypervisor(&store, "Second host")?; + place(&store, &guest, &first)?; + let os = child(&store, &guest, K::SoftwareInstallation)?; + let interface = child(&store, &os, K::Interface)?; + let service = child(&store, &os, K::Service)?; + address(&store, &interface, "192.0.2.30", 50)?; + let before: Vec<_> = [&guest, &os, &interface, &service] + .into_iter() + .map(|t| { + Ok(( + t.clone(), + store.asset(AssetRef::try_from(t).map_err(|_| Error::Invalid)?)?, + )) + }) + .collect::>()?; + let addresses = read(&store, &interface)?.current_addresses; + let usage = store.usage()?; + let review = prepare(&store, &operator(), &guest, Some(&second))?; + assert_eq!( + review.current_host.as_ref().and_then(|i| i.target.as_ref()), + Some(&first) + ); + assert_eq!(store.usage()?, usage, "review must not write"); + record(&store, &operator(), &review)?; + for (target, asset) in before { + assert_eq!(store.asset(AssetRef::try_from(&target)?)?, asset); + } + let current = placement(&store, &guest)?; + let previous = review.current.ok_or("placement")?; + assert_eq!(current.relation_id, previous.relation_id); + assert_eq!(current.revision, previous.revision + 1); + assert_eq!(current.to, Some(second.clone())); + assert_eq!(read(&store, &interface)?.current_addresses, addresses); + let unchanged = prepare(&store, &operator(), &guest, Some(&second))?; + let usage = store.usage()?; + record(&store, &operator(), &unchanged)?; + assert_eq!( + store.usage()?, + usage, + "repeated unchanged placement must not write" + ); + address(&store, &interface, "192.0.2.31", 60)?; + let updated = read(&store, &interface)?.current_addresses; + assert_eq!(updated.len(), 1); + assert_eq!(updated[0].observations.len(), 1); + assert_eq!( + updated[0].observations[0] + .value + .as_ref() + .ok_or("address")? + .address, + "192.0.2.31" + ); + assert_eq!(placement(&store, &guest)?, current); + drop(store); + let store = Store::open(dir.path(), policy())?; + assert_eq!(placement(&store, &guest)?, current); + assert_eq!(read(&store, &interface)?.current_addresses, updated); + Ok(()) +} + +// Losing the old host's role must not prevent reviewing a move to an eligible +// destination. Preparation shows the actual old host and writes nothing; the +// reviewed move must then replace that placement successfully. +#[test] +fn placement_review_allows_move_away_from_ineligible_current_host() -> TestResult { + let dir = tempfile::tempdir()?; + let store = Store::open(dir.path(), policy())?; + let guest = create(&store, &operator(), K::VirtualMachine, "Guest")?; + let first = hypervisor(&store, "First host")?; + let second = hypervisor(&store, "Second host")?; + place(&store, &guest, &first)?; + call( + &store, + &operator(), + Command::Write(api::Write { + target: Some(first.clone()), + expected: Some(store.asset(AssetRef::try_from(&first)?)?.revision), + kind: K::SoftwareInstallation as i32, + changes: vec![text_field( + "virtualization_role", + "VIRTUALIZATION_ROLE_NOT_HYPERVISOR", + )], + ..Default::default() + }), + )?; + let usage = store.usage()?; + let review = prepare(&store, &operator(), &guest, Some(&second))?; + assert_eq!( + review.current_host.as_ref().and_then(|i| i.target.as_ref()), + Some(&first) + ); + assert_eq!( + review.destination.as_ref().and_then(|i| i.target.as_ref()), + Some(&second) + ); + assert_eq!(store.usage()?, usage); + record(&store, &operator(), &review)?; + assert_eq!(placement(&store, &guest)?.to, Some(second)); + Ok(()) +} + +// A caller cannot disguise a hidden old host as "unplaced", use a local VM +// context for peer placement, or apply an obsolete role/revision review. +#[test] +fn placement_review_rechecks_old_host_context_role_and_revision() -> TestResult { + let dir = tempfile::tempdir()?; + let store = Store::open(dir.path(), policy())?; + let guest = create(&store, &operator(), K::VirtualMachine, "Guest")?; + let first = hypervisor(&store, "First host")?; + let second = hypervisor(&store, "Second host")?; + place(&store, &guest, &first)?; + let mut hidden = operator(); + hidden.grants[0].scope = Scope::Targets(vec![ + AssetRef::try_from(&guest)?, + AssetRef::try_from(&second)?, + ]); + let usage = store.usage()?; + assert!(matches!( + prepare(&store, &hidden, &guest, None), + Err(Error::Denied) + )); + let review = prepare(&store, &operator(), &guest, Some(&second))?; + assert!(matches!( + record(&store, &hidden, &review), + Err(Error::Denied) + )); + let local = store.workspace( + &operator(), + api::Request { + working: Some(api::WorkingContext { + focus: Some(guest.clone()), + ..Default::default() + }), + command: Some(Command::PrepareVmPlacement(api::PrepareVmPlacement { + vm: Some(guest.clone()), + destination: Some(second.clone()), + })), + }, + 100, + ); + assert!(matches!(local, Err(Error::Denied))); + assert_eq!(store.usage()?, usage); + call( + &store, + &operator(), + Command::Write(api::Write { + target: Some(second.clone()), + expected: Some(store.asset(AssetRef::try_from(&second)?)?.revision), + changes: vec![text_field( + "virtualization_role", + "VIRTUALIZATION_ROLE_NOT_HYPERVISOR", + )], + kind: K::SoftwareInstallation as i32, + ..Default::default() + }), + )?; + let usage = store.usage()?; + assert!(matches!( + record(&store, &operator(), &review), + Err(Error::Conflict) + )); + assert!(matches!( + prepare(&store, &operator(), &guest, Some(&second)), + Err(Error::Invalid) + )); + assert_eq!(store.usage()?, usage); + assert_eq!(placement(&store, &guest)?.to, Some(first)); + Ok(()) +} + +// A migration replaces the current placement, rather than adding a second +// accepted host and leaving the same VM visible under both hypervisors. +#[test] +fn second_current_host_requires_replacing_the_existing_placement() -> TestResult { + let dir = tempfile::tempdir()?; + let store = Store::open(dir.path(), policy())?; + let guest = create(&store, &operator(), K::VirtualMachine, "Guest")?; + let first = hypervisor(&store, "First host")?; + let second = hypervisor(&store, "Second host")?; + place(&store, &guest, &first)?; + let before = store.usage()?; + assert!(matches!( + place(&store, &guest, &second), + Err(Error::Conflict) + )); + assert_eq!(store.usage()?, before); + assert_eq!(placement(&store, &guest)?.to, Some(first)); + Ok(()) +} + +// In-flight effects keep their original target/placement. Reconciliation must +// settle them before recording another host or deleting the current relation. +#[test] +fn unresolved_effects_block_placement_changes_and_removal() -> TestResult { + let dir = tempfile::tempdir()?; + let store = Store::open(dir.path(), policy())?; + let guest = create(&store, &operator(), K::VirtualMachine, "Guest")?; + let first = hypervisor(&store, "First host")?; + let second = hypervisor(&store, "Second host")?; + place(&store, &guest, &first)?; + let review = prepare(&store, &operator(), &guest, Some(&second))?; + let target = AssetRef::try_from(&guest)?; + let operation = store.accept(&plan(target, &[]), 100)?; + let usage = store.usage()?; + assert!(matches!( + prepare(&store, &operator(), &guest, Some(&second)), + Err(Error::UnsafeTransition) + )); + let mut moved = review.current.clone().ok_or("placement")?; + moved.to = Some(second.clone()); + assert!(matches!( + call( + &store, + &operator(), + Command::Relation(api::Relationship { + relation: Some(moved), + remove: false + }) + ), + Err(Error::UnsafeTransition) + )); + assert!(matches!( + call( + &store, + &operator(), + Command::Relation(api::Relationship { + relation: review.current, + remove: true + }) + ), + Err(Error::UnsafeTransition) + )); + assert_eq!(store.usage()?, usage); + let lease = store.claim(operation, "worker", 101, 200)?; + store.reconcile( + &lease, + 1, + wire::OperationState::Cancelled, + wire::EffectOutcome::NotSent, + None, + 102, + )?; + let fresh = prepare(&store, &operator(), &guest, Some(&second))?; + record(&store, &operator(), &fresh)?; + assert_eq!(store.operation(operation)?.target, target); + assert_eq!(placement(&store, &guest)?.to, Some(second)); + Ok(()) +} + +// Two reviews share one root/relation basis. Exactly one can change it; the +// loser must obtain a new review rather than adding or overwriting a placement. +#[test] +fn concurrent_moves_accept_only_one_reviewed_destination() -> TestResult { + let dir = tempfile::tempdir()?; + let store = Store::open(dir.path(), policy())?; + let guest = create(&store, &operator(), K::VirtualMachine, "Guest")?; + let first = hypervisor(&store, "First host")?; + let second = hypervisor(&store, "Second host")?; + let third = hypervisor(&store, "Third host")?; + place(&store, &guest, &first)?; + let a = prepare(&store, &operator(), &guest, Some(&second))?; + let b = prepare(&store, &operator(), &guest, Some(&third))?; + let barrier = std::sync::Barrier::new(2); + let results = std::thread::scope(|scope| { + let workers: Vec<_> = [&a, &b] + .into_iter() + .map(|review| { + let (store, barrier) = (&store, &barrier); + scope.spawn(move || { + barrier.wait(); + record(store, &operator(), review) + }) + }) + .collect(); + workers + .into_iter() + .map(|worker| worker.join()) + .collect::>() + }); + let results: Vec<_> = results + .into_iter() + .map(|r| r.map_err(|_| "placement worker panicked")) + .collect::>()?; + assert_eq!(results.iter().filter(|r| r.is_ok()).count(), 1); + assert_eq!( + results + .iter() + .filter(|r| matches!(r, Err(Error::Conflict))) + .count(), + 1 + ); + let host = placement(&store, &guest)?.to.ok_or("host")?; + assert!(host == second || host == third); + Ok(()) +} diff --git a/docs/contracts.md b/docs/contracts.md index 57b6216..fc44e00 100644 --- a/docs/contracts.md +++ b/docs/contracts.md @@ -108,6 +108,9 @@ integers. Browser verification is not installation or application acceptance. Install Rust 1.95+, protoc 36.2+, Node 24+, Corepack and cargo-nextest. Run `corepack enable`, `yarn install --immutable`, then `yarn playwright install chromium`. +The complete verification gate also needs the Docker CLI to render the collector's +inspect template against an isolated synthetic API. It uses an empty temporary +client configuration and requires no Docker daemon or running containers. Run `sh scripts/check-contracts.sh` for the complete generation/build/test/browser gate. Successful runs clean their own build outputs; failures preserve evidence. Generated TypeScript is committed diff --git a/docs/inventory.md b/docs/inventory.md index 9bcec35..796130b 100644 --- a/docs/inventory.md +++ b/docs/inventory.md @@ -138,6 +138,28 @@ Replacing account metadata cannot implicitly clear an unreadable saved field. The complete metadata form requires disclosure of every metadata field; selected binding changes retain their independent permissions. +Domain resources keep a free display name and an optional `dns_name`. DNS syntax +is checked with IDNA and structured-public-domains; private namespaces and public +suffix nodes remain valid names. Validation does not prove registration or resolve +the name. Create, edit, bulk and CSV writes share the server validator; DNS names +can also be selected as columns, filtered and searched. + +Responsibility contacts and account recovery routes declare an optional syntax: +email, international E.164 phone, HTTP(S) URL or DNS name. Account sender constraints +may declare an email or domain. Email uses structured-email-address's SMTP mailbox +parser, without display names or provider-specific normalization. Phone input uses +libphonenumber-js with full metadata, and the server independently checks E.164 +syntax and number validity. URL validation rejects embedded credentials and never +fetches the address. Syntax does not establish delivery, ownership or account control. +Provider namespaces, provider identities, login labels and resource names stay opaque. + +An unspecified type preserves an opaque route or constraint, including existing +values. Selecting a type is explicit; the server never guesses or silently converts +old data. The discriminator shares its value's read/write permissions and is hidden +with it. Format 13 adds optional discriminator columns on open and restore, without +creating route history or rewriting existing contact payloads. Unchanged submissions +do not create new records or advance the inventory revision. + For a personal account without delegation, the card says **Account owner action required**. A company approver cannot acquire that account. A request retains the exact managed target, non-secret proposed change, target/account revisions, @@ -370,6 +392,9 @@ an explicit clear. Invalid rows identify the failing row and mapped column witho echoing its value or persisting a rejected preview; invalid input cannot be partially accepted. A valid preview expires, and applying it rechecks the current application revision, actor scope and field types. Raw CSV history is not saved. +Identity correction does not retarget personal CSV approvals. A minimal binding +to the retired identity prevents an old source from silently updating the retained +resource or recreating a duplicate; reversing the correction restores that binding. Preparing a replacement atomically reclaims that importer's earlier review-only drafts and their rows. Failed preparation preserves the prior draft; another importer's previews remain under their own expiry and revision checks. @@ -531,6 +556,21 @@ and conflicting reports at the same source instant are refused. Each selected network section has its own current checkpoint, so accepting routes does not implicitly accept firewall data or prevent reviewing it separately. +For selected interfaces, review lists added addresses, removed addresses from a +complete refresh, and changed attributes. The comparison concerns the selected +source only. A partial refresh preserves omitted addresses and their original +observation dates. It never changes another source, an intended assignment or a +service's literal bind. Current-address read rights apply even when an existing +preview is reused. Preview custody stores the selected input, not another copy +of the derived comparison or an address history. + +Select two devices, VMs, OS installations or interfaces and choose **Compare +identity** to inspect their current permitted fields and source claims together. +Hardware and installation identifiers have separate types and namespaces; +matching reported digests do not establish authenticated identity or merge +resources. Comparison reads only the two selected subjects, without loading +their descendants or routing tables, and writes nothing. + Files are limited to 256 KiB, 128 findings and 2944 protobuf field visits before decoding. The remaining request budget accommodates explicit matches and context; small files with excessive nested fields are refused before generated allocations. @@ -561,6 +601,9 @@ MAC as device identity. Optional local Docker and NetBird modules do not scan arbitrary software packages. Docker candidates distinguish runtime, workload, container lifetime and endpoint attachments. Docker Desktop guest addresses are not macOS host interfaces. +Selected containers are inspected in one bounded batch. Missing, repeated or +unexpected identities refuse that batch; only consumed container and attachment +fields are requested. Windows socket collection avoids repeated array copies. The selected context or environment endpoint is resolved once using Docker's context precedence. All daemon reads pin that checked local Unix socket or Windows named pipe; remote endpoints are refused. Daemon identity is rechecked @@ -573,6 +616,65 @@ Reports contain no environment, full commands, credentials or installed-package inventory. There is no implicit elevation, remote Docker connection, SSH delivery or resident installation. +## Reviewed identity correction + +Select two devices, VMs, OS installations or interfaces and choose **Compare +identity**. Matching reported identifiers support review but do not prove identity +or authorize a connection. Candidate lookup is also available before adopting +report findings, within the current permitted selection. Missing evidence, +cloned UUIDs, movable interfaces and reused addresses never trigger an automatic +merge or an access grant. + +Choose whether the records are separate, one chassis with distinct OS installs, +or duplicates of the same installation. Choose the retained identity and every +field resolution. Add only the OS/interface mappings actually established as +duplicates. Separate boots retain their own services, interfaces and current +addresses. Each physical attachment needs individual confirmation, followed by +ordinary containment, exclusivity and cycle validation. + +Native sources keep independent bindings even when their display labels match. +Manual or unresolved legacy namespace collisions require a reviewed rename. +Neither source relocation nor an address change rewrites literal service binds, +routes, firewall policy or VLAN/VRF membership. No grants, secrets, account control +or operation targets transfer. Explicit responsibility and external-effect +conflicts must be resolved first. + +The bounded preview changes no inventory. Accepting it checks the same working +context, current authority and installation revision, then commits atomically. +Its receipt supports an exact retry and a finite reversal window. Reversal +requires current rights and no subsequent accepted mutation; otherwise prepare a +fresh correction. Old references resolve read-only to the canonical incarnation +only when both original and canonical rights permit it. They never become write +or execution aliases. + +## Record a VM's new hypervisor + +After migrating a VM outside Omloom, open its details and choose **Change +hypervisor after migration**. Drag the VM card onto an enabled destination on +desktop, or use **Choose** on a phone or keyboard. Destination cards show +permitted hardware paths, addresses and exact identities. Searching and paging +stay within the selected enclosing context; choosing all permitted infrastructure +is an explicit action. + +Review the actual old and new host, then **Record and view VM**. This records +inventory placement only: it does not invoke a hypervisor migration. The same +VM, OS installations, interfaces, services and source mappings remain in place. +Current IPs can stay unchanged or be updated by a separate guest observation. +Service binds, routes, network/storage membership and grants are not inferred +from the destination. + +One VM has one accepted current hosting placement. Recording a move conditionally +updates that relation, rather than adding another host. A hidden old host, an +obsolete review, an ineligible destination, a cycle or unresolved guest effects +prevent acceptance. Ambiguous existing multi-host records require explicit +resolution through **Connections in broader scope**; upgrading never guesses +which host to retain. + +Direct ownership stays unchanged. The review identifies permitted inherited +facets bound to the old placement; those become source-unavailable after its +revision changes. Review their administrative source separately in the existing +responsibility editor. The new host never silently becomes the owner. + ## Verification ```sh diff --git a/docs/storage.md b/docs/storage.md index ddaa7d7..1a26f52 100644 --- a/docs/storage.md +++ b/docs/storage.md @@ -108,8 +108,8 @@ envelope, but are **application accounting**, not total filesystem allocation. Pinned detail and unresolved work count against their budgets. Admission fails atomically when it cannot fit; it does not evict safety state. -This unreleased storage format uses schema version 10. Supported versions 2 -through 9 upgrade in bounded restartable batches, including asset name/sort +This unreleased storage format uses schema version 13. Supported versions 2 +through 12 upgrade in bounded restartable batches, including asset name/sort projections from accepted intent, normalized IP ordering and source-scoped current observation facts with separate freshness and accounting. No inventory history is added. The upgrade keeps restore fences @@ -122,6 +122,28 @@ namespace/uniqueness records. A container owns at most one namespace fence and one fully qualified instance claim. Explicit inventory removal reclaims both; unlinking a runtime cannot relabel the container's lifetime. These records are current identity safety state, not a container history stream. +Version 11 adds current source-scoped host identity evidence. Each selected +subject has at most 32 claims across sources. Claims retain their observation +and boot basis, are protected by the `identity_evidence` field right, and are +removed with their subject. A missing claim does not refresh older evidence. +These claims support explicit comparison, never automatic identity or access +decisions. Pair comparison reads only two selected subjects and writes nothing. + +Version 12 indexes typed identity claims for scoped, explicit candidate lookup. +Native current observations use their accepted importer/configuration/subject +binding rather than a display label. This binding survives an approved identity +correction. The next ingestion qualifies a legacy source only when its original +mapping and collection window identify it unambiguously. Ambiguous legacy facts +keep their dates and are marked unresolved, without assignment to a new source. + +An identity correction captures only changed inventory/checkpoint rows, limited +to 128 rows and 256 KiB, inside the same transaction as its audit and retirement +fences. The inverse uses the configured detail lifetime and capacity admission; +it is never a subtree snapshot or default inventory history. Reversal requires +the exact accepted installation revision, current permissions and unchanged +external bindings. Any later mutation requires fresh review. Reversal drops +the bulky inverse and keeps only the retry receipt until ordinary detail cleanup. +Minimal retirement aliases remain safety checkpoints under checkpoint quotas. The host's durable worker calls bounded cleanup with a resumable cursor. Pin changes conflict with cleanup through database CAS. Cleanup reports logical diff --git a/package.json b/package.json index be5a73e..44e680f 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "generate": "protoc --proto_path=proto --plugin=protoc-gen-ts=node_modules/.bin/protoc-gen-ts --ts_out=web/src/generated --ts_opt=long_type_bigint,force_optimize_code_size,force_client_none proto/omloom/v1/contracts.proto proto/omloom/v1/workspace.proto", "typecheck": "tsc --noEmit", "build": "tsc && esbuild web/src/browser-corpus.ts --bundle --format=iife --outfile=dist/browser-corpus.js", - "test:contracts": "node --test web/tests/contracts.test.mjs web/tests/presentation.test.mjs web/tests/field-host.test.mjs web/tests/host-report.test.mjs", + "test:contracts": "node --test web/tests/contracts.test.mjs web/tests/presentation.test.mjs web/tests/field-host.test.mjs web/tests/host-report.test.mjs web/tests/docker-template.test.mjs", "test:browser": "playwright test", "clean": "node scripts/clean.mjs", "build:app": "node scripts/check-vue.cjs --noEmit -p web/app/tsconfig.json && vite build --config web/app/vite.config.ts" @@ -20,6 +20,7 @@ "dependencies": { "@protobuf-ts/grpcweb-transport": "2.11.1", "@protobuf-ts/runtime": "2.11.1", + "libphonenumber-js": "1.13.15", "vue": "3.5.44", "vue-router": "5.4.0" }, diff --git a/proto/omloom/v1/workspace.proto b/proto/omloom/v1/workspace.proto index 82bb264..1f9146b 100644 --- a/proto/omloom/v1/workspace.proto +++ b/proto/omloom/v1/workspace.proto @@ -108,6 +108,35 @@ message WorkingContext { // or focus; omit for retrieval. The server rechecks membership at commit. optional uint64 expected = 3; } +// Read-only review of a placement already changed outside Omloom. This request +// never executes a hypervisor migration or changes the guest's addresses. +message PrepareVmPlacement { + // User-selected existing VM incarnation, never identified by its current IP. + omloom.v1.AssetRef vm = 1; + // Optional proposed enabled hypervisor installation. Omit to read the actual + // current placement before choosing a destination in the enclosing context. + optional omloom.v1.AssetRef destination = 2; +} +message VmPlacementReview { + // Current application basis, echoed as WorkingContext.expected on acceptance. + uint64 revision = 1; + // Exact permitted VM identity/name and accepted inventory revision. + Item vm = 2; + // Actual current placement and its conditional edit revision. Absent only + // when no placement exists; a hidden endpoint makes the review unavailable. + optional omloom.v1.Relation current = 3; + // Independently permitted actual old host, including a legacy device host. + optional Item current_host = 4; + // Independently permitted proposed enabled hypervisor installation. + optional Item destination = 5; + // Permitted currently resolved facets whose explicit old placement binding + // becomes unavailable after a move. Contacts and source references are omitted; + // no owner is copied to the destination. + repeated EffectiveResponsibility invalidated_inheritance = 6; + // Bounded permitted hardware-owner paths and local interface addresses for + // the actual old and proposed hosts, consistent with this review's revision. + repeated ItemContext contexts = 7; +} message Prepare { // User-selected presentation used to suggest applicable creation actions; // the server derives actual parent, kind and field permissions independently. @@ -154,6 +183,19 @@ enum AssignmentMode { ASSIGNMENT_MODE_INHERIT = 2; ASSIGNMENT_MODE_UNASSIGNED = 3; } +// User-declared syntax for a locator. Parsing never proves ownership or delivery. +enum LocatorKind { + // An opaque route or constraint, including older values without a declared type. + LOCATOR_KIND_UNSPECIFIED = 0; + // One SMTP mailbox, without display-name syntax or provider normalization. + LOCATOR_KIND_EMAIL = 1; + // One valid international telephone number in E.164 form, with no inferred country. + LOCATOR_KIND_PHONE = 2; + // One HTTP(S) contact URL without embedded credentials; never fetched by validation. + LOCATOR_KIND_WEB = 3; + // One DNS name. Public suffix recognition is not proof of registration. + LOCATOR_KIND_DOMAIN = 4; +} message ResponsibilityAssignment { // User-selected independent facet; only claimed owner and operator may inherit. ResponsibilityFacet facet = 1; @@ -172,6 +214,9 @@ message ResponsibilityAssignment { // Explicit optional responsibility route, max 512 bytes, separately disclosed // from the assignment. A route is not automatically an approver or a credential. optional string contact = 6; + // User-selected syntax of contact; unspecified preserves an opaque route. + // Requires contact and shares its read/write permissions. + LocatorKind contact_kind = 7; } enum ResponsibilityStatus { RESPONSIBILITY_STATUS_UNSPECIFIED = 0; @@ -195,6 +240,8 @@ message EffectiveResponsibility { optional uint64 source_revision = 5; // Current route, returned only with separate contact permission on the selected resource. optional string contact = 6; + // Server-returned declared syntax, disclosed only together with contact. + LocatorKind contact_kind = 7; } message Responsibility { // Exact resource incarnation selected for reading or editing, never inferred from a name. @@ -280,6 +327,12 @@ message AccountMetadata { optional string credential_binding_id = 7; // Known public provider recovery/transfer route, max 512 bytes; no recovery answers. optional string recovery_route = 8; + // User-selected sender constraint syntax: email, domain or unspecified opaque text. + // Requires sender and shares its independent field permissions. + LocatorKind sender_kind = 9; + // User-selected recovery route syntax, disclosed and editable with recovery_route. + // Requires recovery_route; validation neither contacts a provider nor proves authority. + LocatorKind recovery_route_kind = 10; } enum AccountLinkKind { ACCOUNT_LINK_KIND_UNSPECIFIED = 0; @@ -503,6 +556,9 @@ message Detail { // responses contain only host_network and this cursor. Counts and filters // apply to the supplied page; coverage retains its source-collection meaning. optional string network_next = 12; + // Current selected layer-specific identity claims, filtered by identity_evidence + // field rights. Equality does not establish authenticated identity or access. + repeated HostIdentityObservation identity = 13; } message AddressObservation { @@ -517,6 +573,10 @@ message InterfaceAddressView { InterfaceAddresses coverage = 1; // Current facts with independent dates, including facts omitted from a partial refresh. repeated AddressObservation observations = 2; + // Opaque local source reference, independent of a possibly duplicated display label. + string source_id = 3; + // True when legacy facts could not be attributed to one reviewed native binding. + bool binding_unresolved = 4; } message NetworkObservation { // Exact source observation time, Unix milliseconds, independent of the latest scan checkpoint. @@ -542,6 +602,10 @@ message HostNetworkView { repeated NetworkObservation observations = 2; // Authorized current labels for services referenced by these facts, independent of relation pagination. repeated OwnerContext services = 3; + // Opaque local source reference, independent of its display label. + string source_id = 4; + // True when migrated current facts have unresolved native source provenance. + bool binding_unresolved = 5; } message FirewallSetting { // Reported application policy selector; never an effective per-port verdict. @@ -913,6 +977,170 @@ message HostFinding { bool addresses_complete = 6; // Known container runtime candidate, independent of deployment ownership. optional string runtime = 7; + // Selected layer-specific source claims, at most four. Missing evidence stays + // unknown. Claims support reviewed comparison, never authentication or grants. + repeated HostIdentityClaim identity = 8; +} +// What a source identifier describes. Boot sessions stay in HostReport.boot_basis +// and never become permanent host or OS identities. +enum HostIdentityKind { + HOST_IDENTITY_KIND_UNSPECIFIED = 0; + HOST_IDENTITY_KIND_HARDWARE_UUID = 1; + HOST_IDENTITY_KIND_INSTALLATION_ID = 2; + HOST_IDENTITY_KIND_INTERFACE_ID = 3; + HOST_IDENTITY_KIND_FACTORY_MAC = 4; +} +// Evidence limitations are independent of equality. Even a reported hardware +// UUID can be cloned or forged and cannot authorize a connection or correction. +enum HostIdentityQuality { + HOST_IDENTITY_QUALITY_UNSPECIFIED = 0; + HOST_IDENTITY_QUALITY_REPORTED = 1; + HOST_IDENTITY_QUALITY_MOVABLE = 2; + HOST_IDENTITY_QUALITY_UNRELIABLE = 3; +} +message HostIdentityClaim { + // Collector-selected subject layer and identifier meaning. + HostIdentityKind kind = 1; + // Actual identifier namespace, at most 80 UTF-8 bytes. Equal digests in + // different namespaces are not matching evidence. + string namespace = 2; + // Collector SHA-256 of the normalized identifier, 64 lowercase hex digits. + // This minimizes retained raw identifiers but is not a confidentiality claim. + string digest = 3; + // Actual source limitation. Unspecified quality is invalid, not trusted. + HostIdentityQuality quality = 4; +} +message HostIdentityObservation { + // Original source claim, including namespace and known limitations. + HostIdentityClaim claim = 1; + // Reviewed import configuration name, not the actor or a trusted issuer. + string source = 2; + // Actual claim collection instant, Unix milliseconds. A report omitting this + // claim cannot freshen it by merely updating the source checkpoint. + uint64 observed_at = 3; + // Boot/session basis of that collection. It is evidence, not an OS asset ID. + string boot_basis = 4; +} +message CompareHostIdentity { + // First explicitly selected accepted resource incarnation, never a name/IP. + omloom.v1.AssetRef left = 1; + // Second explicitly selected accepted resource incarnation in the same + // authorized working context. Comparison itself changes no identity. + omloom.v1.AssetRef right = 2; + // Explicit expansion of bounded affected relation metadata for correction planning. + bool include_relations = 3; +} +message HostIdentitySubject { + // Current accepted fields that the caller may read, without descendants, + // routes, firewall configuration or implied rights to those resources. + Item item = 1; + // Current permitted source claims with their original evidence windows. + repeated HostIdentityObservation identity = 2; + // Permitted current source labels; no source payloads or hidden counts. + repeated string sources = 3; + // Selected relation metadata, only when explicitly requested and both ends visible. + repeated omloom.v1.Relation relations = 4; +} +message HostIdentityComparison { + // Server-selected consistent application revision for both subjects. + uint64 revision = 1; + // Authorized first resource and selected identity evidence. + HostIdentitySubject left = 2; + // Authorized second resource and selected identity evidence. + HostIdentitySubject right = 3; +} +message FindIdentityCandidates { + // Selected current subject whose permitted claims supply exact indexed probes. + omloom.v1.AssetRef target = 1; + // Source claims supplied for explicit pre-adoption lookup, at most four. + // Mutually exclusive with target; matching never adopts or authenticates a host. + repeated HostIdentityClaim claims = 2; + // User-reviewed subject layer for claims-only lookup; absent when target is set. + omloom.v1.AssetKind kind = 3; +} +message IdentityCandidates { + // Bounded visible same-layer subjects; a match is evidence, never a merge. + repeated Item items = 1; +} +message ResolveIdentity { + // Original retired local reference. Resolution is read-only, never an effect target. + omloom.v1.AssetRef original = 1; +} +message ResolvedIdentity { + // Original reference retained for provenance in the UI. + omloom.v1.AssetRef original = 1; + // Current canonical resource with current rights applied independently. + Item canonical = 2; +} +enum IdentityDecision { + IDENTITY_DECISION_UNSPECIFIED = 0; + IDENTITY_DECISION_KEEP_SEPARATE = 1; + IDENTITY_DECISION_DISTINCT_INSTALLATIONS = 2; + IDENTITY_DECISION_DUPLICATE_INSTALLATION = 3; +} +message IdentityFieldChoice { + // Exact accepted field being resolved on the selected duplicate. + string name = 1; + // Explicit choice to use the duplicate's value instead of the retained value. + bool use_duplicate = 2; +} +message IdentitySourceChoice { + // Existing label group on this duplicate, shown by the server. A rename + // applies to all bindings with this label, without combining their data. + string before = 1; + // Reviewed display label after relocation; native binding identity is unchanged. + string after = 2; +} +message IdentityMapping { + // Explicitly selected duplicate incarnation to retire, never inferred from IP. + omloom.v1.AssetRef duplicate = 1; + // Existing canonical incarnation to retain at the same subject layer. + omloom.v1.AssetRef retained = 2; + // Explicit decisions for every field on the retiring identity, at most 32. + repeated IdentityFieldChoice fields = 3; + // Optional label changes, at most eight. Unbound source name collisions are refused; + // native sources retain independent bindings even when labels are identical. + repeated IdentitySourceChoice sources = 4; + // Individually confirmed physical attachment relation UUIDs, at most 128. + repeated string attachments = 5; +} +message IdentityCorrection { + // Operator conclusion; equality of identifiers never supplies this decision. + IdentityDecision decision = 1; + // Selected layer mappings, at most eight; never an instruction to merge descendants. + repeated IdentityMapping mappings = 2; + // Exact bounded preview to accept; mappings/decision must be absent on apply. + optional string preview = 3; + // Exact accepted correction to reverse within its window, after fresh checks. + optional string undo = 4; +} +message IdentityImpact { + // Existing relation before the proposed correction. + omloom.v1.Relation before = 1; + // Proposed relation; absent means an explicitly redundant/self attachment is removed. + optional omloom.v1.Relation after = 2; +} +message IdentityCorrectionView { + // Server-owned review identifier; no mutation has yet been accepted. + string id = 1; + // Installation revision fencing the complete reviewed change. + uint64 revision = 2; + // Selected explicit mappings, preserving the operator's field/source decisions. + repeated IdentityMapping mappings = 3; + // Bounded affected relations only, without loading descendant payloads. + repeated IdentityImpact relations = 4; + // Server-declared expiry of this preview in Unix milliseconds. + uint64 expires_at = 5; +} +message IdentityCorrectionApplied { + // Actual application revision after this correction or its explicit inverse. + uint64 revision = 1; + // Server-owned correction identifier for the bounded inverse window. + string id = 2; + // Expiry of the inverse window in Unix milliseconds; zero after reversal. + uint64 undo_until = 3; + // Canonical identities affected by the accepted operator decision. + repeated omloom.v1.AssetRef retained = 4; } message HostBind { // Source-local service candidate owning this observed bind. Empty means the @@ -1012,6 +1240,24 @@ message Address { // This is not observed election state and is absent for ordinary assignment. optional uint32 priority = 15; } +message AddressAttributeChange { + // Previously accepted current value from this exact interface/source. + CurrentAddress before = 1; + // Selected incoming value for the same address, without rewriting a service bind. + CurrentAddress after = 2; +} +message AddressDelta { + // Selected import configuration whose current facts are being compared. + string source = 1; + // Incoming addresses absent from this source's current interface observations. + repeated CurrentAddress added = 2; + // Current addresses omitted by an explicitly complete source refresh only. + repeated CurrentAddress removed = 3; + // Attribute changes on retained addresses; no duplicate unchanged values. + repeated AddressAttributeChange updated = 4; + // Source-declared completeness; false means omissions are retained. + bool complete = 5; +} message ImportRow { // One-based source row used to locate errors without losing input context. uint32 row = 1; @@ -1019,6 +1265,9 @@ message ImportRow { Write change = 2; // Field-specific actionable validation diagnostic; never a successful error envelope. string diagnostic = 3; + // Authorized source-local address differences derived for this bounded review. + // Absent for non-interface rows or when current-address reading is not allowed. + AddressDelta addresses = 4; } message Preview { // Server-generated bounded-lived preview identity, absent for invalid previews. @@ -1187,6 +1436,16 @@ message Request { ResponsibilityTransfer responsibility_transfer = 26; // Review and atomically accept selected native host observations. HostImport host_import = 27; + // Compare two selected identities without adopting, merging or writing. + CompareHostIdentity compare_host_identity = 28; + // Preview/apply/reverse an explicit bounded identity correction. + IdentityCorrection correct_identity = 29; + // Find bounded identity candidates within current permissions and working context. + FindIdentityCandidates find_identity_candidates = 30; + // Resolve a retired reference without transferring its old grants. + ResolveIdentity resolve_identity = 31; + // Review current/proposed VM placement without invoking an external effect. + PrepareVmPlacement prepare_vm_placement = 32; } } message Reply { @@ -1222,5 +1481,17 @@ message Reply { OwnerRequestState owner_request = 14; // Selected responsibility export or the result of one atomic reviewed import. ResponsibilityTransferState responsibility_transfer = 15; + // Consistent bounded comparison, not a correction or authority grant. + HostIdentityComparison host_identity_comparison = 16; + // Derived correction effects under current field and relation rights. + IdentityCorrectionView identity_correction = 17; + // Accepted correction receipt, not a grant or an external effect receipt. + IdentityCorrectionApplied identity_corrected = 18; + // Current authorized candidates; no hidden totals or candidate persistence. + IdentityCandidates identity_candidates = 19; + // Explicit reference provenance and the independently authorized current target. + ResolvedIdentity resolved_identity = 20; + // Current authorized placement and the independently reviewed destination. + VmPlacementReview vm_placement_review = 21; } } diff --git a/web/app/AccountPanel.vue b/web/app/AccountPanel.vue index 8327e08..2752341 100644 --- a/web/app/AccountPanel.vue +++ b/web/app/AccountPanel.vue @@ -1,6 +1,7 @@ + + diff --git a/web/app/App.vue b/web/app/App.vue index 24b7c98..2df7520 100644 --- a/web/app/App.vue +++ b/web/app/App.vue @@ -3,6 +3,9 @@ import HostNetwork from './HostNetwork.vue'; import HostImportEditor from './HostImportEditor.vue'; import CategoryNav from './CategoryNav.vue'; import CurrentAddresses from './CurrentAddresses.vue'; +import HostIdentityEvidence from './HostIdentityEvidence.vue'; +import HostIdentityCompare from './HostIdentityCompare.vue'; +import VmPlacement from './VmPlacement.vue'; import ResponsibilityPanel from './ResponsibilityPanel.vue'; import AccountPanel from './AccountPanel.vue'; import { softwareFields } from './software-fields'; @@ -28,7 +31,7 @@ const editor = ref<'' | 'asset' | 'schema' | 'import' | 'host-import' | 'view' | const initialKind = computed(() => query.value.kinds.length===1 ? query.value.kinds[0] as K : ({ [model.View.OVERVIEW]: K.DEVICE, [model.View.NETWORK]: K.ADDRESS_REFERENCE, [model.View.HARDWARE]: K.DEVICE, [model.View.VIRTUALIZATION]: K.VIRTUAL_MACHINE, [model.View.SERVICES]: K.SERVICE })[query.value.view]); const kindOptions = kinds.map(k => ({ id: String(k), name: label(k) })); const columnOptions = computed(() => { - const options = [{ id: 'name', name: 'Name' }, { id: 'description', name: 'Description' }, { id: 'serial', name: 'Serial number' }, ...softwareFields(), ...definitions.value.filter(d => !d.retired)]; + const options = [{ id: 'name', name: 'Name' }, { id: 'dns_name', name: 'DNS name' }, { id: 'description', name: 'Description' }, { id: 'serial', name: 'Serial number' }, ...softwareFields(), ...definitions.value.filter(d => !d.retired)]; for (const id of query.value.columns) if (!options.some(o => o.id === id)) options.push({ id, name: `${id} (unavailable)` }); return options; }); @@ -40,6 +43,34 @@ let returnQuery: LocationQuery | undefined; const detailHeading = ref(); const moreActions = ref(false); const selected = ref([]); const search = ref(''); +const identityPair = ref<{left:AssetRef;right:AssetRef;working:model.WorkingContext}>(); +const placementVm = ref(); +async function placementSaved(vm: AssetRef, host: AssetRef) { + placementVm.value = undefined; + await router.push({path:'/',query:{view:String(model.View.VIRTUALIZATION),scope:[host.assetId],scopeInc:[host.incarnation],asset:vm.assetId,incarnation:vm.incarnation}}); +} +const identityCandidates=ref();const findingCandidates=ref(false); +const resolvedAlias=ref(); +const comparable = computed(() => { + const items = page.value.items.filter(item => selected.value.includes(key(item.target))); + return items.length === 2 && items.every(item => item.target && [K.DEVICE, K.VIRTUAL_MACHINE, K.SOFTWARE_INSTALLATION, K.INTERFACE].includes(item.kind)); +}); +function compareIdentity() { + const items = page.value.items.filter(item => selected.value.includes(key(item.target))); + if (comparable.value && items[0]?.target && items[1]?.target) identityPair.value = {left:items[0].target,right:items[1].target,working:model.WorkingContext.create(working.value)}; +} +async function findIdentityCandidates(){ + const current=detail.value;if(!current?.item?.target||findingCandidates.value)return; + findingCandidates.value=true;error.value='';identityCandidates.value=undefined; + try{const value=await call({oneofKind:'findIdentityCandidates',findIdentityCandidates:model.FindIdentityCandidates.create({target:current.item.target})},enclosing.value);if(value.oneofKind!=='identityCandidates')throw Error('Candidate search is unavailable.');if(detail.value===current)identityCandidates.value=value.identityCandidates.items;} + catch(e){if(detail.value===current)error.value=String(e);}finally{findingCandidates.value=false;} +} +function compareCandidate(item:model.Item){const left=detail.value?.item?.target;if(left&&item.target)identityPair.value={left,right:item.target,working:model.WorkingContext.create(enclosing.value)};} +async function resolveIdentityReference(){ + if(typeof route.query.asset!=='string'||typeof route.query.incarnation!=='string')return; + try{const value=await call({oneofKind:'resolveIdentity',resolveIdentity:model.ResolveIdentity.create({original:{assetId:route.query.asset,incarnation:route.query.incarnation}})},enclosing.value);if(value.oneofKind!=='resolvedIdentity')throw Error('Reference resolution is unavailable.');resolvedAlias.value=value.resolvedIdentity;} + catch(e){error.value=String(e);} +} const viewSearch = ref(''); const matchingViews = computed(() => views.value.filter(v => v.name.toLocaleLowerCase().includes(viewSearch.value.toLocaleLowerCase()))); const presentations = [{ id: model.View.OVERVIEW, name: 'Overview', symbol: 'â–¦' }, { id: model.View.NETWORK, name: 'Network', symbol: '⇄' }, { id: model.View.HARDWARE, name: 'Hardware', symbol: 'â–¤' }, { id: model.View.VIRTUALIZATION, name: 'Virtualization', symbol: 'â—«' }, { id: model.View.SERVICES, name: 'Software & services', symbol: 'â—‡' }]; @@ -133,6 +164,7 @@ const detailName = computed(() => display(detail.value?.item?.fields.find(f => f let generation = 0; let loadedQuery = ''; async function load() { + identityCandidates.value=undefined;resolvedAlias.value=undefined; const turn = ++generation; loading.value = true; error.value = ''; preparedActions.value = model.Actions.create(); const signature = JSON.stringify(model.Query.toJson(query.value)); @@ -210,6 +242,13 @@ async function open(target: AssetRef | undefined, selection: LocationQuery = rou if (detail.value?.item?.target && key(detail.value.item.target) !== key(target)) trail.push(JSON.stringify({ ...selection, trail: undefined })); await router.push({ path: '/', query: { ...selection, origin: selection.asset ? selection.origin : JSON.stringify({...selection,origin:undefined}), localSearch: undefined, localKind: undefined, localFilter: undefined, localCursor: undefined, trail: trail.length ? trail : undefined, asset: target.assetId, incarnation: target.incarnation } }); } +async function openResolvedIdentity() { + const original = resolvedAlias.value?.original; + const canonical = resolvedAlias.value?.canonical?.target; + if (!original || !canonical) return; + const scope = roots().map(root => key(root) === key(original) ? canonical : root); + await open(canonical, { ...route.query, scope: scope.map(root => root.assetId), scopeInc: scope.map(root => root.incarnation) }); +} async function closeDetail() { const trail = parameters('trail'); const parent = trail.pop(); if (parent) { @@ -234,7 +273,7 @@ watch(detail, async (value, previous) => { else if (window.matchMedia('(max-width: 1349px)').matches) detailHeading.value?.scrollIntoView({ block: 'start' }); } if (!value && previous && !error.value) { - if (loadedQuery === listQuery) { selected.value = listSelection; if (listFocus?.isConnected) listFocus.focus({ preventScroll: true }); window.scrollTo(0, listScroll); } + if (loadedQuery === listQuery) { selected.value = listSelection; await nextTick(); if (listFocus?.isConnected) listFocus.focus({ preventScroll: true }); window.scrollTo(0, listScroll); } else window.scrollTo(0, 0); } }); @@ -312,13 +351,14 @@ onMounted(async () => { try { await metadata(); await load(); } catch (e) { erro

{{ currentTitle }}

SCOPEAll permitted infrastructure{{ detail?.item ? 'Local components and known facts' : 'Changing views preserves this scope.' }}
-

{{ notice }}

+

{{ notice }}

+

Original reference {{ resolvedAlias.original?.assetId }}. Current permitted identity: {{ display(resolvedAlias.canonical.fields.find(v=>v.name==='name')) }}.

{{ page.total.toString() }} resources · Loading…
{{ definitions.find(d => d.id === p.field)?.name ?? p.field }} = {{ display(p.value) }}{{model.ResponsibilityFacet[query.responsibility.facet]?.toLowerCase().replaceAll('_',' ')}}: {{query.responsibility.issuer}} / {{query.responsibility.subject}}
-
{{ selected.length }} selected on this page
-
-
{{ c.name }}Identity