diff --git a/.github/workflows/nuget-publish.yml b/.github/workflows/nuget-publish.yml index 54dabef..ef08e3a 100644 --- a/.github/workflows/nuget-publish.yml +++ b/.github/workflows/nuget-publish.yml @@ -54,10 +54,16 @@ jobs: strategy: fail-fast: false matrix: - library: [ NowAgent ] include: - library: NowAgent libpath: ./protocols/dotnet/Devolutions.NowClient + csproj-paths: | + protocols\dotnet\Devolutions.NowClient\Devolutions.NowClient.csproj + protocols\dotnet\Devolutions.NowProto\Devolutions.NowProto.csproj + - library: UniGetUIPolicy + libpath: ./policies/dotnet/Devolutions.UniGetUI.Broker.Policy + csproj-paths: | + policies\dotnet\Devolutions.UniGetUI.Broker.Policy\Devolutions.UniGetUI.Broker.Policy.csproj steps: - name: Check out ${{ github.repository }} @@ -67,9 +73,10 @@ jobs: shell: pwsh run: | $PackageVersion = '${{ needs.preflight.outputs.package-version }}' - @("protocols\dotnet\Devolutions.NowClient\Devolutions.NowClient.csproj", - "protocols\dotnet\Devolutions.NowProto\Devolutions.NowProto.csproj") | ForEach-Object { - $csprojPath = $_ + @" + ${{ matrix['csproj-paths'] }} + "@ -Split "`n" | Where-Object { -Not [string]::IsNullOrWhiteSpace($_) } | ForEach-Object { + $csprojPath = $_.Trim() $csprojContent = Get-Content $csprojPath -Raw $csprojContent = $csprojContent -Replace '().*?()', "$PackageVersion" Set-Content -Path $csprojPath -Value $csprojContent -Encoding UTF8 diff --git a/.gitignore b/.gitignore index 191bdb2..0970173 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,8 @@ # Build artifacts /target +[Bb]in/ +[Oo]bj/ +*.csproj.lscache # Local cargo root /.cargo/local_root diff --git a/Cargo.lock b/Cargo.lock index 51bb705..0695ba1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -11,30 +11,98 @@ dependencies = [ "memchr", ] +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + [[package]] name = "anyhow" version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + [[package]] name = "bitflags" version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "cc" +version = "1.2.64" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dad887fd958be91b5098c0248def011f4523ab786cd411be668777e55063501f" +dependencies = [ + "find-msvc-tools", + "shlex", +] + [[package]] name = "cfg-if" version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "displaydoc" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "dissimilar" version = "1.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aeda16ab4059c5fd2a83f2b9c9e9c981327b18aa8e3b313f7e6563799d4f093e" +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + [[package]] name = "equivalent" version = "1.0.2" @@ -51,6 +119,21 @@ dependencies = [ "once_cell", ] +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + [[package]] name = "futures-core" version = "0.3.32" @@ -105,6 +188,133 @@ version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + [[package]] name = "indexmap" version = "2.14.0" @@ -130,6 +340,41 @@ version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4a9d7794e854eef2f13fdf79c8502bcc567a75a15fd0522885f37739386a4cef" +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03d04c30968dffe80775bd4d7fb676131cd04a1fb46d2686dbffbaec2d9dfd31" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "log" +version = "0.4.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a" + [[package]] name = "memchr" version = "2.8.0" @@ -159,12 +404,27 @@ dependencies = [ "rstest", ] +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + [[package]] name = "once_cell" version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + [[package]] name = "pico-args" version = "0.5.0" @@ -177,6 +437,15 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + [[package]] name = "proc-macro-crate" version = "3.5.0" @@ -278,12 +547,59 @@ dependencies = [ "semver", ] +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "schemars" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fbf2ae1b8bc8e02df939598064d22402220cd5bbcca1c76f7d6a310974d5615" +dependencies = [ + "chrono", + "dyn-clone", + "schemars_derive", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e265784ad618884abaea0600a9adf15393368d840e0222d101a072f3f7534d" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn", +] + [[package]] name = "semver" version = "1.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + [[package]] name = "serde_core" version = "1.0.228" @@ -304,12 +620,67 @@ dependencies = [ "syn", ] +[[package]] +name = "serde_derive_internals" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_yaml" +version = "0.9.34+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + [[package]] name = "slab" version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + [[package]] name = "syn" version = "2.0.117" @@ -321,6 +692,47 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + [[package]] name = "toml_datetime" version = "1.1.1+spec-1.1.0" @@ -357,12 +769,154 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unigetui-broker-policy" +version = "0.1.0" +dependencies = [ + "chrono", + "schemars", + "semver", + "serde", + "serde_json", + "serde_yaml", + "thiserror", + "url", +] + +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + [[package]] name = "uuid" version = "1.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" +[[package]] +name = "wasm-bindgen" +version = "0.2.125" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ddb3f79143bced6de84270411622a2699cee572fc0875aeaf1e7867cf9fca1a" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.125" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e21a184b13fb19e157296e2c46056aec9092264fab83e4ba59e68c61b323c3d" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.125" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fecefd9c35bd935a20fc3fc344b5f29138961e4f47fb03297d88f2587afb5ebd" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.125" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23939e44bb9a5d7576fa2b563dc2e136628f1224e88a8deed09e04858b77871f" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + [[package]] name = "winnow" version = "1.0.3" @@ -372,6 +926,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + [[package]] name = "xshell" version = "0.2.7" @@ -395,3 +955,86 @@ dependencies = [ "pico-args", "xshell", ] + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml index e9b397e..c53243b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,7 @@ [workspace] resolver = "2" members = [ + "policies/rust/*", "protocols/rust/*", "xtask", ] diff --git a/policies/dotnet/.gitignore b/policies/dotnet/.gitignore new file mode 100644 index 0000000..cd42ee3 --- /dev/null +++ b/policies/dotnet/.gitignore @@ -0,0 +1,2 @@ +bin/ +obj/ diff --git a/policies/dotnet/Devolutions.UniGetUI.Broker.Policy.Tests/Devolutions.UniGetUI.Broker.Policy.Tests.csproj b/policies/dotnet/Devolutions.UniGetUI.Broker.Policy.Tests/Devolutions.UniGetUI.Broker.Policy.Tests.csproj new file mode 100644 index 0000000..9aeab80 --- /dev/null +++ b/policies/dotnet/Devolutions.UniGetUI.Broker.Policy.Tests/Devolutions.UniGetUI.Broker.Policy.Tests.csproj @@ -0,0 +1,22 @@ + + + + latest + enable + enable + false + Devolutions.UniGetUI.Broker.Policy.Tests + + + + + + + + + + + + + + diff --git a/policies/dotnet/Devolutions.UniGetUI.Broker.Policy.Tests/PolicyTests.cs b/policies/dotnet/Devolutions.UniGetUI.Broker.Policy.Tests/PolicyTests.cs new file mode 100644 index 0000000..30f2a55 --- /dev/null +++ b/policies/dotnet/Devolutions.UniGetUI.Broker.Policy.Tests/PolicyTests.cs @@ -0,0 +1,157 @@ +using System.Runtime.CompilerServices; +using System.Text.Json; + +using NJsonSchema; + +using Xunit; + +namespace Devolutions.UniGetUI.Broker.Policy.Tests; + +public class PolicyTests +{ + private static string PolicyCrateRoot { get; } = ResolvePolicyCrateRoot(); + + private static string SamplesDir => Path.Combine(PolicyCrateRoot, "assets", "samples"); + + private static string PolicySchema => Path.Combine(PolicyCrateRoot, "schema", "unigetui.package-policy.schema.json"); + + public static IEnumerable PolicySamples() => + Directory.GetFiles(SamplesDir, "*.policy.*").Select(f => new object[] { f }); + + [Theory] + [MemberData(nameof(PolicySamples))] + public async Task Policy_samples_parse_and_validate_against_rust_schema(string path) + { + var policy = ParsePolicy(path); + var schema = await JsonSchema.FromFileAsync(PolicySchema); + var errors = schema.Validate(policy.ToJson()); + + Assert.True( + errors.Count == 0, + $"{Path.GetFileName(path)} failed policy schema validation:\n" + + string.Join("\n", errors.Select(e => $" {e.Kind} at {e.Path}"))); + } + + [Fact] + public async Task Created_policy_validates_against_rust_schema() + { + var policy = PolicyDocument.Create("contoso.policy", "Contoso IT"); + policy.Rules.Add(new PolicyRule + { + Id = "allow.vscode", + Priority = 100, + Decision = Decision.Allow, + Match = new PolicyMatch + { + Operations = [Operation.Install], + Managers = [ManagerName.Winget], + PackageIdentifiers = ["Microsoft.VisualStudioCode"], + }, + }); + + var schema = await JsonSchema.FromFileAsync(PolicySchema); + var errors = schema.Validate(policy.ToJson()); + + Assert.True(errors.Count == 0, string.Join("\n", errors.Select(e => $" {e.Kind} at {e.Path}"))); + } + + [Fact] + public void Invalid_policy_fixture_is_rejected_by_parser() + { + var path = Path.Combine(SamplesDir, "invalid", "policies", "invalid-failure-decision.policy.json"); + var content = File.ReadAllText(path); + + Assert.ThrowsAny(() => PolicyDocument.ParseJson(content)); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + public void Empty_yaml_is_rejected_with_json_exception(string yaml) + { + Assert.Throws(() => PolicyDocument.ParseYaml(yaml)); + } + + [Fact] + public void Yaml_with_non_scalar_mapping_key_is_rejected_with_json_exception() + { + const string yaml = """ + ? [PolicyVersion] + : 1.0.0 + """; + + Assert.Throws(() => PolicyDocument.ParseYaml(yaml)); + } + + [Fact] + public void Negative_revision_is_rejected_by_parser() + { + var json = MinimalPolicyJson(""" + "Revision": -1, + """, """ + "Rules": [] + """); + + Assert.Throws(() => PolicyDocument.ParseJson(json)); + } + + [Fact] + public void Negative_priority_is_rejected_by_parser() + { + var json = MinimalPolicyJson(""" + "Revision": 1, + """, """ + "Rules": [ + { + "Id": "deny.test", + "Enabled": true, + "Priority": -1, + "Decision": "Deny", + "Match": { + "Operations": ["Install"] + } + } + ] + """); + + Assert.Throws(() => PolicyDocument.ParseJson(json)); + } + + private static PolicyDocument ParsePolicy(string path) + { + var content = File.ReadAllText(path); + var extension = Path.GetExtension(path); + return extension.Equals(".yaml", StringComparison.OrdinalIgnoreCase) + || extension.Equals(".yml", StringComparison.OrdinalIgnoreCase) + ? PolicyDocument.ParseYaml(content) + : PolicyDocument.ParseJson(content); + } + + private static string ResolvePolicyCrateRoot([CallerFilePath] string thisFile = "") + { + var testsDir = Path.GetDirectoryName(thisFile)!; + return Path.GetFullPath(Path.Combine(testsDir, "..", "..", "rust", "unigetui-broker-policy")); + } + + private static string MinimalPolicyJson(string revision, string rules) + { + return $$""" + { + "$schema": "https://aka.ms/unigetui/package-policy.schema.1.0.json", + "PolicyVersion": "1.0.0", + "PolicyType": "PackageBrokerPolicy", + "Metadata": { + "Id": "test.policy", + "Publisher": "Test", + {{revision}} + "PublishedAt": "2026-01-01T00:00:00Z" + }, + "Enforcement": { + "DefaultDecision": "Deny", + "RulePrecedence": "PriorityThenDeny" + }, + {{rules}} + } + """; + } +} \ No newline at end of file diff --git a/policies/dotnet/Devolutions.UniGetUI.Broker.Policy.slnx b/policies/dotnet/Devolutions.UniGetUI.Broker.Policy.slnx new file mode 100644 index 0000000..21b0d0e --- /dev/null +++ b/policies/dotnet/Devolutions.UniGetUI.Broker.Policy.slnx @@ -0,0 +1,4 @@ + + + + diff --git a/policies/dotnet/Devolutions.UniGetUI.Broker.Policy/Devolutions.UniGetUI.Broker.Policy.csproj b/policies/dotnet/Devolutions.UniGetUI.Broker.Policy/Devolutions.UniGetUI.Broker.Policy.csproj new file mode 100644 index 0000000..c2dc958 --- /dev/null +++ b/policies/dotnet/Devolutions.UniGetUI.Broker.Policy/Devolutions.UniGetUI.Broker.Policy.csproj @@ -0,0 +1,36 @@ + + + + latest + enable + enable + Devolutions.UniGetUI.Broker.Policy + Devolutions.UniGetUI.Broker.Policy + true + + + + Devolutions.UniGetUI.Broker.Policy + 0.0.0.0 + Devolutions Agent UniGetUI package broker policy model + Policy creation and parsing APIs for the Devolutions Agent UniGetUI package broker. + Devolutions Inc. + © Devolutions Inc. All rights reserved. + MIT OR Apache-2.0 + https://github.com/Devolutions/now-libraries.git + git + true + snupkg + false + README.md + + + + + + + + + + + diff --git a/policies/dotnet/Devolutions.UniGetUI.Broker.Policy/Enums.cs b/policies/dotnet/Devolutions.UniGetUI.Broker.Policy/Enums.cs new file mode 100644 index 0000000..f710141 --- /dev/null +++ b/policies/dotnet/Devolutions.UniGetUI.Broker.Policy/Enums.cs @@ -0,0 +1,62 @@ +using System.Text.Json.Serialization; + +namespace Devolutions.UniGetUI.Broker.Policy; + +/// Package operation type. +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum Operation +{ + Install, + Update, + Uninstall, +} + +/// Supported package manager names. +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum ManagerName +{ + Winget, + PowerShell, + PowerShell7, +} + +/// Installation scope. +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum Scope +{ + User, + Machine, +} + +/// Target architecture. +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum Architecture +{ + X86, + X64, + Arm64, + Neutral, +} + +/// Requested elevation level. +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum Elevation +{ + Standard, + Elevated, +} + +/// Policy decision. +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum Decision +{ + Allow, + Deny, +} + +/// Rule precedence strategy. +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum RulePrecedence +{ + PriorityThenDeny, +} \ No newline at end of file diff --git a/policies/dotnet/Devolutions.UniGetUI.Broker.Policy/PolicyJson.cs b/policies/dotnet/Devolutions.UniGetUI.Broker.Policy/PolicyJson.cs new file mode 100644 index 0000000..cd4ff8b --- /dev/null +++ b/policies/dotnet/Devolutions.UniGetUI.Broker.Policy/PolicyJson.cs @@ -0,0 +1,19 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Devolutions.UniGetUI.Broker.Policy; + +public static class PolicyJson +{ + public static readonly JsonSerializerOptions Options = new() + { + WriteIndented = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + Converters = { new JsonStringEnumConverter() }, + }; + + public static readonly JsonSerializerOptions StrictOptions = new(Options) + { + UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow, + }; +} \ No newline at end of file diff --git a/policies/dotnet/Devolutions.UniGetUI.Broker.Policy/PolicyModels.cs b/policies/dotnet/Devolutions.UniGetUI.Broker.Policy/PolicyModels.cs new file mode 100644 index 0000000..22591be --- /dev/null +++ b/policies/dotnet/Devolutions.UniGetUI.Broker.Policy/PolicyModels.cs @@ -0,0 +1,310 @@ +using System.Globalization; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization; + +using YamlDotNet.Core; +using YamlDotNet.RepresentationModel; + +namespace Devolutions.UniGetUI.Broker.Policy; + +public static class SchemaUris +{ + public const string Policy = "https://aka.ms/unigetui/package-policy.schema.1.0.json"; +} + +/// A policy document governing which package operations are allowed or denied. +public sealed class PolicyDocument +{ + [JsonPropertyName("$schema")] + public string Schema { get; set; } = SchemaUris.Policy; + + [JsonPropertyName("PolicyVersion")] + public string PolicyVersion { get; set; } = "1.0.0"; + + [JsonPropertyName("PolicyType")] + public string PolicyType { get; set; } = "PackageBrokerPolicy"; + + [JsonPropertyName("Metadata")] + public PolicyMetadata Metadata { get; set; } = new(); + + [JsonPropertyName("Enforcement")] + public PolicyEnforcement Enforcement { get; set; } = new(); + + [JsonPropertyName("Rules")] + public List Rules { get; set; } = []; + + public static PolicyDocument Create(string id, string publisher, Decision defaultDecision = Decision.Deny) + { + return new PolicyDocument + { + Metadata = new PolicyMetadata + { + Id = id, + Publisher = publisher, + Revision = 1, + PublishedAt = DateTimeOffset.UtcNow, + }, + Enforcement = new PolicyEnforcement + { + DefaultDecision = defaultDecision, + RulePrecedence = RulePrecedence.PriorityThenDeny, + }, + }; + } + + public static PolicyDocument ParseJson(string json) + { + return JsonSerializer.Deserialize(json, PolicyJson.StrictOptions) + ?? throw new JsonException("policy document was null"); + } + + public static PolicyDocument ParseYaml(string yaml) + { + var stream = new YamlStream(); + stream.Load(new StringReader(yaml)); + if (stream.Documents.Count == 0) + { + throw new JsonException("policy YAML document was empty"); + } + + var json = YamlToJson(stream.Documents[0].RootNode)?.ToJsonString() + ?? throw new JsonException("policy YAML document was empty"); + return ParseJson(json); + } + + public string ToJson() => JsonSerializer.Serialize(this, PolicyJson.Options); + + private static JsonNode? YamlToJson(YamlNode node) + { + switch (node) + { + case YamlMappingNode map: + var obj = new JsonObject(); + foreach (var (key, value) in map.Children) + { + if (key is not YamlScalarNode scalarKey || scalarKey.Value is null) + { + throw new JsonException("policy YAML mapping keys must be scalar strings"); + } + + obj[scalarKey.Value] = YamlToJson(value); + } + + return obj; + + case YamlSequenceNode seq: + var arr = new JsonArray(); + foreach (var item in seq.Children) + { + arr.Add(YamlToJson(item)); + } + + return arr; + + case YamlScalarNode scalar: + return ScalarToJson(scalar); + + default: + return null; + } + } + + private static JsonNode? ScalarToJson(YamlScalarNode scalar) + { + var value = scalar.Value; + if (value is null) + { + return null; + } + + if (scalar.Style is ScalarStyle.SingleQuoted or ScalarStyle.DoubleQuoted) + { + return JsonValue.Create(value); + } + + return value switch + { + "" or "null" or "~" => null, + "true" or "True" => JsonValue.Create(true), + "false" or "False" => JsonValue.Create(false), + _ when long.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var l) => JsonValue.Create(l), + _ when double.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out var d) => JsonValue.Create(d), + _ => JsonValue.Create(value), + }; + } +} + +public sealed class PolicyMetadata +{ + [JsonPropertyName("Id")] + public string Id { get; set; } = ""; + + [JsonPropertyName("Publisher")] + public string Publisher { get; set; } = ""; + + [JsonPropertyName("Revision")] + public uint Revision { get; set; } + + [JsonPropertyName("PublishedAt")] + public DateTimeOffset PublishedAt { get; set; } + + [JsonPropertyName("ValidFrom")] + public DateTimeOffset? ValidFrom { get; set; } + + [JsonPropertyName("ValidUntil")] + public DateTimeOffset? ValidUntil { get; set; } + + [JsonPropertyName("Description")] + public string? Description { get; set; } + + [JsonPropertyName("SupportUrl")] + public string? SupportUrl { get; set; } +} + +public sealed class PolicyEnforcement +{ + [JsonPropertyName("DefaultDecision")] + public Decision DefaultDecision { get; set; } + + [JsonPropertyName("RulePrecedence")] + public RulePrecedence RulePrecedence { get; set; } + + [JsonPropertyName("AuditMode")] + public bool? AuditMode { get; set; } +} + +public sealed class PolicyRule +{ + [JsonPropertyName("Id")] + public string Id { get; set; } = ""; + + [JsonPropertyName("Enabled")] + public bool Enabled { get; set; } = true; + + [JsonPropertyName("Priority")] + public uint Priority { get; set; } + + [JsonPropertyName("Decision")] + public Decision Decision { get; set; } + + [JsonPropertyName("Reason")] + public string? Reason { get; set; } + + [JsonPropertyName("Match")] + public PolicyMatch Match { get; set; } = new(); + + [JsonPropertyName("Constraints")] + public PolicyConstraints? Constraints { get; set; } +} + +public sealed class PolicyMatch +{ + [JsonPropertyName("Operations")] + public List Operations { get; set; } = []; + + [JsonPropertyName("Managers")] + public List Managers { get; set; } = []; + + [JsonPropertyName("Sources")] + public List Sources { get; set; } = []; + + [JsonPropertyName("PackageIdentifiers")] + public List PackageIdentifiers { get; set; } = []; + + [JsonPropertyName("PackageNames")] + public List PackageNames { get; set; } = []; + + [JsonPropertyName("Versions")] + public List Versions { get; set; } = []; + + [JsonPropertyName("VersionRange")] + public VersionRange? VersionRange { get; set; } + + [JsonPropertyName("Scopes")] + public List Scopes { get; set; } = []; + + [JsonPropertyName("Architectures")] + public List Architectures { get; set; } = []; + + [JsonPropertyName("Elevation")] + public List Elevation { get; set; } = []; + + [JsonPropertyName("Interactive")] + public List Interactive { get; set; } = []; + + [JsonPropertyName("SkipHashCheck")] + public List SkipHashCheck { get; set; } = []; + + [JsonPropertyName("PreRelease")] + public List PreRelease { get; set; } = []; + + [JsonPropertyName("HasCustomParameters")] + public List HasCustomParameters { get; set; } = []; + + [JsonPropertyName("HasCustomInstallLocation")] + public List HasCustomInstallLocation { get; set; } = []; + + [JsonPropertyName("HasPrePostCommands")] + public List HasPrePostCommands { get; set; } = []; + + [JsonPropertyName("HasKillBeforeOperation")] + public List HasKillBeforeOperation { get; set; } = []; + + [JsonPropertyName("HasUninstallPrevious")] + public List HasUninstallPrevious { get; set; } = []; +} + +public sealed class VersionRange +{ + [JsonPropertyName("MinVersion")] + public string? MinVersion { get; set; } + + [JsonPropertyName("MaxVersion")] + public string? MaxVersion { get; set; } + + [JsonPropertyName("IncludePrerelease")] + public bool IncludePrerelease { get; set; } +} + +public sealed class PolicyConstraints +{ + [JsonPropertyName("AllowInteractive")] + public bool AllowInteractive { get; set; } = true; + + [JsonPropertyName("AllowSkipHashCheck")] + public bool AllowSkipHashCheck { get; set; } = true; + + [JsonPropertyName("AllowPreRelease")] + public bool AllowPreRelease { get; set; } = true; + + [JsonPropertyName("AllowCustomInstallLocation")] + public bool AllowCustomInstallLocation { get; set; } = true; + + [JsonPropertyName("AllowedInstallLocationPatterns")] + public List AllowedInstallLocationPatterns { get; set; } = []; + + [JsonPropertyName("AllowCustomParameters")] + public bool AllowCustomParameters { get; set; } = true; + + [JsonPropertyName("AllowedCustomParameters")] + public List AllowedCustomParameters { get; set; } = []; + + [JsonPropertyName("AllowedCustomParameterPatterns")] + public List AllowedCustomParameterPatterns { get; set; } = []; + + [JsonPropertyName("DeniedCustomParameters")] + public List DeniedCustomParameters { get; set; } = []; + + [JsonPropertyName("AllowPrePostCommands")] + public bool AllowPrePostCommands { get; set; } = true; + + [JsonPropertyName("AllowKillBeforeOperation")] + public bool AllowKillBeforeOperation { get; set; } = true; + + [JsonPropertyName("AllowUninstallPrevious")] + public bool AllowUninstallPrevious { get; set; } = true; + + [JsonPropertyName("AllowUpgrade")] + public bool AllowUpgrade { get; set; } = true; +} \ No newline at end of file diff --git a/policies/dotnet/README.md b/policies/dotnet/README.md new file mode 100644 index 0000000..d8907b9 --- /dev/null +++ b/policies/dotnet/README.md @@ -0,0 +1,4 @@ +Devolutions UniGetUI broker policy +================================== + +This package provides .NET types and JSON/YAML parsing helpers for the UniGetUI package broker policy format. diff --git a/policies/rust/unigetui-broker-policy/Cargo.toml b/policies/rust/unigetui-broker-policy/Cargo.toml new file mode 100644 index 0000000..ba16899 --- /dev/null +++ b/policies/rust/unigetui-broker-policy/Cargo.toml @@ -0,0 +1,28 @@ +[package] +name = "unigetui-broker-policy" +version = "0.1.0" +edition = "2024" +license.workspace = true +homepage.workspace = true +repository.workspace = true +authors.workspace = true +readme = "README.md" +description = "UniGetUI package broker policy model and schema" +publish = true + +[lints] +workspace = true + +[dependencies] +chrono = { version = "0.4", features = ["serde"] } +schemars = { version = "0.8", features = ["chrono"] } +semver = "1" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +serde_yaml = "0.9" +thiserror = "2" +url = "2" + +[[bin]] +name = "generate-unigetui-broker-policy-schema" +path = "tools/generate_schema.rs" diff --git a/policies/rust/unigetui-broker-policy/README.md b/policies/rust/unigetui-broker-policy/README.md new file mode 100644 index 0000000..1428b82 --- /dev/null +++ b/policies/rust/unigetui-broker-policy/README.md @@ -0,0 +1,7 @@ +UniGetUI broker policy model +============================ + +This crate provides the Rust policy model and JSON Schema helpers for the UniGetUI package broker policy format. + +It contains only admin-authored policy types and schema generation utilities. +Broker request, response, server, transport, and execution types are intentionally out of scope. diff --git a/policies/rust/unigetui-broker-policy/assets/samples/corporate-allowlist.policy.json b/policies/rust/unigetui-broker-policy/assets/samples/corporate-allowlist.policy.json new file mode 100644 index 0000000..fe788a4 --- /dev/null +++ b/policies/rust/unigetui-broker-policy/assets/samples/corporate-allowlist.policy.json @@ -0,0 +1,134 @@ +{ + "$schema": "https://aka.ms/unigetui/package-policy.schema.1.0.json", + "PolicyVersion": "1.0.0", + "PolicyType": "PackageBrokerPolicy", + "Metadata": { + "Id": "contoso.desktop.standard-allowlist", + "Publisher": "Contoso IT", + "Revision": 4, + "PublishedAt": "2026-05-05T00:00:00Z", + "Description": "Fail-closed policy for standard workstation package installs." + }, + "Enforcement": { + "DefaultDecision": "Deny", + "RulePrecedence": "PriorityThenDeny" + }, + "Rules": [ + { + "Id": "deny.integrity-bypass", + "Enabled": true, + "Priority": 10, + "Decision": "Deny", + "Reason": "Integrity and publisher checks cannot be bypassed by brokered requests.", + "Match": { + "Operations": [ + "Install", + "Update" + ], + "SkipHashCheck": [ + true + ] + } + }, + { + "Id": "deny.custom-parameters", + "Enabled": true, + "Priority": 20, + "Decision": "Deny", + "Reason": "Custom package-manager parameters are not allowed in the workstation allow list.", + "Match": { + "HasCustomParameters": [ + true + ] + } + }, + { + "Id": "deny.prepost-commands", + "Enabled": true, + "Priority": 30, + "Decision": "Deny", + "Reason": "Pre and post operation commands are not allowed in the workstation allow list.", + "Match": { + "HasPrePostCommands": [ + true + ] + } + }, + { + "Id": "allow.winget.vscode", + "Enabled": true, + "Priority": 100, + "Decision": "Allow", + "Reason": "Visual Studio Code is approved for managed workstations.", + "Match": { + "Operations": [ + "Install", + "Update" + ], + "Managers": [ + "Winget" + ], + "Sources": [ + "winget" + ], + "PackageIdentifiers": [ + "Microsoft.VisualStudioCode" + ], + "Scopes": [ + "User", + "Machine" + ], + "Architectures": [ + "X64", + "Arm64" + ] + }, + "Constraints": { + "AllowInteractive": false, + "AllowSkipHashCheck": false, + "AllowPreRelease": false, + "AllowCustomParameters": false, + "AllowPrePostCommands": false, + "AllowKillBeforeOperation": false + } + }, + { + "Id": "allow.winget.powertoys", + "Enabled": true, + "Priority": 100, + "Decision": "Allow", + "Reason": "PowerToys is approved for developer workstations.", + "Match": { + "Operations": [ + "Install", + "Update" + ], + "Managers": [ + "Winget" + ], + "Sources": [ + "winget" + ], + "PackageIdentifiers": [ + "Microsoft.PowerToys" + ], + "Scopes": [ + "User", + "Machine" + ], + "Architectures": [ + "X64", + "Arm64" + ] + }, + "Constraints": { + "AllowInteractive": false, + "AllowSkipHashCheck": false, + "AllowPreRelease": false, + "AllowCustomParameters": false, + "AllowPrePostCommands": false, + "AllowKillBeforeOperation": false + } + } + ] +} \ No newline at end of file diff --git a/policies/rust/unigetui-broker-policy/assets/samples/corporate-allowlist.policy.yaml b/policies/rust/unigetui-broker-policy/assets/samples/corporate-allowlist.policy.yaml new file mode 100644 index 0000000..20d9519 --- /dev/null +++ b/policies/rust/unigetui-broker-policy/assets/samples/corporate-allowlist.policy.yaml @@ -0,0 +1,52 @@ +"$schema": https://aka.ms/unigetui/package-policy.schema.1.0.json +PolicyVersion: 1.0.0 +PolicyType: PackageBrokerPolicy +Metadata: + Id: contoso.desktop.standard-allowlist-yaml + Publisher: Contoso IT + Revision: 1 + PublishedAt: "2026-05-05T00:00:00Z" + Description: Fail-closed YAML policy for standard workstation package installs. +Enforcement: + DefaultDecision: Deny + RulePrecedence: PriorityThenDeny +Rules: + - Id: deny.integrity-bypass + Enabled: true + Priority: 10 + Decision: Deny + Reason: Integrity and publisher checks cannot be bypassed by brokered requests. + Match: + Operations: + - Install + - Update + SkipHashCheck: + - true + - Id: allow.winget.vscode + Enabled: true + Priority: 100 + Decision: Allow + Reason: Visual Studio Code is approved for managed workstations. + Match: + Operations: + - Install + - Update + Managers: + - Winget + Sources: + - winget + PackageIdentifiers: + - Microsoft.VisualStudioCode + Scopes: + - User + - Machine + Architectures: + - X64 + - Arm64 + Constraints: + AllowInteractive: false + AllowSkipHashCheck: false + AllowPreRelease: false + AllowCustomParameters: false + AllowPrePostCommands: false + AllowKillBeforeOperation: false diff --git a/policies/rust/unigetui-broker-policy/assets/samples/deny-risky-options.policy.json b/policies/rust/unigetui-broker-policy/assets/samples/deny-risky-options.policy.json new file mode 100644 index 0000000..ac0896f --- /dev/null +++ b/policies/rust/unigetui-broker-policy/assets/samples/deny-risky-options.policy.json @@ -0,0 +1,81 @@ +{ + "$schema": "https://aka.ms/unigetui/package-policy.schema.1.0.json", + "PolicyVersion": "1.0.0", + "PolicyType": "PackageBrokerPolicy", + "Metadata": { + "Id": "contoso.desktop.deny-risky-options", + "Publisher": "Contoso IT", + "Revision": 2, + "PublishedAt": "2026-05-05T00:00:00Z", + "Description": "Default-allow policy that blocks risky broker request options." + }, + "Enforcement": { + "DefaultDecision": "Allow", + "RulePrecedence": "PriorityThenDeny" + }, + "Rules": [ + { + "Id": "deny.integrity-bypass", + "Priority": 10, + "Decision": "Deny", + "Reason": "Do not broker installs that skip WinGet hash checks or PowerShell publisher checks.", + "Match": { + "Operations": [ + "Install", + "Update" + ], + "SkipHashCheck": [ + true + ] + } + }, + { + "Id": "deny.manager-custom-parameters", + "Priority": 20, + "Decision": "Deny", + "Reason": "Custom package-manager parameters require a dedicated exception policy.", + "Match": { + "HasCustomParameters": [ + true + ] + } + }, + { + "Id": "deny.prepost-commands", + "Priority": 30, + "Decision": "Deny", + "Reason": "Pre and post operation commands are outside the package manager trust boundary.", + "Match": { + "HasPrePostCommands": [ + true + ] + } + }, + { + "Id": "deny.kill-process-actions", + "Priority": 40, + "Decision": "Deny", + "Reason": "Killing processes before a brokered package operation is not allowed by this policy.", + "Match": { + "HasKillBeforeOperation": [ + true + ] + } + }, + { + "Id": "deny.unapproved-winget-source", + "Priority": 50, + "Decision": "Deny", + "Reason": "Only the default WinGet source is accepted by this deny-list sample.", + "Match": { + "Managers": [ + "Winget" + ], + "Sources": [ + "msstore", + "winget-fonts" + ] + } + } + ] +} \ No newline at end of file diff --git a/policies/rust/unigetui-broker-policy/assets/samples/invalid/policies/invalid-failure-decision.policy.json b/policies/rust/unigetui-broker-policy/assets/samples/invalid/policies/invalid-failure-decision.policy.json new file mode 100644 index 0000000..9439e34 --- /dev/null +++ b/policies/rust/unigetui-broker-policy/assets/samples/invalid/policies/invalid-failure-decision.policy.json @@ -0,0 +1,28 @@ +{ + "$schema": "https://aka.ms/unigetui/package-policy.schema.1.0.json", + "PolicyVersion": "1.0.0", + "PolicyType": "PackageBrokerPolicy", + "Metadata": { + "Id": "contoso.invalid.failure-decision", + "Publisher": "Contoso IT", + "Revision": 1, + "PublishedAt": "2026-05-05T00:00:00Z" + }, + "Enforcement": { + "DefaultDecision": "Allow", + "failureDecision": "Allow", + "RulePrecedence": "PriorityThenDeny" + }, + "Rules": [ + { + "Id": "allow.everything", + "Priority": 100, + "Decision": "Allow", + "Match": { + "PackageIdentifiers": [ + "*" + ] + } + } + ] +} \ No newline at end of file diff --git a/policies/rust/unigetui-broker-policy/assets/samples/powershell-advanced.policy.json b/policies/rust/unigetui-broker-policy/assets/samples/powershell-advanced.policy.json new file mode 100644 index 0000000..9aa4cd8 --- /dev/null +++ b/policies/rust/unigetui-broker-policy/assets/samples/powershell-advanced.policy.json @@ -0,0 +1,82 @@ +{ + "$schema": "https://aka.ms/unigetui/package-policy.schema.1.0.json", + "PolicyVersion": "1.0.0", + "PolicyType": "PackageBrokerPolicy", + "Metadata": { + "Id": "contoso.powershell.advanced-scenarios", + "Publisher": "Contoso IT", + "Revision": 1, + "PublishedAt": "2026-05-05T00:00:00Z", + "Description": "PowerShell policy fixture for source, version range, and update operation coverage." + }, + "Enforcement": { + "DefaultDecision": "Deny", + "RulePrecedence": "PriorityThenDeny" + }, + "Rules": [ + { + "Id": "deny.powershell.untrusted-source", + "Priority": 5, + "Decision": "Deny", + "Reason": "Only PSGallery is approved for brokered PowerShell module operations.", + "Match": { + "Managers": [ + "PowerShell" + ], + "Sources": [ + "PoshTestGallery" + ] + } + }, + { + "Id": "deny.powershell.prerelease", + "Priority": 10, + "Decision": "Deny", + "Reason": "Prerelease PowerShell modules are not approved in advanced scenarios.", + "Match": { + "Managers": [ + "PowerShell" + ], + "PreRelease": [ + true + ] + } + }, + { + "Id": "allow.powershell.pester.versioned", + "Priority": 100, + "Decision": "Allow", + "Reason": "Pester is approved from PSGallery for CurrentUser install and update operations within the supported version range.", + "Match": { + "Operations": [ + "Install", + "Update" + ], + "Managers": [ + "PowerShell" + ], + "Sources": [ + "PSGallery" + ], + "PackageIdentifiers": [ + "Pester" + ], + "VersionRange": { + "MinVersion": "5.0.0", + "MaxVersion": "6.0.0", + "IncludePrerelease": false + }, + "Scopes": [ + "User" + ] + }, + "Constraints": { + "AllowSkipHashCheck": false, + "AllowPreRelease": false, + "AllowCustomParameters": false, + "AllowPrePostCommands": false, + "AllowKillBeforeOperation": false + } + } + ] +} \ No newline at end of file diff --git a/policies/rust/unigetui-broker-policy/assets/samples/powershell-current-user.policy.json b/policies/rust/unigetui-broker-policy/assets/samples/powershell-current-user.policy.json new file mode 100644 index 0000000..36af55b --- /dev/null +++ b/policies/rust/unigetui-broker-policy/assets/samples/powershell-current-user.policy.json @@ -0,0 +1,91 @@ +{ + "$schema": "https://aka.ms/unigetui/package-policy.schema.1.0.json", + "PolicyVersion": "1.0.0", + "PolicyType": "PackageBrokerPolicy", + "Metadata": { + "Id": "contoso.powershell.current-user-modules", + "Publisher": "Contoso IT", + "Revision": 3, + "PublishedAt": "2026-05-05T00:00:00Z", + "Description": "PowerShell Gallery module policy for non-admin CurrentUser installs." + }, + "Enforcement": { + "DefaultDecision": "Deny", + "RulePrecedence": "PriorityThenDeny" + }, + "Rules": [ + { + "Id": "deny.powershell.machine-scope", + "Priority": 10, + "Decision": "Deny", + "Reason": "PowerShell module installs through the broker must use CurrentUser scope.", + "Match": { + "Managers": [ + "PowerShell" + ], + "Scopes": [ + "Machine" + ] + } + }, + { + "Id": "deny.powershell.elevated", + "Priority": 20, + "Decision": "Deny", + "Reason": "PowerShell module installs must not request an elevated broker context.", + "Match": { + "Managers": [ + "PowerShell" + ], + "Elevation": [ + "Elevated" + ] + } + }, + { + "Id": "deny.powershell.prerelease", + "Priority": 30, + "Decision": "Deny", + "Reason": "Prerelease PowerShell modules are not approved.", + "Match": { + "Managers": [ + "PowerShell" + ], + "PreRelease": [ + true + ] + } + }, + { + "Id": "allow.powershell.pester", + "Priority": 100, + "Decision": "Allow", + "Reason": "Pester is approved from PSGallery for CurrentUser installs.", + "Match": { + "Operations": [ + "Install", + "Update" + ], + "Managers": [ + "PowerShell" + ], + "Sources": [ + "PSGallery" + ], + "PackageIdentifiers": [ + "Pester" + ], + "Scopes": [ + "User" + ] + }, + "Constraints": { + "AllowSkipHashCheck": false, + "AllowPreRelease": false, + "AllowCustomParameters": false, + "AllowPrePostCommands": false, + "AllowKillBeforeOperation": false + } + } + ] +} \ No newline at end of file diff --git a/policies/rust/unigetui-broker-policy/assets/samples/scenario-coverage.policy.json b/policies/rust/unigetui-broker-policy/assets/samples/scenario-coverage.policy.json new file mode 100644 index 0000000..0447ec9 --- /dev/null +++ b/policies/rust/unigetui-broker-policy/assets/samples/scenario-coverage.policy.json @@ -0,0 +1,257 @@ +{ + "$schema": "https://aka.ms/unigetui/package-policy.schema.1.0.json", + "PolicyVersion": "1.0.0", + "PolicyType": "PackageBrokerPolicy", + "Metadata": { + "Id": "contoso.desktop.scenario-coverage", + "Publisher": "Contoso IT", + "Revision": 1, + "PublishedAt": "2026-05-05T00:00:00Z", + "Description": "Focused policy used to exercise simulator precedence, version, and constraint behavior." + }, + "Enforcement": { + "DefaultDecision": "Deny", + "RulePrecedence": "PriorityThenDeny" + }, + "Rules": [ + { + "Id": "deny.disabled-powertoys", + "Enabled": false, + "Priority": 1, + "Decision": "Deny", + "Reason": "Disabled rules must not participate in policy decisions.", + "Match": { + "Managers": [ + "Winget" + ], + "PackageIdentifiers": [ + "Microsoft.PowerToys" + ] + } + }, + { + "Id": "deny.interactive", + "Priority": 5, + "Decision": "Deny", + "Reason": "Interactive brokered installs are not allowed in the scenario coverage policy.", + "Match": { + "Interactive": [ + true + ] + } + }, + { + "Id": "deny.tie.custom-parameters", + "Priority": 10, + "Decision": "Deny", + "Reason": "Deny must win when allow and deny rules match at the same priority.", + "Match": { + "Managers": [ + "Winget" + ], + "PackageIdentifiers": [ + "Microsoft.VisualStudioCode" + ], + "HasCustomParameters": [ + true + ] + } + }, + { + "Id": "allow.tie.vscode-custom-parameters", + "Priority": 10, + "Decision": "Allow", + "Reason": "This intentionally ties the deny rule to prove deny wins ties.", + "Match": { + "Managers": [ + "Winget" + ], + "PackageIdentifiers": [ + "Microsoft.VisualStudioCode" + ], + "HasCustomParameters": [ + true + ] + }, + "Constraints": { + "AllowCustomParameters": true + } + }, + { + "Id": "deny.prepost-commands", + "Priority": 20, + "Decision": "Deny", + "Reason": "Pre and post commands are outside the package manager trust boundary.", + "Match": { + "HasPrePostCommands": [ + true + ] + } + }, + { + "Id": "deny.kill-process-actions", + "Priority": 30, + "Decision": "Deny", + "Reason": "Killing processes before a brokered operation is not allowed.", + "Match": { + "HasKillBeforeOperation": [ + true + ] + } + }, + { + "Id": "allow.winget.powertoys", + "Priority": 100, + "Decision": "Allow", + "Reason": "PowerToys is allowed and proves disabled deny rules are ignored.", + "Match": { + "Operations": [ + "Install", + "Update" + ], + "Managers": [ + "Winget" + ], + "Sources": [ + "winget" + ], + "PackageIdentifiers": [ + "Microsoft.PowerToys" + ], + "Scopes": [ + "Machine" + ], + "Architectures": [ + "X64" + ] + }, + "Constraints": { + "AllowInteractive": false, + "AllowSkipHashCheck": false, + "AllowPreRelease": false, + "AllowCustomParameters": false, + "AllowPrePostCommands": false, + "AllowKillBeforeOperation": false + } + }, + { + "Id": "allow.winget.vscode.version-range", + "Priority": 100, + "Decision": "Allow", + "Reason": "VS Code is allowed only within the tested version range.", + "Match": { + "Operations": [ + "Install", + "Update" + ], + "Managers": [ + "Winget" + ], + "Sources": [ + "winget" + ], + "PackageIdentifiers": [ + "Microsoft.VisualStudioCode" + ], + "VersionRange": { + "MinVersion": "1.90.0", + "MaxVersion": "2.0.0", + "IncludePrerelease": false + }, + "Scopes": [ + "Machine" + ], + "Architectures": [ + "X64" + ] + }, + "Constraints": { + "AllowInteractive": false, + "AllowSkipHashCheck": false, + "AllowPreRelease": false, + "AllowCustomParameters": false, + "AllowPrePostCommands": false, + "AllowKillBeforeOperation": false + } + }, + { + "Id": "allow.winget.git.customized", + "Priority": 100, + "Decision": "Allow", + "Reason": "Git is allowed with tightly constrained customization.", + "Match": { + "Operations": [ + "Install", + "Update" + ], + "Managers": [ + "Winget" + ], + "Sources": [ + "winget" + ], + "PackageIdentifiers": [ + "Git.Git" + ], + "Scopes": [ + "Machine" + ], + "Architectures": [ + "X64" + ] + }, + "Constraints": { + "AllowInteractive": false, + "AllowSkipHashCheck": false, + "AllowPreRelease": false, + "AllowCustomInstallLocation": true, + "AllowedInstallLocationPatterns": [ + "C:\\Tools\\Git*" + ], + "AllowCustomParameters": true, + "AllowedCustomParameters": [ + "--accept-source-agreements" + ], + "DeniedCustomParameters": [ + "--override*" + ], + "AllowPrePostCommands": false, + "AllowKillBeforeOperation": false + } + }, + { + "Id": "allow.winget.git.uninstall", + "Priority": 100, + "Decision": "Allow", + "Reason": "Git uninstall is allowed for the same corporate package source and machine scope.", + "Match": { + "Operations": [ + "Uninstall" + ], + "Managers": [ + "Winget" + ], + "Sources": [ + "winget" + ], + "PackageIdentifiers": [ + "Git.Git" + ], + "Scopes": [ + "Machine" + ], + "Architectures": [ + "X64" + ] + }, + "Constraints": { + "AllowInteractive": false, + "AllowSkipHashCheck": false, + "AllowPreRelease": false, + "AllowCustomParameters": false, + "AllowPrePostCommands": false, + "AllowKillBeforeOperation": false + } + } + ] +} \ No newline at end of file diff --git a/policies/rust/unigetui-broker-policy/schema/unigetui.package-policy.schema.json b/policies/rust/unigetui-broker-policy/schema/unigetui.package-policy.schema.json new file mode 100644 index 0000000..c824093 --- /dev/null +++ b/policies/rust/unigetui-broker-policy/schema/unigetui.package-policy.schema.json @@ -0,0 +1,627 @@ +{ + "$id": "https://aka.ms/unigetui/package-policy.schema.1.0.json", + "$schema": "http://json-schema.org/draft-07/schema#", + "additionalProperties": false, + "definitions": { + "Architecture": { + "description": "Target architecture.", + "enum": [ + "X86", + "X64", + "Arm64", + "Neutral" + ], + "type": "string" + }, + "CustomParameterString": { + "description": "A custom parameter string.", + "maxLength": 512, + "minLength": 1, + "type": "string" + }, + "Decision": { + "description": "Policy decision.", + "enum": [ + "Allow", + "Deny" + ], + "type": "string" + }, + "Elevation": { + "description": "Requested elevation level.", + "enum": [ + "Standard", + "Elevated" + ], + "type": "string" + }, + "HttpUrl": { + "description": "HTTP(S) URL string.\n\nValidated at deserialization time using the `url` crate.", + "maxLength": 2048, + "pattern": "^([Hh][Tt][Tt][Pp][Ss]?)://.+$", + "type": "string" + }, + "ManagerName": { + "description": "Supported package manager names.", + "enum": [ + "Winget", + "PowerShell", + "PowerShell7" + ], + "type": "string" + }, + "Operation": { + "description": "Package operation type.", + "enum": [ + "Install", + "Update", + "Uninstall" + ], + "type": "string" + }, + "PackageBrokerPolicy": { + "enum": [ + "PackageBrokerPolicy" + ], + "type": "string" + }, + "PolicyConstraints": { + "additionalProperties": false, + "description": "Constraints applied after a rule matches.", + "properties": { + "AllowCustomInstallLocation": { + "description": "Allow custom install location.", + "type": "boolean" + }, + "AllowCustomParameters": { + "description": "Allow custom parameters.", + "type": "boolean" + }, + "AllowInteractive": { + "description": "Allow interactive mode.", + "type": "boolean" + }, + "AllowKillBeforeOperation": { + "description": "Allow killing processes before operation.", + "type": "boolean" + }, + "AllowPrePostCommands": { + "description": "Allow pre/post operation commands.", + "type": "boolean" + }, + "AllowPreRelease": { + "description": "Allow pre-release versions.", + "type": "boolean" + }, + "AllowSkipHashCheck": { + "description": "Allow skipping hash verification.", + "type": "boolean" + }, + "AllowUninstallPrevious": { + "description": "Allow uninstalling previous version before installing update.", + "type": "boolean" + }, + "AllowUpgrade": { + "description": "Allow skipping upgrade on install operations if an existing version is detected (for install operations).", + "type": "boolean" + }, + "AllowedCustomParameterPatterns": { + "description": "Glob patterns for allowed custom parameters.", + "items": { + "$ref": "#/definitions/CustomParameterString" + }, + "maxItems": 128, + "type": "array" + }, + "AllowedCustomParameters": { + "description": "Exact allowed custom parameters.", + "items": { + "$ref": "#/definitions/CustomParameterString" + }, + "maxItems": 128, + "type": "array" + }, + "AllowedInstallLocationPatterns": { + "description": "Glob patterns for allowed install locations.", + "items": { + "$ref": "#/definitions/StringPattern" + }, + "maxItems": 64, + "type": "array" + }, + "DeniedCustomParameters": { + "description": "Denied custom parameters (deny takes precedence over allow).", + "items": { + "$ref": "#/definitions/CustomParameterString" + }, + "maxItems": 128, + "type": "array" + } + }, + "type": "object" + }, + "PolicyEnforcement": { + "additionalProperties": false, + "description": "Enforcement configuration.", + "properties": { + "AuditMode": { + "description": "When true, broker logs decisions but does not enforce.", + "type": [ + "boolean", + "null" + ] + }, + "DefaultDecision": { + "allOf": [ + { + "$ref": "#/definitions/Decision" + } + ], + "description": "Decision when no rule matches." + }, + "RulePrecedence": { + "allOf": [ + { + "$ref": "#/definitions/RulePrecedence" + } + ], + "description": "Rule precedence strategy (must be \"PriorityThenDeny\")." + } + }, + "required": [ + "DefaultDecision", + "RulePrecedence" + ], + "type": "object" + }, + "PolicyMatch": { + "additionalProperties": false, + "description": "Match criteria for a policy rule. All specified fields must match. At least one field must be present.", + "properties": { + "Architectures": { + "description": "Allowed architectures.", + "items": { + "$ref": "#/definitions/Architecture" + }, + "maxItems": 5, + "type": "array", + "uniqueItems": true + }, + "Elevation": { + "description": "Allowed elevation levels.", + "items": { + "$ref": "#/definitions/Elevation" + }, + "maxItems": 2, + "type": "array", + "uniqueItems": true + }, + "HasCustomInstallLocation": { + "description": "Whether request has custom install location.", + "items": { + "type": "boolean" + }, + "maxItems": 2, + "type": "array", + "uniqueItems": true + }, + "HasCustomParameters": { + "description": "Whether request has custom parameters.", + "items": { + "type": "boolean" + }, + "maxItems": 2, + "type": "array", + "uniqueItems": true + }, + "HasKillBeforeOperation": { + "description": "Whether request has kill-before-operation entries.", + "items": { + "type": "boolean" + }, + "maxItems": 2, + "type": "array", + "uniqueItems": true + }, + "HasPrePostCommands": { + "description": "Whether request has pre/post operation commands.", + "items": { + "type": "boolean" + }, + "maxItems": 2, + "type": "array", + "uniqueItems": true + }, + "HasUninstallPrevious": { + "description": "Whether request has uninstall-previous flag set.", + "items": { + "type": "boolean" + }, + "type": "array", + "uniqueItems": true + }, + "Interactive": { + "description": "Allowed interactive values.", + "items": { + "type": "boolean" + }, + "maxItems": 2, + "type": "array", + "uniqueItems": true + }, + "Managers": { + "description": "Allowed managers.", + "items": { + "$ref": "#/definitions/ManagerName" + }, + "maxItems": 16, + "type": "array", + "uniqueItems": true + }, + "Operations": { + "description": "Allowed operations.", + "items": { + "$ref": "#/definitions/Operation" + }, + "maxItems": 3, + "type": "array", + "uniqueItems": true + }, + "PackageIdentifiers": { + "description": "Package identifier patterns (wildcard).", + "items": { + "$ref": "#/definitions/StringPattern" + }, + "maxItems": 1024, + "type": "array", + "uniqueItems": true + }, + "PackageNames": { + "description": "Package name patterns (wildcard).", + "items": { + "$ref": "#/definitions/StringPattern" + }, + "maxItems": 1024, + "type": "array", + "uniqueItems": true + }, + "PreRelease": { + "description": "Allowed preRelease values.", + "items": { + "type": "boolean" + }, + "maxItems": 2, + "type": "array", + "uniqueItems": true + }, + "Scopes": { + "description": "Allowed scopes.", + "items": { + "$ref": "#/definitions/Scope" + }, + "maxItems": 2, + "type": "array", + "uniqueItems": true + }, + "SkipHashCheck": { + "description": "Allowed skipHashCheck values.", + "items": { + "type": "boolean" + }, + "maxItems": 2, + "type": "array", + "uniqueItems": true + }, + "Sources": { + "description": "Source patterns (wildcard).", + "items": { + "$ref": "#/definitions/StringPattern" + }, + "maxItems": 128, + "type": "array", + "uniqueItems": true + }, + "VersionRange": { + "anyOf": [ + { + "$ref": "#/definitions/VersionRange" + }, + { + "type": "null" + } + ], + "description": "Semantic version range." + }, + "Versions": { + "description": "Exact version list.", + "items": { + "$ref": "#/definitions/VersionString" + }, + "maxItems": 256, + "type": "array", + "uniqueItems": true + } + }, + "type": "object" + }, + "PolicyMetadata": { + "additionalProperties": false, + "description": "Policy metadata.", + "properties": { + "Description": { + "description": "Human-readable description.", + "maxLength": 512, + "type": [ + "string", + "null" + ] + }, + "Id": { + "allOf": [ + { + "$ref": "#/definitions/ResourceId" + } + ], + "description": "Unique policy identifier." + }, + "PublishedAt": { + "description": "ISO 8601 publication timestamp (RFC 3339).", + "format": "date-time", + "type": "string" + }, + "Publisher": { + "description": "Organization that published the policy.", + "maxLength": 128, + "minLength": 1, + "type": "string" + }, + "Revision": { + "description": "Monotonically increasing revision number.", + "format": "uint32", + "maximum": 2147483647.0, + "minimum": 1.0, + "type": "integer" + }, + "SupportUrl": { + "anyOf": [ + { + "$ref": "#/definitions/HttpUrl" + }, + { + "type": "null" + } + ], + "description": "URL for support or documentation." + }, + "ValidFrom": { + "description": "Policy becomes active at this time.", + "format": "date-time", + "type": [ + "string", + "null" + ] + }, + "ValidUntil": { + "description": "Policy expires at this time.", + "format": "date-time", + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "Id", + "PublishedAt", + "Publisher", + "Revision" + ], + "type": "object" + }, + "PolicyRule": { + "additionalProperties": false, + "description": "A single policy rule.", + "properties": { + "Constraints": { + "anyOf": [ + { + "$ref": "#/definitions/PolicyConstraints" + }, + { + "type": "null" + } + ], + "description": "Additional constraints applied after matching. When absent, no constraints are enforced beyond the match criteria." + }, + "Decision": { + "allOf": [ + { + "$ref": "#/definitions/Decision" + } + ], + "description": "Decision if this rule matches." + }, + "Enabled": { + "default": true, + "description": "Whether the rule is active.", + "type": "boolean" + }, + "Id": { + "allOf": [ + { + "$ref": "#/definitions/ResourceId" + } + ], + "description": "Unique rule identifier." + }, + "Match": { + "allOf": [ + { + "$ref": "#/definitions/PolicyMatch" + } + ], + "description": "Match criteria — request must satisfy all specified fields. At least one criterion must be present.", + "minProperties": 1 + }, + "Priority": { + "description": "Priority (lower = higher precedence).", + "format": "uint32", + "maximum": 2147483647.0, + "minimum": 0.0, + "type": "integer" + }, + "Reason": { + "description": "Reason reported to the client.", + "maxLength": 512, + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "Decision", + "Id", + "Match", + "Priority" + ], + "type": "object" + }, + "PolicySchemaUri": { + "enum": [ + "https://aka.ms/unigetui/package-policy.schema.1.0.json" + ], + "type": "string" + }, + "ResourceId": { + "description": "Resource identifier (policy IDs, rule IDs, request IDs, audit IDs).", + "maxLength": 128, + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:\\-]{0,127}$", + "type": "string" + }, + "RulePrecedence": { + "description": "Rule precedence strategy — always PriorityThenDeny.", + "enum": [ + "PriorityThenDeny" + ], + "type": "string" + }, + "Scope": { + "description": "Package installation scope.", + "enum": [ + "User", + "Machine" + ], + "type": "string" + }, + "SemanticVersion": { + "description": "Semantic version string (SemVer 2.0.0).\n\nValidated at deserialization time using the `semver` crate.", + "maxLength": 128, + "pattern": "^(0|[1-9]\\d*)\\.(0|[1-9]\\d*)\\.(0|[1-9]\\d*)(?:-((?:0|[1-9]\\d*|\\d*[A-Za-z-][0-9A-Za-z-]*)(?:\\.(?:0|[1-9]\\d*|\\d*[A-Za-z-][0-9A-Za-z-]*))*))?(?:\\+([0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*))?$", + "type": "string" + }, + "StringPattern": { + "description": "Case-insensitive exact value or wildcard pattern.", + "maxLength": 256, + "minLength": 1, + "type": "string" + }, + "VersionRange": { + "additionalProperties": false, + "description": "Semantic version range for matching.", + "properties": { + "IncludePrerelease": { + "default": false, + "description": "Whether to include pre-release versions.", + "type": "boolean" + }, + "MaxVersion": { + "description": "Maximum version (inclusive).", + "maxLength": 128, + "minLength": 1, + "type": [ + "string", + "null" + ] + }, + "MinVersion": { + "description": "Minimum version (inclusive).", + "maxLength": 128, + "minLength": 1, + "type": [ + "string", + "null" + ] + } + }, + "type": "object" + }, + "VersionString": { + "description": "A short constrained string for version values.", + "maxLength": 128, + "minLength": 1, + "type": "string" + } + }, + "description": "A policy document governing which package operations are allowed or denied.", + "properties": { + "$schema": { + "allOf": [ + { + "$ref": "#/definitions/PolicySchemaUri" + } + ], + "description": "Policy schema URI constant." + }, + "Enforcement": { + "allOf": [ + { + "$ref": "#/definitions/PolicyEnforcement" + } + ], + "description": "Enforcement configuration." + }, + "Metadata": { + "allOf": [ + { + "$ref": "#/definitions/PolicyMetadata" + } + ], + "description": "Policy metadata." + }, + "PolicyType": { + "allOf": [ + { + "$ref": "#/definitions/PackageBrokerPolicy" + } + ], + "description": "Must be `\"PackageBrokerPolicy\"`." + }, + "PolicyVersion": { + "allOf": [ + { + "$ref": "#/definitions/SemanticVersion" + } + ], + "description": "Policy syntax version (semver)." + }, + "Rules": { + "description": "Ordered list of policy rules (may be empty; enforcement defaults apply).", + "items": { + "$ref": "#/definitions/PolicyRule" + }, + "maxItems": 1024, + "type": "array" + } + }, + "required": [ + "$schema", + "Enforcement", + "Metadata", + "PolicyType", + "PolicyVersion", + "Rules" + ], + "title": "PolicyDocument", + "type": "object" +} \ No newline at end of file diff --git a/policies/rust/unigetui-broker-policy/src/enums.rs b/policies/rust/unigetui-broker-policy/src/enums.rs new file mode 100644 index 0000000..f363a03 --- /dev/null +++ b/policies/rust/unigetui-broker-policy/src/enums.rs @@ -0,0 +1,114 @@ +//! Policy-domain enumerations shared with broker requests. + +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +/// Package operation type. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema)] +#[schemars(rename = "Operation")] +pub enum Operation { + Install, + Update, + Uninstall, +} + +/// Package installation scope. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema)] +#[schemars(rename = "Scope")] +pub enum Scope { + User, + Machine, +} + +/// Target architecture. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema)] +#[schemars(rename = "Architecture")] +pub enum Architecture { + X86, + X64, + Arm64, + Neutral, +} + +/// Supported package manager names. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema)] +#[schemars(rename = "ManagerName")] +pub enum ManagerName { + Winget, + PowerShell, + PowerShell7, +} + +/// Policy decision. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[schemars(rename = "Decision")] +pub enum Decision { + Allow, + Deny, +} + +/// Requested elevation level. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema)] +#[schemars(rename = "Elevation")] +pub enum Elevation { + Standard, + Elevated, +} + +impl std::fmt::Display for Decision { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Allow => f.write_str("Allow"), + Self::Deny => f.write_str("Deny"), + } + } +} + +impl std::fmt::Display for Operation { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Install => f.write_str("Install"), + Self::Update => f.write_str("Update"), + Self::Uninstall => f.write_str("Uninstall"), + } + } +} + +impl std::fmt::Display for ManagerName { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Winget => f.write_str("Winget"), + Self::PowerShell => f.write_str("PowerShell"), + Self::PowerShell7 => f.write_str("PowerShell7"), + } + } +} + +impl std::fmt::Display for Scope { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::User => f.write_str("User"), + Self::Machine => f.write_str("Machine"), + } + } +} + +impl std::fmt::Display for Elevation { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Standard => f.write_str("Standard"), + Self::Elevated => f.write_str("Elevated"), + } + } +} + +impl std::fmt::Display for Architecture { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::X86 => f.write_str("X86"), + Self::X64 => f.write_str("X64"), + Self::Arm64 => f.write_str("Arm64"), + Self::Neutral => f.write_str("Neutral"), + } + } +} diff --git a/policies/rust/unigetui-broker-policy/src/lib.rs b/policies/rust/unigetui-broker-policy/src/lib.rs new file mode 100644 index 0000000..f3cd2bc --- /dev/null +++ b/policies/rust/unigetui-broker-policy/src/lib.rs @@ -0,0 +1,18 @@ +//! UniGetUI package broker policy model and schema helpers. +//! +//! This crate intentionally contains only admin-authored policy types. +//! Broker request, response, server, transport, and execution types live in +//! `unigetui-broker`. + +#![allow(clippy::std_instead_of_core, unused_qualifications)] + +pub mod enums; +pub mod markers; +pub mod newtypes; +pub mod policy; +pub mod schema; + +pub use enums::*; +pub use markers::*; +pub use newtypes::*; +pub use policy::*; diff --git a/policies/rust/unigetui-broker-policy/src/markers.rs b/policies/rust/unigetui-broker-policy/src/markers.rs new file mode 100644 index 0000000..3882b5f --- /dev/null +++ b/policies/rust/unigetui-broker-policy/src/markers.rs @@ -0,0 +1,66 @@ +//! Marker types -- zero-size structs that serialize to a fixed string constant. + +use schemars::JsonSchema; +use schemars::r#gen::SchemaGenerator; +use schemars::schema::{InstanceType, Schema, SchemaObject, SingleOrVec}; +use serde::{Deserialize, Serialize}; + +macro_rules! fixed_string_marker { + ( + $(#[$attr:meta])* + $vis:vis struct $name:ident => $value:expr; + ) => { + $(#[$attr])* + #[derive(Debug, Clone, Copy, PartialEq, Eq)] + $vis struct $name; + + impl Serialize for $name { + fn serialize(&self, serializer: S) -> Result { + serializer.serialize_str($value) + } + } + + impl<'de> Deserialize<'de> for $name { + fn deserialize>(deserializer: D) -> Result { + let value = String::deserialize(deserializer)?; + if value == $value { + Ok(Self) + } else { + Err(serde::de::Error::custom(format_args!( + "expected {:?}, got {:?}", + $value, value + ))) + } + } + } + + impl JsonSchema for $name { + fn schema_name() -> String { + stringify!($name).to_owned() + } + + fn json_schema(_gen: &mut SchemaGenerator) -> Schema { + SchemaObject { + instance_type: Some(SingleOrVec::Single(Box::new(InstanceType::String))), + enum_values: Some(vec![serde_json::Value::String($value.to_owned())]), + ..Default::default() + } + .into() + } + } + }; +} + +fixed_string_marker! { + /// Marker type for policy type: serializes to `"PackageBrokerPolicy"`. + pub struct PackageBrokerPolicy => "PackageBrokerPolicy"; +} + +/// Schema URI for package policy documents. +pub const POLICY_SCHEMA_URI: &str = "https://aka.ms/unigetui/package-policy.schema.1.0.json"; + +fixed_string_marker! { + /// Marker type for the policy `$schema` field. + /// Serializes to the canonical policy schema URI. + pub struct PolicySchemaUri => POLICY_SCHEMA_URI; +} diff --git a/policies/rust/unigetui-broker-policy/src/newtypes.rs b/policies/rust/unigetui-broker-policy/src/newtypes.rs new file mode 100644 index 0000000..06a0af6 --- /dev/null +++ b/policies/rust/unigetui-broker-policy/src/newtypes.rs @@ -0,0 +1,337 @@ +//! Schema-validated newtypes used by package broker policy documents. + +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +/// Error returned when a policy newtype fails deserialization validation. +#[derive(Debug, thiserror::Error)] +pub enum ModelValidationError { + #[error("{type_name}: {reason}")] + Invalid { type_name: &'static str, reason: String }, +} + +fn validate_bounded_string( + s: &str, + min: usize, + max: usize, + type_name: &'static str, +) -> Result<(), ModelValidationError> { + if s.len() < min { + return Err(ModelValidationError::Invalid { + type_name, + reason: format!("length {} is below minimum {min}", s.len()), + }); + } + + if s.len() > max { + return Err(ModelValidationError::Invalid { + type_name, + reason: format!("length {} exceeds maximum {max}", s.len()), + }); + } + + Ok(()) +} + +/// Semantic version string (SemVer 2.0.0). +/// +/// Validated at deserialization time using the `semver` crate. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, JsonSchema)] +pub struct SemanticVersion( + #[schemars( + length(max = 128), + regex( + pattern = r"^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*))*))?(?:\+([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$" + ) + )] + pub String, +); + +impl SemanticVersion { + pub fn parse(s: &str) -> Result { + if s.len() > 128 { + return Err(ModelValidationError::Invalid { + type_name: "SemanticVersion", + reason: format!("length {} exceeds maximum 128", s.len()), + }); + } + + semver::Version::parse(s).map_err(|e| ModelValidationError::Invalid { + type_name: "SemanticVersion", + reason: e.to_string(), + })?; + + Ok(Self(s.to_owned())) + } +} + +impl<'de> Deserialize<'de> for SemanticVersion { + fn deserialize>(deserializer: D) -> Result { + let s = String::deserialize(deserializer)?; + Self::parse(&s).map_err(serde::de::Error::custom) + } +} + +impl std::ops::Deref for SemanticVersion { + type Target = str; + + fn deref(&self) -> &str { + &self.0 + } +} + +impl std::fmt::Display for SemanticVersion { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.0) + } +} + +impl From for SemanticVersion { + fn from(s: String) -> Self { + Self(s) + } +} + +impl From<&str> for SemanticVersion { + fn from(s: &str) -> Self { + Self(s.to_owned()) + } +} + +/// Resource identifier (policy IDs, rule IDs, request IDs, audit IDs). +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, JsonSchema)] +pub struct ResourceId( + #[schemars(length(max = 128), regex(pattern = r"^[A-Za-z0-9][A-Za-z0-9._:\-]{0,127}$"))] pub String, +); + +impl ResourceId { + pub fn parse(s: &str) -> Result { + if s.len() > 128 { + return Err(ModelValidationError::Invalid { + type_name: "ResourceId", + reason: format!("length {} exceeds maximum 128", s.len()), + }); + } + + if !is_valid_resource_id(s) { + return Err(ModelValidationError::Invalid { + type_name: "ResourceId", + reason: + "must start with an alphanumeric character and contain only letters, digits, '.', '_', ':' or '-'" + .to_owned(), + }); + } + + Ok(Self(s.to_owned())) + } +} + +impl<'de> Deserialize<'de> for ResourceId { + fn deserialize>(deserializer: D) -> Result { + let s = String::deserialize(deserializer)?; + Self::parse(&s).map_err(serde::de::Error::custom) + } +} + +fn is_valid_resource_id(s: &str) -> bool { + if s.is_empty() { + return false; + } + + let bytes = s.as_bytes(); + if !bytes[0].is_ascii_alphanumeric() { + return false; + } + + bytes[1..] + .iter() + .all(|&b| b.is_ascii_alphanumeric() || b == b'.' || b == b'_' || b == b':' || b == b'-') +} + +impl std::ops::Deref for ResourceId { + type Target = str; + + fn deref(&self) -> &str { + &self.0 + } +} + +impl std::fmt::Display for ResourceId { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.0) + } +} + +impl From for ResourceId { + fn from(s: String) -> Self { + Self(s) + } +} + +impl From<&str> for ResourceId { + fn from(s: &str) -> Self { + Self(s.to_owned()) + } +} + +/// HTTP(S) URL string. +/// +/// Validated at deserialization time using the `url` crate. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, JsonSchema)] +pub struct HttpUrl( + #[schemars(length(max = 2048), regex(pattern = r"^([Hh][Tt][Tt][Pp][Ss]?)://.+$"))] + pub String, +); + +impl HttpUrl { + pub fn parse(s: &str) -> Result { + if s.len() > 2048 { + return Err(ModelValidationError::Invalid { + type_name: "HttpUrl", + reason: format!("length {} exceeds maximum 2048", s.len()), + }); + } + + let parsed = url::Url::parse(s).map_err(|e| ModelValidationError::Invalid { + type_name: "HttpUrl", + reason: e.to_string(), + })?; + + match parsed.scheme() { + "http" | "https" => Ok(Self(s.to_owned())), + other => Err(ModelValidationError::Invalid { + type_name: "HttpUrl", + reason: format!("scheme must be http or https, got {other}"), + }), + } + } +} + +impl<'de> Deserialize<'de> for HttpUrl { + fn deserialize>(deserializer: D) -> Result { + let s = String::deserialize(deserializer)?; + Self::parse(&s).map_err(serde::de::Error::custom) + } +} + +impl std::ops::Deref for HttpUrl { + type Target = str; + + fn deref(&self) -> &str { + &self.0 + } +} + +impl std::fmt::Display for HttpUrl { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.0) + } +} + +impl From for HttpUrl { + fn from(s: String) -> Self { + Self(s) + } +} + +/// Case-insensitive exact value or wildcard pattern. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, JsonSchema)] +pub struct StringPattern(#[schemars(length(min = 1, max = 256))] pub String); + +impl StringPattern { + pub fn parse(s: &str) -> Result { + validate_bounded_string(s, 1, 256, "StringPattern")?; + Ok(Self(s.to_owned())) + } +} + +impl<'de> Deserialize<'de> for StringPattern { + fn deserialize>(deserializer: D) -> Result { + let s = String::deserialize(deserializer)?; + Self::parse(&s).map_err(serde::de::Error::custom) + } +} + +impl std::ops::Deref for StringPattern { + type Target = str; + + fn deref(&self) -> &str { + &self.0 + } +} + +impl AsRef for StringPattern { + fn as_ref(&self) -> &str { + &self.0 + } +} + +impl std::fmt::Display for StringPattern { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.0) + } +} + +/// A short constrained string for version values. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, JsonSchema)] +pub struct VersionString(#[schemars(length(min = 1, max = 128))] pub String); + +impl VersionString { + pub fn parse(s: &str) -> Result { + validate_bounded_string(s, 1, 128, "VersionString")?; + Ok(Self(s.to_owned())) + } +} + +impl<'de> Deserialize<'de> for VersionString { + fn deserialize>(deserializer: D) -> Result { + let s = String::deserialize(deserializer)?; + Self::parse(&s).map_err(serde::de::Error::custom) + } +} + +impl std::ops::Deref for VersionString { + type Target = str; + + fn deref(&self) -> &str { + &self.0 + } +} + +impl AsRef for VersionString { + fn as_ref(&self) -> &str { + &self.0 + } +} + +/// A custom parameter string. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, JsonSchema)] +pub struct CustomParameterString(#[schemars(length(min = 1, max = 512))] pub String); + +impl CustomParameterString { + pub fn parse(s: &str) -> Result { + validate_bounded_string(s, 1, 512, "CustomParameterString")?; + Ok(Self(s.to_owned())) + } +} + +impl<'de> Deserialize<'de> for CustomParameterString { + fn deserialize>(deserializer: D) -> Result { + let s = String::deserialize(deserializer)?; + Self::parse(&s).map_err(serde::de::Error::custom) + } +} + +impl std::ops::Deref for CustomParameterString { + type Target = str; + + fn deref(&self) -> &str { + &self.0 + } +} + +impl AsRef for CustomParameterString { + fn as_ref(&self) -> &str { + &self.0 + } +} diff --git a/policies/rust/unigetui-broker-policy/src/policy.rs b/policies/rust/unigetui-broker-policy/src/policy.rs new file mode 100644 index 0000000..bf0b3e5 --- /dev/null +++ b/policies/rust/unigetui-broker-policy/src/policy.rs @@ -0,0 +1,414 @@ +//! Policy document models. + +use std::collections::BTreeSet; + +use chrono::{DateTime, Utc}; +use schemars::JsonSchema; +use schemars::r#gen::SchemaGenerator; +use schemars::schema::{ObjectValidation, Schema, SchemaObject, SubschemaValidation}; +use serde::{Deserialize, Serialize}; + +use crate::{ + Architecture, CustomParameterString, Decision, Elevation, HttpUrl, ManagerName, Operation, PackageBrokerPolicy, + PolicySchemaUri, ResourceId, Scope, SemanticVersion, StringPattern, VersionString, +}; + +/// A policy document governing which package operations are allowed or denied. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[schemars(rename = "PolicyDocument")] +#[serde(rename_all = "PascalCase")] +#[serde(deny_unknown_fields)] +pub struct PolicyDocument { + /// Policy schema URI constant. + #[serde(rename = "$schema")] + pub _schema: PolicySchemaUri, + + /// Policy syntax version (semver). + pub policy_version: SemanticVersion, + + /// Must be `"PackageBrokerPolicy"`. + pub policy_type: PackageBrokerPolicy, + + /// Policy metadata. + pub metadata: PolicyMetadata, + + /// Enforcement configuration. + pub enforcement: PolicyEnforcement, + + /// Ordered list of policy rules (may be empty; enforcement defaults apply). + #[schemars(length(max = 1024))] + pub rules: Vec, +} + +/// Policy metadata. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[schemars(rename = "PolicyMetadata")] +#[serde(rename_all = "PascalCase")] +#[serde(deny_unknown_fields)] +pub struct PolicyMetadata { + /// Unique policy identifier. + pub id: ResourceId, + + /// Organization that published the policy. + #[schemars(length(min = 1, max = 128))] + pub publisher: String, + + /// Monotonically increasing revision number. + #[schemars(range(min = 1, max = 2147483647))] + pub revision: u32, + + /// ISO 8601 publication timestamp (RFC 3339). + pub published_at: DateTime, + + /// Policy becomes active at this time. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub valid_from: Option>, + + /// Policy expires at this time. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub valid_until: Option>, + + /// Human-readable description. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[schemars(length(max = 512))] + pub description: Option, + + /// URL for support or documentation. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub support_url: Option, +} + +/// Enforcement configuration. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[schemars(rename = "PolicyEnforcement")] +#[serde(rename_all = "PascalCase")] +#[serde(deny_unknown_fields)] +pub struct PolicyEnforcement { + /// Decision when no rule matches. + pub default_decision: Decision, + + /// Rule precedence strategy (must be "PriorityThenDeny"). + pub rule_precedence: RulePrecedence, + + /// When true, broker logs decisions but does not enforce. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub audit_mode: Option, +} + +/// Rule precedence strategy — always PriorityThenDeny. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[schemars(rename = "RulePrecedence")] +pub enum RulePrecedence { + PriorityThenDeny, +} + +/// A single policy rule. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[schemars(rename = "PolicyRule")] +#[serde(rename_all = "PascalCase")] +#[serde(deny_unknown_fields)] +pub struct PolicyRule { + /// Unique rule identifier. + pub id: ResourceId, + + /// Whether the rule is active. + #[serde(default = "default_true")] + pub enabled: bool, + + /// Priority (lower = higher precedence). + #[schemars(range(min = 0, max = 2147483647))] + pub priority: u32, + + /// Decision if this rule matches. + pub decision: Decision, + + /// Reason reported to the client. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[schemars(length(max = 512))] + pub reason: Option, + + /// Match criteria — request must satisfy all specified fields. + /// At least one criterion must be present. + #[serde(rename = "Match", deserialize_with = "deserialize_non_empty_match")] + #[schemars(with = "NonEmptyPolicyMatchSchema")] + pub match_criteria: PolicyMatch, + + /// Additional constraints applied after matching. + /// When absent, no constraints are enforced beyond the match criteria. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub constraints: Option, +} + +fn default_true() -> bool { + true +} + +fn deserialize_non_empty_match<'de, D: serde::Deserializer<'de>>(deserializer: D) -> Result { + let m = PolicyMatch::deserialize(deserializer)?; + if m.is_empty() { + return Err(serde::de::Error::custom("match must contain at least one criterion")); + } + Ok(m) +} + +struct NonEmptyPolicyMatchSchema; + +impl JsonSchema for NonEmptyPolicyMatchSchema { + fn is_referenceable() -> bool { + false + } + + fn schema_name() -> String { + "NonEmptyPolicyMatch".to_owned() + } + + fn json_schema(generator: &mut SchemaGenerator) -> Schema { + Schema::Object(SchemaObject { + object: Some(Box::new(ObjectValidation { + min_properties: Some(1), + ..Default::default() + })), + subschemas: Some(Box::new(SubschemaValidation { + all_of: Some(vec![generator.subschema_for::()]), + ..Default::default() + })), + ..Default::default() + }) + } +} + +/// Match criteria for a policy rule. All specified fields must match. +/// At least one field must be present. +#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)] +#[schemars(rename = "PolicyMatch")] +#[serde(rename_all = "PascalCase")] +#[serde(deny_unknown_fields)] +pub struct PolicyMatch { + /// Allowed operations. + #[serde(default, skip_serializing_if = "BTreeSet::is_empty")] + #[schemars(length(max = 3))] + pub operations: BTreeSet, + + /// Allowed managers. + #[serde(default, skip_serializing_if = "BTreeSet::is_empty")] + #[schemars(length(max = 16))] + pub managers: BTreeSet, + + /// Source patterns (wildcard). + #[serde(default, skip_serializing_if = "BTreeSet::is_empty")] + #[schemars(length(max = 128))] + pub sources: BTreeSet, + + /// Package identifier patterns (wildcard). + #[serde(default, skip_serializing_if = "BTreeSet::is_empty")] + #[schemars(length(max = 1024))] + pub package_identifiers: BTreeSet, + + /// Package name patterns (wildcard). + #[serde(default, skip_serializing_if = "BTreeSet::is_empty")] + #[schemars(length(max = 1024))] + pub package_names: BTreeSet, + + /// Exact version list. + #[serde(default, skip_serializing_if = "BTreeSet::is_empty")] + #[schemars(length(max = 256))] + pub versions: BTreeSet, + + /// Semantic version range. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub version_range: Option, + + /// Allowed scopes. + #[serde(default, skip_serializing_if = "BTreeSet::is_empty")] + #[schemars(length(max = 2))] + pub scopes: BTreeSet, + + /// Allowed architectures. + #[serde(default, skip_serializing_if = "BTreeSet::is_empty")] + #[schemars(length(max = 5))] + pub architectures: BTreeSet, + + /// Allowed elevation levels. + #[serde(default, skip_serializing_if = "BTreeSet::is_empty")] + #[schemars(length(max = 2))] + pub elevation: BTreeSet, + + /// Allowed interactive values. + #[serde(default, skip_serializing_if = "BTreeSet::is_empty")] + #[schemars(length(max = 2))] + pub interactive: BTreeSet, + + /// Allowed skipHashCheck values. + #[serde(default, skip_serializing_if = "BTreeSet::is_empty")] + #[schemars(length(max = 2))] + pub skip_hash_check: BTreeSet, + + /// Allowed preRelease values. + #[serde(default, skip_serializing_if = "BTreeSet::is_empty")] + #[schemars(length(max = 2))] + pub pre_release: BTreeSet, + + /// Whether request has custom parameters. + #[serde(default, skip_serializing_if = "BTreeSet::is_empty")] + #[schemars(length(max = 2))] + pub has_custom_parameters: BTreeSet, + + /// Whether request has custom install location. + #[serde(default, skip_serializing_if = "BTreeSet::is_empty")] + #[schemars(length(max = 2))] + pub has_custom_install_location: BTreeSet, + + /// Whether request has pre/post operation commands. + #[serde(default, skip_serializing_if = "BTreeSet::is_empty")] + #[schemars(length(max = 2))] + pub has_pre_post_commands: BTreeSet, + + /// Whether request has kill-before-operation entries. + #[serde(default, skip_serializing_if = "BTreeSet::is_empty")] + #[schemars(length(max = 2))] + pub has_kill_before_operation: BTreeSet, + + /// Whether request has uninstall-previous flag set. + #[serde(default, skip_serializing_if = "BTreeSet::is_empty")] + pub has_uninstall_previous: BTreeSet, +} + +impl PolicyMatch { + /// Returns true if no criteria are specified. + pub fn is_empty(&self) -> bool { + self.operations.is_empty() + && self.managers.is_empty() + && self.sources.is_empty() + && self.package_identifiers.is_empty() + && self.package_names.is_empty() + && self.versions.is_empty() + && self.version_range.is_none() + && self.scopes.is_empty() + && self.architectures.is_empty() + && self.elevation.is_empty() + && self.interactive.is_empty() + && self.skip_hash_check.is_empty() + && self.pre_release.is_empty() + && self.has_custom_parameters.is_empty() + && self.has_custom_install_location.is_empty() + && self.has_pre_post_commands.is_empty() + && self.has_kill_before_operation.is_empty() + && self.has_uninstall_previous.is_empty() + } +} + +/// Semantic version range for matching. +#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)] +#[schemars(rename = "VersionRange")] +#[serde(rename_all = "PascalCase")] +#[serde(deny_unknown_fields)] +pub struct VersionRange { + /// Minimum version (inclusive). + #[serde(default, skip_serializing_if = "Option::is_none")] + #[schemars(length(min = 1, max = 128))] + pub min_version: Option, + + /// Maximum version (inclusive). + #[serde(default, skip_serializing_if = "Option::is_none")] + #[schemars(length(min = 1, max = 128))] + pub max_version: Option, + + /// Whether to include pre-release versions. + #[serde(default)] + pub include_prerelease: bool, +} + +/// Constraints applied after a rule matches. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[schemars(rename = "PolicyConstraints")] +#[serde(rename_all = "PascalCase")] +#[serde(deny_unknown_fields)] +pub struct PolicyConstraints { + /// Allow interactive mode. + #[serde(default = "default_true", skip_serializing_if = "is_true")] + pub allow_interactive: bool, + + /// Allow skipping hash verification. + #[serde(default = "default_true", skip_serializing_if = "is_true")] + pub allow_skip_hash_check: bool, + + /// Allow pre-release versions. + #[serde(default = "default_true", skip_serializing_if = "is_true")] + pub allow_pre_release: bool, + + /// Allow custom install location. + #[serde(default = "default_true", skip_serializing_if = "is_true")] + pub allow_custom_install_location: bool, + + /// Glob patterns for allowed install locations. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + #[schemars(length(max = 64))] + pub allowed_install_location_patterns: Vec, + + /// Allow custom parameters. + #[serde(default = "default_true", skip_serializing_if = "is_true")] + pub allow_custom_parameters: bool, + + /// Exact allowed custom parameters. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + #[schemars(length(max = 128))] + pub allowed_custom_parameters: Vec, + + /// Glob patterns for allowed custom parameters. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + #[schemars(length(max = 128))] + pub allowed_custom_parameter_patterns: Vec, + + /// Denied custom parameters (deny takes precedence over allow). + #[serde(default, skip_serializing_if = "Vec::is_empty")] + #[schemars(length(max = 128))] + pub denied_custom_parameters: Vec, + + /// Allow pre/post operation commands. + #[serde(default = "default_true", skip_serializing_if = "is_true")] + pub allow_pre_post_commands: bool, + + /// Allow killing processes before operation. + #[serde(default = "default_true", skip_serializing_if = "is_true")] + pub allow_kill_before_operation: bool, + + /// Allow uninstalling previous version before installing update. + #[serde(default = "default_true", skip_serializing_if = "is_true")] + pub allow_uninstall_previous: bool, + + /// Allow skipping upgrade on install operations if an existing version + /// is detected (for install operations). + #[serde(default = "default_true", skip_serializing_if = "is_true")] + pub allow_upgrade: bool, +} + +impl Default for PolicyConstraints { + fn default() -> Self { + Self { + allow_interactive: true, + allow_skip_hash_check: true, + allow_pre_release: true, + allow_custom_install_location: true, + allowed_install_location_patterns: Vec::new(), + allow_custom_parameters: true, + allowed_custom_parameters: Vec::new(), + allowed_custom_parameter_patterns: Vec::new(), + denied_custom_parameters: Vec::new(), + allow_pre_post_commands: true, + allow_kill_before_operation: true, + allow_uninstall_previous: true, + allow_upgrade: true, + } + } +} + +impl PolicyConstraints { + /// Returns true if all fields are at their defaults (fully permissive). + pub fn is_default(&self) -> bool { + *self == Self::default() + } +} + +fn is_true(v: &bool) -> bool { + *v +} diff --git a/policies/rust/unigetui-broker-policy/src/schema.rs b/policies/rust/unigetui-broker-policy/src/schema.rs new file mode 100644 index 0000000..d2e26d5 --- /dev/null +++ b/policies/rust/unigetui-broker-policy/src/schema.rs @@ -0,0 +1,26 @@ +//! Schema generation and parsing helpers for policy documents. + +use schemars::schema_for; + +use crate::PolicyDocument; + +/// Get the generated policy schema as a JSON value. +pub fn policy_schema_json() -> serde_json::Value { + let schema = schema_for!(PolicyDocument); + serde_json::to_value(&schema).expect("BUG: schema serialization failed") +} + +/// Validate a policy document by deserializing from a JSON value. +pub fn parse_policy(value: serde_json::Value) -> Result { + serde_json::from_value(value).map_err(|e| e.to_string()) +} + +/// Validate a policy document by deserializing from JSON text. +pub fn parse_policy_json(text: &str) -> Result { + serde_json::from_str(text).map_err(|e| e.to_string()) +} + +/// Validate a policy document by deserializing from YAML text. +pub fn parse_policy_yaml(text: &str) -> Result { + serde_yaml::from_str(text).map_err(|e| e.to_string()) +} diff --git a/policies/rust/unigetui-broker-policy/tests/policy_samples.rs b/policies/rust/unigetui-broker-policy/tests/policy_samples.rs new file mode 100644 index 0000000..815df2e --- /dev/null +++ b/policies/rust/unigetui-broker-policy/tests/policy_samples.rs @@ -0,0 +1,94 @@ +//! Policy model and sample validation tests. + +#![allow(clippy::std_instead_of_core, clippy::unwrap_used, unused_crate_dependencies)] + +use std::path::{Path, PathBuf}; + +use unigetui_broker_policy::PolicyDocument; + +fn samples_dir() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("assets/samples") +} + +fn load_policy(path: &Path) -> PolicyDocument { + let content = std::fs::read_to_string(path).unwrap_or_else(|e| panic!("failed to read {}: {e}", path.display())); + let ext = path.extension().and_then(|e| e.to_str()).unwrap_or(""); + match ext { + "yaml" | "yml" => serde_yaml::from_str(&content) + .unwrap_or_else(|e| panic!("failed to deserialize YAML policy {}: {e}", path.display())), + _ => serde_json::from_str(&content) + .unwrap_or_else(|e| panic!("failed to deserialize policy {}: {e}", path.display())), + } +} + +#[test] +fn all_sample_policies_deserialize() { + let dir = samples_dir(); + + let policy_files = [ + "corporate-allowlist.policy.json", + "corporate-allowlist.policy.yaml", + "deny-risky-options.policy.json", + "powershell-advanced.policy.json", + "powershell-current-user.policy.json", + "scenario-coverage.policy.json", + ]; + + for file in &policy_files { + let path = dir.join(file); + let _policy = load_policy(&path); + } +} + +#[test] +fn invalid_policy_unknown_field_fails_deserialization() { + let value = serde_json::json!({ + "$schema": "https://aka.ms/unigetui/package-policy.schema.1.0.json", + "PolicyVersion": "1.0.0", + "PolicyType": "PackageBrokerPolicy", + "Metadata": { + "Id": "test", + "Publisher": "Test", + "Revision": 1, + "PublishedAt": "2026-01-01T00:00:00Z" + }, + "Enforcement": { + "DefaultDecision": "Deny", + "RulePrecedence": "PriorityThenDeny", + "UnknownField": true + }, + "Rules": [] + }); + + let result: Result = serde_json::from_value(value); + assert!(result.is_err(), "policy with unknown field should fail deserialization"); +} + +#[test] +fn invalid_policy_fixture_fails_deserialization() { + let path = samples_dir().join("invalid/policies/invalid-failure-decision.policy.json"); + let content = std::fs::read_to_string(&path).unwrap(); + let result: Result = serde_json::from_str(&content); + assert!(result.is_err(), "invalid policy fixture should fail deserialization"); +} + +#[test] +fn policy_schema_generates_valid_json() { + let schema = unigetui_broker_policy::schema::policy_schema_json(); + assert!(schema.is_object()); + let obj = schema.as_object().unwrap(); + assert!( + obj.contains_key("definitions") || obj.contains_key("$defs"), + "schema should have type definitions" + ); +} + +#[test] +fn policy_match_schema_requires_at_least_one_property() { + let schema = unigetui_broker_policy::schema::policy_schema_json(); + let min_properties = schema + .pointer("/definitions/PolicyRule/properties/Match/minProperties") + .and_then(serde_json::Value::as_u64); + + assert_eq!(min_properties, Some(1)); +} diff --git a/policies/rust/unigetui-broker-policy/tools/generate_schema.rs b/policies/rust/unigetui-broker-policy/tools/generate_schema.rs new file mode 100644 index 0000000..3ddb19d --- /dev/null +++ b/policies/rust/unigetui-broker-policy/tools/generate_schema.rs @@ -0,0 +1,36 @@ +//! Generates the JSON schema for the policy document. +//! +//! Usage: `cargo run -p unigetui-broker-policy --bin generate-unigetui-broker-policy-schema` + +#![allow(clippy::print_stdout, reason = "this is a developer-facing CLI tool")] +#![allow(clippy::std_instead_of_core, unused_crate_dependencies)] + +use std::path::Path; + +use serde_json::{Map, Value}; +use unigetui_broker_policy::POLICY_SCHEMA_URI; +use unigetui_broker_policy::schema::policy_schema_json; + +fn main() { + let crate_dir = Path::new(env!("CARGO_MANIFEST_DIR")); + let out_path = crate_dir.join("schema").join("unigetui.package-policy.schema.json"); + + let schema = with_id(policy_schema_json(), POLICY_SCHEMA_URI); + let json = serde_json::to_string_pretty(&schema).expect("BUG: schema serialization failed"); + + std::fs::write(&out_path, &json).unwrap_or_else(|e| panic!("failed to write {}: {e}", out_path.display())); + + println!("Wrote {}", out_path.display()); +} + +fn with_id(schema: Value, id: &str) -> Value { + let Value::Object(existing) = schema else { + panic!("BUG: schema root is not an object"); + }; + + let mut object = Map::new(); + object.insert("$id".to_owned(), Value::String(id.to_owned())); + object.extend(existing); + + Value::Object(object) +} diff --git a/xtask/src/dotnet.rs b/xtask/src/dotnet.rs index 0415b32..6cdba48 100644 --- a/xtask/src/dotnet.rs +++ b/xtask/src/dotnet.rs @@ -3,17 +3,38 @@ use std::env; use std::path::{Path, PathBuf}; const TARGET_FRAMEWORKS: &[&str] = &["net9.0", "net10.0"]; -const SOLUTION_PATH: &str = "protocols/NowProto.sln"; + +struct DotnetSolution { + path: &'static str, + artifact_project: &'static str, + set_platform: bool, +} + +const SOLUTIONS: &[DotnetSolution] = &[ + DotnetSolution { + path: "protocols/NowProto.sln", + artifact_project: "protocols/dotnet/Devolutions.NowProto", + set_platform: true, + }, + DotnetSolution { + path: "policies/dotnet/Devolutions.UniGetUI.Broker.Policy.slnx", + artifact_project: "policies/dotnet/Devolutions.UniGetUI.Broker.Policy", + set_platform: false, + }, +]; pub fn fmt(sh: &Shell) -> anyhow::Result<()> { let _s = Section::new("DOTNET-FORMATTING"); - let output = cmd!(sh, "dotnet format {SOLUTION_PATH} --verify-no-changes") - .ignore_status() - .output()?; + for solution in SOLUTIONS { + let solution_path = solution.path; + let output = cmd!(sh, "dotnet format {solution_path} --verify-no-changes") + .ignore_status() + .output()?; - if !output.status.success() { - anyhow::bail!("Bad formatting, please run 'dotnet format'"); + if !output.status.success() { + anyhow::bail!("Bad formatting, please run 'dotnet format {solution_path}'"); + } } println!("All good!"); @@ -29,24 +50,28 @@ pub fn get_target_arch() -> anyhow::Result<&'static str> { } } -pub fn get_dotnet_output_path(target_framework: &str) -> anyhow::Result { - let arch_folder: &str = get_target_arch()?; +fn get_dotnet_output_path(solution: &DotnetSolution, target_framework: &str) -> anyhow::Result { let build_config = "Debug"; - let output_path = Path::new("protocols") - .join("dotnet") - .join("Devolutions.NowProto") - .join("bin") - .join(arch_folder) - .join(build_config) - .join(target_framework); + let mut output_path = Path::new(solution.artifact_project).join("bin"); + + if solution.set_platform { + output_path = output_path.join(get_target_arch()?); + } + + output_path = output_path.join(build_config).join(target_framework); Ok(output_path) } -pub fn list_files_for_target_framework(sh: &Shell, target_framework: &str) -> anyhow::Result<()> { - let build_path = get_dotnet_output_path(target_framework)?; - if !build_path.exists() { +fn list_files_for_target_framework( + sh: &Shell, + solution: &DotnetSolution, + target_framework: &str, +) -> anyhow::Result<()> { + let build_path = get_dotnet_output_path(solution, target_framework)?; + let absolute_build_path = sh.current_dir().join(&build_path); + if !absolute_build_path.exists() { anyhow::bail!("Expected build output directory does not exist: {:?}", build_path); } @@ -60,12 +85,19 @@ pub fn list_files_for_target_framework(sh: &Shell, target_framework: &str) -> an pub fn build(sh: &Shell) -> anyhow::Result<()> { let _s = Section::new("DOTNET-BUILD"); - let platform = get_target_arch()?; - cmd!(sh, "dotnet build {SOLUTION_PATH} -p:Platform={platform}").run()?; + for solution in SOLUTIONS { + let solution_path = solution.path; + if solution.set_platform { + let platform = get_target_arch()?; + cmd!(sh, "dotnet build {solution_path} -p:Platform={platform}").run()?; + } else { + cmd!(sh, "dotnet build {solution_path}").run()?; + } - if is_verbose() { - for &target_framework in TARGET_FRAMEWORKS { - list_files_for_target_framework(sh, target_framework)?; + if is_verbose() { + for &target_framework in TARGET_FRAMEWORKS { + list_files_for_target_framework(sh, solution, target_framework)?; + } } } @@ -77,9 +109,15 @@ pub fn build(sh: &Shell) -> anyhow::Result<()> { pub fn tests_run(sh: &Shell) -> anyhow::Result<()> { let _s = Section::new("DOTNET-TESTS-RUN"); - let platform = get_target_arch()?; - - cmd!(sh, "dotnet test {SOLUTION_PATH} -p:Platform={platform}").run()?; + for solution in SOLUTIONS { + let solution_path = solution.path; + if solution.set_platform { + let platform = get_target_arch()?; + cmd!(sh, "dotnet test {solution_path} -p:Platform={platform}").run()?; + } else { + cmd!(sh, "dotnet test {solution_path}").run()?; + } + } println!("All good!");