From f85ee8dba9a19d958f88ef36455618464258abb2 Mon Sep 17 00:00:00 2001 From: Artem Goncharov Date: Wed, 12 Aug 2026 09:03:29 +0200 Subject: [PATCH] feat(wasm): Complete WASM auth plugins support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds osc plugin search/install/update against an HTTPS-hosted registry index (bootstrapped at plugins/registry/index.json in this repo), with sha256 verification before any bytes touch disk. Before a registry-installed plugin is trusted, verify it was actually published by CI in its claimed GitHub repo: fetch the GitHub artifact attestation, verify the DSSE envelope signature against the Fulcio leaf cert, chain-verify against vendored pinned Fulcio root/intermediate CAs, and check the cert's OIDC-issuer/repository identity extensions match the plugin's declared source_repo. Rekor transparency-log inclusion is recorded for display but not cryptographically verified (documented, deliberate scope limit - see provenance.rs module docs). An unverified or tampered plugin fails closed (Untrusted) unless the caller passes --allow-unsigned, which is loudly logged and surfaced by `osc plugin list`. Local --file installs now go through the same explicit escape hatch, since a local file has no attestation to check. `osc plugin update` re-verifies provenance on every run rather than trusting a stored record from first install. Move the browser-based SSO callback server (bind/listen, HTML response, browser opening) out of sdk/auth-websso into a new sdk/websso-host crate, so both the native WebSSO plugin and the upcoming WASM SSO ABI (Phase 5) can share one implementation of this security-sensitive logic instead of duplicating it. The shared CallbackServer now embeds a host-generated, per-flow random state token in the callback URL's query string and rejects any callback whose state doesn't match via a constant-time comparison, closing a CSRF gap the previous implementation didn't have. The browser-open path is gated by a BrowserOpenPolicy so callers can choose whether to allow plain http:// (kept for the native plugin's existing local/dev Keystone use case) or require https://. sdk/auth-websso is trimmed down to a thin caller of the new crate; its local hyper server, HTML template and tests move to sdk/websso-host along with their coverage. Extends osc plugin's WASM auth ABI with a second, mutually-exclusive flavor for browser-based SSO logins: `sso_build_request` (pure, guest computes an https:// URL to open and declares its intended redirect host) and `sso_parse_callback` (pure, guest turns an already CSRF-validated callback into a token). Neither export gets a socket or browser-opening capability — both are structurally guest-sandboxed (`Manifest::disallow_all_hosts`) — so all I/O (the local callback listener, anti-CSRF state check, and browser launch) stays in the host, reusing the `openstack-sdk-websso-host` service extracted earlier. `WasmAuthPlugin::load` detects which flavor a module implements via `Plugin::function_exists` and rejects modules exporting both or neither. Before ever prompting the user or opening a browser, `auth_via_sso` hard-rejects a non-https `url` and any `redirect_host` that doesn't exactly match the host-bound callback listener's own authority — both checks are unconditional, with no `--allow-unsigned`- style override, since a mismatch here means the plugin is trying to redirect the callback somewhere the host didn't intend. Adds an example SSO WASM plugin fixture (fixtures/example-sso-plugin, checked in as tests/fixtures/example_sso.wasm) and an integration test suite exercising ABI-flavor detection, the two hard-fail security checks (proven to trigger before the interactive confirmation step), the guest ABI's request/callback round trip, and the shared callback server's anti-CSRF rejection. Adds three libFuzzer targets covering the untrusted-input surfaces where a WASM auth plugin's own bytes cross into host code, extending the existing fuzz/ crate rather than starting a new one: - fuzz_wasm_plugin_identity_http_request: the one host function every plugin can call, fuzzing its request-parsing/validation step (JSON decode, relative-path check, URL join, method parse) ahead of any network I/O. - fuzz_wasm_plugin_sso_build_response: the SSO ABI flavor's security-relevant validation (URL must parse and be https, redirect_host must match the host-bound callback listener) that runs before a browser is ever opened. - fuzz_wasm_plugin_auth_result: the AuthResultMsg deserialization every guest response (`auth`, `sso_parse_callback`) is parsed through. Each target exercises the crate's real (previously inline, now extracted into standalone functions: `host::resolve_request` and `plugin::validate_sso_build_response`) parsing/validation logic directly, via new `fuzzing`-feature-gated entry points, rather than reimplementing it -- matching this workspace's existing fuzz-target conventions (openstack-sdk-auth-core, openstack_sdk_core). Wired into the `fuzz` CI job alongside the existing targets. Documents the osc plugin feature end to end: the operator-facing guide (sandbox model, installing, trust model), the plugin author's guest ABI reference (both auth and sso flavors, host-mediated HTTP capability, build/test/publish flow), and how entries land in and are trusted out of the plugins/registry index. Wires all three pages into the mdBook table of contents. cargo deny check already passes cleanly against the extism/wasmtime and sigstore-adjacent (x509-parser/ring/rcgen) dependency tree added in earlier phases, so no deny.toml changes were needed for Phase 6. Assisted-By: Claude Sonnet 5 Signed-off-by: Artem Goncharov --- .github/workflows/ci.yml | 2 +- Cargo.lock | 81 ++- Cargo.toml | 6 + cli/plugin/Cargo.toml | 1 + cli/plugin/src/confirm.rs | 89 +++ cli/plugin/src/install.rs | 145 +++- cli/plugin/src/lib.rs | 8 + cli/plugin/src/list.rs | 24 +- cli/plugin/src/remove.rs | 1 + cli/plugin/src/search.rs | 91 +++ cli/plugin/src/update.rs | 180 +++++ doc/src/SUMMARY.md | 3 + doc/src/plugins.md | 121 ++++ doc/src/plugins/author-guide.md | 173 +++++ doc/src/plugins/registry-governance.md | 105 +++ fuzz/Cargo.toml | 27 + .../fuzz_wasm_plugin_auth_result.rs | 20 + .../fuzz_wasm_plugin_identity_http_request.rs | 59 ++ .../fuzz_wasm_plugin_sso_build_response.rs | 41 ++ plugins/registry/README.md | 64 ++ plugins/registry/index.json | 4 + sdk/auth-websso/Cargo.toml | 21 +- sdk/auth-websso/src/lib.rs | 339 +-------- sdk/plugin-wasm/Cargo.toml | 13 +- .../fixtures/example-sso-plugin/Cargo.lock | 663 +++++++++++++++++ .../fixtures/example-sso-plugin/Cargo.toml | 16 + .../fixtures/example-sso-plugin/src/lib.rs | 115 +++ sdk/plugin-wasm/src/error.rs | 127 ++++ sdk/plugin-wasm/src/host.rs | 45 +- sdk/plugin-wasm/src/index.rs | 405 +++++++++++ sdk/plugin-wasm/src/lib.rs | 9 + sdk/plugin-wasm/src/lockfile.rs | 38 + sdk/plugin-wasm/src/plugin.rs | 329 ++++++++- sdk/plugin-wasm/src/provenance.rs | 680 ++++++++++++++++++ sdk/plugin-wasm/src/registry.rs | 347 ++++++++- .../tests/fixtures/example_sso.wasm | Bin 0 -> 424854 bytes sdk/plugin-wasm/tests/registry_remote.rs | 137 ++++ sdk/plugin-wasm/tests/wasm_sso_plugin.rs | 179 +++++ sdk/plugin-wasm/trust/fulcio_intermediate.pem | 19 + sdk/plugin-wasm/trust/fulcio_root.pem | 18 + sdk/websso-host/Cargo.toml | 33 + sdk/websso-host/src/lib.rs | 429 +++++++++++ .../static/callback.html | 0 typos.toml | 2 +- 44 files changed, 4815 insertions(+), 394 deletions(-) create mode 100644 cli/plugin/src/confirm.rs create mode 100644 cli/plugin/src/search.rs create mode 100644 cli/plugin/src/update.rs create mode 100644 doc/src/plugins.md create mode 100644 doc/src/plugins/author-guide.md create mode 100644 doc/src/plugins/registry-governance.md create mode 100644 fuzz/fuzz_targets/fuzz_wasm_plugin_auth_result.rs create mode 100644 fuzz/fuzz_targets/fuzz_wasm_plugin_identity_http_request.rs create mode 100644 fuzz/fuzz_targets/fuzz_wasm_plugin_sso_build_response.rs create mode 100644 plugins/registry/README.md create mode 100644 plugins/registry/index.json create mode 100644 sdk/plugin-wasm/fixtures/example-sso-plugin/Cargo.lock create mode 100644 sdk/plugin-wasm/fixtures/example-sso-plugin/Cargo.toml create mode 100644 sdk/plugin-wasm/fixtures/example-sso-plugin/src/lib.rs create mode 100644 sdk/plugin-wasm/src/index.rs create mode 100644 sdk/plugin-wasm/src/provenance.rs create mode 100755 sdk/plugin-wasm/tests/fixtures/example_sso.wasm create mode 100644 sdk/plugin-wasm/tests/registry_remote.rs create mode 100644 sdk/plugin-wasm/tests/wasm_sso_plugin.rs create mode 100644 sdk/plugin-wasm/trust/fulcio_intermediate.pem create mode 100644 sdk/plugin-wasm/trust/fulcio_root.pem create mode 100644 sdk/websso-host/Cargo.toml create mode 100644 sdk/websso-host/src/lib.rs rename sdk/{auth-websso => websso-host}/static/callback.html (100%) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e34cfe375..5fd00a663 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -104,7 +104,7 @@ jobs: - name: Run tests run: | - for target in fuzz_openstack_sdk_config fuzz_openstack_sdk_config_yaml fuzz_link_header fuzz_next_page_from_body fuzz_expand_link fuzz_discovery_endpoints fuzz_api_version fuzz_api_error_from_openstack fuzz_auth_error_response fuzz_build_request_url fuzz_state_cache; do + for target in fuzz_openstack_sdk_config fuzz_openstack_sdk_config_yaml fuzz_link_header fuzz_next_page_from_body fuzz_expand_link fuzz_discovery_endpoints fuzz_api_version fuzz_api_error_from_openstack fuzz_auth_error_response fuzz_build_request_url fuzz_state_cache fuzz_wasm_plugin_identity_http_request fuzz_wasm_plugin_sso_build_response fuzz_wasm_plugin_auth_result; do cargo +nightly fuzz run "$target" --features=fuzzing -- -max_total_time=60 done diff --git a/Cargo.lock b/Cargo.lock index 1f17b64c5..34cc01ee6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3697,7 +3697,7 @@ dependencies = [ "openstack_sdk", "openstack_sdk_core", "openstack_types", - "pem", + "pem 4.0.0", "rsa", "serde_json", "ssh-key", @@ -3872,6 +3872,7 @@ name = "openstack-cli-plugin" version = "0.13.7" dependencies = [ "clap", + "dialoguer", "eyre", "openstack-cli-core", "openstack-sdk-plugin-wasm", @@ -4143,31 +4144,15 @@ name = "openstack-sdk-auth-websso" version = "0.22.6" dependencies = [ "async-trait", - "bytes", "dialoguer", - "form_urlencoded", - "futures", - "futures-util", - "http", - "http-body-util", - "httpmock", - "hyper", - "hyper-util", "inventory", - "open", "openstack-sdk-auth-core", + "openstack-sdk-websso-host", "reqwest", - "reserve-port", "secrecy", "serde", "serde_json", - "serde_urlencoded", - "tempfile", "thiserror 2.0.19", - "tokio", - "tokio-util", - "tracing", - "tracing-test", "url", ] @@ -4340,13 +4325,19 @@ name = "openstack-sdk-plugin-wasm" version = "0.1.0" dependencies = [ "async-trait", + "base64 0.23.0", "chrono", + "dialoguer", "dirs", "extism", "httpmock", "openstack-sdk-auth-core", + "openstack-sdk-websso-host", + "rcgen", "reqwest", + "ring", "secrecy", + "semver", "serde", "serde_json", "sha2 0.11.0", @@ -4355,6 +4346,26 @@ dependencies = [ "tokio", "tracing", "url", + "x509-parser", +] + +[[package]] +name = "openstack-sdk-websso-host" +version = "0.1.0" +dependencies = [ + "bytes", + "form_urlencoded", + "http", + "http-body-util", + "hyper", + "hyper-util", + "open", + "reqwest", + "ring", + "thiserror 2.0.19", + "tokio", + "tracing", + "url", ] [[package]] @@ -4575,6 +4586,7 @@ dependencies = [ "http", "libfuzzer-sys", "openstack-sdk-auth-core", + "openstack-sdk-plugin-wasm", "openstack_sdk", "openstack_sdk_core", "serde_json", @@ -4839,6 +4851,16 @@ dependencies = [ "hmac", ] +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64 0.22.1", + "serde_core", +] + [[package]] name = "pem" version = "4.0.0" @@ -5510,6 +5532,19 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "rcgen" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75e669e5202259b5314d1ea5397316ad400819437857b90861765f24c4cf80a2" +dependencies = [ + "pem 3.0.6", + "ring", + "rustls-pki-types", + "time", + "yasna", +] + [[package]] name = "redox_syscall" version = "0.5.18" @@ -8407,6 +8442,7 @@ dependencies = [ "lazy_static", "nom", "oid-registry", + "ring", "rusticata-macros", "thiserror 1.0.69", "time", @@ -8445,6 +8481,15 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" +[[package]] +name = "yasna" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17bb3549cc1321ae1296b9cdc2698e2b6cb1992adfa19a8c72e5b7a738f44cd" +dependencies = [ + "time", +] + [[package]] name = "yoke" version = "0.8.3" diff --git a/Cargo.toml b/Cargo.toml index 5ac630bfb..017844bb6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,6 +24,7 @@ default-members = [ "sdk/auth-receipt", "sdk/auth-totp", "sdk/auth-websso", + "sdk/websso-host", "sdk/core", "sdk/block-storage", "sdk/container-infrastructure-management", @@ -94,18 +95,22 @@ openstack-cli-placement = { path="cli/placement/", version = "^0.13" } openstack-cli-plugin = { path="cli/plugin/", version = "0.13.7" } openstack-sdk-auth-core = { version = "0.22.6", path = "sdk/auth-core" } openstack-sdk-plugin-wasm = { version = "0.1.0", path = "sdk/plugin-wasm" } +openstack-sdk-websso-host = { version = "0.1.0", path = "sdk/websso-host" } openstack_sdk_core = { version = "0.22.7", path = "sdk/core" } openstack_sdk = { version = "0.22.7", path = "openstack_sdk" } openstack_types = { version = "0.22.7", path = "openstack_types" } openstack-types-core = { version = "0.22", path = "types/core" } pem = { version = "^4.0" } regex = { version = "^1.13" } +rcgen = { version = "^0.13" } +ring = { version = "^0.17" } rsa = { version = "^0.9", features = ["getrandom", "pkcs5"] } ssh-key = { version = "^0.6", features = ["rsa", "encryption"] } reqwest = { version = "^0.13", default-features = false } reserve-port = "^2.5" schemars = { version = "^1.2" } secrecy = { version = "^0.10", features = ["serde"] } +semver = { version = "^1.0" } serde = { version="^1.0", features=["derive"] } serde_json = "^1.0" serde_bytes = "^0.11" @@ -125,6 +130,7 @@ url = { version = "^2.5", features = ["serde"] } webauthn-authenticator-rs = { version = "^0.5", features = ["ctap2", "mozilla", "ui-cli"]} webauthn-rs-proto = { version = "^0.5" } uuid = { version = "^1.24" } +x509-parser = { version = "^0.16" } zeroize = { version = "^1.9" } [profile.dev] diff --git a/cli/plugin/Cargo.toml b/cli/plugin/Cargo.toml index a32969b1d..a7b947bf2 100644 --- a/cli/plugin/Cargo.toml +++ b/cli/plugin/Cargo.toml @@ -11,6 +11,7 @@ repository.workspace = true [dependencies] clap.workspace = true +dialoguer.workspace = true eyre.workspace = true openstack-cli-core.workspace = true openstack-sdk-plugin-wasm.workspace = true diff --git a/cli/plugin/src/confirm.rs b/cli/plugin/src/confirm.rs new file mode 100644 index 000000000..152566bca --- /dev/null +++ b/cli/plugin/src/confirm.rs @@ -0,0 +1,89 @@ +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 + +//! Shared install/update confirmation UX, used by both `osc plugin install` +//! and `osc plugin update`. + +use std::io::IsTerminal; + +use dialoguer::Confirm; + +use openstack_cli_core::error::OpenStackCliError; +use openstack_sdk_plugin_wasm::registry::{PendingInstall, ProvenanceOutcome}; + +/// The single permission a WASM auth plugin's guest ABI can use today: an +/// HTTP request scoped to the identity provider origin passed at auth time. +/// See `sdk/plugin-wasm/src/plugin.rs` module docs — the guest ABI exposes +/// exactly one host function, so this is not currently derived per-plugin. +const PERMISSIONS_SUMMARY: &str = + "identity_http (HTTP requests to the identity provider origin passed at auth time)"; + +/// Print what's about to be installed/updated — publisher, source repo, the +/// permission this plugin ABI can use, checksum, and the provenance result +/// — then, unless `yes` is set, ask for interactive confirmation. +/// +/// Returns `Ok(true)` to proceed, `Ok(false)` if the user declined. Errors +/// out (rather than prompting) in a non-interactive context without `--yes`, +/// so this never silently hangs on stdin. +pub fn confirm_pending(pending: &PendingInstall, yes: bool) -> Result { + println!("Plugin: {}@{}", pending.name, pending.version); + println!("Source repo: {}", pending.source_repo); + println!("Permissions: {PERMISSIONS_SUMMARY}"); + println!("SHA-256: {}", pending.sha256); + match &pending.provenance { + ProvenanceOutcome::Verified(record) => { + println!( + "Provenance: verified — published by CI in {} (OIDC issuer: {})", + record.source_repo, + record.oidc_issuer.as_deref().unwrap_or("unknown") + ); + if let Some(workflow_ref) = &record.workflow_ref { + println!("Workflow: {workflow_ref}"); + } + } + ProvenanceOutcome::Unverified { reason } => { + println!("Provenance: UNVERIFIED — {reason}"); + } + } + + if yes { + return Ok(true); + } + if !std::io::stdin().is_terminal() { + return Err(eyre::eyre!( + "refusing to install {}@{} without confirmation in a non-interactive context; pass --yes to proceed", + pending.name, + pending.version + ) + .into()); + } + Ok(Confirm::new() + .with_prompt(format!("Install {}@{}?", pending.name, pending.version)) + .interact()?) +} + +/// Unconditionally warn (stderr, not gated by log level, plus a structured +/// `tracing::warn!`) that a plugin is being trusted without provenance +/// verification. Call whenever `--allow-unsigned` is what made an +/// install/update proceed. +pub fn warn_allow_unsigned(name: &str, version: &str) { + eprintln!( + "WARNING: installing {name}@{version} without provenance verification (--allow-unsigned). This plugin's origin has not been cryptographically verified." + ); + tracing::warn!( + name, + version, + "installing plugin without provenance verification (allow_unsigned)" + ); +} diff --git a/cli/plugin/src/install.rs b/cli/plugin/src/install.rs index 54dccc746..5071c9908 100644 --- a/cli/plugin/src/install.rs +++ b/cli/plugin/src/install.rs @@ -12,7 +12,7 @@ // // SPDX-License-Identifier: Apache-2.0 -//! Install a wasm auth plugin +//! Install a wasm auth plugin, either from the registry or a local file. use std::path::PathBuf; @@ -22,30 +22,63 @@ use tracing::info; use openstack_cli_core::output::{OutputFor, OutputProcessor}; use openstack_cli_core::{cli::CliArgs, error::OpenStackCliError}; +use openstack_sdk_plugin_wasm::registry::ProvenanceOutcome; use structable::{StructTable, StructTableOptions}; -/// Validate and install a wasm auth plugin. +use crate::confirm; + +/// Install a wasm auth plugin, from the registry by name or from a local +/// `.wasm` file. +/// +/// `osc plugin install ` resolves `` against the registry index +/// (latest version, or `@` to pin a specific one), downloads +/// it, verifies its checksum and — unless `--allow-unsigned` is given — +/// its GitHub artifact attestation, shows what was found, and asks for +/// confirmation (skippable with `--yes`). /// -/// The plugin is loaded and its ABI is validated before anything is copied, -/// so a malformed `.wasm` file is rejected without touching the plugin -/// directory. It is installed as `@` (name comes from the -/// plugin's own ABI; version defaults to `0.0.0` when `--version` is not -/// given) and becomes the active version for that name. Installing over an -/// already-installed `name@version` fails unless `--force` is given. +/// `osc plugin install --file ` installs a local `.wasm` file instead. +/// A local file has no provenance to verify, so this always requires +/// `--allow-unsigned`. In both cases the plugin is loaded and its ABI is +/// validated before anything is written to the plugin directory, and +/// installing over an already-installed `name@version` fails unless +/// `--force` is given. #[derive(Debug, Parser)] pub struct InstallCommand { - /// Path to the `.wasm` auth plugin file to install. - pub file: PathBuf, - - /// Version to record this install under. Defaults to `0.0.0` when not - /// given; multiple versions of the same plugin name may be installed - /// side by side. + /// Plugin to install: `` (latest) or `@` (pinned), + /// resolved against the registry index. Omit when using `--file`. + #[arg(required_unless_present = "file")] + pub spec: Option, + + /// Install from a local `.wasm` file instead of the registry. Local + /// files have no provenance to verify, so this always requires + /// `--allow-unsigned`. + #[arg(long, conflicts_with = "spec")] + pub file: Option, + + /// Version to record a `--file` install under. Defaults to `0.0.0` when + /// not given. Ignored for registry installs — use `@` in + /// the positional argument instead. #[arg(long)] pub version: Option, /// Replace an existing installation of the same `name@version`. #[arg(long)] pub force: bool, + + /// Registry index URL to resolve against. The pinned default is never + /// silently overridden by anything but this explicit flag. + #[arg(long)] + pub registry_url: Option, + + /// Proceed without an interactive confirmation prompt. + #[arg(short = 'y', long)] + pub yes: bool, + + /// Install even though the plugin's provenance could not be verified + /// (required for `--file`, since a local file has no provenance to + /// check). Loudly logged. + #[arg(long)] + pub allow_unsigned: bool, } /// Information about an installed plugin. @@ -71,18 +104,84 @@ pub struct InstalledPlugin { impl InstallCommand { /// Perform command action pub async fn take_action(&self, parsed_args: &C) -> Result<(), OpenStackCliError> { - info!("Install wasm auth plugin from {}", self.file.display()); - let op = OutputProcessor::from_args(parsed_args, Some("plugin"), Some("install")); - let plugin = openstack_sdk_plugin_wasm::registry::install( - &self.file, - self.version.as_deref(), - self.force, - ) - .map_err(eyre::Report::from)?; - let (major, minor) = plugin.api_version(); + let plugin = if let Some(file) = &self.file { + info!("Install wasm auth plugin from {}", file.display()); + if !self.allow_unsigned { + return Err(eyre::eyre!( + "local --file installs have no provenance to verify; pass --allow-unsigned to install anyway" + ) + .into()); + } + let plugin = openstack_sdk_plugin_wasm::registry::install( + file, + self.version.as_deref(), + self.force, + ) + .map_err(eyre::Report::from)?; + confirm::warn_allow_unsigned( + plugin.name(), + self.version + .as_deref() + .unwrap_or(openstack_sdk_plugin_wasm::registry::DEFAULT_VERSION), + ); + plugin + } else { + let spec = self + .spec + .as_deref() + .ok_or_else(|| eyre::eyre!("either a plugin name or --file is required"))?; + let (name, version) = match spec.split_once('@') { + Some((n, v)) => (n, Some(v)), + None => (spec, None), + }; + info!("Install wasm auth plugin {spec} from the registry"); + + let registry_url = self + .registry_url + .as_deref() + .unwrap_or(openstack_sdk_plugin_wasm::index::DEFAULT_REGISTRY_URL); + let client = + openstack_sdk_plugin_wasm::index::http_client().map_err(eyre::Report::from)?; + let pending = openstack_sdk_plugin_wasm::registry::plan_remote_install( + name, + version, + registry_url, + &client, + ) + .await + .map_err(eyre::Report::from)?; + + if matches!(pending.provenance, ProvenanceOutcome::Unverified { .. }) + && !self.allow_unsigned + { + return Err(eyre::eyre!( + "refusing to install {}@{} without provenance verification (pass --allow-unsigned to override)", + pending.name, + pending.version + ) + .into()); + } + if !confirm::confirm_pending(&pending, self.yes)? { + return Err(eyre::eyre!("installation cancelled").into()); + } + if matches!(pending.provenance, ProvenanceOutcome::Unverified { .. }) { + confirm::warn_allow_unsigned(&pending.name, &pending.version); + } + + let pinned = version.is_some(); + openstack_sdk_plugin_wasm::registry::finalize_install( + pending, + self.allow_unsigned, + pinned, + self.force, + ) + .map_err(eyre::Report::from)? + }; + + let (major, minor) = plugin.api_version(); let info = InstalledPlugin { name: plugin.name().to_string(), source: plugin.source().display().to_string(), diff --git a/cli/plugin/src/lib.rs b/cli/plugin/src/lib.rs index 1d0c49646..cc883c010 100644 --- a/cli/plugin/src/lib.rs +++ b/cli/plugin/src/lib.rs @@ -23,10 +23,14 @@ use clap::{Parser, Subcommand}; use openstack_cli_core::{cli::CliArgs, error::OpenStackCliError}; +mod confirm; + pub mod info; pub mod install; pub mod list; pub mod remove; +pub mod search; +pub mod update; pub mod verify; /// WASM auth plugin management @@ -49,6 +53,8 @@ pub enum PluginCommands { Info(info::InfoCommand), Remove(remove::RemoveCommand), Verify(verify::VerifyCommand), + Search(search::SearchCommand), + Update(update::UpdateCommand), } impl PluginCommand { @@ -60,6 +66,8 @@ impl PluginCommand { PluginCommands::Info(cmd) => cmd.take_action(parsed_args).await, PluginCommands::Remove(cmd) => cmd.take_action(parsed_args).await, PluginCommands::Verify(cmd) => cmd.take_action(parsed_args).await, + PluginCommands::Search(cmd) => cmd.take_action(parsed_args).await, + PluginCommands::Update(cmd) => cmd.take_action(parsed_args).await, } } } diff --git a/cli/plugin/src/list.rs b/cli/plugin/src/list.rs index 13a4e7a02..a6bc77dbb 100644 --- a/cli/plugin/src/list.rs +++ b/cli/plugin/src/list.rs @@ -18,7 +18,7 @@ use clap::Parser; use serde::{Deserialize, Serialize}; use tracing::info; -use openstack_cli_core::output::OutputProcessor; +use openstack_cli_core::output::{OutputFor, OutputProcessor}; use openstack_cli_core::{cli::CliArgs, error::OpenStackCliError}; use structable::{StructTable, StructTableOptions}; @@ -50,6 +50,11 @@ pub struct PluginListEntry { /// When this version was installed. #[structable()] pub installed_at: String, + + /// Whether this entry was trusted without provenance verification + /// (`--allow-unsigned` at install/update time). + #[structable()] + pub allow_unsigned: bool, } /// List installed wasm auth plugins. @@ -66,6 +71,7 @@ impl ListCommand { let lockfile = openstack_sdk_plugin_wasm::registry::installed().map_err(eyre::Report::from)?; + let mut unsigned: Vec = Vec::new(); let data: Vec = lockfile .plugins .values() @@ -75,6 +81,9 @@ impl ListCommand { .get(&entry.name) .map(|v| v == &entry.version) .unwrap_or(false); + if entry.trust.allow_unsigned { + unsigned.push(format!("{}@{}", entry.name, entry.version)); + } serde_json::to_value(PluginListEntry { name: entry.name.clone(), version: entry.version.clone(), @@ -82,10 +91,21 @@ impl ListCommand { source: entry.source.display().to_string(), sha256: entry.sha256.clone(), installed_at: entry.installed_at.to_string(), + allow_unsigned: entry.trust.allow_unsigned, }) }) .collect::>()?; - op.output_list::(data) + op.output_list::(data)?; + + if matches!(op.target, OutputFor::Human) && !unsigned.is_empty() { + println!( + "\n⚠ {} installed plugin{} running without provenance verification (allow_unsigned): {}", + unsigned.len(), + if unsigned.len() == 1 { " is" } else { "s are" }, + unsigned.join(", ") + ); + } + Ok(()) } } diff --git a/cli/plugin/src/remove.rs b/cli/plugin/src/remove.rs index 0dc8cbec6..ae1d76a98 100644 --- a/cli/plugin/src/remove.rs +++ b/cli/plugin/src/remove.rs @@ -66,6 +66,7 @@ impl RemoveCommand { source: entry.source.display().to_string(), sha256: entry.sha256.clone(), installed_at: entry.installed_at.to_string(), + allow_unsigned: entry.trust.allow_unsigned, }) }) .collect::>()?; diff --git a/cli/plugin/src/search.rs b/cli/plugin/src/search.rs new file mode 100644 index 000000000..206034054 --- /dev/null +++ b/cli/plugin/src/search.rs @@ -0,0 +1,91 @@ +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 + +//! Search the plugin registry index. + +use clap::Parser; +use serde::{Deserialize, Serialize}; +use tracing::info; + +use openstack_cli_core::output::OutputProcessor; +use openstack_cli_core::{cli::CliArgs, error::OpenStackCliError}; +use structable::{StructTable, StructTableOptions}; + +/// Search the plugin registry index for plugins whose name or description +/// matches `query`, or list every published plugin when `query` is omitted. +#[derive(Debug, Parser)] +pub struct SearchCommand { + /// Case-insensitive substring to match against plugin name/description. + pub query: Option, + + /// Registry index URL to search. The pinned default is never silently + /// overridden by anything but this explicit flag. + #[arg(long)] + pub registry_url: Option, +} + +/// A single matching registry entry. +#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize, StructTable)] +pub struct PluginSearchResult { + /// Plugin name. + #[structable()] + pub name: String, + + /// Human readable description. + #[structable()] + pub description: String, + + /// The version that would be installed by `osc plugin install `. + #[structable()] + pub latest_version: String, + + /// The `owner/repo` the latest version claims to be published from. + #[structable()] + pub source_repo: String, +} + +impl SearchCommand { + /// Perform command action + pub async fn take_action(&self, parsed_args: &C) -> Result<(), OpenStackCliError> { + info!("Search plugin registry for {:?}", self.query); + + let op = OutputProcessor::from_args(parsed_args, Some("plugin"), Some("search")); + + let registry_url = self + .registry_url + .as_deref() + .unwrap_or(openstack_sdk_plugin_wasm::index::DEFAULT_REGISTRY_URL); + let client = openstack_sdk_plugin_wasm::index::http_client().map_err(eyre::Report::from)?; + let index = openstack_sdk_plugin_wasm::index::fetch_index(registry_url, &client) + .await + .map_err(eyre::Report::from)?; + + let data: Vec = + openstack_sdk_plugin_wasm::index::search(&index, self.query.as_deref()) + .into_iter() + .map(|entry| { + let latest = + openstack_sdk_plugin_wasm::index::resolve_version(entry, None).ok(); + serde_json::to_value(PluginSearchResult { + name: entry.name.clone(), + description: entry.description.clone(), + latest_version: latest.map(|v| v.version.clone()).unwrap_or_default(), + source_repo: latest.map(|v| v.source_repo.clone()).unwrap_or_default(), + }) + }) + .collect::>()?; + + op.output_list::(data) + } +} diff --git a/cli/plugin/src/update.rs b/cli/plugin/src/update.rs new file mode 100644 index 000000000..e28a99d83 --- /dev/null +++ b/cli/plugin/src/update.rs @@ -0,0 +1,180 @@ +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 + +//! Update installed wasm auth plugin(s) to the latest registry version. + +use std::io::IsTerminal; + +use clap::Parser; +use serde::{Deserialize, Serialize}; +use tracing::info; + +use openstack_cli_core::output::OutputProcessor; +use openstack_cli_core::{cli::CliArgs, error::OpenStackCliError}; +use openstack_sdk_plugin_wasm::registry::{ProvenanceOutcome, UpdateOutcome}; +use structable::{StructTable, StructTableOptions}; + +use crate::confirm; + +/// Update installed, non-pinned wasm auth plugin(s) to the latest version +/// available in the registry index. +/// +/// Provenance is re-verified fresh for every plugin considered, never reused +/// from a previous install — the same GitHub attestation check `osc plugin +/// install` performs. A plugin installed with an explicit `@version` is +/// "pinned" and is skipped by `--all` (and refused by name) unless +/// reinstalled explicitly via `osc plugin install @`. +#[derive(Debug, Parser)] +pub struct UpdateCommand { + /// Plugin name to update. Omit and pass `--all` to update every + /// installed, non-pinned plugin instead. + #[arg(conflicts_with = "all")] + pub name: Option, + + /// Update every installed, non-pinned plugin. + #[arg(long)] + pub all: bool, + + /// Registry index URL to resolve against. The pinned default is never + /// silently overridden by anything but this explicit flag. + #[arg(long)] + pub registry_url: Option, + + /// Proceed without an interactive confirmation prompt for each update. + #[arg(short = 'y', long)] + pub yes: bool, + + /// Allow updating to a version whose provenance could not be verified. + /// Loudly logged. A plugin whose new version fails verification is + /// skipped when this is not given. + #[arg(long)] + pub allow_unsigned: bool, +} + +/// The outcome of one plugin's update attempt. +#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize, StructTable)] +pub struct PluginUpdateResult { + /// Plugin name. + #[structable()] + pub name: String, + + /// What happened: `updated`, `up-to-date`, `declined`, `skipped + /// (pinned)`, or `not in registry index`. + #[structable()] + pub status: String, + + /// The version that was active before this update attempt. + #[structable()] + pub from_version: String, + + /// The version now active (equal to `from_version` unless `status` is + /// `updated`). + #[structable()] + pub to_version: String, +} + +impl UpdateCommand { + /// Perform command action + pub async fn take_action(&self, parsed_args: &C) -> Result<(), OpenStackCliError> { + info!("Update installed wasm auth plugin(s)"); + + let op = OutputProcessor::from_args(parsed_args, Some("plugin"), Some("update")); + + if self.name.is_none() && !self.all { + return Err(eyre::eyre!("update requires either a plugin name or --all").into()); + } + if !self.yes && !std::io::stdin().is_terminal() { + return Err(eyre::eyre!( + "refusing to update without confirmation in a non-interactive context; pass --yes to proceed" + ) + .into()); + } + + let registry_url = self + .registry_url + .as_deref() + .unwrap_or(openstack_sdk_plugin_wasm::index::DEFAULT_REGISTRY_URL); + let client = openstack_sdk_plugin_wasm::index::http_client().map_err(eyre::Report::from)?; + + let yes = self.yes; + let allow_unsigned = self.allow_unsigned; + let outcomes = openstack_sdk_plugin_wasm::registry::update( + self.name.as_deref(), + self.all, + registry_url, + &client, + allow_unsigned, + |pending| { + if matches!(pending.provenance, ProvenanceOutcome::Unverified { .. }) && !allow_unsigned { + eprintln!( + "refusing to update {}@{} without provenance verification (pass --allow-unsigned to override); skipping", + pending.name, pending.version + ); + return false; + } + let proceed = confirm::confirm_pending(pending, yes).unwrap_or_else(|e| { + eprintln!("{e}"); + false + }); + if proceed && matches!(pending.provenance, ProvenanceOutcome::Unverified { .. }) { + confirm::warn_allow_unsigned(&pending.name, &pending.version); + } + proceed + }, + ) + .await + .map_err(eyre::Report::from)?; + + let data: Vec = outcomes + .into_iter() + .map(|outcome| { + let entry = match outcome { + UpdateOutcome::UpToDate { name, version } => PluginUpdateResult { + name, + status: "up-to-date".into(), + from_version: version.clone(), + to_version: version, + }, + UpdateOutcome::Updated { name, from, to } => PluginUpdateResult { + name, + status: "updated".into(), + from_version: from, + to_version: to, + }, + UpdateOutcome::Declined { name, version } => PluginUpdateResult { + name, + status: "declined".into(), + from_version: version.clone(), + to_version: version, + }, + UpdateOutcome::SkippedPinned { name, version } => PluginUpdateResult { + name, + status: "skipped (pinned)".into(), + from_version: version.clone(), + to_version: version, + }, + UpdateOutcome::NotInIndex { name } => PluginUpdateResult { + name, + status: "not in registry index".into(), + from_version: String::new(), + to_version: String::new(), + }, + }; + serde_json::to_value(entry) + }) + .collect::>()?; + + op.output_list::(data) + } +} diff --git a/doc/src/SUMMARY.md b/doc/src/SUMMARY.md index e585d1d2e..44bda4270 100644 --- a/doc/src/SUMMARY.md +++ b/doc/src/SUMMARY.md @@ -11,6 +11,9 @@ # Components - [Rust SDK](./sdk.md) +- [WASM Auth Plugins](./plugins.md) + - [Plugin author guide](./plugins/author-guide.md) + - [Registry governance](./plugins/registry-governance.md) --- diff --git a/doc/src/plugins.md b/doc/src/plugins.md new file mode 100644 index 000000000..426482b38 --- /dev/null +++ b/doc/src/plugins.md @@ -0,0 +1,121 @@ +# WASM Auth Plugins + +`osc` supports authentication methods beyond the ones compiled in, loaded at +runtime from [Extism](https://extism.org/) (WebAssembly) modules. This lets a +third party ship a new `auth_type` — for example a corporate SSO flow, or an +identity provider-specific token exchange — without a fork of `osc` or a +recompile, and without ever giving that third party's code a socket, the +filesystem, or environment variables: every WASM plugin runs in a sandbox +that structurally cannot perform I/O other than through the narrow, +host-mediated capabilities described below. + +This page covers using plugins as an `osc` operator. If you want to write +one, see the [plugin author guide](./plugins/author-guide.md). For how +plugins get into the registry `osc plugin install` resolves against, see +[registry governance](./plugins/registry-governance.md). + +## The sandbox, briefly + +A loaded plugin gets: + +- A bounded amount of linear memory (16 MiB) and a per-call timeout (20s). +- No filesystem, no environment variables, no WASI. +- For the `auth` ABI flavor: a single host function, + `identity_http_request`, that proxies HTTP requests to the identity + endpoint `osc` resolved for the configured cloud — never to a URL the + plugin picks itself. +- For the `sso` ABI flavor: no host function at all. The plugin never holds + a socket or a browser-opening capability; it only ever computes the + identity-provider URL to open and, later, parses an already + CSRF-validated callback. The local callback listener, the anti-CSRF + `state` check, and the actual browser launch are all owned by `osc` + itself. + +Either way, the plugin cannot reach any host other than the one `osc` +explicitly hands it, and it cannot exfiltrate data through any channel other +than the token it ultimately returns. + +## Installing a plugin + +```console +$ osc plugin search sso +$ osc plugin install example_auth +$ osc plugin install example_auth@1.2.0 # pin a specific version +$ osc plugin install --file ./my-plugin.wasm --allow-unsigned +``` + +`osc plugin install [@version]` resolves the name against a registry +index (`--registry-url` to point at a different one than the built-in +default), downloads the `.wasm` artifact, and before ever trusting it: + +1. Checks the downloaded bytes' SHA-256 against what the registry index + declares, before anything touches disk. +2. Verifies the artifact's GitHub attestation (produced by `actions/attest` + in the plugin's own CI) proves it was actually built and published from + the repository the index claims, not just that the bytes are + internally consistent. See + [Trust model](#trust-model) below for exactly what this does and does + not prove. +3. Shows you the source repository, the checksum, and the provenance + result, and asks for confirmation (skip with `--yes`). + +Installing from a local file (`--file`) skips steps 1–2 entirely — there is +no registry entry and no attestation to check for a file on your own disk — +so it always requires `--allow-unsigned` and is always loudly logged as +such. + +Other commands: + +| Command | Purpose | +| --- | --- | +| `osc plugin search [query]` | List registry entries matching `query` (name/description substring), or every entry if omitted. | +| `osc plugin list` | List installed `name@version` pairs, which one is active, and whether it was trusted via `--allow-unsigned`. | +| `osc plugin info ` | Show every installed version of a plugin. | +| `osc plugin update [ \| --all]` | Re-resolve non-pinned installs against the registry and upgrade. Provenance is re-checked fresh every time, never reused from the original install. | +| `osc plugin verify ` | Re-check installed file(s) against the SHA-256 recorded in the lockfile at install time — catches on-disk tampering or corruption after install. | +| `osc plugin remove [--version]` | Remove one or every installed version of a plugin. | + +## Trust model + +Two independent checks stand between a downloaded `.wasm` file and it being +loaded and used for authentication: + +- **Checksum**: the bytes must match the SHA-256 the registry index + declares for that version. This only proves the download wasn't + corrupted or tampered with in transit relative to what the index says — + it says nothing about whether the index itself can be trusted. +- **Provenance**: the artifact's GitHub attestation must verify against a + vendored, pinned Fulcio root/intermediate CA, and the attestation's + embedded identity (OIDC issuer + `owner/repo`) must match the source + repository the registry index claims for that plugin. This is what + actually proves the file was built by CI in the claimed repository, + rather than uploaded by hand or by an attacker who compromised the + index. + + This verifier deliberately does **not** check Rekor transparency-log + inclusion (the Merkle audit-path proof that the attestation was actually + published to the public log, not just handed to `osc` directly) — see + `sdk/plugin-wasm/src/provenance.rs` for why. In practice this means: a + tampered or entirely unattested plugin still fails closed, but a + withheld-from-the-log attestation would not be caught. + +If either check fails, `osc plugin install`/`update` refuse outright unless +you pass `--allow-unsigned`, which is always logged (both to the terminal +and in structured logs) and always recorded in the lockfile, so +`osc plugin list` continues to show you which installed plugins are running +without full verification. + +## Using an installed plugin + +Once installed, a plugin's declared auth method(s) become usable exactly +like a built-in one — set `auth_type` in `clouds.yaml` (or pass it however +you normally configure authentication) to the method name the plugin +reports (visible via `osc plugin info ` or `osc plugin install`'s +output). + +For an `sso`-flavor plugin, `osc` will additionally ask for confirmation +before opening your browser, showing you the exact URL it's about to open. +Declining, or the plugin returning anything other than an `https://` URL on +the callback address `osc` itself bound, is refused outright with no +override — see the [author guide](./plugins/author-guide.md#the-sso-abi-flavor) +for the mechanics. diff --git a/doc/src/plugins/author-guide.md b/doc/src/plugins/author-guide.md new file mode 100644 index 000000000..9ec5a768e --- /dev/null +++ b/doc/src/plugins/author-guide.md @@ -0,0 +1,173 @@ +# Plugin author guide + +This page describes the guest ABI a WASM auth plugin must implement, and how +to build, test, and publish one. For the operator-facing view (installing, +trust model), see [WASM Auth Plugins](../plugins.md). + +A plugin is a single `.wasm` module targeting `wasm32-unknown-unknown`, +built against [Extism's PDK](https://extism.org/docs/quickstart/plugin-quickstart) +(`extism-pdk` for Rust, or any other language the PDK supports — the ABI is +just exported functions taking and returning strings, so it isn't Rust-only). +It runs with no filesystem, no environment variables, no WASI, 16 MiB of +linear memory, and a 20-second per-call timeout. It cannot open a socket or +resolve DNS itself; the only way it ever touches the network is the +host-mediated capability described below. + +## Common exports (every plugin) + +Every conforming module exports these four functions regardless of ABI +flavor: + +| Export | Signature | Purpose | +| --- | --- | --- | +| `plugin_abi_version` | `(_: string) -> string` | Must return the literal `"1"`. This is the only ABI version `osc` currently understands; a mismatch is rejected at load time before anything else runs. | +| `auth_supported_methods` | `(_: string) -> string` | JSON array of the `auth_type` name(s) this plugin implements, e.g. `["my_corp_sso"]`. Must be non-empty. | +| `auth_api_version` | `(_: string) -> string` | JSON `[major, minor]` pair, informational. | +| `auth_requirements` | `(hints: string) -> string` | `hints` is a JSON value or the literal `null`. Returns a JSON Schema object describing the fields this auth method needs from the user, in the same shape `OpenStackAuthType::requirements` produces for compiled-in auth types. | + +At load time `osc` calls these four in order, validates the answers, and +then inspects which of the two ABI flavors below the module additionally +exports (via `extism::Plugin::function_exists`) — a module must implement +**exactly one**; exporting both, or neither, is rejected. + +## The `auth` ABI flavor + +For non-interactive auth methods (token exchange, credential-based flows, +anything that doesn't need a browser). + +- **`auth(request: string) -> string`** — `request` is a JSON object: + + ```json + {"identity_url": "...", "values": {...}, "scope": {...}, "hints": {...}} + ``` + + Returns either: + + ```json + {"ok": {"token": "...", "auth_info": null}} + ``` + + or: + + ```json + {"error": "human readable message"} + ``` + + This is the only export in this flavor, and it's also the only place a + plugin may perform outbound HTTP — and only through the host-provided + `identity_http_request` import, never directly (there is no direct-socket + capability to use even if you wanted to). + +### `identity_http_request` + +The one host function available to `auth`-flavor plugins. It proxies a +request to the identity endpoint `osc` already resolved for the configured +cloud — never to a URL the plugin picks itself: + +```json +// guest -> host +{"method": "POST", "path": "/v3/auth/tokens", "headers": {...}, "body": "..."} +``` + +```json +// host -> guest +{"status": 201, "headers": {...}, "body": "..."} +``` + +`path` must be relative (start with `/`) and is resolved against the bound +identity origin (scheme + host + port only) — there is no field for +supplying a different host, so a plugin structurally cannot be redirected +into calling anything other than the identity endpoint it was invoked for. + +## The `sso` ABI flavor + +For interactive, browser-based (WebSSO-style) auth methods. Both exports +are pure functions: no I/O capability is available to this flavor at all. +The host owns the local callback listener, the anti-CSRF `state` check, and +the actual browser-launch step — the guest only ever computes strings from +strings. + +- **`sso_build_request(request: string) -> string`** — `request` is: + + ```json + {"identity_url": "...", "callback_url": "...", "values": {...}, "scope": {...}, "hints": {...}} + ``` + + `callback_url` is the host-bound local callback URL, with the anti-CSRF + `state` token already embedded — the plugin doesn't generate or see the + raw CSRF secret, it just has to make sure the identity provider redirects + back to this exact URL. Returns: + + ```json + {"url": "https://idp.example.com/authorize?...", "redirect_host": "127.0.0.1:PORT"} + ``` + + `url` is the page the host will open in the user's browser after showing + it to the user for confirmation. `redirect_host` is the `host:port` + authority the plugin configured as the identity provider's redirect + target. Before opening anything, the host independently verifies that + `url`'s scheme is `https` and that `redirect_host` exactly matches the + authority of the callback listener it itself bound — a plugin that + returns a URL pointing at a different, undeclared redirect host is + rejected outright, with **no override**, since this is the one place a + compromised plugin could otherwise redirect a real user's browser + somewhere attacker-controlled. + +- **`sso_parse_callback(callback: string) -> string`** — `callback` is: + + ```json + {"params": {"code": "...", "state": "..."}} + ``` + + the form fields from the callback POST, handed to the guest only *after* + the host has already validated the `state` token itself. Returns the same + `{"ok": ...} | {"error": ...}` shape as `auth`. + +## Building + +```console +$ rustup target add wasm32-unknown-unknown +$ cargo build --release --target wasm32-unknown-unknown +``` + +Using `extism-pdk`, the common exports and one ABI flavor's exports are +ordinary `#[plugin_fn]`-annotated functions returning `FnResult` (or +a PDK JSON-typed wrapper) — see the +[Extism Rust PDK docs](https://github.com/extism/rust-pdk) for the exact +macro shape. There's nothing `osc`-specific about the build step beyond the +export names and JSON shapes above. + +## Testing locally + +Before publishing, install straight from the built artifact with +`--allow-unsigned` (a local file has no attestation to check, so this is +always required — see [Trust model](../plugins.md#trust-model)): + +```console +$ osc plugin install --file ./target/wasm32-unknown-unknown/release/my_plugin.wasm --allow-unsigned +$ osc plugin list +$ osc --os-auth-type my_corp_sso ... # exercise it +``` + +`osc plugin verify ` re-checks the installed file's SHA-256 against +the lockfile at any point, useful for confirming nothing got corrupted +during iteration. + +## Publishing + +1. In your own repository's CI, build the release artifact and attest it + with [`actions/attest`](https://github.com/actions/attest) — the same + action this repository's own release workflow uses + (`.github/workflows/release.yml`). This is what lets `osc` later prove + the `.wasm` file it downloaded actually came from a build in your + repository, not from an attacker who compromised the registry index. +2. Publish the artifact (a GitHub release asset is the natural choice) and + compute its SHA-256. +3. Open a pull request against this repository adding your plugin's + `name`/`versions[]` entry to `plugins/registry/index.json`. See + [Registry governance](./registry-governance.md) for what reviewers check + before merging. + +Once merged, `osc plugin search`/`install ` picks it up from the +default registry immediately — there's no separate publish step on the +`osc` side. diff --git a/doc/src/plugins/registry-governance.md b/doc/src/plugins/registry-governance.md new file mode 100644 index 000000000..d6f5c450f --- /dev/null +++ b/doc/src/plugins/registry-governance.md @@ -0,0 +1,105 @@ +# Registry governance + +`osc plugin search`/`install`/`update` resolve against a plugin index — +by default `plugins/registry/index.json` in this repository, served over +plain HTTPS via `raw.githubusercontent.com` (see +[the registry directory's own README](https://github.com/gtema/openstack/blob/main/plugins/registry/README.md) +for the exact JSON schema). This page describes how an entry gets into that +index, what stops a bad entry from being trusted even if it does, and how a +published plugin gets removed. + +## Why the index itself doesn't need to be trusted blindly + +The index is a plain JSON file in a normal git repository, merged through +normal pull-request review like any other change here. It is **not** a +security boundary on its own — anyone who can get a PR merged (including, +in principle, a reviewer who makes a mistake) can add or edit an entry. +What actually stops a malicious or mistaken index entry from being trusted +is downstream of the index, at install time: + +- **Checksum**: the downloaded bytes must match the `sha256` the index + declares, checked before anything touches disk. +- **Provenance**: the artifact's GitHub attestation must verify against a + pinned Fulcio trust root, and the attestation's embedded identity + (`owner/repo`) must match the index's `source_repo` field for that + version. + +So an index entry that points `download_url`/`sha256` at a legitimate +release, but lies about `source_repo`, fails closed at install time — the +downloaded bytes' real attestation won't match the claimed repo. An index +entry pointing at an attacker-controlled URL with a self-consistent +`sha256` still fails unless that URL's artifact also carries a valid +attestation naming the claimed `source_repo`, which requires control of +that repository's CI, not just of the index text. Full detail on exactly +what the provenance check does and does not prove is in +[Trust model](../plugins.md#trust-model). + +This is a deliberate design point: **reviewing an index PR is a +plausibility check, not the trust boundary itself.** It means review can +stay lightweight without turning into the sole thing standing between users +and a malicious plugin. + +## What a reviewer checks before merging an index PR + +1. **The PR only touches `plugins/registry/index.json`** (and, for a new + plugin, its `name`/`description`), not unrelated files. +2. **`source_repo` is a real, publicly reachable repository** that plausibly + belongs to whoever opened the PR — a repository under someone else's + account/org, or a private repository, is not an appropriate provenance + target for a public registry entry. +3. **`download_url` actually resolves to a release asset in that + `source_repo`** (typically a GitHub Releases download URL), and the + declared `sha256` matches what's actually at that URL — a reviewer can + check this by downloading the asset and hashing it locally. +4. **The repository's release workflow attests the artifact** with + `actions/attest` (or an equivalent Sigstore-based GitHub attestation) — + visible in the repo's own Actions workflow file. An entry whose CI + doesn't attest will simply fail closed for every installer later + (`--allow-unsigned` required), which is safe but a poor experience, so + reviewers should ask for this before merging rather than after users + start filing confused issues. +5. **`abi_version` matches what the artifact actually reports** — a + mismatch here is caught automatically at install time, but catching it + in review saves a round trip. +6. For an update to an *existing* plugin (a new `versions[]` entry, not a + new plugin), the new entry's `source_repo` should match prior versions' + unless the PR explains why ownership moved (e.g. a transferred + repository) — a silent `source_repo` change on an established plugin + name is exactly the shape a supply-chain compromise would take, so it + gets a closer look, not a rubber stamp. + +None of this review substitutes for the checksum/provenance checks `osc` +itself performs — it exists to keep obviously broken or obviously +inappropriate entries from being merged at all, and to give users installing +a plugin for the first time (who see the source repo and provenance result +before confirming, per [Installing a plugin](../plugins.md#installing-a-plugin)) +something meaningful to look at. + +## Removal and revocation + +There is currently no automated revocation mechanism — removing or editing +an entry in `index.json` is, like adding one, a normal pull request. If a +published plugin turns out to be malicious, broken, or abandoned: + +- Removing its entry (or a specific bad `versions[]` entry) from the index + stops new `osc plugin install`/`update` calls from resolving it. This + does **not** reach back into anyone's already-installed copy — `osc + plugin remove ` on the affected machine is still up to the user (or + their own tooling) to run. +- For an actively malicious release, opening an issue/PR promptly and + flagging it for expedited review is preferable to waiting for a routine + review cycle — the index has no separate "yank" mechanism, only "the + entry is gone from `main`" the next time someone fetches it. + +## Scope of this bootstrap registry + +This index living in `gtema/openstack` rather than a dedicated registry +repository is a bootstrap choice, not a schema constraint — nothing about +the index format or the fetch/verification code is tied to this +repository. `--registry-url` already lets anyone point at a different +index today (their own private registry, an alternate community one, etc.), +subject to the same checksum/provenance checks on the client side +regardless of which index served the entry. If plugin release cadence ever +needs to move independently of `osc`'s own releases, the default index can +move to a dedicated repository with no code changes beyond the pinned +default URL. diff --git a/fuzz/Cargo.toml b/fuzz/Cargo.toml index 36a41642f..e70274c81 100644 --- a/fuzz/Cargo.toml +++ b/fuzz/Cargo.toml @@ -19,9 +19,11 @@ fuzzing = [ "dep:tempfile", "dep:openstack_sdk_core", "dep:openstack-sdk-auth-core", + "dep:openstack-sdk-plugin-wasm", "openstack_sdk/fuzzing", "openstack_sdk_core/fuzzing", "openstack-sdk-auth-core/fuzzing", + "openstack-sdk-plugin-wasm/fuzzing", ] [package.metadata.dist] @@ -33,6 +35,7 @@ bytes.workspace = true http.workspace = true libfuzzer-sys = { version = "0.4", optional = true } openstack-sdk-auth-core = { workspace = true, optional = true } +openstack-sdk-plugin-wasm = { workspace = true, optional = true } openstack_sdk_core = { workspace = true, optional = true } serde_json.workspace = true tempfile = { workspace = true, optional = true } @@ -128,3 +131,27 @@ required-features = ["fuzzing"] test = false doc = false bench = false + +[[bin]] +name = "fuzz_wasm_plugin_identity_http_request" +path = "fuzz_targets/fuzz_wasm_plugin_identity_http_request.rs" +required-features = ["fuzzing"] +test = false +doc = false +bench = false + +[[bin]] +name = "fuzz_wasm_plugin_sso_build_response" +path = "fuzz_targets/fuzz_wasm_plugin_sso_build_response.rs" +required-features = ["fuzzing"] +test = false +doc = false +bench = false + +[[bin]] +name = "fuzz_wasm_plugin_auth_result" +path = "fuzz_targets/fuzz_wasm_plugin_auth_result.rs" +required-features = ["fuzzing"] +test = false +doc = false +bench = false diff --git a/fuzz/fuzz_targets/fuzz_wasm_plugin_auth_result.rs b/fuzz/fuzz_targets/fuzz_wasm_plugin_auth_result.rs new file mode 100644 index 000000000..c8cb0ffe5 --- /dev/null +++ b/fuzz/fuzz_targets/fuzz_wasm_plugin_auth_result.rs @@ -0,0 +1,20 @@ +#![no_main] + +use libfuzzer_sys::fuzz_target; +use openstack_sdk_plugin_wasm::plugin::fuzz_parse_auth_result; + +extern crate openstack_sdk_plugin_wasm; + +// Every guest export that finishes an auth attempt (`auth`, and +// `sso_parse_callback`) returns its result as an `AuthResultMsg` JSON string +// that the host deserializes. This is the last guest-controlled parse before +// the host either mints an `Auth::AuthToken` or surfaces an error message to +// the caller, so it must never panic regardless of what the guest returns -- +// a compromised or simply buggy plugin is the threat model, not a +// well-behaved one. + +fuzz_target!(|data: &[u8]| { + if let Ok(s) = std::str::from_utf8(data) { + fuzz_parse_auth_result(s); + } +}); diff --git a/fuzz/fuzz_targets/fuzz_wasm_plugin_identity_http_request.rs b/fuzz/fuzz_targets/fuzz_wasm_plugin_identity_http_request.rs new file mode 100644 index 000000000..8676b0a71 --- /dev/null +++ b/fuzz/fuzz_targets/fuzz_wasm_plugin_identity_http_request.rs @@ -0,0 +1,59 @@ +#![no_main] + +use arbitrary::Arbitrary; +use libfuzzer_sys::fuzz_target; +use openstack_sdk_plugin_wasm::fuzz_identity_http_request_parsing; +use url::Url; + +extern crate openstack_sdk_plugin_wasm; + +// `identity_http_request` is the one host function exposed to every WASM auth +// plugin -- the sole point where guest-controlled bytes cross the Extism +// boundary into host code. Its request-parsing/validation step (JSON decode, +// `path` must be relative, URL join against the bound identity origin, method +// parse) runs on every call before any network I/O, so it must never panic on +// adversarial input, however the plugin was built. + +#[derive(Debug, Arbitrary)] +enum FuzzRequest { + /// Fully arbitrary text: covers "doesn't even parse as the request shape". + Raw(String), + /// A well-formed `HttpRequestMsg` JSON shape with fuzzed field values, + /// reaching the relative-path check, URL join, and method parse. + Structured { + method: String, + path: String, + headers: Vec<(String, String)>, + body: Option, + }, +} + +#[derive(Debug, Arbitrary)] +struct FuzzInput { + origin_host: String, + request: FuzzRequest, +} + +fuzz_target!(|input: FuzzInput| { + let Ok(origin) = Url::parse(&format!("https://{}", input.origin_host)) else { + return; + }; + + let request_json = match &input.request { + FuzzRequest::Raw(s) => s.clone(), + FuzzRequest::Structured { + method, + path, + headers, + body, + } => serde_json::json!({ + "method": method, + "path": path, + "headers": headers.iter().cloned().collect::>(), + "body": body, + }) + .to_string(), + }; + + fuzz_identity_http_request_parsing(&origin, &request_json); +}); diff --git a/fuzz/fuzz_targets/fuzz_wasm_plugin_sso_build_response.rs b/fuzz/fuzz_targets/fuzz_wasm_plugin_sso_build_response.rs new file mode 100644 index 000000000..37741f84e --- /dev/null +++ b/fuzz/fuzz_targets/fuzz_wasm_plugin_sso_build_response.rs @@ -0,0 +1,41 @@ +#![no_main] + +use arbitrary::Arbitrary; +use libfuzzer_sys::fuzz_target; +use openstack_sdk_plugin_wasm::plugin::fuzz_validate_sso_build_response; + +extern crate openstack_sdk_plugin_wasm; + +// `sso_build_request`'s response is the guest's one chance to steer where a +// real browser gets opened, so the host validates it before ever prompting +// the user: the URL must parse and be `https`, and the declared +// `redirect_host` must exactly match the host-bound callback listener's own +// authority. A malicious or buggy plugin fully controls both strings; this +// target checks the validation logic never panics on adversarial input, on +// either axis (the response shape and the expected-host comparison). + +#[derive(Debug, Arbitrary)] +enum FuzzBuildOutput { + /// Fully arbitrary text: covers "doesn't even parse as the response shape". + Raw(String), + /// A well-formed `{"url", "redirect_host"}` shape with fuzzed field + /// values, reaching the URL-parse/scheme/host-match checks. + Structured { url: String, redirect_host: String }, +} + +#[derive(Debug, Arbitrary)] +struct FuzzInput { + build_output: FuzzBuildOutput, + expected_redirect_host: String, +} + +fuzz_target!(|input: FuzzInput| { + let build_output = match &input.build_output { + FuzzBuildOutput::Raw(s) => s.clone(), + FuzzBuildOutput::Structured { url, redirect_host } => { + serde_json::json!({"url": url, "redirect_host": redirect_host}).to_string() + } + }; + + fuzz_validate_sso_build_response(&build_output, &input.expected_redirect_host); +}); diff --git a/plugins/registry/README.md b/plugins/registry/README.md new file mode 100644 index 000000000..62dbb172b --- /dev/null +++ b/plugins/registry/README.md @@ -0,0 +1,64 @@ +# osc plugin registry index + +This directory hosts the bootstrap index consulted by `osc plugin search` +and `osc plugin install [@version]`. The default registry URL +(`openstack_sdk_plugin_wasm::index::DEFAULT_REGISTRY_URL`) points at +`index.json` in this directory on the default branch of this repository, +fetched over plain HTTPS via `raw.githubusercontent.com`. + +This is a bootstrap: nothing about the index schema or the fetch/verify code +is tied to living in this repo. If plugin release cadence ever needs to move +independently of `osc` releases, the index (and the plugins it points at) can +move to a dedicated repository with no code changes beyond the pinned default +URL — `--registry-url` already lets any URL be used explicitly today. + +## Schema + +```json +{ + "schema_version": 1, + "plugins": [ + { + "name": "example_auth", + "description": "Human readable one-liner.", + "versions": [ + { + "version": "1.0.0", + "download_url": "https://github.com///releases/download/v1.0.0/example_auth.wasm", + "sha256": "", + "source_repo": "/", + "abi_version": "1", + "min_cli_version": "0.13.0" + } + ] + } + ] +} +``` + +- `schema_version` — bumped on breaking changes to this shape; `osc` rejects + an index whose `schema_version` it doesn't understand. +- `plugins[].versions[]` — one entry per published version; `osc plugin + install ` without `@version` installs the highest by semver, `osc + plugin update` re-resolves this same way. +- `source_repo` — the GitHub repository (`owner/repo`) whose CI is expected + to have published this exact `.wasm` artifact and its attestation + (`actions/attest`). This is what provenance verification checks the + downloaded file's GitHub attestation against before `osc` trusts it. +- `sha256` — checked against the downloaded bytes before anything touches + disk, independent of and prior to provenance verification. +- `abi_version` — informational; matched against the guest ABI's own + self-reported `plugin_abi_version` after download. +- `min_cli_version` — optional; if set and the running `osc` is older, + install is refused with a clear version-mismatch error rather than a + confusing runtime failure. + +## Publishing a plugin + +A publisher's own CI (in the plugin's own repository) builds and releases +the `.wasm` artifact and attests it with `actions/attest` (the same GitHub +Action already used by this repo's own release workflow, see +`.github/workflows/release.yml`). Getting an entry added to `index.json` +here is a normal PR against this repository, adding the plugin's +`name`/`versions[]` entry with the release's real `download_url` and +`sha256`. diff --git a/plugins/registry/index.json b/plugins/registry/index.json new file mode 100644 index 000000000..ea952a7be --- /dev/null +++ b/plugins/registry/index.json @@ -0,0 +1,4 @@ +{ + "schema_version": 1, + "plugins": [] +} diff --git a/sdk/auth-websso/Cargo.toml b/sdk/auth-websso/Cargo.toml index a8415a0aa..d2153d342 100644 --- a/sdk/auth-websso/Cargo.toml +++ b/sdk/auth-websso/Cargo.toml @@ -11,35 +11,16 @@ repository.workspace = true [dependencies] async-trait.workspace = true -bytes.workspace = true dialoguer.workspace = true -form_urlencoded.workspace = true -futures.workspace = true -futures-util.workspace = true -http.workspace = true -http-body-util.workspace = true -hyper = { workspace = true, features = ["full"] } -hyper-util = { workspace = true, features = ["full"] } inventory.workspace = true -open.workspace = true openstack-sdk-auth-core.workspace = true +openstack-sdk-websso-host.workspace = true secrecy.workspace = true serde.workspace = true serde_json.workspace = true -serde_urlencoded.workspace = true reqwest = { workspace = true, features = ["form"] } thiserror.workspace = true -tokio = { workspace = true, features = ["signal"] } -tokio-util.workspace = true -tracing.workspace = true url.workspace = true -[dev-dependencies] -httpmock.workspace = true -reserve-port.workspace = true -tempfile.workspace = true -tracing-test.workspace = true -tokio = { workspace = true, features = ["macros", "rt-multi-thread"] } - [lints] workspace = true diff --git a/sdk/auth-websso/src/lib.rs b/sdk/auth-websso/src/lib.rs index 3a719a103..f36d36035 100644 --- a/sdk/auth-websso/src/lib.rs +++ b/sdk/auth-websso/src/lib.rs @@ -25,26 +25,12 @@ //! 4. Receive the Keystone token via the callback server //! 5. Return the authenticated token -use std::collections::HashMap; -use std::convert::Infallible; -use std::sync::{Arc, Mutex}; use std::time::Duration; use async_trait::async_trait; -use bytes::Bytes; -use futures::io::Error as IoError; -use http_body_util::{BodyExt, Empty, Full, combinators::BoxBody}; -use hyper::server::conn::http1; -use hyper::service::service_fn; -use hyper::{Method, Request, Response, StatusCode, body::Incoming as IncomingBody}; -use hyper_util::rt::TokioIo; use secrecy::{ExposeSecret, SecretString}; use serde_json::{Value, json}; use thiserror::Error; -use tokio::net::TcpListener; -use tokio::signal; -use tokio_util::sync::CancellationToken; -use tracing::{error, info, trace, warn}; use url::Url; use dialoguer::Confirm; @@ -53,6 +39,7 @@ use openstack_sdk_auth_core::{ Auth, AuthError, AuthPluginRegistration, AuthToken, AuthTokenError, AuthTokenScope, OpenStackAuthType, }; +use openstack_sdk_websso_host::{BrowserOpenPolicy, CallbackServer, WebssoHostError}; /// WebSSO authentication for OpenStack SDK. /// @@ -154,52 +141,19 @@ pub enum WebSsoError { source: dialoguer::Error, }, - /// Http error. - #[error("http server error: {}", source)] - Http { - /// The source of the error. - #[from] - source: http::Error, - }, - - /// Hyper error. - #[error("hyper (http server) error: {}", source)] - Hyper { - /// The source of the error. - #[from] - source: hyper::Error, - }, - - /// IO communication error. - #[error("`IO` error: {}", source)] - IO { + /// Error from the shared WebSSO callback host service (binding the + /// callback listener, serving/validating the callback, or opening the + /// browser). + #[error("WebSSO host service error: {}", source)] + Host { /// The error source. #[from] - source: IoError, + source: WebssoHostError, }, - /// Thread join error. - #[error("`Join` error: {}", source)] - Join { - /// The error source. - #[from] - source: tokio::task::JoinError, - }, - - /// Auth data is missing. - #[error("Auth data is missing")] - MissingAuthData, - /// Protocol is missing. #[error("Federation protocol information is missing")] MissingProtocol, - - /// Poisoned guard lock in the internal processing. - #[error("internal error: poisoned lock: {}", context)] - PoisonedLock { - /// The source of the error. - context: String, - }, } impl From for AuthError { @@ -221,275 +175,32 @@ pub async fn get_token_auth( // Perform WebSSO by opening a browser window with tiny webserver started to capture the callback /// -/// - start callback server +/// - bind the callback server (host-generated anti-CSRF `state` embedded in +/// its URL) /// - open browser pointing to the SSO url /// - wait for the response with the OpenStack token async fn get_token(url: &mut Url, callback_port: Option) -> Result { - let port = callback_port.unwrap_or(8050); - let listener = TcpListener::bind(format!("127.0.0.1:{}", port)).await?; - let addr = listener - .local_addr() - .map_err(|_| WebSsoError::MissingAuthData)?; - url.set_query(Some( - format!("origin=http://localhost:{}/callback", addr.port()).as_str(), - )); + let server = CallbackServer::bind(callback_port).await?; + url.set_query(Some(format!("origin={}", server.callback_url()).as_str())); let confirmation = Confirm::new() .with_prompt(format!( "A default browser is going to be opened at `{}`. Do you want to continue?", url.as_str() )) .interact()?; - if confirmation { - info!("Opening browser at {:?}", url.as_str()); - let cancel_token = CancellationToken::new(); - let state: Arc>> = Arc::new(Mutex::new(None)); - - tokio::spawn({ - let cancel_token = cancel_token.clone(); - async move { - if let Ok(()) = signal::ctrl_c().await { - info!("received Ctrl-C, shutting down"); - cancel_token.cancel(); - } - } - }); - - let websso_handle = tokio::spawn({ - let cancel_token = cancel_token.clone(); - let state = state.clone(); - async move { websso_callback_server(listener, state, cancel_token, None).await } - }); - open::that(url.as_str())?; - - let _res = websso_handle.await?; - - let guard = state.lock().map_err(|_| WebSsoError::PoisonedLock { - context: "locking WebSSO authentication state".to_string(), - })?; - guard.clone().ok_or(WebSsoError::CallbackNoToken) - } else { - Err(WebSsoError::CallbackFailed) - } -} - -/// Start the WebSSO callback server on a pre-bound listener -async fn websso_callback_server( - listener: TcpListener, - state: Arc>>, - cancel_token: CancellationToken, - start_tx: Option>, -) -> Result<(), WebSsoError> { - let addr = listener - .local_addr() - .map_err(|_| WebSsoError::MissingAuthData)?; - info!("Starting webserver to receive SSO callback on {}", addr); - if let Some(tx) = start_tx { - let _ = tx.send(()); - } - // Wait maximum 2 minute for auth processing - let webserver_timeout = Duration::from_secs(120); - loop { - let state_clone = state.clone(); - - tokio::select! { - Ok((stream, _addr)) = listener.accept() => { - let io = TokioIo::new(stream); - let cancel_token_srv = cancel_token.clone(); - let cancel_token_conn = cancel_token.clone(); - - let service = service_fn(move |req| { - let state_clone = state_clone.clone(); - let cancel_token = cancel_token_srv.clone(); - handle_request(req, state_clone, cancel_token) - }); - - tokio::task::spawn(async move { - let cancel_token = cancel_token_conn.clone(); - if let Err(err) = http1::Builder::new().serve_connection(io, service).await { - error!("Failed to serve connection: {:?}", err); - cancel_token.cancel(); - } - }); - }, - _ = cancel_token.cancelled() => { - info!("Stopping webserver"); - break; - }, - _ = tokio::time::sleep(webserver_timeout) => { - warn!("Timeout of {} sec waiting for authentication expired. Shutting down", webserver_timeout.as_secs()); - cancel_token.cancel(); - } - } - } - Ok(()) -} - -/// Server request handler function -async fn handle_request( - req: Request, - state: Arc>>, - cancel_token: CancellationToken, - //) -> Result>, hyper::Error> { -) -> Result>, WebSsoError> { - match (req.method(), req.uri().path()) { - (&Method::POST, "/callback") => { - let b = req.collect().await?.to_bytes(); - trace!("Body is {:?}", b); - let params = form_urlencoded::parse(b.as_ref()) - .into_owned() - .collect::>(); - trace!("Params = {:?}", params); - - let mut data = state.lock().map_err(|_| WebSsoError::PoisonedLock { - context: "locking WebSSO authentication state".to_string(), - })?; - if let Some(token) = params.get("token") { - *data = Some(token.clone()); - } - cancel_token.cancel(); - - Ok(Response::builder() - .body(Full::new(Bytes::from(include_str!("../static/callback.html"))).boxed())?) - } - _ => { - // Return 404 not found response. - Ok(Response::builder() - .status(StatusCode::NOT_FOUND) - .body(Empty::::new().boxed())?) - } - } -} - -#[cfg(test)] -mod tests { - use std::sync::{Arc, Mutex}; - use tokio::net::TcpListener; - use tokio::signal; - use tokio_util::sync::CancellationToken; - use tracing::{info, warn}; - - use super::WebSsoError; - use super::handle_request; - - /// Test-only variant that accepts a pre-bound listener to avoid port reservation races - async fn websso_callback_server_test( - listener: TcpListener, - state: Arc>>, - cancel_token: CancellationToken, - ) -> Result<(), WebSsoError> { - use hyper::server::conn::http1; - use hyper::service::service_fn; - - use hyper_util::rt::TokioIo; - use tracing::error; - - info!("Starting webserver to receive SSO callback"); - let webserver_timeout = std::time::Duration::from_secs(120); - loop { - let state_clone = state.clone(); - - tokio::select! { - Ok((stream, _addr)) = listener.accept() => { - let io = TokioIo::new(stream); - let cancel_token_srv = cancel_token.clone(); - let cancel_token_conn = cancel_token.clone(); - - let service = service_fn(move |req| { - let state_clone = state_clone.clone(); - let cancel_token = cancel_token_srv.clone(); - handle_request(req, state_clone, cancel_token) - }); - - tokio::task::spawn(async move { - let cancel_token = cancel_token_conn.clone(); - if let Err(err) = http1::Builder::new().serve_connection(io, service).await { - error!("Failed to serve connection: {:?}", err); - cancel_token.cancel(); - } - }); - }, - _ = cancel_token.cancelled() => { - info!("Stopping webserver"); - break; - }, - _ = tokio::time::sleep(webserver_timeout) => { - warn!("Timeout of {} sec waiting for authentication expired. Shutting down", webserver_timeout.as_secs()); - cancel_token.cancel(); - } - } - } - Ok(()) - } - - #[tokio::test] - async fn test_callback() { - let listener = TcpListener::bind("127.0.0.1:0") - .await - .expect("port available"); - let addr = listener.local_addr().expect("listener address"); - let cancel_token = CancellationToken::new(); - - tokio::spawn({ - let cancel_token = cancel_token.clone(); - async move { - if let Ok(()) = signal::ctrl_c().await { - cancel_token.cancel(); - } - } - }); - - let state = Arc::new(Mutex::new(None)); - let websso_handle = tokio::spawn({ - let cancel_token = cancel_token.clone(); - let state = state.clone(); - async move { websso_callback_server_test(listener, state, cancel_token).await } - }); - - let params = [("token", "foo_bar_baz")]; - let client = reqwest::Client::new(); - client - .post(format!("http://localhost:{}/callback", addr.port())) - .form(¶ms) - .send() - .await - .unwrap(); - - websso_handle.await.unwrap().unwrap(); - assert_eq!(*state.lock().unwrap(), Some(params[0].1.into())); - } - - #[tokio::test] - async fn test_callback_no_token() { - let listener = TcpListener::bind("127.0.0.1:0") - .await - .expect("port available"); - let addr = listener.local_addr().expect("listener address"); - let cancel_token = CancellationToken::new(); - - tokio::spawn({ - let cancel_token = cancel_token.clone(); - async move { - if let Ok(()) = signal::ctrl_c().await { - cancel_token.cancel(); - } - } - }); - - let state = Arc::new(Mutex::new(None)); - let websso_handle = tokio::spawn({ - let cancel_token = cancel_token.clone(); - let state = state.clone(); - async move { websso_callback_server_test(listener, state, cancel_token).await } - }); - - let client = reqwest::Client::new(); - client - .post(format!("http://localhost:{}/callback", addr.port())) - .send() - .await - .unwrap(); - - websso_handle.await.unwrap().unwrap(); - assert_eq!(*state.lock().unwrap(), None); + if !confirmation { + return Err(WebSsoError::CallbackFailed); } + // `require_https: false`: the Keystone identity endpoint this URL is + // built from may legitimately be plain `http://` in a local/dev + // deployment, unlike the WASM SSO plugin ABI which has no such + // grandfathered use case and always requires `https://`. + openstack_sdk_websso_host::open_browser( + url, + BrowserOpenPolicy { + require_https: false, + }, + )?; + let mut params = server.wait_for_callback(Duration::from_secs(120)).await?; + params.remove("token").ok_or(WebSsoError::CallbackNoToken) } diff --git a/sdk/plugin-wasm/Cargo.toml b/sdk/plugin-wasm/Cargo.toml index e86ecb6fa..5ba5798fc 100644 --- a/sdk/plugin-wasm/Cargo.toml +++ b/sdk/plugin-wasm/Cargo.toml @@ -13,12 +13,17 @@ repository.workspace = true [dependencies] async-trait.workspace = true +base64.workspace = true chrono.workspace = true +dialoguer.workspace = true dirs.workspace = true extism.workspace = true openstack-sdk-auth-core.workspace = true -reqwest = { workspace = true, features = ["rustls", "blocking"] } +openstack-sdk-websso-host.workspace = true +reqwest = { workspace = true, features = ["rustls", "blocking", "json"] } +ring.workspace = true secrecy.workspace = true +semver.workspace = true serde.workspace = true serde_json.workspace = true sha2.workspace = true @@ -26,11 +31,17 @@ thiserror.workspace = true tokio = { workspace = true, features = ["rt", "sync"] } tracing.workspace = true url.workspace = true +x509-parser = { workspace = true, features = ["verify"] } [dev-dependencies] httpmock.workspace = true +rcgen.workspace = true +reqwest = { workspace = true, features = ["form"] } tempfile.workspace = true tokio = { workspace = true, features = ["macros", "rt-multi-thread"] } +[features] +fuzzing = [] + [lints] workspace = true diff --git a/sdk/plugin-wasm/fixtures/example-sso-plugin/Cargo.lock b/sdk/plugin-wasm/fixtures/example-sso-plugin/Cargo.lock new file mode 100644 index 000000000..f06fa979c --- /dev/null +++ b/sdk/plugin-wasm/fixtures/example-sso-plugin/Cargo.lock @@ -0,0 +1,663 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "either" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "example-wasm-sso-plugin" +version = "0.1.0" +dependencies = [ + "extism-pdk", + "serde_json", + "url", +] + +[[package]] +name = "extism-convert" +version = "1.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad19858c4c462309a8f3a20abec53e8603bda1eefda26c8bfab51d5516b40cbb" +dependencies = [ + "anyhow", + "base64", + "bytemuck", + "extism-convert-macros", + "prost", + "rmp-serde", + "serde", + "serde_json", +] + +[[package]] +name = "extism-convert-macros" +version = "1.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f2932799f6d9f9646f97b65287f6bb2addc75a0ee61e40fb24559a7540dd928" +dependencies = [ + "manyhow", + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "extism-manifest" +version = "1.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2f59c8dadb5e0bde9a48c6ed45312e6ef625cbcd5f67c28459dbc8fe8bc0383" +dependencies = [ + "base64", + "serde", + "serde_json", +] + +[[package]] +name = "extism-pdk" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "352fcb5a66eb74145a1c4a01f2bd15d59c62c85be73aac8471880c65b26b798f" +dependencies = [ + "anyhow", + "base64", + "extism-convert", + "extism-manifest", + "extism-pdk-derive", + "serde", + "serde_json", +] + +[[package]] +name = "extism-pdk-derive" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d086daea5fd844e3c5ac69ddfe36df4a9a43e7218cf7d1f888182b089b09806c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[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 = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "manyhow" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b33efb3ca6d3b07393750d4030418d594ab1139cee518f0dc88db70fec873587" +dependencies = [ + "manyhow-macros", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "manyhow-macros" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46fce34d199b78b6e6073abf984c9cf5fd3e9330145a93ee0738a7443e371495" +dependencies = [ + "proc-macro-utils", + "proc-macro2", + "quote", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit", +] + +[[package]] +name = "proc-macro-utils" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eeaf08a13de400bc215877b5bdc088f241b12eb42f0a548d3390dc1c56bb7071" +dependencies = [ + "proc-macro2", + "quote", + "smallvec", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "prost" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "528ac67416ff8646872a3c02cad9cc4ee5dc9f9540c9b10771855c95cb2e5ae1" +dependencies = [ + "bytes", + "prost-derive", +] + +[[package]] +name = "prost-derive" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b570b25f7617e43d59005d0990ccb79e950a423952cea19671b7a876da390adf" +dependencies = [ + "anyhow", + "itertools", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rmp" +version = "0.8.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ba8be72d372b2c9b35542551678538b562e7cf86c3315773cae48dfbfe7790c" +dependencies = [ + "num-traits", +] + +[[package]] +name = "rmp-serde" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f81bee8c8ef9b577d1681a70ebbc962c232461e397b22c208c43c04b67a155" +dependencies = [ + "rmp", + "serde", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[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.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "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 2.0.119", +] + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.25.13+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +dependencies = [ + "winnow", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[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 = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +dependencies = [ + "memchr", +] + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[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 2.0.119", + "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 2.0.119", + "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 2.0.119", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/sdk/plugin-wasm/fixtures/example-sso-plugin/Cargo.toml b/sdk/plugin-wasm/fixtures/example-sso-plugin/Cargo.toml new file mode 100644 index 000000000..8fe9e399f --- /dev/null +++ b/sdk/plugin-wasm/fixtures/example-sso-plugin/Cargo.toml @@ -0,0 +1,16 @@ +[workspace] + +[package] +name = "example-wasm-sso-plugin" +description = "Example SSO ABI v1 plugin used by openstack-sdk-plugin-wasm's tests" +version = "0.1.0" +edition = "2021" +publish = false + +[lib] +crate-type = ["cdylib"] + +[dependencies] +extism-pdk = "1.4" +serde_json = "1" +url = "2" diff --git a/sdk/plugin-wasm/fixtures/example-sso-plugin/src/lib.rs b/sdk/plugin-wasm/fixtures/example-sso-plugin/src/lib.rs new file mode 100644 index 000000000..d333ad930 --- /dev/null +++ b/sdk/plugin-wasm/fixtures/example-sso-plugin/src/lib.rs @@ -0,0 +1,115 @@ +#![no_main] + +//! Example SSO ABI v1 plugin, used as a test fixture by +//! `openstack-sdk-plugin-wasm`'s integration tests. It implements a toy +//! browser-based `v3examplesso` method: `sso_build_request` points the +//! browser at a fake identity-provider authorize page carrying the +//! host-provided callback URL as its `redirect_uri`, and +//! `sso_parse_callback` reads the `token` field out of whatever the +//! callback POST carried. +//! +//! Neither export performs any I/O — the guest sandbox forbids sockets +//! entirely (`Manifest::disallow_all_hosts`), so this is enforced +//! structurally, not just by convention. +//! +//! A `mode` value (`values.mode`) lets the same fixture also exercise the +//! host's SSO security checks: +//! - `"bad_scheme"` — returns a plain `http://` url (must be rejected before +//! any browser is opened). +//! - `"bad_host"` — returns a `redirect_host` that doesn't match the +//! callback URL the host handed in (must be rejected before any browser +//! is opened). +//! - anything else (including absent) — well-behaved. + +use extism_pdk::*; +use serde_json::{Value, json}; + +#[plugin_fn] +pub fn plugin_abi_version(_input: String) -> FnResult { + Ok("1".to_string()) +} + +#[plugin_fn] +pub fn auth_supported_methods(_input: String) -> FnResult { + Ok(json!(["v3examplesso"]).to_string()) +} + +#[plugin_fn] +pub fn auth_api_version(_input: String) -> FnResult { + Ok(json!([3, 0]).to_string()) +} + +#[plugin_fn] +pub fn auth_requirements(_input: String) -> FnResult { + Ok(json!({ + "type": "object", + "properties": {} + }) + .to_string()) +} + +#[plugin_fn] +pub fn sso_build_request(input: String) -> FnResult { + let request: Value = serde_json::from_str(&input)?; + let callback_url = request + .get("callback_url") + .and_then(|v| v.as_str()) + .unwrap_or_default(); + let mode = request + .get("values") + .and_then(|v| v.get("mode")) + .and_then(|v| v.as_str()) + .unwrap_or(""); + + let parsed = url::Url::parse(callback_url) + .map_err(|e| Error::msg(format!("invalid callback_url: {e}")))?; + let real_redirect_host = match parsed.port() { + Some(port) => format!("{}:{port}", parsed.host_str().unwrap_or("")), + None => parsed.host_str().unwrap_or("").to_string(), + }; + + let (scheme, redirect_host) = match mode { + "bad_scheme" => ("http", real_redirect_host.as_str()), + "bad_host" => ("https", "evil.example.test:1"), + _ => ("https", real_redirect_host.as_str()), + }; + + let url = format!( + "{scheme}://idp.example.test/authorize?client_id=demo&redirect_uri={}", + urlencode(callback_url) + ); + + Ok(json!({"url": url, "redirect_host": redirect_host}).to_string()) +} + +#[plugin_fn] +pub fn sso_parse_callback(input: String) -> FnResult { + let request: Value = serde_json::from_str(&input)?; + let token = request + .get("params") + .and_then(|p| p.get("token")) + .and_then(|v| v.as_str()) + .unwrap_or_default(); + + if token.is_empty() { + return Ok(json!({"error": "callback didn't carry a token"}).to_string()); + } + + Ok(json!({"ok": {"token": token, "auth_info": null}}).to_string()) +} + +/// Minimal query-value percent-encoding, just enough for the test fixture's +/// own callback URL (which itself only ever contains `[A-Za-z0-9:/._-]` plus +/// `?`, `=`, `&`) — not a general-purpose encoder. +fn urlencode(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + for b in s.bytes() { + match b { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { + out.push(b as char) + } + _ => out.push_str(&format!("%{b:02X}")), + } + } + out +} diff --git a/sdk/plugin-wasm/src/error.rs b/sdk/plugin-wasm/src/error.rs index abf444743..f09ec7fef 100644 --- a/sdk/plugin-wasm/src/error.rs +++ b/sdk/plugin-wasm/src/error.rs @@ -157,4 +157,131 @@ pub enum WasmPluginError { /// versions of `name`". version: Option, }, + + /// The registry index could not be fetched. + #[error("failed to fetch plugin registry index from {url}: {source}")] + RegistryFetch { + /// Registry index URL. + url: String, + /// The underlying HTTP error. + #[source] + source: reqwest::Error, + }, + + /// The registry index was fetched but could not be parsed, or declared + /// an unsupported `schema_version`. + #[error("malformed plugin registry index at {url}: {reason}")] + RegistryFormat { + /// Registry index URL. + url: String, + /// Human readable reason. + reason: String, + }, + + /// A downloaded plugin's content does not match the sha256 declared for + /// it in the registry index. + #[error( + "downloaded plugin {name}@{version} failed checksum verification: expected sha256 {expected}, got {actual}" + )] + ChecksumMismatch { + /// Plugin name. + name: String, + /// Plugin version. + version: String, + /// The sha256 declared in the registry index. + expected: String, + /// The sha256 computed from the downloaded bytes. + actual: String, + }, + + /// The requested `name`/`version` is not present in the registry index. + #[error("no plugin matches {name}{} in the registry index", version.as_ref().map(|v| format!("@{v}")).unwrap_or_default())] + NotInIndex { + /// Plugin name. + name: String, + /// Specific version requested, if any. + version: Option, + }, + + /// Install/update was refused because the plugin's provenance could not + /// be verified and `--allow-unsigned` was not given. + #[error( + "refusing to install {name}@{version} without provenance verification: {reason} (pass --allow-unsigned to override)" + )] + Untrusted { + /// Plugin name. + name: String, + /// Plugin version. + version: String, + /// Human readable reason verification did not succeed. + reason: String, + }, + + /// The GitHub attestations API could not be reached or returned an + /// unexpected response. + #[error("failed to fetch attestations for {owner}/{repo}: {source}")] + AttestationFetch { + /// Repository owner. + owner: String, + /// Repository name. + repo: String, + /// The underlying HTTP error. + #[source] + source: reqwest::Error, + }, + + /// A fetched attestation bundle failed cryptographic or identity + /// verification. + #[error("attestation verification failed: {reason}")] + AttestationVerification { + /// Human readable reason. + reason: String, + }, + + /// The shared WebSSO host service (callback listener, CSRF check, or + /// browser-opening step) reported an error while running the SSO ABI + /// flow. + #[error("plugin {name} SSO flow failed: {source}")] + Host { + /// Plugin name. + name: String, + /// The underlying host-service error. + #[source] + source: openstack_sdk_websso_host::WebssoHostError, + }, + + /// Error using the interactive confirmation prompt during the SSO flow. + #[error("error using the dialoguer: {}", source)] + Dialoguer { + /// The error source. + #[from] + source: dialoguer::Error, + }, + + /// `sso_build_request` returned a URL that failed host-side validation + /// (unparsable, or not `https://`). + #[error("plugin {name} `sso_build_request` returned an invalid redirect: {reason}")] + InvalidRedirect { + /// Plugin name. + name: String, + /// Human readable reason. + reason: String, + }, + + /// `sso_build_request`'s declared `redirect_host` didn't match the + /// host-bound callback listener's own authority — the plugin tried to + /// point the identity provider's redirect somewhere the host never + /// bound a listener on. Always rejected; there is no override. + #[error( + "plugin {name} declared SSO redirect host `{declared}` but the host-bound callback listener is `{expected}`; refusing to open the browser" + )] + RedirectHostMismatch { + /// Plugin name. + name: String, + /// The `redirect_host` the plugin's `sso_build_request` response + /// declared. + declared: String, + /// The actual authority of the host-bound callback listener. + expected: String, + }, } diff --git a/sdk/plugin-wasm/src/host.rs b/sdk/plugin-wasm/src/host.rs index c7fc5c1c1..caf2d2bb1 100644 --- a/sdk/plugin-wasm/src/host.rs +++ b/sdk/plugin-wasm/src/host.rs @@ -56,15 +56,46 @@ struct HttpResponseMsg { body: String, } -extism::host_fn!(pub(crate) identity_http_request(ctx: HostContextState; request: String) -> String { - let req: HttpRequestMsg = serde_json::from_str(&request) - .map_err(|e| extism::Error::msg(format!("invalid identity_http_request payload: {e}")))?; - +/// Validate a guest-supplied request against `origin` and turn it into a +/// concrete URL + method, without performing any I/O. This is the entire +/// part of `identity_http_request`'s handling of untrusted guest bytes that +/// doesn't require a live [`HostContextState`], split out so it can be +/// exercised directly (including by the `fuzzing`-feature entry point below) +/// without a real WASM call or network access. +fn resolve_request( + origin: &url::Url, + req: &HttpRequestMsg, +) -> Result<(url::Url, reqwest::Method), extism::Error> { if !req.path.starts_with('/') { return Err(extism::Error::msg( "identity_http_request: `path` must be relative to the identity endpoint (start with '/')", )); } + let url = origin + .join(&req.path) + .map_err(|e| extism::Error::msg(format!("identity_http_request: invalid path: {e}")))?; + let method = reqwest::Method::from_bytes(req.method.as_bytes()) + .map_err(|e| extism::Error::msg(format!("identity_http_request: invalid method: {e}")))?; + Ok((url, method)) +} + +/// Fuzz target entry point for the otherwise-private [`resolve_request`], +/// the part of `identity_http_request` that parses and validates raw bytes +/// a guest module controls (Extism's guest-to-host call boundary), without +/// making any network call. +/// +/// Only compiled with the `fuzzing` feature; not part of the stable public +/// API. +#[cfg(feature = "fuzzing")] +pub fn fuzz_identity_http_request_parsing(origin: &url::Url, request: &str) { + if let Ok(req) = serde_json::from_str::(request) { + let _ = resolve_request(origin, &req); + } +} + +extism::host_fn!(pub(crate) identity_http_request(ctx: HostContextState; request: String) -> String { + let req: HttpRequestMsg = serde_json::from_str(&request) + .map_err(|e| extism::Error::msg(format!("invalid identity_http_request payload: {e}")))?; let state = ctx.get()?; let state = state @@ -79,11 +110,7 @@ extism::host_fn!(pub(crate) identity_http_request(ctx: HostContextState; request .as_ref() .ok_or_else(|| extism::Error::msg("identity_http_request: no http client bound to this call"))?; - let url = origin - .join(&req.path) - .map_err(|e| extism::Error::msg(format!("identity_http_request: invalid path: {e}")))?; - let method = reqwest::Method::from_bytes(req.method.as_bytes()) - .map_err(|e| extism::Error::msg(format!("identity_http_request: invalid method: {e}")))?; + let (url, method) = resolve_request(origin, &req)?; let mut builder = client.request(method, url); for (k, v) in &req.headers { diff --git a/sdk/plugin-wasm/src/index.rs b/sdk/plugin-wasm/src/index.rs new file mode 100644 index 000000000..5f0413369 --- /dev/null +++ b/sdk/plugin-wasm/src/index.rs @@ -0,0 +1,405 @@ +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 + +//! Client for the HTTPS-hosted plugin registry index (`index.json`), and the +//! download step that turns a resolved [`IndexVersion`] into checksummed +//! bytes ready for [`crate::registry::plan_remote_install`]. +//! +//! The default index lives in this repository at +//! `plugins/registry/index.json`, served over plain HTTPS via +//! `raw.githubusercontent.com`. Nothing about the schema or this client is +//! tied to that location: any URL matching the schema documented in +//! `plugins/registry/README.md` works, and `--registry-url` on the CLI lets +//! a different one be used explicitly. The default is never silently +//! overridden by an environment variable — only an explicit flag changes it, +//! so a plugin never ends up resolved against an unexpected index by +//! accident. + +use std::time::Duration; + +use serde::Deserialize; +use url::Url; + +use crate::error::WasmPluginError; + +/// The registry index consulted when no `--registry-url` is given. +pub const DEFAULT_REGISTRY_URL: &str = + "https://raw.githubusercontent.com/gtema/openstack/main/plugins/registry/index.json"; + +/// The only `schema_version` this client understands. +const SUPPORTED_SCHEMA_VERSION: u32 = 1; + +/// The full registry index. +#[derive(Clone, Debug, Deserialize)] +pub struct PluginIndex { + /// Schema version of this index document. + pub schema_version: u32, + /// Published plugins. + #[serde(default)] + pub plugins: Vec, +} + +/// A single plugin's registry entry: its name and every version published +/// for it. +#[derive(Clone, Debug, Deserialize)] +pub struct IndexEntry { + /// Plugin name. + pub name: String, + /// Human readable one-line description. + #[serde(default)] + pub description: String, + /// Published versions. + pub versions: Vec, +} + +/// A single published version of a plugin. +#[derive(Clone, Debug, Deserialize)] +pub struct IndexVersion { + /// Version string. Compared as [`semver::Version`] when possible for + /// "latest" resolution, falling back to exact string match otherwise. + pub version: String, + /// URL the `.wasm` artifact is downloaded from. + pub download_url: Url, + /// Lowercase hex-encoded sha256 the downloaded bytes must match before + /// anything is written to disk. + pub sha256: String, + /// The `owner/repo` GitHub repository whose CI is expected to have + /// published (and attested) this artifact. + pub source_repo: String, + /// Informational: the guest ABI version this build declares. + #[serde(default)] + pub abi_version: Option, + /// Minimum `osc` version required to install this version, if any. + #[serde(default)] + pub min_cli_version: Option, +} + +/// Build a `reqwest::Client` suitable for registry/attestation fetches: +/// rustls, a bounded timeout, and an identifying user agent. Shared by +/// [`fetch_index`]/[`download`] and `crate::provenance`. +pub fn http_client() -> Result { + reqwest::Client::builder() + .timeout(Duration::from_secs(15)) + .user_agent(concat!("osc-plugin-manager/", env!("CARGO_PKG_VERSION"))) + .build() + .map_err(|source| WasmPluginError::RegistryFetch { + url: "".into(), + source, + }) +} + +/// Fetch and parse the registry index at `url`. +pub async fn fetch_index( + url: &str, + client: &reqwest::Client, +) -> Result { + let response = client + .get(url) + .send() + .await + .map_err(|source| WasmPluginError::RegistryFetch { + url: url.to_string(), + source, + })? + .error_for_status() + .map_err(|source| WasmPluginError::RegistryFetch { + url: url.to_string(), + source, + })?; + let bytes = response + .bytes() + .await + .map_err(|source| WasmPluginError::RegistryFetch { + url: url.to_string(), + source, + })?; + let index: PluginIndex = + serde_json::from_slice(&bytes).map_err(|e| WasmPluginError::RegistryFormat { + url: url.to_string(), + reason: e.to_string(), + })?; + if index.schema_version != SUPPORTED_SCHEMA_VERSION { + return Err(WasmPluginError::RegistryFormat { + url: url.to_string(), + reason: format!( + "unsupported schema_version {} (expected {SUPPORTED_SCHEMA_VERSION})", + index.schema_version + ), + }); + } + Ok(index) +} + +/// Entries whose name or description contains `query` (case-insensitive), +/// or every entry when `query` is `None`. +pub fn search<'a>(index: &'a PluginIndex, query: Option<&str>) -> Vec<&'a IndexEntry> { + match query { + None => index.plugins.iter().collect(), + Some(q) => { + let q = q.to_lowercase(); + index + .plugins + .iter() + .filter(|e| { + e.name.to_lowercase().contains(&q) || e.description.to_lowercase().contains(&q) + }) + .collect() + } + } +} + +/// Resolve `version` (or, when `None`, the highest by semver, falling back +/// to the lexicographically greatest version string when any published +/// version doesn't parse as semver) within `entry`. +pub fn resolve_version<'a>( + entry: &'a IndexEntry, + version: Option<&str>, +) -> Result<&'a IndexVersion, WasmPluginError> { + match version { + Some(v) => entry + .versions + .iter() + .find(|iv| iv.version == v) + .ok_or_else(|| WasmPluginError::NotInIndex { + name: entry.name.clone(), + version: Some(v.to_string()), + }), + None => { + if entry.versions.is_empty() { + return Err(WasmPluginError::NotInIndex { + name: entry.name.clone(), + version: None, + }); + } + let all_semver: Option> = entry + .versions + .iter() + .map(|iv| semver::Version::parse(&iv.version).ok().map(|sv| (sv, iv))) + .collect(); + let latest = match all_semver { + Some(mut parsed) => { + parsed.sort_by(|a, b| a.0.cmp(&b.0)); + parsed.pop().map(|(_, iv)| iv) + } + None => entry + .versions + .iter() + .max_by(|a, b| a.version.cmp(&b.version)), + }; + latest.ok_or_else(|| WasmPluginError::NotInIndex { + name: entry.name.clone(), + version: None, + }) + } + } +} + +/// Download `version`'s artifact and verify its sha256 matches what the +/// index declared, before returning the bytes to the caller. Nothing is +/// written to disk here or by any caller before this check passes. +pub async fn download( + entry_name: &str, + version: &IndexVersion, + client: &reqwest::Client, +) -> Result, WasmPluginError> { + let response = client + .get(version.download_url.clone()) + .send() + .await + .map_err(|source| WasmPluginError::RegistryFetch { + url: version.download_url.to_string(), + source, + })? + .error_for_status() + .map_err(|source| WasmPluginError::RegistryFetch { + url: version.download_url.to_string(), + source, + })?; + let bytes = response + .bytes() + .await + .map_err(|source| WasmPluginError::RegistryFetch { + url: version.download_url.to_string(), + source, + })? + .to_vec(); + + let actual = sha256_hex_bytes(&bytes); + if actual != version.sha256.to_lowercase() { + return Err(WasmPluginError::ChecksumMismatch { + name: entry_name.to_string(), + version: version.version.clone(), + expected: version.sha256.clone(), + actual, + }); + } + Ok(bytes) +} + +/// Lowercase hex-encoded sha256 of an in-memory byte slice. Mirrors +/// [`crate::lockfile::sha256_hex`]'s streaming file-based variant for bytes +/// that are already in memory (downloaded content, not yet written to disk). +fn sha256_hex_bytes(bytes: &[u8]) -> String { + use sha2::{Digest, Sha256}; + let digest = Sha256::digest(bytes); + digest.iter().map(|b| format!("{b:02x}")).collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sample_index() -> PluginIndex { + PluginIndex { + schema_version: 1, + plugins: vec![IndexEntry { + name: "example_auth".into(), + description: "Example auth plugin".into(), + versions: vec![ + IndexVersion { + version: "1.0.0".into(), + download_url: "https://example.invalid/v1.wasm".parse().unwrap(), + sha256: "aaaa".into(), + source_repo: "gtema/example-auth-plugin".into(), + abi_version: Some("1".into()), + min_cli_version: None, + }, + IndexVersion { + version: "1.2.0".into(), + download_url: "https://example.invalid/v1.2.wasm".parse().unwrap(), + sha256: "bbbb".into(), + source_repo: "gtema/example-auth-plugin".into(), + abi_version: Some("1".into()), + min_cli_version: None, + }, + ], + }], + } + } + + #[test] + fn search_matches_name_and_description_case_insensitively() { + let index = sample_index(); + assert_eq!(search(&index, None).len(), 1); + assert_eq!(search(&index, Some("EXAMPLE")).len(), 1); + assert_eq!(search(&index, Some("nope")).len(), 0); + } + + #[test] + fn resolve_version_picks_highest_semver_by_default() -> Result<(), Box> { + let index = sample_index(); + let entry = &index.plugins[0]; + let latest = resolve_version(entry, None)?; + assert_eq!(latest.version, "1.2.0"); + let exact = resolve_version(entry, Some("1.0.0"))?; + assert_eq!(exact.sha256, "aaaa"); + assert!(resolve_version(entry, Some("9.9.9")).is_err()); + Ok(()) + } + + #[tokio::test(flavor = "multi_thread")] + async fn fetch_index_parses_a_served_document() -> Result<(), Box> { + let server = httpmock::MockServer::start(); + let mock = server.mock(|when, then| { + when.method(httpmock::Method::GET).path("/index.json"); + then.status(200).json_body(serde_json::json!({ + "schema_version": 1, + "plugins": [ + { + "name": "example_auth", + "description": "Example auth plugin", + "versions": [ + { + "version": "1.0.0", + "download_url": "https://example.invalid/v1.wasm", + "sha256": "aaaa", + "source_repo": "gtema/example-auth-plugin" + } + ] + } + ] + })); + }); + let client = http_client()?; + let index = fetch_index(&format!("{}/index.json", server.base_url()), &client).await?; + mock.assert(); + assert_eq!(index.plugins.len(), 1); + assert_eq!(index.plugins[0].name, "example_auth"); + Ok(()) + } + + #[tokio::test(flavor = "multi_thread")] + async fn fetch_index_rejects_unsupported_schema_version() { + let server = httpmock::MockServer::start(); + server.mock(|when, then| { + when.method(httpmock::Method::GET).path("/index.json"); + then.status(200) + .json_body(serde_json::json!({"schema_version": 2, "plugins": []})); + }); + let client = http_client().expect("client builds"); + let result = fetch_index(&format!("{}/index.json", server.base_url()), &client).await; + assert!(matches!( + result, + Err(WasmPluginError::RegistryFormat { .. }) + )); + } + + #[tokio::test(flavor = "multi_thread")] + async fn download_rejects_checksum_mismatch_before_any_caller_sees_the_bytes() + -> Result<(), Box> { + let server = httpmock::MockServer::start(); + server.mock(|when, then| { + when.method(httpmock::Method::GET).path("/plugin.wasm"); + then.status(200).body(b"not what the index promised"); + }); + let iv = IndexVersion { + version: "1.0.0".into(), + download_url: format!("{}/plugin.wasm", server.base_url()).parse()?, + sha256: "0".repeat(64), + source_repo: "gtema/example-auth-plugin".into(), + abi_version: None, + min_cli_version: None, + }; + let client = http_client()?; + let result = download("example_auth", &iv, &client).await; + assert!(matches!( + result, + Err(WasmPluginError::ChecksumMismatch { .. }) + )); + Ok(()) + } + + #[tokio::test(flavor = "multi_thread")] + async fn download_returns_bytes_matching_the_declared_checksum() + -> Result<(), Box> { + let payload = b"a fake but checksum-consistent wasm module"; + let server = httpmock::MockServer::start(); + server.mock(|when, then| { + when.method(httpmock::Method::GET).path("/plugin.wasm"); + then.status(200).body(payload.as_slice()); + }); + let iv = IndexVersion { + version: "1.0.0".into(), + download_url: format!("{}/plugin.wasm", server.base_url()).parse()?, + sha256: sha256_hex_bytes(payload), + source_repo: "gtema/example-auth-plugin".into(), + abi_version: None, + min_cli_version: None, + }; + let client = http_client()?; + let bytes = download("example_auth", &iv, &client).await?; + assert_eq!(bytes, payload); + Ok(()) + } +} diff --git a/sdk/plugin-wasm/src/lib.rs b/sdk/plugin-wasm/src/lib.rs index e7c7729aa..30b8818a9 100644 --- a/sdk/plugin-wasm/src/lib.rs +++ b/sdk/plugin-wasm/src/lib.rs @@ -20,10 +20,19 @@ pub mod error; pub(crate) mod host; +pub mod index; pub mod lockfile; pub mod plugin; +pub mod provenance; pub mod registry; pub use error::WasmPluginError; pub use lockfile::{PluginEntry, PluginLockfile, TrustInfo}; pub use plugin::WasmAuthPlugin; + +/// Fuzz target entry point for the otherwise-private [`host`] module. +/// +/// Only compiled with the `fuzzing` feature; not part of the stable public +/// API. +#[cfg(feature = "fuzzing")] +pub use host::fuzz_identity_http_request_parsing; diff --git a/sdk/plugin-wasm/src/lockfile.rs b/sdk/plugin-wasm/src/lockfile.rs index 6636ab0d9..e89f0a9d9 100644 --- a/sdk/plugin-wasm/src/lockfile.rs +++ b/sdk/plugin-wasm/src/lockfile.rs @@ -47,6 +47,32 @@ pub struct TrustInfo { pub allow_unsigned: bool, } +/// The result of verifying a plugin's GitHub artifact attestation, recorded +/// on successful install/update via `crate::provenance::verify_attestation`. +/// +/// Presence of this field does not by itself mean the plugin is trusted: +/// what mattered is whether verification *succeeded* at install/update time, +/// which is what gated whether the install was allowed to proceed without +/// `--allow-unsigned` in the first place. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct ProvenanceRecord { + /// The `owner/repo` the attestation's signing identity was verified + /// against. + pub source_repo: String, + /// The GitHub Actions workflow ref (from the signing certificate's SAN), + /// if present. + pub workflow_ref: Option, + /// The OIDC issuer the signing certificate was issued for (expected + /// `https://token.actions.githubusercontent.com`). + pub oidc_issuer: Option, + /// A Rekor transparency-log entry index found in the attestation + /// bundle, if any. **Not** itself verified for inclusion (no Merkle + /// audit-path check is performed) — informational only. + pub rekor_log_index: Option, + /// When this verification was performed. + pub verified_at: DateTime, +} + /// A single installed `name@version` plugin record. #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] pub struct PluginEntry { @@ -64,6 +90,16 @@ pub struct PluginEntry { pub installed_at: DateTime, /// Trust metadata for this entry. pub trust: TrustInfo, + /// Whether this entry was installed with an explicit `@version` (`true`) + /// or resolved to "latest" (`false`). `osc plugin update --all` skips + /// pinned entries. + #[serde(default)] + pub pinned: bool, + /// The provenance verification result recorded at install/update time, + /// if any was performed (registry installs only; local `--file` installs + /// have no provenance source to check). + #[serde(default)] + pub provenance: Option, } /// The full set of installed plugins and which version of each is active. @@ -221,6 +257,8 @@ mod tests { confirmed_by_user: true, allow_unsigned: true, }, + pinned: false, + provenance: None, }, ); lf.active.insert("demo".into(), "1.0.0".into()); diff --git a/sdk/plugin-wasm/src/plugin.rs b/sdk/plugin-wasm/src/plugin.rs index 3e9d4517a..14f166fde 100644 --- a/sdk/plugin-wasm/src/plugin.rs +++ b/sdk/plugin-wasm/src/plugin.rs @@ -18,7 +18,7 @@ //! //! ## Guest ABI (version 1) //! -//! A conforming module exports: +//! Every conforming module exports: //! //! - `plugin_abi_version(_: string) -> string` — must return the literal `"1"`. //! - `auth_supported_methods(_: string) -> string` — JSON array of auth method @@ -27,14 +27,45 @@ //! - `auth_requirements(hints: string) -> string` — `hints` is a JSON value or //! the literal `null`; returns a JSON Schema object describing required //! fields, in the same shape [`OpenStackAuthType::requirements`] expects. +//! +//! and then exactly one of the two ABI flavors below, detected at load time +//! via `extism::Plugin::function_exists`: +//! +//! ### `auth` flavor +//! //! - `auth(request: string) -> string` — `request` is a JSON object //! `{"identity_url", "values", "scope", "hints"}`; returns either //! `{"ok": {"token": "...", "auth_info": }}` or //! `{"error": "human readable message"}`. //! -//! Only `auth` may perform outbound HTTP, and only via the host-provided -//! `identity_http_request` import — never directly. Every other export must be -//! a pure computation over its input. +//! Only `auth` may perform outbound HTTP, and only via the host-provided +//! `identity_http_request` import — never directly. +//! +//! ### `sso` flavor +//! +//! For interactive, browser-based (WebSSO-style) plugins. Neither export may +//! perform any I/O: the host owns the callback listener, the anti-CSRF +//! `state` check, and the browser-opening step (via +//! `openstack_sdk_websso_host`); the guest only ever sees already-validated +//! strings in and returns strings out. +//! +//! - `sso_build_request(request: string) -> string` — `request` is +//! `{"identity_url", "callback_url", "values", "scope", "hints"}`, where +//! `callback_url` is the host-bound local callback URL (with the +//! anti-CSRF `state` token already embedded) the guest must have the +//! identity provider redirect back to. Returns +//! `{"url": "https://...", "redirect_host": "host:port"}`: `url` is the +//! page to open in the user's browser, and `redirect_host` is the +//! `host:port` authority the guest configured as the identity provider's +//! redirect target. The host verifies, before opening any browser, that +//! `url`'s scheme is `https` and that `redirect_host` exactly matches the +//! host-bound callback listener's own authority — a plugin that returns a +//! URL on a different (undeclared) redirect host is rejected outright, +//! with no override. +//! - `sso_parse_callback(callback: string) -> string` — `callback` is +//! `{"params": {...}}`, the form fields from the already +//! state-validated callback POST; returns the same +//! `{"ok": ...} | {"error": ...}` shape as `auth`. use std::collections::BTreeMap; use std::path::{Path, PathBuf}; @@ -42,6 +73,7 @@ use std::sync::{Arc, Mutex}; use std::time::Duration; use async_trait::async_trait; +use dialoguer::Confirm; use extism::{Function, Manifest, Plugin, UserData, ValType, Wasm}; use secrecy::{ExposeSecret, SecretString}; use serde::{Deserialize, Serialize}; @@ -49,10 +81,22 @@ use serde::{Deserialize, Serialize}; use openstack_sdk_auth_core::{ Auth, AuthError, AuthResponse, AuthToken, AuthTokenScope, OpenStackAuthType, }; +use openstack_sdk_websso_host::{BrowserOpenPolicy, CallbackServer}; use crate::error::WasmPluginError; use crate::host::{self, HostContextState}; +/// A guest module implements exactly one of these two ABI flavors, +/// detected at load time. See the module docs for the exports each one +/// requires. +#[derive(Clone, Debug, PartialEq, Eq)] +enum AbiFlavor { + /// Non-interactive: a single `auth` export handles the whole flow. + Auth, + /// Interactive/browser-based: `sso_build_request` + `sso_parse_callback`. + Sso, +} + /// WASM modules are given at most this much linear memory. const DEFAULT_MAX_MEMORY_PAGES: u32 = 256; // 256 * 64KiB = 16MiB /// A single guest call (including any `identity_http_request` round trips it @@ -81,6 +125,26 @@ enum AuthResultMsg { }, } +#[derive(Serialize)] +struct SsoBuildRequestMsg { + identity_url: String, + callback_url: String, + values: BTreeMap, + scope: Option, + hints: Option, +} + +#[derive(Deserialize)] +struct SsoBuildResponseMsg { + url: String, + redirect_host: String, +} + +#[derive(Serialize)] +struct SsoCallbackMsg { + params: BTreeMap, +} + /// A single loaded WASM auth plugin, wrapping an [`extism::Plugin`] instance. /// /// Cheaply cloneable: the underlying plugin and its per-call host state are @@ -96,6 +160,7 @@ pub struct WasmAuthPlugin { host_ctx: UserData, supported_methods: Vec<&'static str>, api_version: (u8, u8), + flavor: AbiFlavor, } impl std::fmt::Debug for WasmAuthPlugin { @@ -105,6 +170,7 @@ impl std::fmt::Debug for WasmAuthPlugin { .field("source", &self.source) .field("supported_methods", &self.supported_methods) .field("api_version", &self.api_version) + .field("flavor", &self.flavor) .finish_non_exhaustive() } } @@ -208,6 +274,26 @@ impl WasmAuthPlugin { reason: format!("auth_api_version did not return a JSON [major, minor] pair: {e}"), })?; + let has_auth = plugin.function_exists("auth"); + let has_sso = plugin.function_exists("sso_build_request") + && plugin.function_exists("sso_parse_callback"); + let flavor = match (has_auth, has_sso) { + (true, false) => AbiFlavor::Auth, + (false, true) => AbiFlavor::Sso, + (true, true) => { + return Err(WasmPluginError::InvalidAbi { + name, + reason: "plugin exports both `auth` and the SSO entry points (`sso_build_request`/`sso_parse_callback`); a module must implement exactly one ABI flavor".into(), + }); + } + (false, false) => { + return Err(WasmPluginError::InvalidAbi { + name, + reason: "plugin exports neither `auth` nor the SSO entry points (`sso_build_request`+`sso_parse_callback`)".into(), + }); + } + }; + Ok(Self { name, source: path.to_path_buf(), @@ -215,6 +301,7 @@ impl WasmAuthPlugin { host_ctx, supported_methods, api_version: (major, minor), + flavor, }) } @@ -282,6 +369,21 @@ impl OpenStackAuthType for WasmAuthPlugin { values: &std::collections::HashMap, scope: Option<&AuthTokenScope>, hints: Option<&serde_json::Value>, + ) -> Result { + match self.flavor { + AbiFlavor::Auth => self.auth_via_auth(identity_url, values, scope, hints).await, + AbiFlavor::Sso => self.auth_via_sso(identity_url, values, scope, hints).await, + } + } +} + +impl WasmAuthPlugin { + async fn auth_via_auth( + &self, + identity_url: &url::Url, + values: &std::collections::HashMap, + scope: Option<&AuthTokenScope>, + hints: Option<&serde_json::Value>, ) -> Result { let request = AuthRequestMsg { identity_url: identity_url.to_string(), @@ -364,4 +466,223 @@ impl OpenStackAuthType for WasmAuthPlugin { }), } } + + /// Run the `sso` ABI flavor: bind a host-owned callback listener, ask + /// the guest (a pure computation) to build the identity-provider URL to + /// open, validate that URL before ever opening a browser, wait for the + /// already state-validated callback, then hand the callback's fields + /// back to the guest (again pure) to turn into a token. + /// + /// The guest never sees a socket, a browser-opening capability, or the + /// raw (unvalidated) callback request — every step it participates in + /// is a JSON-in, JSON-out call over data the host has already checked. + async fn auth_via_sso( + &self, + identity_url: &url::Url, + values: &std::collections::HashMap, + scope: Option<&AuthTokenScope>, + hints: Option<&serde_json::Value>, + ) -> Result { + let callback_port = values + .get("callback_port") + .and_then(|v| v.expose_secret().parse::().ok()); + + let server = CallbackServer::bind(callback_port) + .await + .map_err(|source| { + AuthError::plugin(WasmPluginError::Host { + name: self.name.clone(), + source, + }) + })?; + + let request = SsoBuildRequestMsg { + identity_url: identity_url.to_string(), + callback_url: server.callback_url().to_string(), + values: values + .iter() + .map(|(k, v)| (k.clone(), v.expose_secret().to_string())) + .collect(), + scope: scope.map(serde_json::to_value).transpose()?, + hints: hints.cloned(), + }; + let request_json = serde_json::to_string(&request)?; + + let build_output = self + .call_guest("sso_build_request", request_json) + .await + .map_err(AuthError::plugin)?; + + // The plugin's declared redirect target must be exactly the + // callback listener the host itself just bound. A mismatch means + // the plugin is trying to point the identity provider's redirect + // somewhere the host never bound a listener on — always rejected, + // no override. + let sso_url = + validate_sso_build_response(&self.name, &build_output, &server.redirect_host()) + .map_err(AuthError::plugin)?; + + let confirmation = Confirm::new() + .with_prompt(format!( + "A default browser is going to be opened at `{}`. Do you want to continue?", + sso_url.as_str() + )) + .interact() + .map_err(WasmPluginError::from) + .map_err(AuthError::plugin)?; + if !confirmation { + return Err(AuthError::plugin(WasmPluginError::InvalidRedirect { + name: self.name.clone(), + reason: "user declined to open the browser".into(), + })); + } + + openstack_sdk_websso_host::open_browser( + &sso_url, + BrowserOpenPolicy { + require_https: true, + }, + ) + .map_err(|source| { + AuthError::plugin(WasmPluginError::Host { + name: self.name.clone(), + source, + }) + })?; + + let params = server + .wait_for_callback(Duration::from_secs(120)) + .await + .map_err(|source| { + AuthError::plugin(WasmPluginError::Host { + name: self.name.clone(), + source, + }) + })?; + + let callback = SsoCallbackMsg { + params: params.into_iter().collect(), + }; + let callback_json = serde_json::to_string(&callback)?; + + let output = self + .call_guest("sso_parse_callback", callback_json) + .await + .map_err(AuthError::plugin)?; + + let parsed: AuthResultMsg = serde_json::from_str(&output).map_err(|source| { + AuthError::plugin(WasmPluginError::MalformedAuthResponse { + name: self.name.clone(), + source, + }) + })?; + + match parsed { + AuthResultMsg::Ok { token, auth_info } => { + Ok(Auth::AuthToken(Box::new(AuthToken::new(token, *auth_info)))) + } + AuthResultMsg::Error { error } => Err(AuthError::UnknownAuth { + code: 0, + message: Some(error), + }), + } + } + + /// Call a pure (no host-function-using) guest export off the async + /// runtime, since a WASM call can run for up to the plugin's configured + /// timeout and must not stall the executor. + async fn call_guest( + &self, + function: &'static str, + input: String, + ) -> Result { + let name = self.name.clone(); + let inner = self.inner.clone(); + tokio::task::spawn_blocking(move || -> Result { + let mut plugin = inner.lock().map_err(|_| WasmPluginError::HostContext { + name: name.clone(), + function, + reason: "plugin lock poisoned".into(), + })?; + plugin + .call(function, input.as_str()) + .map_err(|source| WasmPluginError::Call { + name, + function, + source, + }) + }) + .await + .map_err(|source| WasmPluginError::Join { + name: self.name.clone(), + function, + source, + })? + } +} + +/// Parse and validate a guest's `sso_build_request` response: the returned +/// `url` must be well-formed and `https`, and the declared `redirect_host` +/// must exactly match `expected_redirect_host` (the host-bound callback +/// listener's own authority). This is the one guest-response deserialization +/// path in this module with security-relevant validation logic beyond a +/// plain type check, so it's kept as a standalone, panic-free function that +/// both [`WasmAuthPlugin::auth_via_sso`] and the `fuzzing`-feature entry +/// point below can exercise directly. +fn validate_sso_build_response( + name: &str, + build_output: &str, + expected_redirect_host: &str, +) -> Result { + let build: SsoBuildResponseMsg = serde_json::from_str(build_output).map_err(|source| { + WasmPluginError::MalformedAuthResponse { + name: name.to_string(), + source, + } + })?; + + let sso_url = + url::Url::parse(&build.url).map_err(|source| WasmPluginError::InvalidRedirect { + name: name.to_string(), + reason: format!("`sso_build_request` returned an unparsable url: {source}"), + })?; + if sso_url.scheme() != "https" { + return Err(WasmPluginError::InvalidRedirect { + name: name.to_string(), + reason: format!( + "`sso_build_request` returned a non-https url (scheme was `{}`)", + sso_url.scheme() + ), + }); + } + if build.redirect_host != expected_redirect_host { + return Err(WasmPluginError::RedirectHostMismatch { + name: name.to_string(), + declared: build.redirect_host, + expected: expected_redirect_host.to_string(), + }); + } + Ok(sso_url) +} + +/// Fuzz target entry point for the otherwise-private +/// [`validate_sso_build_response`]. `expected_redirect_host` is itself fuzzed +/// input rather than a fixed value, so the mismatch path is exercised too. +/// +/// Only compiled with the `fuzzing` feature; not part of the stable public +/// API. +#[cfg(feature = "fuzzing")] +pub fn fuzz_validate_sso_build_response(build_output: &str, expected_redirect_host: &str) { + let _ = validate_sso_build_response("fuzz", build_output, expected_redirect_host); +} + +/// Fuzz target entry point for parsing the otherwise-private `AuthResultMsg` +/// every guest response (`auth`, `sso_parse_callback`) is deserialized +/// through. +/// +/// Only compiled with the `fuzzing` feature; not part of the stable public +/// API. +#[cfg(feature = "fuzzing")] +pub fn fuzz_parse_auth_result(output: &str) { + let _ = serde_json::from_str::(output); } diff --git a/sdk/plugin-wasm/src/provenance.rs b/sdk/plugin-wasm/src/provenance.rs new file mode 100644 index 000000000..d5e98794d --- /dev/null +++ b/sdk/plugin-wasm/src/provenance.rs @@ -0,0 +1,680 @@ +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 + +//! Verification of GitHub artifact attestations (Sigstore bundles produced +//! by `actions/attest`) proving a registry-listed plugin artifact was +//! actually published by CI in its claimed source repository, rather than +//! just checksum-consistent with what the registry index says. +//! +//! This is a deliberately reduced verifier, not a general Sigstore client: +//! it checks the DSSE envelope's signature against the attestation's leaf +//! (Fulcio-issued) certificate, that the leaf certificate chains to a +//! vendored, pinned Fulcio root/intermediate CA (`../trust/*.pem`), and that +//! the leaf certificate's GitHub Actions identity extensions (OIDC issuer + +//! `owner/repo`) match the plugin's claimed source repository. It does +//! **not** verify the Rekor transparency-log inclusion proof (the Merkle +//! audit path + signed tree head) — that needs a TUF trust-root client and +//! Merkle-proof code that only the `sigstore` crate provides today, and that +//! crate's dependency graph (a TUF client, a possible OpenSSL pull-through) +//! conflicts with this workspace's explicit no-OpenSSL stance. This is the +//! same tradeoff `cosign verify-blob --insecure-ignore-tlog` documents, +//! chosen deliberately here rather than silently: a tampered or unattested +//! plugin still fails closed, but a withheld-from-the-public-log attestation +//! is not detected. + +use base64::Engine as _; +use base64::engine::general_purpose::STANDARD as BASE64; +use chrono::Utc; +use ring::signature::{self, UnparsedPublicKey}; +use serde::Deserialize; +use x509_parser::certificate::X509Certificate; +use x509_parser::extensions::GeneralName; +use x509_parser::pem::Pem; +use x509_parser::prelude::FromDer; + +use crate::error::WasmPluginError; +use crate::lockfile::ProvenanceRecord; + +/// The GitHub REST API host attestations are fetched from by default. +pub const GITHUB_API_BASE_URL: &str = "https://api.github.com"; + +/// Fulcio's "OIDC issuer" certificate extension OID. +const OID_OIDC_ISSUER: &str = "1.3.6.1.4.1.57264.1.1"; +/// Fulcio's "source repository" (`owner/repo`) certificate extension OID. +const OID_SOURCE_REPO: &str = "1.3.6.1.4.1.57264.1.5"; +/// The only OIDC issuer a GitHub Actions-signed certificate can have. +const EXPECTED_OIDC_ISSUER: &str = "https://token.actions.githubusercontent.com"; + +const FULCIO_ROOT_PEM: &str = include_str!("../trust/fulcio_root.pem"); +const FULCIO_INTERMEDIATE_PEM: &str = include_str!("../trust/fulcio_intermediate.pem"); + +/// The pinned Fulcio root + intermediate CA certificates a leaf certificate +/// must chain to before its signature is trusted. +pub struct TrustRoots { + root_der: Vec, + intermediate_der: Vec, +} + +impl TrustRoots { + /// The vendored, pinned Sigstore public-good-instance Fulcio root and + /// intermediate CAs (`sdk/plugin-wasm/trust/*.pem`). + pub fn production() -> Result { + Self::from_pem( + FULCIO_ROOT_PEM.as_bytes(), + FULCIO_INTERMEDIATE_PEM.as_bytes(), + ) + } + + /// Build from arbitrary PEM bytes. Exposed so tests can substitute a + /// synthetic root/intermediate pair, making the certificate-chain and + /// DSSE-signature verification path exercisable without a real + /// GitHub-signed artifact. + pub fn from_pem(root_pem: &[u8], intermediate_pem: &[u8]) -> Result { + Ok(Self { + root_der: pem_to_der(root_pem)?, + intermediate_der: pem_to_der(intermediate_pem)?, + }) + } + + fn root(&self) -> Result, WasmPluginError> { + parse_cert(&self.root_der, "vendored Fulcio root") + } + + fn intermediate(&self) -> Result, WasmPluginError> { + parse_cert(&self.intermediate_der, "vendored Fulcio intermediate") + } +} + +fn pem_to_der(pem_bytes: &[u8]) -> Result, WasmPluginError> { + let pem = Pem::iter_from_buffer(pem_bytes) + .next() + .ok_or_else(|| WasmPluginError::AttestationVerification { + reason: "no PEM block found in trust anchor".into(), + })? + .map_err(|e| WasmPluginError::AttestationVerification { + reason: format!("invalid trust anchor PEM: {e}"), + })?; + Ok(pem.contents) +} + +fn parse_cert<'a>(der: &'a [u8], what: &str) -> Result, WasmPluginError> { + let (_, cert) = + X509Certificate::from_der(der).map_err(|e| WasmPluginError::AttestationVerification { + reason: format!("invalid {what} certificate: {e}"), + })?; + Ok(cert) +} + +/// The response body of `GET /repos/{owner}/{repo}/attestations/{subject_digest}`. +#[derive(Clone, Debug, Deserialize)] +pub struct AttestationsResponse { + /// Attestations found for the given subject digest. + #[serde(default)] + pub attestations: Vec, +} + +/// A single attestation entry. +#[derive(Clone, Debug, Deserialize)] +pub struct Attestation { + /// The Sigstore bundle itself. + pub bundle: Bundle, +} + +/// A Sigstore bundle: a signed statement plus the material needed to verify +/// it. Only the fields this verifier uses are modeled; unknown fields +/// (`mediaType`, `bundle_url`, ...) are ignored by `serde` by default. +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Bundle { + /// The signed DSSE envelope. + pub dsse_envelope: DsseEnvelope, + /// The certificate(s) and transparency-log entries backing the + /// signature. + pub verification_material: VerificationMaterial, +} + +/// A DSSE (Dead Simple Signing Envelope) as used by in-toto attestations. +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DsseEnvelope { + /// Base64-encoded payload (an in-toto statement, not itself parsed by + /// this verifier — only its signature is checked). + pub payload: String, + /// The payload's media type, part of the signed pre-authentication + /// encoding. + pub payload_type: String, + /// Signatures over the payload. Only the first is checked. + #[serde(default)] + pub signatures: Vec, +} + +/// A single DSSE signature. +#[derive(Clone, Debug, Deserialize)] +pub struct DsseSignature { + /// Base64-encoded signature bytes (ASN.1 DER ECDSA signature). + pub sig: String, +} + +/// The certificate(s) and transparency-log entries a bundle carries. +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct VerificationMaterial { + /// A single leaf certificate, in newer bundle media types. + #[serde(default)] + pub certificate: Option, + /// A certificate chain, in older bundle media types. When present and + /// `certificate` is absent, its first entry is the leaf. + #[serde(default)] + pub x509_certificate_chain: Option, + /// Rekor transparency-log entries, if any. **Not** verified for Merkle + /// inclusion by this verifier — see module docs. + #[serde(default)] + pub tlog_entries: Vec, +} + +impl VerificationMaterial { + fn leaf_certificate_der(&self) -> Result, WasmPluginError> { + let raw_bytes = self + .certificate + .as_ref() + .map(|c| c.raw_bytes.as_str()) + .or_else(|| { + self.x509_certificate_chain + .as_ref() + .and_then(|c| c.certificates.first()) + .map(|c| c.raw_bytes.as_str()) + }) + .ok_or_else(|| WasmPluginError::AttestationVerification { + reason: "attestation bundle has no leaf certificate".into(), + })?; + BASE64 + .decode(raw_bytes) + .map_err(|e| WasmPluginError::AttestationVerification { + reason: format!("invalid leaf certificate base64: {e}"), + }) + } +} + +/// A single DER certificate, base64-encoded. +#[derive(Clone, Debug, Deserialize)] +pub struct RawCert { + /// Base64-encoded DER certificate bytes. + #[serde(rename = "rawBytes")] + pub raw_bytes: String, +} + +/// A chain of DER certificates. +#[derive(Clone, Debug, Deserialize)] +pub struct CertChain { + /// Leaf-first certificate chain. + #[serde(default)] + pub certificates: Vec, +} + +/// A single Rekor transparency-log entry reference. Only kept for display; +/// not verified. +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TlogEntry { + /// The log index, if present. GitHub's API has represented this as both + /// a JSON string and a JSON number across bundle media types, so it's + /// captured generically and stringified for display. + #[serde(default)] + pub log_index: Option, +} + +/// Fetch the GitHub artifact attestations for the artifact whose sha256 +/// digest is `sha256_hex`, from `{base_url}/repos/{owner}/{repo}/attestations/...`. +/// +/// `base_url` is a parameter (rather than hardcoded) so tests can point this +/// at an `httpmock` server; production callers should pass +/// [`GITHUB_API_BASE_URL`]. An optional `GITHUB_TOKEN` environment variable +/// is sent as a bearer token if set — purely a rate-limit/private-repo +/// convenience, never required and never a factor in what ends up trusted. +pub async fn fetch_attestations( + base_url: &str, + owner: &str, + repo: &str, + sha256_hex: &str, + client: &reqwest::Client, +) -> Result { + let url = format!("{base_url}/repos/{owner}/{repo}/attestations/sha256:{sha256_hex}"); + let mut request = client + .get(&url) + .header("Accept", "application/vnd.github+json") + .header("X-GitHub-Api-Version", "2022-11-28"); + if let Ok(token) = std::env::var("GITHUB_TOKEN") + && !token.is_empty() + { + request = request.bearer_auth(token); + } + let to_fetch_err = |source| WasmPluginError::AttestationFetch { + owner: owner.to_string(), + repo: repo.to_string(), + source, + }; + let response = request + .send() + .await + .map_err(to_fetch_err)? + .error_for_status() + .map_err(to_fetch_err)?; + let bytes = response.bytes().await.map_err(to_fetch_err)?; + serde_json::from_slice(&bytes).map_err(|e| WasmPluginError::AttestationVerification { + reason: format!("malformed attestations response: {e}"), + }) +} + +/// Fetch and verify GitHub artifact attestations for `sha256_hex`, claimed +/// to have been published from `source_repo` (`owner/repo`), against the +/// production trust roots. Returns the first attestation that verifies +/// successfully; if none do, returns the last verification error +/// encountered (or a "no attestations found" error if none exist at all). +pub async fn verify_for_source( + source_repo: &str, + sha256_hex: &str, + client: &reqwest::Client, +) -> Result { + let (owner, repo) = + source_repo + .split_once('/') + .ok_or_else(|| WasmPluginError::AttestationVerification { + reason: format!( + "registry entry source_repo `{source_repo}` is not in `owner/repo` form" + ), + })?; + let response = fetch_attestations(GITHUB_API_BASE_URL, owner, repo, sha256_hex, client).await?; + let roots = TrustRoots::production()?; + + let mut last_err = WasmPluginError::AttestationVerification { + reason: "no attestations found for this artifact".into(), + }; + for attestation in &response.attestations { + match verify_attestation(&attestation.bundle, owner, repo, &roots) { + Ok(record) => return Ok(record), + Err(e) => last_err = e, + } + } + Err(last_err) +} + +/// Verify a single attestation bundle's DSSE signature, certificate chain, +/// and GitHub Actions identity against `expected_owner`/`expected_repo`. See +/// module docs for exactly what is and isn't checked. +pub fn verify_attestation( + bundle: &Bundle, + expected_owner: &str, + expected_repo: &str, + roots: &TrustRoots, +) -> Result { + let payload = BASE64 + .decode(bundle.dsse_envelope.payload.as_bytes()) + .map_err(|e| WasmPluginError::AttestationVerification { + reason: format!("invalid DSSE payload base64: {e}"), + })?; + let signature = bundle.dsse_envelope.signatures.first().ok_or_else(|| { + WasmPluginError::AttestationVerification { + reason: "DSSE envelope has no signatures".into(), + } + })?; + let sig_bytes = BASE64.decode(signature.sig.as_bytes()).map_err(|e| { + WasmPluginError::AttestationVerification { + reason: format!("invalid DSSE signature base64: {e}"), + } + })?; + let pae = dsse_pae(&bundle.dsse_envelope.payload_type, &payload); + + let leaf_der = bundle.verification_material.leaf_certificate_der()?; + let leaf = parse_cert(&leaf_der, "attestation leaf")?; + + let leaf_key = UnparsedPublicKey::new( + &signature::ECDSA_P256_SHA256_ASN1, + leaf.public_key().subject_public_key.data.as_ref(), + ); + leaf_key + .verify(&pae, &sig_bytes) + .map_err(|_| WasmPluginError::AttestationVerification { + reason: "DSSE envelope signature verification failed".into(), + })?; + + // Chain the leaf to our own vendored, pinned root/intermediate — + // deliberately ignoring any intermediate/root certificates the bundle + // itself supplied, since trusting the server's own chain would defeat + // the point of pinning. + let intermediate = roots.intermediate()?; + let root = roots.root()?; + leaf.verify_signature(Some(intermediate.public_key())) + .map_err(|e| WasmPluginError::AttestationVerification { + reason: format!( + "leaf certificate does not chain to the pinned Fulcio intermediate: {e}" + ), + })?; + intermediate + .verify_signature(Some(root.public_key())) + .map_err(|e| WasmPluginError::AttestationVerification { + reason: format!( + "pinned Fulcio intermediate does not chain to the pinned Fulcio root: {e}" + ), + })?; + + let oidc_issuer = extension_string(&leaf, OID_OIDC_ISSUER); + if oidc_issuer.as_deref() != Some(EXPECTED_OIDC_ISSUER) { + return Err(WasmPluginError::AttestationVerification { + reason: format!("unexpected OIDC issuer: {oidc_issuer:?}"), + }); + } + let expected_repository = format!("{expected_owner}/{expected_repo}"); + let repository = extension_string(&leaf, OID_SOURCE_REPO); + if repository.as_deref() != Some(expected_repository.as_str()) { + return Err(WasmPluginError::AttestationVerification { + reason: format!( + "certificate identity `{repository:?}` does not match claimed source repo `{expected_repository}`" + ), + }); + } + + let workflow_ref = subject_alternative_name_uri(&leaf); + let rekor_log_index = bundle + .verification_material + .tlog_entries + .first() + .and_then(|e| e.log_index.as_ref()) + .map(value_to_display_string); + + Ok(ProvenanceRecord { + source_repo: expected_repository, + workflow_ref, + oidc_issuer, + rekor_log_index, + verified_at: Utc::now(), + }) +} + +fn value_to_display_string(value: &serde_json::Value) -> String { + match value { + serde_json::Value::String(s) => s.clone(), + other => other.to_string(), + } +} + +/// The DSSE Pre-Authentication Encoding: the exact byte structure that gets +/// signed. See +/// . +fn dsse_pae(payload_type: &str, payload: &[u8]) -> Vec { + let mut pae = Vec::with_capacity(payload.len() + payload_type.len() + 32); + pae.extend_from_slice(b"DSSEv1"); + pae.push(b' '); + pae.extend_from_slice(payload_type.len().to_string().as_bytes()); + pae.push(b' '); + pae.extend_from_slice(payload_type.as_bytes()); + pae.push(b' '); + pae.extend_from_slice(payload.len().to_string().as_bytes()); + pae.push(b' '); + pae.extend_from_slice(payload); + pae +} + +/// Best-effort extraction of a Fulcio identity extension's string value. +/// These extensions hold their content as a primitive ASN.1 string TLV +/// (tag + DER length + UTF-8 bytes); rather than pull in a generic ASN.1 +/// value parser for this one shape, the two-or-more byte header is skipped +/// directly. +fn extension_string(cert: &X509Certificate, oid: &str) -> Option { + let ext = cert + .extensions() + .iter() + .find(|e| e.oid.to_id_string() == oid)?; + decode_der_string_content(ext.value) +} + +fn decode_der_string_content(value: &[u8]) -> Option { + let (_tag, rest) = value.split_first()?; + let (len_byte, rest) = rest.split_first()?; + let content = if *len_byte < 0x80 { + rest.get(..*len_byte as usize)? + } else { + let n = (*len_byte & 0x7f) as usize; + let len_bytes = rest.get(..n)?; + let len = len_bytes + .iter() + .fold(0usize, |acc, b| (acc << 8) | *b as usize); + rest.get(n..n + len)? + }; + std::str::from_utf8(content).ok().map(|s| s.to_string()) +} + +fn subject_alternative_name_uri(cert: &X509Certificate) -> Option { + let san = cert.subject_alternative_name().ok().flatten()?; + san.value.general_names.iter().find_map(|gn| match gn { + GeneralName::URI(uri) => Some((*uri).to_string()), + _ => None, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn dsse_pae_matches_spec_example() { + // From the DSSE spec's own worked example: PAE = "DSSEv1" SP + // LEN(type) SP type SP LEN(body) SP body. + let payload_type = "http://example.com/HelloWorld"; + let payload = b"hello world"; + let pae = dsse_pae(payload_type, payload); + let expected = format!( + "DSSEv1 {} {payload_type} {} {}", + payload_type.len(), + payload.len(), + "hello world" + ); + assert_eq!(pae, expected.into_bytes()); + } + + #[test] + fn decode_der_string_content_strips_short_form_header() { + // UTF8String (tag 0x0c), length 5, "hello" + let der = [0x0c, 0x05, b'h', b'e', b'l', b'l', b'o']; + assert_eq!(decode_der_string_content(&der).as_deref(), Some("hello")); + } + + /// A synthetic root -> intermediate -> leaf certificate chain, shaped + /// like what Fulcio issues (leaf carrying a SAN workflow-ref URI and the + /// two GitHub Actions identity extension OIDs), built with `rcgen` so the + /// DSSE-signature and certificate-chain verification paths in + /// [`verify_attestation`] are exercisable without a real GitHub-signed + /// artifact. Not related to (and not a stand-in for) the real, + /// vendored Fulcio roots in [`TrustRoots::production`]. + struct SyntheticChain { + root_pem: String, + intermediate_pem: String, + leaf_der: Vec, + leaf_signing_key: ring::signature::EcdsaKeyPair, + } + + fn der_utf8_string(s: &str) -> Vec { + assert!(s.len() < 128, "test helper only supports short strings"); + let mut v = vec![0x0c, s.len() as u8]; + v.extend_from_slice(s.as_bytes()); + v + } + + fn build_synthetic_chain( + oidc_issuer: &str, + source_repo: &str, + workflow_ref: &str, + ) -> SyntheticChain { + use rcgen::{ + BasicConstraints, CertificateParams, CustomExtension, Ia5String, IsCa, KeyPair, SanType, + }; + + let mut root_params = + CertificateParams::new(Vec::::new()).expect("empty SAN list is always valid"); + root_params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained); + let root_key = KeyPair::generate().expect("key generation"); + let root_cert = root_params.self_signed(&root_key).expect("self-sign root"); + + let mut inter_params = + CertificateParams::new(Vec::::new()).expect("empty SAN list is always valid"); + inter_params.is_ca = IsCa::Ca(BasicConstraints::Constrained(0)); + let inter_key = KeyPair::generate().expect("key generation"); + let inter_cert = inter_params + .signed_by(&inter_key, &root_cert, &root_key) + .expect("sign intermediate with root"); + + let mut leaf_params = + CertificateParams::new(Vec::::new()).expect("empty SAN list is always valid"); + leaf_params.is_ca = IsCa::NoCa; + leaf_params.subject_alt_names = vec![SanType::URI( + Ia5String::try_from(workflow_ref).expect("valid IA5 URI"), + )]; + leaf_params + .custom_extensions + .push(CustomExtension::from_oid_content( + &[1, 3, 6, 1, 4, 1, 57264, 1, 1], + der_utf8_string(oidc_issuer), + )); + leaf_params + .custom_extensions + .push(CustomExtension::from_oid_content( + &[1, 3, 6, 1, 4, 1, 57264, 1, 5], + der_utf8_string(source_repo), + )); + let leaf_key = KeyPair::generate().expect("key generation"); + let leaf_cert = leaf_params + .signed_by(&leaf_key, &inter_cert, &inter_key) + .expect("sign leaf with intermediate"); + + let leaf_signing_key = ring::signature::EcdsaKeyPair::from_pkcs8( + &ring::signature::ECDSA_P256_SHA256_ASN1_SIGNING, + leaf_key.serialize_der().as_slice(), + &ring::rand::SystemRandom::new(), + ) + .expect("rcgen's PKCS#8 output loads into ring"); + + SyntheticChain { + root_pem: root_cert.pem(), + intermediate_pem: inter_cert.pem(), + leaf_der: leaf_cert.der().to_vec(), + leaf_signing_key, + } + } + + const TEST_PAYLOAD_TYPE: &str = "application/vnd.in-toto+json"; + + fn build_bundle(chain: &SyntheticChain, payload: &[u8]) -> Bundle { + let pae = dsse_pae(TEST_PAYLOAD_TYPE, payload); + let sig = chain + .leaf_signing_key + .sign(&ring::rand::SystemRandom::new(), &pae) + .expect("sign PAE"); + Bundle { + dsse_envelope: DsseEnvelope { + payload: BASE64.encode(payload), + payload_type: TEST_PAYLOAD_TYPE.to_string(), + signatures: vec![DsseSignature { + sig: BASE64.encode(sig.as_ref()), + }], + }, + verification_material: VerificationMaterial { + certificate: Some(RawCert { + raw_bytes: BASE64.encode(&chain.leaf_der), + }), + x509_certificate_chain: None, + tlog_entries: vec![], + }, + } + } + + const TEST_WORKFLOW_REF: &str = "https://github.com/gtema/example-auth-plugin/.github/workflows/release.yml@refs/heads/main"; + + #[test] + fn verify_attestation_accepts_valid_synthetic_chain() { + let chain = build_synthetic_chain( + EXPECTED_OIDC_ISSUER, + "gtema/example-auth-plugin", + TEST_WORKFLOW_REF, + ); + let bundle = build_bundle(&chain, br#"{"_type":"https://in-toto.io/Statement/v1"}"#); + let roots = + TrustRoots::from_pem(chain.root_pem.as_bytes(), chain.intermediate_pem.as_bytes()) + .expect("trust roots parse"); + + let record = verify_attestation(&bundle, "gtema", "example-auth-plugin", &roots) + .expect("valid attestation should verify"); + assert_eq!(record.source_repo, "gtema/example-auth-plugin"); + assert_eq!(record.oidc_issuer.as_deref(), Some(EXPECTED_OIDC_ISSUER)); + assert_eq!(record.workflow_ref.as_deref(), Some(TEST_WORKFLOW_REF)); + } + + #[test] + fn verify_attestation_rejects_tampered_payload() { + let chain = build_synthetic_chain( + EXPECTED_OIDC_ISSUER, + "gtema/example-auth-plugin", + TEST_WORKFLOW_REF, + ); + let mut bundle = build_bundle(&chain, br#"{"_type":"https://in-toto.io/Statement/v1"}"#); + // Swap in a different (still validly base64/JSON-shaped) payload + // after signing, without re-signing — this is what a tampered or + // substituted artifact attestation looks like on the wire. + bundle.dsse_envelope.payload = BASE64.encode(b"{\"_type\":\"tampered\"}"); + let roots = + TrustRoots::from_pem(chain.root_pem.as_bytes(), chain.intermediate_pem.as_bytes()) + .expect("trust roots parse"); + + assert!(verify_attestation(&bundle, "gtema", "example-auth-plugin", &roots).is_err()); + } + + #[test] + fn verify_attestation_rejects_repository_mismatch() { + let chain = build_synthetic_chain( + EXPECTED_OIDC_ISSUER, + "gtema/example-auth-plugin", + TEST_WORKFLOW_REF, + ); + let bundle = build_bundle(&chain, br#"{"_type":"https://in-toto.io/Statement/v1"}"#); + let roots = + TrustRoots::from_pem(chain.root_pem.as_bytes(), chain.intermediate_pem.as_bytes()) + .expect("trust roots parse"); + + // The certificate's repository extension says gtema/example-auth-plugin; + // the caller expects a different repository entirely (as would happen + // if a registry index entry's source_repo were spoofed/mismatched). + assert!(verify_attestation(&bundle, "someone-else", "unrelated-repo", &roots).is_err()); + } + + #[test] + fn verify_attestation_rejects_chain_not_rooted_in_pinned_ca() { + let chain = build_synthetic_chain( + EXPECTED_OIDC_ISSUER, + "gtema/example-auth-plugin", + TEST_WORKFLOW_REF, + ); + let bundle = build_bundle(&chain, br#"{"_type":"https://in-toto.io/Statement/v1"}"#); + // A second, unrelated synthetic root/intermediate pair the leaf was + // never actually signed by — simulates an attacker presenting their + // own CA instead of the pinned Fulcio one. + let other = build_synthetic_chain( + EXPECTED_OIDC_ISSUER, + "gtema/example-auth-plugin", + TEST_WORKFLOW_REF, + ); + let roots = + TrustRoots::from_pem(other.root_pem.as_bytes(), other.intermediate_pem.as_bytes()) + .expect("trust roots parse"); + + assert!(verify_attestation(&bundle, "gtema", "example-auth-plugin", &roots).is_err()); + } +} diff --git a/sdk/plugin-wasm/src/registry.rs b/sdk/plugin-wasm/src/registry.rs index 132763a4b..353c859d0 100644 --- a/sdk/plugin-wasm/src/registry.rs +++ b/sdk/plugin-wasm/src/registry.rs @@ -42,8 +42,10 @@ use std::sync::{Arc, OnceLock, RwLock}; use chrono::Utc; use crate::error::WasmPluginError; -use crate::lockfile::{self, PluginEntry, PluginLockfile, TrustInfo}; +use crate::index::{self, IndexEntry, IndexVersion}; +use crate::lockfile::{self, PluginEntry, PluginLockfile, ProvenanceRecord, TrustInfo}; use crate::plugin::WasmAuthPlugin; +use crate::provenance; /// Version recorded for a plugin installed without an explicit version. pub const DEFAULT_VERSION: &str = "0.0.0"; @@ -235,6 +237,8 @@ pub fn install( confirmed_by_user: true, allow_unsigned: true, }, + pinned: false, + provenance: None, }, ); lf.active.insert(name.clone(), version.clone()); @@ -350,3 +354,344 @@ pub fn verify(name: &str, version: Option<&str>) -> Result, Was } Ok(entries) } + +/// The result of verifying a planned remote install's provenance. +#[derive(Debug)] +pub enum ProvenanceOutcome { + /// A GitHub artifact attestation was fetched and successfully verified + /// against the plugin's claimed `source_repo`. + Verified(ProvenanceRecord), + /// No attestation could be verified; `reason` explains why. Proceeding + /// with the install requires an explicit `--allow-unsigned`. + Unverified { + /// Human readable reason verification did not succeed. + reason: String, + }, +} + +/// A remote install/update that has been resolved against a registry index, +/// downloaded, checksum-verified, and had provenance verification attempted +/// — but not yet written to disk. Built by [`plan_remote_install`] (or, +/// during [`update`], the equivalent internal step); the CLI shows this to +/// the user for confirmation before calling [`finalize_install`], keeping +/// this SDK crate itself free of interactive/UI concerns. +pub struct PendingInstall { + /// Plugin name. + pub name: String, + /// Resolved version. + pub version: String, + /// The `owner/repo` this version claims to be published from. + pub source_repo: String, + /// Lowercase hex-encoded sha256 of the downloaded bytes (already + /// verified against the registry index's declared value). + pub sha256: String, + /// The downloaded `.wasm` bytes, not yet written to disk. + bytes: Vec, + /// Provenance verification result. + pub provenance: ProvenanceOutcome, +} + +async fn plan_from_resolved( + entry: &IndexEntry, + iv: &IndexVersion, + client: &reqwest::Client, +) -> Result { + let bytes = index::download(&entry.name, iv, client).await?; + let sha256 = iv.sha256.to_lowercase(); + let provenance = match provenance::verify_for_source(&iv.source_repo, &sha256, client).await { + Ok(record) => ProvenanceOutcome::Verified(record), + Err(e) => ProvenanceOutcome::Unverified { + reason: e.to_string(), + }, + }; + Ok(PendingInstall { + name: entry.name.clone(), + version: iv.version.clone(), + source_repo: iv.source_repo.clone(), + sha256, + bytes, + provenance, + }) +} + +/// Fetch the registry index at `registry_url`, resolve `name`[`@version`], +/// download and checksum-verify the artifact, and attempt provenance +/// verification against its claimed `source_repo` — all before anything is +/// written to disk. +pub async fn plan_remote_install( + name: &str, + version: Option<&str>, + registry_url: &str, + client: &reqwest::Client, +) -> Result { + let idx = index::fetch_index(registry_url, client).await?; + let entry = + idx.plugins + .iter() + .find(|e| e.name == name) + .ok_or_else(|| WasmPluginError::NotInIndex { + name: name.to_string(), + version: version.map(|v| v.to_string()), + })?; + let iv = index::resolve_version(entry, version)?; + plan_from_resolved(entry, iv, client).await +} + +/// Write a [`PendingInstall`] to disk and record it in the lockfile, making +/// it the active version for its name. +/// +/// Refuses with [`WasmPluginError::Untrusted`] if provenance verification +/// did not succeed and `allow_unsigned` is `false` — nothing is written to +/// disk in that case. Refuses with [`WasmPluginError::AlreadyInstalled`] if +/// this exact `name@version` is already installed and `force` is `false` +/// (mirroring [`install`]'s same guard for local files). `pinned` should be +/// `true` when the user requested an explicit `@version` (so `update --all` +/// skips it later), `false` when they asked for "latest". +pub fn finalize_install( + pending: PendingInstall, + allow_unsigned: bool, + pinned: bool, + force: bool, +) -> Result, WasmPluginError> { + let key = lockfile::entry_key(&pending.name, &pending.version); + if !force && PluginLockfile::load()?.plugins.contains_key(&key) { + return Err(WasmPluginError::AlreadyInstalled(format!( + "{}@{}", + pending.name, pending.version + ))); + } + + let provenance_record = match &pending.provenance { + ProvenanceOutcome::Verified(record) => Some(record.clone()), + ProvenanceOutcome::Unverified { reason } => { + if !allow_unsigned { + return Err(WasmPluginError::Untrusted { + name: pending.name.clone(), + version: pending.version.clone(), + reason: reason.clone(), + }); + } + None + } + }; + + let dest_dir = version_dir(&pending.name, &pending.version)?; + fs::create_dir_all(&dest_dir).map_err(|source| WasmPluginError::Io { + path: dest_dir.clone(), + source, + })?; + let dest = dest_dir.join(format!("{}.wasm", pending.name)); + // Staged in a `.tmp` subdirectory, not as a `.wasm.tmp` sibling file: + // `WasmAuthPlugin::name()` is the loaded file's `file_stem()`, which only + // strips the *last* extension, so a `.wasm.tmp` file would probe as + // named `.wasm` and always fail the check below. Keeping the tmp + // file's own name exactly `.wasm` (just in a scratch subdirectory) + // keeps that check meaningful while still renaming atomically into place + // afterwards (same filesystem, since it's a subdirectory of `dest_dir`). + let tmp_dir = dest_dir.join(".tmp"); + fs::create_dir_all(&tmp_dir).map_err(|source| WasmPluginError::Io { + path: tmp_dir.clone(), + source, + })?; + let tmp = tmp_dir.join(format!("{}.wasm", pending.name)); + fs::write(&tmp, &pending.bytes).map_err(|source| WasmPluginError::Io { + path: tmp.clone(), + source, + })?; + + // Validate before finalizing the file into place, mirroring `install`'s + // "never leave an unloadable module recorded as installed" guarantee. + let probe = WasmAuthPlugin::load(&tmp).inspect_err(|_| { + let _ = fs::remove_file(&tmp); + let _ = fs::remove_dir(&tmp_dir); + })?; + if probe.name() != pending.name { + let _ = fs::remove_file(&tmp); + let _ = fs::remove_dir(&tmp_dir); + return Err(WasmPluginError::InvalidAbi { + name: pending.name.clone(), + reason: format!( + "registry entry name `{}` does not match the plugin's own name `{}`", + pending.name, + probe.name() + ), + }); + } + fs::rename(&tmp, &dest).map_err(|source| WasmPluginError::Io { + path: dest.clone(), + source, + })?; + let _ = fs::remove_dir(&tmp_dir); + let sha256 = lockfile::sha256_hex(&dest)?; + + let mut lf = PluginLockfile::load()?; + lf.plugins.insert( + lockfile::entry_key(&pending.name, &pending.version), + PluginEntry { + name: pending.name.clone(), + version: pending.version.clone(), + sha256, + source: PathBuf::from(format!("registry:{}", pending.source_repo)), + installed_at: Utc::now(), + trust: TrustInfo { + confirmed_by_user: true, + allow_unsigned, + }, + pinned, + provenance: provenance_record, + }, + ); + lf.active + .insert(pending.name.clone(), pending.version.clone()); + lf.save()?; + + let mut reg = lock_write()?; + load_active(&mut reg, &lf, &pending.name)?; + reg.by_name + .get(&pending.name) + .cloned() + .ok_or_else(|| WasmPluginError::NotInstalled { + name: pending.name.clone(), + version: Some(pending.version.clone()), + }) +} + +/// The outcome of one plugin's update attempt. +#[derive(Debug)] +pub enum UpdateOutcome { + /// Already at the latest version available in the registry index. + UpToDate { + /// Plugin name. + name: String, + /// The version already installed and active. + version: String, + }, + /// Updated to a new version. + Updated { + /// Plugin name. + name: String, + /// The version that was previously active. + from: String, + /// The newly active version. + to: String, + }, + /// The user declined the confirmation for this update; left untouched. + Declined { + /// Plugin name. + name: String, + /// The version that remains active. + version: String, + }, + /// Skipped because the active version is pinned (explicit `@version` + /// install): `update --all` never changes a pinned plugin. + SkippedPinned { + /// Plugin name. + name: String, + /// The pinned, still-active version. + version: String, + }, + /// The plugin is installed locally but no longer listed in the registry + /// index. + NotInIndex { + /// Plugin name. + name: String, + }, +} + +/// Update installed, non-pinned plugin(s) to the latest version available in +/// the registry index at `registry_url`. +/// +/// If `name` is `Some`, only that plugin is considered; if it's pinned this +/// returns an error (pinned means the user explicitly asked to stay on that +/// version — use `install @` to change it deliberately). If +/// `name` is `None`, `all` must be `true`, and every installed plugin is +/// considered, with pinned ones reported as [`UpdateOutcome::SkippedPinned`] +/// rather than erroring. +/// +/// For each candidate whose latest index version differs from what's +/// active, `confirm` is called with the planned install so the caller can +/// prompt the user; declining leaves that plugin untouched. Provenance is +/// re-verified fresh for every candidate on every call — no previously +/// recorded [`crate::lockfile::ProvenanceRecord`] is ever reused. +pub async fn update( + name: Option<&str>, + all: bool, + registry_url: &str, + client: &reqwest::Client, + allow_unsigned: bool, + mut confirm: impl FnMut(&PendingInstall) -> bool, +) -> Result, WasmPluginError> { + let lf = PluginLockfile::load()?; + + let candidates: Vec = match (name, all) { + (Some(n), _) => { + let entry = lf + .active_entry(n) + .ok_or_else(|| WasmPluginError::NotInstalled { + name: n.to_string(), + version: None, + })?; + if entry.pinned { + return Err(WasmPluginError::Registry(format!( + "{n}@{} is pinned to an explicit version; run `osc plugin install {n}@` to change it", + entry.version + ))); + } + vec![entry.clone()] + } + (None, true) => lf + .active + .keys() + .filter_map(|n| lf.active_entry(n).cloned()) + .collect(), + (None, false) => { + return Err(WasmPluginError::Registry( + "update requires either a plugin name or --all".into(), + )); + } + }; + + let idx = index::fetch_index(registry_url, client).await?; + + let mut outcomes = Vec::new(); + for entry in candidates { + if entry.pinned { + outcomes.push(UpdateOutcome::SkippedPinned { + name: entry.name, + version: entry.version, + }); + continue; + } + let Some(idx_entry) = idx.plugins.iter().find(|e| e.name == entry.name) else { + outcomes.push(UpdateOutcome::NotInIndex { name: entry.name }); + continue; + }; + let latest = index::resolve_version(idx_entry, None)?; + if latest.version == entry.version { + outcomes.push(UpdateOutcome::UpToDate { + name: entry.name, + version: entry.version, + }); + continue; + } + + let pending = plan_from_resolved(idx_entry, latest, client).await?; + if !confirm(&pending) { + outcomes.push(UpdateOutcome::Declined { + name: entry.name, + version: entry.version, + }); + continue; + } + let from = entry.version; + let to = pending.version.clone(); + let updated_name = pending.name.clone(); + finalize_install(pending, allow_unsigned, false, true)?; + outcomes.push(UpdateOutcome::Updated { + name: updated_name, + from, + to, + }); + } + Ok(outcomes) +} diff --git a/sdk/plugin-wasm/tests/fixtures/example_sso.wasm b/sdk/plugin-wasm/tests/fixtures/example_sso.wasm new file mode 100755 index 0000000000000000000000000000000000000000..ac4ce863cf70bead3f559acb9661abb657b31cb3 GIT binary patch literal 424854 zcmeFa3$$fdS?9YR`~5g)SJeS3Q~_&m%I;HAWlD{yn8Zu1x*-V<2f20}gC2wKB$(U| zrwUT36o}(u>V#A^ zPjY$Azx+z=pX<}>FHf&eE>G;rZAH)?^`ZC%1owUYQFmFTJ< zf0E0`eiQypE+6Uc`s@97a(OSjQ2oB$>m5JTf^K7*Jfd4cmD^Kb<5Bk7SXOoQ;Q00Y z8zALQg}Dr^Y5#|xRj+FOdj4ok^p<3Qk@l9add;zyU-yc0ufFEGV`pCV%2&6qIDGh; z??^g}&-+&$J$lX2%dWfXSkhg5H+uOikG$&GWrweN<-a?2b+Vz!rSSTiE3UlkRcD{I zvF&lQz&S~OvHkva$F4bgmDL?AzUzf|=On|$SA%O_bxd#x=0X00t+%w2B+FW9HnKly zHqLp#L;XqhXFS%w*|?Rb&{Lb+Ob^;w*3P)kmeO>2GFIViyd~R8sdQU9&bDn^SsFiO zyp-ol=~BLwwU)N!d0TyDnKe147ye&Lvvi5d+mfYiG`4L#PE!8k|8bfDODoS?D=V#) z^r=tVeg@rTPaltWjPrck%F5DG+8Qsd@FX9P+W?va;Ld@-qpm?D0Qr?^DC#v!+iCWU zcFyB=3t$9Inzew#YEx*!pXF_>q@`E9Z*AL_3mgIXjA!zDyyOT|JutPDCF4YhXm1@S zTZAG*&$FnzjbW!-!Q)bzY@=T)3#l}$|E(AegGeD{n?WsjlQxKL^QTaqtlid+G+p6d z@B)FV2yOwu#H0*=Ymz3}mev>?PFpn2f8%jBo>@u%Cs)Dkkt_3T!%joRDV z?XB%RA8$)`S&I~HwZ7(Gw7Th$^k?|n2B+IPXXMAza((?&GCZD?>z}{3zIN|O`<+)@ z^@?kbepi-Eu6Wh4t1r9a$jdK#^;Ji&d-*l5OqRFUqoY?{`>K~8z3LTLz4F*~$>gc_ z?7CMSIdaX>V^>{y*(Al%^J(%zKT)xyB4R;P^7hU)-_C9;Hcw_cG*&DLwe9iQE z|N8}ByX#->e#V|}`o{g=a_)I&pY;vr{BPfU{7S;5pZ-bucj;f`C$gKf*JZEIZp!{+c4PL#*^g!? zvv+5|kpFysSN6W_J=uG+pUQqR`%w1t*+;VvXFr>LB>RQzXR?oFzm)xA_UY{Y>@(S) z=l_s>F8kZ;zh{4+eIa`|JCVOGzcK&2Y(0N{epCMC?DN@|vM*-;lzl4uqwJ0O_vRnS zKa&4M{*(DV`G@kK&QIm<&;N7&i}|nQznp(8|E2u1`KR*}t>4b?%l|{`_ww6Xf1Ce6 z>x=nYTYr;(A^-l?;D_#5dP}QF)8pBJB3aFnS^B(jqB|az>Ew6T*VmKb@URug4(M-@ z?1c>=v>e797D?G!o4i%UMj5}Ts3ua~_39>;L&3MHU{%WQRBHR-vgds*4~JV5o_z3* zQ|n3Do~)ms!tkA0+Bt4bPBVi65>8({h7P9rC>;V64<>q$*@H4K1^@bb>-jsT`LKYI zD3hy&q{t3^tAzGI;rxZPW;HU^@QU5?{;HzA%l(~-bVCbXGA*~h zx;$c?q(k1ObO+1~D_c7|>y^py5}(J+$Eg{Z2W7o?ZWJdWB#< zksO*OyX+pyoUBd$%MCo{_MA0UTbZT0Y@ht49%l#iVQduPF; zv$O5~YlTbe++@NfIXl}|BnQ>)WPi5GlaIaq)H+Yk=jlnFcJ>Q6p06_V=Wv}cJjOWr zM~w^21L(dfcwNo*K(<^~ryVO+wvJAm2~!(@F*uDG4j_^S4j^E<3Io>!jp(xNv(~X$ z^KRk)|Eg})&B`>TF>b(Wqc$j?weFgACu%U6fsmp#`8`#NU8jw%i(b*4+!x+2w9{$QO>(qRH{~Vt)!I94kK z&kr>D97bN8PVspBD$`Cigsk*EL&Tmy>&N;vQ;@|w-yE|tmA~q)fC7>MI zywrbP5-N#;D{;Y`qBqOr%;dy6V?K|nX_>4h7-sOfH3W-Y;0+z1A%4O9WqxiV;?^k3 z75xK>_!k7+COp-H*_#wgUDwgM;j&m0>o*nku1Zm_(s^Jnw5hdU%L(veC5?T{KYC>OZG}0@23|(<^HX4l4q6 z#pEQrvz7f#czMq39~99dxizdHxp&o)8;;{S6L)r2a3jscga*-VGW#XdROEQ6OYlo1 z!6{bFU$(ipZkOuT^_1ujO)@XFy{leBT%RAM$fA}zFw5_nbs7>fe*|%jsZ)vTws;Jg z80l$f;<_s-1V}rRnY^19QM*PAdn-O)uYgbKjx zG{GBn0%S0&ko80WG>Ltnme?IE%P6q}fIgck6USK2nHiF@29la^x^5HKA#fJ(wPNTJ zkdQ-EZyMo~hp&|ex=?2&aw^(i+;)*qWu1bDF4CUSI=aND9#(qAfv4n zMAh~=qb)ofRq_iJA<;aNu>+aO*s)*oCXl@{V

GlMlQ-2%FFPCjlIod+R zzMW~}@+8`IXd~Hmhyxh_7<97hAU`SahFzyIM!U{z?^Yqh+*g`Leb%10>)Mr|u&y!RtFGtlI#b55O7+$RMP$b(U^tna*pA%VD7B*D#MnXE zWVR8q+F~-h4&borYT%qFL6z(}Q1t}Bd;E5tyG>2})lj;j$zcB9x9f7*b=xbu4tCi@ zE<83H@BUWSu5G-1O;>JLZQ}J%m=ONVCVWrR#M^Z`6HgU-3&!23a^z(ERF+u^r++=1 z4y_o8Hhm&zXz5k>8mOTj8?!?DkUBj&JxQuM^>>7I{;Dl)@g{ zo8XxUoRs-zJY0Iy&d{pgZq?CM^VO4SkLuZh^6mB>{n}{TUxFI6?|j=lP}{O6w9;AI!|aTGw+mK!iM7 zn|z-)p(m4%I;wdCHr}8gL!a4cX2d52rYW17gDlxEcfp=-SF4(v?Q6T6&&{RN^J4z_ zYAnAx|9oG3zHk1yxf5#VocZTQ(bh3{v1d6j%)e+)voBjhqU`_|q{{`GK&T~sX_*?a z>HgoIml}E`g*Bkb?>lzX!l-1FlCf09w0c-^UW8WrGf?qP0tpqEd0;Xbkc6Ohx{j;-Aihiy6Uiq4}r95F7 zXION1C&|G-`T76w4>$eQAO7?glW!ZRWg9mCl^5A#AyJUdXdCBh;j zA7pIuns3XRbENW7J$Iis?)qHEIz=}S8n)SOS$R_fJ;uw^QGS5VJ4lh4F}#yQdC6M& zaC&Vy@c90xx&7#Ie{_Zx2h#Fityx@~yH6*I{HEnU*)=If`*WKLq5MN{XYvAuDwN-x zBDcT*sP8jIN4`x0Qxz5;Ou7h-)dIxr8A}F~J(*22=&CR}>=p>2eockcSet>#lUdQz zB1^yMMKvhC$fiB-P#qb^dOwt@`l0=vP1Nn#tXsgvpoJkwpN6J~3MH-~*W2aswHZ^S zc@z;~;&20bErdXB%K}PX-#|a*H)Zhq)0)8_1ckqJgQKuwa38^XR;@&xa5i=KrJnw; zfKrjc7t_Gcc||87DhHql`ac(Odh{K|_hT*`Xhw zOQ&rono8+yDnE$A*75_~)ssg1w~7Ps8zk=3-Vl>sX6u_Ni@c{?0G8AM#Ra#4M ziWD#u+A)M8Lm|X-=-gVYhvE!cAy+y=3|AQn1waxRu7MoID6nW>((sAGNp*v>S|b}3 zu<5)(qavu(8ugp4l2!0xis3U>3(eMCG;q>#9E}~GZx-}K=4=}E6!K~>^i;OC3qD?F ziiRJ0YSydW&|A``8~Px#5?8Yb?P?&0-3vX^bV9FwY(A)}U$PMSxKztptC_7bNQ1Is zA+ouY3p!EJWnkc63!Mw%$lO;4w=;M=EsK>2zTknj#bFkWH2a)Z7Cg{H=O)|e1wH)o z^O7yP+npcTpA)dKVOr=w1_>b+YD&16omhTJ3Z|43$&!BXL7~6$AM820w()SmWt_Ff z1{C9b5%sxT5EM^sSE4kvYNZ9ay~&BU(^s$;Sdh~%Uy#GZl-?GqOmFl1M6^Ag(NvWR z7C*(*EcMw%?OTP?C8 zSq1F`jim6IyhUV!$&&5$;<@5jomwMV82}|?fHL){Md%)h@PVD>mpy^BFsAE>;sAX8 z_3sjJ(ZJ9X2Ygz!%E_;Dyyhd(CJqzhjP&`iXuhIPh zLmFLK8S6@A8n~owv-g@S5n@}{!j_gMoir*ed%w~Gl;b;(HJv6GXW=B{M znDv4sO2C1nyVIv<5p}36QJa4kDwTttjt1aIFiDT!u)e+?`Qjnv6>~mN)7YN;>w9`ER@kANZU}a3K#fdB3lvgZs|RG60XHPC|y-<#6hIvtIC)r zL-ir$j2k)^PO5oaOom~TK~0Vda%dY}{+}cSZ@`uf33?l;Y59#BEQ-aDHF=An%8_L_ znB1Tz;>vP`ToC8-Mvu5NZ|%3(d31etJdb&}r*LWVFgeJrbCaHiAhDE}C$U(`&t98+ zLeuEe!ASGH$w|g9dB*LJa;vqPP@Kt{yhX)}oNyJfY?oda3B^2lhb>){q*OB^OJcC~ zNKpl1Q5VTW?9fDm&3CJUK;gxTUq-;!+Ap&JaNWRnAb-n&lqSIgG3Z#j0pB!OEwANz zWYN)HTyew2vbDGEaR{Qs+yVqD9j$dAHsev#j*FrZ*RbJNMy&>Aqm}Omq^sGTDKime zn}#Ors=V9nRbj)M6>caNN4;{5g;S=9s9$V#+K#tt)0A)m1+UdKsf4vu zO_SzRLOi4pD;CY~W7ABD3*i5lp}*=qB3V8A!lsw@1l!iT8cI|3g+ng3< z-!KvW#wKIdvp4jbADQ)SX(?6ZuGZvaDi%GvEBdO0LiY$`8Xkrw$ zq3Hfp)E~oJJK3;~v}k)k%f(WZJGx43Bp&269YN3vN@hKV=dEckI{>Y>%Qt0fQ;dl- z)9=q`gK~6uIwbxv(o0$xLg9nyNU--A&veWM-dQS!<*~Ktupq^3Z93xNNR4!hCGAA% z6k~Nf*3I*G_!8i3Nxu#iOFL%cGI>G4KNla;Vii9n9EZg?s0NGW_E@8t_K^9bGCKzS z-Ih-kh0*>HGBGYjbs^&}(0CI1g{oT9<)XEl41|ebZ52JeCSg;4X=`_WfY&NK>&rLp z0&tWrln&LsF=;~$7WugT8j0$3I+Ag<{4QYGet2!x54i_@v0m8q0O|K3{n@a3!rTjC z&C=onub{b%LRPfZDp()f>ELeXf<{^4vb-NL-_n{abHz9;c-oTqqILWdTIY^YNyAgl zcxxYTh=6(#9L7cg@Pp8h-Y2t1?RZhC2xVRC@#J39MlCb*04-Pmx8+rSp&<}kLAr;P zI(5zJw5>UT)bpSsmhY^m>`fWB5lQ{>Ru&Cfz*B=H@Igg|7vZph8o67FMwsP;abSuN zY$;IW)Q%O^D{sqVmevEElua`SWTiHCd}*D)AF^)M)UiKejiNvWMM~PRl-MyzftX{}l_K^|?@#wX^%7RrXNl_3U4lVG zHvl0XWVaX4bilpwC#~I_xtqo7TVMl(`@K1XE|MKFA(HlBLl}>yN$^uM&mU(a39yZY z%e#O46x$JQJb~E}x&{(0CrBrDre-B-l@!J+Z_iPQjy_=_qhK=aie|Ym*skzl0wuxf zyiKLS7f7L`4GyQGffy#{^mwTn=n6Ys!>Ztb>CC^={Rjr6kvADifJM>^VOEQM!=(Y7 zV%gL>Lt&W~3#gMqff-@S_-*m2^8J7Gp-7XerrH9Lycoibk7{CEn?~5QZ?q zZ2I(5SQC0>m}PF1hFg(}u*aSuqb8YhG-Xsly+BR&?vb!YyQ%Pn)h+<&lPZu5?Jysa zq*Hop1-eOVx(NV~vqv+XSZ`Pr1UTMEK!Jc@BM;x2rdi-JQew>72Axt1NlyR%m%+St zZEgmV8vVwfX6wn1hglhKV+N8sP?`90KwYeXeoclkqtIr<4_oP=3(-W}N~isG<;Q;v zqAyZB?%%l)!XG{d7lOwS#SceIcs1Ku4X0qh0ixmXkFvC1iIUO8$=AzbX15p^4gZ3`why%`xHa5M5)_9#=D zf=tP@o~xy!ela+Ny}5PIv^*TjW)A2PR5NY#XtHmO7~P}4=cO$_wnMEwhihL7_i}B9JA#$0R12}3QH$1|M|C+k?oqbszB9!;j%20o zL_h3mS6-43TSfatrcFB9o3uQE&r4Cu=0F^-W{}WGEuorO*dm3W*gxW9-r+}wcls5{ zU%=O6Y&wtIMQ4$qxMjKYq}qJ+0UCv_x;OXJcR)Atem8yiyv;mcNehJo-Vnn9IQRvF z0%)mcW>Zbq&h)S^NUf909;v;b^Y*ME{WBt5xa9^7iT~HF19YbMq}Dd+iE;4XRQ=h) zQ1&?u&(j7;(t9BMYL@uQT!}AkQsPbTXtr@8{A#vw`&@}zV~PKNzqLv7N84}xBk3Gf zgn&nOK=D8%ZINk5I)7sX2pvdFN=!LJ-J9r@KtIMW3cqj%VE|%@WlwjL_ltCBOAO}u zmtrpctP<>v-JGx*RFz{K?lQuA@;8LP)*vd6zW`ZaoFQeb7}T<>)Jkrl?_i8pvllli?5R+<*;%M-#uZoMv`Otx!;mvOC*Jgrx#PIwQtU1;i-&EO2Bd zw~$(Ljzs&`#2ir700F8&DJ+Q4SIrma*L+(Xnx)Pyz{MVpzY6hB`*F%BSOYHtOBKoR zJE>fOs}3Dp3|hclt-#I7yMN3GBa9Nr8G$mcx!VaeicHg^{T6h1mI_Q&zr(JQh^gU~ z&3=o8ev3xGx7PiFA-Xb}v+f_Q)8S*q%+GCxnGuADdOal+4!_zO^M&;sHdX?Vo=Dh9 z(8v;)NLJIxlP!R?p71|HsYwk?saRIxn184+`J`FC3lIOm9^SXJJs{NG;{R@>@RJ0) zJ5EE{fk*!85GNsXwTfObJTzOP?#$xr-5`3XhdYcz(ID|vz>DT| zW_h<^xFPkHAm9@j?pOpq9YDv+#h9Uwv>#xLf#9S|pVlh}<6H|-4|O_DS`n{A;|AiT z)FEPdasGUDFHYS;jYeBGk!G4~af-#zVmgtFj)nNt)&*ofh43RrOt|YPlmA;ED=>L4yv5(NmS3`;B`qRQ7PY|Ul4Kb%7d{Z85nH`ovhB8+)EQ4# ziplgT#nSYt`GG=9Yonrf5j$dGIRa~47jZ>q5^Q7=j27-q)}~KGM73W`w5ZF(6F?WO zXPR<^Q&&_O_TU3ztkk@jKqil<0N^@^vuWzZF6&U(eRpb>i#E8A)f+0#h3 z@sGcE@ir>DtUQWBjBydE>Xt?W?{3=3l&~?zX(WH>LS0iSaKNMVOu-0aPB74utTP4q zFm}2Y9t2X^JwytNp3_`bjE$?*nI5l@4@(in@u~LWF$)WzoxzASN;t&A{e|&-V*64`V3c-w+1~Wb=33rmzOA`fY@9&6>cnkwndpr0bp`F*5 z`_FSDPr9gIg*b~ELx4A52_#j?7oIb7KqWM^kXBIjXbenHiphblsktmA3ec5w96(Zp z=ci9YN-B;DW!8_{>;Mc3f)H;!h6x41(n*;ZUTZ~RbV{RA6drf)*aD&!9gqP|W({ui zw{WzUZYx>P39{hklmQ@k*_va7eFhCk{Iasunoi6rMd28c=S-o}2coFRB!@rN23?s?$oUA@ad=Rojz%scA~74 z;%O2vPgNxeK?xWj36&N^+d|J2i~{Cqnm*f`p!J-!+0z(>rVvOad?iW332TTvqaJlu z2^heTV2~9_Y=U25I4)wm-O!?~Xh{AG(}-`Id~8gceDN?s+{hfBmWaV;WcHsX5#u8i zSQ0U*8VMo>da`zqB%vJ_F`_eRKMDqsl(jTTz**PhspQg$ zI-gD*>6H$sCNy(K)R15fX=Pl*I=|>l3X{{5T=`x&meg^riw2$3x@pyhYu#S_twvbz zEUx0v^%qZU#U@tfLUw z)<34B%%F{jjGwkiumMZP^B4Dkpp6tFX zSXD^NQ(83O0K125@gR=*Zu@oPiS_kFYJ|n&{1$b&M82gjNoIB>OKul1 zuuq4WeXumTV#f~otKl~*?*9 z@S}O!jvl4*6f%E~nknQ^L@$ot>+F!&#TCqv=5|mhJL-5AOB}jC%1h7WVbaSkn4b$;Rp#_~Z4+ zbu@Pz>KGf6aa%314}a;Ixi)}D z!}XiM+`zFHz%I~?F$s?4FT}Pk#GRxCe|OwwAdUzHKwiN1=7?|vHco@_nQ=8RSH>qyIaL?(guQaQlvq|-H)cg6TtG+eP2MA&KaxO${ zL;)wiE+HAyYb5u9B=E_-5sh z4rj(I#%-{F%Uq?VeI4DEYER&H^MiFZZ%^rT+8E#iof#E^v$L~%7@Fe>Z|iPeOZI3B zE0ckGBqHm#>Cq`R!L*I~BN-$*OOm@$ zs%)?HAYhT6k7|+2(MjRLO>=Gd?rH z-o!k_EHU%v%holGVPy#ZdmwZlyu*Dr+a=yFQ^ip7K~NstNI3G#B(y?xMOQuIU!O_C z=Oso*XD3QH=b-I=07$WgqOC3k9j$2F!acAc%6(4h<4>_GV~#<9tUq^#NmgPc+f2xk zU7QGmpAFn*V8P3hDA*430&uCqFnno3N8^uT=i8Vwz!a;S6JWA)v}B^ADUSf3$N@DU zpG1;H7^Ts0sM${Ec$NkO*CxEF#S@c()Ji8mXhJg7N!Q?V0a5>*!>C+e=i6RR8A%@F z+pbKdU3{ma#ea)`nvdE+9KXQbN#1C)LPgcI(=GoI zp6A9aVXJ%FW_53~y0_PLWkZXJtje96Rk_ouyr-%XCRlXG;w0Qq1-GJr3(p(u0Rh~d z&W8KZJ`gFo{C-XzL$VIvWe4c6Yk}tykCWff6lmv{3pt6n(>81JbQ3T{Z?U_%ZZ0C4 z1?nmmw_7rKC%r zoIUj?VPGxXJyXqF~j*gLJ7bze#S z_7kPmD$gv_?qeG(8wxfe>m?e`OT$>(vKHE=oh+h{&t^4oem2&;L^Ut1Y6ep?@`?>9 zYv`}BJjb@QWrqH0SK87|{#Y$&%N!=F=nDjC4O6((><+Rtw@YqI@9=^2)ofK=i+KRCF)=-hSi;q=DWLIz_WzWMMZ!9tBK`SF0f3A~J_ ziq16PwMqp#Sv3{0V+Cj#&xKC%5C5#&=^~HgCwNE)`G>W>?|+%KUiqw7&Dz(2KE%sUDhnB@Gr1rM zf3Co-W^L>RP{ZVcllT7RO<(x&5>8JiEI~L6^&tg>PrJ;IJtseP%P+4dTFzJf zw3rlIsHnxxR!OW;lZAY=!fgZy{bS6|3e@29N#aPhOsZHh02H;O6owF3)>xo^;Z|CI1G)ppB zlE#C$uiixTZCR)>o(9`Au9#3}EI*ndk*tkN?C3sRE<4Y!Ek@hLOfd)jFb8ezO|w)_aF= zhMx5*U$?l9q2Q;zfJ^J*>vq@@s>*=C#WELHWsHT!k%ergx{Me$>RqSidlegv8*D^g zfV0uK!A6A2jg25l1G)6f=c)C(*_N{YK?P9Q`r4y zDBa{L)W>kNRsr$0%NP5Rmwm0+Rc*mF?6o#mF}7vP+6}H*Xp5FESu-_a4A1%slMD~B z@)&ps0W5vk5*NuJ>es62Qu=QE{5GUAYUhPgKMii92)e2rbQL;S3?+H&?9>l9ftQGj zJ5J!G#c{BpKgUaR;{d@nUT_S>{E!d;q*N&4>9^cBC8y0u{+xXEnF^ugrS(#z$Yqf(!B9W!7Z}7m@HzcY=Q-~tG zhJN%K`VFrE2Sa>D%I`|S859l2{4C7ORhXk*kVtrf{e|JXs(<#lWi4GG$%+8|M+K{p z1btKmVI&=Mo*mF%d2KD=>sgEr`9Tbz?wI#%sdX-h5T}B;SDpcBX9vo(iqu&nXDGc2 zZA2I9P3}vhx#l>`BC~~+80M)5G0`9@`$7(Yflia>@)1mpXQrJ_S;_Jx$f*2_x&`i< zf>E;Aq0$|xq*hN7W@G1ts)kjO3WKRdH|j1WsWw(M-@dYCOrTq6LlXuYZ>-P^<Bhn_Sw@8D8UzK4hkD;pnnAbjpE)smY-;mr}9xmH2HUa*&|K1XQYgdOq3FUv^f92FU zmDWlBXvJ2r4)KqSVRFUu6Hw{#{0^)XQn=)6=G9_AN2-noaWMd{f_(Sr;wd@=uR@+V;in8=9& zB^b>5uw<7V_hh=@tNAP_s}eI$+lqPELUHk&o!UN)tqaFpMOGr!YZ{5uP)-M~o~{jj zIbyDjj=B5|N~S&9Q24C;fH5(qCc^3u?Oqe;9BCdU%XiG8f$SWHAYl$4YR)|X`qTU8 zo+2D-@-CP^=wSH!-FlO4R(=g_X9w~FKujC_0M#Eu<*$CGO%uri(bp;L@i9Q!*zQ1Uen z4V~In`LPXHYpM*mR}Z*nbUr^|LqauR$BqrS1K)`Bn*)x8o$7m=43FGGL&L^V!oBmO zgl2Vgk&m((uq2gyofv2M^urHx0Fu5OfWrchbizAr5~$zd4Vi4u8qc1N*aHRLl$vGQ z*OvZmmL!Bisy|8fC*v_Ms^6R5JnCyH{QIw8EAM|V!e9NbpL*?D`HA!V$1NxL@n$&J z-mLTEpB|3o)?YLCi_Ff(yCZYI4$u9%dhXYibH6U1`}NYfUoV;ab?MwMu-54PMa^IL zU&OCZS5RLN-w|I|PR08Af%rsPfoy@sHtpf?!#OHIdZ|_b2>k4T=0}QBawE$A|3#&K z(OX(dBY{C}P?e-Diu5r^a)lf=NMTq-y1|cL>6G9UiwsMpfmst?ltIbEKN}>r8BzKi zmSbL#GR_E*P%%lp{+~{f4jXNuoM$cjsP~+79{was>^LeYFQ4L5rfKu3E3aR(V<&QC zSGtNd@!*5MbtCZui}GzXcUQN4$!8~Sg(=f)a*$GnO)IpNLVjcDG*{wl>*5St$D3L&`E+L zidGPwi@lmdZ#}-DgJTWdM`J@bAlc2-9J-;S8oE?T7`h#ngDN@Q(6MfP=&oOE=4zuNb0AW%)|-yvv~9hAx6WnG{M$+MSzJEHWpzs z;<;I)T_KB&twNTTR~UWYkk!|Sx&c`O9)c|O)^}tLHP!(>5w$>;q1N0esR&(c)cBFetm7+*jngLN_BvoSL!zX`d%HCE&7ISFlULR8PpC8?B7|IqXT$V3q^OjECN~f7KTWG zm`YhJm-6|FCH3~{=EuE+cZQsm=R$o*W1t20(dK;!eq zTY;6$`T}`)(RkZPDZAQ--!%s27V1@}wra6)T2)w6TAryGr4=%FT@3A&50w@P(5q~* zKJj3y-(4$~l(1+`@Prgx&ATCdv`zf~Y_u?!=PJuxr(lVUQ<<+$7g}C1OH3+XazJ^U zq7)->VBsx-Km*}!#XK}_MzfC1rJR2Xk%M3yFJeHn$#w!UkY5#R8fj`&2+`*a!TUZ? zfj#RUvhS>!>sqmZ%q#3`sVE&I7={eE+Kb!qt#VM5j-8m#12&XE2tvCJYTwyx2C-VUankOJ{e%ncZgoGwL@T#bfkW0WHM1R3Iuu&b zNoqREH5olce(?Rtnu6ge|4>Q={27IGV1VGr<1^OP;;{hLGs3^^ zDq${PFvh~S#bh+c&F?-{+u{IWlxq{(Iiyu@=8RGh(7aTm1qyL;-rB*>{`ULd{bPUr zYd?6VeHKNJwU1j<+E$$RaxR9ch&J3dQ1BU-T|zh*!y=Qc35)rE66MZs#Twrl=W+*YYnK$F}=Vsa6*k< zW5$YAgiNAwkyTtXfON-hz@0{1;A{!4hC0x~U@ymBF`>CB@|5|~zzgf08Y=vt?R*+B zLj?_C;9`=^MofXn$*abLR*TO^?D6_+-P>i7zdEuF;6#Y} zhb0>=!19k)d6W-#Cd|Y?&jdhbdGdh9prmkxd1Xr9c~?UW>05y*2*t#cKH}mL1lVa% zu@m!(1%vJ$%t!sLUY}DI8lr3%I8A2Uf*_NH5xz|*c{W@#o{i^3v~|2UL--au$vT%X z6iWiej5R7G9OR#T6Nh`4D5s^KyqA8t6Tw+x4jwCSIJv&g-VdO4Lz)|$5lvr^jW#23 z`Z!qRbh5D!Pw$#%aFdSI-Ap!Ed)1r4BEh&O6ty^ewHiWG zG!1JxP))-+Ok6OkT^HlAO#V!S7!W8X_Px9}|PEW=HH;0B)|HVJTCK^WCxMxVf3+{A%Y)TW5~n%nsxiF;cm4^wK!kRQwQ6 zU^Q}^GBz=?NanUwODAom-rkB{l$a(99{=9HfaC+?WOK6E|0xdb3cVcS;#I!?VL>rCRp79!a;Lxmtas=- z;FGqxvXT0BU6;>G-=-^y`>nb{fb4&0mmd_6cO5KV&po>&^l6mKkx)0kS6$rtdj91F zg3HgYB~=#OsbHBNopn|dEVRo@4zN+n%(xyT-=VR*18+pvlS+$b@VBK)Ia!G?MfkdR zg>MoBc@{Skvvp2e`>A<3&Il$LcXujfx?R4J9SH66M=WbVrl)2x`H#%v%?zX}KPZ!n zOk1^Alkb2i7bjP13UP8dEPylzt-jrD<9!b8zmKtRRFr-ES*IF((cVbuobilucCq6Co-F5 z-Y5hZN;G{Ug-YC3ImgVr&=Eq8u09jHI^VlGXDU~OZfw3}1JNs{Yv4&Uop82glOhVV zs-fPPExc~2SHcc_eOvjz(An03{pP}&wzkuVHvK{H%a;Z zKdnahEHsL@$f#hy!{C?2^Q#Tba^5fJ4)#2v2WGQfNkP%_Zz&5vEUn4o0R%4tSU8!p z-yTW4i!>MPx8<`909ppSJKPC+?abeZY z;(`o0hZi{VZS48UC$Wq|?TMc-NNv)kEGo#tnb^H{Z$WEwikKUf(6kHx67)9T;G&*y zvSn=EEMvUxQ}B*J7nk0QFOS<;>jCMxj@{(K8k^@6YeztEZ$|}E$bEJ0*az)VCXsuH zA@MB4khlvmBBDm(bZr65>C)$TG5Chthu4oKqp;LBZP`9&tb zA13d5al8+Rk{zwIgsSme6N})AeCxkVoFNsQ)_<8fQ`c=f0c5#EH@F&j*b#uT@njegpe~Zk10&+@%oV! zwiU@yF)@2i@=U-zIkHdJsL%B*afYfaefAw3()pIf((}#QT(0C}N8KxXst|5U3#|-vQGE9vh0*juDB{{W z-V75`r{LJRLtVuyon;hzoXnQ(+$1r~k}(X&nO6F799&fZnCF__bSzkxs@Tw2uIf_c z7|ngLWX6^qezef(NDBetnPv8#Yu8G*yzg`%v$n8Md!ImNn5;B>wLo6o(CHordDTEx z`ark5_jDlRL@$6`2xNE+pZTi=GNN>$)140Tc7Y7Qonj-y2(0o>p}ES>5M|BA(Z>%dEAR@MXo0ge@GeFo7B^zubh{ zf!#ivhzK~~3Bg^iC}2yg1X(<>kU8Nk?_i6fW%i7qGzsTNFSBllj<;`lNb*Zsbe0dPsic09vQu8-|33p{T?b-^QnA zBhM`!p<$&!hcW~!?FYIwT{K|pL;QL%bjvJM%pf74z`g8axR8m+4nR!6Qm&`Z-!ZfK zXZbWJ7k$KT!6aKYKJW<~vtDcrIw~Jd=$NiXa^5#r?#ZKhrq2^|UMHN!qg%#kbN{ByLk%vZ};p|Q_VpIp!9(}`rSsy}t*(2E`!1U#qpv_|hp0DI2Y z14V_B&Tt{&_>sc#8R7U*497F=@Nb2)dz(1ZV3D3n=WQYY04k#}SxX`k7XZ{U5yiWlcL2O3fFbo_DFTd?GUlUS zioa)WeQac=tR#RG8HnQ3W|8@%i)Go{3~Bd*gG~OUVdoUWNE!U7VVU+a_d@|YVQ+?D z+ENu*4*tM$IH|8|l4^@dD$!*vx9pd;bClPcD^ck7yUW*7X1(|P9ezm42+*8Qg>h+` za{VV77-zP9K*~hd+y`CVva#9ssi2N;g;GZBAQZnb1<(KwgOb+s*^(G-VJ4U;Q__|( zlComfii>IzI_lOti3nw?0JdgYreeu-DFtZjwBpTVDosf_FIP+}5hsCAOqAc^4jtJ# z>I_|OoihfKJ=`?1QwZpvILy-gGMR6-V(Q|lYd|kD@O%o+&epjBOND*Q!B${E;+P{9 zHQhq0tt8NHM4sy++v(vY56#9*999|3vavH`sd2rj3qbBOM*4MyHxp!2QNJZ0hcW9* zr>OzOSCbqr#Pn~On0`*E9mTXGKN9jIp;>3{1T9kO0JMmANN?k{L-y4IELeB9u=dP| z9b1UsC?2B*T7in-Kw}8a64pE#5N%=3s?p4INm%0{%+do#Blm+XM{kI}7AsRrylS~@ zRB=C8(tqTkAT0zc6r{rV!E=S(4{q#quu}NJ==1?3XaFf;&jM@XVi`%Ku@mYInq5RP zr`daCDAT!#YQqwk*I+mG9GMgv@=z4DKx6R7tdYmqjs#zul=fV(N6; zjz1SR^*28N!L}hXLvKr%4e65>&)@_S!>pyIw8V{ImMEVvtGL;Q^hrMK&M~#ZtYMof zj#*8^hV)6keYa5fk@9tXpTp3B75??vhI=?VH`Aba{ZE|ek zs(-eRyFIu@bZPN@dJ}#YWH+snv7ObThza ztXp_!6^GZuj^``Nq=+Hu8QNgnDRIzs{Bjj`1jeF*cGt8AxpZRK@kL(k0xp=e7xMy7 zo3}BD;)5lYEo!Bqo&HflH`GWyAJH@j8>p0t){0RSZSA&%4*Db3 z+2~Xqu~$=<$*kw@Jt;sGiKEgh9SzQ-EVC$mXrO5rg*^jA48>FSQIWt? zx8&?Z3vvGX7>evb{97c}c9F^Oj(G1*(s>@+a(RR5-Ebsbr47jw|lp>7Izr2gd{&uo(L$Oh}g-OHz|mZ>yzWF0Q&r z+pttWBB)Uk#$bu~{5*S?ApoorE6x7px zIH(Wp$mxPst|v>1v+CruX>}8YV@L5AD;2$9NwESZOG=hsdwy>PE2m;S#YZ43LQPbu z_g1v$_g1JdmegF~4SOpf%%0XT$yrYUn}nM%q=Bu`oPINUJFWjT@AXt`G``ou&H^Ep zuCXc-z;Y)?43G&tRf+Y9?pZ_C{dx=M5|^s4cqp+563>M<6ud~5D2cWpqBBr^%G6j9 z4(1+c0Cb@U5wfRbcOT@8@C(EIUlOPwTlJ702X^?28kA{mqk}*7xOEgz?l%Uwe1KNev@#t zi8;bkMQbrg2#{OM1}#7xNYAW~e0R2D(nofbq?CD_NjyP=Q6!oaGgDT$KyrvGMG-0N zPI_Ww#X>!zk>Ku80R@gL_iN6y!G}f~;e#~|l;3FAsUe()6TGls=N2s|C5DKYfCoU; z$B1P{R2nsf+=n1c8o3XVnv`N2gh|MKFz42CA8KJ@2@F)J7k)$T!wBSg?!y8#X>J5f zBr01v_7sRFQF$>Y!KXi@KzP-1J`+OeaB-)X`N$017R*g&1+mm8#J2Q|R$Bkb>RY>O zA$(#pOd$K_XveIe)9(k8Vr6&)J_WL0x zmR7cVN(HHeYI9-f&-?K`TvW4knE_Nk7oO^x=zkQDV@uM1#CPYx1|C@@29{YT<`AW3 z75GR#6GyUwTwo*|f-G`3Z!MOC>Oz_y1$KJ5?ZN+%7KYsA8FlavT^SG8m+CACr8>3z zxvY8#Zp;mYLzqe1HVw}t(jJNrkP}9`L@nX_F9&2`zYy86xuh@ZdCf)o9y-u_*@OSL*Y=FM&pdggi@7d zN`ep1qGhD6_a^5|ZI{|vQv+ll-YI?T!8<2W{%aNZ3d0=mB?u`*Ml>w@2rk$c)v}O= zo0WZAiEx5vQ#PeaR#mB$!T8#)ZXgi^P<4t0Xy#raa=p|zbl$W}v`VWl$;mEZA0u!h zPq9rpVMk$Gwh4+)wh6*lXX@Coxo$v6oGQvgE@X_KbsF)rE{EAr3){q0neA7mii;Bk zO*(FyXycN^i3;Zng*V2}R0`XKeAyJRITSKtwZ76Gxg_7-?Y7(ex5=Mg<5}ZA(c{MN zMk&9=nb*23#)Pj`7`aLk*-<%4TLndy{>!=H;a4$mFriLC8=ea9@K)mqKu0r32&v5= zSrjsZ@C0Z|3oMZu+I zHV)f3B+gn`HUNi>Bd!THj#vc8)u3yN26vG*^3lT!(nB5mH8{Z2)V4O1h5z*Wob;I6=N9; zIy;h3bx6NdbFesuJ{4UI9jH(3J`**wfjlHZh-1-DuSTFNhr{YQ zkbD$dn6?_6M_UZN;;6jevL{s0-pNwnor-v>#c@lukd?gk)#VXoT1Zy#n>$nF9N;7J zvwi;Tf*|sYcmRJ!vZ6SQcmO1P(a*haufE5}-j0pXnK5RdjAe$yl zRCAiVoEbHUQB}jIw&f5HHClQ59utB1g&teYK?13= zYQS0$rpOGowIFG0hVZD8&|n-@mGBf54iJw8sR5)xPu_Gw`i-n^#1FQR7B={yuPM-} z9iq*Jm_J?}3$0@TuvDmThCw8xyzQr$jQCwwhDQKE2bt0x=2F3)-vlMAV>hzRyTvT0 z+tjI~#KgE|^>?G9efF!eIwoNySRhSz}?n6YGZDaxL(3HL(Dc?xj`m%*% zys)T{sM%@~4hNH-%*x+U=1kdg^wF@2%pdZ_bcoH!=kLg{{R3G*5To=*XK!-B40Vdo zLn<<>e0BDGD9$+XrCbL4vrDOFO!3M2)k>&XqlFpSC1C>$9~rS92+^>9PiU~Al!OS6 zSXH=!NSNemqXgwm?IP`5#8&;PFYT1z5|<+pq}NL1&X2A+E;)^h@0+7%ccey7A#9Qr zZ6TnvK5S#lo(QE?@HE;&5Pd!2e~5r5FT$1Ja*2SERuHNnyO(f*5`{5a9j+i3s^^!7 zT`@cwssbG~F~+gVr$v_u%&Jp$xzo|L-B)iNjEiT%Z?0T<*hrHUGbHfJ@b4Cp=16sP zDL+un@A2kWWo8)TNjKwMfuuUG0x=2D$d|mlt+^-uPuiqg6ht0n1~MgBg;Ltc4&b!( zir{#M^a^*p&3nRleiX+Wj+Xai@f-7~sB&7zTZQqS<_d3cyepSUYq2=sA-y7aPG<74 z8BCuQt{f=2tno}M44ZC!beNy`#Yv@kB}N!Dq#unI^B!Asfd?~C{0Ob+JqmE_UU`o# z4?NgdeGhl#J^F!bar|0wXnEiP+TGlHL~vV-foa58Zk^V9l(kB8O&l;;^Ei-C7EE4d zxcm`LBIRUJ5oy~n<22b}7I#$w@(K2CI4^mTIRJ{-x@Yq&auee|h-NcZM-#AvgEd>jAbg(+m+ zZ+yWpZQSb?>`_XmDiAnQWR5gbTQ_9m7^=?*&!YX|Shf)DZyd`;=-#9RVV~$7hGW?b zTVtKHR!95Cb4!O~s4yJMHV)WQ;XEuI#=;x1701n5kg%mJX=|!Z!Xip_`eWHB>x+a5 zS60~J4aBkHU1Wxfz^plK29$SWn+BzY+d5!L`(pJ(+g-VEFqUnB@kt9$s6!eunQOB6 zs1eWDvwAv546-YT$offr)zZ@ow8pNDv7*YGn{k_ZZUv}}SnJwOVc4gHCJ5LY*9T&_ zw|ox~k(e^1JaNZHO?a1;B;`t>OE2a^@ZkbIG}|l&@(P}qc2)M&%{K&%yo z!Xp}BuA~>}rAlbApIZIs&0~*3adnSP{^l>A9>wbA5vDx=21)}Z)Dv%~<%l9=^v%tG zaDJI2pL(`?;2Yc01VtqeffvotHI<`RI-x6nrXu>McmgR%v$e*Rz+|i zcOP#w9*gPG?v_kij|0Y{5`uSM)@QlR_ zQr)^%*6|Daip&3WVJr|OjgZ|N1&oVhkv4!T;<<@bhR+(}*tDf~y|KHYO%!g+*VP2e@NCe?`i(X9@jYT^3svuth zk^o{ixS7eZ22NC?hK}m_O!Ox>4NNsAigBq$3AmJhm>D;wd4zG_IIqu2sYQGV(WU=W~mY`iK$K=QfE?O@eNtZ%f{=bZEm`Q--2n=IvO+uf-W^{Ptp=uVkcxI(T2`X%B^qu}`0q ztU0jnW;m|aC?Rl;;>aN2PeeBmJ7bp}V1`C#Zz2*RYA@_1I+e!M z9k!Mh9Szk0;C6>x4q`2A0w&>Yh8e^k`{14XJH zb4q#cab?jQcPdtfFW7BTKz(jUlLAaoPJ4h+%d8$$Ly}u0ge2rNxfr#y%*ToEa0myr z!XX^og+n;F3x{yTd4YjNwHyq?>_bD6`MT58aQK|ZGYO~2x1d=0$!0j^_7sY&|1vs# zQXnIk2tOpNN&QKIGF>K&gBOy_2MTx4bRu(^PYqxOsu;*&1G0q}VW-m3cCcLu0l zX7);;&&4Ho3_od~t$-tSG7J&v)|53l20Kkkw)f_$* zb|4iUbj?R6tX9f9mJ?r9VYPCu%7y{d5iCb>Gi)OlelB!HM6Wl+Q4OIRXiz`fcxlK{ z22H4AF#Pbd(&ZRdU*~Z87*?0~%_b{?DarS){?#?ft&FheaL6=}{*7Q89vB4S6>vboWw=EX=@EbMbL+GK9f52uNQ zNvbwoulX;D&~K*Q(A{{NDxWgA;pDnbQ$=Hi1!xl}zBL<6vCdFFm?*YmQLtr6OEn`r zCB-ASszQv!rTHL`rAeMg#g=)|5dn{et+~P*uw@f~xC{}75HD<^$3_CZ>q~JZ31f(A zn;A<)C^JtGYbHPV*-3@0_bz*W6tUn8CRCLSf-Yw+xIyp&ZKdk6-GSHTN9J~xgD~L^ zaYWMCgQh-jJb^72qm&@c4m#0sy#faM-0Z{^<*DTH#1&3=Rzsc}!0rxm1EjTCM3m*& zVJla4xR~3EQghs4EowIBER|8PRYy%*)wx82Z&)gPAM$kesx1S>Ue!}H(`UZR51zfQ zSmux`=|<;3nr4@+q%<)B?eRIPHK3l(Gk&~50v&t7Y)UzXKmqAsW%>fz)nNiin65$) z>O(R_{diaig@Na(-$c|93bBJ_Rg1p_s4*Wltv8!dXuV`uR7Kc|_pd-UAoyWa>(#TZ1T)13L`SQ`T(Q8C zwQ!j0x@`)2vvUtHfgMr)GL#>8eAz(Uqn~nq23V+)Y{0WGOC#Jk`F09Hh#e>d=}WkI zfd6qR0iEX*6!ek={$p1HOOK)imYzfr$EF+l<<{FDrDh?!PZdedWEC;K}K09b01{Xc@fb3)!8(&vUM>fRb+m`2 zmC2HSnUzP56)PscFg#sP8geCUY01`%{ujdN zJSq)~d=^ftF+q(fliI$bJt|gA270i;>8X%Y#i-lG7O6`vP?i=vSd^*!XE0|u9z2`; zk%Sf%I%lS2)F_==w_7zU*D^)AuuMBFw)m!LS@&Bmk_0KXY>XY_$;j11m_oLGA`%m> zNI!)k0mZ6peMcEL%c1#l@Jf!X7#u!S?qjX?ESofFL&=FsU^BCT;uJYzCBCuDP1G-! z4zOpTGk7UO%r)~)DWkIuxYZl^qvXJ~7EMW^oVbv_g_2kkhr@kSf zr(0!q_dq4{THey!qp6V>TV+&I{xE8XN!79MEKh|D#Us*lAr=KSAbd^X5hjrQ&JhKu zKZJoEB;TlJjzj@v69Pe`*DmE5sY-bLDsGwbqAc!SgFNq)UnfoW*H!!pJ$7dDit|#^ zt*WvtiCtub#L4I1I9qZ!8+j=l3>Lvu!g;6sL&7s^d9Qyghf^-kPQD#j`TQ|yHzL(N z=OzDIz@M93$z`BJtL@ZXB5rLnu*Su4b%L6Se%i64>n-EaTg!SHW$SE!2|nW}E#)s*Uz}5H`pQy<$W$~xWW+=C6&jBl z^V%##gT4}kbNY%ZwZ0m<*CvNt`bvf8^c96&Uo{V$QK?E#`FNWD0>L96yxM6Twwg5V zb(>=#OOYd5GWj8PC>lGfbSA+-9vZ0@hk%odGAQ%Lc8C^(S5;xB^v#=f@~(Ymfa8hX zs%neU=1&YRW_Z@bW8_8<;GiuGZVBT~kTa0}qMWg{989`ED4n$e)Wr1z%G>v&&;cUi z`QEfRulVY0XsL3ngV$-%i=aa8^OYw8HAHxybWq`>`QAnjYEPgdF<9jhnUaaEAVbOw z`0OC#<58iYO{drdckhu_OlVILF*FyOq)o23STKnj4auAMR(C};t)w#frU0g<%kcXI zRJF$}wcSwWv9L^TD%cW0dBJQ{A6K&DKm;`erlYhK3B0qJcVV<+hhtS^Dv=A}IL-mu z2_=mwj|A(_A0#R|iTg2iACD20E@?%lmRlI!2JGD%sfOmwG2>8b6Acz<_xxoBh@T|7 z#0Mg?i~eJfo?uS|2k&HVAdIr$vj}?4{&r--#=i(PPTK5q(q^8tvR*2KLV;>Ksj{t8 z^T?~PJl*QxO>LQY^TtOD2XCs>!oi#R?mKaKS`PJvv+R2LmM?&~xhFBv)%m6Qzd^j)C z&tB9<4}G8jodFygD?Gta*=?y5pthD_WW(}HC%B{3b#y!G`bAtg!TO@I(l*7M=$EU1>ONOj`-q%e~MuO zR7PPxdT|hrUZjG~gXLY$I{@y)qZbM9JHUSQqV3iKviHmd$UONB8aQtBqj}PFR0l2g z!a<9JQ?q_JN71&@>)0C;2R$;_g)9n;ssbFeXx|>_%^kF;%|Pry0`KzH`}Hq|tb-P% z!m5K7*)y+$78MkdP_rd6v%tZy9&9*j5tmX5nB^Ca4E|G@HnveQxFpJgh4xvc5Y?%{ znj)QtAir+CjDC6^GQG=YHq5QKlx{p4)damF`!L$H$3Xef3L`veN7{T}``JLa zo|HwkKis8Yh*lEhQfe$#S>{=)iW3;VH$+I}U?@Of$XN*dwi$JZkk}WO>j=qQEO(Q@ zFA0XWL)j8W=L);Q)%e^r#QnPiJ@QrCJvy*(61hp&CqY(sf#mf z75cTE6r|yaZ&4hmIHOXXq9xT(KHKE99bqR~Xi;DC%PsP&U@>#WPgB#j_7Q)<%E1iS z9#$ip3UV&$+bVC-p%u2fR4MP;$d6oJDO$4bwX?X2`m(Wr%^#vO?&L#=@Z4qwbQ95= zu26?^l`fF1CdC5*Smh~zYFqU~T{rsl?Ui;404M918i#%YDH$n3gCa%S-AGRK+AgC$ zoCFrzkyrisu=(vfa%C~6kx2C&>XuhA{0Ns?eOEd<`IEW<boh5xbIu-|$JIbHo@5 zhdan~vyvgKrD8EvIMMlJ8jmLGYC3@#N~JV}mV%;-+WN(qtA$0t=yW&Rp(!G9#R#tS z|IpcQN9X+$fgyW~W}_pDY7yYzoV>UOY_(bTX0*|GG_8%sqiJn49zjw$W^C9qkPMYF zl+%-C(SIShlKcc$*%L!#HLyjiC)05?e$RZvpfU2y@{+_40h&v0D<{HM(n4^C;--w! z;yyM!>4CB0+tSf3+83n-vQy4}TMQQaQX*7M) zPes^4=?=nF7&cY-6=%h`!lD`|z^&woM)9AYcdoLxP(-#69I)iJ;~+%lc@R&@)>QOP*R=Eknu`~k*!QmItV9VazJT)L zMW1AeQ!j#FLcoTf)i2mx-#W=DlvhLn?kmB2^Pdam+Z;W|&VS$RxJ=`TI7Uwtn?ijkb|QC(KH?#oopzF^)C_ zmC#g5|3hiF0dncb$IeeVQcWx)67hME2sLn1k9U->Yc{5}rX0%$fmoiv*8E)h_?}x^ z4;7`oun=+&5c#iN{oj=3IrpwbUlbY4Yzh>$a)J9-mQYlxxs+9EnhseC9wuLfESoAI zbwgvy-`^>U&+8M9_jhSDJ2I`0`{ED+_rD6bnS@NRje$IZn^Jz}#HDAU$7|L{K~NM5 z3r9+tivARPEFxSK`{zhLa+Q*IBX}UN=54q9imCaH=_(wm)&Jk4vn*+`#Kgq4Pr=Ww zZ0&dXh4C5-me>9|s%nz)hCDE0yh%`qwZD$1vT(ij*M$?~ornd4B4*b90Cds`W<=GL z6AVFhBARMBgj5);f)jRBhgfL1;pA(V%F#H-t{k!_IK-$K^m8h-1`_$jrVgt}dV`W{ zvYfCSdblLHlTE3kOWwmEC-uZ{ayMXC(DU1)N4o<<%i_H93{(%cgRq_v&d4e zA(bqqlI76H*nHfeYjHYI)#$!8p|_d(YOjW^@CHSp*$zi+y=#ocaZSPO%jRUt8C}ke z?!PP=W0D%*m4s6_)>ay1s%S4PE_Ie0cKpk6?!`(PI;;sr^}`>%h*fJa@#Kkka%keo zuf&s$i6{Sf-;>{uCz}&x{xqI!O+5LVAG59NC!YLTJo$Gfp8Rniz84C4|_vIZTG~-!;SQCh5W@lm^KilM z5qC>&;(J~*G68-1-ee||?E?r#F23i#HxJu&``pC$yq<1fHa*#XSoUE%vYs~&TXp-~ z#P`~)>-JezkGR9TiP4Y{k0ud;L_#)EJorCLYO~^;6wgUzk2h94a?z1O_=4!DPQs~R*~4o!pmIiIcS3sm-qE6 zHo*h`YzZE@AI-VlT%b3gNPCYpwO_ItmdSb8 zqLOf~SjoayQ3)ou1j+JOQHh6XMR!)dib`-Lq)Jx5ic0V!rb>EWMI}%|s-*kUl`!q* z4iHKn6S?|GB=h>BF}^I%S4=zeXDjSo$|6<*LmQrzVu?p(xW7NkEu#1S6$&kwsixVx>-vKCV^FP}gfP+1T;BxZs+-lMA{u zMjb%uX~?FNB&s&7j@Z+?R*|i9m-$lD1uGt&&Zq&8bbj|eJG#gKa{10xRdWgfNoYh| zX*uf6z?hH8(#sXGY6(`f8KU5>Ff?J*UB}VUG4!V;?$5cQ ze1#;s%B$TSQt8G3b0A%0xohByaD+$*N3Xb=#85=}6T|GX)+*|&9{T$iv6R$|ELx-> z%e^5Y9paJe<~(Z!kuU13>*_KD@~((wM(%T#aNLNY96*XHcvHcNr_|tCH34#D{q?k= zZq%kEiGa%ohBgro;ys91QOt?&djmT>{XK+G7JlDF2d_V!`f6p)SD>r@>OJ$n>dg5{ zV##gYH~*`}IbRuzlfU}w^S^4(`O1ix{MCOk|EuPlub`9qs~?#El~ZmOhfrY}QO=LTG(0> z+wK0&Vsq(2^M0)!iEU2*#jDy}E|^GR%X#{FR&IQpTF{<;I^Cb*{^qc&8FOS}wb+me z>n`~3$(V6DUJ0ldV1;DyGp>T!vm>zQOzh3@r>+KX>~{vw7B>>^B%DOS@r?7MY=Mh{GsltP z-lr7+rIkfmA)5*+Y|K`{O)UuZ&|_bklR%g03EW*K4->2wFv&KF@h7{ zbC#Fw)fF(PW#{gsU&7t^Zc+a)u*`vxZ2xm%i~wWXz#KAOEf-uZ7h<){a2qQ%AMVqH z&E{q^xTM$~S5;V5QC9`?zI+$j;lh@jC76%TkhJDQzqgOhFhUd5_YKeL{_4^=IG$q* zn`zR8?m0W(d`LP{#z=pDgB=QRDwh*1iL6VGzFirg9J)wA9af+SkcT8z$Yn;paY5QJ2{lp@-v0^d9#>Ei$<`dWIt=~X%zW?4emxi z>WA@Y^6A8VgW?Upy43%8d6~C-g-pO;RPSA3>2&TjtIs%YX6eNyY9=^VTr>Qu{PSmf zr~f%)jp(|;92kD{(J1`lc$V}Sk6PNmprDX*<;JOSeox<-dYCwV!g{Rblu~Jg^^^K)l z8wIa0Y-rZG-ZS1Bl90!Y1ni^TFTx6a$*goIkH=S9 zMD#56lkl>|Jmb~&$O{yu zI2rhK#8;J7)`n&YJLrJ?KlX{V%~o;ySNPwDf9SEjx5nLnNs&h`^#6fiBoN*J{qU|ZlAG@lw z;gzBIcv=7~sa9>uZd&?cSyev~H=vb`HT^HwEk`CMxFXKgQ4%GrfrgF4*<(5yG&j|A z+SIiAN_&5E?$rAn3A^&`s%zf7)||`U+#JSS8o0Y`hX3;-ses<8n1s4hwa)Dj6b;E3 zM*gxUc`XP*A+33{ye%B38gFoAVxFC9{FUih&NGI?S>DZTD1wL(wfD{P>d}~uIaLBA z;1u;Z^4EsPbv$dow13v-)9%o34b@HfkU03rgWJdWpF&T`f=a|;LJ%fMju?hAXc$cS zr5GCzJX6syd}~iyJvVo&)1Pd<^G!i~U&PD&h+`BFzaOQnd5+BaM)i0U9!S7neK$1| z0z;^xp@4?JaeVj!P^>acb` z4YB2818O2a7U%s@bctLk^-~bq#;1@l8q1dYOGa+h=ayF$$A+!!@W1fw_pven3H}#a z0~}D~K}gjmk6P#0>1Lf)*sV6t_#t0)t9#Qt*0q|tzZ_q>xswQ)j8`To2f2rugSHdR zW(U`~v!T+(ue0cO=`0$GoYynT*h|;5tEUd4@#@KKgAHbs)w+}pAEMxHI zRRkMLG9}>D{&6Pk^K}}q?oYIfUTuyWdoCH<&^8vdX)SZk}Y2Wtn=~<=_imo4T|bTX`3GGJxH%J%59te6siK`h%?eRkc=*GA#(R0E|*pr&t7>rew@G{N#*2b{jy z1vegI!et^fhRYXOq;HE*hEUIgzeWknwRMSZU4ngYv02`IZ}GNT!nA}bJodUpKq$ZB zfY^`_e3yFb2qp+6(_Q$U85&~M*iCbvHHCoeR|BLt$4vHQ(AVT?Vt-))QXg#sO>*%`3PtB5ASnks^+5kn_SkWaNzQj1_$1(g<)z_g%eYo*kc(YXWDyKFc56&5%sXe@i7yvib*lf z0^de(h{o720{2D+@N+v7*^}TcVa5#bxn+$@%Rka*7{`7rhT1~OTB^bks~~9eNTLu9 z;HTX;v%*+eW6+?z80@AJM?WQUiu+Q8gYy3WS}%Rv|KY^_|K@%78VU(Y5t9Zgn>>r* z4%)dc=30d}I!`+Y6cU!EgTj;Hs(LOK!9{ePdIhHl6myEz4mKS;h2VaZwu@|80Zo_v+esmNX;oCmBrd63lC9E1o3 z^8&e{4zQE%R8DInkulf2ptppwofa!*Vsp2;PyNs@2FonIOMK1_0#+DiCzp1v1q#si zSL}>_H~k%Z1e9-D&>6p5_4}06k|)SDO_jT-4gg>X8iyG%>?)jNPj4Cel&CkMh_mbx zV#uX~F+4~|>8%xrOY?+Pd)H1$Fpw;hda?w4U;;&oqO$G)LF|r%LVQ@X~(X2i|%J*rAq8sBOimk5rO!y##LBTS+w~PjI&T=7q1ahK`jblWP zv9zeT*BT547k8s$bq9!(c?BIP*`TZ1l}IGIK)}6n2KQiPB+YWZ$Vi%vGl)}n)%=46 zi$w^8b->zWfJ@bV?1-JD6KFNaWrEruh7q0UUf}nLPV^uIvE=eriE`u)GA+2!MrJJUMvrHQvQHG({rAg1o&R(J^%5=G6`MN_3Cx3AVhTxW3Y*1IiNY1dg-(e( zI2yXUZmN=BJjDA@v9m01)5-ay;*t72O8(>{6z++$BBE5bO4p^bRr(~gy3$g04YXip z7nhnb4p+7nR)x!?1LEszobpYN&~~}NjFuT8ugWBPlhK+PtHBL|gzqHN8>R8uhIUG; zjFg_bx@9Xy?eK!?mgxp>$~AE5GW$@jsXZ>ukPD#tSd~VGR!Fot&b7dvhjvxg1Ngp$ zv72D_piVnBGNF_q90c__bYb?%Mvu5>Pj*On8bA=HJic=|J8BFvqORpQu@ibxYzQlv z^bBX=9i+{PoK=pQHHqVD%-$YKRbZI>jPmGGiyLU~sT{9jJOg!WPD183Hb)NERxon# zR!V;N|HgxmoXG19h^1u+Q>N%-Dm6#X8cIB)5*&_JiPE+gj1qZ_V#awo8^?WCL}7!s zFS(44WYEiXC35u}@-YQQJAnbP01C{LJvFxlAnfZYdeFR{M!b;b8mR9w{cxGo5J(k!IMU&sM|+NQEVQy{|}31lYJe`71Wek>|&vSlS9kx-7`;>t*7oEc5-k9w3zo2 zb)G=ZcQo#$R9ZvRo91o)ON)=JxxiPzuGP;|G%d5dY)Ngg8zM3EKxZ)Y$REtxgej*f z(+rqtm{pt?Ce(o;u4l0mIg?WiHm1bMmq*;T&ptw zIYciMx4qsOnp@7DL5tKt905=6bF5yJ8xsalJ@+fz^#6?-Q^;dQwSFdPgk_N|51@A& z`y%#VmyzyIzB)260yOMz)X2n0Fi*CTmKdQe2j!cAW&Pz?!2agfU;+D7dmR783f9Ui z80+M2)q2;UT^+!%4ZFD`W#d84kVF{xa!=fP3NW*(~ObS}LDz(xfi?YS6+_gi~|eMNf# zOezRxUIILnYe%Km|5WC_t+5R9r>$aX!a$Wt2w;&^Bpi1oC=m^p1`r9L1_kj-jVA;g zmWx>bqVojDLM`5=f2;(4kf^~B7R>}oQe^PgBV$lY#0K0+a*w6t`}a25F@S?|`ca|` zk^^lm5tA~iFUqlE#9|J77zGd`)mfpPdZRGqjy`ZL1~aKOgGqh8*! zu1@u2Q0xu-#EJN5{P=tj@B1Hw$|DcdLV{OgwqPM6wds&MBbYQv=rW<&0vtT+&c27- z#`-hVE5tbb*onZW?XVmJ45B3ElG5%v`oc3#ptAbFuTD;&LXqZ<60|7UL_d`<6{>0! zh0GQLPdf5^V(dRw6wQgTFB1F60|&|7fgnC&7~C`A(TF$<{rk=%pEi}#g9jKiRw^je zJOCU^8p8~Pe4>G%G@l8K1!C0Ry2wLzxBtF`YU-xC5&8c9!uU9+u|8~(*n6n~li3`Yl6MP zbR0~y?BN4x-IimNdy!QiXRGA7Gwde^fchMd5CYu+TnzeQwght%?6u`fnr8Nm*y}nE zsG!n13-fX^%>hW){P3_BOaF3lWAh!Pu93_IgQ zENX3>P_qAQK3jf$V#^TqKE52(9YlMDeAy3bCh;=(GM`-$U#8p@@#W`L^WpN}gp8T4 zy+Y3X-@OE9h5(3)tohj$vgY7EOd5#f>&2S?$#QXZy`p?!Y+Xj2XeBu`#b*)sP{Z!( z9>GE(hX(21#smb$H|TyQKs9mZZ%>^0=ERwAOq}^-GHH1}DJ4tJr{QwVAmW!c2M7GM zF*xX@KRD#2$1yHkI)lSr+Jhrr8iT76cMpcLuv_(!41y~zRPRzq&}8&|DHTlO=B$Cm z`O#_@MoTt;F#kQhfb{pNzFdo!Yx`ajz1yhPkcE@ee&srvI`#N%)U zCpGs)yeT1QLe3+egQ&4vE+BdY612Wy5!5#kO1E1NQMv&hrJIm7g`6h7rxJglEa=0v zOnH*GBNo)TGz(f5XPR>D_DZxVu!=rQAf4@lXr*0y6pjKqC2I=V$3!?FDETB%Q39g_ z^g^`aaElj!rVEb@WBx&(IfdQQ^VrVxKd3Mt%|WIH(9+;ya1?V$SgHPIqbQHaiH3`InfdLpLyhHP2L)1v!O)nCJ4M2q`rvW7P zZx%p^LjZ9`L3${O0@#@bu#*6cE{Rn7nb5chfGc66NQ5yWUX0H!J~(GFy+E~CcN5h< zHkKWy%BaN#)kfbJ^k|H!RxvuF9D`;@yNZ6WJy)Y=JVT|2LifpziBw}nyh7nIzIX-F zqu*VT_-Mf^k{@48mY;7%`H`-%7`zhA{_nqp{0I>=Cm7Tf3Xs8SAX^CK>m@+`db?Pz z`R%Mlvp64CwBSYm|Fmc>!HC{_Zx|rMn27-sKDhVvCojCa+I!@g3#Yeakt2Ji!FXWxWal%gzODlTdf}xo|0{v5ds}+M7C+-3qrt6fU>J*E7F&Yl;r(Z z%~T#Rlaj3XG_53S!9`SkSys&-T`bln$fyo-fpA|n5wQ&9Rp{$zBEnLM1mNPI_Sl=O z;A&j?3uhy=MDr1=dPI+ABTa-J;<|)XCWyFYu{l&S}QVgxX2`j)HQdfyRZyqon{P zvoP1db$E+#oo~dw+JQV^)$bkKvh?6CTI!v9_Lt*CbRFA}!h$drEUku0XpX)jp zG!$iu>PtSV`f0Dn010etxhfmozea;mKhuqcrk$mtu(!pChoBL{2x71u#~~nRK`>BA z8^!L(2#oNCV8M+(1LgJZ-<$S&729yGy@t5_oz}29_TLKwl}{Q1WymE(a{veg5w#OWuf}{^ipEL2yyq{#31XQv~+sb9hteoj9(E&Ng0G6*c&T zaU6R3;3?==>Z}P4yfEz3djGMSF)RdtAyP&AX){~2AHnKsPW#%&e;&`r?Jw?Br)WPC z1F>X;XD77%n1O^{6Vi#f6-?|Ide*>-x-eJ+6+Jjx7xfeb$vSzmgiVz$!rDYqx$L(c z4iiQRNAChL7+bZU40RZ@CkHktL&x-*oG}O3i%>Ps8~1&Xh7RzKqd8=jWSc)943Afy zFMgn052H|@YOI(rq&`-VEtO;~A6#w7H@bG0c!RHe^d>fj&wZ2jtWSLtK*uKWL!3)2 z^o~ilJSeXTzyNBA6KxN;=3EA>+ zu6Uv%w-&sM`=z^gBZi~8njq5Z_lyB-cB_wu5BbNm$FO$uHSC}|648H@X0$W$Edka` z5Fd7=8R8?G3mb7+AIkO9;Dz&nB^CB&-AToZ1QP*1fYH&m9bBe&a0$TbR?iAowN`z8 z(#IV=?AU>Z9SG@(F5b1;0%R=do;Buz?0kL2^QG(na>ix^EXSUOL)9}_xNt`lXv;2NGI+!TiY`Zdbqk<;IRZf{&^q%jCEjIYa95>Gf^>79hxrJq9*=h4n}-w2nkYWxg_vYd^i7iiP*xssaZw@8aqf*V zd@#_xr(2jc_MRacI*c1e2$kz3DG4(uv*-v5md5{@P*vK(qlbUbuq`OV@jN0|KOEVG zjwn$cB-6+605P!DW=I5Z`Ig>17O7qAACwG2Rk<1-sW%%Op@7NPes9WF_Uet7&b5coJ1_TzvRAJ)o5)hl2pRUL2f{9NWOrF(=qeWmN6>QWJpkmP33DDVdV?bZhylp?S@i#2h&Z9EO_gEFv8=7qTaGm+ zQiS1C0%xr0VtJ%ILfffFX?D9)Hz#3n4ib*zpiHwEM=qScAx^<#tMw3PunB1|UaAf| zFQ6;lbawim5Hx3hCTFI*G-o6MZ-3Rdyd%#q+k_i+^u0n_Xpn85*{vD|zI^?i=A)+r zz4R`2;bBZef1&0Muxt{-YybRPmZWKCs#zQapnnD6E;T6s8#6e4Q^Sq>hlhj3w#&m$z+DgS3=g{K!NY0>dmuB| z9t;-t>-P2ygrt>SSZw;1iSq=Dd*+$$sbj@aO9J5!hvnY>027>glg(o@YK{;lw`rxSQ=m{M2GH>CS^WTDl;FyGqiSW4` z^C0Lt0LMHu#W5qGq{EBu;E){i;2e%QRmOFdZnEMZ%p4O(yF(KjX%3Z##vHRnzfrj+ zDKJer*rp?MNw%poyk?t$78qwEZeX35w`$h;E5SP9R+8LHG0bJg;F8=D;Cp3o(n)TK zJobvY<;9J0V5j*ITS+v9yR_*^4ir8UXB4N&7*F^16rZEBMYZ=N2~HNO$A7zFul4lYDzyqybc@7uUQP;cdeoA`UUL{ta- z+o%o(#HbE@sYZ3M$JnS2_G%f`0Sy?{fkX z15{wg$h|C#5wNA{GH}fK?e{2|LAsx9zl&(7&$jR0jaSt*@5*5Y7}xGVnhYzDNTUBv+o5>i32bq(H^7q)o-eFC_bUF6-7l*7wMk$HIfXB_!piz2wPV9*sf#@0F$v^|;9?bh^Xc!Yn4XC#Hezr zzIRO4*Gu)`pb*yv&Bq4|GoXJzY1Kw>vqxUI3E`TVB_ROW$+x)4kO6v@DgBn3hh6F_ z{E_|yD&Z{L$To%K!2Mej?%(#t)dNDsgk<_RLAf*?cz-YspEGf*yZ?MuedV~*(a+yL zTwtr+mWF4RJ$((|aV8c(?iSADyJ($>jfoP($wtop3Of7$quAD>Nl+3|;br4g<00bf zHJ~=w7~lpLnp#|IwHigM%Ol*?dqfY3Lj2S?L1H)}GZ(*q*Y@-_BZ-L*Ba8-Vg6R zeLqR9NK^hmzGc(MPv+a~q6-CY!~q~nCEu85C(Ssn;>ZjA~r3MFieLj7xTmgNgGRT5KAF(o|d9iseM_B|OGSg2H7IlM>o{gES zQ<{rA$0ETIWY2-^5E4-om*VuJb`yhVDluCoiBgAgO^Xv7e(L?uokaKApko4HTc!sx zbXiF(3$aN{<0cFPyG%na#c93I26K)Tj%lE6Zvj4mfYGY{j|MF3r1h!Vt1h70DsTKx zej{F76IBLz_G>mK+OOB0Ny$g|L3+lw<$Yw1CKqEH9|ZM(t^9K%zmzOsWMI z!r*&+*}9uG%sqm1XXjJ{hcOG-Ak`f3Fv^y33Ps0?F*uAWZsm$wR!}oz#QC?I2x)sOL5JhR7GcM_K)4Hbe0-W6hY6gO zCbMI0;N+pL8eGOK_Km~Ut#o`|*>hD`NwUNWOH>yH!|W)?6Czqu2v50S5#=Rf*IL;+ z8|nt9s7O{t-izSf(s~;Fq|rptu^N+Gxu`LSM=|6bs}IWS5=(--;m? zcbV&}b|^e`zu=H&w8ucAHYJLQ#H#g5?}<2z;GE@;Wpi@H7oo*DbYSQ|Wm1Zawb(qc zaU^Mg(>XkugQ3LI6+_u&C^h(_n}#qUonXIZ5GSjSv4k9A7#A7FlnH{YYsWD%uzhqi zoZ4*jaI)>4$~Xx=Ev~WF08AcDu)ml_6G|K{m=mLEN?YF{XLN$m>-FR6W@A||yj z^ulkL+J}bs7MLBXooXLSplTmVpd667q}m64;GIzW zw6{;Is9#sL54}HhyeYL0KS#JfTD97@Jfk~Zq1qSP;*8q&-^feUwZ2Ab->AMTR{NGO zqxLPM<0%g;wj#64%ToICO2-;DtNJl}8~tCbJI+Fviy$Pqf`@u?IVEc~DOsv}p=7nN z<}B*Km{hWq^}@e6M-Yra7sNo1gRX_Z)fpj>NRgK-1Og8tU~g6gTn(|ax^iu=GuHMx zrU*#dUWaY@F}0#sBjXeMppaU~fWh<{8=tZ!=LVRp?d>-@O_f1-VaeodjLT_zoj5R~ zM(o!Ogv)7roiICngFr~yo~NAs1q{S|EF^6&4iZh;UYu^7v_18kxtKv!m`@*?;a-Y9 zQkjvqr=X)TYXR9VYkNVEog~QRj3&9kv3YOF>6P+?AQ+FdJq(Te82ofmJxi{NYI`95 zm1$G^w8U3saXf6_)Ux;|+&qR3^m&#qR`ZeL7PaDSIBqopBr9Y4Mie4!|5&USnLQW; z$>QyHKei3V@4reg6G8vis%Kfsf(Mt?u5|J%4@rOe=UzA*_ zr#UbxZlU_mtN`8W;tQYNqb6?tPj1X)cF_Y6&!4T>8Ime~7RJ=qI>*^vI*T@7) zrm`=z&Yb15?>KXo(4o{wi!N9VJ5w*$#M$ZxiN7oY`uj$Wgw-`FG+Fk76*KFirFGjp zXm+&c8)wei9t*8~zcBh*|K?k@Il0gywl@3!#I=27fRI}v%YyjYmaDRwSk;vIpjF#) z5d*a1V?5vvoXxf6nkNuRVlk_mpaGzwZ(+;DRhDhJlu1#b<-)8zmycrq1rk_NU}qlb#{ln?G#+rD;I8U-BF@8-imDqkJ#%P-;3^u!cF`@#i>WeWBHS4 z)5=Y<9*fd7%Bp?4HIVZ9K{oRDs*j}ju0!M74Q^jRo#ta=SmIXMi#VWDjkUp5Y?dl| zgV^=cXLmj5>57?#J}SgIF-CWkcL^$V_L zywRp~padLp;~mJ0cEC|q#4R++GCnPjGe06g&bwB1icBrk16amR#JnTotFZN*!LjnV zunYqB!FAEgW43cFy|p)k?NWe;Cn&aUb&WFJ*416@#7~bVf;X5&-SRrBv1Pu3n!#h; z0Z>J}YB9K0q}qMM7+C|y{eu+kDkzRV_BCxB%iBecVX;{UuKr-)UgTraEt_gc77>|~ zd9yxnC52Fqxfpi(X%p@KGUYanE2O;!GA#$?U}Vnl^Jw(Cycu#`g19bb8L!F=*P3t* zDx&83oc34gqv^zZ9Fa6@+8{of34eiO?1zfDNAf%BPPI;laQlNeS5trttbR4j^hWa{?#WhoT;As;K%-e^lDmiPWm+k zzK#2D4Pu4jJVK=U9LJ3}prk|@t^-7K;+LzxX{2wop`z7PdmICZIVi;@Vjn6EMETY( zG1{YMbu|W0wuU#aM*5gsJ;jq;3TK~ME{-gq@ z{a>m-i$+^E%Arv<2Jwtf8B5;($-%M^V8$0~Mx3V6wV7{U8=UzDrqK!c_O+rRNMJkh?HeG06V|_7Ub~NPPnB^&bosggMxrh= zu;(Yr6C=h5HV8v7U=AEy-z~q51t@0veujPgQVjd}6)@~nM(!%ZuGFa*j23Ka%%saL zvkR48SQfmrS@gItIswzw!7}j>fENS~(Q>t)T7UHPcjYnc#UnKw^*Wv)E#^~5dB$Gk#QAM^@7AN9(* z^8v5NZfQQ^^%__!R|KzTiJ<+mO~6+T9w52*Q_iWYo3~XD7vCl7d6^iPMIO%QQ5=Qs zMa)(zka)AATV0@6Zqf)fBM>$pY*rxy(`MY<(%kb+p3jdoTepF*3(i~K@RDaElBSRyB@n=I0dkbP6 zlFXQp1W+jIm_$L3yu83~JW+9&CJSoZ zv6q2*A%MMRjZf!JP-yEmLEK}v+z1nUsDOMaF2rwIWy^Aqs9;d?76xpd6c8>&$2#|5 z^Va4?sRkh6ti9WdTOrgKtFrp=f24fyA@{~c-QoQ$tLNUnQ*E&gi5^4o>vGz)jxl`UOSc*3jW7nr#gf*0~;gUjMCRIJ@=l&hZT;!BoFXe|JNUY zYV)83qB0U#^h+uewif@Tf1&{z8l)s;{D4$q@b4WSHyZsg-_X~?%HhYM3e@@+911nR2*o&yKitDo=u@V;8doNaB1WPvST=rkwxS72#wpxiGHj`BWH3X zG;WK=6Y$SK<3f3Ybsc%RMkCZiLC|_pnQaIhA1XtQsl6mQ~1E0TRVY3KINP7fKZrS z)@eH4I~A=aE`AmB&tuXALKv3F$PTZ9r;jpmHSDrn0!ldBh-AYyz!&O@S4bT zL1JhacQ(vo{|gkW(hPWWh8{GU@VPkeMlOmWaz2KvygS1Nw`jL`h%82 z)^X`CgsedlvX1dK1_G_$k(^SFr^-P6iQK_MSc(zQQ(N^KN+zb^G3%I6zLjIsKgRev z=Ps9B&4aD#kxyRSV>HX_9BW3rpH4Gj!5_;iE^Ob;sl>EVKhth zPBp%ljtkf=PE9qccW`~tXqC`n8 zHQ0z$*Z|E>R5l0`i8eIkX2Hpy4g752;mK|om>DIQy6PD0)b5W`SkNJ=er)O)3%klE z_yrp@20WL?agFJ0Ckb+JZG=mOx-F#cmV}z*{8?j~)Nv=ql44=ypwBU8zi`h^^^5nw z%RF;Fm2-6FlyRY5N+M5a&uJ(Fcs>a7C=>a`aDJW)hXc75N@Sw33>khp))>SFwN`lL zgdRUb9pmcC@`U-odesNe@aqNX23SXVVz>X~1ABXoge0)Z%=-W{GSJ}{8CDS;b7&sG zu#@WqwZm{hvI&Dus$TU7Vrv)xXqg*uEk8@o!vNs(2?K!T!vNs(X#>Cqnzyzd$*TVx z698ZKf7-5Sf|!Li#-l8=`W(PE0QS0AayABK>iDHh2Jp6IFkpn|7z~1u@@+XXH=sqk z`qjxhWL2`##fc(_b{HkcMWDRKn}#Pb&_GDjv%_y|GXaCX-%LQ1()1Tto@SpA%lowR zs5)6a7xSrJ)AxVLVhC(snwoW>ta>q?d@*OiAMoU%i_X^iN$O%)GeYv8wF|H=GzKU6 z4`p*ba2^yzx|d)$lN=I^2WKosbgB8JEai%rr{I>g1*W{7xlo?u&&UpQ98!;(2f^

s38xVZ2!0bI&93neJemVp{6pS*UC!5JPL@ilh}R5*8#;G>u553 zjBKKqN`Y=|Kw$lZO|(olQPt;IjLHPcl;LBlOvu9%DmF-goah+?gT&EN;t0dkeZl=| z>eTu%QDWI2GbM;!NbLF181VXpS{U}Iw|=Gelfz+2T3MV)nMZmu0Ig|H#>bDx9HsO% zL+t2NjAwHg#^ub6WqZ@iI4gK^?d{C9;}D}c&oi&xp02?)3=d6#sLo;wVPHFu_s1Q_ z-7#c}6u1N&^ug^0;D7Eb-*GDj=EMo5oOla!+dpw)wWSiO2^-3G)d@rHV89rjV1SP? zz}qs$FnSNt0H5IHF|v(RG`xjud!r<^HMp@n1vPPVit6wy2+`EoPkoLAJ4p8M@>DW< z-5B<8%^A=m)gCL4jRvbiX)TAY#bH%;eZST)spCFmdA@@ANfmA$kK8h@rF@G&Xj9f3 zl7#aBl6$0^WQn~m{B?B8r^8~vD*uD`@hcP>e!Od^`nzAO-#m6NZys~UOW@I9P;DpW z;8dfGJr|A{V}PGdV&QIb&xNj(5QM>4tN#H>jmc9Xgq*8>=3*mE4w&!}c@WJZqvNYu zIGyM*RgX-vq9bWFCR94c^*Rd0UikI$h+N36>PYmOq*z$+{lviY%sDYUj=u)EuYgIS z0njAAIRznv_Sj?&`i@8ls7bqc+d@WKr!XF;ZNQ1XZ|CU|Hal?n@-fl3i7_Tj*AFiG z=c@OL3osnyj-@fI#IlMRqGn~$dPo(wl}I=Iz+?zqMF5DASrN+qq6Ezwr*bq-<^FLx zoS-Z7IFWRqMQvd7a4?T+s{=Yy)y;^J2JXY>B)R%u*|$`nh!1`?I;4PAM&x>;`s?U0 zNR8}2$5uNmt>C@SB&0vN2N*>%I~mgKygnQr_2KX(`*3*FhZ?+e4XCR>|IDUVo~2N& z6Wln3o5g;7AAk?wy$u9ZO`JaZc$%}L(ubl)r3gya&LrQexgBq>v&5VM<&cn~YE~G+ z0>|{|ja%F|-?&9_=`quSRk#K_S%z2)7D8&LI8!KM_xF(sYs3`h{S;O^2ifwWuN|@z zihuW^8pZJTM9#Ba2>o~#l}j<%@S{d-caT)95V}^uKa)i~#jUg?mg`>5ks=VgnD`7E zsI>W#6^<2S#4*SxqVm%#uhTD3m}diF7#;0(jRo!wzpi0ex9Qv|R6W+&*~Ub%*XaIG zhq#g;*di0Xj5j7>Jay+5933P9)=XB#*x4FcnC-wL1w4SBKX|D-E`K(nb3d@n*Q(FX!s(gxBtQ>;0I4CX1_ z`_j^P$Js-H=}huXmkzh6!KPLM!QL^5NHb|~kwN^8$T3mKVn8g@jm;I2yNX2NU%{7D zJ$zAva2ii=SdUpeSfxh$4%CSmK^|$dAtICehUqqU4elnpZ6i3D2Mv4PD;qgL1v;W4 z4ryYq;Exb|MbA0|S|-xweF4Now#1yk03A0mV`c#WeDlM#x{9f+CjR2nc1L zZDxtiFwnkquIkAI8)B?M`%Wu)7XTvyA}Oe|Q(569&?= za~b`yD8yuNCeRy1B34w4UD6%j718t%Jg0-EC9^yIw)%rOTv$?oa-F{kipP%Eldo!Y z|IOj?QO2KZ?hcm|dtlS#;;pSsE`$jg`V&FK5GZ76gbe#k*VuEyueNmPLBU+yuEeFy zLLzaRT#kXKP-nO*CI)7lBMZfVZTEu>$tu3xNZ%ez-=?H&gH=Y*tr8z3Fj)7@qg=I3 zJe#lhs?zU*Yx8xD&svU6ET8epDF_$6ayG&Vos!qXGuqt9Bw}^1IP|HsBf9PA^CmnOrytr9d7IaEXf!E zRL=k<7;S!3Xss0h^7wJ^Nd#DJP+}RoMR=mD)WKbg>LYgjV0`CHlMFzhNL=yP>^}&i zb`Y%@&gDMvm6)PzWH>$S3G~V4Nu~^!8^QFx3V8l_F=UwK$Nab_qQVl~_mreHp$BhV z1zrL;gn=`PxqT*d?iJpKD~{Ehhxbwqm-~i?SnV69a&77O-R2k?d>-b+}}dKm~5H6F)x;T9~U z5XCLcdo-vVj9#&$kNz{`S)?h$<#B$~i|`Z@&>wds4C6~M@^n!TzQR}#>%F?L#91^u zZc;?r78gkUoE_4!8K1?23`&?$WH~+i0+V4}o-Q1R7;4*WGLc7Q6*RxfiS{uUB5@ew zB`;o0%LSxyJ;Z)%^YaosAbggqZpUY-h^kh@U)C7;%NogFwp#nkbXJDjv7x^Vwo|(u zSCADs96b5U!aPnv{ADZ2?YI*DGCr#PWf5%>v)9kEL#6HrSk4$_`{gQVNxk=PKnWI@ zImxTSJyRl);1CUzEQEYQ4N_+Pk}O9Y}N6F{dBv1f*S) zz7Sm&bs(>sC}Y?QB(9H;xSo(m=u-0CE;wSQUs)H4eMUb!9s7_HLSl_X#7PQ*#9neb z_5z7~G)AJBs$hYBmYt5o(~A-~gEL-!P|)=t|JAToJ)WG7A~8}#NnDf-6`hAC*>_lD zI97#MfEu5(OA*r$m<(n0gyH?nNXMZIIg)$rERnf6CyWY z@eQ%D48pMQkg-qG0pS`0Q}&a`kX^77NwgI)QW_Kf$ z0n!hIyKxnXgnrtaC;XQj36j$vi^n(QV6QdWMRZc zz^9+NcoBPiZJtfW5I?cbEIK4qbi*F=Z`^_Z#W*a4!BxzP`{2R<4Bb`4Sg>8FdJ!L%Nl8%>p(hjxo^9;# z=MkM-%(&ie|LF(z_84BW{C9@%us4O)sIdSRN%>QaKciNH-2+I9J=_`g86+*r96_(R z*-xpAc(*AkR6_Hqr5*d^rU1-!k+;#&Fyh|Ha3!_yDbix6TvO>;4I-w_0!k!*rxo*< zw^1$K)`UvUfbar&zpF5NFI69)>;XxN=N~ZdKi5Y|{ibE1clRKne+rs|x%4J0VD#sp z0q;~}j_VEn>$ZS@P{7(c{5lM|9N}tezrrr7Y_sdEF(A)i4Yv7F)tICIevrnEtljhu z8F5YcON=pP$I}k8fU6}IY2ReE=452bst=jtleSn7w1FIu#z;&j#VvSYzLOhKt0bc) zGgiu4mo{Ql6k(JhD7LNsu=1pX!MG~^?UNu>~8 zJ^CqU`+FaRjO63<{*|b50dWuP06%_=!73A4*@+U9?&@mGoU8=00>>HVvy$RUOkkJvRS~nT43vV7C-Wmq-NRctwHoci{8x+&n zSt`hwj-fdWEFLBJ$>PlG8Z3aZ&uQ^|bsGhdB0vwbfC6byAm{{NU{L*;W?&~92stc% z*jLjDt%uVntflz`VM>=I>-(((*%^>x$0)Vb#a-=er-&8Ychgq(uk`8@i#yP!_s-Bw^FvgF+wD0js^-yr9 zS{8xwvA#)Tt1hp=uT`40m0+V`>=MJm08`@y&%#L1Oos;|C;4cveVW862tB4rt69tH z7`G94Y8@Yy5a;-XezPcwG2@bSIbHGc_ zOirszGcq*dpJH)~xT!$-O7-F-zUe>^+}dsxFy*y`6f?8bR+Cxv;_t8v>6V7;iDSa* z77gO1msKgJkO=w-<&&_GvSCR9=2M7Rji+uef4F+ieKAixcx&s$PW7*UhE}l z{W8CoB1C}eYs(yfG1w|10VgZP=D3Y;$p&MS;kqhj5|)za8{)`XmGX!ydpMe$1=Igc zyON6_SFL5ufDf*r$W-Lccv&q&s)AUHQ7FyFV<75K8eU~04}RK%#vB3rmc&J*Q59ok!*{a+rGpaKTZv>>-L_LzOoUKF82!CSe|Ln*XL6KuF^wu)p3{Z=)bm!_C-StM)h${HE9<g|Sk_j!Y|i zeI#KXG(|ueejv*Jfn_n>($D_$6Zd}t)TUG1)kgyMuqwd7d z*WNv36CjFK|Kkv%02ln@B{5@@&kK+NBPOlG^m9p=XGOvkseVOR`?R@N6L~J4em7_D z(SkSK-zi0{PS1)dS%+^;|Q&k)+zA zgA<2;E!KyaHPUfq#EqDGzTN)~hd6RK1FI_ybyuHioMz+uKeW?4u~;G`I>cA~eX66f{E%wTqc>c0=>c4;4or*7}JN4tmcuFu(?KDuh#~zftJ!iXJJ^P8c-Q@N& zfB5S@-Qf1Q-+$Iy&Qb!&uWPKu_4S2`+rIcm7ky>E#)m%i@UVjW)XqQ`_CoEw)!U(tz1aBbuQ^zHT$%&&Jcp8=zo$2`58h%MSi&EhYk8Bbl8YfPfDgv?g?g!)RWix z{^ryZg^r1yERA{+up2yedf49bgSsbxt*{8#eo1Nu4tLL;TL&uIm&Ta@0To6dIo3&q z>Ckpg4;!fqKEBydL>wvOC$AOfBB&joAMo>nesgsD6CM@7!7~#zFtl{)wdH{)nB8kn z3>QxiJ6o|&nVrFY)`-4~P=HOtm057uq#C#*hP~7}PGr`qdn3)kNo0&Izo-1w1RdtRy^?d^$+V;$UNjwUWt#BUR7Wk)9Ki}d36D^{M662ZReLSDw zeGDMf)5?t4O_;JaKHBsTOxh`IwfyS9kF0LkOI--(87sm!KxhA#x&%`>$gcJ*$Q3ur{S(!2*y{bC8Mv+T)wYt)m1!>XAOI$^Fu)=)8 z*E)s5dIh04?8*5fKV6nWNYW5f5H=#heLb3jtuzIVG-sBSq1Se4}Pr{E&Le54PgdP7jys=Hhou1im$v zNA?Y-D5dj^IY}+t!9D9G;9swfGvK26ENzEAlsVHNjcZ{A$i$zNkkzzUYVcqro+YsX zmT9TsT9&M6DGAoGmBx8+ez;t{_HN_;#K{6sb#oIJBk~4vk2$)SwGgdY6kXwQS}Rio z%<5?lYr~%>n%C7Uh#D%r(YVzHSMdAT{{1s**Y9<7Ddylm_OhRzOvUC?Z(q{rH+ zSpc@nR#r3$n$!IiQ+IfP^Lv1}KR6UUbQn1?(;U*P0C9*zGM(QW;>d==H{e9Q7wO5m zg?1~5p9dqpu>zff1C}!PRaxjTh4iyHP4L7|l>B~6a>$UbB1^7Y@^D>pZKC8moSn-G znRVHy6%%NxwJz)D^m19jkLWSiEk*(?+B3VeHtNngw4b_T)k3f?WzTeoS(GZILxh{v z6>_qZP9BlL*9nFi2X-DTb3hlPZ0o)rYTgG5_P8e_F7n{+V1u{ZSk*?w!KiDD(TZP` z1!u`>M8pMaWgFFfXJ9@Z9PcSy4B&BkXAIdQd(T$V+1T&4qoX%$r1K47o?j`Z->~z> zH=dt8^=7qtM@k+>Xg%ad5gcK-1T_6%2vHUL=$nlQoMl^aef#6c%7GwWh3C*1*fshFSzg<^(}CMe$RAn4XCL32LYA)x?) ziJ1E}G50S=%n|a?xa*R{%zGdUa=z>G#B9k-&Ud{OFQmZKS}K19O=Mo&|yST21tpQ?THa;O@5$#k8m+QTMg8PGMbUnyNn(i(B` z7!Hh_@N`gD7z3FI`d;K8$}2Cp#JxAR*5bL`7yVV{GCefFp&PaoG6q zKoW~q6_Sx94y7gyO=fclaK`VAs;M%vF>tS|PvYqzZA3@n;L_8>s|d_$5^6@#NXDdK zvgs2K%3=Jyvar*p#8g%7$bsPMh%49OjQ_lJf{l7k>^)|$PawX zQNeDOSK$W*c?1FgIn^H4PC@zPlh|byb&GK-}l+(k5wH;~A=|UYecQn~mnPLVnaO$*>qtYMB^QXMp zC?L@7ne3tLZ+O{Aq0*bFiK7#BZ=@=q?3Ho#Kx~-IVLo*>m+T%^nv-*X*?->Wy;Bjds5uIAG z?y@}h|7TU-XhLsv_(apD+!n8k^g29Em8)bF^k;jBG2ARc@8;1nWY*q$5EY9)x~ zR0Gc~e?~zxgd}dB5B~~oADrXE1-67y0OcTs(=3D|cF9ME(->4j5e{6|gy+VeUP-GR zQ&=0yr`(~(Iet1!1mWv*TbW?wP967!?9d-QV8X*Im+(Xcy6oLa5p(F;4)12s}NJ_8pWHgV8E2)GJSW-;|QyqS7{`9Mc1m*}oYCycbZ4;mI1(Pz(B^8p|j z{Sp}AEZ@|o<7}JDv}rht!q%uweUVf@)g~v6&bDbt4|}6%V)^DqrkU`z-dPW4r4D8z>&}S z7`*XnDgx(2-&n2P@DJiy)Y=jGuT*O%p62Ci?d{Pi~@Y+YBZ%^&TSM^O&4HV-;bvDQ)7H=DQl zLTz59<=~Dw_R*1EZ;A5IV55aX4_VPY>=E2Jg*VEhUe>c2052D$Y4mJQ0q`k}exugtSx3hjy=Y^96`+8_(C8W2SESLWVVjaB|M`(i z#m8hbQV~AP(%peYvXZ6@SC@#3`5&Aq6E4H9$tRG4U>H>!Brb=ch9LMH7h_Z!z3%@b zze{;I3>(7x%Gf*71asuOH3CGSr`WE`i@pIe3LxIN?|&2^W6Gi-8%bcdhQ&0`=ln$} zEJnFUNoP7Th6Y(BjEo89Papw%cjaW@+||R`Ph`v*0BjIU5QL`nmR;aTLohsdmfp9M z;&|4no>-A#e+wbQ!6QWrR80wDXUy#)fOR1v4ar-C#5Y}%uevb4$&yy*CZ3s+_prB? zvc0LYb87WloK;-~H*A{B&J>-(*aV3UW1m}es59!2RSUzKT_P}y5S`RKTMrRr$o&vi zMmn8-T_w_rON0pF`&8aq{KW2q=^}Oq%W!<{wjks?P}Hg82$pvT?xsUbv#N+CSZ94P z^$4*wqv#mAqwpL#^cXjY{rWe;FVxJ+0GsU-G}vsGFn?z-bqE{g~{ z=*b*J+7MU+Qc`pb^(^+&O0+ysd`;0YY}|@KKtjA)^qqhUTFr!f2+n59AT1H`0g0HX z!;*w7(%bOxh=`2^%tQ>4IObC5T@tao-lmCIG8&WdU5M#S4Crd6Bwtx_Ovb4?iFl@D z5V2*)WIVT4(hJn+pC#f0(VXJ!AeQ*tuLIFPl*@dO(OP>tupvw|FLKbdocyCoEb? z;9{Fx$}|&M5HJdT zP=r7Ot+K^3@{mj-mXu{9$$dr~c_AZFlMSr~{g9z&;9+n=C74O)c71l%5NBL>OZC*A8o-&mrIjLo zv=&{VDQfnf&C78ArjAwP$Xx*FSpU-@WT$P$Lzo@R$ThIsNpA?bj!c7$1pQ9f&BzJV zq=yv?3#5WS1+xWelmUdUZcyDugljV-Qy6s+tc|%FG(rjl=Cn^8ez_xI@8JPrToeGz z3`Jqs1=FN#Moc`vf}bOwu*BCNyByMsF4M+@eFle2gH9e5gDVmyu-t}lc^iaco=+~#9X9Q=V_p3S3D}dqy%K_%=sq<+h zxgWA~SaNj(F)%Yv#0ZaXvQ?iF79(X zH#Hi5FkM#i!!AWxgt&$JTjG zZD-ETeia}tRT!q(LA4|4U~8M|!bGjqgO=Ug@r36dS$rL#=*(H#gJ(3W`|WnKTYWB; ziQjEhPkU^2zusssuGTr@7c{!vNXbRpY008+dh2AP(X9>Wm)JYp9_<~zX0&%WX(yT} zr}qx0=PozXdxz6=pmhl-Vpt@4;DNNfW6xf_gV>+bn}~nwvYUwae&kO9r1Em9ZNz5C zhlb#K)7yx{=l@E!5rI6-&MJ5Zv;K5Q#1+eJS@<;)Gud_YqedKrvn45qRGcLR^ z1(Rsye0A{`FYNUH@dFlz%`oBJo<>V+tp+QcGwmx&q&4|C`B3;J#+s>kxML9K+zteP z8iV0s<#cism;BjYv0OuR^9Z8U8tuhoP*W5){2)=5(2oE45OyG|MS6}r-=@0L&D9D| zRv)u|&FtPp9!bjdG*nb>Z3JM`S?G7cDG{OB@&;HtYzSlZ>QYt2hMKyDFiPkrK<#4p zC$T%96+8CRT15{$Xi1(|g9A~Rf5&!e(*|yt38?TD32b$>PB zW{qD_=wj?OwG@Nf`IL+pF*Yioo|q5Hp)nWiT#H7blX(@usZ#|wmZmBI6{!N}O;o@L zE-*2ObPLE$$%8Pv}+ z5N;Jj(*>?p>OrRN@eukIZUkp{&tAoSgL}$QdrW7V*?W-#LF_+32h7nXFeJNle7~l| z6?a&MR-EGGb|8;Kix(|PnUixJu1 zD|gCGfGI%iA`Ng!5fP0=bGtd$3i}J}LR}3bRxLme2#blr6Ge$Bq^O`+HztB)cG=KW zO}6-3CULb76h;Rg<{%Xb9(Y*|j{*_iGl(<#pSnH*V4{&XktBM~v+RG=kYe09k;j z0yBZPBdxH`FS zC~iz(p9E}@d->T2&WRT6fApuiD?rE86k}ckGXBtA%QZ;`Cq-3P?wX;1jnr(PW|=m? z4@Vq#1JN8dnHIIYn%umz9cxh46!s?WMn$A-+Sl-g=CtynTJowTU2d8$#c)b4pG#3zdsF+8Hd$L`-* zJKShb{I6)YoBV8n#>e=?VgE2+*}q5pO$UFB-+{SZArL2V0~`!JJRp|e>K!mZKu2hG zq*<27h)9q{&)Rks!0cai;yt5IwCn^O_0YlG7X(3{+1dLomme>faBT4$W!KMCy>n*~ z?Y`s8*~qsuU7ixhqoiM?0{hTbw&TN$#4%!RtNW{+qE`z6h$YQ5nhBlt-&pcgKPSN3|i>5qxPw*0oMs>{@b>DL7yfKVY8p@F7G*%lGm*J8IH+TU2Ty&AC z*^gQAwO7Xmk2MfMPrA(rTKWIjdmCuWuBy&=f1Gpfx%b?A>r_&af{KHEPfD!}Q&K$!FNw(0 z+P4vjNE4BEINs>d;CsHIqbh@8#Te<=A-N%ykSB-%gN7J2K%hV=i5lskK?@8THAvK; zX$=w}YSbv9g(uwv@_zsST>InPdn=XDV7KoGaWyYrOwNV`b^!}&oKev>kT;ycp%5A*c!sBwqNV6u+y zYGwL|HtTRlXhS|fZ04s9duX-p#nO@LueGOIR_as-W)l8BBPMo z4nm90yvct|(?krl*bD1zr?gBon`clC)ra)F=Zs7R?d$$7)Do8)xUF2;4B z0q_2+m~qN8LIg)xNLZeUKJflvLC8n!YuX>Lis}d`9^?UkbhgnnZiDS z;-e@oJ1IXrBWXklL<(_&lR`lf0RbCpLeesp-ZzXzG`ezRvS?e4E{2pmS1Q1hV^74Z=SG+)%uz%Dw|6xWq2qKf4&8 z2l+q%qIylT86G%}Cx}3aeP*tGu`j1V!wa0xhFZ}4O=_b}<<6+J#A?OqY@nBA1$1pA zC={A%)wDQmEpdWLTCb78(|hGKz^m5K0v{$iz>P*dSX+P=ib<7pb+wNhz~woe+)%(1 zBK=V8g}o(O@X@{Ov^378MrdfwPR~KAotK>sBTCg<6zuCHUAv~2o((apopCl3WXCYf z7Nz3J>?jo<$`R6HFZZ0p#N*Ma_)uk`!eB}1byifx;F^4((hJ*Eu1xuyPO85IneYK5 ze2q+X?wD4yTcIO3G~}Q!n=FCI>7PjKOs*>idE`mDoSx;y!RaeC46}l)Vpe#dPiHM7 z+)NraN0h;g>?VJrl6Ahu;4xAk_IGB*W|YjIsZrHa+b3A8m)EYdqWxHQ)!J~~&a|}* z#)Gw{FzZHkUdiS9_%g`Y5pxjg`W2VWh8M!-UJ@*E>3%m|v_Ku^UByDI4MSd6|>`E+y;=h#AG>s3px$SV9C={HC0! zFuhvUB28Ehh6V&Od@ESX7SLe4n#ry zmI0l>(kxjd$$LN&y)`s^S8cJ zMeD`-gZ`D(e2cH{i|4dWuF%_bvwg16tBQ6yH&zZ~KvFLXc%dv076<8WNkU#n;==4I zKuqp{LuEu-yjNtWq{*lj5|FUaR;q?8*rHA3o7ESklGY2E7QGj!VZho5) z?o(SOZo$=duUVf+mGjX#BI-7gk))+VhdIVA09KJMtuTE;S3u8J5tEBRv$~>=Y0@Y0 zrp2J$tAo7Aa%PUVe?e&euUY+RU9=Ml4d3(u$q{|rAy6aJ%VJF9dz;jsU%JMMgl25O zlQ#Ctd7&I|Rem-nPZS}rii508H~C8ANW7|m_UJRhzlVYR31EQJLmLcOb6EUwr97N{ z4CEaKz{aOVkexh6r6NM6?~|pHHJpAz-1cQ>$meg*VPg2KVR)GtNH02tS9zz^gBc!* z>sY(WCf`n7qJwNL;qvh^>;9EG>j`^4eYki1ce!nI%D8t!GJ0LF=1TIK)C;p9xiGlL6{7I)7j9rQV#QFU~}JO1Nd~pZwgfJ@^yv zx%nSocD3%u>$e>J?0fEb(?9(5fvb;RxM%WxbfA@flEsm&S@eZ2Ya|a)9=*~an6b`d zV+*#qw^IgJA=bxDQZMhbQ6kN>-LjZ`ONS+L8RvT~B>c(r?`=Au+JROQ^S_xPbkpP= zh66udbZM)Z!w#Z?hPNc`q0unEN?EGrIV?X+H?y>L9kti{>Q}zfXrKPP+I6|X{tW%3 z1D}()%nN#dE;`~S4&AUN_rt(gA z<3{bHWLve`_&4eSR<4@F`iPOC^_KG|KeMY?z_dNfwc{XfU7?=%VB!Kg6d1_u|E0rx zGtka@EI`{|)!ci@OJ6_UfjpH@T`|RHaUKuin;gC4_3z3<)K-Z-&L6Q~_&51*Ax~Y{=-( z^7QS3W)5IwE!-AhmVNX+9Er!_&8ZifP*aaAer0vGujv;JW*$ADi@EIvvMW5hNI9Wub)VIp+c;ck+LH zr!*(C%-`i}9)utXW`xhkUvC#U7CIzAeE@rHq4t&M7{=r4XXMurZBdm#!r$~oqAK;& zJ|5;)(OWhEDnh5e2{#N}Nsn^%Xh^|4R!RAxA;l%IaAadWt8Y;0p3M!V4EZp1QiUF` zkF%zNv3fdoPYW%O2^c0E^=rthq3hijanOkX4hLLZw=b;gT9lu}!of85OAJAqW#?Eb`r+XF7?2?tUyu44=`Zh|u~G>8a-Qzxg}XS%7mHA?R2H zi`3SnTiBmD=HCRJ*RQrMmsDn=W(6it0l}>L;Rq6`0f|NQlo$eofo%U6hBi9%kqr*X zM+?8%;(6u1YVlae|J2Y{wn3m8FLB5e7K==d#6J-DWhF|Qa6Ln5X00W-$x7s1&q~xBvau4gzLl8Q z>~W1ZbsqZSM0O~d>e=LtrPG_`mn8#{c)6mn;{{C~9ZKCo9%RJn;UkiSLzsd3D$}vE z#q?K9U)iA=ad$X|K{#opku1%SMn;*w!XZ_zf6J?8gR85-E1D{7VBk{yw_qhkxIX>) zBzL8uL-l@a-G0+2ZH>0t*1VRGeLd|B-)sM|T+#TV*JTs)zt29FeHV=-qIiv|ud`me z9?aL-A%Gdl^fvXrDz@vmaFjX@=*i@DFPa#W%6<_B>N>SOeNY~TuMExq#6$C(Jii>e z7k{*qY_VBPvaw>BWbbMx+1WZih~)|<_adB30!VvuVSkF=eU0rr_ceT@Q-7F4%KR$b ztXvw77nS`6PpU%Je5g>uLqenVY|BDUf?;wRAk^1g1&!6im_etbvJgwi4?O<&iu5M$ zC^!Kmlz$=4=c|pJ#VQ1dEbw@s9dASo>p_jrOJ#f@B5)KDSY$}x37tUzZve4ixz(Iv z%R)iCl8J1J7_{vu8Z%dzkWNIDea(r8p1P+K5m_hEafM_l>79tk#Q0Kdb43&RyZC^B zIBm}I7m5rR#fJ;M6A}H6zZupU zomW&6JoPg%@0376U+q3Ly;kVAw8P@H<@?MoAi|QWcxFt`KS3=%TM;p>tqeL`ai4~f zyJg?eK>c7-itDkTlBh(ukhZIT_Dv@?Qp10I71ga}K!VkhRt~t*%0WY*RMFE-ILYs_ zEI!Xw*SGs-L*zSs?NKVrCT|!}-;@2%L$&%;v{*S9juys?OOxf5)g9BFr=7m*jIVmi zZbWmgGQkwVC-PfsevnW9QuvcUwSI#cX?I&7uaEo5p5BwY{N!o9Cm;8dr}mzF&QF$m zPhNFYEP8tH$&Fi{9QTveUYTFt^5l2@q+j!gx0LzBmM5R`lYZ+*uNFN0Ui_?|d~>h$ z`~75B@5v*6^32|oTdq+(=k%W3<0pH2Pd@D@{eHdjRhzN#20uBo_th`?Ng9jE_j5|$ zSP(GG$OB%J2(fpE%8wD3<}nlk=#BlgLt@G3ZqLdEoctqm`WP)(|E)ve?w| zDK5}ruUi#Gn#yL7(Xypq9*ozRpWE?P zsob9}Yl5*qTX8tnc5Fzk#GTgv{?O`+i>&v*LGSvMm!(S#`8!Hw7x*1#i#ePMT%KSD zWIrazI8e%QUpdCsYtw@zDJ{z#29O?lB`G3>z?*sfP(4$)Kv6*(!bI%2IMp}zJI0{< zX!JxHdd7T14)Z)!L*w~xyt(aE-z?966WT|@_i>Q0H2+O3H=W{}mHBUMA)E@f#kp@Z z>nb$AW*#&3zMqbki^;!3$#(plNSP5A*ESrXtMT-8!M~~|#nWAB3dl98Y7xC8UM@Qa zxy-Px^2EW`IA-CVCWy)*D^<>)prqVgh8rtd(LAkrK)8<;>c@A6ElQZ zFBvN^pV)94w)=7SjZiP)No)Va^|1p>TVG@rC?bitT(s#W&&YpI@-V{veEVu{FMeM= zWBPoVexQ~?(&t6ab45^ug zQ#a#AR=}j>WLxiNs23W*U@bUKOaqSTdBMt(d%@+CC5BNwYKg&N*0@yql)xYqFeh1nayjcIe8JTU-N&zQ(-^ZI1e>~Kd#b=}ku&Ei9NK2u2fvE&g z%u|;ZDHvx{8ykK!Y3Kmb$yQ>>0@1MrAWh#xYhaBxRWKvc1OgxqvTK;R(I~duY~0iI zogJb*ATFM1rxBB%FREdL_(;=}N22=#0TLM6!-rHlpfm`g88sjD!VAZJB8 zZ+;eBETZTcmR}-e{7t#~xmg*1X)4EIvR&oBH1JMv&E8>bkTEFG0TNf3u`J(P_?9N> z5=l_yFYp#QoXohBvs!175tEC;V>Rq37Jvc)GbJ(j(d*xSPxeEHt04!{U-`*iEDIHXwxRL$OTqg4wHwN`OKyA3_E zo*r?YfYiJ({9H}Law*k%$=5~ix@@l3XcFUREq6C(BQ2$ZLak3nSJL+`=jGupP7?Te zf?FQp5>$fo=P)LrquezjH}%=#j-jI`sNMD&oPt1~aiU0|Pi<^1L6@QdJcZbfTH+VSMaN~V8;x5!KBVKr znv5lqT#PEFt9c3}4>+Wyc6>Pe6>+`zRT%;}=?K)RG03JrA?1QE2b_wUU^_^I=J>(g zdNvR&u3vF=wYV-s>fh|@^~XN{xqtfPM}PCN%P-^k1Hbv$7j*xU%Z_rV4u0U(KlO|E zXZ>PFS!X~fTzlQkCpNCQYPNWx*73<0^?w~B#+-)h{~&09LM`9@(7+(5tF)GWo82kQ~DJ+k$kx=fE~ISOHsRHtY~646aR84}?@c zFf-1O(FnUHbohHaw?gMzG3vUxm;#M4+XSFiAo+K}o@Qz$*NUpfI?(PrHKGYwH-nnI z=;RZ!?Pq(ZuJY~y4()D|mu8T`!1@a+1TcX+GCW`JiIyG^jnB_uHWGHc0U0IXek#|3 z>tF782!7X~W8iICbG_2+P`)Z>XHRURQwZ5+>&*6?qtLkh){mM3IVS5|I zBlLyg`{!6Ju>P99%;)oN>r)z*hx^0wXJtNVsEG;waIc+y317V z(;G~tivDOHz0r?sahB^h{K{Y5{YL&h^?UfNT-T*VZE8e0lOZ9Eobz8oNOi*fUkGqR;$&E>ht`k@v7B3u?dfA~6$R60#zbpoD(EHTa;8dfq%6y)l9K$QA#)L|dxS zv}uanzB(#y35)7v@wrZDyBghC6??w1)J)RJd0;`o(aanspI07*EjXnH^5?iU5D4d> z6m)o(ecjU;2T4P{f%O1xY%B2Jk}ilSghGBpbe?8Ypi|#ky1D{=%Nwhq;78X0>qQ+G zIxPbNT zLehG5IwJ`QLnQsZU?tos)j(jqs8ki9k7PLSg3gAG!82}z7euXrmh>7#%UZ5$pICTJ z-mC0pqD`@{+MI@RM__AxT2dPXp&-~7Ymg)(6%Xb8)V%PT2+}=4W}+F@-|$>(y;1!E zOVJV1^xIb8(M`>3!O%KZeGp4S4%+LBtO{;9Iru-N;hy|O-q~H9&S^wZCF6D=52~?i z^J}U!f99;^wULAp)_X^q4PlmogypSWkbnY_G{J4fK`UK_E?0dj0aHhWK$W!;u({Z1 zrGv4fU8#NP;LV8-y#xAdUJ|^hQH6rb;uaW@bDa6#9?D)=6zW*K)ic%Q2&5z z`hkLMG4Tfq5N0fwFIpp$#u{E-Yu*JBf)htno(m|-j(H|b%_cmkLuq)ZMG^%f(XJ!v zVjsG!Ei|4cLa28g&o{mCD5Hj`;qI_bDp{=ICjLCn30Sz*`Xib#ZRjELSls~oAcdG? z$c<(QQC37TGFDol{7PKL6&L>xjmy|55n`PRE5c?FV+LsLH(BbDz>SBbz=o~#8!%4dLzVP zHhqhvhhoQ7Ya}Eh$?!#D2YD2r=N#D}2n=)mrmf9Qd+<)rD>pRn-Fkq0HV`xBES-;-Z&23Kc^PlQBBMXJWOx;u%XD9w5+c?Wd(CIBON28nADpggN3}IL`c@GQO-OwjJKM|L>Wfx1V0e00z*M74r;`s zVw^RRFtN3pPWvYiJAyoD=gb zcUbH|!OWA^9@~D<3=$KVayk&4HAJCD#UHQhZB=qCC&YEc22k1nKdP@Q6kJI6uJ=Xk zk4m(RmDA&_H}^@PEg|K|Nmdu()lGsZv#*Z9l6o!=8rw_^n;kyjyIQ1Sp$Z&mP_{$7 zbR$Ky7l$9@Slay~#E&|}7Y8c@kcXqK5{Q^6&Y@fsX8IaG3A8izN}yu2OJhH2%=dNy z2_zwWnDFe51@7sb&oQ(3+D10-?#bren|9U7)zpa)mOnR*UpU@`-$Pw}@<-4Fe}D2QO@2gL>CYFRN_wYYWB7cF|ay^fQJQ zyC3M8bk`d5X=X7`7v*xkwN+x$FnKsKR zWis#dL{wJCZro)NGwx39#+BDI-?ST-`kxxRanYojtN|}2*_F%&T^>bssR5fRycGd zs8gA`5-~irno*vQ&x&9*lfs( zl>MAD#VJAUb4G+owB_A=@|vaoF6EP1cJvqvY<|-*7MBIkX4?+2lWeZE4Q{h-SBj$a zY2?RO&0=7y!6#wcb?uzQhAd@6CgmFAWZs*4gCWT5DmaO8Sc-mg$OTL87Euo-gyzs; z$>kx=;0TGcvMq_+*Vd)uPUc43c3R`^9Yj6e4|fSB`0o zYGeNCirkxgnUx1EV6d}LLiRxM6gN?1+ZnCtJ6TIM6(tUzIzlF64Jt7QT#PjWU)vhNI9by&&2WA%a!GIG48-f%7_{`2tEAs30NyUi&V+EbS{6Qa?}d6C%)Oj;44#q!=-u5naTX)__hQmd9H7nW=$#5>z= zCUiq{7ElPw%q&3FNmw?P%w}Qfsa`7$%gK#Jw_J%VEceC&6GO^WUVV+23M3Y0fP4wg z4?Wy!2Bhb6Y9o=e0uoUH@|P)BMUW&{%HPS)z%&>16|@afFafY4LxaTUG;Y0CqC$id zD6sKFM3`z_h|#s8hW-&!ix7M;FEym8KidG)+*<%APlgj1+q++OR^DjxnxZ>ypobd| zCx(F_w;Ifnp|lA}B9(b3M?!?rC^$Cv!#f1BUO$kFvM|D$K6O9*y%FHH=Pd0f_%+lT z^QSc^sE3Vw@|VS229;q$DP{>KQm=cP48v}24>r&v47v;ixCwzHIm_5Me0Ko*@tzno z=^TbjlPvg=$>tjVYd)Tl-E!j=DxJx8l%d8{Ot?cx^MQm^B9NvjS#uOys1@_7`QZJ3 z`@Sp7b2FAEaER_WLVW`AS=ovo3@&XSEW1*z@ztZKRJ@PKcviMU_3+JVDzZvS`zRtS zk*S%8&0!n?2J81}8AVVg50H%sVS`!$e;!$K#5T*oUqSOf51Oyf?eG`96vziJ{mCO5 zdn}>+CQB^d1JAe|yeB)NbmT&mvrV4T9ohJe45AqQtk$V)gft+E>Z8Y?yy+)oR1yCL zV>c8so?Oes)g(p+ z3-G?8XcV_o^IroX)EVh2NZe`jsZxLD^9|RIi+b)JeO~3tJZ#$kWp$UDwUist?_8yY z!jutH?-UJ_H9>MlU5_EI=!7uP=1J@*Do?<)Fa<=w!8%IhK(2RBoAxEEqkXPo&8_X7 zp2(DH+GorVQ5{3RKQ9XA`Z$M(Jx(cH6h(*lsZnT$j~uVc(#Z#kW+Qo4Rm^} zET>-Mq!bH(?VAFU<9*6ci(zn)6jn-6tMyM_%6OwSY}$q*5hbQHNA_C^3R4nOn0Z`L zJ-#_~svlrh1nv3YoZla?^pIP2H_LAEg~^>g_vj?Iet zbNeo;Pv)-lgU)@%{apT+m z_FbRIRr$EB{8$axKX?)p$StrP3h)T7Z)_a>!rT7hV>`R*Z~o9-58U;MAH6~i47vtR zQg?Qex(f!lSWlx>t}iMiCWqQDCp3@IKE@!W(HeSiNIu>h9JC=xX00DJOhg7UFxhuX zA4QmLy~|6uj45@I@B_unD@4^5Bn!CJ!t24S;O1;{_v( zZ2kTBJ@Bc&xa!rPyKFt1{`66R;5}JO^y*jN^|^=Nd*ax8ew&@|?K><$`~>@mdEw`P za`6scd|cP(Up8A^t|+E8H)y`^nqy|EC8&{^O@JFC<6o)@N=UjpS1)qi)(%0>0`(%2ufaEMJG$FEWIE%G18I1Tvh@-BVIXZ=@f zP7&BY0E+neK@Ch91M~8^fl-IsfdRr3d^!D|61zjiiy#auaLs4)&27^jO_ zJ~84Kk6(s*p}p_Xp9?(zi7d0r)c$41lurHZ@_G7m5Ul8l4j@{3{+>W*QQw>&0s-Uw z*@<@uA3EELZiAMBZ80nq^+Z`u(^Lo zo1!3kiDURj$b((W^p#A6$HpU9o>;hQ{r@4cg-Q4PYk+^_3*}Yo`>r{v%Z*2Qz1vSonL;h&YmVy0 z_*#L-1Fm=LnsRy$2>P6>^RGR6^r*wU1|6TxFo!q*8IcV|s27*NLKOQV_C5SI#`t=E z&3bAho?9 z{87DJ==T!)P)D9J$JVL4<#|kyh>+}kbDlf8vz{{St+?8Ah*du;hI%KC=EXhwd{L5% zpcaG!CXtR@*EY`_Sv0J_K@k+pyrd1(mT2kutUXb-yrh2MgK*NJvr85mzuC=U(3Kcb z&vxl@{eVgaGSHigWXJuXtpOA9b=I%J(EHnJGQDt}Z* z&?cZ*){H@HzKK&RdY>`M@DPz|oDvbxQ^!)&ftk!aRo&B@#Rf`1lKmXz0Wcz*>Fr!m z>DycLLwm(%DDcUr^DfY1F3KI#o`nl_q2AEZjQpmIBTSar-{mbuyz_D3C<)DKnRrB| z5)QNuB6YSBEu^Jy2d*$>EAdyW`YJvi)(>ch-*R2X$9}JaVR^4&u?JF-F>0#KSi-`D z2TITMbuQn5GzQfiZkIVLogems@+lRs^34j$nssm2#qA2!u(m8=+56RO2s2f;9TfL)iVvY^V;LHIz+ zax@Rlsm8RA_*Z}(%2LrYTARlk;$^4$=)3OPpnLLqIgJCZHh|lqCfFaSm#i;s2nWn$ z{f0h?`gKi_rRnv0=@q@mCR_iwzglR&5*9YqB?EIwJ>?HW$ni&v1a!eE(}&}`*^Y}f z#h&vv?TEC*HyGr+4bpo{J~wks6hq%nHghdE&l0H%RQR;iEC zPxD35foY1KJne$!5vx;KSy!DWRB7`)3S4m1EP@lPpc># zA(~U(Ml4mq^#a<8wy=EMffSKpT zP_9Pi=N1=M1;#kZ{m&}|&8-sKhIajCo{}C+pcXYS3HU4ko_=0@?a;3CQ+hC^ zOlXtLX|hs0kA$)Y)#LW4yGZAuu}z4|TnZc?tfv@D^%qoBIDd9MjR1#rbzS+MAP3$_y3iChY_Q9CS%v` zTDOvL@-b88`C&l11+;KVrzaL*ZJ!}Kc@ZrWBOf7g>#P1*8a}7>g!{F-zR!LBF=!rN zjw{B695s zu05r}3S$zd0Y^fjvSgSoXde@INnf#{t$xU~`{z(zb39NDDPcWF zyvi*?Kp;Lb{67dI%`+vv9`_|k4k#_>ni~R!>n0drvZG?EHP%-!(~0;2G9GawraLAJ&!dPv#0{ z)VW@!0^>_r!9H|obx$^I{+Blw>9=5_#E1I{M3bByD$-(q_Sp}KlnPGh)kKQ-4={{g zpdb``zL3u5lai@821^bk%W6^v$QpyDv;?LYrOA87?D=IO;Ywt!4#T4dFvwI17~*nw zti$cXaGZsWHe(TuZi$LU-ipoUGS-X2a`pn7lf~h7b@#{VD*N$2xQubu zfN6O+QpCB#puIyhMdSht^&PG+!`_4v*}}F6JuF}cv6oOGhWKA8nrbPT12Tfn0iaqj zA9U{=7I9zd-uuRMuURXTe8o%(U$|~m9YO+5B%(<;^A^mCp2B-OJko#!rk3$AS;&lq z4~Ly_yBq6=pIs4kh(|<8F(~25guMw}3HpFB$$2aEtLXp>6F@K9RlDj`HMin=hgc~v zACe!1X(ibJq5+2sHan^~!-Ggk!{ctm@GxUmN)BX2nCt>c;!)hq`J2g$1oPS*#ayF36!x68p ziI7o%Mh<3)3R?uELDCRvF~H)`(cXY^o?LmP z=mMCZ8!0MWZpI0eX*)F;Sl|Z{8^eVK?p>0!$z-4!rQXaDRRXlx?LkMOE>5;Cygd)x zx9$jS#c0e#A_1mwI~Vn(%wUo>_Sa2VZ<2Mr+Ovk2LrWal`g-;CM_c zoXtgY3Q4QPDDsN}&V>$rU&!Eu+UV+Av%?r?ef^8lxgL--4nTi*ahrg~^>~<4$K@O78&3_dC zjJ&{tisQfjWAK&tU1W?3YsoJ^vR2kWeA^>vByD;jUx1ccDgS9!U?ru?`fH*6@ulC- znCh%pUTLN8n^}^DFM-_SuYE%G4S!E94p&E}iD&tjc2j9{YF0lL<(6N1n7>bd?w(8R zh|NmCtwuU1|=hppTk;V6BQ9%3-yzD&y_NNUGp<3RKD6pF_lL!*I#{UJ%;@>iVtj`pA?ErGBOP1V z8hoOowed-0dm^kC_rn4>;4z%dAGNx`J8o6`LQ^r?7F{kTP^PPq(94+6%9&lKd=^M3 zIrt6Gq>+Y#fkPP>9+nJ!hZMou;TRah2j5Vkv^VYyr3$CgspI>21a1LBRqRgWfQZB* z*9%0nUnr6uxJ6$gD-c+|;wfs8_&g32E-2H1x$KJo*zMG;HU5DN|Why|$F+hV;efR}*_3q>2f5*Uj; zFhpmKjG%oKx_gtP1Wlv5LhI z7o>?x1YU%lr$=o^Ab?&+B$IG?%d`O~bp+KFKiD7)qy?AS!0<9KTmr12WO$GbKxrui z?nN}}x9^durX^j%a!hVgfb6@vN>O8r1t? z^q$qnw)e!Hx4n-g9m>-CaY6}9G46&@CzT_F)O*+>c%J++V*NClfNh#VmRO`iZITfZu_Q~TaDZkICb5N!lXS(u*AiMaIO0N^g~%HG$j8lI0Lxe~cbHkE zCd_aFLQ#ERLawE-+u$7!O-S@Z22TL8=-oaaV~Y zfM7vdbwY|82`L$2SSc1G7G@z!m;@!op|5oKdBUlTc4=Qefz&q%snKR3HR^=asHwOU zQuMId!^@>l|LF@VNHFUODy*nEL8ZBzpbA07Y|Jo%E6>(iK?V4zA{n|Se7OYp2tmaQ z5_?cMUdThB7p8(fCs1w^CqVD!11Z#?C9Z7y^yPr~fNmA_hFvC%#Pmw}5OBo2pLJ3Iy6*7-QotP%ZFOTNK9Cgf(D6p#wye2HsypMYg4d7I&oE^wx$7g>Tp6bBjCY3;QW=PsH21)7w`%t|7bb6Z#`)T*~k@VKT=N6C7 z-+ngT`uE)8=qHWPI^AxhTmPP0yk`FP+H~vRbBpWeZ?8+Y{$+gTZ*NGq{$(KNZ*NYw z{yn#N=lt#4)2)9Q-*kJcei`5Sw|)vXZjZNj&KLGm-kylJcc-_hu%Ghwu6TQIdYcOS zDR1wIxA&*Fsj#2&_P%)gV0xPh`zdc9h_?@?x2dq7^7f&4`|0#H74}o!J`!&qO>a|S zKjrObV&GGh~>1`_Pr{Z)X%w$zK%>xqBvZVX!?eUdF!(nXe??E(asY}WGEcpwr_3$yw3LHaRG_YXZS`gE(&uQCuSY;_C#HEDAo7SLOn*Gb1UPH#W9- z4@X#22}fIyf&(Rx;_OwAhZLjjr9!-%2%tnN^$@;^aZR7W;MpNj*N6z?h3;<_?AjDD zwsNYLxMuuLC6T2A{VCs_$qYhRt7W3|LvOKtFp^|AS+<}_CM|;%KlDv!knkK8M2|d#kcyyBgf0AbcFH)YT@N+lvI6AcDq1ZHCC1$e zbtDoK%MF6>heYJw0LJ8B3^SU3Se_V&fVfIN!b7Hc_aMwvrf?t95+c{$aPabp} ze2gW=RuRMt9rLmjE3nv6YhNo% zGUXnemPizeq>X2J{dI?&?=+kKaX4@5Ti(mi*WXaztKTMcr+zR!z5X|PPwyS!-=#H1 zNg^d0wG#j_Q5qEJ5g`Lv7Fws0QH=u(l2{WEx&5)ij;ThIJ$p=GW>Wdtd)pv6`BH*! z)mV(PIYusLstT5$n-#b^<|3@5X_RQl121dacS(*=IS_)YREKIa!KYSe?~tfQbBGSu zK72r+DmH`HekdU-xvZ7wB$pu!YEXUmiP_|erwL!<+!_=I^~%aY({>83IkNy>Joa?WK3suw!K351kh!?HA#-Yp{%It7{i3N>1jMD$q%P2`&4$^4lLd5_ zgb=<5;4AdREJV_Vv*~P7>A6_R+?xYvw)CC9#B@?uWGc{lzh1qV^hD>#4+1;-EZUE#l?4IZj( zwHyPb6&xX=JP0u`Pz<*QiZxabRH1>=pLU>d5`ap+sjo;D*<|vo3oL?*dHVkWlG4@- zheInkb2Zxrj^LyWu4WLwE~4h;Y(WH%#o4NaHg!0Igifr{%nEUrsKmMm&;RpRFlAth zSc>8If;}QLSbJ?u1q*ylU5H%kLYRutL~2jZr8EdK>Oz>O3<&1bB@D8H*c8!Yl@aR%ybS7Q`dfea$= zunjvlgQ^x>$lVgjLRy^Bt`nhsa|L{nn<$g)S8l9E+yfI4#uu!B3UxuSV8Muz9S3)$ zT8OnHX)~6m`}KfgWdrdhdI7Eg>#ne&Zj_e41+@}1_BRpRAUj1mt78kjH!H}t0?7vD z1$eYiT6^OK60%!~HcIG_Yqim+(M`@O##*4CkiGtCQBDMfNKD(5jl9q(B{6ZLc?;jE>p9?Dm~yB~|!9Mv4joYnDe`Vv1W>L8I!7h_&pN8AX|8eOdkT=$ei- z*#p!OR#}QY%IjMxS42K5&FW;yS;mDeZQvC(Jk38os{p~h18Vpn>xL$St4 z&u(Ra%bq{<-2+S_hD@S3x-5B9hD}l?n}km=l~hJlK<_oH;-Q6<21*kLO)!#80$`zv z(i&_tmq=tsZzv&0s_NQgl(;Scut8F&mDV8fT@s9-9dbk*rtejgC0t@(^~kM2L>;6u zNE+Y*tL;3efvx>e{2c-vDyZ_9=xkP`=rE5JOPQfafvQ!cd0&yj45Cjzga6V@5lr+H zDes|tDN?v-Zk_|srV%~fVzNj?HYN*@HW--i_jU%0jUZ~=VopSj$+YNdY;d|`GC9BK z9gEs6omb5Cr7;uy5U0+=mS1^#pR8xJJlLE*6xT1vOmds!BSICV5DE4)X&^-Lie%WI zVYkFuay+6s?+(i+DnZ?U9(6xsZp%uMrU9lrF`cnoFgKe1O_X1wB5viXctBx_a#cV& zXBYRSyMBg;N9IaA)Rg$#xKO12S)Ov#Cy3KuNbN#`inQb>(_*j&1N)VZk!xid^K#{u zj=XMuF{V2Uy-it*1brx;c^tW@m&9>z{1pJDRC7Ws#&c6&?p)RQU)Xuc|8CBeoo%6h zCBbbGAFU9c&Gp@~r9P1?O=0cnRXx%TcEMceK22@vL~8V&)aX@BqxENnsm-l!Y;Lt# zw~(#DXd^7^qfHfSEE78WlS~!;-V3}fK?@XUbH&?=!3jbHKBLAQT4S#<;+~dt`CT$tPj~H%< zML<5$ZVkwLVZ@)%dYwb}MYa+>sIS;d^!a=)nN$*s9$m>M4Lx5_{JpBf8A|lBIH+p& zhLy`zwU%L8DRVxB%jw5sfsj%*1j5beL!*@>Oox?wU-h%?P`NpaoG@8$mn3FWN-L28a+3I_5?z9cJ^a>pzZL|!P(W#9X5Ly z%}IZACqw?~Mm*GjIiSC`>JWPzln-pyGb{zmIrG zT}&SoXJnJoqTduMh#hj$q}UDIAK_W+D77c zo2l-X(4({rsNJdO6IFil_wL2C4iz6^@G@!`H%!1 zbv8#Sy%`yP#aTiNTD46nD*{qwbW>ukCJMav93{HyObt?f)_P&d#e4a z8TWq?&&3D*|J0pqwX}+bq&^}8ramp?6&9&iJYa}jk+=Y4!K6y2;GGTHZ$JGjYYx=| zT&mh+FPbH^nrRCUO1Q^LS{AdQ9Ka)SK3izg%`hHeGcw;G#X)innV+N-|ipG>$}WTXS6&e#SR@X zmaV1~G3cST0Vb&qDa2u6^0Qi7@}7-Lz>~V#)0Ud7NilDyjhc66%ar3nJ!`e4TMGZ{ z69Oy;FgO-3&zBo8S`zo)+@^5Lq|A#(tZifP{PS2l$c{$NVqcKJCr|VP6UQ)5o|hE| z{jeKk7Yfx7g|IbRDX+3VtR@Ii6*+U*j-eb`o-y|dR)mL#bmz0)wSzm_}g zG%RALA<8Ru8Zt`Rx+NuC8uE~RRavLo3am2WjmqlHExa4wShr5p zpO(0dAQKtI(h))d9&g0Z48N}h@4AEqDGFG2)|;3J80Y#U>?ewO8U^sDAwm_*As0Ca zl8b!TY^bFqt$+lOh(!4b0rr+~Z#C$khQ`M`?uZBlVKfL|mvp-^kwdfns}d6;W(OSENvp z!bRwn63P%~@dlcSQ@)lVy)-qbzyvP1phpF6Q35rgb{z+>0AuY>A^?nX#pEPO5a699_Ga@Zc9 zP9GI0@;q>TwMIV8Bh9twlr5ZJeKp9Hg`>F23s`q8I?p!?82U30kcM;Q>09shBlV3}t{uB5`u63;Ha=R79c z999TWlBaqqNhuiBBa8>C`3_VAKhG0s?kk=Hd+>8`O>vuCJhQ$+6k;zHC(Bfd-6Rxj zdaIy_itulBZ}NE4!qmXl|5d$!4u3}*Y>TW39*mU~9%0i+F7p#fi9pVv&6EY~ITX*! z`aa=n`X_=!+=_sr4=O7jwB*pS-FNL|8L4eC@F|;Cg?hctNgdu+TUd`m}j1dznl_t-d104#Kzh;r4lE-tp;#b)2KD{fOI%=hu=qrdSt|Ahobqxv%Tw+`$M z7^2L$EVSFC#2xwy=lI|wDf80LtNfg-1iWuYHRNiYe<%8IserWQcMw~QSzBMu(6GA5 zQ>j@JuK6z;+ zgc*^LCY;I~#?v7PhQL^Q*Xf#s<^G!(9wq9@jMTBHjM5qdMI+#^u(nR5kf==Lp$ua{ zHt0$F;l*lMRHls1Gy|258!7`H3R~K%X-Rt(hRWs&Z`nX)IHQT zZj41jy?HX^$z*N08qQXZ>(qx;9UsG*?<(hbkRO?lXzZmDs#{_!l{>uH>#!95e21|T ztlsc#sKYzD4)0LsChCJMis-{c&-f(RN<^PJJgIi14wLF)Q{m|Fj*hKdr90eoZV5V9 zZQ71sEmte`623Hb)%`$H@a{?VW4Y_c@{{Yw@+tbUylp?`j&cOYDDCmS(= zpuezTo@$Yp01C1{QHwYfaP;r)58O2;jS-kUy}$odvVdpE5~OndZoQ(ip3IR9k%&`! zWv@MkeF3Rr%SLUX$Lc%!HCLL0s+WgkUDZ#u4P}KO#b;Kwt)t*Is6#ABAA+YlMwWi% z|8fPO^7OMCl}R9zvX^5zYm0+c38z0Poda@FWVmZZSX^xl;ZyGqCriXlbCXD0uDjH(<{h6Vu)jE4p)jE@rO|4@z&b3Z&ctBQP z$+vuMk&Y8f=_E)KQhU_*-U+(v{}t#kCF(nU_o#9BTG<;I(Wfk7UI{ggDKHR{`4Pb& zN!7P==(R<-_HvNTPRApQ83l)hnjRyO9`28wYGufmER~ihR)2#sC#*S|k-cQ1VtAD^ zlTmEUPh5KFDO1*}Zcfi@WQ-L%#PiWy09w?n^fWl2u83_1ji_0n@nP-hRJD?1jR*vy zbD(-tdW)=~jZontN4jZ+-w>9q{GkO;{n5uB%Z`{hpC+|EMC#TOOI*E#V?g2xg|Gd( zJbNotJc`S!xg`ArY=#5|(Slk@b+scXcjA5xwS+szqGZ*QNC%bQx_iibB*zW@Qjzg8 zKajDec94~3ySRsC+~JtsYukA8aN$$A292%UEyN&UuymR1P^0X(qn?)K(;pWvtcSA| z=abG9pMvt2HRKW-K^vurUb(n1-K^1#)Yh6E`YKc)^{=i z7$)lAnz=t)b9;E^QH$dPj$Uw~| zLfoa2pOMPVm;%9<)Q@r`FC+NQ4fQ_98ck2>nqJdPqCOwZ9gP|unZK!vTfPhSp|sVi8gk|l#|+6X2NRa#gH%B(FK(AvL(ed1#_j_Q zCY3;^d<*9>NUerI@{*#m{y*@7AiQ8ApUTOxeL4& zHLTyE6K0a`W9A~BA{>~bkiNM)U0fjDtg~@o^oB&Y@D(BB!o3^{Hv4nv2?f z2e-pE_CYoviNkf`a<3TI)lhDe9zAh7?4n(=V_aC`(iwJkRf0bwnL4DkE4!4!zKsM6 zs71OyLnyEXPnr=km#t`w#B5O*M3C!vlSK(G(T9bXo-FxR!U}2a>yOGZ0fS^1(56&* z-gSqL^uv_5iJ5`Ir`gVI5i9dsx`X_?($Gjeg#bqZs}^-*p}yz+&|Ev=17jj)BW_@? z03uFguG&P4zaFt*XT_`^I!rU}pdG;Jk)Mc9Er+7XkOc2tE_C3KEt6ES&W9!#X6lzEZ(@{OZ%!HmSndlD0V zeGlrOh1PjT<7n9gU#Wq_GZlFo#86FP?Dr@Ri;UF283(Aq6^3pOlNdHlEy7i`VAbqw z4%DYQp(s%bl{j-M(JoR>T?4Luq=`ii&U&>~3!YjmdTN2D(y3g9Dw@d1acXjwodO76HBnW{Ggc-`g=wYejM16v|E7C@aFQ(~k8sHZ ztZ(S3HrR`OY;bLk$EC886(J%Q`{c{85(rT($Vh_Q@ONsKDZ7WMRVl15 zRh~biIn_Rb=@ii57zxetvKyb`h{M$UQ$f=Dx|mp+H#c%80j%UE7DQOYwHlmP+10tST8+$MXNNE+c)!dvDYkaeg-($v|&wZm#aQy_kihW zyct~u<;u)Z8J}Bd#2!aB^8`NA+V!lgKkf8i@Mgd>_-4c7HL$c#uc4k)U)z9q9?9O5 z&%Zh?#MDV5X%kcfIxs&gL%|u_UV|uw6MI@Mh$cv)U?mm6JKjrwfv=L!BgrX&`P$kQ< zs|g%3Yq1I$*Z&FYWK`sNJ|225KCXX5_R2s}@Z>zy^@6;XZj3l{ge)$gronbc#9(XMQOF(o7VC zT#Y2}vusPiEDmTtTQhFUrauPlrv%2zN{eG=Qx~o*n`fBD5QMY>CE5DDE@&+l-&YhK zOgpM?`bpQZ!#DNo`o+F=WqDaq_EZ2A^}?a)4KBM1@<15UNupB*nsl?GBg4FqPp~!6 zv<@O`gFOgZ2}ABlkJOlMB6jhDCZ8=`G=!+^2x`~-mbeeHNWe7IA~2|<9XH5qQs3b| zNMM*9z(*8`3KRD&Z!^61xO_RNZp9QXs$19$F_bnr2`JDeE`%>N!S!LG^6GI2u4*`+ zA&g0J7WhtM_8UetBbpGsswIful9kWzAM*?4O2i&$$5|d?5ImTEBnVfEnt8`w{W*u@ z**a*j4WjKeVZH%Fs2v(IYfqz&X=b^RT(0zh@Hy z$r)iW(fZL;M}n?&BlpfXqTIC1zd&%|*B_jJ%`$Q`AQkfYfC~oks75L|gpH{y#;-Ebm2$ti@8tWoQaWo$Wg zcg#U$jR*=6?1^TEUJWKUEEc;s+4`)Q$XrLTLNXhQgY||)BIVhVmT{x~-oc7$XIU}) zRqiJBCq-Qmm?o=VvvGri#>YrWdS9b*FLdC>`St|Tt<9*eI{fBvt&Xsh6 zZ9b6SOx-@j?)tTz^&P{t(>O6~?euDBpXM&wSUaO+{Xo?&GIUGz-Zc%rXk&%@<%Fsw z(FaC2_~X?PtS$zb13Q(IPQesBk2=J0OZLPVmC}6~57NAw`Bcf>As?0N0<)##)ynbN zS6x1Pil0ud4TQ z4C(Qvu9CVq^E0dWf>Ao7PRTz@_0=xcjjCn&RD^1*zm=EGPOr{5G&`d@T~E)bH*PI| z2vu+g>V4z7JFd{fg=&Wc{!R#GA-41LXi}YCty~0rf`E-Sgt-X7jl4eLX~=_OFH)32 zZ;YX-HYRHfiBQ8_2&D3{tTe`oZ;ML~RcBBLTpGs=_x?oROQziA>a>^5vTEgJl~P*O zcPZy@#b^cpEzuSODSF{Vrbn~qi(=}tUN&16;BE+199r82aV^(-FRC9UhKZ}M#gO6Z zWrrY;ouap$)l@%IR$5N0Rvf7lgsE9*uoF!|qi`bGL$zf28t)dd$|IthS3$Q^Ss z06C#i$go*&`Q5uV*s_wWH1*XCi-)2br-vA}F8Y&p_BT7(kuiQO;%~mDz9bXli8!dJ zuNxdnGb^UCHTcq~-0D&JRE)~o=c3-Ko?r?I(X+QS*Vtmccv_1noW4Qz(Fc?}78(~w zr>XZ;18g&G33DBV$oznq`LNgroy9K2B?CAGZrD1W3(TNSNlPcPkRq>DTdA{=f`|~X1^X!7C8q zR)V{5(yP~vQk0$BtjBoQT`bsK-(Rp%2jtuDQu$5ZHz|hQ1e5Vd4QVt~J=i6C7Z?zB zw;hpKfQ$0YUln&yDH^1m6XMd#TT{FU<4Ds<=j<%u51IK6qQtQCJa90*E{(DsZ(cWR z0MQb>=G>5iEpZ%WGDy_2C%CMCp%hO39}|n1sM}?~`jm2EI+_lr>3>C$=jC8J9Zx5L z1&7gQ?QA%Kzp{%a!v;F7EtsZ^Mg6DAXlUk#XC!of>xv-lF!VxV=TevJyYYrbBwd4# z=^?sk$Ds%ba>PxtKie6x>KYZH1)47sa6$9nvrq8DWQ(356tS^U6XT|1ExaGu1TUQ% z;P^K{YKQ?`tpCD=a0#axcY^jUHKhq*J2O6A5=2}ZhTmy?FklZeDs}GD2VFO)NOkW@ zpVj&FCK1BQ>BgJcoRg>Na+*@MUz~=eK3Am*{ne&^ohCngHvJh@X}F*xE{?41b3OOTPtY_tf-Rup-lyLE^Edy{aFLP%Ji~27G<}o5ToHHXXP3KYqYH)GYBYVbUo5qF4kEs96B=oKP%FrQz9u3Z)&F@D_7=U| zl3r(9UhBM%M0yLFJ2eB>c?KGYm4z?FD?AcXRxzb5!A86k8khs0#P3}TRSq6nxhB;jYQ-5GFeW#EIIHh^D<#)D*INUHM%Z$#k2J>3 z4gWq3%d&yJn(k?6L#Ajdr?ol_@i@_>*OHq-!bXt)9OqKtQs$gm(O^)g2BTSnAq@3~ zIfpIb zy!=%f(uL_;En`k!M&z?*^1>BDVpx6sn0Mfch45r^X~?R#be|mA-*?Y0ADLx&>3nf^>au4v$p&( ztrB}raniP5uhwtz>p}Z^hdg6ndJKTpgaNrtPl6k za(AJj%B3y%Sw-c6UybPeKpu-ZQY#2MOR*j6#-M5jR@63qyIWn+Q3xhUaX!8+)$>Srd>agfYUes6fIltxGj4L_WmUt)LFaTjsuHYic(u zZtdI()-E@bu8`;{QL+$|9wCO&0`NFOxh!%?Xg5g}TUO9k#y!VoOKDs28~y2qcd;Gb z$H$c$w(0nGa}%+RN$#*K$vB$+LPIjlibSkHF|2>VrCTFJvq&tr2B|Sukk!`nu$Fq= z_$(UvB@`(mu}4FTuMCP-_QScWA@ZG~ZCYhw8!U{0?VYkR@l>S*W4l46oV=YGWt(SP z(+p3nRWOw=CC7Q*1USjXG;f`?!6dc~ylF@uq`7HhnKjlZ0OXCKi3x-Mi2#7qtW6k4 zli(~-?=Vy8eC*l0vBW3PdXEW%$@?ay)eS?DH4>4yR(es$L79WG-{}@rvF9LiS$$u; zWoOx?8LQgp1_V@@9Aw(sbkn?|tluhkN8L@Uigj!rBbJ!H7c~$=aaz?V>UX$!JK@x- zhPOE_c#d*X%-WMLYZ4I1!G@{8$&SSC$h=z5!-xbfs7M0;EuvuXgGIl2QC_|i0Rjbd z1yjA%=onFW%)#gE%b)qm*nBC+=ABP;Y*N9;8yoc6lT;hcN3`yMF6y%{0~ z-t2oenM`$_%`FqwL6U^xpUlRxd3igGG`rv%siQbW3U^*bc)6PZCGDX{xq}Ok(@0@E zNJ9M;2LZgW0{mqZ;IJsOEbpnnZMA5`awZ?tkSYu=3Nx#J!4gEVeGw1PFN*V~^)}GA zVf{-k45FKPiUK*|e?du=o|R$aFEdPBBZ2 zgwT4PR}E16-?3H%L4r#qH|D?mj6GXjSceR_ErlL+AvUEX>a%RizbStXEXa}r zq$rc|+5AB8=kxPXZl4)12{h2lbK;Jj&{~bzo61!DdiOXJ`&*=yJPvemx7~NttnP`n z*!|NsuM>FdeR>gNkmDDq_eJ_F3GsN`R2atMkkYYlO7|q%)17Rj)JE&)X!@v~5k@cz zK@GuZD;Udxt?N@etu$0JSFC5Ch-g-gDDMV8Ok3n`lLPIjEchl*sqjoU=_tJZtNA_2c(BgsT#cojJ~aO)>Vv3lvm9zu2w1;ueV1KRjEl z6=_ea+GV-ws}(vZI?$#jRZ>kts;nr$XO)dk2$mf@(m=0Nt7|(1W1-<*;*-_stB*pq znoutj6T#1TZ3XgllPr-qFRYjJ75=N>Pn|B#K|zY^r&C3>tl>}?@Z#aw4zgZx9?y-c zl2;($%(c~u&$VG`P_1e*v!hx)3=qr^%zMkSu<`iRgZeh@HEGkG(O+~Vwz!${YZ@%? zO;ZzP;ATzDEhMa0I)8@;GvY1iV`^`~?Lj-$j+iY;kAZ+jqECLJB&AT2=bh^}P~cWY zb&QyqiD{GYT8QhjzWZ>uV{mXEp1GDZed`NPA9=5401_M@FC)j+BnP58+3 zrpDEQ&X=fx+npj|wLo1dXF~w29MeU3J0nWU9cbQ_^)Dv|PUa0Ag%;x({2)+6^9h?1v>e+1kMdvp z?G|P~f>oZEqfsHguD?zJPkDVm(wJR3Y;wvW^2R+GxD}!Qu`5Q;%#^^JK@h9 zawnOH&_q1JQAERBnG*+xsnT`&0evq)&ThRmjPb{-T;{tr?x14*3n=I#Z;2mTL=k@N zt+T46%A`8vRMMpAdUx7lKtf+;B+5Tfl=h@rhxFoJestU|XKKSkzSjnFVR8{6b`q4a z+18~3oP>3?LRFtioG&*v>GX?RWVB#yTv5RV)XD@!RtI``U4T~F1Fqr!g3`!g+B)Dy zmLAhO$zA7(+EpAxCkxE!wW2CeLfqSGQ7|YrPAu}lTT4X@XTN@IO`S^Bt6hC;>zifH zUj+x2bbj-HjAt+8T0EA`7ootKxG}5y+?a`_HR%g(WYTqBKBG}{W2Jo+-6L^hB?j#t zi5n~2`n^Zu#!61=uGF0nW+je+^E{arH)5VO+z1{V`JA{hm%(d;^FWUwb37RtyBaEK z*g+$9R*mBogcS;akO|=!=+yUVmC?R zSwfdD$t}5&yo`*Y^R1+6K%!gmOz15labIAwu_sYSYve~8<{Tx zPwa9+ifB$}tDqm*aH2Jpi^&@rpSk%yToKeEn%0vh#4J1@u~)J2fWVYm!li!73Z8(> z-i*dm-mVujZ?Laoe!)A=Ip1TkLIy3g@s zcDFe9l0uJ?nUtR@f~D2J#pKVwY~-?;ST=`GA*-|LajQc_7HJ`rtV%T5{1>d)gn@Rm zb~*K8*8WIxR7r9vfCH}LfbaXQ~le|i++F=;uEeWczaF453o!Vw;IYgsB;Yn#^Yw_7ghe2$40L>{PCMT5Ei-=%)krw5Dr^-fYG*6f)J zH@d$vS7BY86O zi@(1vE}daP59-eu03})@nL)rpnaDqS2m`fwB;;$}(t|}PT2dZQYlbC$inH^Pj>jtM z&+{R%w=&!YGTE>P-#ji?A=&PXY?GH2lSu-ta3)zZ_<5$zqV*Mq7EV=}P&|G%X%pCU zA}UZm0HpiALYptWm54q2a?E+Qq+wbPP&tH(z%^635mICAQ~?-w+s%%=Oc`rSw74oDfk@5}Ue_-G72OhNB%IRQngi)TJHk2@ zy`Cc1srY_@UYsfIqcbt!hVC9wT#5xQi(wlNbqFiaEDcakK}NH?MI~U6A;ai%#_~U< z!N%BLuFSIb2~)r#6MenRTUUvEw9LhM(S53ky?VTSZc&lNGL#r(Jz}&3brM~QeaSbE zk6ix|L0&C5pW+CWS{B0kTad)pr2IiTn#2y$m4)&r z=&DOj&=~_?!BH@Knn`8Wr_fwWAazhlOL9oJ5d#; z0_fPDsso8B94WkTNOA&A!^82z=u10NNvRVjfRpjyMl`Y5FQn5|& zu}^N7>%*Uk%dOz5EDP5Zq!#<;Pl5154Ty_yuKeXowRY5~SZkL%R<9-)ZrSZz zYzh;jDf9!hspZ@T4+$b6k#3#^l8>0-RD3WKLYMY$Gko-J;BG$t1&n*x{aM_RDDaU$ z%0CK7v%m@2p<_m`pgA4A&321wFS#7F%0kBwZyL>7220lDeDsj#?9-8m2|WfDg7&ur zbQzw(ma_mh1|S`MCD0z1iugi&0p8z+Pb-OK121ejmiyjy3Av`QS{Z|KGv-F`wOjP# zNd9(w&{JjBDxku9?2B{Pr6K#icwY?XezLMLKtm7-3PaRdLfH5sE^H!#VCxfWl{W)zR#Pc8L4!y^j39%Y;*-M+|H-qU|3~@;X}fz zC>xW!ds2H%@KT;SD3V7q!xZ6fvdpM++Ny*GNLCq)W-&W3dH2xW4_5N*eRSmx$c0@R z&#SbCNzfebpmFMJnnr1-O=Kt@U4GH-AyL5M>#%kf%i5(ujWgC4mJvh){$9~K}z4!!zc2jE| z-Nwi3v#ksb@x`q5MAJ18r6Ik9SnnH@UIC#^3OJm`6{{K^*t@*9{b}R@Wg*lT zN<&4Lm~H2-p&{ss2W2fK^u2{%R83(;jVpo*`2##AhRTz>CV0>YUnp|@1e;;j$9p~$ zKdfh4MM9d{^^sm2!oq3BS_=S~YS!CLdt>Zc2xeYDgjNA2%8&}z8ox)fb>Wz6*$EQX z0WMt%t!hUp>Z>R=B-FstYuS@*>un^c2}gritQm@m8lP*K`YgDlqB6-s+e~!dcD>3b ziXY#*k)ibQ_LX}2pCMj-IC^<=HBD9pa@Wcj=E!vLVqrpMj=% z12`G>%L4n6q~ua^O5+zA^MvMN3-DE4X+u1E>zWcA81?l8OiHptAP5vtm)v!CNzWNg zeq+I)x+a@W>H>WzV0Zel=jb;gpY#e_Uyr7OGth#Z=CNuD zs;J7UbK&5dB_$r83tGaO(ydtxjH32{nsJ;>S!66EB7Rsc`bSf7RSWgDJS=H{^-2O1 z!smfzbG6`UMGWgSd1M}{$#oq~Rj-S1#Lv`fnns6_B__oJAdT>edrR8a>RuPtiqF81 z>;s7aBs`IQFw>KbWbx((OpCztnqyp$QU&sA?|;8at3}a$N*M?krkKaSc@H~|SLX51 zB^JYu&XsxG_c`;h)q*mQ?|2Ve8z}R*&*x!hqs+sX=^;5YR@-LT7%lPwu5GD}?35Y7 zk?vO8t*oO&Mf|6MPcF;we^qJzF%+UGm%S&tP@*GFW zED?(r`?d%f_^?-yYQ!nH`tSppfeSLTgzk39+qRWGe z`zj!?v2HDrLOIo|!A&DAYRr+e$z(A2+vS=i?Wtz8h`g>0#n9q2N6W+Vc_>=C1cz@asUvQa{gz}6FfgRPASz*f|TLfe&H z2IAr6XT?JWH6H+|nCLQGEQrDQSV^Cs*fJg93aI$N~-YDE9foF8N zi9J8K%kzmw(Nj@TY?Je1n?Ks*Abxi^NA+CDhRh2r4)1G<{&V{hxA#J z!$DFS*h)JWDIu|T5K2hQP{+1&!q`j&(iXoe&0N3XZX`y?fNCY@L) zg!olLA|heP8eQTBy`rbbN| zNM)RY4_Ooj1tL?_PXdI{q&Zs1jDp;!T-nqwJ=NrE6ql(nv-dD12=Ui&kE%^%6RufH zK+4sogyGRX)>-)A=C)L1B0qgK+^aaG#p54{BN#FD#T5~3kQ+`gC@-ZAASJD0+X~Up7BbO;P^KxNyzyZDkZ^j-WM9ATB=EW zeMz4+>?*Bc0|$iEMNn{4e0uog&aeQ~7bMZu0=DTL6A7dec8vUX>VULCLVtlGB$`vw z%$}_l89z6s)c!8pA!{L!5Dupql;(@|5m8`!-dj`d^p>b`8;=-d^<1eu`OHGU` zd0-EzH8wq929_~%I-!TyM+zW{a5WCQrXz}nret2C_I3h*i^!3 zO7yIiGIQM@g;?HR_nSc>$v;qu!zUv4l6;Kq-Db$)xf?kRti97A1ZWQ+nk#GNYYjQx!x)p-v(D`FUfuN+7Aj`sRy6n#}g2b(G zriqnRnS8U8!A2!<_IyDmq)|)}WCOU8N-_I3>?aoyBwS!ud{;Edsma_#u!yQh#nm4( zzC3CV+W?Zw-R<_ZKA;Mw7OTE=plG^5!M}{`Mllc$1%?RZ>Zw_@Bh~P*Cy~U0eXeaz zM>}i)LB>WRVQDm>r~n9&kl>Qw`DQ)5h(JLPtK>)RD&_!jv!I~A6{93`e`q0TtT3H> zDdVb=^&RL-#^Z{oEMS{IB_U#xC4as2;fa*I235h*e0|TBW0Br~V)5PoagvD`+CPO< z9qB4mmc$qfwFd?O3!PvJ)RQtxuA)vAT1C?90ON5>cuLo2tomT&1pc})Dsx}7a)M^-+Y z7jVo!Cc$LE5I7@n%>99wXUMr++!Hbu7X50ty{E_0Db&{ZQBZ(K3$;d<@-I@QM0!a{ z0d(Jo1*lSheWYL_iEPwhWk`oyz^ccKfmoZ%cy>1%g|R2Ep_F*S`LPgsq^rZo9qIBI zHR^U{O+ntT)&fE~dl%6|2oB0)O&}mJtLE*c4KX3g=I*I<^(9!B_8z1_=QPyk*_>v5 zaMizhb!ACo$095d6jonQdcRA6h8K3s*!g7qYs!bnJWTRZ%bl}LaLVy2k+x|xo4BRQ zrk`L+bTz%sF@iM<8LQaUBQpOVD_|+Z#hXh z;ltqJt4si9b7;F}xT-`B*3joeH)tvuN9iL@J@^<0wn=0|EC;h^%E|)cF+7Q^A|j(G z`(AjNjXn99gtq8Kms*6V82M(p_$c)almEqCl9`V{l816qr)sE&jL--X1{;1neH?zr z)A(l&(J3Gc1fsBco+RNT!+oD>=SoNzHlTkvW(oiLFFH9JJ&h^hl`F z?}s5@jZN?a*-Q>-d8a)318-t8n5hjgbgy)km7FL^8)yXpM`JfhHx*s{XbFI=b#&4w ztlDTzQ%*D05li8%SfoT;a27M z0mg2l%`M(97`u%!w|Ku`?51ZAIsU`A?Dq?%_PJls!ld+GIi>Ua1+A=X9f8K$e!)0B zu_Ni3F0UPeY1<*lMXoS_$qqr=6|8d_@DI`BcCbpPT=Wi3B7 zPcpZ1*z0{sZI3903wvvDD~J6-Kxq7HfIVSG!k)737xv?aVL!PH_PMC0$)7?Mi-O?N zF*h7?en=Pwqz)A^;-&CpyaB|TbJwabYD-?aC^gBAQDZwg9ZXNd1-oDlgWMgdGQK_K zM@8R<#B0IRYL2B%JtV~!TY8+(k3VwI-c>kX{}t6V{8YRpJYrM>kJ#`d*^or4Py}ic zU(`)FxicqDQu(3J&C2k@6HnJ^nea5mTk?%cu>Zuwd4YU zSqPFgX{UvzBgsQNi|y4(M|q^pi3_bG45?vko*mdy?gmE35H8)0-89VEP zDm==-Vt|=6NFD1kslc&j64rVNZY}2&VM!5CP@GCwP#zxjz|SCiHB`~<_E2_$g`R|jZoQZ4v+^)vRHRW;XLs}_4@`b;7M$1_HIFm9U`zp^qYTsA(8CzI@|6yn?O<8+}CB&e_WlTxRa;#-( z)#n%#bJVva0Pn69+`3 zG|ejUDd{3vYF()=Qt7*hHhNl8O;wg^F+l`qN)i&l+j8t82jysUg2LKK0tMTJU&cQ- zs7YtC0Fou+WO`tABadc%JveNLSDX{qylWBEH zm-BhR5T%w2mXbSbXXAeJ z@JskYYRf`sU7m}TngC};@+VB?~XitS~Ez;>3x=)34jbIHWm{rS|sq&h`Vkd$j- zaBtN3DV9{M2BkYieW63fW9<~x5Kk5CXuAPRE@k8F9T`36sy&B(p(Kc&jjxE| z?Gqs%!-ihPH3X^zcQ5;1P=I~G;6WAU${Guv3JS4!MAttdGCK!m&3XnH50$Exe3CZ2 zTCF5bArqZT2u;=fSMQrQ|eNNAMS{$=nFe&er?!n1{|LC7DoUAc zMr?Ct3%yOjxIn>32nC4Vf5Gk{dLkFq_+>}Wk)_m;0om)y#fog(MlXIIG~6pIQ2>g- zwh5?#11u@c`T|duC`T$6?Y4-s@Hu8fbc0!*m!2$6L?STt(N*RaKY>Hw-NA;ba6(kf z<&Ga->KmS?Yigy7BjEFGNh0E+1kJ%%j?)SR3@IyxI{PqoL&GB%SU-Cqo7SzZwkE6f zHnL7d+u1U$Xq*VqtTw$NI%kW-aiRrGGXw#Hg5&rTy|B4rH{3OgIofZQ-pGJYy_pI@ z_8EK?h=+hPT2hfnBIt{Tc3iOQOg&_#6%n%1DI0CjUZUC!g-uoVQ9Of^wfy{B*6IJb`l+4?@hM3X+#Y5g?X-i2v6|=~?EPKR zNJA$*teEWrCpeGNiVA?7Kce9k$oAF8V4#3m-%3vcSlX=EWZ-oi%O|Zde>hDxxy3da=N@xut>UFhK;XS5f$ zumm;Q~fs~%9rlf^r z$zR+8L`^)Fs06gP!dE2W7T8Z;#`h%LLdEs4df+Ni_F@@Ga73vzUY0QtM)m^Tr<`6A zyxB7is*egIg0^=OR=o7JBe;edf4TV-`p*!9Wsy6G(h^i@T(M~nU4!T2p$xcc`WgrZ z^igXwTm2Yl^YuJ3&;tkbS>tEm*Nk_sg^yC{;P$|WGQLF}joB;}RG> zmCkhzi^Ycv3Jf)AVWdC5KbkxkCndohg`B*#Io(fsq{G?w{Rr?%GYN9fAv)g|Ve)n2 zU^zMc(l+^Kfd#f2Z|AdbrtiN%Gn4Nq&LA&KhP z5{W9F#l3Jr?h>~z8Lr`GQyNMa7=f=N-3_8N>^0dEnTR}|o6s;wT}4L0Q&1KX53{3P z>w8gopNN(`%1cv1-v5@;m|XUMq%@cA2R5K-32X#&`qf{C4b#WJ3>=>dqu~C5^S=EQ zhyGb29cL^BDfb?Wh0f|buO=P$@kdC<%dzkxXBX$~@E0 z1Vu$p>|=*gfS2DOEdEzV#3jQjZYR^eO5~+}ld;XjLvSeGWUM{P(dRTlEO8u%qlSY4 zmUxG^-QAyYdG@%)1b~UVhE-9Gtc)tZ`3YK&^xcH9%=^+T@oO`cGX!R-9jN2xxB$S5 zz~r9z@#3%@%z<~Q(m@w(2twdn4N+uP-OOG9ZBN12Xmm;|I&7N|$&PARCdWrhI?Z|J zH<~TwqBA)I!BkIjguoVnN`kZNr!=_oVrr*2wz8w=jiaFpc4Lu=WTzV>9wl}jxqV>>pbEYoCzi}J8)Q1pLMs<2i!+2#-Do?B@|G(pF;FK(UZxcjv8_!KisvP-%)!{J zIoMlOo|yPdFrFb@Y8lJ&F>U+n^isfCiu$V#5-F~6#KgRw$|RWwTUNt12t zHUzE&)tW0OL`cKJ$_erxkw1;0NE8>q?9Vs)mL<8L5bQGYwYosOIBJM#oM$6dZWV!Y zThYF4=@4NbISC3ne$Fy^J%)8a37lASnVGWJlI^QXmz`z_P_K>a`jXH}OFyZG--|1= zri%-lxKIqJJx`hE}$caIllGA-X!QD3-D14Q2PmsBqbkR@KG+*{Fouvx^Pl z*~qX=jkGgrs*G;>=u7y zBm3}BM*}X`mnf)7^TLW4xa^BYf+65avgU!ym%mEZPvr%WVbD=hwqOl|49iH6`3>mw z0c4t`QSH1$0!!T)M~)@1TmpAb{agv;L~JCWjqZyp>6cr|mMN`UQb9%owl8uGibc40 z{}D9zVHH3Ps%IZCBtXh756C!$Wadt`8U^xHN~J41hBbO7#7Ux@CI_S^U z0qL(mV^FNn%{SH2i!94H1A~bsHTv7|2fNqg1Qf=4Aq@L5`=iQ1YEUuQ7cm4?A&6$= z*}tF3nfD!Uf00rf_-W-A>e5UzGvt^ck^K-t3A;HcrEO)e?0C7-Zr_!;A!1)<4?CFz z`pQu7)H-fO^su-?q6)?LNl;*tkpYLL`T@C zA_fw?1UI!zkOTu9lAnN`w%?Y4Tcfd(vc_erUr9(MNnHU#f}@qW9VL$w?>&kNuB4$3 z25D_(B+55emu+;k`a!aXX|q=IJ`>ZCvaniOqVtwhH&396a*ajdG+k-(l-PF?8|Lh8 zD@HHgm=MQNq|4Bxm>Taj)a`p9gBrie0OntiouGw%%#K5~sl_5z^gOUi;~{p9h|R+lwXCD>#tpJ}CH-2xLE`THSCc-&T|Y=$hy59txs)EMCU5>1!%WpWS+pVK5s*3qhZjSvX=rH&FMpF31}K z5(!8DO@~Q)Cx#UUtzlw8i&qxu$VuXzu))qTZ(-QbSA0+-CK(JZmB}aL4(kRCFXxJ6 zL$~a38;qo8==49@1V^}6@8QqO*(Mv$cXEs@AF-&Z;cIy_SKC!a_dl*GQwqCX(0aA_ zS3+{HvXC@%4BwzLvI)jFZ40$JhxDH7cYxcrcC9Tn4JM;lgtu&7M$yjKFT`Ejl|<@q zl4Hz>9va#`B;qjx?lysS0z}JHmx<{^bd(}!hNMkJsw7l3hQ|b2Q)hy#EyM&8E+qhH zM0RNvpOVL+bqfjW6q-*-`ek=yr5N`1U5&+M)u2+t)o51rGFB0)r^@;|eRdypqC05V zi?PG_9y?ghUaS?Aht6)7ji_BYMlM|75V?aSaH=BRmo#sM#HrjoVC`!Ao$w~^@LiM@ z$z7E25gggK$w69)5Xv7U=QOR(J-!%h!CN4sg(s4q4i^}G*K9SDJP-rq@Y3#{C}pWd z=}1F8%Y^ zf^;0v5aZ0q&r1Pue~)+giHWdHurB5=ZI_?9rip2I*sLvep5=ubN|0EKQbmlGB|%u2 zQ0;95@P4(|qz=|0gw*y%0Rw1~Bw>{(QXGGCtg&=1w=18nw)J1iZ&PuJ&yG@Y7N!EH8oFSgKJqcxZ}efzY_SE8aVoDLS>gV z=O+-L8pr$u2`uam0|pu`Nw6db6KhF`zmxp)2O@CAT~-Q5I-0aC>`_T5>e-~Z;6OpE5zZ-oV zw_Rm!q7F}$5il*{y!0wAd&PPz#oH!;oOR)d!bI0y$uJIAo{Fma%t#H)NZn^cXbzqs z%5fzImtaV$mgzI2EI{Iy`U`&a0Y%?uy#4x>1=sG4Wgt~*IdcqBlOQD(St)IpOG-R$ zu!-xuvwp`oSxy#`>x?x=Uc7Jyq|!!LxJ7oL>g0w=2(-@w8X&|7wAqgtn-)d1J3mM- z#w`{7;rld@#wV_}~$&0Qpe##Vd2iV|`NA=*jzQj)&Ly9^(6x1NMExkNP)6XM5*AAHN~`uV+@8<;9(gmQ!mRN}08?lz{+6&mO6))K!8F zKm|wC68!OL>fEfOLev2JAIuKPXLt@VwCk>dM37qnu=@}mDP2{K@U|V1=|%JjTjWGW zBqY1@8G+IBM~H{2hCnuG0Ik~m>%zv(0XLWeqhs~xME*;vbl)Gedwp$)*C={ZO z0c{03A-nUaG&>mjxmC(CSRo=?_CJcNB4r-X2-Y?S(5Sq!C_0Tk(s*4WON1hp&7l5r|XB96rM8cOzY%+ zdX(;YAPOQqfDpPtn8d-{XGIQu!&*7hwlWF+lSi1=;-NT5TM3XD2O&0WI6Nb}g9)!2 zlksK>ZGFg?VL+nIY(n8mU5l!@5@dq+Ce(^z;#tMaLO?Wo0L4wR+ano)UxK7NAtaD+ zqN2aKYGYUxFHK6~9lKU;3@i7sESX`ZP4RgBjhs}vkk>V+sg+H5QgejLl{|NKwUW)ZY8TBNoo(jPyq!--iQf1r!z`kSQZ#dL@^C~0Pr#mPYJ~+ zgCdLsDRJ3?prAQ44amM!E$ymNkd2>dK&^`kLjsi@`yM?5~j4o zIzr2$v?XN!Fx*6Ze{)(gxZoGoFuM_&RZaC(qCr$8j-#p^EXGJ~ z#eQg&L)?*ihDP}rsY_m(MZ1YCzP<_l$O!kb;)mOS+x=aK;WYnpgbl8HqHdMYMch&=}!4aOifKZ(TE^)_zHhg z!10(>@t2bLZ9k!Hn}3F&Sprr0;{;qKt2<7s>^OlocYp$wx;|W}Qg)mG3{tUd1BiW9 zdO_;&X#w^cD1^yOtD=(A0(LaB%1#SNx@HxF0~%2yNp5t618>;T{du!EDxg5<_nQu7 zfE<&E)|4|0#|$wrgwPwCbAS@+5ugF&D{JoRd!HXK zUH{E})s>Nkl<$&idmdqqLuM$e1sMUXo~7jmh0h`%!=S9!HQNI^|{kuL6efl!n# zu4|a)y-EP(*jc0jm&s^yMNA(blX4C|@aYjd$h^ zckk#Qt>cscg(3`B(69PWG3r>dBRtOE=c-L$UVho+p<4t_#BN;=4xXOSx+SB<6&cOpx1$!EoREMKO%Fdfl3-;C4vVXHpxoaQV?-_B{SLBvR8 zsOfolR;={P1%%mk4zjPxmDis`CvoS|f~oV)Ifub7_jVWGg^-RotRlWm`%qF0uj!4P zRlWWqsw>I&_05@!cd6K6=3;+$m6qypzqpB4W8+&ceC{cwoBPZQRW68zhIR+JkG@dV zyLv;{nt{!Iy_>uG2D-cY*L3vstnOH|an;top23cutzG?_dON%7R(GxIE`&!N9d;Eu z!x_6X`rO?xGu*bms}T10bg$_ytP9WB?am<8)!PTU!tO$6*O}q&@nLs=*t>Ng>|GnK z?%i7G#I|-{0S{5YLhnG>5w2O^(dPqq^ld*f>>iL;ccH(#vn$+P70yul{o(G4nZrr# zKH`YBw(ic&tzBn!Y}(w@)k=tgwvMd>>wEjU&+0m6O;2}MVPI8v=h2;An|crF>+0<8 zquNxm`{>=lZg+&V*7!d-sJlSbyF0_J18Z9rh5cPywssZPbSb;tqeGwl+P>aRNhO0r zy9;Z2l`j?daSR5hFmnN=23N62++|5bLc)zv1eb$s#QSw+f zu%3tw-c#)!x9Mw(*3sWDjOs0fXY_QfrG5v5eckKU51bLM?db05>Z}Q=$J&7-!rj$1 z-iObiVXtTn<IJJ8EI4d!8~wOtZtH^9MHYO0Z~s6s|G%MvYkK>-EFnYU zw*KC}fwq47YgcDm|0ZfLhc`U^0ku%umaW}uHoo5UhZ|_^rnsPgGh{mo+t>GQYnjtZ zleNz`s1y_Z+t8q%=E)#*b$9cOJ{1zFzs zdQu#w8^8}<=Z#5g??Ix_G1$EhTK;;{FZPDUNZJQ~v(NLf2-`sV&*KA!?Qe=je zxvlL+u$0Z_UUC6AU2SVN%XmFi7zTGqe(^Y3Ag|Y3kyzsAIv*zYzuN?tPzu+MQ7Kl4gI|ZKk_e_V`bLxT!d_!F?O9j;)r_Jz|zfao#TTgZa(PW zT!`+0ULi1yhiB{HKVN5x<-=kzJ@^=38Dog|&85EXyp40Db<`ynw9Ykn7EACq!-xs( z8u;xNrf$8RkZj1+yf-Xyk-|c69}Nte_LWe=*I(}La^kdgpnv|t5tz0A#u#i@(8FK* zjECXuh$A#VUB$$85i|RlAv62ta9c-z*mdS+rZ7yD271Hw9fMur9GjcZwrS_?QD*56 zw=tIr*J^Gbj60))`5i2f=IkZ_n4vl80W-t?^}SnrI>Xgn5*%p$Y94N8usavd=p`%7 z%);Grs0?S!36DC;EEP-TK4W*)Y@5+CHLID;R84bQz6rg$qjOdNn)O|qy4DX2Z0_$G z?CvR(0UR+$?jUz@%C8v^Twlkg{(;_&T?NZHnK6c)0Dt;G$ULiWJCXW=uR8C~uMB?asFN3cE_>bwKDlC2Z+**qS3mfP>cd*L+<5GHyB~by zgn6Hdn(LpNdcq|?S^m`0tFHLd=nt%4c0%;|=T*0)ezoy;Vpn#;3744gneA>b_Q|B^L+qHaT`&Jdmt)ADegh2Qu~D1?GwbE$oC}F!Klfo5Y>d5 zflA_*+LO4C#&MeoR~mN;_Nl08s6$ZGQA%qj>QK}y)NB+2#Gn<`hLT@9DuK%!zUQKZ z2d}+1CL)YeC2m4iGtl+rY(wxa(pZ2JOva}hf-^9$az5{2d5IURAl>L(*VVedOR|>m z=%bNd^{h>-E#aK^*7)>p0(FI_iTy>sPe$#X9uv}5dMrprYMqD=%#y@g3G!!4(s>Yo z3x0|J7(4~bz0=)SIgPiA%T8fS(n*u9%6nvWL$C_-9h6`2zQ491I2H2*`Ud|+dlcJm zs%Qw_g8SqO-;RlW+6eo3v3+EEMpm}<_pcq;93*l7NZ7ZNW@4Wbx5ZlITF4<~6#b@B zPT_76_D$HAmDo39KN5SA@2MloT}8d;mxTW+_Gau7LbnVB5Aqq1{pU*T4T+t8S@cix zpAxr6VsDD=)8=+1|4XOM#(anDlg?b zjZgiCWWTh;o}_miuonI#_H&3Y_`z3o^oW-8t++T_TGV|g3Hv0l<;LisjSiZ)U6~F7 z;g#al4h1h0{!G-@)xVOyyO3|iTZBq}gb5BuX{dlupYfKi{;fR&M;x)Wu&u9S^Ng8i zgh)Ce-)BIa&j?L&Ud(e2ZFUTClQMjp#E!$xl1}qG?Q5K%F%VM4?SE>CQ*oCu6nRa6@ZPRbXWla2KSNzlZde<>f zZ_?U=jnl26$!k_2kMG;Qs)Hh~?dw>laTMwlw~R_CnfWmx48@dUoy`tad4Gyd&@~-(rO*y z>JIR6q*utQP)C8~p_bKMEgdZiV*&T!tkgPtyZQ@1AG%_|v#lLgM{%+JT?6>{hno;% zvv5LxogW^*Bt(8~Yft@KH>-kOok4Izr5{(SV;Z8q`dP+a5t(OD;vqVSRA})~>_H^2 z1tJfd{$kko(AS0gN!+hsuP(9w2X^)0#Q#Uw#RDbw@z}-NB=&jO#U~~9_SilWZxjC^ z!qQcX;X7jcyWX>R9sS}Em5A4hiaa}DPFk(FFsB8f*BJD*qOj%vnh}@+P%(|23Ee)5 zZ_&(zZqwzA|9cZ=(w_GNFVXhB%OnZVDidObM*HY4{Semu&ZxvPwzIItZXWy#)e3e0*C|B zW2l#W{aix+i&1~gxU;;jA$S{cPA6~ir)%Q8!`|V{lQ2sOqwy}WKSPG%>Gy_@8r)Cb zlCaYVD_q_?te^ZHrI}ty+TsNSi@n2(cu~smO*T19(!Y`P75-o0Syi78QC5{Lv40)= zH0(^3v;sZA=zzp?*#CPY!Z#6C@m~f$e~0;O+Vdu-#qz%*eEO65surYIcZHjKA#GYE z?A!|3=nYSM+bQon_SEB+u3CDE87D{YShf6=)84jn)k#ZFIez5{q@9`E5WE@l?e(7j zyp^&DR*C(i)J=Gp*q_JVg8fXCV5V=W#Q()O{A-;{B=Ks1xnPvoL+t7^iTz0IYX8K3 z0Cvq=68kdj;!6_yLzF}OMq)n!`lxXXM$(r?);{r&Hfe#t4)t4*K1Ze06U_&dA(#P;JDn@?sQ za58zF92^Vxek_#ywBYpMt@bC^(}NRGZwE7uB^0R#BpPC%=D$EA7}cprY|u)LO&ch zFG3xLT8LVJnva@?nv0r)YB$vy99J>DLh5a%uQdIw6|GPQ|2L=NJ_|7$pD>WF@W)py zKwn;QLd87Hr4@&xufTN<_7nJ)@8P&Ft!P2D5$D*78Tcx$VlP2|$U;3-v8-Y?apzUE zqA##GhvTx87>YlG6joG3e62t!WQ*yu&0TR@NK4_DS_+D_!t!2$yK-NGY9SY;qi>}n zU8Z!UsZ06~e9_4k_X*TgxFJC+FqEITWbhU@f<1CCyE*F(ey07n2pr;g$KrJ_Y zh3RTFxg3VR!t|x4w@`P57X+49%;#&V>8bjho3MW)o)pU8q;)>HM-i&^#>8GGCBlTl}+APdjR*K_kH7P-7C129@-OBfD z!nYD$ss%M0%xVG7QnSf%Hn|9ng5qpS)=D~p-AcZst8G@A%W}TvqUMmIutIpCzqzP6 z#8=N*fm)85PA+QG<>aD1v7C~wK*?N&nv0qP|!eywrs5#^=99)iChEjejQPP$F zN?fIT_c`?QH&<*%7mRq)#B{+Z3{E!vB=qBn*-AZT#r1m7Qn=gnyG;L%=_i=}u<6q) zzEd$!@nH7x>_B!?_Pp#M=0;Q(YB1X$3oL7MSQm8gJr8R2bk{uS)ze*bp={C@ znSPk*3r$~O`h3&pnLgKa#hq($=UCi17IzMm@#(HP&_?NsI|uqGU2*3?Bc&_u9E%$T z$tUpHFW@olIFa zqHasSopjz#8Du{l`{~$E$9_7c=*QlVy&t>E+(C-tn)|8a@#Ne=Nu+NyeN*hSk$CgS zRr)5=GD0+tO0Yh`Wpu zEhFw4^6w$^62dM4Dob#SNGl>-M7W4>QE=$``c(&=`lju5Yj5qD{?0WUTh?B(vj4pe ztCz$7brO1>>2nFY1_+)`D|JxY$b371uxdG%R=#EPT*6A9Yx*41+tGy~(ifS2nCS~m zUtqf83PYqTt}sNp;{K-nTkUtZKi~d4^c&l+z_$@b^kUv7UG+jIOq)c!GSziQuu zQrPF(pK1Rz;hw_vIn(bZL;o1xO8+r)Q;F_2*K5rEQA_{!_D8LZH)GS+@7iy{e6IaY zv)xM#Bi4V)~b2+owri zb@jD--o6B*J8_YDtCj5m>T_rN_bjb@FqPtS?RTNyOeqAHTP)-q*ndTgFX8qz%scRT zk}pByIdXp4>iZSG@1cbhOHg{b{eJVml~~tcSL{8+6XdVQ?%P#gNz3Rv33WZ8pNmUz zef!ho|1z(CYMx)?s!k?*hC{{@#P(FOC*neWS(Dx>h>iuTX8 ze;z-z)*a~gVyX8KOD6Pbr@K-+kuTiK5R6Qz>%Au-Jm8c3-7L`V&PyuQlInJ}c zx%B3h!BV*I-|Y|&{wvc*ovQCm+oi93tMu<&BHfw$F9!9!_FdA?Sty~aOhH(tpe$36mMLh<6vSl;>M{j+nWIe~WqO0@^`_UEo;N*bdbR15rdODrH9c*5 z%JcyJD`57;?SJ2XG?JO4k$4}0KC9XP&0yqEuG>vN)AVho51PK!^a0cRP46>(i|Lz9 z?=`((`XG)K9 z8a{<2{>-4YS%2-pap62Tm2Uex6As6Jb@TNO=?de&#oZ}i>7qk24~Hv=$ZIA1fO`2!@LMM%QSS|Iz-J9IhOMT{ zek`MU8+?uQHE?6_HKG4&LUwixYrXng5b7@ss?shkJwP9uUShgj#+u6*a~We{#+XYL zE*-%)Q(vjyQa@DxWz(;#ySnbWx-07LulsV{$#rw0AofSQi#%3hG|1aprw^>^?t z8z{Hb3OJk%)I@3poXS~TZmUqz{|^1ORDlpT<9jo3-vE?X7{o+Xj|=CTKA(6S;B@*> z;&|2s+fbYN6E~_qxemhX4B~SZa*BTB6dlI34w}9VuBad1&7`4tn^5cFwl<<~W>gRd zx|%dS@3|VDb5rnLculEoaAVu3(KbSEBgP>1LF|LX+YJ1bmiS2N1EhBr?$S4zt{l!H z##&;mCCpkvu0|F+@_?Gl{X+7!A^Z@;4Q*yo8TyLh8Z%N;1O0GAW>y766-Lx=7@r4&M$HplM zL%dzF8y(x_Ic5f=Kd{qV?_r^G`?2041H+@{$6<7Y=_ZBeu zmh{0-xSA(i)f2Ab3737sWu9=y?r}@@xM+`CyvH50$GvHfJ9>{hYL7c|k2_+IJA98@ zw8tH`$1U9B7VL5J_qcg`+}u5G&K}pk$F=Qot$SR{9yfcBo3+Osy2s7j<7Vt})AzVT z_PA+#+|)g8${yFe$NhW3JydYtFSrK_?ty~)Ucud8aQ7A5y#;qq!F{*j?k>3R6x>|} z_w9nav*5l}aQ{|tcNE<2g1f!ozFBbJD7f1S?$&}kzu?X*xN{5coPs;M;La+z?FDyc z!EGzJ!Ghaba03O`UvPZ|x251V7hG?_6$)-s!Sxi}#)8{WaNPyBzTnms+}eWcD!9&q zTT^hW3$CN!&M3Gi8r@GD-A@|b2Mi(CGfX(LL1Y zzTfB`Y;+Gay6-i*`y1VTjqct?cTc1HZlk-q(S4`U-PP#6-RSOYbl+-p|JLa4Xmq<9 z-G#MoSFPJw>n^BuJ8IoftvkQgomcD5t##+ry0dHDS+#C^tvj>UZL4*IwQg&z8>n^t zwXUz0>k!@MTK6A$_uIUCG4FnpcfZcN7xM1;yn8P1ewBB7^6uHZ`(@relXp+&-BWq@ zi@f`J-u*1^p3J)^^6sa3_mjMPJntUMyC3J>kMi!(yn7_?ewcR;=iLwT?%(t7p}hNk z-aVLi59HnV^6vh;yD#tV&AWT@?z?$+ciw#`@9xUGZ|B{edH1cn`?tKiBky+S-ECQS zYu4S8bzjfAo3rkwtovHleKqU8l65y`-3?jyrL5zv(d)DB3t4wv*8NM?eLm|xmvz@> z-Dk7zpR?|otouyXU7dATW!pq@!AIrKcvhJf<_mQmoQ^x&Q#{Dtl z{*ZD1JL7(zasQcdFJ;{CGVVVz?zb8DV#fU@<9?lSFJ#>F8TVYq{VL=3WZbhE_sfiX zCgYyYxTiAi7a8~SjQd%}J(+P&WZX|P?k5@dc*Z@JaX-$uA7$L58Fy;Nosw}UXWU5{ zcVfn^$hhShcS6P;pK;4FZfV9HmvP5t+>#8psJg`&cTC2;DdUdLxT7-e$c#H8;||Zb zMHzQk#x2aa1sOL#D!(aqSt`mT|2a*OGCwGj3MK9hz}7Gj2u(v48NnupxLK z!vCA*H3WytAy^y?1-sd}aSRVB+~!_(N2X3p?MmI5%A}7=pOyaC^h@danSsn#GcRRY zkfB_QEF}mgR-96CX~oAY9;-O8@|~6Mue`nTmCB>4&aAq*>i1RitNW{OsD81!t!7ir z7iylbnVCBycUA7m+~oYK{HO9i%}=U*ckL%?AFn;I?wxfXsXHaNJ~x=VB=?2fy}74z znf!X}Q}a>2BY)2S$loVeplb+1M*hX{^k+cfhl0z3tDxh%*`V@d@FI|CaG`4l9;doh zt{15MgZq^G60o_?J?@?dLiyCh)a;bt^sdy#SU($>U7oro^;IDEgVa;0mw?~c^wjix zptv&KnH~U^f1mzX`d@(PJ?S5(e+67KnX#FvnfaOHGAlEkz<5{Y^2{|r`p(P`GEV{T zOm=K`YPKbNboS)zdw~79*-Num(gHVUzmt80c6dJfayDO))Mp}cv7;(ZqGjG&QK&eN z_PMg+`igJRO4n7~T5&IJ^<2fDDsq(vR?e(EymC41)?K-swtIi&6_uZ_yoEM=pz?{z z-_VlvRga?}?`eRI@zu|G8G=cE2(RBH6-(RZdMj-EaGsL>~le)s5}(dUf5 zWb`LSe_`}(qwgF2_~_?HzdSlWX5yIHV~!eg(wKLT=^1m*m`lcdV$2uD+&1REF^`XV ze$2~b@?$5Cojvxbu_ugu$Jljaw~XB}_8-SyJ@&@2cZ?;^;QM2r9Q)$fU|hqva9rEC zW5%62ZWVvM4dKdxdtbwKGC#%615de?G&FxUZC zxg&Vf_*2Hecl_G%gX1q6eYEzbRDqufL#YD+y_k$f=?dsMZUHNAFJ|zcS6CEbc7c>_zQw> z#-Zi&y#s!Fz^@PZ^8s~@2RF7hzNzt)#`iWB8qaI|VB;qnzu2ho-((xfosB|0Z*(}?9;M@b39Qc+4SM$C3z%v;`*c^A@Wd~k$;0*`vKJdWQcc!kjezHW{ zXoAzwlaei}I%AUdX>Ex^ZwK;+$2~_*n+dj zgP%M2PY35FPnbMw@{yBIocylI8z-MV`2&+bKKZ)Iw@$uy@?(>qoBXHAxo|?LoM(lL z!V|)GgzLjI!;8Yp!%v4dguBBB!yx!c_(J&KVQtf-rk19on@(zZ$ER_gaQKi2$Q^Pie? zQzlHAHRZ@DCr)|Slnqn1Pq}!?N2Yvs%GajcIpqgao|^K~l+4tzQ>RXyKlQk&4;-{| zYUk8}sXM2BXzFLCetGIiwC0;D|7~htv7e|FlHbr zT|e#2X%|mBz4F7;K8x>{r=3$8@h^O<*PO)+ct>!@wC_***|gtIOUmsIQM4TTTdZ8d z-@&Z+Wv?;r8>stP_=y^dj_7w8*p)-Eqy3O`solauwlTv`eo44xha|mqGie-++Ce%i z54k2zKMALJ+bs1RLFXX@hwM7!aw}C3JfTf4!8M1dFMjoqJ8|u(@nuW&q`y6M$a%rX zvP$Jiiz}GDc*rY&m z`%e7*??Zg$vl?YjC&VYGpBI<;+UYAQ6KZkO^ua*<)~S_uO@Da$)6@TB`hQKYnQ;(8 z(Wx`$%~&$ygc%78*>yf+^(3X?f9q##o8e)c)GaaP|GpU?nQ`rmgqF)CpFf+j_7~&d zNNn*DN^epA4szTcmoG^tF%>%qzE4Sp|9&>(x5O~_oSRuYlYN3ScT$%m#F5qmj?B-R znUwd)na2^z|9)F9*!iCzn8ey)IG5Pue;&T40zL3xrp5_RJ#*R9Du+vZrL}tJ%>J2! z!A|nn68s}y`VEw&ouoT#s{PnKMEV=^zpm5Ro&23|@!6Rc*D|)gCZn`IN$q{aEiuov zuVmB`f9+psItfITqagKqexCX$g<&ZFb!7{Jt7qOoE9@_pFP)&jCD177f6x*>`hwjt zraVZSpHg{x(1360LA?|Yyf=lC4{1|LX(mXqu zNx1#}M3L_x^p;|H#Vh}vTjrOf``XQqlK!jz&JDg(JL;-qQHx-$%0UzV%hg7X;&`2aK0q2@^``uTL-OC*`S+wD}%zlu@Az5B;Y@ zKXd2}?wkp7mdcE@w06 zp|g&dwPM!a&GJ5LXARCeHdsCDqFEoF)i7&m@baOW3nX!l4Sbw(pU=*^Y1Un{emLuy zS+~u)Z`R|pzC7#SW<50P=d->(>uu>rQQw^Py;;9!J4}9d)$F8ni8&B_I-4&UDThnC z?=Ru)#M%4ylk4o+^Jg!a{g&CQXZOv%fN+nu(AAY3#OF}gJT0x9&kCMt=^Ow1mMCC5WiTEEht-;tW*Z?Ewk~Tuy>)HtVCzM#o`WpU zD@k>K=BB(>efIwwq;%&OOQ?>hib$5)MHrBoHr$?W@^tYQ3xVhpo@F{=T)MZG77yZ429$ zwVmF!wr#NOqP7pWeYWkUw!7MX*!E1@@7pTc$G0ESzOa2+`|0g#+Xve(YX30%HEv>$ z%B$t^m;7IA-G{k>@)&=qzwSHV$C!WZ|8ED}#udRW!H9Pk){@wmtj&`%N+Z`pmxnCTEVx ztje69`Bdii%oCYcGLy5%WLIUkXRpZKlKoNkkJ$s+75dJKGb?U#AFcR$MUuw-*l%(# znC?seJHE$MCjKMMDY4H@?%2wASDslpvc@0XZ_Fg6uU9^btT(aMR~5ggS6kI_Rh?D4 zs;;TJv+5@I6n-yNjjf(l{if>oR-ad0pZQ4j&DD=o|L^LXTusfPHA`z0cVr3nZSSfX z>Aq`(%h@CBrQt8a@1HF<<@oKIr}vX{Hdh*JU*8AkO6&W#e7EL){Yp9h|Nqn0!2;51 z)EZPLstdIiwGOo&)s5PK+KB2wZ9)}Ly{OG7M&ZR!-o=b6iy7G#LtPd#YAlA{FJ|0W z47ary?rrh^jnMt?SAxa=Z>sQB;1UMABEd~uNU`T+=(HK z>?k*Y9p%==>1`bXUxw6X7l1bxVD7}+iFqOBg|sOq`ELalD94O0LOCQH5ejoxbQXD| z+*w0AF?YJN$OA>*zTMuM3Ks8-^8@P^3tv{^c5(cDI^SMTdeKq#GDK`HXZnwnWimM-IM;F^iiROYPXn|LpLI#HtHI8quI)E7poVy5| zyNLF`i1xn-xL*X^=>x$lOHip%!5=aIi1|Cr-(kLh`9ezBjS7Cr_b+k#8RpMY&(NkQ z_jFvQb)1Je-$y@vALhlF7t^2KM}K=C=Eay7yI$-l%!@HE z2Dje_Zod!nV$6%(R_rLui!m<-ufXjLOl7{K|H|&qnP)iB}Py@;_at zFxVM|v}G7vh`OBb9jFVVBwx{x9?Bv5B;M(tuqS^1Pngd<8VtSB>RHl!e1wwQqa)ms zF!$gtmDm$=f9{F@zD!3tBh|=oW%d10u=^P);+e_!E$Lvf?4|Ka<0S6l%l74-_{*;p z7P9X_{VG-?zf044t>0THtMH^hHgAdXXIpGfcwJUgPcFqL&TudO9bppx#1ua{DtP?F z8+Q&xHxuS-GEujn6z2BW{5IxyQKPu|ioLLlzl{0{>Z_=)p>9I4b9V99QG(~cqHaZr zSN#U+o2c7SyHVU-W99f3=AEc-qwYd|2X!~}$#HQdSxCkc7Ph}BY_5Fv~l;5A?Z@Hh%_c^F@Q42Cd(Sppb=q(xM3A=`( zm6&fsoo;GZ^bX8-qW%u`F4Pf(Swx&;GdrVI*xONaP;*i9Q1ej>PzzCqp%$6i86AOn zB(QIuUge>SWX@s8dm@&#V0wW#Hsq z=MOls8Pm@lfKyp|r^oK4SpX+?aSpN7)92k0mvv2ScE#qp*zAtYjj_2YHhW`pOKkSX z=GMgA1)s6&eQtYfKRY(hjm`68b4P5djQgS)N!dRThr2X3{~ABoK? zV)Ns%d1Y*VDmJf<&CkTAckIgT|=8duWmDv1RY~CE3x5VbHvH6YI zygfD_z+4E{Z z|3XX@+jw_HC^ijy|3#S#=sy>L8@n)fvG;dZgkrO?_uqpZ#RlSC5eoddfWCGC<}OU^ zyDp4SY$x6oq1evstXe z?%6Z{ak0rg9%er5^CPb51IL|EoX$Hsn;V zzu%3*IhElbug4)LElyv#poGbl9%;IjHBzf70?&)cg1R;T&^e;Azb2z1JOo{=UbpzT5UM^x+X( z8TNhlxAnOC$Lqe)ZPe)!)@c&=Z}gk4L(gjomk!vM+Vj7wdcX8O`|q!Wb)3KLpU3X! zU#@+B|GRtizbjqu)2-_Nu7&@3O?r$2sUGhe)uQ`Gd~f9UpQQP>P0I3*3l#SE^AGDh zdLJbHpZ-Z1|1|Hd}qeX zw(aS+VoRS$89Gl+wxsL2MD5oHID6s^pgei# zvq14TiwpD1=ikTn`~|{14GTgcxD84|JXC@zVE6sMuw|~@tX_SLYCr$5j{mpxW^K~x zx*a-fuV1Ix#yG{Eu5Io2_wY7DsV#;cYweellKK znV(XZy?p<$f0}Na-A~8aeg93{URR)Ub$YeD#T#k6j?I=lx{WgK+DDhA)3o}>>#&Tu z_R(e7!~S+b+E55?11Uq&D_QOG_tjxt*MFkAtQ)DleDSk~bF%&Y{KGlr`zP@?E8CuK zx9xRFykI-l?a8L?pVuC?kGDAM^8ShXSFFqMkH1-1=L@7a$}9f%bpNnDP20MToWeT( z-_o14$)0Yv?e*(6+r~V_o~~`}=L>H({t9p6{JzmVzw5X_YL%0MUUyCq-;&{;M)4rF z<#(Ov@~(^g4+6TpoKzQ`Q+p)co>%+p_`kv%=$pX4x@@gC^3!$wRc_!i?ddnNb$MFx zO|kpycv1g&rPi}g>tOA5V=jL7e0H0pzm2&){YJLE{(s93RNp@-TgtKL^AFq8{tf%z zC|~cp?RdYgY4$oAM^p;2hsDu;j@M<`wIi?jJ#x5a&=qNa9oCvtyiWI5?H@?}eRC6* za_oNoVSC!YVc$ymIr&Pw@b~dt!!c_9rC4enpFVpvX{*e0III2`4v_epjV&%Ig09o9 z#9#Z9_-g^n(7yihIfebp(D`$#IhA{(_&{~u$p8P9G#NL+YLC|IniqKAo@d;jg{*7u z&m@z7A759H-)n_mTEL(#FJR(-Au{m3{yP=veetj3-zok2QgT~|_S0>~6Tb!^mgnhR(T2%*|N*FMV{ArB1Q6>Uga=#q0EcLv>z{-^P6+V<*_o z^JbA!eol4hc+n~3wPSCO_Or+3WQ(uQ-$5JyY@z*h{Qs5ecH~yW^ZDjTZvFM={kir3 zH~gedDMzc^*DT?{e@hzp$NR>~KX&oo{FGW4AZ~tuxF-U{`Iqk>&b%&<|NKa0pVDN% zxF0&h(pU0rZ4#q?Yq8GMpIb<}9bJRYr@wpA$A%7Tzuf8E>)I)K0{7pXzxgROD?r@L z0C6({#NDiQ)2_=CxJ|w?^gIbv;*IhJ^4D?xq343{T@o@ zk)BI_E;kKcHwJgALOY=$+Tw4zvwjX|qE(__)GYDphbK7Mrija28+YCrC7luxHgKCyLv9iLm(`LxO$ls_H5ncCZXGhgXz-L9MY z+S7e4x_)a<&&ihiVkJ%2Y1f?c*yH8iT4}SK)9iW0w)^?pI*&bG=htz!(r-GCU8Rm2 zq3e^j2lA8nKxkh{lltuWwZHbUtKH8Ye=}S1>9*$N8>nn~woB*JYVV)xZJtCl{~oq< zs---oX#VlU;@CAGcU%}V#4SWk%fi<5KzlR^-NB9YThF?Ip`FHF;;7_n03$l;$tfh>l zKiV%)yw2zUoTd&-y8Tbeu!qH#^6hcDK8d^5ZqA3=w)^JBPUhK1Srelli2ndo)=81I z0B*KUiulX=8>M&_Q`Sq7XOHzdDe~O0l--uJc3|A@t3#LHjkq4r6Qmv~OV(kLHCSX_ z1}Rt8D=0|5LLlYqvTrBMk!!BSQU=@pOs0OGQr32n_UJWRw2wS%sC}f)8~xqOJik%8 ze_DImBx}udLS_9B*|$MxA2b;Tf~=n)>nPj@5773d=m?mG?L|2&C5{GJPl2;l;&|Zf zl{gvFfOi;)tLPtj#$DD(xQ%?WPJ%q|E^E;|j%I?alOWGh>iHsbOWT3eo-XJ1_dw$I z-Xve3vg~p4T(DdR2vXiDkn~Tju-FoxoHCDf0_HjT@eDbjnP?`u2wlYUuY*}1V6ZWO zbZ`tH9neMSBJ>IL33LIv0RLqClZ_bF1MOi9Bpw_C)uJRIy%X`Bj6swKtXq=CIt^)# zA%p?VL^B;jR0iuCWDtIWu&iUUknlp*j9A2)5mHVY%4uT^<9T9m3?m<)i_k@m;e-Ld zX81KTh7%9OFF+SKMvxECbTl2!L^D~hB%Sq2($P#*!i&&F=mJ#s&rNx5BSzhe-iy{o zYok@ss>W!_1;=R00oHX$XI%%;OjO!a3BO9lSjq*~E=gzYl5{i^mGm;Cm!Uit>Oza4 zMU3(I0c)FNqM7I-bP>7$UBKEI^H@7$9=Zr! zF-fH)fnyTw2ErMHGYF>>PDeA*Ovhxx;FwH0u;xuV>+Z<bU?%2Cr;H$~Rp`LTuUt3GzmbIgdHOg?ZVr?5^?N(SxCLp$@(UI*$xe@z17zem4E zze2x4KSe)9FQOOGv*=m$1bPBJj2?CjqHE-~=uUJCx&_^cZba9i z>sYsgHHyeLlyZP|J2F`}Lo@@G_O8ZnHTnYj0=f)chAu&up!3oB=v;IzIuo6VrlYd{ z##D4FItiWRm_Ye}W}s637~;pE!_Z;qKy)D52knD)Ls+jz!{nOI5=`$T`osUw^2dt@b22R2;I0X9@=}Dwh#|Md|!$~*>hhRU?@6U9s zH}iLLVBI!w_lfN@`fVR-eeAZulHjsaVng3Z8qn?4MVG%qDk1NuXNGH#; ziKN5Run3-n#}hY_f1{bdo+C!pg*s3ZszDW=;h&jAdQyz42X&zi)P!nKC20%!x0v~h zI$~5Ih=Z7G?I%5nbbO0KA&7&RYwh1j{+(ui!@-(0iq9|MXZQiGn%4@XoBpJm*9)Xu zdNpwm`S+Olhu}W|`a)0W3LO>cNu*QfkVMjMpDhjbEBpvoSxe}~>1&f_9zPSO4&wEu+J{yk>< zkF{j{nd7Xbfy#Os;v;Ktlu*7oS%NZhi~aKvU9aLVx`glCBDVdnM9Si8=X-m+-PYxZ zO1wSnZwNO{fL6p$^;yl^}mS>tdiRLZ4UxWF1Ax zFKhJaHT-TRztovbUU@Gf?*(MdAbHOqYaI=P;h@)Rl=lXDjYi#8QG0&`whu8^bN8tz?Zj>>2|kZXOX%eWQ2$5xNqW;e3zrnWf$a_?2X{Y$=qkAI$4_})XldN2Tcpe^rM z>~Xg6MC?hnuzy)oNPEDRo`#)n3s1+MVGH}`&ncWmo)xeXR>6ZX6*6HSMN zu>J;q_WYpxH@H(uASDMRfZ9mZGA$<SA2&tBDx~0=Bonh%rOJ`X+$I`i$W?K4$rSmOaXz5~0msq;g zQm>`UEM0EtbC$kf=}JpiS-RTNHI}Zmbe*N^E!|-0MoTwYy4lh#mTt3jhow6$-EHX} zOZQp&uB8VpJ#6VwOOIQ6!qWFGJ#Fb(OV3;Sp`{lswU3p*{ki4yrKS4%^r{sObl&kV z!yXoY3Fnkg$J_G+vbA3zO};bdIs*Co$KA?zx{}wweeVvtKF;g?>%EVET{(sMdHclj zPz&lqH<$*q;Td=X>bK*44Ge=cSOBZw1Na_PdzH8Zeue@axW5MKKoazXp^yfz!8>pf zPITs;n=X78)|KyCx^Yhr6o7It4%+r$4KVl#^7Z7N9H;{$VGNw^#rJFQ4g3LPd-Ht) zJPhB#9}wP$`+%S=B*Qq^0ipfaUuXeU26A478L$wZg^ln5T!ya);Xjx@f)ez1Ke!*( z!9F+!ry<{P?l*!OFl@9+tT>iBpf1#hCeRIfz)O%Zj_*X^%y_=@fWi~#4=4`_Fbn3v z#EG0Y;bqtYU&850e9s71;qLqS-UQx)@l$9M{0!qBr0<~C!<@t6QP>QQ6z-dX{7@Yp zhM90NmG36uD*Of!Y1|_PE*J@8VKTIu&i+B-BfMTfe|Tsn?S&(t9_6)ZHm@ zVIO<}SHZD}HM`+7oQDOAS^pbef)!8kJuaMwGD{eHs0Q~ycewmC-(7j=6L=0b!47yA z4#UszJ4AVzS5N}#LUU*jbKq%s268&y{Nqa~-!aTj?)>fe_CMYOh2{Gx`L0U7x03r$ zKClSnD{=X)>rgp18$u%p#!cX zQ2JOXk# zZ(H^*OW(1jNxJ>-AZZ7!emH`C#IlcLAGhq2*e5OfH1=uBwwLof@jCn!_Sf(Yd~1uB z^xw_!v(3%<7Q^uiFUF{?2Ko}Z8l2cc5DeLb*TO5X4qk=zP?WR~$OEBJ62c%aghK>G zLKNhKXo!LQkWBu5&>sfCKo|srVF(O``(PLhhY`SUZf_k0qhSn;g>f(*CV+e%8AsU# zU^l!2`{4i_g%j{T+y-aiJlqb&p#;Q3DJTaOpc32xcS2>T0@a`vBtjEt3E#lC&;`0e zH^_i#@CanWJa`g3unev{)_J{`*Zwd#-I@MF?}Tuu#_N$>9~1!xROrTQRTtki0-rbW zp2q`AU=hrRc`z4d!%UbC>5u|bVM=pDO@y&967GXR&<}b;cjyf5p*1vz#?S!jH4j#` zxxJ^VId>eYGUZi*@=yli!3DQL5hw_;5Dk%#7xF+5IN*x#Mx(!ykczupjooF4zHE zVKck|8zkI}UlNB$;LT==ag<|5jukmh^sykvfS&(>=04?-|8;l`UWHd68(xN&;6+#p zE8ux}7P6W-i8It&g2>l3e;`8QS=tlIlmRY@CAGYU&A->E$}PM%!hW&hgN7)_OB6|h~5i#Hw#iVNK3%J6DmSE!ll3k#h?(x zLOuw`KbU^`j-|-|grA!_u?_Vlw(tq|rKS$`A)LcL1@GZ=81@sl2VNy^C%PTBG*zl5 zY{q^AHc-Y|(pTfZ0y_)aL;Mn0M0g(I$IwRzPs1k#ol4yO=tOiZaU;;7=m4}YW%tJJ zMtW!HKwKNNB{YM^q$Lq|FMhQtw>ELrh^t0iWvEEF9F)c`flqO?DCq^TWATq9Eid+! zCVb`xm*7J<2S39Pa239WFPd;p!#@Zd@CV^v;V1YWzJ;&gbNCcKf{XA0oP~9qgHBS; zTIS?YbPeHy=pNGc!yfF_#O*|Pz&6;@#88{inZ&PxmGA;Q2g{)#Y2zqw1%5AJFC)$i zOGODUg&HsqpQm6UJPDbk&n12i%z~LP1ExVbOoH(+hIXaERO|=PDKH5nZ7eG3BdB`> z;hB{C7|eo4pm!4|1c6dLp*wVi&d`+lr$Yv`hBQl4(1+kb=m}FSos3R2zeA>7*mWqQ z7WIy{;zklT94ZihA6f<-f{sC5kRNWRtwqtoP!Ky7qOk{=WjIuS{FA{+TpzR-`_LV_ zV8705!cjN`@4{Z#4R6CXc#F@5e}Jp-HGBbA;A6N1AHq2}4e~kiaX118VL$AFU9bbT z!e)2_Ho&WmleoW@R{`a=w2`GvENy0~+=nZEtt@S0X*)|hSlY?bE|zw)w1=g=EbU`y zvZehk9cbxbONUxI%+e8-N?ZKD_tbIvGpHM>_{v|ZRbINK(=44~=}b#!Svtqkxt3;H z`h=zPEnR5oVoR4;D)%+ZZ!_q&`C9S!t$xpvljCjIvb3(H_gY%t(j-gWmNv4qiKWdfZDDCEOWRo5&e9H+cCxgKrQIy;VQDW* z`&gQ6X@5%xS~}R$p_UG_bcCg&EY*D%NT*mn4_bQv?R>6a*{PPMTRP3s8J611n{CAn zG<@SDX_;2s6Mp^+t?*(?msq;gQm>`UEM0EtbC$kf=}JonQD;v`hT(7@Nc|pk1}udR ztGFhy{5M#-(b7$pZnkubrQ0msVd;}-8f863AH8G6?X`3O@$XvpK}!!?deqY6mY%Tm zeM?VUy2Nq)=P~kKkbG}62;}>rA1puFw{rOE-_etBZvW)7T>CNRpHF|*YxfCc`{%n+ z*na!b<7OChyFGgI99#wAtNnDFv_2Ed`y|MM`U@L5c?XUyh zhMlkrcEdZc2lm1~*bnc*0XPVU;4mD4qi_t4!+UT7PQv?e3QofrI1A_CJbVBj!Uebp zm*6sd1RujE@F`q@&){?T0=|T=K=w@kdK`1BO?l7y-5}p*=_&olXrF^C^nb*zx`AmH(b)?gdCw!mJzxOC(-<5D5mDrc>IUb^new4Qt|Lye2 zj|*rI>HT{0Sv_@~qAmGoZxza^{Se>%kbZ==%p%_z+O;2U<9n00NdJaqu7?dJk zvoU=C!}m{n$^RYg9YueX#rI3vT8eRNPJb7ooaL~Ydf#D=Y-fyRKlV`G_w4fnwEbo3 zs7G5T&@Y$Rho3w9`s$5d^eg@K3h_g!=PBl2W9q0ek2y&nH)X5~FJc_pPzG&ym3@4j z_z%f5opMgKr7iS%ea3My`zm8`fHpUn$lRih57I{Y-sKVYvoiT+(#{8%dsiWeF@Jg_ zpD9sqH~MxM;}p$4_NG4hY^pN-Fqu4+sG|t&yn=s4_I(!n)R2BEME}b;PsgVM<>q7m z2g-b+uGW;lg7nLbVF=}4p#1yUw}bS}hp?ahxIn)89T;!ga65Sq(e6d~{=zY{oU|@a z^9&W^w2-p)QTHbtKl1%ui3j+MmcHCNmVKtbyUxOpa(;`6%bQ@%W3%-#L6Lvafq5@_iWN&>IHA zTa9%AmmSoSxVx%vg; za(|kSF3h=T`gH*~7?-=?=xjdMoWtuSeJ}lRk$wD=KC8>VPoUk=YPY*E`Stm|EMvKz zeIEiT^hG04*iAqD0@4qiU@=?(8LxHFup6IMLci|xDPw+wv3(zgGGAB2N$P4%--Lqn zQ(ut2s?m>gF30F?9P{m=GyNuGFX!lD&>7@AZ8{)J+-LP=@zK8id|?vz9?_?3 zK<3F9`Zi<&`v|vBVk$%r$X8{lC`g+Xt9KJ?LNQ<9b%#wr5U%1^3UO zZu)e()wf%iTQa})f%LPSf4>0f??*xUyAJ(5mi}Ix$@k>*I2SDN^}Wp3(;#CI&KSrz z;O-vyjy|kl*{kry3OHI{MpbryE=d_T5t~_Yxn2({KjP!Y6PADk-jW zrhU-h)8G#q#Hb*?7IGvWBJZK#GjOiKVK@Ru;TRl;_dx1CiM|h~;53{8i9cuQ2k3{E zeG$E6*&m@F!zb`5T!G`$&NMhY?Rkw?;mnejH21U_(QtiZLEcwjD=dV2NSj7{ZFDZohgHN+M}J0NhC-xYMaK|V z06P|jV7~@yiGLDei0er>9~ueWu-_oQA$9`@hm(X8(Ry$Xgh3t%hA;4cmGrx@>%bK3 z{)%qLXAAs^{U%zL_=^n_ zLh87yLNka_DUEsd5~jc;7!P9@hmkPM8jm5UjGK(f)0EQ}mGS6}>M`kx-5F#&I-+_^ zT4T2a8JFg$9vj_P_CE6O7kgjSBCqs84YV4_ekY*5eV2Vvsv_a?Ap5%W+W!7Ew{Exg z=V$Wz&#mu?^Pg8&iIa8*nnyX!p(50u(>%(}9MW?q(0uWqC#meC{~Yn38=ujB-@IsI z_L2XbnBCBK%t@cfm`Xp$80vlh3R{kkH(?UI0eYNdd}JJ?4(apR4SBAw5zqC(1>!z{ zb8rSu!AW=zj=>Q)1P8zguamz3x)S{zUV{CE_rg1{3#P&k#J`S8Ti%B4uod2d|8;%- z?U6o`{*m*A{rqD0leE_wm78A1E$H()-`8>8%eGjJz6dYCv#<<27E91YFdyc@T$l|r zVY)>+D!k0trl7)9m|`&r6~@CD7-=yK9Rh2~_oT%@!u_BR^n`8}CrR&&eH`t8wuM#} z%~7F=MMHD}eo1IOs19|Z4t?SKbEukxt3egG11dmSi&AI_xE(&A9HA(7A&7$*h=Onk zgUiWQ?LN$!DH|!OeZ}ZxJFkG5T0~hnR0K^fAV(+79#Q-LOIu|Br61~p^nHY#8X75%70 z*v=h#w;Q36BX<@jKdXDQXSd{-m19PZ+uk6@XkYkW)i39q2Vp8a1k)<;o)V-l^?63l zm%oDCFSQJo!*lQgtb~hj89s&`@Ec^oKfNdHpWM@RmHK2|VL|4Q+*9~}aX+q}$2r}X ztJ4R^1in94r{{E^u1>e#udCxkvlzGK@Dh|zX-Utc&%w$PzT?X%!{_KFdEXE7;0bt! zxP4$RC%3lZVpRKL=lW0bXlJ`Eak@OM+V56WUVm?vU%w{XRp+s*f4%lJvHjEi!_jI+ z>u5C{ra=azLmH$)3Oo!C!Blt<9)KxuKTL*6FcBueco+v`VGN9hQ7{rlz;GA__rXvY z0)t@?41@vDANoNu^lk0mUjJ~k>Vscz=mkBY2Xu#S&=tBsXXpeSp#!vscF-2uKx=3P zEujT8hi1?enm}V{1P#Fr4Il{;p+3}ud*Pnex8DDC$#*y01$Ce{)PkB&1FAzcsA`rS ztrD=SKxMcS?tn^A5h_4=Cz1mu2}r_sTX0WZQT7!J&{R*${FYp!W`X!V$>?OXjs-4n4Vf!rT6 z#f)#)YAX5=q`(Sz9tP8<7tlvw%pJbA&c$8^S+EGU(uQrY9hl3ln9Hr~eXMPJKiF-1 zy4dzKyKPU?d32l(=f*#fug<5-ld$%)tHjHBQ&i50qJjQ&9`TViRqW?lohJ7f1j;LU z0{I8>)nx@ze}B7Av?|Q;RR{_~0f>WG$PY0P4f!AnA|V38AuohMDCB_<2!K7~)W%;UFXwEnmLX-tX0>e zRmaOQnG@P4H>%4Ob*R*krHNm*LOcBqw39mi+nfAbgL-2CY>@J+G#+if%2 z^1IeYd}f>OYsd0*KATU+$;@Lb&%6gWr@3qQ6Z=*%8^y-IeA|4{E;(sRP6>YTDg3q_ zb7`scG4_&ceJynds_$m|;#dBy22*m%pzGD?4z-weFTB=XiO;FcI!|uYzYJZkzviYa z?IUIWll=C!_~(yS2f4;L0Pn(n*av%I54;1rVHfO#w_yithi$MGw!mAk8Qz3V@CIyz z*I@&^2J7KfSO>4bTF7pt_brg>wo01aw);xF4%=-V7eG@2#F^zwJl~g@=L~yU(tm<~ zKHa9=sDIu-<@x*A!{UFldhGuGwtav6(K%>&qcb`yZvvGo1zm@`+Y!I8I-N)$kImf)`;WtbiBbd3X+Wbl+-h#WL(=t%+RL`b--;chW^j+vWOsg*0jcm1JOGp3@|%!?|8b_vzhI?n zKl}a%vi18{yXvw69h2Hm``k?Z%ea}pJv~N^q92B{-xFXgjDq2AyU(rK_V!3#`*_{R z&h0vk|EurpZd8x#$G`R6q|_tj^+yL-wuI$7P~BhpJ5U|={|;2*Z}uIi|NclG`L0#; zM&Gqc`mKD|s_T(_vOo6kbR|yE`;*gmy5e)A{gHHCPoQy-boq{0RFm8C^f=hteWU*I zUnjIGpJCVLGi_b(TIcn@=d+&hYrrQi=I`0adiievpWGzA349xn_!jVufw}gx{JxL; z?#&K(8+L;Jy`Oim_duR_?ioh)ZvwrF{ZHzVI&M^tK|NBgv`5-3?U44#8rA=_{@vu& z`(fOm{ImE8x?gkq+VM~QbUk*>>0D^{(YF0O=x>XUe_WvOjmol@VYlt&==^p~2;zPj zkl#bA4mF@A)PmYj2kwTta1Ydj`j7}o&;Z=f5E?;aV1bN879vS(0j;1l%zU0_PteZL z6+VSEa1d^#4Z41*JEwY^lO}a%g4EXu`oJn^M?2P{_CB~(+uo=Cc20A`{@QmVpFnA{ zR-Yi(0#cvd#~!xZb{}o)w(785CGBSYVE6a8CGU;mBwb(21k#(;E$Mt8&o%c17!TuM zER2ECFbYP(2pA5-;64}%Ltrorf`Kpq`a?fRhQ81TdP6Vh2|b`Ybc3$Y1v*2g8-DKK z-v{=v?tg#P?|c3GK;J(n_rl3NZjvtddda_BeaJ5--`p6>J=4azeWA)mHn3Z-0Zi# z-zCqD`d!j<+HZTmi=XtnsQ$y8r$Ch$!=KD>KTC8u>zNM6w+xHDT%Y(nH)6^20vnEm#v=QX? z&y8;q)c4DCuU|N~ALqKI7v~~uSuZaSX|iT-2mDg+=ROdg;|wKl%1HbNV?%Yy9ZMar zb)xRfzwaaW-Md*^3{JrbI0lE|0PKTz%zKw}>W_u&%U84|r}Ue}>rn`#l2_vW%dq?A z_WCkVIa2Pu^kp4L0$#)1eR>Wx<`-ZSzqGL1FygC&(Lomgt_5?I?xn`Kou1^ zJwV)d5Q#ktt%g>Gmw?xQ_qXVe5W+Db$45N$ z1Fj2rrnjH_bL?U~H(d@+Vh=*Uti^q{5X$jY&w}d*_YUlnAZtBb1+FLDf1)8gi{63x zC~J|Y*5)27RMsQk3Uh#K4!4`*hwBdaNOTxH2p_@)s8WY{jF#t_?R(|9ZRkfF*C_4> z(QLx&;5CpnG`MbY%R1&SKrmil40nXfzX-Q4{M4}*)uJ%zptC*WsD?aOQN zT|D~@@(exKKJMLU(`42O0C`?qbPC8b$u%OA_B3Iso^zxto#i7wl=!p7jA_jR5YcbU%i= zAZ{q_z^4Ja9In8M`^2Zc`!rhk9?q%5B;3y35p7L)Eb0Y$PJR<`FB|>e23osY50`K& z_ZIYB7z_n@mcAay+63}k{ZN<;(|~(8-8IlEBbh@`{a)WQ_q(yVx6}PIX(KsDbB`zG zH*@d8zKi2dp2sf+3-E7_PK8OZ8n_SC-Dr$x6Zg}mHg=aE%e51<1?~-Xr=asqyCLPe z-BZSKJi$x#xVI2}9Tl34=bn0y=lrL^UMN)&k&uQ{w8v-Efz?_$0=_Le>em3%^-tV{`#r#9MRgzSqtl!Wa+5q>Wl3v~9OTtD2iL2(`^#GrxKshs9)qUha?%Brv z1ic7qs`w?i`=bu#)o#>yh-*QpL7014DYLS>n`z(ap7OB7-Qk{({sO@%60U?#MR#A* zuHYVRYI*l@^1OoWOywD*hRg|ceRuIMhkscp1EtO5J1C8F=vv}EAETYPVG55sOd-M2>1V00*~ghwE12In_a)^I2f!(cj`AkT-W;}On9*nLs? zy#Vgvb)P`bLCwbWAG)cA#22NmBJQ_{+YQe=>Ra^I?SGp>O9*kim7(U07>NP4XM$;a7Oh{=?={O)3Coq4noPQrum^Ao&2J}K!j z?pgCWPMYyK9?HFr?vJo{EMOda(Z>rVE+6GaxkonVURn6SjEi)?g3Y~jI}h*OqH;BNxIK3*3vjLHkR!kEAJjFuY`Rq^yQVX znGhS?ok@ziU{1!7_Y>AQh#n=tl%2q+YQlRea`~@$=1iu_Z?0*PHR{&3LgTM&c)^V$M>|PUco@r)fLQ_%PEB zGi_zsK7WU4JDj7;Ty4y)*iNU|PBT8tw8KnWnYJ=*hiOaRQceR~wxg6`=9R73l2^83 zOJ3QEEqP@-(9G4y+zuqh$)oJD6+6td!%UlzBu1Gwqm1ovCX*|~nPhI0RS3C~%&pjA zrX7Y)2)QI)nYP2UeR&ORX>+n!W0JWQTk^_QY{@HIu_do;C#WEf?@s1+f^rhm$=r^k zv^>rZ=60NlFm2h6Qx4OX?O0NSctaxFQIsJ4AX~Aee`Gs~(n7IiE4KK^cDR|VrMVrd z-}-#3@@>fXJS>B!VICwB-vjLo?Vu$zfcGQMN4_6zk|u`A*|I3BS@?C=xeyTjiK-v#rqn}m0PdeAw1ci4%r-C=KqjZ%5&sW#?T`a*2k zj$+TnmTfZUW*HmVj#A~#c-bbKxsuFnvgw&*ZYQvnV?efIN0@emY0Fqj&q{odY14bp z8<|-~RF)CE%sB9jk+8&gZN9PKNh9x*M$vgjqfBGc9OIW+M&%jC7t@R*(~NVWMo_3x zyL5Flr4q?ipr0UEO%1y0Lz+@xoxE zPjzF(K%>+^fmp^mYyz_F%)<8^bpG0w3$&cQa@+`bX#*dFWH6D!--%`L{`X3Q)zm=WvP6zfPc zW17S|y2Luz){Avii*b~Taa1+8&qX`lh;}S7w~Ni~sbEJQr#fP8k3M7^>S;7L=z~Gvq4nKs#jN*-XjM_n%HEdKP=OW|1hy{+C#YQ-SPYzJQC-bWiew3i_ z$r`HI$*OAb*(CK{*$x>olZoGSZFlzQU~&sCj?Q)|v9kdxnqB<+m? z8`*9uP?eaCXB=DKjMLVpYt~zF)l|aSkdrm>tW(6Pil5D^TxTOyiL*sjg|k(uy*MfH z$=WKJEG-g0$JSEGCE1ouj%QmgxdPkLW~#$XEn}vZPp(<4qgtFWLOqpmMFpMAqh3E> z7Pxc&e4N^TzL46(gSkQMS=xEW*7VrN*=ELWXZucURpQ0kZCV*v(_+P!)#|YyGre}k zzHuh6Dt9(c)jMBIbvZA2nvmMk^fc6B_WP;mC2XILeva)bmOqdZs?yrZG1b^siP3pl z#O75o#iCTbVj+;Yq<*oaB@K$Xm&6pyzvS*>b(h>z?A|58#X^?kDHggUtXSS9;l(1B zL>7x$Qbny!sHb)%6j!GcqSQBh>F`a$&)MH5{F429!XMeE6V7CRmGEQs*9kvmA0h2X z!qM#G3GZc}On5(gSHkY>cM|qwA51uuy^*|INZ*pMHG5ma_UyM4b`tkS_NIh4vo|Na zmA#I%bqTL#uTOX_dqcwO*{c&?&R&y{oxL{UmF#5do;VY_vj-&%&hA9s9;Ek3=$YLsp?7wl zgucXe&hC=XHM?6v_v|*LwMl53-7cYhc87$H*^LvP%x;m;GP_km>+CRfFd;7-X77%% zXD8URQ|$FwY8pdL<7vetS}}!IOwHaKSwQWL3{?js!`1o72vwQ!C=*@waennQ^3!rV4Bw=HLN3Mr&Ig@mem!ot*BVNvP?i)HK%+dnfwl?y2|-B9Jj zD$Gm>t2(oKSPkNKhwYo$$eCYt2zgm`3<*}-LPB8s^dlihrymd5I=x&-sp(BaN>6Vd zQg%A4TB)+G)@qQetQzUcuO4)T!nfYnTpPT*T)VwLxPJ7GbiM2yDuVueY!38E>*{nYSW76dGX2Xdej^4n&pjf z&GtsR=8%87H;-$EH`MirH_SEDyNA9p@H1R#UZ*SF8|2FH2D_#af7N@y`JGp}QoIgV zs`n*lsCvoys`q8*+upU#b>0om9o|gmo8E=a{oa+%-@MZM@zZ0SZH#ixv5o}ik1By5 z)~V|3q3Sr(R0HR2s=TYTYDde5P}>mKNP1?JcZ=(w_Yc>3Z#!2PZ#P#TdSVbgG1yxb zpQ^6f-a4*&-n(4&y=`5c>4D1hKoxI-tCn}4lXsi=gu0^Gi+t>HS@yUb@gLLT5L%qa z%ey_ELVC};)AQ2uDHNn$J74rRdH&#$_sTpBULlcSk=cfUXAihQS&|hR36U&mCrLoE$|FeH9f6WC|^-D^|V*L z`FMGxr@MO6(?ErI8mcHy3su9@Qr+#Tq{hbAQZwRD_Jw*XJ%!_U&xvle=#d7{-vxH@vE~QAWs^3(&96+(&MLPO^<(s zxYVo%<3Grn8vkL|*!b~Te>xhN-kMe&Y72My!z@+XGc}p=}=8oMS7=_ zr-16OimG8MN{ywr$Eq%=<5Zv2@v497FjYHsxVk5Gq-vbnUwxc1K*e|lstRd?RVkJg zu0X#<@w1&F&^fh(>YdtA^-FD`YNfVRbyHibMyYPKJEftDX4J~3HBs@YO;veMnBuAv zf>Q^oBB?`FmDEw{v(yP{TxyaEN^PVH^Rq~mQ`@L3sh!l=)NnO`I!02*NY#R|9m6b` z#JI-Njv}7n>R!e*M737U8Pn0siiwP8e(EdCc;4eF#fN33oKdPY?yQHlkND%bHDnY+4O|x8OH_BIMtsP zjGzUVJrmW#o=IvZEs)t)+cQGBnSo6_qtv_1!XC6Rk``{Hf6Fj;_b~&jd)iP(XX@xq zi@U3No@BL*SsLu=tRC`oQIB}KsqgUF&kXJE>7e?tN5k2pOU(DYs+rnLZ&zclYI$0z z2JCfXPm+3u863nON3h4Qdzz}!9*3gWXyG_4K8c|YhbPtGszH+PsN!f z)H~lQ8CVN=1i0tMc<85`O?O!*t7|%P+GEb zrVUhi(uS(=v^J!*QBi5_RsOV2s$d%LInx@ckTm))Em%z;b%Gj`S}N$<40q7gj5ew* zJu#Y|XqQ@=IdBK%7F1K{>nZA?)HCY+)Zf&p)D4a=Q|B39;dLZ+tm?**k<5`XkiI*{ z+|8?osza&wsjoRwKIKTM$C1)Bm9q|WyIAUA^)W|@E3GwWsgC$|R0F8-Xli#gH8ois z;E0OkC~3l6Kb+cLb>#@^o7x1wCh8-Ol;X7eAVQhW{h-qs zp9EdW_%!IVj21yZXS53XBBOIqmy9~3*9p3uQ7`CY`udxUCPCk4)C_8uQ7hU z8HFjM96sfOzRRc-^i#&2L7!(N1RW**QbzHhk1|RHeVtJtC`1(qa%2AT z=dtvVpgbATK}Rx1sd)80`|q2VFQz}|T$R4jxi@{Mb6@)Rs*UG9#}v;n#{(Rpp=y$v z=9#SKuup%c99OM5LVrjZuNHEQ{Fc&%GI)Ormr@3*&r*h{avZH+5uf4ds%CRUPUdVB z!qImyrHy)z{y3A8q~1qYo<8V5s^R8$gpVHLfEmURZaYOdFwCd}KQ~ioZzz|RW;zKmbskM^s7QQ z&VR|A|ArCQh+eFcHd=M@3?}bjRg!sLigRER=e=;+UMO{ta?$qUsw?^VBnir;^;1qy ze-)nERXxhSJjN^V1I+ml&d7Z^*WJfCa5{M^r?pj`IR|$2G$yaF_Y+e2pqg`DL*j~i zny3;SAMx}>*|Y}A@VHf8j*GnXOTpA)Y9e#vJI3`HM<{&YS?2hVQTdWl`HJ2@!N|PI z$gF1sLe*aNHly$!BeaUq*~wXbDtrAiBlIFIOoqygZc3$BUC=MlKMf_;@nu2qajMwE*=7RdAb+x>FH6t zm#0q9MUIZIGHMmC<5}xm%lYvz=gkkjg@Z0|1bmrM&WKW(&JfthdGUSDm!Epe86T!r zHWJdR7**4LP?getQ}3l-aeSWond6Jpvc?Cgqj-%uPQP8EUoWZisUN9}so$$R(~he< z(%w@Q)6S^l__X5uHH@1vs;9lJ&ZX{B7dS6PaYm7Atys=mC7FAbh`W!yttMxURL&Zy zjZ}WlPbJv1ik!1rrn=N~&a$dhP=5AKj`zFRo8B4E6o1XLtoQ~`sh}v1>BAiNCphl6 zbKHii=%7O!*Y9ymZ{t`mlJT7L6J8;*+4Do}`3K&?D!(dkgu-o}3dZf8Oy}$D`AOnk zoNe!<YiV{ zUOW_bc{Uc`?b%=ar03n@?=#+$Qm3d)&ojkeqTQ>R*`aEb`YvUh>P20@rc6>3=#kr* z;bmyUmnrwDn#|h#yn=_qn7JoKAaIUQ>~`5kedSVsX* zOGjHzD@Qwy(HA&Iw{Z*~_57yJq#BNFW~uM0Z7(x-uiDD7e8h7`on~fV^{h}|c(<$P z@%cPur23h;^KHrm^)us{%Gorp>ZIOhoKiT>zhw+RNExU;VQjW??7oxIOzlr;t$t#> zj_}$yUd1||RCfn8S9Pv?e>9x;Mp&DfP;T;x4e1Y;A)*u;5b zTm`&$2lZrJf*6-z`n5FUlAqV-P&k?%8|2I=8WfRHJ*Y#*PUpMndz=T-_c|X>-{;Iu zUysjv=j!wg&X;+;T$8@Vxu4h0$I@3i=ccc9zLdVk!Bcbi%y&MLzR)={eX;Y=bgy$Z z@t-h0pE5qv(}!?g9paem8S1#7SGQA)-)Y9F5aZOIaq7UUL{oK=SD>#rMt)=d|E5kd zRxfkTUBj3b^n9hNFouUc->X)hpVXhJm+`r*e&tN{d+Nt3j4|HgIjR2O3hYGc1@)X~ zC+A_`x%w^7K~5LdRT2yf2zuk5suZ4%Z}1UTjMd~fN{d3mzN1JosR6zL16?y+b@9=R%6->6~Xyp1pa(LYs!p z2;CJL9M&)_J#0sqBX9k@59i&I_m8~w!XM&Wg5SgMjhGtoX2fq1bt4~$+!Xn1WZkIy zqv9eeN7Rex7%@KLiHOw^`y)Q*-_H>RBWp#r`>*^PW0jg2DgWk~TTw-ymQV@wMqAZa zjiyiLDz92Y&m2_enMr@Dd=8hRileflo};y+H+?nLF~_l#9^34A*KwA9``!_36fjB~ zHH?NvNBXd@G1^F>Cq2f?##?58erfzcugW&cS=?FKSf*S<45AMh4 zqy^6l&SH$#1@8zx%1E6L{wnxS#>*8_C8R#1)-hy2$auzXWXMAyk1>KThrAVXfN}gX zYsPd?o{4#;GqOwbtj@EU@jaF2N}gXB?Y z41FPV1LOW)=trSHF!}|;%7oQq4s;9~5H_Bf@I=`1uyxFbqhS}rt}-iP@|Mh7mATO- zZ=bxQm?3lXdh@Pfo*c}3KJQn|mZs5s|k=-Vs?hvPEQ%$YIR1S&>U3S25rA zMV^lQj9C{RRV=DfRNbf+Q9YuDF#~5tEsk0lwVWQ?81+un$*51FevAsq7niSez9f~T zYUE4iSZtV2(r@&yW4-~VPe*gxGvDxhALjM>54ZA;&zF&J4)#*BTq!$AtdJh%eLzq?|Z#dMA-9bP(oP|U=b14dHF zw3xz?d-6!y{Fvo2C!=18c`Ifg;R7+A5Q#k#^O>3V*O)x{3+0!%()nxTm%eD2zhnLZ z`N!pdFu(Npc#a+UH#`4A!V^sU07sGh`;(*b`hPFvw})TLzbpT1{M(iPJ+>d^|H1qh z5?dg)Ol-~AMzNh@2gXi_ofi9KY*y?mvD;&h#9oN~mVYWPI?feWIj&w@>$skA!{Z)| zn;rKw|5nCrjN21eB<5t?Cv3lq`{^eCLJJg$DGa`UrLevrM|HGzfktNh(tL#@n-?e? zIhz`0$92EvJE*{HY6$f2P~3ZF%GcMWPrQ~hP@W<&(+fOKiDON#XA1lj_aX%L{43Uf z?^YLBPcHfQ7P{B6{k{GQ`%3yL?xb1k<-fJiH}hqTlhyYHf(qJ4F1BENL5Z(c&^Nyt z6l{;xui$;5zJD_MV+*Dfe7xW@1+xooEm%0RaAc8~Lk0h8{TZA6SFF$a9RDIQzZJ|| zsA!>zg&gJ#mU`|k^w&|?tkCa468BeXRB*RK5+h~w$0`ytwop&@Dy`5gvoFOi663S| zYYY_j?b$!y>N1xHkGQr6|D?2NbB~I|{D17d1$Y!o(>6T2+1+e59ulHJAekUR)^`I0 zh!R|bLp&iQBoHCEyGw8j?(VJ!f&~tGu!FlF+`np>orxjmxt{C)zxR84GSgkvRd-iQ zb@$9pcB66ayd8dqF&X3*I=fh-{@Un#(D^%a7HcBC+s^+u?-7e#99+Iq&n)MnbgAPK z>eAGugG*-@7wasSVJ?fLlU)|V6&)L04&qdv%M}-I%RKR87c>`q0$vZP!gZ02cUxFv5F)2Z;#%hW-O=1Q%@)nTa~#jO zUv>|3df-kx-qrp+P9cgr(3l~*Q!LCqEIm-V1YFDElp3yqIMv)E+5_pJc_S_Rhv5g|tTiyqHZqrzyxUl17&Hw(M3q zfEftfi2_}OGS*R!YmifVruEv=dm&fmh-}CM%`EBPKrfO$$2yCo-!K(j(xwbT&6Jid z)45dDGPTN}oxvh$<1!t9pAEZ`bfA;V472Ww<;K$dIb~Lr*;VF5nTx<}X>q^I+cFYQ zQV&PZ7`c}x!cfXIp5B&0o@iIv+%wuU&2yk!>3Swm9jOM0=zh# z6c|y9q|Lk{LAIOMB57~0(U3yCSrBtlMa7GxE4_Alg@gVPuX<%Knh;&Ar&x@2yzlka z%gh_${N6!EK35)@3COjCw^y-v(@kx_Y5s03v{FOM3G!~?T@gn3e{$5q;~gOsH$r}9 z=_090*3P^5z980R<2}3|T_e%Vf_l+bnn3H42U2-%I2N-dpN#(+prv#2V0rt@(ahiU`EP@kp%OhpeOUo%@E`W zrG7PC^9SYgCC#v&WZ`1nkZto5Y5RPZ7G(pExky^e+zx!TGuzYD9z|*7=W)BfqT`Wl z(R`je10)`}yV@^j(H=TLmM5-I;7;HgowInIqEb)s{(3tt?+|;2y$A zjI1!N!n_Iza(n5T3Y;JBG2w9X1!FWe>gBt)y;d^K*~K0muJ%t$o~lrI^wb6KKu5B( ztNlIjMT8LPeBT}2mliw`=1iQL51#qGqs#k0ga#Fxb%#ja+4W^K)S!T-wa z5FC31X(9hd>Fc2L*33zwlY~o(mdlh(rN8lD(IeG~Z#ShiBeN^hg7GhuLI&B_+>*^mbD|C&$IPG-rp|LvxY5 zl)Rv|{&G&TxVWRdpFGFDI_;#|Vn*}@c@on2g9oSj9*21jQn zw~}t+VuPcLlgm#^x3zM1h2ZY&<}UScv2%BGcd@i|6FWP*SxQS;T3QxYx(hX`xUyZi z43YTX9lxtaN)d~23>QeC3rZ1!BnT`LLMf7k6JjZ_#8L<%Gco-uU_$|tfPgT@N7U~f zq=l%32!mJW8t+$QskC@WNrB)7LC8WhA8-yO;Uh>sgQjHvVL)}1xQXB&Y9wH?vj}|# z?dB#z!;FCgvI_=a(oj?#NiRbV-+1{`NJ%7iLBM1{aA?007geH=m%pgOlsj%MbMwME zzr@KeRBRRVi}4iSa{22DZ4dJe1YuQ*N;{W;AB%Q(WqzyAOURsM#4F{PwMT5g(PEl?v!r7~Xr-=&N9S8}s=?@nZvYKw6lO4KG-p8)ewp;cTwRpLQv4ok@ix>rRj;UU0pgv-BR93vS}F*OGo1;vd7PKZHWjTE7yXzB3#=5lnxKRM9= zB*sGc(BQ)1;OYwNKLmGI*fGN#z?DulxMENVg21sMrhkkKl?dQq2V9{QPzsR{kR~=1 z5q#+~)Pe9%TwLHw$MP5(sTK_RONU&(?xYG}2^7k~(zsyVon5&M(q+u=H$AF6S3CkS zwLC>e6@ox2g0lI#yGu#CQ%UGNS}QD12&#(oF$iu*l8?(JW#g_!Jq{fJFYyWMj!TCO z8u-#thvVStj`N`EFkEOrhEQWTmMJd8M@4P(H3cDhUVNB&5j-l`oa%YZ_DmQ!zg(-y~#8z!*m39!f<` zfSVCFDG11E^&pwQ`jiiSKw$EXJQFhMbeI!UBGnhLM753;{JXT^&qwt#EP^mV!H_s0$ixv^R?@mJ`7FeDTou)OtpOhaNJC zALS&ks3L_tSg+AaCk*beAAyyfxCoDD>NiVwSoqy#j!uqF{J`P5p}0C=J}MZuoasUq z4A@PPx|#UImw2-16ztqkpr!(atASYgHe15_=1&W(#lwb}14|jW1lYt^p0p1U$61iX zgC9Hxk22H}$VdSq#vqyCCOj6fz+$prg&P7C;40XhI-~mr6A*4$(=erD!9*i5+^D7= z?hnObJs-B;oEXwJN}JbOKcY56V&~IGUhBHq@SoYq{a|?(ex(V!C-w}UEqKD+n)3QDGgN=tFLV&3d4;n&Y zGe{DZjT!+3dSg@2lgv3u6eELBSri%90g!3&QYe`l6ZF=@L_z^lYz}S%fFe2w2-$-p zuDZw?lua54Dd38s(ovRRDx~HhVRBzCxG_UF$`AziZ|ZevI(U+{A9^ zsg;Su6x5Vt!}9>sGc8;^(6~Tep0VNszF91RgR`?oDMt@LipfH#f&8 z&os%A1k8nK_`wA=9c~~oqR8_%k|hELYA7rs!jq#IHA5*uFu|eH(ZRO_Hqp@uN9e*ykeFj5ic1qIOcC3NwZhc|yb1RfaVzv# z3%UdQfLJ0SB(VGfM)QkDDLl^MLdHAb z!WvKlQ{W!74e)rUGbT!x!0SW_XGfgDUGT<%Q^5~}xOOG0C$$PkB!}l9zjK#J6`VTi zY;u%vbaHYxjXpH|vI8+6jh(!eULULQz3ViA_o=BuFeuJ2gT?~55Y54MuC@Pz~$P3J~}ncz3{vfH2H&@ zf(eV`!?RbEgvZio;BZ`sQ7AwoG9M3$M#X`;1pY+?1!N_*6kqTHhFS-$N2FcgK_9u} z!{ZJ0K4(9_Hew{xl7^NxvYaAGw8J3zpep4bt8r5s%I9u4Ffv*mfkXNp<)Jc7fsydt zLf|pDj2{LB81#i!AmgbS2wiwzO%`6N4^@X{5$#K1!vPA^c8KUwiA{tHEXs5%LMn&L z1CB(3b}2NFEY4G5oiYXdsNFQpOpm<#Aq&!UVb(gvj zP}BVrluG_6UrL81jj*e57fyu4PL3R1RK6=PX|KVGN^F9uq-06X)lrN;-U6wTCDGE0 zcR(-$kQ|p1FqJasGccsg6@_E;e_0?e{CLdNmSQpbcnhErIhb8uNL~)IQ<{?O%a7cpBg%pewnF)X-OyW?U z>2?ACOCGfV4{t0bun3Y2t}M#n_;9}g!32nRi-;056<$j?d0vtspSy^7Q9gyVISOJ> zA`=+D-N85n6pZ0o8PB0GY10huNsc8gom-&M9RLcZW`TUr;dw;f)L4Qp#xf?FfFMm1 z6iy(&=I*0VTT*+_UKy!ycMPU!j@ri+Yr@A2K;-hdWr@JxqF{)ifbBvTA_@t@r%=3s zkdI|KgDFYmaSpoBT$t@mER;aqLIM#$g`xUUrr;(Ey+9C1NLGR_Hk`BzH%)*n zNWl7zm56;HWV#ok3e+f5J|5PB+7ny&)<8DU=b5<2@tlj9PG95w!o3J=uI5{CG3rZseLXVM^1TC!O zgaoZ0dENkOD1)|hK1-1x@3vf_@UO%02Nb$M9bid;nwv&9KhXG^aH>!U?jG{M7&W9| zZ-qdRwYdL45OpPPBiPQFdW5SajfpU-8cNA|pT*eNuaG@Bm(&nLLaqrBtR%wpny&@W zGXPQsLYLC!=ewPxa7F1wU?4Oqr%mdFM0j0zcLFxQwB13He3}76rwBOQX9(KVvyeuW z#+`tA53G~Jh6Nx^>YOQ{i~|Ux(y%m2L$i`0Ks1O~V0*AQv5b5QMcc+SN~tUcM8ZTC zR|1+=XavHh+9o!VgVR(v*RGu96pfBaWGD(;u4$~oQ)+4>LNnRMP-{RQ&+kNr_b^3qBtKhK!jva%S?hCR|+>~`9i+(ac`0fzKJx^pn}q-=VcVa zlyCAs;4s)n1X(CkKq6f%CgAbCLL4lgpEFGUyJZ9rsj_D_!nb@JZfMU8t%x}Gj0?~x z*ol(N`5iX-K9u(ie|%$Viu#JiicW|wh!EF7O!C6S!^KC$pT(8UTA59R-y67LRz}hh zzMo^4_&EDti~lJGA8n?8^nEDr8FP4LuROFz z(eXFNMpPQUEv2TYp{Ro>8MogY(Q0T#blnYYc~$g8^ch-|d~-@c90zeZv05AmEnPHz zn7EC&v$!Ypz%=nv@mA=Ei{ks@chDP_X6|ObW|hrqnl&`*06i0L*28Q#^iPi2db9n| zOBc-U!CU1w%wCv%H?x(LkyMk^mNbEW>niCd8ArceWf%0}3CUH-6X;11{O@;JX;o=0 zX=7<8=+%DGaqu4bYWQW5lhSL@!=I&=@NsV?S#?=`Sv%BnYzaxJQW5>awoP`=jP?$vBt;|IUol1To>{1H7q3(7 zQRHAA?*%_j`GY1pPSKc9bujP8nm@|DuBePXj+c1?_~XDb?!-BY-WDQDRJJ`_g~w=+ zTwI_Yp7`5kgigQP-GClT zrDnlP(@4ISVnqQf5Vx~phvmT{_N#rKOd#m4{Vj ztD05~t==;otk8kb-NngPJ*|dYRS`|ILUo0EXf^ox$q|3b-}CWl*G%*p*)9G0&RNT)bl#J_9x+Egzh*EZkyLha0# zTMJ_)iM|MG=l_G&Ky8~Ova(JRxm){M^LEs*<~`{IZB;&(OQCWbfL0}115}gt6s3!( zNs)S81f?_riH1c=aV7jp%+W9qdrfYMjA6xVyA#d{`5LHyPgd+$cfUye)K55I$Q4bux~$xp3ULrNrD zMrndwrND^!f^~|j6VFrFxY~?|ZwIRkD;0lKw9(t})Q?hrHc28b-+*;$+YDodG1YDQ zicTrW7;It_Q3x&7W`GU(UJmq4XPhr{c1^IEZ^NaFi!ac2=K8sbsELRloj+bR*4gm0 z0>R%`s65mvKR!<5mMc!?2F`s^q|A2{S7uwBKF3F`Thi9YRu7VOijZy!zng<(BjH-v zObIRhh1y-&j4`_jyz%0c0(xllyk`OxPU4!P8ccs%GV(^-qV)tFXbeuZb;cv{J+o3u z@|VCjhmriY(v7wRgU}pz3XEeSE0(!~wyl`MsI6FL{XjOJ;s+|iEZPai?g871)IWrH zd^*n--YB3Fe?$5`(}a%oj?&u#&Gypg)IwgDPqmdc&9^JybGXze+m!;V2zL}N&%zE- zCGD0g-?X@Bgqc>v#4ls|W_ z-D*32Hd<_3lJ;SKsh?5brMrJ(1J|!kzl_hVPi>`ShC?=-ETR-fcC83*aTaslUpPi{ z%2T_-{QR6RAW;X_x;iiyquo_s(XZDC*>qoF1^-bD{YS@LRT!6C2^E1~)WPK^iHcu? zE-+|)MdzRRMC&0sPtj9vAB+!Vbzf0SdvskVS}t87t*uD<$tV}v6sw4^?<%m9>m{Mg zd#3m{;k>w(qNe~QzcxqJ`iWh_b(PcMN8 zb|pt)4wq9fpO@rQ2Gfd=UyG;EsA($L(rkQ;MeGTb$jF)~6Im))97=wV*b&nag<86t7Qm9Ib0XDUa+4 z_1?a=NaAprQbOGXfhCR(q}KoTTE+qGV}5iH-z)C=Bh6pQ8_LXKcEh(#d|^D`=|u|TUINc0?wLs> zKa%-zo|hy*5+xZVS%5JPNN!2qN^GUoq>ZG>($Ui8(p>31_!iirFv`e^;tF|HO%ef+ z3O7+gnO{w3n!ug*a3*`spxAGmoi)VT_M{X{+FgH;+0dI=QE#U1n{3(V)0l)1ilc7hvB*@4| zKIE}uQP*F|C*|ZXR!GTE>F7fg1*1?nAX~&@lvIe(MfjRXJ_`h|kL2TX#Lx0Mxng5l zGPc2VVTerv8uWR}l;RTkpX~lVg{B!gF^ru;;{m~4Q7eiHpwbwC>b znLdX3%>;Vz4=)}mtR)g_nYBV8DBSv2fRx7PY(gb{#THGBtxO4)I316$4P5z z&ZorULi#@`l{c593hDm}LjY1h{5Sf+1R@fl0S=n>MQbBLet4i1p^BD9I3NL`fN6zD z+)1cWX(%L6fWgTXjHLw``RHOs{m6+p zSUP_HA8Eh`k5GXkbvVEu#-wy4fQR;^pq%?sH?gVOsfX#yxFwgW^De1kR702Tg zPC;=9gv;O(JO^^}5uBtc=2T3K-%8}u&PNmrLzWl`nbMKUuc;NrGj%&^06{C{t8YHS zgh7`%6OF7W(u|_HLcL9<3ICb1Wg>rw!0BvMG1SH7~TEh9U z*+nAraK$SZp97F+p0Ya z5ofaALLiBf1$4=P=9v;SdUy=9HapN6JO(sG0 z=?34#L0S+Tol2A>DfH$p*}0gRft28#7edqbQcMwuATX0*n8Y>`Hf&w7$r3C^tj9TA zJ&-a4xD~;-1Sk&$e)mLPCMH~RMNp<9NRl)e#^EGMfpD*bB}`nb2p7^V91aJ^OPjkNjHJ4p2jSe@AXF@x2~w zJ18DQ1{z#3!@IIwI@|aX=1DCs-U||%s^6H4owo8XLCM*%tYdxx%{mF;qWvE zNVc#F5uZa+rhK%G1p=Y+Jlv6_4(N(9P5nbi+GkuYl0YI{Cz3HkxVQ&NJ`opWQYBgh zkw-!#KrSwaU}GF?3$X^}QVlFX4a01zj|6aO93GL!S6rj~DP%qTae>RkMvx2v8aK4K zal+BUyF5Sw5O6Mm5eyzYTp1|l#|~fsjgUw_!Gtt7-mpo0MX@mOs5nv*BzattBK5)b zqe)~Nfy6b3B!UnGwD2Pt%A#tffg+@dt){D>R`}mtaR$Kf0tC}Uo2m{RQj7dqj9@+$ zR1%@Us?n{7J>vlE?9uj&(g88;;R!GacA`OM!gtSFP`)sl!Yne8H)+aN)ChioY7hMC zlun!}UMYSo_B88cHqY!Td|zgB$t1}cc>32E-c)J~Zz);9n@J<#EhM>lU2`khAoE4$ zx6RASTgy}AE9BedSDCl+YVggHCnOK0BAJcM8-61y0=^q_noRUxKDNSZlKI~C6zQBjNunMlG|6IYh7L3hCWsUc&rl2Mt^_9{EkL+R&CA3;m+kGy02OtXL-w5r>Q8 zpq+<_r;3+AYwr{v7hi!k|0GtJxtV#IDb4DbHHG$1G3#$O9(rM|*$Dm41PK^pI7S)x__ab&>UkKAiz?7UqAytb(}_dbzcEf_WD7HIW}=KFNH6`3C6s zP3DKp&q441V=k6E$ji!A@&I`=7zqjTEchj_6nTI7c=;p#K;z+vRUS?=Og}nGE?T{%$BJN35gc`A*1(t|SU%E$z`M?H7$ce5 zmd!;Q>Bt|$B*=xM;2*0-)C+}peEPf9Kp1(1wzVF}XE1}{n*w-pfTRq4|D5OXsqRcX zp0jvP!IZCczk)1gCzQ=F)iG{e>t0MNoEpW%7h6(~B65Zcc$1i^){6x+Dsq!GUyp+Y z@=(4ote22Bo&zn~LzrPoPhl=toib6slNiRF{YmLukCL(TE7+XRcE{t2X!I>Y(OXL9 z`y#T6V3Ac~3LV2m2o%YhY&ns+0W}oP$Y_Q8H-W4ncdb!;gz3Zdg*9sOZvZJJFRZ@` zTAOR9{1C;rGv`4g@+%Zc{Y*x-;`=J>xCw-@(; z@8SPF9ij9N&?VpDPq=<&EzOe5#+a=z%Qd?X?IrU4z7)qXjjE?V`(h>lHgQ$^Py`(J_8qQo>CD}R7b$$^g%QMo4bFf=NQ zuMtVX#~nzJ22kXZCJstKv!M(wH-DMMQG%ihh*0d}BaR1~g7|_pP>N6m z3Ivi=3~C^#A*vQ?5hP9i2NuL=8=HECCg3p;YHt)IT&v?k(S@%GNTZ^-mf{QJtu$$o zM6nq-yKqH*i9Z9fAXVq-{HbEvJik!U6jvITTO`zvN=N<3DgSm%N=Ir4 z1Zs^^KSn7iLmHphP^2o!Dhx{6_-Dlx*5f(EWK*GBB42^|2ws(@IF&*fzr@I=osak> zbu!X^O|+1dsoPPfp{Aq~rUat%5k){0FY{x~LQp~mRux!ObPxZy0IoJd+}}uZ>bPxw zLyw4pHZmUqRV9Rp{B=Mcsd(k$bD$hFJ8@S0MzNg1IOaxyfXUTQz$%<1#q$unp(KTJ z1UBKi5}H(7m`8@FP=Qm06#{(<#qcWw)f220QnHLtbyKMnf=5L;9FwaeVt@n=fXpRL zqSP|1hcb}}lHzKDQ$z@7an^B2h(^Mk2F{n_I#CuW67xwWErphhvndA3Cna;bz<^hs zu^xBHR}z*-5X1sO9<2d~Y!X35&@xCKEfK+EA)4TXAm)=iDB+P_gM}P@K?no4DR>z- zql>>7c(7|gfMhp7^3ke~)_uVi4T+%b1MJj@vS6Qpx0y%)2UJ%;M3UU+sTds>%xU4R z9j5^?E?po&FgO8(1Z6XLFM@%=5R@q)Ef2-f7Qh-r*~CGlaKvC5v7lW!A=CtfPZ}Gg zF&2{GGpQ1hB??4}mWvSy4%MfiSe7dR#Ylk)LQFu2k(9`nk0d#9$R~eCcHh6N^Zlyg_2|X(i^A8MeF@w-o zw3q@KPUsAz9F7hGx?xnW)Xa?Z#NKgH$%zRmT@zzHYk4NerL>mVF%ghoAI>sxa&$1H z(f8~G%j(HIney2YZpiK!``@llD%ak14s zV^U+|BD$p}ret{5^h}RSj<4D|E-E%It!n4Qgw7G^8Bu8&o<2Us<<#1C4?n7k&BvIJ7JfvI~U8KMDV-`Di}*&jR=LNkzwYLbj(|UI*xb zf&%B;*fAZTA1XnAC?RH*N>-_2m1*Hh|Rz zvf3b48_a4$SZye)Q?fc0t5dT&4Xe|#IvuOivpNH-GqO5ARu{nP0$E)Us|#jzA*?Qx z)hk)Oiq)%Gy@u6mS-psh^l)f-v8AFB^w^?|HDh}8$P`Vdwh${LicLB$%>tU<#X zw5&nL8uYBez#5FK!H+cru!ca^5X2gSSwjeG2xW~*)~I5QYSyS>jat^IV~u*&Xkd*- z*67C?16X4qYYbwI!K^WaHHNZ&O4d)s`l(qz4eO_6{dBCKp7k@Zen!^MkM#>+{Q_CP zAl5IK^$TJBLfHT%8=ztX)NFu;4bZXyIyOMh1{l}?BOBny1_ZDHfown!8xYI}gs=gj zY@m`2RI!0-Hc-O`YS};?8>nXk4Q!y14fJCJ1K7YoHZX_{3}ypE*uYRWNXZ7N*dR3< zq+x@!Y>~u$m3lu)$h3SjPtI z*qe9dEn?mzby*E(vs!+_ih&-rMsM{_m`+`JzG>F|trV@O(BYFZDN_hTb6 zyG3NA#vwWaCVwoAzHgD}gchKYU5IyguRRk6aXmQE+};4rhH#?5mDLE+jZ;(NTBLT3 zgXugjEhQ@16Bkt7GY-*lv7VVJX>n08oui_Y3;7WSBPz<$}a9{^?v$~+Nm+%qQKeypSDvIz(Vb0wcj4T*MMP~ z!Py*6)J`qnL~D~boT$9;0(2|N3j^KOkZuEKTR7XniP{0ppB>@s1SeXjBjJpKGaAl* z@lna?aT#fuaVeR}$$iM8>Y0)1nU$895$BoJITac@Ix{{#E{$0S_#U8x)^xPBI|=kV zOFP`BZOw^&HDLWjWt0SgY7l#R#&^viov6SiSli*c?S?#<(wTTTt>8rUN`SL7oQZI9 z0m*cMGm0C$o{3$%CC7Dz@eqZ#0cmj=nQ197GNMvQF)&2pdUlJ8fl;5_i+Lfp!)+K3 zp9Z5{4<5%MO+uk&uzm3C7L}Hsn3CWb<(ZtAo>2rp#WOX!3xE}o1%oXObc@P?5t$js zS>YL3J(5pXj`U1T^Nj3RnAMMGCB~xhPHW*=0IggkH{`+Jqq>2s5C=%f>>3R|gRh>j zQS(fV_e_gQNiYdyrX-@x680NUykm+p3!m8fP}{xgKY-4_;0f?G#*8`4s1^mvR4|s{E7mZMZ42c5nrZ*3{;!qtzf%9M1-N(o(D&LzGToSg z%)|n{oWrbV_QU7;=&itiQ^tR)|8L6tpQ8hB4_Vpab#)UgO6XS*I2tX6B?L5j(xws6 z7C_e$(6&HV7tl)q5A7c~ycEdie!mge5MI_Mv3DI3wT2!E>?j6rwr$0J$uIrxII%vD|yt*3}l&o|v?`+*t_2;}9G1xq8NrywP zD#rMSJ#ck1eCn7M7L{}JaL>0(!+Pbqyt}yZT3Ew=r`sNRVP9`vhoG0+l0xep(X@QJ zMAo-nhn&ru$`9R8FFkibj_TgMdK0Q2m-p}FUVm0>|37Tpo7La{^?Hx9QN!vh`%QJM zyK`s#S}E6)lQ+MtpLfi0;}PzaQjryYZy#^O*}5m29$QN4*kb8a8ZV?`ZS%;<`aiO3t)j zcG7)ollI>)oY}YVNt2oThTYy4Qnu-UYUQu8)5Duae16&N#h1}d-RJ73IQ_l9X>_pU z!z`z_P5XpPw~E?VYSuOUko~QmkoyCHLYA?^P_DmTG%GdZ(i!J9dVBqo@?&ixWc|gW8^K?ME$dN?Alr_topTn z=3FJcMUxI|9(Me&tVPVGzoIkWU2jpk+tH2|I~`iG4gI!^>0GyE)%hb9t_|$hvfrU> zc1^vFEjzsVnBsEne#`Xk+eeq4;t~EZbW_RKSDS?|bcww7qWSRf;KWpzxwvJyXSmr74KbQ z``ZP|)&rk8`=lR_ZGH3M+!wZAXSN=@;o#efwa&DD(NUAWxsjwzl8%{JHp;KfAA!u9 z#2u+^c2yi`ynSv_n@ZZ8BUf_&YNHDX>uLYlrtN_WRh~TV5Y+Zn+J_~qwrATl6MEQI z3s~Fsr0dGWJr8fUt)1g|_~}CDc1?0!-!2=|sGYi$@?%)m;CB7J`x@MxwzZpArM)!z z%F}j(zqASJCok82QH^TZb4IsnKUPs=M^Kfq?Wf;A+@k)R1MU4iw?6D(_O5-S<#)DapI-?B#Ev>m(Nof>x2 zze~r%Ydcn19iP)N(`}2f^}zET9inc_y5?APTGh9acd(>Rr;Ar3tAtB4I-PXb(Q~D2 zMW+F+ACwFayU}Ugw5C;c<4Q!d-+i~&;BH|NX-_)3EE?NCqWq@nFYY;Sig*=ak#ytw zgNSF_PK30+P%83G^Z8?1EomOPq5O!ds~e91@2F6{b-6u};^}RN_R#$kdFV?jd;XwL z)SJ&Is*XzU5Y@9^?LE5ONl_O&OsE`G{YaFRcGLTFxu2s(FlTxX?Wl@g!3>>wZ*yGq zdFI~t(&c7H5A}Qz(cyoBS=oX_+m>t@4)8d$K zbpqS8x_LQfe&>qsb(QU6j~slR5o8FC?YT+W+%cqAY^$5QrVeC5$Td zwB5$;(-NL|FMCzm=43*NEP3m5BSoDrWgo9Bf1>MbUHz-!cwAEFu;6de^U5sf-0s4j z&%+Wfbly;ZP49#Qio|t|SFgUgy-s4g=u4Me9%LlGs(#A)hUdz}i@`aa$F#heXw@n= zaBZSvmkCL|&$$k**Ck!nHq7(hfG+aIhAP?@o4P2*4Ks?KJnYi&<4f-olFr{!-~UkgDn!?hHm`k zKf78T9DQ&@Sy{@2v?`j?D{G|8O!#u6dHwDw0|tGb{$S3Mlp9f>`jwSjO{v^(_w(%A zcBwt)Bvq>J9+H|pM7Bj9-8|c%+|lh(wsv-E`ipM%*O>$Caom4RFPN%qW2PCt`zwA|L43(_Ucd#$?D=3@HrrLCuVys*r0 z%zbygduBj}OYe;EytA1Z^Je_{cu?I{88P1vsyA5O%BZ<7eZci5PMMbLn%+3SsD5Tb z*U6S~O9y7|muEHpc6xK>tK*KhznVSHy!fr*n*M>FJ;v6`X;ZpI%O2Gl=RUueFsjF{ zsNP*}9N5>xso&UzzL#J3Xg8#DqWOWC-zd*{iSKu%Y*Wx|Q7}!|wI&)b@SZko#_Zu1>G6dvdyIpR@j>#P`jH^$8v4 zJLE?2&OQx|pEp=0z3dYq>pd@Tn0McIBV0lQm$mKtspaXUQSym>jg<}^S*1AK_voAS z?X9do_Wkf`V*Ki+)%w+#?zTT^a!fykf7$i2tQq|pj;+`sH#@K2`u8*azIvGTpOeXq zta;ba-)suAvcdJP{cpFgQ@_c|h5fIjo$2ha6C`)tD_IZNd3kV3v+98ZDo*q| zelNGj08Pf~nq~T|9*~p!WZ3JIw+1A^Mv8lmfj&Rz3MYDwkx4^zMoMOU8b|Rl!;}N5 ze;3emo&UcFrAn>Q>huPqUsQBVY+QW5a`_%OweM58ddGs#j1-j>)#2IYuRY9!1~`}2 z-D~rp+y>WUw#k|g+_4ZnljQm$71Bbw8_ zU^E*2{J=y%DBcgC{XoqRSWvZp72u}a zsVJU`;;AT}isC6Lo|57zDV~zzDJh42m@eCBtK=I%MeOymH#nV$fJ;l>gJUzwJQ#?Jz(^EV>#nV$f9mUg8JRQZ; zQ9K>R(@{Jf#nVwd9mUg8JT1l3Qamli(^5Pw#nVzeEydGPJT1l3QalaC(@;DO#nVtc z4aL(?JPpOuP&^IA(@;D$#Zyx}HN{g?JT=9m{sEhR((Y=Cr>1ynibwrJt)h4;il?G@ zDvGC~_EGk1K`iVA1dk}D(W99>K`iVA1dk}D(W9_J45iO zf2gQ`sHlIasDG%af2gQ`sHlIasDG%af50sh!K416qW+K`iVA1dk} zD(W99>K`iVA1dk}aC=GcsDG%af2gQ`sHlIasDG%af2gQ`sHlIasDHpMEWxAxp`!kw zqW+reM#wr@a`&Nh#?m=RT2kt>=iU;mNXo?5!L1>Bx?m=jZ2kwDsa1Rnw zJa7*}Q#^1FLQ_0&4?Bx?m=jZ2kwDsfP=S9U>wpI;4lqvI0iTz100S44#xn8V}L`kFTmlL zqiMb`mG`Ckz7)@w;``F_d};Z<)E-}IpD(yGveTE^?+fluXi`64m<|X{>hBBF1))j% z_|o?Bh2EJ<9$9a}zbGDAkAbFmWW5HO z;*s?nXo^SHd!Q*E*$;r9QarL>0Gi^F{RGeykL)*qrg&sO0yM=V`xRfhe}R~`AKBjk zP1}#`e}Ja#NA^cR)Al3#C!lHjk^L3S9~6)5zksHAWPb)U#UuMSpeY{N-vLeW$o|h4 zJ_$hk28d~W$$k-NT3@oC1e(^D>^Fg?^(FgJn8#@Qk^L&r6p!p@fu?w5zY8?QBl}^X zDIVD``_lb0#MC~rzXqDtm+ZfRru8NJbD(K`$^IQ^T3@oihxwJ_k^Mi=6p!2=08R18 z{R7YxkKA7XP4MXb2h7t14?ao3?MUxefKTd6?`MD}^`-YaK$H5?`ypR?e*`hbqy7OO z*brRmA8P6!YU&?q>L2v}4D_je)IZeJKj2dyfu^P?P<=ubTP?d;&%Eseh=cf2gT{sHuOb zseh=cf2gT{sHuObsei!dSp<*zhno6_n)-*D`iGkOhno6_n)-*D`iGkO2Yfn4@Th;N zseh=cf2gT{sHuObseh=cf2gT{sHuOzXKe(J`iGkOhno6_n)-*D`iGkOhno6_n%rOd zs;Pg#CwByo`iGkOhno6_n)-*D`iGkOhno5aeLmz%pBF()@u+{$=S#q+{Y(9WK92%E z9naK1G<5yYQ2)?S|A0>yv7PYIB#jB4hWdwwt{)ogAFxULSHY^^*KgAfM4Ze`i(XOh zS&Pz5uI=9YWkc5Y-nP3+%z5~b-C&`A?|*vb{MCJLb|j;)*oGlMCU!7t$wNq`j~L1@r52C-k(Je57u6GYRdb7 zIps#5Z}qH~c;RbX`vW~Qn+GLb3~r?z8xS&M#mb&Zqe>`-SY1pCcrmHf-WC^H9Z8yT z(rdzrBlb!9r_Yv;e!6Y=-hGw*YI{5pFWi*9;^J$yw-#_%dMPnlfj`-I<&);PNv6we>&Ge>0{580Xd7WD4_fG8GxZLf`CbdRPS}1wAG&rlB`1Z{u)6!)6`TDd57o{} zE!Wp><|gO7!{^F2GH$jFKjAyO<$#>IqIY3kB10FnsMP=S&Fm&a#Rq0~4g7Kyb-lL4Y|~}-^_li}8*Q~Q;RhNl ztjP|U?7n_i$yCwjXDc_%J2B?DS5~<>kFOm+&})TLr_c!J%pRksE|`3*wCmGRr#@Ng zJ32T%`&4gRpZGEfPX~U?8~UbX+vUAH?zz=@FtGfsZG+FYS!=L3shVIna^m{!PdEN) z{$l1Z%i7;II<4G%?f$Yd!;f`g67ur)+?aL#)|8u*mL=>7jAQ~9TpV-LyK9HayK--K zZPByNq7hD z1upD zcWdcApK&ce&0E*it-k1wK8I)7&F{AU(e@+m77OMCY@MasfBwi=5ATEz(G8B7osAJ4 z`Vjo((xe`BW|unMF)-%uo4xd{J~z^5+a0_c_}1ZzQ`;GbeS6;u+L8Uw+$kHK`?OE3 z)vo>1X%wTCPF4P3wJ^vY?CdX3wY@JGPgthzbZa)yL|$+`6M zX{39-k1H$R9Tl9mW$xkOCExf(&)#;y&~f#JymEhJR`5_Atv;ANF)ZWKn88Wpu?$h zQOOw+_Uw-q+T&@sw2;r9-6bhhBQO3ymR79mVkr|{2{pa1cCOcklU^UV4ljjA|ZchB7V@_EnD3mx`#jLGTQ?tJvw zyQ?cX&roPzX#3`t>^|!AaGTY=ZRQUjI5nq{|JwVyj^`a(yPgeQesibAf|G3{hh&Vp zw0i#VHfLu%YH+M}H{;?zePt)TT4oLTKI!!J@}Y}lF@5CwDpw4T9k4asJOJG;l16Z#+fP3y<5?+OxaTnPtWh< z8`o{<$q^an%OoT+o`>r8s8RdK*&F9`ho;{QVBTDQTG8Tk)=_80=Oq){G-tZob$)a5 zVEB%?CwraV{5-zSILE==_szU8<#w6XgOuTQ!v1Rb>PR)_gz;*5=Y@T{G;L>-vu1mZ z{a(1^_6=@hE@&!8nd)i*OK)oOsra~httzJ_2+#Xp_Vir-*(o{ zQ|H{u2Vei=YsK2bZoPU@r^11ex0F*`zVG{H{Qb7iegE9i^X=GL*RyBkwQ*H`$k;Xb z!9}%lykqqPqIG3z>YI1DvtVrP-5X|(e05+}`4flk2Vc9lZJCp80ait^@ii0 zYrIH2I3jd=;Iij+nl$U^|4ES~*;KL3eTQb1>=!NBY<2Da!P~ZO?+^pQ?S6h)HTNs|UaCX%2i+jF(v+2`9kvlANM2P5XiQ)Q)h)0z(WP2Vo ze>5%N%Zz0b!!|FYeOTq3WsANvt1;luKO{>k4|i(!{&3iR{}RJq4E|j1!PWFeGiJTL z5@FpYe$}F#Pi~$)6ItuPra!mYJ+D3Ox=F*SjRAr9b%YJlv*Z1b#2Gk49diC|( zY}>D|qAtwWEPe58%;T-!atF8Qo3iux(ft#W9#vfu`>jt*XLm*7o{#I(k1Xn)_tn2j zt+LIQMwOh?Sl(f&)q=gU?c);f*?w|A6s>rDx=Fp1FrQb4{+{yT_Ulh4UmRL=byT}; zJ*p>lxY~7w$IZ-kp%tfnh;y_Uu4z7MuUDHAucmL>^=?4hF$w#Z`aZ8*wav4BIbAYd zIB%ec<<%%hzn#va^+UiODkuS>KP`cfrBc>rP_wv+3`?-?E!9 zXZjzjUc4Q5Am&Z!PqoJ#-!$aw)@e!U8#eacu=K+O<({0oyJP!?p1J<_)sAQ0ZR)%F zuFbo>)|D5IPFhhtu=mSTNpmM}s%pQj{+c5#j-8#6d|-HRR%GuBv2*|0w!Dw|zN6hA zj;Y@_SsD>NBCXz<*h?OD?W=U0ee}q?>MKsq{>$I`?u-k)w1a!~+&t>sp@WUb*_HZU z|3u`g1}E(FLwEW$Yf~xhM&7sW&q{yG4q8%SWZd)3ji(>1Rer@%|K8bo>c`(IMwjzX zAF;jS$oP{UgCd&OZr4vIlP~wtRvP#{X2E-()9bGtG(X;4c|aTaefO~~FU8lVY#Q|? zq3^2zW9`acZm$kH-_vvTuKmyS6^AUFJ=eP7%ApOi8(dJW82UP9lK2n%8|ULX^_r*X zcP{SbZe7s33nlA+-{KtFsOsX{C8voV&kSiie&3&Y4VwCmJ3agJpxFHvZ*{LW{9vcM zBfA|M+%mN5^oVos-56YdkIWgWZJ>=3S~kURPo33!mG&#^}Qp??*{S z-MhMR(W>=V9#J)w%1L;b;_E9S?>wS4{PLzPV*S53QCZpM_l zd-_-G=AHHN#`{*gr}*~o*;fC;^+=D$pU1S@8ea2i<0q}nl5(;f?|oYP$}7ooa=H7@ z4i6hSwe7qeSDrl$8LwXz@o097%&+gon@2ise&QlNan*j%^0QyInB7|Gy1-}t!9C3{ zE^KbzcTw=n3la5RhHAP#Xs~3;&?@(j4;Xf}N-L{6i@T+c-+piV{5?08Hs6tC+w@e! z6B~WXEHhs-{mimup(PeGp8c!!OwaW`*=cZY1*@^~%=z&(T21^^Wx%_IpVYS11~Q}4 zjwfk*eJs8C#E$Q0tLN15ZL)j#piA3)7JOU2Z$!OZyLY$sZ`R*eZ}*;m?9!Vm{f=L@ z4pMc@seS+1kzMd^vxk4vN!k4}MxKsX@uAYByy-Cw7iad(3)^z*YU1pjZntXfT{rxg zyLfiFE6sxsc8;2vx2FBW&kGL*teTi`;h#f0Y6s3tyZvd2t3}ez$UmmLoliJ6f5fS4 zwvi{KkG5~!<9Y1Bt`U!zmL-g}>Ut%3KkHm$r9&yptvhzq2z#BiXX3%I)K0$t9NAD| zd40E+7oKi@?PjUpy4F})-Tb;|vSo$a>u-%a6m#VW8_{BPwwHO^aW-p=?fQq!X>EA8 zy4TaNb?ck`b>!vTc0;NJmvfm>-u>#exbYv7LsWM^Bstmc`|wge`g?J z>~npUF{^7=E9c)oH0^fQq!BBJepqwKc2}2^rT(&M|9EHiN(nF@!8Z}+L$@YeK|H{vEY>Mqf|T^_G&F#odug;fLk zoH=s4_RNZPzKu6q9(r$D#H^t`uTI#fIkP8C(tO6>ec$h2(&}vbi#owwCk%_PHEqqT z8RdJ#ET6i({^DLGm%W_v96$`}wVPlUpk~O@F#7yxh?i&#oQ4T(wHtm0Qa(d!SZ|{|lcAUBJr{%rtBeKpM+E-=G`PhfS4URPKG<9?R z=quA}J{?-i-tt9_SMByhUH^1uPlNJH$6q-0c=VbLPj_bfg{&#P;q*WC2Q7K#@^`|U zFF_3|Z^|2VDX-gz?9-12s=hdOdLI$EaGbc&ie5w51+{9-(!G6u_P8=Ar$n2(DHhhp znIZpd8S(W?i;*rVbB~V+>;Hea`wBQGjyLSvBruil+um9I%WB^=WG1)R^Wem)p;MmgivBrp`=x=ET2@Y| z^Gl8$(`#81KTfmr>_1v^;=*gFx-|PtNaJ9)#J0?y3?fa#}6T9v(G(NXt zb*1ND6&#l(=vgC*ofR5-wqv06V)1p8Mm?GRgJf$evcQ*r4J5k z9us(I+o%(~q0Yvk)&F|4toXs;w|Bp*f5w(`VZ_+yJ5Jr-e)!FA7xFh5_C@A8uJzw6 zT*uV3#|}3dC%+gL)Rq4GurMfV&qK?rezk=umuc7QZMAyeJT+BjjZbUntIs;-ZS z{LXhfJ0x_?kGmR7ufC|*pnCc1t=O|TtYDn^V$zY&a&q2l?Q(BCwq!xm8rg;2Co2Ei zZ1gwL*#_PzNe8Sb@P!m~=G)k+_)q;mw(6l&AGZ)HDU~p4ZmRhHRPmQ7<27ccj5pt% zGG6>?ig?nLsVU<6Z97uLOLu>eBL0_VvUj}3#@buF<5?D-yzLz?duz~muXxf^{cr2N z;&bVx_Um48-OdB~$9To}7`Dw{^DbTk99{6`ck$c9b;b6Lco%o9*mJkn%H()b@7x

y4RgdD%T`ymaK={8x6j`E}4UHE!D1F>7R{DZ$yN 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 literal 0 HcmV?d00001 diff --git a/sdk/plugin-wasm/tests/registry_remote.rs b/sdk/plugin-wasm/tests/registry_remote.rs new file mode 100644 index 000000000..87a642fac --- /dev/null +++ b/sdk/plugin-wasm/tests/registry_remote.rs @@ -0,0 +1,137 @@ +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 + +//! End-to-end test of the remote registry install path: a mocked registry +//! index + download served over HTTP, resolved and checksum-verified by +//! [`registry::plan_remote_install`], then finalized by +//! [`registry::finalize_install`]. +//! +//! The plugin's declared `source_repo` here is deliberately not a real, +//! attested repository, so provenance verification comes back `Unverified` +//! deterministically (whether this sandbox's egress proxy blocks the GitHub +//! API call outright or a reachable GitHub API simply has no attestations +//! for this made-up repo, the result is the same `Unverified` outcome +//! either way) — exactly the case that must fail closed without +//! `--allow-unsigned`, and succeed (loudly) with it. Provenance +//! *verification itself* (valid/tampered synthetic attestation) is covered +//! separately, and without any network dependency, by the `rcgen`-based +//! unit tests in `src/provenance.rs`. +//! +//! This is the only test in this binary so it can safely set the +//! process-wide `OSC_PLUGIN_DIR`/`OSC_PLUGIN_LOCKFILE` environment variables, +//! mirroring `tests/registry.rs`. + +use std::path::PathBuf; + +use openstack_sdk_plugin_wasm::index; +use openstack_sdk_plugin_wasm::lockfile; +use openstack_sdk_plugin_wasm::registry; + +fn fixture_path() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/example_auth.wasm") +} + +#[tokio::test(flavor = "multi_thread")] +async fn untrusted_install_fails_closed_and_allow_unsigned_is_the_only_way_past_it() +-> Result<(), Box> { + let plugin_dir = tempfile::tempdir()?; + let lockfile_dir = tempfile::tempdir()?; + // SAFETY: this is the only test in this binary, so nothing else races on + // the process environment. + unsafe { + std::env::set_var("OSC_PLUGIN_DIR", plugin_dir.path()); + std::env::set_var( + "OSC_PLUGIN_LOCKFILE", + lockfile_dir.path().join("plugins.lock"), + ); + } + + let fixture = fixture_path(); + let sha256 = lockfile::sha256_hex(&fixture)?; + let bytes = std::fs::read(&fixture)?; + + let server = httpmock::MockServer::start(); + server.mock(|when, then| { + when.method(httpmock::Method::GET).path("/index.json"); + then.status(200).json_body(serde_json::json!({ + "schema_version": 1, + "plugins": [ + { + "name": "example_auth", + "description": "Example auth plugin", + "versions": [ + { + "version": "1.0.0", + "download_url": format!("{}/example_auth.wasm", server.base_url()), + "sha256": sha256, + // Not a real, attested repository: provenance + // verification is expected to come back + // Unverified for it, deterministically. + "source_repo": "gtema/nonexistent-plugin-fixture-xyz" + } + ] + } + ] + })); + }); + server.mock(|when, then| { + when.method(httpmock::Method::GET) + .path("/example_auth.wasm"); + then.status(200).body(bytes); + }); + + let client = index::http_client()?; + let registry_url = format!("{}/index.json", server.base_url()); + + // Refused before anything is written to disk, without --allow-unsigned. + let pending = + registry::plan_remote_install("example_auth", None, ®istry_url, &client).await?; + assert!(matches!( + pending.provenance, + registry::ProvenanceOutcome::Unverified { .. } + )); + let err = registry::finalize_install(pending, false, false, false).unwrap_err(); + assert!(matches!( + err, + openstack_sdk_plugin_wasm::error::WasmPluginError::Untrusted { .. } + )); + assert!( + !registry::plugin_root()?.join("example_auth").exists(), + "a refused install must not leave anything on disk" + ); + assert!( + registry::installed()? + .entry("example_auth", "1.0.0") + .is_none() + ); + + // The same plan, but with the explicit escape hatch, succeeds and is + // recorded as unsigned. + let pending = + registry::plan_remote_install("example_auth", None, ®istry_url, &client).await?; + registry::finalize_install(pending, true, false, false)?; + + let lockfile = registry::installed()?; + let entry = lockfile + .entry("example_auth", "1.0.0") + .ok_or("expected example_auth@1.0.0 to be installed")?; + assert!(entry.trust.allow_unsigned); + assert!(entry.provenance.is_none()); + assert_eq!( + lockfile.active.get("example_auth").map(String::as_str), + Some("1.0.0") + ); + + Ok(()) +} diff --git a/sdk/plugin-wasm/tests/wasm_sso_plugin.rs b/sdk/plugin-wasm/tests/wasm_sso_plugin.rs new file mode 100644 index 000000000..195785476 --- /dev/null +++ b/sdk/plugin-wasm/tests/wasm_sso_plugin.rs @@ -0,0 +1,179 @@ +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 + +//! Integration tests for the `sso` ABI flavor, exercising a real Extism +//! (WASM) SSO plugin built from +//! `sdk/plugin-wasm/fixtures/example-sso-plugin` and checked in as +//! `tests/fixtures/example_sso.wasm`. +//! +//! What's automatable in a headless sandbox vs. not: +//! +//! - The host-side security checks (`https://`-only, redirect-host must +//! match the host-bound callback listener) run *before* +//! [`WasmAuthPlugin::auth`] ever prompts for confirmation or opens a +//! browser, so they're fully exercised here through the real public +//! `auth()` entry point. +//! - A full happy-path run additionally needs an interactive confirmation +//! (`dialoguer::Confirm`, reads a real terminal) and a real browser — +//! neither exists in this test environment, matching the same +//! can't-verify-live-here caveat already documented for GitHub +//! attestations in `provenance.rs`'s tests. Instead, the guest ABI's own +//! correctness (`sso_build_request`/`sso_parse_callback` shapes) is +//! verified directly against a raw [`extism::Plugin`], and the shared +//! anti-CSRF callback listener `auth_via_sso` relies on +//! (`openstack_sdk_websso_host::CallbackServer`) is exercised end-to-end +//! here too, forged state included. + +use std::collections::HashMap; +use std::path::PathBuf; +use std::time::Duration; + +use extism::{Manifest, Plugin, Wasm}; +use secrecy::SecretString; + +use openstack_sdk_auth_core::OpenStackAuthType; +use openstack_sdk_plugin_wasm::WasmAuthPlugin; +use openstack_sdk_websso_host::CallbackServer; + +fn fixture_path() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/example_sso.wasm") +} + +fn raw_plugin() -> Result> { + let manifest = Manifest::new([Wasm::file(fixture_path())]).disallow_all_hosts(); + Ok(Plugin::new(manifest, [], false)?) +} + +#[test] +fn load_validates_sso_abi() -> Result<(), Box> { + let plugin = WasmAuthPlugin::load(&fixture_path())?; + assert_eq!(plugin.name(), "example_sso"); + assert_eq!(plugin.supported_methods(), &["v3examplesso"]); + assert_eq!(plugin.api_version(), (3, 0)); + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn bad_scheme_is_rejected_before_any_prompt() -> Result<(), Box> { + let plugin = WasmAuthPlugin::load(&fixture_path())?; + let identity_url = url::Url::parse("https://keystone.example.test/v3")?; + let http_client = reqwest::Client::new(); + + let mut values: HashMap = HashMap::new(); + values.insert("mode".to_string(), SecretString::from("bad_scheme")); + + // If this reached the interactive confirmation prompt it would hang or + // error on this environment's non-interactive stdin; a bounded timeout + // makes that failure mode loud (test failure) instead of a silent hang. + let result = tokio::time::timeout( + Duration::from_secs(10), + plugin.auth(&http_client, &identity_url, &values, None, None), + ) + .await + .expect("must not block on a confirmation prompt"); + + let err = result.expect_err("a non-https redirect must be rejected"); + assert!( + err.to_string().to_lowercase().contains("https") + || err.to_string().to_lowercase().contains("scheme"), + "unexpected error: {err}" + ); + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn undeclared_redirect_host_is_rejected_before_any_prompt() +-> Result<(), Box> { + let plugin = WasmAuthPlugin::load(&fixture_path())?; + let identity_url = url::Url::parse("https://keystone.example.test/v3")?; + let http_client = reqwest::Client::new(); + + let mut values: HashMap = HashMap::new(); + values.insert("mode".to_string(), SecretString::from("bad_host")); + + let result = tokio::time::timeout( + Duration::from_secs(10), + plugin.auth(&http_client, &identity_url, &values, None, None), + ) + .await + .expect("must not block on a confirmation prompt"); + + let err = result.expect_err("an undeclared redirect host must be rejected outright"); + assert!( + err.to_string().to_lowercase().contains("redirect host"), + "unexpected error: {err}" + ); + Ok(()) +} + +#[test] +fn sso_guest_round_trip_is_well_formed() -> Result<(), Box> { + let mut plugin = raw_plugin()?; + + let build_request = serde_json::json!({ + "identity_url": "https://keystone.example.test/v3", + "callback_url": "http://127.0.0.1:54321/callback?state=abc123", + "values": {}, + "scope": null, + "hints": null, + }) + .to_string(); + let build_output: String = plugin.call("sso_build_request", build_request.as_str())?; + let build: serde_json::Value = serde_json::from_str(&build_output)?; + + let url = build["url"].as_str().ok_or("missing url")?; + assert!(url.starts_with("https://idp.example.test/authorize")); + assert!(url.contains("redirect_uri=")); + assert_eq!(build["redirect_host"].as_str(), Some("127.0.0.1:54321")); + + let callback_request = serde_json::json!({"params": {"token": "guest-token"}}).to_string(); + let callback_output: String = plugin.call("sso_parse_callback", callback_request.as_str())?; + let callback: serde_json::Value = serde_json::from_str(&callback_output)?; + assert_eq!(callback["ok"]["token"].as_str(), Some("guest-token")); + + let empty_callback = serde_json::json!({"params": {}}).to_string(); + let empty_output: String = plugin.call("sso_parse_callback", empty_callback.as_str())?; + let empty: serde_json::Value = serde_json::from_str(&empty_output)?; + assert!(empty.get("error").is_some()); + + Ok(()) +} + +/// The exact primitive `auth_via_sso` waits on: a forged `state` never +/// satisfies the callback wait, and the real one does. Covered in depth in +/// `openstack-sdk-websso-host`'s own test suite; repeated here narrowly to +/// document that the SSO ABI flavor's anti-CSRF protection is this same +/// host-owned primitive, not something plugin-wasm reimplements. +#[tokio::test] +async fn callback_server_rejects_forged_state() -> Result<(), Box> { + let server = CallbackServer::bind(None).await?; + let mut forged = server.callback_url().clone(); + forged.set_query(Some("state=forged")); + + let wait = tokio::spawn(server.wait_for_callback(Duration::from_secs(5))); + + let client = reqwest::Client::new(); + let forged_resp = client + .post(forged.as_str()) + .form(&[("token", "attacker-token")]) + .send() + .await?; + assert_eq!(forged_resp.status(), reqwest::StatusCode::FORBIDDEN); + + // wait is still pending: cancel it by letting it time out quickly is + // unnecessary here since we don't send the real callback in this test — + // dropping the task is enough, nothing else depends on its outcome. + wait.abort(); + Ok(()) +} diff --git a/sdk/plugin-wasm/trust/fulcio_intermediate.pem b/sdk/plugin-wasm/trust/fulcio_intermediate.pem new file mode 100644 index 000000000..35de9d626 --- /dev/null +++ b/sdk/plugin-wasm/trust/fulcio_intermediate.pem @@ -0,0 +1,19 @@ +# Vendored Sigstore public-good-instance Fulcio intermediate CA certificate. +# Source: https://raw.githubusercontent.com/sigstore/root-signing/main/targets/fulcio_intermediate_v1.crt.pem +# Fetched: 2026-08-11 +# Point-in-time copy pinned for offline, dependency-free chain verification; +# refresh by re-running the same fetch against the URL above. +-----BEGIN CERTIFICATE----- +MIICGjCCAaGgAwIBAgIUALnViVfnU0brJasmRkHrn/UnfaQwCgYIKoZIzj0EAwMw +KjEVMBMGA1UEChMMc2lnc3RvcmUuZGV2MREwDwYDVQQDEwhzaWdzdG9yZTAeFw0y +MjA0MTMyMDA2MTVaFw0zMTEwMDUxMzU2NThaMDcxFTATBgNVBAoTDHNpZ3N0b3Jl +LmRldjEeMBwGA1UEAxMVc2lnc3RvcmUtaW50ZXJtZWRpYXRlMHYwEAYHKoZIzj0C +AQYFK4EEACIDYgAE8RVS/ysH+NOvuDZyPIZtilgUF9NlarYpAd9HP1vBBH1U5CV7 +7LSS7s0ZiH4nE7Hv7ptS6LvvR/STk798LVgMzLlJ4HeIfF3tHSaexLcYpSASr1kS +0N/RgBJz/9jWCiXno3sweTAOBgNVHQ8BAf8EBAMCAQYwEwYDVR0lBAwwCgYIKwYB +BQUHAwMwEgYDVR0TAQH/BAgwBgEB/wIBADAdBgNVHQ4EFgQU39Ppz1YkEZb5qNjp +KFWixi4YZD8wHwYDVR0jBBgwFoAUWMAeX5FFpWapesyQoZMi0CrFxfowCgYIKoZI +zj0EAwMDZwAwZAIwPCsQK4DYiZYDPIaDi5HFKnfxXx6ASSVmERfsynYBiX2X6SJR +nZU84/9DZdnFvvxmAjBOt6QpBlc4J/0DxvkTCqpclvziL6BCCPnjdlIB3Pu3BxsP +mygUY7Ii2zbdCdliiow= +-----END CERTIFICATE----- \ No newline at end of file diff --git a/sdk/plugin-wasm/trust/fulcio_root.pem b/sdk/plugin-wasm/trust/fulcio_root.pem new file mode 100644 index 000000000..825b19b83 --- /dev/null +++ b/sdk/plugin-wasm/trust/fulcio_root.pem @@ -0,0 +1,18 @@ +# Vendored Sigstore public-good-instance Fulcio root CA certificate. +# Source: https://raw.githubusercontent.com/sigstore/root-signing/main/targets/fulcio_v1.crt.pem +# Fetched: 2026-08-11 +# Point-in-time copy pinned for offline, dependency-free chain verification; +# refresh by re-running the same fetch against the URL above. +-----BEGIN CERTIFICATE----- +MIIB9zCCAXygAwIBAgIUALZNAPFdxHPwjeDloDwyYChAO/4wCgYIKoZIzj0EAwMw +KjEVMBMGA1UEChMMc2lnc3RvcmUuZGV2MREwDwYDVQQDEwhzaWdzdG9yZTAeFw0y +MTEwMDcxMzU2NTlaFw0zMTEwMDUxMzU2NThaMCoxFTATBgNVBAoTDHNpZ3N0b3Jl +LmRldjERMA8GA1UEAxMIc2lnc3RvcmUwdjAQBgcqhkjOPQIBBgUrgQQAIgNiAAT7 +XeFT4rb3PQGwS4IajtLk3/OlnpgangaBclYpsYBr5i+4ynB07ceb3LP0OIOZdxex +X69c5iVuyJRQ+Hz05yi+UF3uBWAlHpiS5sh0+H2GHE7SXrk1EC5m1Tr19L9gg92j +YzBhMA4GA1UdDwEB/wQEAwIBBjAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBRY +wB5fkUWlZql6zJChkyLQKsXF+jAfBgNVHSMEGDAWgBRYwB5fkUWlZql6zJChkyLQ +KsXF+jAKBggqhkjOPQQDAwNpADBmAjEAj1nHeXZp+13NWBNa+EDsDP8G1WWg1tCM +WP/WHPqpaVo0jhsweNFZgSs0eE7wYI4qAjEA2WB9ot98sIkoF3vZYdd3/VtWB5b9 +TNMea7Ix/stJ5TfcLLeABLE4BNJOsQ4vnBHJ +-----END CERTIFICATE----- \ No newline at end of file diff --git a/sdk/websso-host/Cargo.toml b/sdk/websso-host/Cargo.toml new file mode 100644 index 000000000..14a7f4217 --- /dev/null +++ b/sdk/websso-host/Cargo.toml @@ -0,0 +1,33 @@ +[package] +name = "openstack-sdk-websso-host" +description = "Shared browser-based SSO callback host service for OpenStack SDK auth plugins" +version = "0.1.0" +keywords = ["api", "openstack"] +categories = ["api-bindings"] +authors = ["Artem Goncharov (gtema)"] +rust-version.workspace = true +edition.workspace = true +license.workspace = true +homepage.workspace = true +repository.workspace = true + +[dependencies] +bytes.workspace = true +form_urlencoded.workspace = true +http.workspace = true +http-body-util.workspace = true +hyper = { workspace = true, features = ["server", "http1"] } +hyper-util = { workspace = true, features = ["tokio"] } +open.workspace = true +ring.workspace = true +thiserror.workspace = true +tokio = { workspace = true, features = ["net", "rt", "sync", "time", "signal"] } +tracing.workspace = true +url.workspace = true + +[dev-dependencies] +tokio = { workspace = true, features = ["macros", "rt-multi-thread"] } +reqwest = { workspace = true, features = ["form"] } + +[lints] +workspace = true diff --git a/sdk/websso-host/src/lib.rs b/sdk/websso-host/src/lib.rs new file mode 100644 index 000000000..9c26bf870 --- /dev/null +++ b/sdk/websso-host/src/lib.rs @@ -0,0 +1,429 @@ +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 + +//! Shared browser-based SSO callback host service. +//! +//! Both the native WebSSO auth plugin (`openstack-sdk-auth-websso`) and WASM +//! SSO auth plugins (the SSO ABI flavor of +//! `openstack_sdk_plugin_wasm::plugin::WasmAuthPlugin`) need the same three +//! host-controlled primitives: +//! +//! - bind a local callback listener and hand out its URL, +//! - generate and validate an anti-CSRF `state` token embedded in that URL, +//! - open the user's browser, optionally enforcing `https://`. +//! +//! Centralizing them here means the security-sensitive parts — the CSRF +//! check and the callback listener itself — are implemented once. A WASM +//! guest never gets a socket or a browser-opening capability of its own: it +//! only ever sees the already-bound callback URL as an input string and +//! hands back already-received callback data as an input string, both pure +//! JSON round trips through the host. + +use std::collections::HashMap; +use std::convert::Infallible; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use bytes::Bytes; +use http_body_util::{BodyExt, Empty, Full, combinators::BoxBody}; +use hyper::server::conn::http1; +use hyper::service::service_fn; +use hyper::{Method, Request, Response, StatusCode, body::Incoming as IncomingBody}; +use hyper_util::rt::TokioIo; +use ring::rand::{SecureRandom, SystemRandom}; +use thiserror::Error; +use tokio::net::TcpListener; +use tracing::{info, warn}; +use url::Url; + +const CALLBACK_PATH: &str = "/callback"; +const STATE_PARAM: &str = "state"; +const CALLBACK_PAGE: &str = include_str!("../static/callback.html"); +/// Number of random bytes used for the anti-CSRF `state` token (256 bits). +const STATE_BYTES: usize = 32; + +/// Errors from the shared WebSSO/SSO host service. +#[derive(Debug, Error)] +#[non_exhaustive] +pub enum WebssoHostError { + /// Building an HTTP response failed. + #[error("http server error: {}", source)] + Http { + /// The error source. + #[from] + source: http::Error, + }, + + /// Hyper server error. + #[error("hyper (http server) error: {}", source)] + Hyper { + /// The error source. + #[from] + source: hyper::Error, + }, + + /// IO error binding the listener or opening the browser. + #[error("`IO` error: {}", source)] + Io { + /// The error source. + #[from] + source: std::io::Error, + }, + + /// The URL a caller tried to open required `https://` and didn't have + /// it. + #[error("refusing to open a non-https URL (scheme was `{scheme}`)")] + InsecureScheme { + /// The scheme the URL actually had. + scheme: String, + }, + + /// No callback (matching or not) arrived before the deadline. + #[error("timed out after {:?} waiting for the SSO callback", .0)] + Timeout(Duration), + + /// The wait was cancelled (e.g. Ctrl-C). + #[error("SSO callback wait was cancelled")] + Cancelled, + + /// The secure random generator failed. + #[error("failed to generate a secure random `state` token")] + Random, + + /// Internal lock was poisoned. + #[error("internal error: poisoned lock: {}", context)] + PoisonedLock { + /// Context describing which lock was poisoned. + context: String, + }, + + /// Building the callback URL from the bound listener's address failed. + #[error("failed to build the callback URL: {}", source)] + UrlParse { + /// The error source. + #[from] + source: url::ParseError, + }, +} + +/// Whether [`open_browser`] should refuse to open a non-`https://` URL. +/// +/// The native Keystone WebSSO flow may reasonably point at a plain `http://` +/// identity endpoint in a local/dev deployment, so it keeps this `false` +/// (unchanged, pre-existing behavior). WASM SSO plugins have no such +/// grandfathered use case and MUST set this `true`, per the SSO ABI's +/// fail-closed design. +#[derive(Clone, Copy, Debug, Default)] +pub struct BrowserOpenPolicy { + /// Require the URL's scheme to be `https`. + pub require_https: bool, +} + +/// Open `url` in the user's default browser. +/// +/// Refuses with [`WebssoHostError::InsecureScheme`] if `policy.require_https` +/// is set and `url`'s scheme isn't `https`. This check happens before any +/// attempt to actually launch a browser. +pub fn open_browser(url: &Url, policy: BrowserOpenPolicy) -> Result<(), WebssoHostError> { + if policy.require_https && url.scheme() != "https" { + return Err(WebssoHostError::InsecureScheme { + scheme: url.scheme().to_string(), + }); + } + info!("Opening browser at {:?}", url.as_str()); + open::that(url.as_str())?; + Ok(()) +} + +/// A bound local callback listener with a fresh anti-CSRF `state` token +/// already embedded in its URL. +pub struct CallbackServer { + listener: TcpListener, + state: String, + callback_url: Url, +} + +impl CallbackServer { + /// Bind a local callback listener on `port` (or an OS-assigned ephemeral + /// port if `None`), generating a fresh `state` token and embedding it in + /// the returned callback URL's query string. + pub async fn bind(port: Option) -> Result { + let listener = TcpListener::bind(("127.0.0.1", port.unwrap_or(0))).await?; + let addr = listener.local_addr()?; + let state = generate_state()?; + let mut callback_url = Url::parse(&format!("http://{addr}{CALLBACK_PATH}"))?; + callback_url + .query_pairs_mut() + .append_pair(STATE_PARAM, &state); + Ok(Self { + listener, + state, + callback_url, + }) + } + + /// The full callback URL, including the embedded `state` token, that a + /// caller should direct the identity provider (or SSO plugin) to POST + /// back to. + pub fn callback_url(&self) -> &Url { + &self.callback_url + } + + /// The `host:port` authority of the callback URL — what a plugin's + /// self-declared redirect target must match. + pub fn redirect_host(&self) -> String { + match self.callback_url.port() { + Some(port) => format!("{}:{port}", self.callback_url.host_str().unwrap_or("")), + None => self.callback_url.host_str().unwrap_or("").to_string(), + } + } + + /// Wait (up to `timeout`, cancellable with Ctrl-C) for a single POST to + /// the callback URL whose `state` parameter matches the one embedded in + /// [`Self::callback_url`]. + /// + /// The `state` token is read from the callback URL's own query string + /// (not the POST body — the identity provider/plugin only ever POSTs + /// back to the exact callback URL it was given, so the query string + /// round-trips unchanged regardless of what body fields it sends). Any + /// request with a missing or mismatched `state` is rejected with `403` + /// and does **not** satisfy the wait — a forged callback (state + /// omitted, guessed, or replayed from a previous run) can never + /// complete the flow; the server keeps waiting for the real one until + /// the timeout. Returns every form-encoded parameter from the accepted + /// request's POST body. + pub async fn wait_for_callback( + self, + timeout: Duration, + ) -> Result, WebssoHostError> { + tokio::select! { + res = self.wait_for_callback_inner(timeout) => res, + _ = tokio::signal::ctrl_c() => Err(WebssoHostError::Cancelled), + } + } + + async fn wait_for_callback_inner( + self, + timeout: Duration, + ) -> Result, WebssoHostError> { + let Self { + listener, state, .. + } = self; + let result: Arc>>> = Arc::new(Mutex::new(None)); + + loop { + tokio::select! { + accepted = listener.accept() => { + let (stream, _addr) = accepted?; + let io = TokioIo::new(stream); + let state = state.clone(); + let conn_result = result.clone(); + let service = service_fn(move |req| { + handle_callback(req, state.clone(), conn_result.clone()) + }); + // Single-shot server: force `Connection: close` so + // `serve_connection` returns as soon as the response is + // sent, instead of idling on a keep-alive connection + // until the client's pool eventually times it out. + if let Err(err) = http1::Builder::new() + .keep_alive(false) + .serve_connection(io, service) + .await + { + warn!("failed to serve SSO callback connection: {err:?}"); + } + if result.lock().map_err(|_| WebssoHostError::PoisonedLock { + context: "SSO callback result".into(), + })?.is_some() { + break; + } + } + _ = tokio::time::sleep(timeout) => { + return Err(WebssoHostError::Timeout(timeout)); + } + } + } + + let guard = result.lock().map_err(|_| WebssoHostError::PoisonedLock { + context: "SSO callback result".into(), + })?; + guard + .clone() + .ok_or(WebssoHostError::Timeout(Duration::default())) + } +} + +async fn handle_callback( + req: Request, + expected_state: String, + result: Arc>>>, +) -> Result>, WebssoHostError> { + match (req.method(), req.uri().path()) { + (&Method::POST, CALLBACK_PATH) => { + // The `state` token was embedded in the callback URL's query + // string (see `CallbackServer::bind`), not the POST body: the + // identity provider (or SSO plugin) is only ever told to POST + // back to that exact URL, so the query string round-trips + // unchanged regardless of what body fields the provider sends. + let received_state = req.uri().query().and_then(|q| { + form_urlencoded::parse(q.as_bytes()) + .find(|(k, _)| k == STATE_PARAM) + .map(|(_, v)| v.into_owned()) + }); + let b = req.collect().await?.to_bytes(); + let params: HashMap = + form_urlencoded::parse(b.as_ref()).into_owned().collect(); + let state_ok = received_state + .as_deref() + .map(|s| constant_time_eq(s.as_bytes(), expected_state.as_bytes())) + .unwrap_or(false); + if !state_ok { + warn!( + "rejected SSO callback with a missing/invalid `state` parameter (possible forged or replayed callback)" + ); + return Ok(Response::builder() + .status(StatusCode::FORBIDDEN) + .body(Empty::::new().boxed())?); + } + + let mut guard = result.lock().map_err(|_| WebssoHostError::PoisonedLock { + context: "SSO callback result".into(), + })?; + *guard = Some(params); + drop(guard); + + Ok(Response::builder().body(Full::new(Bytes::from(CALLBACK_PAGE)).boxed())?) + } + _ => Ok(Response::builder() + .status(StatusCode::NOT_FOUND) + .body(Empty::::new().boxed())?), + } +} + +fn generate_state() -> Result { + let rng = SystemRandom::new(); + let mut bytes = [0u8; STATE_BYTES]; + rng.fill(&mut bytes).map_err(|_| WebssoHostError::Random)?; + Ok(bytes.iter().map(|b| format!("{b:02x}")).collect()) +} + +/// Constant-time byte comparison, used for the `state` check so a mismatch +/// can't be timed to leak how many leading bytes matched. +fn constant_time_eq(a: &[u8], b: &[u8]) -> bool { + if a.len() != b.len() { + return false; + } + let mut diff = 0u8; + for (x, y) in a.iter().zip(b.iter()) { + diff |= x ^ y; + } + diff == 0 +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn accepts_matching_state_and_strips_it() { + let server = CallbackServer::bind(None).await.expect("bind"); + let callback_url = server.callback_url().clone(); + assert_eq!(callback_url.path(), CALLBACK_PATH); + let redirect_host = server.redirect_host(); + assert!(!redirect_host.is_empty()); + + let wait = tokio::spawn(server.wait_for_callback(Duration::from_secs(5))); + + let client = reqwest::Client::new(); + let resp = client + .post(callback_url.as_str()) + .form(&[("token", "secret-token"), ("extra", "1")]) + .send() + .await + .expect("post callback"); + assert!(resp.status().is_success()); + + let params = wait.await.expect("join").expect("wait_for_callback"); + assert_eq!( + params.get("token").map(String::as_str), + Some("secret-token") + ); + assert_eq!(params.get("extra").map(String::as_str), Some("1")); + assert!(!params.contains_key(STATE_PARAM)); + } + + #[tokio::test] + async fn rejects_forged_state_then_accepts_the_real_callback() { + let server = CallbackServer::bind(None).await.expect("bind"); + let mut callback_url = server.callback_url().clone(); + let real_query = callback_url.query().unwrap_or("").to_string(); + + let wait = tokio::spawn(server.wait_for_callback(Duration::from_secs(5))); + + // A forged callback with a guessed/omitted state must not satisfy + // the wait. + callback_url.set_query(Some("state=forged-state-value")); + let client = reqwest::Client::new(); + let forged_resp = client + .post(callback_url.as_str()) + .form(&[("token", "attacker-token")]) + .send() + .await + .expect("post forged callback"); + assert_eq!(forged_resp.status(), reqwest::StatusCode::FORBIDDEN); + + // The real callback, with the correct state, does. + callback_url.set_query(Some(&real_query)); + let real_resp = client + .post(callback_url.as_str()) + .form(&[("token", "real-token")]) + .send() + .await + .expect("post real callback"); + assert!(real_resp.status().is_success()); + + let params = wait.await.expect("join").expect("wait_for_callback"); + assert_eq!(params.get("token").map(String::as_str), Some("real-token")); + } + + #[tokio::test] + async fn times_out_when_nothing_arrives() { + let server = CallbackServer::bind(None).await.expect("bind"); + let err = server + .wait_for_callback(Duration::from_millis(50)) + .await + .expect_err("should time out"); + assert!(matches!(err, WebssoHostError::Timeout(_))); + } + + #[test] + fn open_browser_refuses_non_https_when_required() { + let url = Url::parse("http://example.com").expect("valid url"); + let err = open_browser( + &url, + BrowserOpenPolicy { + require_https: true, + }, + ) + .expect_err("should refuse http"); + assert!(matches!(err, WebssoHostError::InsecureScheme { .. })); + } + + #[test] + fn constant_time_eq_matches_and_rejects() { + assert!(constant_time_eq(b"abc", b"abc")); + assert!(!constant_time_eq(b"abc", b"abd")); + assert!(!constant_time_eq(b"abc", b"ab")); + } +} diff --git a/sdk/auth-websso/static/callback.html b/sdk/websso-host/static/callback.html similarity index 100% rename from sdk/auth-websso/static/callback.html rename to sdk/websso-host/static/callback.html diff --git a/typos.toml b/typos.toml index 28dd792cf..44e07b055 100644 --- a/typos.toml +++ b/typos.toml @@ -28,7 +28,7 @@ ratatui = "ratatui" [type.rust] extend-glob = [] extend-ignore-identifiers-re = ["consts"] -extend-ignore-words-re = ["udid"] +extend-ignore-words-re = ["udid", "abd"] extend-ignore-re = [] [type.py]