diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b611a850..1f75b0d1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -33,12 +33,18 @@ jobs: sudo apt-get update && sudo apt-get install --yes libgtk-3-dev libwebkit2gtk-4.1-dev xvfb - uses: zackees/setup-soldr@bb28e96d2dc32c058242f56722297caf1efcbd90 + with: + # The native tests use Cargo's dev profile. Prebuilding release + # dependencies cannot satisfy their cache keys. + prebuild-deps-flags: "" - name: Restore native pkg-config search path if: runner.os == 'Linux' run: | echo "PKG_CONFIG_LIBDIR_x86_64_unknown_linux_gnu=/usr/lib/x86_64-linux-gnu/pkgconfig:/usr/share/pkgconfig" >> "${GITHUB_ENV}" echo "PKG_CONFIG_LIBDIR_aarch64_unknown_linux_gnu=/usr/lib/aarch64-linux-gnu/pkgconfig:/usr/share/pkgconfig" >> "${GITHUB_ENV}" - - run: soldr cargo test --locked --all-features + - name: Test every native feature together + timeout-minutes: 15 + run: soldr cargo test --locked --all-features - run: >- soldr cargo clippy --locked --all-features --all-targets -- --deny warnings @@ -63,6 +69,12 @@ jobs: run: >- soldr cargo run --locked --all-features --bin kernal-tauri-smoke -- bootstrap + - name: Verify native Windows interactive window lifetime + if: runner.os == 'Windows' + timeout-minutes: 15 + run: >- + soldr cargo run --locked --all-features + --bin kernal-tauri-smoke -- window-close - name: Verify HTTP wire semantics under backend feature unification # Run after locked checks: this deliberately resolves a backend feature # outside our manifest's production graph and may update the lockfile. @@ -170,6 +182,10 @@ jobs: - hash-sha256 - secure-random - text-similarity + - command-arguments + - config-toml + - json + - source-cpp - terminal-style - terminal-input - event-stream @@ -204,6 +220,11 @@ jobs: - run: >- soldr cargo check --locked --no-default-features --features ${{ matrix.feature }} + - name: Test JSON with unified arbitrary-precision backend feature + if: matrix.feature == 'json' + run: >- + soldr cargo test --locked --no-default-features + --features json,serde_json/arbitrary_precision --test json_documents # A unit test does not own process main on every test harness, while Tauri # requires its event loop to start there. These executable proofs diff --git a/COMPATIBILITY.md b/COMPATIBILITY.md index 91f6cf07..a68d921b 100644 --- a/COMPATIBILITY.md +++ b/COMPATIBILITY.md @@ -86,7 +86,7 @@ observation and bounded disappearance/exit evidence on the listed target. Until 1.0, the four first-party clients use an exact Cargo requirement: ```toml -kernal-api = { version = "=0.1.1", features = ["..."] } +kernal-api = { version = "=0.1.2", features = ["..."] } [profile.dev.package.kernal-api] codegen-units = 1 @@ -95,7 +95,7 @@ codegen-units = 1 codegen-units = 1 ``` -The Python companion is likewise pinned with `kernal-api==0.1.1` when used by +The Python companion is likewise pinned with `kernal-api==0.1.2` when used by first-party Python tooling. A source checkout may temporarily use a path patch only on an explicit migration branch; release branches must resolve the exact registry version. There is no `optional = true` legacy implementation behind diff --git a/Cargo.lock b/Cargo.lock index 2cfc16d1..7aa35fe6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -64,6 +64,56 @@ dependencies = [ "libc", ] +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + [[package]] name = "anyhow" version = "1.0.104" @@ -475,6 +525,33 @@ dependencies = [ "inout", ] +[[package]] +name = "clap" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b193af5b67834b676abd72466a96c1024e6a6ad978a1f484bd90b85c94041351" +dependencies = [ + "clap_builder", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + [[package]] name = "cobs" version = "0.3.0" @@ -484,6 +561,12 @@ dependencies = [ "thiserror 2.0.20", ] +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + [[package]] name = "combine" version = "4.6.8" @@ -975,6 +1058,12 @@ dependencies = [ "syn 3.0.4", ] +[[package]] +name = "data-encoding" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" + [[package]] name = "deflate64" version = "0.1.12" @@ -2353,6 +2442,12 @@ version = "2.12.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0" +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + [[package]] name = "itertools" version = "0.14.0" @@ -2543,11 +2638,12 @@ dependencies = [ [[package]] name = "kernal-api" -version = "0.1.1" +version = "0.1.2" dependencies = [ "addr2line 0.24.2", "blake3", "bytes", + "clap", "console-api", "console-subscriber", "crash-handler", @@ -2555,6 +2651,7 @@ dependencies = [ "flate2", "framehop", "futures-core", + "futures-util", "getrandom 0.4.3", "globset", "gtk", @@ -2584,6 +2681,7 @@ dependencies = [ "serde", "serde_json", "sha2", + "shell-words", "strsim", "sysinfo", "tar", @@ -2595,10 +2693,13 @@ dependencies = [ "thiserror 2.0.20", "tokio", "tokio-stream", + "tokio-tungstenite", "toml 0.8.23", "tonic", "tracing", "tracing-subscriber", + "tree-sitter", + "tree-sitter-cpp", "ureq", "url", "wasmparser", @@ -3359,6 +3460,12 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + [[package]] name = "openssl" version = "0.10.81" @@ -3719,6 +3826,15 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + [[package]] name = "precomputed-hash" version = "0.1.1" @@ -3939,6 +4055,35 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + [[package]] name = "range-collections" version = "0.4.6" @@ -4540,6 +4685,7 @@ version = "1.0.151" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" dependencies = [ + "indexmap 2.14.0", "itoa", "memchr", "serde", @@ -4823,6 +4969,12 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" +[[package]] +name = "streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b2231b7c3057d5e4ad0156fb3dc807d900806020c5ffa3ee6ff2c8c76fb8520" + [[package]] name = "string_cache" version = "0.9.0" @@ -5412,6 +5564,18 @@ dependencies = [ "tokio-util", ] +[[package]] +name = "tokio-tungstenite" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d25a406cddcc431a75d3d9afc6a7c0f7428d4891dd973e4d54c56b46127bf857" +dependencies = [ + "futures-util", + "log", + "tokio", + "tungstenite", +] + [[package]] name = "tokio-util" version = "0.7.19" @@ -5714,12 +5878,59 @@ dependencies = [ "tracing-log", ] +[[package]] +name = "tree-sitter" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af1c71c1c4cc0920b20d6b0f6572e7682cd07a6a2faec71067a31fa394c586df" +dependencies = [ + "cc", + "regex", + "regex-syntax", + "serde_json", + "streaming-iterator", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-cpp" +version = "0.23.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df2196ea9d47b4ab4a31b9297eaa5a5d19a0b121dceb9f118f6790ad0ab94743" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-language" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca0d1bf6fdd806e43ae5198f82f527056d359def39e54e67a0f478ac09dac081" + [[package]] name = "try-lock" version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "tungstenite" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442" +dependencies = [ + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand", + "sha1", + "thiserror 2.0.20", + "utf-8", +] + [[package]] name = "twox-hash" version = "1.6.3" @@ -5859,12 +6070,24 @@ dependencies = [ "url", ] +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + [[package]] name = "utf8_iter" version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + [[package]] name = "uuid" version = "1.25.0" diff --git a/Cargo.toml b/Cargo.toml index 6dc739e1..cfc8456f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "kernal-api" -version = "0.1.1" +version = "0.1.2" build = "build.rs" edition = "2021" rust-version = "1.95.0" @@ -17,6 +17,10 @@ exclude = ["/.github", "/ci", "/dylints", "/python", "/vendor", "/pyproject.toml name = "kernal_api" path = "src/lib.rs" +[[test]] +name = "command_schema_contract" +required-features = ["command-schema"] + [[bin]] name = "kernal-symbolize" path = "src/bin/kernal-symbolize.rs" @@ -44,6 +48,13 @@ secure-random = ["dep:getrandom"] # migrations, and scheduling policy; run this facade on a kernel blocking worker. sqlite = ["dep:rusqlite", "dep:tempfile"] text-similarity = ["dep:strsim"] +command-arguments = ["dep:shell-words"] +command-schema = ["dep:clap"] +# Owned, bounded application-error context without a third-party error carrier. +error-context = [] +config-toml = ["dep:toml"] +json = ["dep:serde", "dep:serde_json"] +source-cpp = ["dep:tree-sitter", "dep:tree-sitter-cpp"] terminal-style = [] # Native terminal capture and key decoding without PTY process spawning. terminal-input = [] @@ -83,6 +94,9 @@ daemon-registration-v2 = ["running-process/daemon-registration-v2"] pty = ["dep:portable-pty", "terminal-input"] event-stream = ["dep:futures-core", "dep:tokio-stream", "tokio-stream/sync"] http-server = ["dep:hyper", "hyper/server", "hyper/http1", "dep:hyper-util", "dep:http-body-util", "dep:bytes", "dep:futures-core"] +# HTTP/1 WebSocket upgrading remains part of the owned server surface: callers +# exchange facade messages and never name Hyper or a WebSocket implementation. +websocket = ["http-server", "dep:futures-util", "dep:tokio-tungstenite"] # Window-icon and stock-icon mechanics for the host console or a child. This # is GUI hosting: on Linux it decodes PNG and speaks the X11 client protocol, # so it is gated like every peer capability rather than sitting in the @@ -228,8 +242,14 @@ dirs = { version = "=6.0.0", optional = true } flate2 = { version = "=1.1.9", optional = true } framehop = { version = "=0.13.3", optional = true } futures-core = { version = "=0.3.34", optional = true } +futures-util = { version = "=0.3.34", default-features = false, features = ["sink"], optional = true } getrandom = { version = "=0.4.3", optional = true } strsim = { version = "=0.11.1", optional = true } +toml = { version = "=0.8.23", optional = true } +shell-words = { version = "=1.1.1", optional = true } +clap = { version = "=4.6.0", features = ["std", "string"], optional = true } +tree-sitter = { version = "=0.26.11", optional = true } +tree-sitter-cpp = { version = "=0.23.4", optional = true } globset = { version = "=0.4.18", optional = true } interprocess = { version = "=2.4.3", optional = true } jwalk = { version = "=0.8.1", optional = true } @@ -248,7 +268,7 @@ prost-types = { version = "=0.14.4", optional = true } # the exact set of supported hosts/filesystems this release covers. reflink-copy = { version = "=0.1.30", optional = true } serde = { version = "=1.0.229", features = ["derive"], optional = true } -serde_json = { version = "=1.0.151", optional = true } +serde_json = { version = "=1.0.151", optional = true, features = ["raw_value"] } sha2 = { version = "=0.10.9", optional = true } sysinfo = "=0.30.13" tar = { version = "=0.4.46", default-features = false, optional = true } @@ -268,6 +288,7 @@ tokio = { version = "=1.53.1", default-features = false, features = [ "time", ] } tokio-stream = { version = "=0.1.19", optional = true } +tokio-tungstenite = { version = "=0.28.0", default-features = false, features = ["handshake"], optional = true } tonic = { version = "=0.14.6", default-features = false, features = [ "codegen", "transport", @@ -393,6 +414,9 @@ toml = "=0.8.23" features = [ "full", "independent-spawn", + "config-toml", + "json", + "source-cpp", "daemon-identity", "daemon-frame-v1", "daemon-registration", diff --git a/ci/check_compilation_boundary_dependencies.py b/ci/check_compilation_boundary_dependencies.py index 7bbead56..a66cadd7 100644 --- a/ci/check_compilation_boundary_dependencies.py +++ b/ci/check_compilation_boundary_dependencies.py @@ -14,6 +14,10 @@ CASES = ( ("pty", "portable-pty"), ("text-similarity", "strsim"), + ("command-arguments", "shell-words"), + ("json", "serde_json"), + ("source-cpp", "tree-sitter"), + ("source-cpp", "tree-sitter-cpp"), ("wasm-sketch-host", "wasmtime"), ("ipc", "interprocess"), ("tokio-console", "console-subscriber"), @@ -49,7 +53,7 @@ } -def tree(features: str) -> set[str]: +def tree(features: str, *, normal_only: bool = False) -> set[str]: command = [ "soldr", "cargo", @@ -59,6 +63,8 @@ def tree(features: str) -> set[str]: "--prefix", "none", ] + if normal_only: + command.extend(("--edges", "normal")) if features: command.extend(("--features", features)) completed = subprocess.run(command, check=True, text=True, capture_output=True) @@ -72,6 +78,11 @@ def tree(features: str) -> set[str]: def main() -> int: default_graph = tree("") failures: list[str] = [] + # TOML already builds the kernel's catalog. Only its runtime edge is opt-in. + if "toml" in tree("", normal_only=True): + failures.append("default runtime graph unexpectedly contains toml") + if "toml" not in tree("config-toml", normal_only=True): + failures.append("config-toml runtime graph omits toml") for label, graph in (("default", default_graph), ("full", tree("full"))): unexpected = sorted(graph & SKETCH_AND_WEBVIEW_PACKAGES) if unexpected: diff --git a/docs/config-toml.md b/docs/config-toml.md new file mode 100644 index 00000000..7fe480f2 --- /dev/null +++ b/docs/config-toml.md @@ -0,0 +1,18 @@ +# Bounded TOML configuration + +Enable `config-toml` for `config::Document::parse_toml(&str)`. The document's +root is a kernel-owned `Value::Table`; children preserve strings, signed +integers, floating-point values, booleans, arrays, tables, and canonical TOML +date/time text. No parser types, Serde traits, filesystem reads, interpolation, +or application defaults are exposed. Comments and original formatting are not +retained. Applications match values and enforce their own field schemas. + +Source is limited to 1 MiB before parsing. The returned document is limited to +16,384 values (including containers and the root) and depth 32 (root depth 0). +Those two decoded limits are checked after backend parsing, not as independent +CPU or parser-allocation quotas. The private parser retains its default +recursion limit. Errors return no partial document and do not echo source text. + +The exact private `toml` 0.8.23 dependency is enabled on runtime edges only by +this feature. It already occurs as a build dependency, so whole dependency-graph +absence and build-speed gains are not claimed. CI checks runtime edges separately. diff --git a/docs/json.md b/docs/json.md new file mode 100644 index 00000000..cb438213 --- /dev/null +++ b/docs/json.md @@ -0,0 +1,55 @@ +# JSON documents + +Enable `json` for `kernal_api::json::{parse, encode, Value, Layout}`. The API +owns its values and errors; it exposes no Serde bounds, derives or backend +types. Applications retain schema validation, defaults, field names, unknown +field handling, HTTP status codes and trailing-newline policy. + +Parsing accepts one UTF-8 JSON value with optional surrounding whitespace. +Duplicate object keys retain the last value. Objects encode in sorted key +order, arrays retain order, and pretty output uses two-space indentation with +no trailing newline. Signed and unsigned 64-bit extrema are exact. Positive +integers use the signed variant when representable; larger integers outside +both integer ranges can round to finite f64. This is not arbitrary precision. +Nonfinite caller-provided floats are rejected instead of silently becoming null. + +Input is limited to 8 MiB before parsing. Decoded trees are limited to 262144 +values (including containers, excluding object keys) and depth 64, root zero. +Those traversal bounds apply after the private parser constructs its tree; +they are not independent parser memory or CPU quotas. The private parser's +own recursion limit remains enabled. + +Encoding validates count, depth and finite numbers before serialization, +borrows caller-owned values without constructing a second tree, and limits +output to 8 MiB including whitespace and escaping. Failures return no partial +document and diagnostics never echo source contents. Limits do not constrain +allocations performed by callers while constructing their values. + +JSON is distinct from TOML configuration: null, unsigned integers and JSON +output semantics should not alter the configuration contract. The private +JSON backend is also used by the existing Firefox profile exporter. + +## Duplicate-aware schema inspection + +`parse_members` is an opt-in alternative for typed application protocols that +must distinguish duplicate known fields from duplicate unknown fields. Every +object, including nested objects, becomes `Value::ObjectMembers`, a sequence +of decoded key/value pairs in source order. It does not decide which names are +schema fields and does not merge repeated keys. Escaped equivalent keys have +the same decoded spelling. Ordinary `parse` continues to return last-key-wins +map objects, so existing dynamic-object consumers are unchanged. + +The same byte, depth and node limits apply. Unlike ordinary map parsing, +member parsing checks node/depth bounds during decoding and counts all repeated +member values, rather than only values surviving a merge. It does not build a +second tree or a separate duplicate index. Encoding a member object preserves +all members and their order; it does not promise to preserve source whitespace +or number spellings. Caller-constructed member objects receive the same +encoding validation and output limits as map objects. + +Private borrowed raw-value slices distinguish actual objects from the synthetic +number dispatch enabled by dependency feature unification. Numeric scalars stay +numeric with `serde_json/arbitrary_precision`; user object keys cannot impersonate +that dispatch. Raw syntax validation precedes member decoding and can revisit +nested slices, with work constrained by input size and accepted depth rather +than a separate CPU quota. No raw backend type crosses the public API. diff --git a/docs/source-cpp.md b/docs/source-cpp.md new file mode 100644 index 00000000..4a159358 --- /dev/null +++ b/docs/source-cpp.md @@ -0,0 +1,32 @@ +# C++ function analysis + +The optional `source-cpp` feature privately owns an exact-pinned C++ grammar and +parser. `source::analyze_cpp` returns owned `FunctionDefinition` records in source +order, with byte ranges and namespace/aggregate/explicit-linkage context flags. +It does not expose parser nodes, trees, grammar symbols, or backend traits. + +The implementation retains provenance from FastLED/fbuild's source scanner at +`1e75ccf5a4ca922b4d922a6da286b965fac8832d`, via the FastLED WASM adapter. It uses +syntax nodes to remove function parameter defaults, rather than splitting on +commas or guessing expression nesting. Headers retain their source formatting, +including newlines terminating line comments. Do not flatten or trim those +newlines before appending a declaration terminator. +They are not guaranteed to be standalone declarations: this is syntax analysis, +not a C++ compiler, preprocessor, or name-resolution service. + +Applications own record selection, deduplication, Arduino `setup`/`loop` policy, +tab order, generated includes, source maps, and editor publication. Definitions +inside another function body are not inventoried. Invalid or incomplete syntax +returns an error without partial records, allowing an editor to keep its last +good product output. + +The source limit is 8 MiB, checked before parsing. The parser observes a +two-second cooperative deadline; this is not process containment or a hard CPU +deadline. Subsequent iterative traversal is limited to 131,072 node visits, +depth 256, and 16,384 output records. Signature traversal shares the visit budget. +These are traversal/output bounds, not independent parser allocation quotas. +Diagnostics do not contain source contents. No filesystem, runtime, or ambient +process state is created by analysis. + +Issue #209 tracks contract expansion, platform verification, and FastLED +adoption. The initial implementation is not yet release-accepted. diff --git a/pyproject.toml b/pyproject.toml index f2cc3448..48515b56 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "kernal-api" -version = "0.1.1" +version = "0.1.2" description = "Async OS HAL, profiling, symbolization, and allocator instrumentation" readme = "README.md" requires-python = ">=3.10" diff --git a/python/kernal_api/__init__.py b/python/kernal_api/__init__.py index 351f6aa2..bd6b2bc5 100644 --- a/python/kernal_api/__init__.py +++ b/python/kernal_api/__init__.py @@ -11,7 +11,7 @@ import sys from dataclasses import dataclass -__version__ = "0.1.1" +__version__ = "0.1.2" RUST_MSRV = "1.95.0" SUPPORTED_SYSTEMS = frozenset({"Linux", "Darwin", "Windows"}) SUPPORTED_MACHINES = frozenset({"x86_64", "amd64", "aarch64", "arm64"}) diff --git a/python/tests/test_compatibility.py b/python/tests/test_compatibility.py index f0e122c0..beb55362 100644 --- a/python/tests/test_compatibility.py +++ b/python/tests/test_compatibility.py @@ -4,7 +4,7 @@ def test_python_and_rust_versions_are_explicit() -> None: - assert kernal_api.__version__ == "0.1.1" + assert kernal_api.__version__ == "0.1.2" assert kernal_api.RUST_MSRV == "1.95.0" diff --git a/src/arguments.rs b/src/arguments.rs new file mode 100644 index 00000000..60e6cd85 --- /dev/null +++ b/src/arguments.rs @@ -0,0 +1,44 @@ +//! Bounded argument decoding for tool output, not shell execution. + +/// Maximum UTF-8 input size, checked before parsing or allocation. +pub const MAX_POSIX_INPUT_BYTES: usize = 1024 * 1024; +/// Maximum number of decoded arguments returned to a caller. +pub const MAX_POSIX_ARGUMENTS: usize = 16_384; + +/// Semantic failures independent of the private parsing implementation. +#[derive(Clone, Copy, Debug, Eq, PartialEq, thiserror::Error)] +pub enum ArgumentParseError { + #[error("argument source exceeds 1048576 UTF-8 bytes")] + InputTooLarge, + #[error("argument source contains a NUL character")] + ContainsNul, + #[error("missing closing quote")] + UnterminatedQuote, + #[error("argument source exceeds 16384 decoded arguments")] + TooManyArguments, +} + +/// Decode POSIX-style quoting, escapes, continuations and comments into words. +/// +/// No shell is started and no variable, tilde, glob, arithmetic or command +/// expansion occurs. Operators are literal text rather than shell grammar. +/// This is not Windows command-line decoding. Empty quoted words are retained; +/// empty or comment-only input returns an empty vector for caller policy. +/// +/// Input is limited to [`MAX_POSIX_INPUT_BYTES`] before backend allocation. +/// This also bounds intermediate storage when [`MAX_POSIX_ARGUMENTS`] is +/// exceeded: the word-count check occurs after parsing. Errors never return a +/// truncated list. NUL is rejected because process arguments cannot contain it. +pub fn parse_posix(source: &str) -> Result, ArgumentParseError> { + if source.len() > MAX_POSIX_INPUT_BYTES { + return Err(ArgumentParseError::InputTooLarge); + } + if source.contains('\0') { + return Err(ArgumentParseError::ContainsNul); + } + let words = shell_words::split(source).map_err(|_| ArgumentParseError::UnterminatedQuote)?; + if words.len() > MAX_POSIX_ARGUMENTS { + return Err(ArgumentParseError::TooManyArguments); + } + Ok(words) +} diff --git a/src/bin/kernal-tauri-smoke.rs b/src/bin/kernal-tauri-smoke.rs index dbceefc5..dc0fcde9 100644 --- a/src/bin/kernal-tauri-smoke.rs +++ b/src/bin/kernal-tauri-smoke.rs @@ -146,7 +146,9 @@ async fn lifecycle( if scenario == SmokeScenario::Bootstrap { let window = WebviewWindowOptions::new("kernal-api bootstrap proof", 800, 600) .map_err(|error| WebviewError::HostFailure(error.to_string()))?; - let bootstrap = WebviewPageBootstrap::new("window.__kernal_bootstrap = 17;") + let bootstrap = WebviewPageBootstrap::new( + "if (Object.isFrozen(kernalWindow) && Number.isFinite(kernalWindow.initialScaleFactor) && kernalWindow.initialScaleFactor > 0) { window.__kernal_bootstrap = 17; }", + ) .map_err(|error| WebviewError::HostFailure(error.to_string()))?; let outcome = match client .open_webview_with_bootstrap(url, window, WebviewPermissions::deny_all(), bootstrap) @@ -199,7 +201,7 @@ async fn lifecycle( } (SmokeScenario::Cancel, Ok(())) => { webview.cancel(); - if webview.wait_until_terminal(Duration::ZERO).await != Err(WebviewError::Cancelled) { + if webview.wait_for_terminal().await != Err(WebviewError::Cancelled) { return Err(WebviewError::HostFailure( "cancellation did not publish its typed terminal outcome".into(), )); @@ -208,8 +210,50 @@ async fn lifecycle( assert_clean(client) } (SmokeScenario::WindowClose, Ok(())) => { + for timed in [false, true] { + let pending = async { + if timed { + webview.wait_until_terminal(Duration::from_secs(30)).await + } else { + webview.wait_for_terminal().await + } + }; + let mut pending = std::pin::pin!(pending); + if async_engine::timeout(Duration::from_millis(20), &mut pending) + .await + .is_ok() + { + return Err(WebviewError::HostFailure( + "interactive wait ended before window closure".into(), + )); + } + if async_engine::timeout(Duration::from_secs(1), webview.wait_for_terminal()) + .await + .map_err(|_| WebviewError::TimedOut)? + != Err(WebviewError::TerminalWaitInProgress) + { + return Err(WebviewError::HostFailure( + "overlapping terminal wait was not rejected".into(), + )); + } + if webview.wait_until_terminal(Duration::ZERO).await + != Err(WebviewError::TerminalWaitInProgress) + { + return Err(WebviewError::HostFailure( + "overlapping timed wait was not rejected".into(), + )); + } + } + let observation = client.test_observation(); + if observation.native_backings != 1 || observation.live_resources != 1 { + return Err(WebviewError::HostFailure(format!( + "cancelled wait revoked its window: {observation:?}" + ))); + } webview.request_window_close_for_test()?; - if webview.wait_until_terminal(Duration::from_secs(5)).await + if async_engine::timeout(Duration::from_secs(5), webview.wait_for_terminal()) + .await + .map_err(|_| WebviewError::TimedOut)? != Err(WebviewError::WindowClosed) { return Err(WebviewError::HostFailure( diff --git a/src/command.rs b/src/command.rs new file mode 100644 index 00000000..45409e5e --- /dev/null +++ b/src/command.rs @@ -0,0 +1,819 @@ +//! Bounded declarative command parsing. Applications own their command names, +//! field mapping, and effect policy; Clap is a private implementation detail. + +use std::collections::BTreeMap; +use std::ffi::OsStr; +use std::fmt::Write as _; + +/// Maximum number of command-line words accepted by [`Command::parse`]. +pub const MAX_ARGUMENTS: usize = 1_024; +/// Maximum UTF-8 bytes accepted for one command-line word. +pub const MAX_ARGUMENT_BYTES: usize = 64 * 1024; +/// Maximum total UTF-8 bytes accepted for all command-line words. +pub const MAX_TOTAL_ARGUMENT_BYTES: usize = 1024 * 1024; + +/// A facade-owned scalar validation rule. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum ValueKind { + /// Any non-NUL UTF-8 string within the input limits. + String, + /// An operating-system string, retained without UTF-8 conversion. + OsString, + /// One of the declared strings. + Enumeration(Vec), + /// A finite IEEE-754 double accepted by Rust's `f64` parser. + F64, + /// A non-negative 32-bit integer. + U32, +} + +impl ValueKind { + /// Accept an arbitrary string value. + pub const fn string() -> Self { + Self::String + } + + /// Accept a non-NUL operating-system string within the input limits. + pub const fn os_string() -> Self { + Self::OsString + } + + /// Accept exactly one of `values`. + pub fn enumeration(values: I) -> Self + where + I: IntoIterator, + S: Into, + { + Self::Enumeration(values.into_iter().map(Into::into).collect()) + } + + /// Accept a Rust `f64` value. + pub const fn f64() -> Self { + Self::F64 + } + + /// Accept a Rust `u32` value. + pub const fn u32() -> Self { + Self::U32 + } +} + +/// One long option in a [`Command`] schema. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct OptionSpec { + name: String, + kind: Option, + default: Option, + default_missing: Option, + repeated: bool, + conflicts: Vec, + requires_any: Vec, + help: Option, + hidden: bool, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +struct PositionalSpec { + name: String, + kind: ValueKind, + optional: bool, +} + +impl OptionSpec { + /// Declare a boolean `--name` flag. + pub fn flag(name: impl Into) -> Self { + Self { + name: name.into(), + kind: None, + default: None, + default_missing: None, + repeated: false, + conflicts: Vec::new(), + requires_any: Vec::new(), + help: None, + hidden: false, + } + } + + /// Declare a string-valued `--name VALUE` option. + pub fn value(name: impl Into, kind: ValueKind) -> Self { + Self { + name: name.into(), + kind: Some(kind), + default: None, + default_missing: None, + repeated: false, + conflicts: Vec::new(), + requires_any: Vec::new(), + help: None, + hidden: false, + } + } + + /// Supply a value used when this option is absent. + pub fn default(mut self, value: impl Into) -> Self { + self.default = Some(value.into()); + self + } + + /// Allow this value option without a value and use `value` in that case. + pub fn optional_value(mut self, value: impl Into) -> Self { + self.default_missing = Some(value.into()); + self + } + + /// Preserve every occurrence of this value option in declaration order. + pub fn repeated(mut self) -> Self { + self.repeated = true; + self + } + + /// Reject this option when `other` is also present. + pub fn conflicts(mut self, other: impl Into) -> Self { + self.conflicts.push(other.into()); + self + } + + /// Require one of these option names whenever this option is present. + pub fn requires_any(mut self, names: I) -> Self + where + I: IntoIterator, + S: Into, + { + self.requires_any.extend(names.into_iter().map(Into::into)); + self + } + + /// Describe this option in facade-rendered help. + pub fn help(mut self, text: impl Into) -> Self { + self.help = Some(text.into()); + self + } + + /// Keep this option parseable but omit it from facade-rendered help. + pub fn hidden(mut self) -> Self { + self.hidden = true; + self + } +} + +/// A declarative command and its nested subcommands. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Command { + name: String, + about: Option, + version: Option, + options: Vec, + positionals: Vec, + subcommands: Vec, + exclusive_groups: Vec<(String, Vec)>, +} + +impl Command { + /// Start a command schema. + pub fn new(name: impl Into) -> Self { + Self { + name: name.into(), + about: None, + version: None, + options: Vec::new(), + positionals: Vec::new(), + subcommands: Vec::new(), + exclusive_groups: Vec::new(), + } + } + + /// Add a long option. + pub fn option(mut self, option: OptionSpec) -> Self { + self.options.push(option); + self + } + + /// Describe this command in facade-rendered help. + pub fn about(mut self, text: impl Into) -> Self { + self.about = Some(text.into()); + self + } + + /// Set the version rendered by [`Command::render_version`]. + pub fn version(mut self, value: impl Into) -> Self { + self.version = Some(value.into()); + self + } + + /// Render deterministic, backend-independent version text. + pub fn render_version(&self) -> String { + match &self.version { + Some(version) => format!("{} {version}\n", self.name), + None => format!("{}\n", self.name), + } + } + + /// Render deterministic, backend-independent help for this command. + pub fn render_help(&self) -> String { + let mut output = format!("Usage: {}", self.name); + if !self.options.is_empty() { + output.push_str(" [OPTIONS]"); + } + if !self.positionals.is_empty() { + for positional in &self.positionals { + let token = format!("<{}>", positional.name); + if positional.optional { + let _ = write!(output, " [{token}]"); + } else { + let _ = write!(output, " {token}"); + } + } + } + if !self.subcommands.is_empty() { + output.push_str(" [COMMAND]"); + } + output.push('\n'); + if let Some(about) = &self.about { + let _ = write!(output, "\n{about}\n"); + } + if !self.options.is_empty() { + output.push_str("\nOptions:\n"); + for option in &self.options { + if option.hidden { + continue; + } + let suffix = match &option.kind { + None => String::new(), + Some(_) if option.default_missing.is_some() => " [VALUE]".to_owned(), + Some(_) => " ".to_owned(), + }; + let repeat = if option.repeated { "..." } else { "" }; + let help = option.help.as_deref().unwrap_or(""); + let _ = writeln!(output, " --{}{suffix}{repeat}\t{help}", option.name); + } + } + if !self.subcommands.is_empty() { + output.push_str("\nCommands:\n"); + for command in &self.subcommands { + let about = command.about.as_deref().unwrap_or(""); + let _ = writeln!(output, " {}\t{about}", command.name); + } + } + output + } + + /// Add a required positional value in declaration order. + pub fn positional(mut self, name: impl Into, kind: ValueKind) -> Self { + self.positionals.push(PositionalSpec { + name: name.into(), + kind, + optional: false, + }); + self + } + + /// Add an optional positional value in declaration order. + pub fn optional_positional(mut self, name: impl Into, kind: ValueKind) -> Self { + self.positionals.push(PositionalSpec { + name: name.into(), + kind, + optional: true, + }); + self + } + + /// Add a nested subcommand. + pub fn subcommand(mut self, command: Self) -> Self { + self.subcommands.push(command); + self + } + + /// Require at most one named option from this group. + pub fn exclusive_group(mut self, name: impl Into, options: I) -> Self + where + I: IntoIterator, + S: Into, + { + self.exclusive_groups + .push((name.into(), options.into_iter().map(Into::into).collect())); + self + } + + /// Parse one bounded command line into facade-owned values. + /// + /// The private parser never runs a shell. Input limits are checked before + /// it receives any owned words; errors deliberately do not echo input. + pub fn parse(&self, arguments: I) -> Result + where + I: IntoIterator, + S: AsRef, + { + let mut total = 0usize; + let mut words = Vec::new(); + for argument in arguments { + if words.len() == MAX_ARGUMENTS { + return Err(CommandError::TooManyArguments); + } + let value = argument.as_ref(); + let bytes = value.as_encoded_bytes(); + if bytes.contains(&b'\0') { + return Err(CommandError::ContainsNul); + } + if bytes.len() > MAX_ARGUMENT_BYTES { + return Err(CommandError::ArgumentTooLarge); + } + total = total + .checked_add(bytes.len()) + .ok_or(CommandError::InputTooLarge)?; + if total > MAX_TOTAL_ARGUMENT_BYTES { + return Err(CommandError::InputTooLarge); + } + words.push(value.to_os_string()); + } + let explicit_options = explicit_option_names(&words)?; + self.validate()?; + let command = self.clap_command(); + let matches = command + .try_get_matches_from(words) + .map_err(|_| CommandError::InvalidArguments)?; + let mut path = vec![self.name.clone()]; + let mut schema = self; + let mut selected = vec![(self, &matches)]; + let mut final_matches = &matches; + while let Some((name, next)) = final_matches.subcommand() { + let Some(next_schema) = schema.subcommands.iter().find(|child| child.name == name) + else { + return Err(CommandError::InvalidArguments); + }; + path.push(name.to_owned()); + schema = next_schema; + final_matches = next; + selected.push((schema, final_matches)); + } + let mut values = BTreeMap::new(); + for (command, command_matches) in &selected { + command.collect_values(command_matches, &mut values)?; + } + let selected_schemas = selected + .iter() + .map(|(command, _)| *command) + .collect::>(); + Self::validate_selected_relations(&selected_schemas, &explicit_options)?; + Ok(ParsedCommand { path, values }) + } + + fn clap_command(&self) -> clap::Command { + let mut command = clap::Command::new(self.name.clone()) + .disable_help_flag(false) + .disable_version_flag(true) + .disable_help_subcommand(true) + .subcommand_precedence_over_arg(true); + for option in &self.options { + let mut argument = clap::Arg::new(option.name.clone()) + .long(option.name.clone()) + .global(true); + match &option.kind { + None => argument = argument.action(clap::ArgAction::SetTrue), + Some(ValueKind::String) => argument = argument.action(clap::ArgAction::Set), + Some(ValueKind::OsString) => { + argument = argument + .action(clap::ArgAction::Set) + .value_parser(clap::value_parser!(std::ffi::OsString)); + } + Some(ValueKind::Enumeration(values)) => { + argument = argument + .action(clap::ArgAction::Set) + .value_parser(values.clone()); + } + Some(ValueKind::F64) => { + argument = argument + .action(clap::ArgAction::Set) + .value_parser(clap::value_parser!(f64)); + } + Some(ValueKind::U32) => { + argument = argument + .action(clap::ArgAction::Set) + .value_parser(clap::value_parser!(u32)); + } + } + if let Some(default) = &option.default { + argument = argument.default_value(default); + } + if let Some(default_missing) = &option.default_missing { + argument = argument + .num_args(0..=1) + .default_missing_value(default_missing); + } + if option.repeated { + argument = argument.action(clap::ArgAction::Append); + } + command = command.arg(argument); + } + for (index, positional) in self.positionals.iter().enumerate() { + let mut argument = clap::Arg::new(positional.name.clone()) + .index(index + 1) + .required(!positional.optional) + .action(clap::ArgAction::Set); + match &positional.kind { + ValueKind::String => {} + ValueKind::OsString => { + argument = argument.value_parser(clap::value_parser!(std::ffi::OsString)); + } + ValueKind::Enumeration(values) => argument = argument.value_parser(values.clone()), + ValueKind::F64 => argument = argument.value_parser(clap::value_parser!(f64)), + ValueKind::U32 => argument = argument.value_parser(clap::value_parser!(u32)), + } + command = command.arg(argument); + } + for child in &self.subcommands { + command = command.subcommand(child.clap_command()); + } + command + } + + fn validate(&self) -> Result<(), CommandError> { + let mut option_names = std::collections::BTreeSet::new(); + self.validate_into(&mut option_names)?; + self.validate_references(&option_names) + } + + fn validate_into( + &self, + option_names: &mut std::collections::BTreeSet, + ) -> Result<(), CommandError> { + if !valid_name(&self.name) || self.name == "help" { + return Err(CommandError::InvalidSchema); + } + let mut child_names = std::collections::BTreeSet::new(); + for option in &self.options { + if !valid_name(&option.name) + || option.name == "help" + || !option_names.insert(option.name.clone()) + { + return Err(CommandError::InvalidSchema); + } + match (&option.kind, &option.default, &option.default_missing) { + (None, Some(_), _) | (None, _, Some(_)) => return Err(CommandError::InvalidSchema), + (None, _, _) if option.repeated => return Err(CommandError::InvalidSchema), + (Some(_), Some(_), Some(_)) | (Some(_), Some(_), _) if option.repeated => { + return Err(CommandError::InvalidSchema) + } + (Some(ValueKind::Enumeration(values)), default, default_missing) + if values.is_empty() + || values.iter().any(|value| value.contains('\0')) + || default + .as_ref() + .is_some_and(|value| !values.contains(value)) + || default_missing + .as_ref() + .is_some_and(|value| !values.contains(value)) => + { + return Err(CommandError::InvalidSchema); + } + (_, Some(value), _) if value.contains('\0') => { + return Err(CommandError::InvalidSchema) + } + (_, _, Some(value)) if value.contains('\0') => { + return Err(CommandError::InvalidSchema) + } + _ => {} + } + if let Some(kind) = &option.kind { + if (option.repeated && !matches!(kind, ValueKind::String | ValueKind::OsString)) + || option + .default + .as_ref() + .is_some_and(|value| !valid_value(kind, value)) + || option + .default_missing + .as_ref() + .is_some_and(|value| !valid_value(kind, value)) + { + return Err(CommandError::InvalidSchema); + } + } + } + let mut optional_positional_seen = false; + for positional in &self.positionals { + if !valid_name(&positional.name) + || positional.name == "help" + || !option_names.insert(positional.name.clone()) + || (optional_positional_seen && !positional.optional) + || matches!(&positional.kind, ValueKind::Enumeration(values) if values.is_empty() || values.iter().any(|value| value.contains('\0'))) + { + return Err(CommandError::InvalidSchema); + } + optional_positional_seen |= positional.optional; + } + for child in &self.subcommands { + if child.name == "help" || !child_names.insert(child.name.clone()) { + return Err(CommandError::InvalidSchema); + } + child.validate_into(option_names)?; + } + Ok(()) + } + + fn validate_references( + &self, + option_names: &std::collections::BTreeSet, + ) -> Result<(), CommandError> { + for option in &self.options { + if option + .conflicts + .iter() + .chain(&option.requires_any) + .any(|name| !option_names.contains(name)) + { + return Err(CommandError::InvalidSchema); + } + } + let mut group_names = std::collections::BTreeSet::new(); + for (name, options) in &self.exclusive_groups { + if !valid_name(name) + || !group_names.insert(name) + || options.len() < 2 + || options.iter().any(|option| !option_names.contains(option)) + { + return Err(CommandError::InvalidSchema); + } + } + for child in &self.subcommands { + child.validate_references(option_names)?; + } + Ok(()) + } + + fn collect_values( + &self, + matches: &clap::ArgMatches, + values: &mut BTreeMap, + ) -> Result<(), CommandError> { + for option in &self.options { + let value = match option.kind { + None => ParsedValue::Flag( + matches + .try_get_one::(&option.name) + .map_err(|_| CommandError::InvalidArguments)? + .copied() + .unwrap_or(false), + ), + Some(ValueKind::String) if option.repeated => matches + .try_get_many::(&option.name) + .map_err(|_| CommandError::InvalidArguments)? + .map(|items| ParsedValue::Strings(items.cloned().collect())) + .unwrap_or(ParsedValue::Absent), + Some(ValueKind::String) | Some(ValueKind::Enumeration(_)) => matches + .try_get_one::(&option.name) + .map_err(|_| CommandError::InvalidArguments)? + .cloned() + .map(ParsedValue::String) + .unwrap_or(ParsedValue::Absent), + Some(ValueKind::OsString) if option.repeated => matches + .try_get_many::(&option.name) + .map_err(|_| CommandError::InvalidArguments)? + .map(|items| ParsedValue::OsStrings(items.cloned().collect())) + .unwrap_or(ParsedValue::Absent), + Some(ValueKind::OsString) => matches + .try_get_one::(&option.name) + .map_err(|_| CommandError::InvalidArguments)? + .cloned() + .map(ParsedValue::OsString) + .unwrap_or(ParsedValue::Absent), + Some(ValueKind::F64) => match matches + .try_get_one::(&option.name) + .map_err(|_| CommandError::InvalidArguments)? + .copied() + { + Some(value) if value.is_finite() => ParsedValue::F64(value), + Some(_) => return Err(CommandError::InvalidArguments), + None => ParsedValue::Absent, + }, + Some(ValueKind::U32) => matches + .try_get_one::(&option.name) + .map_err(|_| CommandError::InvalidArguments)? + .copied() + .map(ParsedValue::U32) + .unwrap_or(ParsedValue::Absent), + }; + values.insert(option.name.clone(), value); + } + for positional in &self.positionals { + let value = match &positional.kind { + ValueKind::String | ValueKind::Enumeration(_) => matches + .try_get_one::(&positional.name) + .map_err(|_| CommandError::InvalidArguments)? + .cloned() + .map(ParsedValue::String) + .unwrap_or(ParsedValue::Absent), + ValueKind::OsString => matches + .try_get_one::(&positional.name) + .map_err(|_| CommandError::InvalidArguments)? + .cloned() + .map(ParsedValue::OsString) + .unwrap_or(ParsedValue::Absent), + ValueKind::F64 => match matches + .try_get_one::(&positional.name) + .map_err(|_| CommandError::InvalidArguments)? + .copied() + { + Some(value) if value.is_finite() => ParsedValue::F64(value), + Some(_) => return Err(CommandError::InvalidArguments), + None => ParsedValue::Absent, + }, + ValueKind::U32 => matches + .try_get_one::(&positional.name) + .map_err(|_| CommandError::InvalidArguments)? + .copied() + .map(ParsedValue::U32) + .unwrap_or(ParsedValue::Absent), + }; + values.insert(positional.name.clone(), value); + } + Ok(()) + } + + fn validate_selected_relations( + selected: &[&Self], + explicit_options: &std::collections::BTreeSet, + ) -> Result<(), CommandError> { + for command in selected { + for option in &command.options { + if explicit_options.contains(&option.name) + && option + .conflicts + .iter() + .any(|name| explicit_options.contains(name)) + { + return Err(CommandError::InvalidArguments); + } + if explicit_options.contains(&option.name) + && !option.requires_any.is_empty() + && !option + .requires_any + .iter() + .any(|name| explicit_options.contains(name)) + { + return Err(CommandError::InvalidArguments); + } + } + for (_, options) in &command.exclusive_groups { + if options + .iter() + .filter(|name| explicit_options.contains(*name)) + .take(2) + .count() + > 1 + { + return Err(CommandError::InvalidArguments); + } + } + } + Ok(()) + } +} + +fn explicit_option_names( + words: &[std::ffi::OsString], +) -> Result, CommandError> { + let mut names = std::collections::BTreeSet::new(); + let mut options_enabled = true; + for word in words.iter().skip(1) { + let bytes = word.as_encoded_bytes(); + if options_enabled && bytes == b"--" { + options_enabled = false; + } else if options_enabled { + if let Some(name) = bytes.strip_prefix(b"--") { + let name = name.split(|byte| *byte == b'=').next().unwrap_or_default(); + if let Ok(name) = std::str::from_utf8(name) { + names.insert(name.to_owned()); + } + } + } + } + Ok(names) +} + +fn valid_name(name: &str) -> bool { + !name.is_empty() + && !name.starts_with('-') + && name + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_')) +} + +fn valid_value(kind: &ValueKind, value: &str) -> bool { + match kind { + ValueKind::String => !value.contains('\0'), + ValueKind::OsString => !value.contains('\0'), + ValueKind::Enumeration(values) => values.iter().any(|candidate| candidate == value), + ValueKind::F64 => value.parse::().is_ok_and(f64::is_finite), + ValueKind::U32 => value.parse::().is_ok(), + } +} + +/// Parsed scalar values independent of the private parser backend. +#[derive(Clone, Debug, PartialEq)] +pub enum ParsedValue { + /// A declared flag. + Flag(bool), + /// A declared value option. + String(String), + /// A declared lossless operating-system-string option or positional. + OsString(std::ffi::OsString), + /// A repeated value option, in command-line order. + Strings(Vec), + /// A repeated declared lossless operating-system-string option. + OsStrings(Vec), + /// A declared `f64` option or positional. + F64(f64), + /// A declared `u32` option or positional. + U32(u32), + /// A declared value option was absent and has no default. + Absent, +} + +/// A facade-owned parsed command line. +#[derive(Clone, Debug, PartialEq)] +pub struct ParsedCommand { + path: Vec, + values: BTreeMap, +} + +impl ParsedCommand { + /// Selected command names including the root. + pub fn command_path(&self) -> &[String] { + &self.path + } + + /// Read a declared boolean flag. + pub fn flag(&self, name: &str) -> Option { + match self.values.get(name) { + Some(ParsedValue::Flag(value)) => Some(*value), + _ => None, + } + } + + /// Read a declared string option or its default. + pub fn value(&self, name: &str) -> Option<&str> { + match self.values.get(name) { + Some(ParsedValue::String(value)) => Some(value), + _ => None, + } + } + + /// Read all values of a repeated option. + pub fn values(&self, name: &str) -> Option<&[String]> { + match self.values.get(name) { + Some(ParsedValue::Strings(values)) => Some(values), + _ => None, + } + } + + /// Read a declared operating-system-string option or positional. + pub fn os_value(&self, name: &str) -> Option<&OsStr> { + match self.values.get(name) { + Some(ParsedValue::OsString(value)) => Some(value.as_os_str()), + _ => None, + } + } + + /// Read all values of a repeated declared operating-system-string option. + pub fn os_values(&self, name: &str) -> Option<&[std::ffi::OsString]> { + match self.values.get(name) { + Some(ParsedValue::OsStrings(values)) => Some(values), + _ => None, + } + } + + /// Read a declared `f64` option or positional. + pub fn f64(&self, name: &str) -> Option { + match self.values.get(name) { + Some(ParsedValue::F64(value)) => Some(*value), + _ => None, + } + } + + /// Read a declared `u32` option or positional. + pub fn u32(&self, name: &str) -> Option { + match self.values.get(name) { + Some(ParsedValue::U32(value)) => Some(*value), + _ => None, + } + } +} + +/// Parsing or schema failures without backend diagnostics or input echo. +#[derive(Clone, Copy, Debug, Eq, PartialEq, thiserror::Error)] +pub enum CommandError { + #[error("command schema is invalid")] + InvalidSchema, + #[error("command line contains a NUL character")] + ContainsNul, + #[error("command line contains a non-UTF-8 argument")] + InvalidUtf8, + #[error("command line has too many arguments")] + TooManyArguments, + #[error("command-line argument exceeds byte limit")] + ArgumentTooLarge, + #[error("command line exceeds byte limit")] + InputTooLarge, + #[error("invalid command-line arguments")] + InvalidArguments, +} diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 00000000..8472866f --- /dev/null +++ b/src/config.rs @@ -0,0 +1,95 @@ +//! Configuration mechanics only; applications own field validation and defaults. + +use std::collections::BTreeMap; + +/// Maximum UTF-8 source bytes, checked before invoking the parser. +pub const MAX_INPUT_BYTES: usize = 1024 * 1024; +/// Maximum decoded values, counting the root table and every container. +pub const MAX_NODES: usize = 16_384; +/// Maximum value depth, with the root table at depth zero. +pub const MAX_DEPTH: usize = 32; + +/// Semantic configuration values independent of the private TOML parser. +/// Table keys are ordered; source formatting and comments are not retained. +#[derive(Clone, Debug, PartialEq)] +pub enum Value { + String(String), + Integer(i64), + Float(f64), + Boolean(bool), + /// Canonical TOML date/time spelling; no timezone conversion is performed. + DateTime(String), + Array(Vec), + Table(BTreeMap), +} + +/// A parsed configuration. Construction enforces bounds before it is returned. +#[derive(Clone, Debug, PartialEq)] +pub struct Document { + root: Value, +} + +/// Bounded diagnostics that never echo configuration contents or secrets. +#[derive(Clone, Copy, Debug, Eq, PartialEq, thiserror::Error)] +pub enum ParseError { + #[error("configuration exceeds 1048576 UTF-8 source bytes")] + InputTooLarge, + #[error("invalid TOML document")] + InvalidSyntax, + #[error("configuration exceeds 16384 decoded values")] + TooManyNodes, + #[error("configuration exceeds value depth 32")] + TooDeep, +} + +impl Document { + /// Decode a TOML document without filesystem access or interpolation. + /// + /// The source byte bound applies before parsing. Node/depth bounds apply + /// after the private parser constructs its tree, during conversion to owned + /// semantic values. They are not independent parser CPU or allocation quotas; + /// source size and the parser's own recursion limit bound that earlier stage. + /// No partial document is returned on error. Unknown fields remain values + /// for application policy; empty input is a valid empty table. + pub fn parse_toml(source: &str) -> Result { + if source.len() > MAX_INPUT_BYTES { + return Err(ParseError::InputTooLarge); + } + let table = toml::from_str::(source).map_err(|_| ParseError::InvalidSyntax)?; + let mut remaining = MAX_NODES; + Ok(Self { + root: convert(toml::Value::Table(table), 0, &mut remaining)?, + }) + } + + /// The root of a TOML document is always a table. + pub fn root(&self) -> &Value { + &self.root + } +} + +fn convert(value: toml::Value, depth: usize, remaining: &mut usize) -> Result { + if depth > MAX_DEPTH { + return Err(ParseError::TooDeep); + } + *remaining = remaining.checked_sub(1).ok_or(ParseError::TooManyNodes)?; + Ok(match value { + toml::Value::String(value) => Value::String(value), + toml::Value::Integer(value) => Value::Integer(value), + toml::Value::Float(value) => Value::Float(value), + toml::Value::Boolean(value) => Value::Boolean(value), + toml::Value::Datetime(value) => Value::DateTime(value.to_string()), + toml::Value::Array(values) => Value::Array( + values + .into_iter() + .map(|value| convert(value, depth + 1, remaining)) + .collect::>()?, + ), + toml::Value::Table(values) => Value::Table( + values + .into_iter() + .map(|(key, value)| Ok((key, convert(value, depth + 1, remaining)?))) + .collect::>()?, + ), + }) +} diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 00000000..dfc1b874 --- /dev/null +++ b/src/error.rs @@ -0,0 +1,151 @@ +//! Owned application error context with bounded display text. + +use std::error::Error as StdError; +use std::fmt::{self, Display, Formatter, Write as _}; + +/// Maximum UTF-8 bytes retained for one error context message. +pub const MAX_CONTEXT_BYTES: usize = 8 * 1024; + +/// Context wrapper used by [`Context`] to retain an underlying error. +#[derive(Debug)] +struct ContextError { + message: String, + source: Error, +} + +impl ContextError { + fn new(message: impl Display, source: E) -> Self + where + E: Into, + { + Self { + message: bounded(message), + source: source.into(), + } + } +} + +impl Display for ContextError { + fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result { + formatter.write_str(&self.message)?; + if formatter.alternate() { + let mut source: Option<&(dyn StdError + 'static)> = Some(self.source.as_ref()); + while let Some(error) = source { + write!(formatter, ": {error}")?; + source = error.source(); + } + } + Ok(()) + } +} + +impl StdError for ContextError { + fn source(&self) -> Option<&(dyn StdError + 'static)> { + Some(self.source.as_ref()) + } +} + +/// Facade-owned application error type. Its standard conversion support lets +/// callers use `?` with any thread-safe standard error. +pub type Error = Box; + +/// Construct a message-only error, truncating excessively long text. +pub fn message(message: impl Display) -> Error { + Box::new(MessageError(bounded(message))) +} + +#[derive(Debug)] +struct MessageError(String); + +impl Display for MessageError { + fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result { + formatter.write_str(&self.0) + } +} + +impl StdError for MessageError {} + +/// Facade-owned application result alias. The optional second parameter keeps +/// ordinary `Result` signatures source-compatible during migration. +pub type Result = std::result::Result; + +/// Add bounded context to a standard fallible result. +pub trait Context { + /// Attach a context message only if the result is an error. + fn context(self, message: impl Display) -> Result; + /// Lazily attach a context message only if the result is an error. + fn with_context(self, message: F) -> Result + where + F: FnOnce() -> M, + M: Display; +} + +impl Context for std::result::Result +where + E: Into, +{ + fn context(self, message: impl Display) -> Result { + self.map_err(|source| Box::new(ContextError::new(message, source)) as Error) + } + + fn with_context(self, message: F) -> Result + where + F: FnOnce() -> M, + M: Display, + { + self.map_err(|source| Box::new(ContextError::new(message(), source)) as Error) + } +} + +impl Context for Option { + fn context(self, message: impl Display) -> Result { + self.ok_or_else(|| crate::error::message(message)) + } + + fn with_context(self, message: F) -> Result + where + F: FnOnce() -> M, + M: Display, + { + self.ok_or_else(|| crate::error::message(message())) + } +} + +fn bounded(message: impl Display) -> String { + let mut output = BoundedText(String::with_capacity(MAX_CONTEXT_BYTES)); + let _ = write!(&mut output, "{message}"); + output.0 +} + +struct BoundedText(String); + +impl fmt::Write for BoundedText { + fn write_str(&mut self, text: &str) -> fmt::Result { + let remaining = MAX_CONTEXT_BYTES.saturating_sub(self.0.len()); + let mut end = remaining.min(text.len()); + while end > 0 && !text.is_char_boundary(end) { + end -= 1; + } + self.0.push_str(&text[..end]); + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn context_retains_source_and_bounds_display() { + let error = Err::<(), _>(std::io::Error::other("source detail")) + .context("opening test file") + .unwrap_err(); + assert_eq!(error.to_string(), "opening test file"); + assert!(error.source().is_some()); + assert_eq!( + message("x".repeat(MAX_CONTEXT_BYTES + 1)).to_string().len(), + MAX_CONTEXT_BYTES + ); + assert!(format!("{error:#}").contains("source detail")); + } +} diff --git a/src/http_server.rs b/src/http_server.rs index 7e9f6751..724cb2c7 100644 --- a/src/http_server.rs +++ b/src/http_server.rs @@ -7,7 +7,16 @@ use bytes::Bytes; use http_body_util::{BodyExt, Limited}; use hyper::{body::Incoming, service::service_fn}; use hyper_util::rt::{TokioIo, TokioTimer}; -use std::{convert::Infallible, future::Future, io, net::SocketAddr, sync::Arc, time::Duration}; +use std::{ + convert::Infallible, future::Future, io, net::SocketAddr, pin::Pin, sync::Arc, time::Duration, +}; + +#[cfg(feature = "websocket")] +mod websocket; +#[cfg(feature = "websocket")] +pub use websocket::{ + Message as WebSocketMessage, Upgrade as WebSocketUpgrade, WebSocket, WebSocketLimits, +}; mod body; mod diagnostics; @@ -18,6 +27,9 @@ use diagnostics::increment; pub use diagnostics::{Diagnostics, Snapshot}; pub use target::QueryPairs; +#[cfg(feature = "websocket")] +type UpgradeTask = Pin + Send + 'static>>; + /// Shared bounded native preparation of file responses. Clones share admission; /// use one instance for a server's routes, not one instance per request. #[derive(Clone, Debug)] @@ -185,10 +197,13 @@ impl Limits { #[derive(Debug)] pub struct Request { method: String, + http_1_1: bool, target: String, uri: hyper::Uri, headers: hyper::HeaderMap, body: Vec, + #[cfg(feature = "websocket")] + upgrade: Option, } impl Request { @@ -230,14 +245,33 @@ impl Request { pub fn body(&self) -> &[u8] { &self.body } + + /// Consume this request as an RFC 6455 WebSocket upgrade. Validate route, + /// origin, host and authorization before calling this method. The returned + /// value owns the upgrade future; it cannot be reused as an HTTP request. + #[cfg(feature = "websocket")] + pub fn into_websocket(self) -> io::Result { + websocket::Upgrade::from_request(self) + } } /// An application-selected HTTP response with validated status and headers. -#[derive(Debug)] pub struct Response { status: hyper::StatusCode, headers: hyper::HeaderMap, body: ServerBody, + #[cfg(feature = "websocket")] + upgrade_task: Option, +} + +impl std::fmt::Debug for Response { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Response") + .field("status", &self.status) + .field("headers", &self.headers) + .field("body", &self.body) + .finish_non_exhaustive() + } } impl Default for Response { @@ -247,11 +281,37 @@ impl Default for Response { status: hyper::StatusCode::INTERNAL_SERVER_ERROR, headers: hyper::HeaderMap::new(), body: ServerBody::bytes(Bytes::new()), + #[cfg(feature = "websocket")] + upgrade_task: None, } } } impl Response { + #[cfg(feature = "websocket")] + pub(super) fn websocket_upgrade(accept: &str, upgrade_task: UpgradeTask) -> Self { + let mut headers = hyper::HeaderMap::new(); + headers.insert( + hyper::header::CONNECTION, + hyper::header::HeaderValue::from_static("Upgrade"), + ); + headers.insert( + hyper::header::UPGRADE, + hyper::header::HeaderValue::from_static("websocket"), + ); + headers.insert( + hyper::header::HeaderName::from_static("sec-websocket-accept"), + hyper::header::HeaderValue::from_str(accept) + .expect("derived WebSocket accept key is valid"), + ); + Self { + status: hyper::StatusCode::SWITCHING_PROTOCOLS, + headers, + body: ServerBody::bytes(Bytes::new()), + upgrade_task: Some(upgrade_task), + } + } + /// Construct a response. The server separately enforces its body-size limit. /// /// # Errors @@ -275,6 +335,8 @@ impl Response { status: hyper::StatusCode::from_u16(status).map_err(io::Error::other)?, headers: hyper::HeaderMap::new(), body: ServerBody::bytes(Bytes::from(body)), + #[cfg(feature = "websocket")] + upgrade_task: None, }) } @@ -433,15 +495,49 @@ impl Server { let read_budget = self.read_budget.clone(); tasks.spawn(async move { let request_diagnostics = diagnostics.clone(); - let service = service_fn(move |request| dispatch(request, handler.clone(), limits, request_diagnostics.clone(), response_headers.clone(), read_budget.clone())); + #[cfg(feature = "websocket")] + let (upgrades_tx, mut upgrades_rx) = tokio::sync::mpsc::unbounded_channel(); + let service = service_fn(move |request| { + dispatch( + request, + handler.clone(), + limits, + request_diagnostics.clone(), + response_headers.clone(), + read_budget.clone(), + #[cfg(feature = "websocket")] + upgrades_tx.clone(), + ) + }); let mut builder = hyper::server::conn::http1::Builder::new(); builder.timer(TokioTimer::new()) .header_read_timeout(limits.header_timeout) .max_buf_size(limits.max_header_bytes) .max_headers(limits.max_headers); let socket = transport::ProgressIo::new(socket, limits.write_timeout); - let connection = builder.serve_connection(TokioIo::new(socket), service); - match tokio::time::timeout(limits.connection_timeout, connection).await { + let connection = builder + .serve_connection(TokioIo::new(socket), service) + .with_upgrades(); + #[cfg(feature = "websocket")] + let mut upgrade_tasks = tokio::task::JoinSet::new(); + #[cfg(feature = "websocket")] + let outcome = tokio::time::timeout(limits.connection_timeout, async { + tokio::pin!(connection); + loop { + tokio::select! { + result = &mut connection => break result, + Some(task) = upgrades_rx.recv() => { upgrade_tasks.spawn(task); } + Some(result) = upgrade_tasks.join_next(), if !upgrade_tasks.is_empty() => { + if result.is_err() { increment(&diagnostics.0.task_failures); } + } + } + } + }).await; + #[cfg(not(feature = "websocket"))] + let outcome = tokio::time::timeout(limits.connection_timeout, connection).await; + #[cfg(feature = "websocket")] + upgrade_tasks.abort_all(); + match outcome { Err(_) => increment(&diagnostics.0.connection_timeouts), Ok(Err(_)) => increment(&diagnostics.0.connection_errors), Ok(Ok(())) => increment(&diagnostics.0.completed_connections), @@ -466,12 +562,21 @@ async fn dispatch( diagnostics: Diagnostics, response_headers: Arc, read_budget: body::ReadBudget, + #[cfg(feature = "websocket")] upgrades_tx: tokio::sync::mpsc::UnboundedSender, ) -> Result, Infallible> where H: Fn(Request) -> F, F: Future, { - let mut result = dispatch_inner(request, handler, limits, diagnostics.clone()).await?; + let mut result = dispatch_inner( + request, + handler, + limits, + diagnostics.clone(), + #[cfg(feature = "websocket")] + upgrades_tx, + ) + .await?; result.body_mut().set_read_budget(read_budget); for (name, value) in response_headers.iter() { result.headers_mut().insert(name.clone(), value.clone()); @@ -500,11 +605,15 @@ async fn dispatch_inner( handler: H, limits: Limits, diagnostics: Diagnostics, + #[cfg(feature = "websocket")] upgrades_tx: tokio::sync::mpsc::UnboundedSender, ) -> Result, Infallible> where H: Fn(Request) -> F, F: Future, { + let mut request = request; + #[cfg(feature = "websocket")] + let upgrade = hyper::upgrade::on(&mut request); let (parts, body) = request.into_parts(); let collected = tokio::time::timeout( limits.body_timeout, @@ -528,10 +637,13 @@ where }; let request = Request { method: parts.method.to_string(), + http_1_1: parts.version == hyper::Version::HTTP_11, target: parts.uri.to_string(), uri: parts.uri, headers: parts.headers, body, + #[cfg(feature = "websocket")] + upgrade: Some(upgrade), }; let mut response = match tokio::time::timeout(limits.handler_timeout, handler(request)).await { Ok(response) => response, @@ -540,6 +652,10 @@ where return Ok(empty(hyper::StatusCode::GATEWAY_TIMEOUT)); } }; + #[cfg(feature = "websocket")] + if let Some(upgrade_task) = response.upgrade_task.take() { + let _ = upgrades_tx.send(upgrade_task); + } if !headers_fit(&response.headers, limits) || response.body.configure(limits).is_err() { increment(&diagnostics.0.response_rejections); return Ok(empty(hyper::StatusCode::INTERNAL_SERVER_ERROR)); diff --git a/src/http_server/websocket.rs b/src/http_server/websocket.rs new file mode 100644 index 00000000..3bf9b77b --- /dev/null +++ b/src/http_server/websocket.rs @@ -0,0 +1,358 @@ +//! Owned RFC 6455 upgrade and message transport for [`super::Server`]. +//! +//! Route, origin, authentication and application protocol policy remain with +//! callers. This module owns only HTTP upgrade validation, bounded frames and +//! the private transport implementation. + +use super::{Request, Response}; +use futures_util::{ + stream::{SplitSink, SplitStream}, + SinkExt, StreamExt, +}; +use hyper::header; +use hyper_util::rt::TokioIo; +use std::{future::Future, io}; +use tokio_tungstenite::{tungstenite, WebSocketStream}; + +/// Per-connection WebSocket acceptance limits. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct WebSocketLimits { + /// Maximum decoded message payload bytes. + pub max_message_bytes: usize, + /// Maximum single-frame payload bytes. + pub max_frame_bytes: usize, +} + +impl Default for WebSocketLimits { + fn default() -> Self { + Self { + max_message_bytes: 64 * 1024, + max_frame_bytes: 64 * 1024, + } + } +} + +impl WebSocketLimits { + fn config(self) -> io::Result { + if !(1..=64 * 1024 * 1024).contains(&self.max_message_bytes) + || !(1..=self.max_message_bytes).contains(&self.max_frame_bytes) + { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "invalid WebSocket message/frame limits", + )); + } + Ok(tungstenite::protocol::WebSocketConfig::default() + .max_message_size(Some(self.max_message_bytes)) + .max_frame_size(Some(self.max_frame_bytes))) + } +} + +/// Facade-owned WebSocket message. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Message { + Text(String), + Binary(Vec), + Ping(Vec), + Pong(Vec), + Close, +} + +fn from_transport(message: tungstenite::Message) -> Message { + match message { + tungstenite::Message::Text(text) => Message::Text(text.to_string()), + tungstenite::Message::Binary(data) => Message::Binary(data.to_vec()), + tungstenite::Message::Ping(data) => Message::Ping(data.to_vec()), + tungstenite::Message::Pong(data) => Message::Pong(data.to_vec()), + tungstenite::Message::Close(_) => Message::Close, + tungstenite::Message::Frame(_) => Message::Close, + } +} + +fn into_transport(message: Message) -> tungstenite::Message { + match message { + Message::Text(text) => tungstenite::Message::Text(text.into()), + Message::Binary(data) => tungstenite::Message::Binary(data.into()), + Message::Ping(data) => tungstenite::Message::Ping(data.into()), + Message::Pong(data) => tungstenite::Message::Pong(data.into()), + Message::Close => tungstenite::Message::Close(None), + } +} + +/// An upgraded connection with bounded facade messages. +pub struct WebSocket { + inner: WebSocketStream>, + limits: WebSocketLimits, +} + +/// Write half of an upgraded connection. It retains the connection's bounded +/// outbound-message policy after [`WebSocket::split`]. +pub struct WebSocketSender { + inner: SplitSink>, tungstenite::Message>, + limits: WebSocketLimits, +} + +/// Read half of an upgraded connection, returned by [`WebSocket::split`]. +pub struct WebSocketReceiver { + inner: SplitStream>>, +} + +impl WebSocket { + /// Receive one message. `Ok(None)` means the peer closed cleanly. + pub async fn receive(&mut self) -> io::Result> { + match self.inner.next().await { + Some(Ok(message)) => Ok(Some(from_transport(message))), + Some(Err(error)) => Err(io::Error::other(error)), + None => Ok(None), + } + } + + /// Send one message, flushing it to the connection. + pub async fn send(&mut self, message: Message) -> io::Result<()> { + validate_outgoing(self.limits, &message)?; + self.inner + .send(into_transport(message)) + .await + .map_err(io::Error::other) + } + + /// Split this connection into independently owned read and write halves. + /// Both halves are cancelled when their owning tasks are dropped. + pub fn split(self) -> (WebSocketSender, WebSocketReceiver) { + let (inner, receiver) = self.inner.split(); + ( + WebSocketSender { + inner, + limits: self.limits, + }, + WebSocketReceiver { inner: receiver }, + ) + } +} + +impl WebSocketSender { + /// Send one bounded message and flush it to the peer. + pub async fn send(&mut self, message: Message) -> io::Result<()> { + validate_outgoing(self.limits, &message)?; + self.inner + .send(into_transport(message)) + .await + .map_err(io::Error::other) + } +} + +impl WebSocketReceiver { + /// Receive one message. `Ok(None)` means the peer closed cleanly. + pub async fn receive(&mut self) -> io::Result> { + match self.inner.next().await { + Some(Ok(message)) => Ok(Some(from_transport(message))), + Some(Err(error)) => Err(io::Error::other(error)), + None => Ok(None), + } + } +} + +fn validate_outgoing(limits: WebSocketLimits, message: &Message) -> io::Result<()> { + let bytes = match message { + Message::Text(text) => text.len(), + Message::Binary(data) | Message::Ping(data) | Message::Pong(data) => data.len(), + Message::Close => 0, + }; + if bytes > limits.max_message_bytes + || bytes > limits.max_frame_bytes + || (matches!(message, Message::Ping(_) | Message::Pong(_)) && bytes > 125) + { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "WebSocket outgoing message exceeds configured limits", + )); + } + Ok(()) +} + +/// A validated, one-shot WebSocket upgrade. +pub struct Upgrade { + upgrade: hyper::upgrade::OnUpgrade, + accept: String, +} + +impl Upgrade { + pub(super) fn from_request(request: Request) -> io::Result { + if !request.http_1_1 + || request.method != "GET" + || !has_token(&request.headers, header::CONNECTION, "upgrade") + || !has_token(&request.headers, header::UPGRADE, "websocket") + || singleton(&request.headers, "sec-websocket-version") != Some(b"13".as_slice()) + { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "request is not a WebSocket upgrade", + )); + } + let key = singleton(&request.headers, "sec-websocket-key") + .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "missing WebSocket key"))?; + if !valid_key(key) { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "invalid WebSocket key", + )); + } + let upgrade = request + .upgrade + .ok_or_else(|| io::Error::other("WebSocket upgrade unavailable"))?; + Ok(Self { + upgrade, + accept: tungstenite::handshake::derive_accept_key(key), + }) + } + + /// Return the switching-protocol response and run `callback` after upgrade. + /// Callback errors are isolated to its connection and do not stop serving. + pub fn on_upgrade(self, limits: WebSocketLimits, callback: F) -> io::Result + where + F: FnOnce(WebSocket) -> Fut + Send + 'static, + Fut: Future + Send + 'static, + { + let config = limits.config()?; + let accept = self.accept; + let task = Box::pin(async move { + if let Ok(upgraded) = self.upgrade.await { + let socket = WebSocket { + inner: WebSocketStream::from_raw_socket( + TokioIo::new(upgraded), + tungstenite::protocol::Role::Server, + Some(config), + ) + .await, + limits, + }; + callback(socket).await; + } + }); + Ok(Response::websocket_upgrade(&accept, task)) + } +} + +fn has_token(headers: &hyper::HeaderMap, name: hyper::header::HeaderName, token: &str) -> bool { + headers.get_all(name).iter().any(|value| { + std::str::from_utf8(value.as_bytes()).is_ok_and(|value| { + value + .split(',') + .any(|candidate| candidate.trim().eq_ignore_ascii_case(token)) + }) + }) +} + +fn singleton<'a>(headers: &'a hyper::HeaderMap, name: &str) -> Option<&'a [u8]> { + let values = headers.get_all(name); + let mut values = values.iter(); + let value = values.next()?; + if values.next().is_some() { + None + } else { + Some(value.as_bytes()) + } +} + +/// RFC 6455's nonce is exactly sixteen bytes encoded as standard Base64. +/// Its canonical encoding is consequently twenty-four ASCII bytes ending in +/// two padding characters. The handshake digest consumes the original text. +fn valid_key(key: &[u8]) -> bool { + key.len() == 24 + && key.ends_with(b"==") + && key[..22] + .iter() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'+' | b'/')) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn limits_are_bounded_and_frame_cannot_exceed_message() { + assert!(WebSocketLimits::default().config().is_ok()); + assert!(WebSocketLimits { + max_message_bytes: 0, + max_frame_bytes: 1, + } + .config() + .is_err()); + assert!(WebSocketLimits { + max_message_bytes: 10, + max_frame_bytes: 11, + } + .config() + .is_err()); + } + + #[test] + fn upgrade_rejects_non_websocket_before_claiming_transport() { + let request = Request { + method: "GET".into(), + http_1_1: true, + target: "/terminal/ws".into(), + uri: "/terminal/ws".parse().unwrap(), + headers: hyper::HeaderMap::new(), + body: Vec::new(), + upgrade: None, + }; + assert!(Upgrade::from_request(request).is_err()); + } + + #[test] + fn websocket_key_must_be_one_canonical_16_byte_nonce() { + assert!(valid_key(b"dGhlIHNhbXBsZSBub25jZQ==")); + assert!(!valid_key(b"")); + assert!(!valid_key(b"dGhlIHNhbXBsZSBub25jZQ=")); + assert!(!valid_key(b"!!!!!!!!!!!!!!!!!!!!!!==")); + } + + #[test] + fn facade_messages_do_not_expose_transport_values() { + let message = Message::Binary(vec![1, 2, 3]); + assert_eq!(from_transport(into_transport(message.clone())), message); + } + + #[tokio::test] + async fn server_emits_a_real_switching_protocols_response() { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + + let server = super::super::Server::bind( + "127.0.0.1:0".parse().unwrap(), + super::super::Limits::default(), + ) + .await + .unwrap(); + let address = server.local_addr().unwrap(); + let serving = tokio::spawn(server.serve(|request| async move { + match request.into_websocket() { + Ok(upgrade) => upgrade + .on_upgrade(WebSocketLimits::default(), |_socket| async {}) + .unwrap(), + Err(_) => Response::new(400, Vec::new()).unwrap(), + } + })); + let mut client = tokio::net::TcpStream::connect(address).await.unwrap(); + client + .write_all( + b"GET /ws HTTP/1.1\r\nHost: localhost\r\nConnection: Upgrade\r\nUpgrade: websocket\r\nSec-WebSocket-Version: 13\r\nSec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n\r\n", + ) + .await + .unwrap(); + let mut response = [0; 1024]; + let count = tokio::time::timeout( + std::time::Duration::from_secs(1), + client.read(&mut response), + ) + .await + .unwrap() + .unwrap(); + let response = std::str::from_utf8(&response[..count]).unwrap(); + assert!(response.starts_with("HTTP/1.1 101 Switching Protocols\r\n")); + assert!(response.contains("connection: Upgrade\r\n")); + assert!(response.contains("upgrade: websocket\r\n")); + assert!(response.contains("sec-websocket-accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=\r\n")); + serving.abort(); + } +} diff --git a/src/json.rs b/src/json.rs new file mode 100644 index 00000000..24b5193f --- /dev/null +++ b/src/json.rs @@ -0,0 +1,316 @@ +//! Bounded JSON mechanics. Applications own schemas, defaults and field policy. + +use std::collections::BTreeMap; +use std::io::Write; + +/// Source byte limit, enforced before parsing. +pub const MAX_INPUT_BYTES: usize = 8 * 1024 * 1024; +/// Encoded byte limit, including escaping and whitespace. +pub const MAX_OUTPUT_BYTES: usize = 8 * 1024 * 1024; +/// Maximum decoded values, counting containers and the root (not object keys). +pub const MAX_NODES: usize = 262_144; +/// Maximum value depth; the root has depth zero. +pub const MAX_DEPTH: usize = 64; + +/// Owned JSON values, independent of the private serialization backend. +/// Map objects encode in key order; member objects preserve order and duplicates. +/// Positive parsed integers use `Signed` when +/// representable, otherwise `Unsigned`; numeric variant identity is not a +/// wire-format guarantee. Floating-point values must be finite when encoded. +#[derive(Clone, Debug, PartialEq)] +pub enum Value { + Null, + Bool(bool), + Signed(i64), + Unsigned(u64), + Float(f64), + String(String), + Array(Vec), + Object(BTreeMap), + /// Unmerged members from [`parse_members`], in source order. Applications + /// decide which duplicate fields their schemas accept. Encoding preserves + /// all entries; it does not merge them into a map. + ObjectMembers(Vec<(String, Value)>), +} + +/// Output style; neither style appends a trailing newline. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum Layout { + Compact, + /// Two-space indentation. + Pretty, +} + +/// Bounded diagnostics that do not echo document contents. +#[derive(Clone, Copy, Debug, Eq, PartialEq, thiserror::Error)] +pub enum Error { + #[error("JSON source exceeds byte limit")] + InputTooLarge, + #[error("invalid JSON document")] + InvalidSyntax, + #[error("JSON exceeds value count limit")] + TooManyNodes, + #[error("JSON exceeds depth limit")] + TooDeep, + #[error("JSON numbers must be finite")] + NonFiniteNumber, + #[error("JSON output exceeds byte limit")] + OutputTooLarge, + #[error("JSON encoding failed")] + EncodingFailed, +} + +/// Parse one UTF-8 JSON value; trailing whitespace is allowed, trailing values +/// are not. Duplicate object keys retain their last value. +/// +/// The input byte limit is checked before parsing. Value count and depth limits +/// apply to the decoded tree after private parsing, not as independent parser +/// allocation or CPU quotas. The parser also retains its own recursion limit. +/// No partial result is returned. Integers outside i64/u64 may decode as finite +/// f64 values with rounding; this is not an arbitrary-precision number API. +pub fn parse(source: &[u8]) -> Result { + if source.len() > MAX_INPUT_BYTES { + return Err(Error::InputTooLarge); + } + let value = serde_json::from_slice(source).map_err(|_| Error::InvalidSyntax)?; + let mut remaining = MAX_NODES; + convert(value, 0, &mut remaining) +} + +/// Parse without merging object members, including objects nested in arrays or +/// other objects. Every object becomes [`Value::ObjectMembers`]. Scalars and +/// arrays have the same representation as [`parse`]. +/// +/// The input byte limit applies before parsing. Depth and node limits apply +/// during decoding, counting every member value even when keys repeat. Keys +/// are not nodes. There is no second tree or duplicate-key index. These bounds +/// are not independent CPU or allocator quotas; individual strings and keys +/// are also bounded by the source byte limit. No partial result is returned. +/// Private borrowed raw-value syntax validation precedes member decoding and +/// can revisit nested source slices, bounded by input size and accepted depth. +pub fn parse_members(source: &[u8]) -> Result { + use serde::de::DeserializeSeed; + if source.len() > MAX_INPUT_BYTES { + return Err(Error::InputTooLarge); + } + let mut state = MemberState { + remaining: MAX_NODES, + failure: None, + }; + let mut parser = serde_json::Deserializer::from_slice(source); + let result = MemberSeed { + state: &mut state, + depth: 0, + } + .deserialize(&mut parser); + let value = result.map_err(|_| state.failure.unwrap_or(Error::InvalidSyntax))?; + parser.end().map_err(|_| Error::InvalidSyntax)?; + Ok(value) +} + +struct MemberState { + remaining: usize, + failure: Option, +} + +struct MemberSeed<'a> { + state: &'a mut MemberState, + depth: usize, +} + +impl<'de> serde::de::DeserializeSeed<'de> for MemberSeed<'_> { + type Value = Value; + fn deserialize>(self, deserializer: D) -> Result { + if let Err(error) = visit(self.depth, &mut self.state.remaining) { + self.state.failure = Some(error); + return Err(serde::de::Error::custom("JSON resource limit")); + } + // A private raw slice distinguishes actual objects from the synthetic + // map used by serde_json when arbitrary_precision is feature-unified. + // Never interpret a user-controlled object key as a backend marker. + let raw: &'de serde_json::value::RawValue = serde::Deserialize::deserialize(deserializer)?; + let mut parser = serde_json::Deserializer::from_str(raw.get()); + use serde::Deserializer; + match raw.get().as_bytes().first() { + Some(b'{') => parser + .deserialize_map(self) + .map_err(serde::de::Error::custom), + Some(b'[') => parser + .deserialize_seq(self) + .map_err(serde::de::Error::custom), + _ => { + let value = serde_json::from_str(raw.get()).map_err(serde::de::Error::custom)?; + let mut scalar_budget = 1; + convert(value, 0, &mut scalar_budget).map_err(serde::de::Error::custom) + } + } + } +} + +impl<'de> serde::de::Visitor<'de> for MemberSeed<'_> { + type Value = Value; + fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("a JSON value") + } + fn visit_seq>(self, mut sequence: A) -> Result { + let mut values = Vec::new(); + while let Some(value) = sequence.next_element_seed(MemberSeed { + state: &mut *self.state, + depth: self.depth + 1, + })? { + values.push(value); + } + Ok(Value::Array(values)) + } + fn visit_map>(self, mut object: A) -> Result { + let mut members = Vec::new(); + while let Some(key) = object.next_key::()? { + let value = object.next_value_seed(MemberSeed { + state: &mut *self.state, + depth: self.depth + 1, + })?; + members.push((key, value)); + } + Ok(Value::ObjectMembers(members)) + } +} + +fn visit(depth: usize, remaining: &mut usize) -> Result<(), Error> { + if depth > MAX_DEPTH { + return Err(Error::TooDeep); + } + *remaining = remaining.checked_sub(1).ok_or(Error::TooManyNodes)?; + Ok(()) +} + +fn convert(value: serde_json::Value, depth: usize, remaining: &mut usize) -> Result { + visit(depth, remaining)?; + Ok(match value { + serde_json::Value::Null => Value::Null, + serde_json::Value::Bool(value) => Value::Bool(value), + serde_json::Value::Number(value) => { + if let Some(value) = value.as_i64() { + Value::Signed(value) + } else if let Some(value) = value.as_u64() { + Value::Unsigned(value) + } else { + Value::Float(value.as_f64().ok_or(Error::InvalidSyntax)?) + } + } + serde_json::Value::String(value) => Value::String(value), + serde_json::Value::Array(values) => Value::Array( + values + .into_iter() + .map(|value| convert(value, depth + 1, remaining)) + .collect::>()?, + ), + serde_json::Value::Object(values) => Value::Object( + values + .into_iter() + .map(|(key, value)| Ok((key, convert(value, depth + 1, remaining)?))) + .collect::>()?, + ), + }) +} + +fn validate(value: &Value, depth: usize, remaining: &mut usize) -> Result<(), Error> { + visit(depth, remaining)?; + match value { + Value::Float(value) if !value.is_finite() => return Err(Error::NonFiniteNumber), + Value::Array(values) => { + for value in values { + validate(value, depth + 1, remaining)?; + } + } + Value::Object(values) => { + for value in values.values() { + validate(value, depth + 1, remaining)?; + } + } + Value::ObjectMembers(values) => { + for (_, value) in values { + validate(value, depth + 1, remaining)?; + } + } + _ => {} + } + Ok(()) +} + +// Only this private borrowed adapter implements the backend trait. Encoding +// does not clone caller strings or construct a second tree. +struct Borrowed<'a>(&'a Value); + +impl serde::Serialize for Borrowed<'_> { + fn serialize(&self, serializer: S) -> Result { + use serde::ser::{SerializeMap, SerializeSeq}; + match self.0 { + Value::Null => serializer.serialize_unit(), + Value::Bool(value) => serializer.serialize_bool(*value), + Value::Signed(value) => serializer.serialize_i64(*value), + Value::Unsigned(value) => serializer.serialize_u64(*value), + Value::Float(value) => serializer.serialize_f64(*value), + Value::String(value) => serializer.serialize_str(value), + Value::Array(values) => { + let mut output = serializer.serialize_seq(Some(values.len()))?; + for value in values { + output.serialize_element(&Borrowed(value))?; + } + output.end() + } + Value::Object(values) => { + let mut output = serializer.serialize_map(Some(values.len()))?; + for (key, value) in values { + output.serialize_entry(key, &Borrowed(value))?; + } + output.end() + } + Value::ObjectMembers(values) => { + let mut output = serializer.serialize_map(Some(values.len()))?; + for (key, value) in values { + output.serialize_entry(key, &Borrowed(value))?; + } + output.end() + } + } + } +} + +#[derive(Default)] +struct Output { + bytes: Vec, + exceeded: bool, +} + +impl Write for Output { + fn write(&mut self, bytes: &[u8]) -> std::io::Result { + if bytes.len() > MAX_OUTPUT_BYTES - self.bytes.len() { + self.exceeded = true; + return Err(std::io::Error::other("JSON output limit")); + } + self.bytes.extend_from_slice(bytes); + Ok(bytes.len()) + } + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } +} + +/// Encode a caller-owned value, checking depth/count/finite numbers first and +/// enforcing the output byte limit during serialization. No tree/string clone +/// or partial output is returned. These bounds do not limit caller allocations +/// made while constructing a value before this call. +pub fn encode(value: &Value, layout: Layout) -> Result, Error> { + let mut remaining = MAX_NODES; + validate(value, 0, &mut remaining)?; + let mut output = Output::default(); + let result = match layout { + Layout::Compact => serde_json::to_writer(&mut output, &Borrowed(value)), + Layout::Pretty => serde_json::to_writer_pretty(&mut output, &Borrowed(value)), + }; + if output.exceeded { + return Err(Error::OutputTooLarge); + } + result.map_err(|_| Error::EncodingFailed)?; + Ok(output.bytes) +} diff --git a/src/lib.rs b/src/lib.rs index 81505e98..bf1c43e9 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -52,6 +52,22 @@ pub use running_process::{ SpawnOptions, }; +#[cfg(feature = "command-arguments")] +pub mod arguments; +/// Bounded command-line schema parsing with facade-owned values and diagnostics. +#[cfg(feature = "command-schema")] +pub mod command; +/// Owned application error context and source chaining. +#[cfg(feature = "error-context")] +pub mod error; + +/// Bounded configuration decoding with caller-owned schemas and defaults. +#[cfg(feature = "config-toml")] +pub mod config; +/// Bounded JSON values and encoding with caller-owned schemas. +#[cfg(feature = "json")] +pub mod json; + /// Kernel-owned BLAKE3 content hashing for bytes, readers, and files, plus /// an incremental hasher and key-derivation domain separation. pub mod hash; @@ -64,6 +80,10 @@ pub mod hash; #[cfg(feature = "sqlite")] pub mod sqlite; +/// Bounded source analysis; callers own language integration and product policy. +#[cfg(feature = "source-cpp")] +pub mod source; + /// Bounded, fallible operating-system entropy without token-format policy. #[cfg(feature = "secure-random")] pub mod random; @@ -85,6 +105,9 @@ pub mod archive; pub mod http; #[cfg(feature = "http-server")] pub mod http_server; +/// Owned native pseudo-terminal sessions without backend descriptor types. +#[cfg(feature = "pty")] +pub mod pty; /// Facade-owned identity, sidecar, probe, and endpoint-mux semantics for an /// existing daemon endpoint. diff --git a/src/platform_win/ipc_private_dir.rs b/src/platform_win/ipc_private_dir.rs index 2265c60b..9cf3a6ea 100644 --- a/src/platform_win/ipc_private_dir.rs +++ b/src/platform_win/ipc_private_dir.rs @@ -9,13 +9,19 @@ use std::{fs::File, os::windows::io::AsRawHandle as _}; #[cfg(feature = "ipc")] use crate::platform::ipc::OwnerPrivateDirectoryOutcome; -/// Protected, inheritable owner-and-SYSTEM DACL for private IPC directories. +/// Protected, inheritable current-user-and-SYSTEM DACL for private IPC directories. /// /// OICI is required because applying a protected DACL re-propagates inherited /// ACEs through existing descendants. The earlier non-inheritable policy could /// leave descendants with an empty DACL, including files with hardlinks outside /// the directory. Reapplying this policy repairs that legacy state. -const PRIVATE_DIR_SDDL: &str = "D:P(A;OICI;FA;;;OW)(A;OICI;FA;;;SY)"; +#[cfg(feature = "ipc")] +fn private_dir_sddl() -> io::Result { + Ok(format!( + "D:P(A;OICI;FA;;;{})(A;OICI;FA;;;SY)", + current_user_sid_sddl()? + )) +} #[cfg(feature = "ipc")] pub fn ensure_owner_private_directory(path: &Path) -> io::Result { @@ -25,7 +31,7 @@ pub fn ensure_owner_private_directory(path: &Path) -> io::Result io::Result { } // Binary equality covers ACL revision, ACE flags/masks/SIDs/order and // callback or object payloads that SDDL substring checks can misclassify. - let expected = LocalSecurityDescriptor::from_sddl(PRIVATE_DIR_SDDL)?; + let expected = LocalSecurityDescriptor::from_sddl(&private_dir_sddl()?)?; Ok(actual.dacl()?.bytes()? == expected.dacl()?.bytes()?) } /// Validate the confidentiality policy of an already-open regular file. /// /// This uses `GetSecurityInfo` on the handle rather than reopening the path: -/// a rename or reparse-point swap cannot change the object whose owner and -/// DACL are checked. We accept only the two exact owner/SYSTEM full-control -/// ACL forms Windows creates directly or by inheriting `PRIVATE_DIR_SDDL`. -/// Every other ACE kind, principal, mask, order, or callback payload differs -/// byte-for-byte and fails closed. +/// a rename or reparse-point swap cannot change the object whose DACL is +/// checked. We accept only the exact current-user/SYSTEM full-control +/// ACL forms Windows creates directly or by inheriting `private_dir_sddl()`. +/// Every other ACE kind, principal, mask, order, size, or callback payload +/// fails closed. #[cfg(feature = "fs")] pub(super) fn opened_file_is_current_user_private(file: &File) -> io::Result { use windows_sys::Win32::Foundation::ERROR_SUCCESS; use windows_sys::Win32::Security::Authorization::{GetSecurityInfo, SE_FILE_OBJECT}; - use windows_sys::Win32::Security::{ - EqualSid, DACL_SECURITY_INFORMATION, OWNER_SECURITY_INFORMATION, PSECURITY_DESCRIPTOR, PSID, - }; + use windows_sys::Win32::Security::{DACL_SECURITY_INFORMATION, PSECURITY_DESCRIPTOR}; let current_sid = current_user_sid_bytes()?; - let mut owner: PSID = std::ptr::null_mut(); let mut descriptor: PSECURITY_DESCRIPTOR = std::ptr::null_mut(); // SAFETY: `file` owns a live handle; all output pointers refer to writable // locals. On success the descriptor is a LocalFree allocation adopted @@ -76,8 +79,8 @@ pub(super) fn opened_file_is_current_user_private(file: &File) -> io::Result io::Result bool { + // ACL header: revision, reserved, byte size, ACE count, reserved. + if dacl.len() < 8 + || dacl[0] != 2 + || u16::from_le_bytes([dacl[2], dacl[3]]) as usize != dacl.len() + || u16::from_le_bytes([dacl[4], dacl[5]]) != 2 + { + return false; } - // `OW` is the Owner Rights SID rather than a literal account SID: it is - // exactly what the protected parent policy inherits into a child. The - // owner equality above independently proves this object belongs to the - // current user. - let direct = LocalSecurityDescriptor::from_sddl("D:P(A;;FA;;;OW)(A;;FA;;;SY)")?; - let inherited = LocalSecurityDescriptor::from_sddl("D:(A;ID;FA;;;OW)(A;ID;FA;;;SY)")?; - let actual = actual.dacl()?.bytes()?; - Ok(actual == direct.dacl()?.bytes()? || actual == inherited.dacl()?.bytes()?) + const SYSTEM_SID: &[u8] = &[1, 1, 0, 0, 0, 0, 0, 5, 18, 0, 0, 0]; + let Some(next) = exact_full_control_ace(dacl, 8, current_sid) else { + return false; + }; + exact_full_control_ace(dacl, next, SYSTEM_SID).is_some_and(|end| end == dacl.len()) } #[cfg(feature = "fs")] +fn exact_full_control_ace(dacl: &[u8], offset: usize, principal: &[u8]) -> Option { + const ACCESS_ALLOWED_ACE_TYPE: u8 = 0; + const INHERITED_ACE: u8 = 0x10; + const OBJECT_INHERIT_ACE: u8 = 0x01; + const CONTAINER_INHERIT_ACE: u8 = 0x02; + const FULL_CONTROL: u32 = 0x001f_01ff; + + let header_end = offset.checked_add(8)?; + if header_end > dacl.len() || dacl[offset] != ACCESS_ALLOWED_ACE_TYPE { + return None; + } + let flags = dacl[offset + 1]; + // Direct files use no inheritance flags. Windows may retain the parent's + // OI|CI flags when materializing an inherited file ACE, always with ID. + if flags != 0 + && flags != INHERITED_ACE + && flags != (INHERITED_ACE | OBJECT_INHERIT_ACE | CONTAINER_INHERIT_ACE) + { + return None; + } + let ace_size = u16::from_le_bytes([dacl[offset + 2], dacl[offset + 3]]) as usize; + if ace_size != 8 + principal.len() { + return None; + } + let end = offset.checked_add(ace_size)?; + if end > dacl.len() + || u32::from_le_bytes([ + dacl[offset + 4], + dacl[offset + 5], + dacl[offset + 6], + dacl[offset + 7], + ]) != FULL_CONTROL + || &dacl[header_end..end] != principal + { + return None; + } + Some(end) +} + fn current_user_sid_bytes() -> io::Result> { use windows_sys::Win32::Foundation::CloseHandle; use windows_sys::Win32::Security::{GetLengthSid, GetTokenInformation, IsValidSid, TokenUser, TOKEN_USER}; @@ -148,6 +200,37 @@ fn current_user_sid_bytes() -> io::Result> { Ok(unsafe { std::slice::from_raw_parts(sid.cast::(), length).to_vec() }) } +#[cfg(any(feature = "fs", feature = "ipc"))] +fn current_user_sid_sddl() -> io::Result { + let bytes = current_user_sid_bytes()?; + let revision = *bytes + .first() + .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "empty current-user SID"))?; + let sub_authority_count = *bytes + .get(1) + .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "truncated current-user SID"))? + as usize; + let expected_len = 8 + sub_authority_count * 4; + if bytes.len() != expected_len { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "invalid current-user SID length", + )); + } + let mut authority = 0_u64; + for byte in &bytes[2..8] { + authority = (authority << 8) | u64::from(*byte); + } + let mut sddl = format!("S-{revision}-{authority}"); + for index in 0..sub_authority_count { + let offset = 8 + index * 4; + let sub_authority = u32::from_le_bytes(bytes[offset..offset + 4].try_into().unwrap()); + sddl.push('-'); + sddl.push_str(&sub_authority.to_string()); + } + Ok(sddl) +} + #[cfg(feature = "ipc")] fn apply_protected_dacl_sddl(path: &Path, sddl: &str) -> io::Result<()> { use windows_sys::Win32::Security::PROTECTED_DACL_SECURITY_INFORMATION; @@ -392,8 +475,29 @@ mod tests { let temporary = tempfile::tempdir().unwrap(); let directory = temporary.path().join("private"); fs::create_dir_all(&directory).unwrap(); - apply_protected_dacl_sddl(&directory, "D:P(A;OICI;FA;;;SY)(OA;OICI;FA;;;OW)") - .unwrap(); + apply_protected_dacl_sddl( + &directory, + &format!( + "D:P(A;OICI;FA;;;SY)(OA;OICI;FA;;;{})", + current_user_sid_sddl().unwrap() + ), + ) + .unwrap(); + + assert!(!owner_private_directory(&directory).unwrap()); + assert_eq!( + ensure_owner_private_directory(&directory).unwrap(), + OwnerPrivateDirectoryOutcome::Hardened + ); + assert!(owner_private_directory(&directory).unwrap()); + } + + #[test] + fn interactive_principal_dacl_is_rejected_and_repaired() { + let temporary = tempfile::tempdir().unwrap(); + let directory = temporary.path().join("private"); + fs::create_dir_all(&directory).unwrap(); + apply_protected_dacl_sddl(&directory, "D:P(A;OICI;FA;;;IU)(A;OICI;FA;;;SY)").unwrap(); assert!(!owner_private_directory(&directory).unwrap()); assert_eq!( @@ -411,14 +515,22 @@ mod tests { let parent = temporary.path().join("parent"); let directory = parent.join("private"); fs::create_dir_all(&directory).unwrap(); - apply_protected_dacl_sddl(&parent, "D:P(A;;FA;;;OW)(A;;FA;;;SY)").unwrap(); - apply_protected_dacl_sddl(&directory, PRIVATE_DIR_SDDL).unwrap(); + apply_protected_dacl_sddl( + &parent, + &format!( + "D:P(A;;FA;;;{})(A;;FA;;;SY)", + current_user_sid_sddl().unwrap() + ), + ) + .unwrap(); + let private_sddl = private_dir_sddl().unwrap(); + apply_protected_dacl_sddl(&directory, &private_sddl).unwrap(); let protected = file_security_descriptor(&directory).unwrap(); let protected_bytes = protected.dacl().unwrap().bytes().unwrap(); apply_dacl_sddl( &directory, - PRIVATE_DIR_SDDL, + &private_sddl, UNPROTECTED_DACL_SECURITY_INFORMATION, ) .unwrap(); @@ -471,7 +583,11 @@ mod tests { fs::create_dir_all(&directory).unwrap(); fs::write(&file, b"payload").unwrap(); - apply_protected_dacl_sddl(&directory, "D:P(A;;FA;;;OW)").unwrap(); + apply_protected_dacl_sddl( + &directory, + &format!("D:P(A;;FA;;;{})", current_user_sid_sddl().unwrap()), + ) + .unwrap(); assert!(!owner_private_directory(&directory).unwrap()); assert!(File::open(&file).is_err()); assert_eq!( @@ -490,9 +606,11 @@ mod tests { let child = directory.join("marker"); fs::write(&child, b"marker").unwrap(); - assert_eq!( - crate::platform::fs::read_private_regular_file_bounded(&child, 6).unwrap(), - b"marker" + let read = crate::platform::fs::read_private_regular_file_bounded(&child, 6); + assert!( + matches!(read.as_deref(), Ok(bytes) if bytes == b"marker"), + "inherited child private-file read failed: {read:?}; child DACL bytes: {:02x?}", + file_security_descriptor(&child).unwrap().dacl().unwrap().bytes().unwrap(), ); apply_protected_dacl_sddl(&child, "D:P(A;;GR;;;WD)").unwrap(); @@ -503,4 +621,24 @@ mod tests { io::ErrorKind::PermissionDenied ); } + + #[cfg(feature = "fs")] + #[test] + fn private_file_dacl_parser_accepts_only_the_expected_inherited_aces() { + let user_sid = [1, 2, 0, 0, 0, 0, 0, 5, 21, 0, 0, 0, 7, 0, 0, 0]; + let system_sid = [1, 1, 0, 0, 0, 0, 0, 5, 18, 0, 0, 0]; + let mut dacl = vec![2, 0, 0, 0, 2, 0, 0, 0]; + for sid in [&user_sid[..], &system_sid[..]] { + dacl.extend([0, 0x10]); + dacl.extend((8 + sid.len() as u16).to_le_bytes()); + dacl.extend(0x001f_01ff_u32.to_le_bytes()); + dacl.extend(sid); + } + let dacl_len = dacl.len() as u16; + dacl[2..4].copy_from_slice(&dacl_len.to_le_bytes()); + + assert!(dacl_is_exact_user_system_file_policy(&dacl, &user_sid)); + dacl[9] = 1; + assert!(!dacl_is_exact_user_system_file_policy(&dacl, &user_sid)); + } } diff --git a/src/pty.rs b/src/pty.rs new file mode 100644 index 00000000..52cd4bf6 --- /dev/null +++ b/src/pty.rs @@ -0,0 +1,130 @@ +//! Owned pseudo-terminal session facade. + +#[cfg(not(windows))] +use crate::platform::terminal::PtyChild; +use crate::{ + platform::terminal::{PtyBackend, PtyMaster, PtySize, PtySlave}, + Backend, +}; +use std::{ + ffi::OsString, + io::{self, Read, Write}, + path::PathBuf, +}; + +/// Caller-selected process program and arguments for a native PTY session. +#[derive(Debug, Clone)] +pub struct PtyCommand { + pub program: OsString, + pub arguments: Vec, + pub cwd: Option, + pub environment: Option>, +} + +impl PtyCommand { + pub fn new(program: impl Into) -> Self { + Self { + program: program.into(), + arguments: Vec::new(), + cwd: None, + environment: None, + } + } +} + +/// An owned child process and its pseudo-terminal. +pub struct PtySession { + master: ::Master, + child: <::Slave as PtySlave>::Child, + writer: Box, +} + +impl PtySession { + pub fn spawn(command: PtyCommand, size: PtySize) -> io::Result<(Self, Box)> { + let (mut master, slave) = Backend::openpty(size)?; + let reader = master.try_clone_reader()?; + let writer = master.take_writer()?; + let mut argv = Vec::with_capacity(command.arguments.len() + 1); + argv.push(command.program); + argv.extend(command.arguments); + let child = slave.spawn( + &argv, + command.cwd.as_deref(), + command.environment.as_deref(), + )?; + Ok(( + Self { + master, + child, + writer, + }, + reader, + )) + } + + pub fn write(&mut self, bytes: &[u8]) -> io::Result<()> { + self.writer + .write_all(bytes) + .and_then(|_| self.writer.flush()) + } + pub fn resize(&self, size: PtySize) -> io::Result<()> { + self.master.resize(size) + } + pub fn try_wait(&mut self) -> io::Result> { + self.child.try_wait() + } +} + +impl Drop for PtySession { + fn drop(&mut self) { + let _ = self.master.kill_process_group(); + let _ = self.child.kill(); + let _ = self.child.wait(); + } +} + +#[cfg(all(test, unix))] +mod tests { + use super::*; + use std::time::{Duration, Instant}; + + #[test] + fn session_owns_a_shell_command_and_its_pty_io() { + let mut command = PtyCommand::new("/bin/sh"); + command.arguments = vec!["-c".into(), "printf kernal-pty-session".into()]; + let (mut session, mut reader) = PtySession::spawn( + command, + PtySize { + rows: 24, + cols: 80, + pixel_width: 0, + pixel_height: 0, + }, + ) + .expect("spawn shell in PTY"); + + let mut bytes = Vec::new(); + let mut buffer = [0_u8; 64]; + loop { + match reader.read(&mut buffer) { + Ok(0) => break, + Ok(count) => bytes.extend_from_slice(&buffer[..count]), + Err(error) if error.kind() == io::ErrorKind::Interrupted => continue, + // Linux PTYs report EIO when the slave closes normally. + Err(error) if error.raw_os_error() == Some(libc::EIO) => break, + Err(error) => panic!("read PTY output: {error}"), + } + } + assert!(String::from_utf8_lossy(&bytes).contains("kernal-pty-session")); + + let deadline = Instant::now() + Duration::from_secs(2); + loop { + if let Some(status) = session.try_wait().expect("reap shell") { + assert_eq!(status, 0); + break; + } + assert!(Instant::now() < deadline, "shell did not exit promptly"); + std::thread::sleep(Duration::from_millis(10)); + } + } +} diff --git a/src/source.rs b/src/source.rs new file mode 100644 index 00000000..95a87af1 --- /dev/null +++ b/src/source.rs @@ -0,0 +1,195 @@ +//! C++ syntax mechanics adapted from FastLED/fbuild's source scanner at +//! 1e75ccf5a4ca922b4d922a6da286b965fac8832d via fastled-wasm. No Arduino policy. + +use std::ops::{ControlFlow, Range}; +use std::time::{Duration, Instant}; +use tree_sitter::{Node, ParseOptions, Parser}; + +pub const MAX_SOURCE_BYTES: usize = 8 * 1024 * 1024; +pub const MAX_FUNCTIONS: usize = 16_384; +pub const MAX_VISITED_NODES: usize = 131_072; +pub const MAX_DEPTH: usize = 256; + +/// Enclosing syntax contexts, not compiler-resolved semantic scopes. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub struct FunctionContext { + pub namespace: bool, + pub aggregate: bool, + pub explicit_linkage: bool, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct FunctionDefinition { + /// Definition header without its body or function parameter defaults. + /// Formatting and comments are retained. This is not a semantic C++ compiler + /// and does not promise that every header can become a standalone declaration. + pub signature: String, + pub source_range: Range, + pub context: FunctionContext, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, thiserror::Error)] +pub enum AnalysisError { + #[error("C++ source exceeds the byte limit")] + InputTooLarge, + #[error("C++ syntax is incomplete or invalid")] + InvalidSyntax, + #[error("C++ parsing exceeded its cooperative deadline")] + TimedOut, + #[error("C++ analysis exceeded its traversal or output limit")] + LimitExceeded, + #[error("C++ definition form is unsupported")] + UnsupportedDefinition, + #[error("C++ parser initialization failed")] + ParserUnavailable, +} + +/// Analyze definitions in source order, without deduplication or name filtering. +/// Definitions nested inside a function body are not inventoried. +/// +/// The byte limit is checked before parsing. A two-second cooperative parser +/// deadline is not a hard CPU deadline. Traversal depth/node and record limits +/// apply afterward; they are not independent parser-allocation quotas. Failure +/// returns no partial records. Source is never included in errors. +pub fn analyze_cpp(source: &str) -> Result, AnalysisError> { + if source.len() > MAX_SOURCE_BYTES { + return Err(AnalysisError::InputTooLarge); + } + let mut parser = Parser::new(); + parser + .set_language(&tree_sitter_cpp::LANGUAGE.into()) + .map_err(|_| AnalysisError::ParserUnavailable)?; + let started = Instant::now(); + let mut progress = |_: &tree_sitter::ParseState| { + if started.elapsed() >= Duration::from_secs(2) { + ControlFlow::Break(()) + } else { + ControlFlow::Continue(()) + } + }; + let bytes = source.as_bytes(); + let tree = parser + .parse_with_options( + &mut |offset, _| bytes.get(offset..).unwrap_or_default(), + None, + Some(ParseOptions::new().progress_callback(&mut progress)), + ) + .ok_or(AnalysisError::TimedOut)?; + if tree.root_node().has_error() { + return Err(AnalysisError::InvalidSyntax); + } + let mut remaining = MAX_VISITED_NODES; + let mut pending = vec![(tree.root_node(), 0, FunctionContext::default())]; + let mut output = Vec::new(); + while let Some((node, depth, mut context)) = pending.pop() { + charge(&mut remaining, depth)?; + match node.kind() { + "namespace_definition" => context.namespace = true, + "class_specifier" | "struct_specifier" | "union_specifier" => context.aggregate = true, + "linkage_specification" => context.explicit_linkage = true, + "function_definition" => { + if output.len() == MAX_FUNCTIONS { + return Err(AnalysisError::LimitExceeded); + } + output.push(definition(node, source, context, depth, &mut remaining)?); + continue; + } + _ => {} + } + if pending.len().saturating_add(node.child_count()) > remaining { + return Err(AnalysisError::LimitExceeded); + } + for index in (0..node.child_count()).rev() { + let index = u32::try_from(index).map_err(|_| AnalysisError::LimitExceeded)?; + if let Some(child) = node.child(index) { + pending.push((child, depth + 1, context)); + } + } + } + Ok(output) +} + +fn charge(remaining: &mut usize, depth: usize) -> Result<(), AnalysisError> { + if depth > MAX_DEPTH { + return Err(AnalysisError::LimitExceeded); + } + *remaining = remaining + .checked_sub(1) + .ok_or(AnalysisError::LimitExceeded)?; + Ok(()) +} + +fn definition( + node: Node<'_>, + source: &str, + context: FunctionContext, + root_depth: usize, + remaining: &mut usize, +) -> Result { + let header = node + .parent() + .filter(|parent| parent.kind() == "template_declaration") + .unwrap_or(node); + let body = node + .child_by_field_name("body") + .ok_or(AnalysisError::UnsupportedDefinition)?; + let range = header.start_byte()..body.start_byte(); + let mut removals = Vec::new(); + let mut pending = vec![(node, root_depth)]; + while let Some((part, depth)) = pending.pop() { + charge(remaining, depth)?; + if part.start_byte() >= range.end { + continue; + } + if part.kind() == "optional_parameter_declaration" { + let mut cursor = part.walk(); + let equals = part + .children(&mut cursor) + .find(|child| child.kind() == "=") + .ok_or(AnalysisError::UnsupportedDefinition)?; + let mut start = equals.start_byte(); + // A newline can terminate a preceding // comment. Removing it + // would comment out the following comma or closing parenthesis. + while start > part.start_byte() && matches!(source.as_bytes()[start - 1], b' ' | b'\t') + { + start -= 1; + } + removals.push(start..part.end_byte()); + continue; + } + if pending.len().saturating_add(part.child_count()) > *remaining { + return Err(AnalysisError::LimitExceeded); + } + for index in (0..part.child_count()).rev() { + let index = u32::try_from(index).map_err(|_| AnalysisError::LimitExceeded)?; + if let Some(child) = part.child(index) { + pending.push((child, depth + 1)); + } + } + } + let original = source + .get(range.clone()) + .ok_or(AnalysisError::UnsupportedDefinition)?; + // Copy retained spans once. Repeated in-place deletion would make a wide + // parameter list quadratic even though its source and node count are bounded. + let mut signature = String::with_capacity(original.len()); + let mut retained_start = range.start; + for remove in removals { + if remove.start < retained_start || remove.end > range.end { + return Err(AnalysisError::UnsupportedDefinition); + } + signature.push_str(&source[retained_start..remove.start]); + retained_start = remove.end; + } + signature.push_str(&source[retained_start..range.end]); + Ok(FunctionDefinition { + // Likewise keep a trailing newline before the removed function body: + // the caller may append a semicolon after a line-commented header. + signature: signature + .trim_start() + .trim_end_matches([' ', '\t']) + .to_owned(), + source_range: range, + context, + }) +} diff --git a/src/tauri.rs b/src/tauri.rs index e805fc81..b36193a8 100644 --- a/src/tauri.rs +++ b/src/tauri.rs @@ -285,6 +285,12 @@ impl NativeWebviewBackend { } }); + // The native getter synchronously routes to the event loop. Query on + // this creation worker, never inside the UI closure below. Script-free + // routes do not need a scale query or bootstrap context. + let bootstrap_source = request.bootstrap.as_ref().map(|script| { + script.for_origin(&request.url, dispatcher.scale_factor().unwrap_or(1.0)) + }); let window_for_ui = dispatcher.clone(); let completion_for_ui = Arc::clone(&completion); let terminal_for_ui = Arc::clone(&terminal); @@ -294,7 +300,7 @@ impl NativeWebviewBackend { &window_for_ui, request.url, request.permissions, - request.bootstrap, + bootstrap_source, completion_for_ui, terminal_for_ui, ); @@ -509,7 +515,7 @@ fn build_isolated_webview( dispatcher: &WryWindowDispatcher<()>, target: Url, permissions: WebviewPermissions, - bootstrap: Option, + bootstrap_source: Option, completion: Arc, terminal: Arc, ) -> Result { @@ -520,8 +526,7 @@ fn build_isolated_webview( let completion_for_popup = Arc::clone(&completion); let terminal_for_popup = Arc::clone(&terminal); let completion_for_load = Arc::clone(&completion); - let bootstrap_origin = bootstrap.as_ref().map(|_| target.origin()); - let bootstrap_source = bootstrap.as_ref().map(|script| script.for_origin(&target)); + let bootstrap_origin = bootstrap_source.as_ref().map(|_| target.origin()); let builder = WebViewBuilder::new() // Deliberately do not call `with_ipc_handler`: Wry documents that it // exposes `window.ipc.postMessage` to page JavaScript. @@ -645,6 +650,9 @@ pub enum WebviewError { WindowClosed, #[error("the webview host failed: {0}")] HostFailure(String), + /// Another timed or untimed terminal wait is currently pending. + #[error("a terminal webview wait is already active")] + TerminalWaitInProgress, } /// Semantic permissions for one external webview. @@ -694,6 +702,13 @@ pub enum PageBootstrapError { /// a block in the main frame's ordinary page world, not an isolated privileged /// world. No native IPC or guest ABI capability is installed. Runtime syntax /// errors follow normal page error reporting; they are not host-open errors. +/// +/// The block reserves the lexical binding `kernalWindow`: a frozen object with +/// `initialScaleFactor`, the native window's creation-time scale (physical +/// pixels per logical pixel). This finite positive snapshot is independent of +/// browser zoom, defaults to 1 on unavailable/invalid native data, and does not +/// update after moving between displays. It exposes no native methods or IPC. +/// Source must not redeclare `kernalWindow` in the same block. #[derive(Clone, Debug, PartialEq, Eq)] pub struct WebviewPageBootstrap { source: String, @@ -718,13 +733,18 @@ impl WebviewPageBootstrap { &self.source } - fn for_origin(&self, target: &Url) -> String { + fn for_origin(&self, target: &Url, native_scale: f64) -> String { // WebView2 injects into subframes regardless of Wry's main-only flag. // Guard in page code too, including against initial about:blank. // The origin is URL-canonicalized and JS-string escaped; source is // deliberately trusted caller code, never a remote page's input. + let scale = if native_scale.is_finite() && native_scale > 0.0 { + native_scale + } else { + 1.0 + }; format!( - "if (window === window.top && location.origin === \"{}\") {{\n{}\n}}\n", + "if (window === window.top && location.origin === \"{}\") {{\nconst kernalWindow = Object.freeze({{ initialScaleFactor: {scale} }});\n{}\n}}\n", target.origin().ascii_serialization().escape_default(), self.source ) @@ -807,6 +827,17 @@ pub struct WebviewHandle { store: u64, resource: OpaqueToken, terminal_operation: OpaqueToken, + terminal_wait_active: AtomicBool, +} + +// The hub terminal operation is single-consumer. Admission belongs to the +// borrowed future so cancellation releases it without revoking the window. +struct TerminalWaitGuard<'a>(&'a AtomicBool); + +impl Drop for TerminalWaitGuard<'_> { + fn drop(&mut self) { + self.0.store(false, Ordering::Release); + } } /// Acceptance-only semantic counters for the process-main-thread smoke test. @@ -990,6 +1021,7 @@ impl ExternalWebviewClient { store: self.store, resource, terminal_operation, + terminal_wait_active: AtomicBool::new(false), }) } Ok(Some(result)) => Err(map_terminal(result.terminal)), @@ -1101,7 +1133,31 @@ impl WebviewHandle { /// This is useful when an allowed top-level document finishes and then /// attempts a prohibited redirect or popup. It is itself a hub-owned /// operation, so callback completion never needs to retain a Store. + /// Only one terminal wait may be active; overlapping timed or untimed + /// waits return [`WebviewError::TerminalWaitInProgress`] without expiring + /// the window. Dropping the future releases that admission. pub async fn wait_until_terminal(&self, timeout: Duration) -> Result<(), WebviewError> { + self.wait_terminal(Some(timeout)).await + } + + /// Await user closure, cancellation, or a terminal host/security event + /// without imposing a lifetime deadline on an interactive window. + /// + /// Owns no timer and does not poll periodically. Dropping this borrowed + /// future leaves the window alive; a subsequent wait observes retained + /// terminal state, including an event that arrived between waits. Dropping + /// or cancelling the handle still revokes the window. As with the timed + /// variant, normal user closure is reported as [`WebviewError::WindowClosed`]. + /// Overlapping terminal waits return [`WebviewError::TerminalWaitInProgress`]. + pub async fn wait_for_terminal(&self) -> Result<(), WebviewError> { + self.wait_terminal(None).await + } + + async fn wait_terminal(&self, timeout: Option) -> Result<(), WebviewError> { + self.terminal_wait_active + .compare_exchange(false, true, Ordering::Acquire, Ordering::Relaxed) + .map_err(|_| WebviewError::TerminalWaitInProgress)?; + let _admission = TerminalWaitGuard(&self.terminal_wait_active); // A cancellation or window callback may have completed this operation // before the caller first awaits it. Poll first; if completion wins // the short race before suspension, consume that typed terminal below @@ -1120,12 +1176,16 @@ impl WebviewHandle { .wait_external_operation(self.store, self.terminal_operation) { Ok(wake) => { - if async_engine::timeout(timeout, wake.notified()) - .await - .is_err() - { - self.service - .revoke_with_terminal(self.resource, Terminal::TimedOut); + if let Some(timeout) = timeout { + if async_engine::timeout(timeout, wake.notified()) + .await + .is_err() + { + self.service + .revoke_with_terminal(self.resource, Terminal::TimedOut); + } + } else { + wake.notified().await; } } // Completion can race the poll above; the final observe below @@ -1323,6 +1383,21 @@ fn map_hub(error: HubError) -> WebviewError { #[cfg(test)] mod tests { + #[test] + fn bootstrap_exposes_native_scale_snapshot_before_caller_source() { + let target = Url::parse("http://127.0.0.1:8080/").unwrap(); + let bootstrap = + WebviewPageBootstrap::new("window.scale = kernalWindow.initialScaleFactor;").unwrap(); + for scale in [1.0, 1.25, 2.0] { + let wrapped = bootstrap.for_origin(&target, scale); + assert!(wrapped.contains(&format!("const kernalWindow = Object.freeze({{ initialScaleFactor: {scale} }});\nwindow.scale"))); + } + for invalid in [0.0, -1.0, f64::NAN, f64::INFINITY, f64::NEG_INFINITY] { + let wrapped = bootstrap.for_origin(&target, invalid); + assert!(wrapped.contains("initialScaleFactor: 1 }")); + } + } + use super::*; #[test] @@ -1351,7 +1426,7 @@ mod tests { None )); let bootstrap = WebviewPageBootstrap::new("window.marker = 1; // comment").unwrap(); - let wrapped = bootstrap.for_origin(&target); + let wrapped = bootstrap.for_origin(&target, 1.0); assert!(wrapped.starts_with( "if (window === window.top && location.origin === \"http://127.0.0.1:8080\") {\n" )); diff --git a/tests/command_schema_contract.rs b/tests/command_schema_contract.rs new file mode 100644 index 00000000..a10748e5 --- /dev/null +++ b/tests/command_schema_contract.rs @@ -0,0 +1,327 @@ +use kernal_api::command::{ + Command, CommandError, OptionSpec, ValueKind, MAX_ARGUMENTS, MAX_ARGUMENT_BYTES, +}; + +#[test] +fn parses_nested_commands_defaults_repeated_values_and_constraints() { + let schema = Command::new("fastled") + .option(OptionSpec::flag("quick")) + .option( + OptionSpec::value("link", ValueKind::enumeration(["static", "dynamic"])) + .default("static"), + ) + .subcommand( + Command::new("source").subcommand( + Command::new("update") + .option(OptionSpec::value("ref", ValueKind::string()).default("master")), + ), + ); + let parsed = schema + .parse(["fastled", "--quick", "source", "update", "--ref", "main"]) + .unwrap(); + assert_eq!(parsed.command_path(), ["fastled", "source", "update"]); + assert_eq!(parsed.flag("quick"), Some(true)); + assert_eq!(parsed.value("link"), Some("static")); + assert_eq!(parsed.value("ref"), Some("main")); +} + +#[test] +fn rejects_invalid_enumerations_and_bounds_before_backend_parsing() { + let schema = Command::new("fastled").option(OptionSpec::value( + "link", + ValueKind::enumeration(["static", "dynamic"]), + )); + assert_eq!( + schema.parse(["fastled", "--link", "unsupported"]), + Err(CommandError::InvalidArguments) + ); + assert_eq!( + schema.parse(["fastled", "--link", &"x".repeat(MAX_ARGUMENT_BYTES + 1)]), + Err(CommandError::ArgumentTooLarge) + ); + let too_many = std::iter::once("fastled").chain(std::iter::repeat_n("arg", MAX_ARGUMENTS)); + assert_eq!(schema.parse(too_many), Err(CommandError::TooManyArguments)); + let too_large = std::iter::once("fastled".to_owned()) + .chain(std::iter::repeat_n("x".repeat(MAX_ARGUMENT_BYTES), 17)) + .collect::>(); + assert_eq!(schema.parse(&too_large), Err(CommandError::InputTooLarge)); +} + +#[test] +fn invalid_schema_never_exposes_a_backend_error() { + let invalid = Command::new("fastled").option(OptionSpec::value( + "mode", + ValueKind::enumeration(Vec::::new()), + )); + assert_eq!(invalid.parse(["fastled"]), Err(CommandError::InvalidSchema)); + assert_eq!( + Command::new("fastled") + .option(OptionSpec::flag("bad\0name")) + .parse(["fastled"]), + Err(CommandError::InvalidSchema) + ); + assert_eq!( + Command::new("fastled") + .option(OptionSpec::flag("help")) + .parse(["fastled"]), + Err(CommandError::InvalidSchema) + ); + assert_eq!( + Command::new("fastled") + .option(OptionSpec::flag("-bad")) + .parse(["fastled"]), + Err(CommandError::InvalidSchema) + ); + assert_eq!( + Command::new("fastled") + .option(OptionSpec::flag("flag").default("true")) + .parse(["fastled"]), + Err(CommandError::InvalidSchema) + ); + assert_eq!( + Command::new("fastled") + .option(OptionSpec::flag("flag").optional_value("true")) + .parse(["fastled"]), + Err(CommandError::InvalidSchema) + ); + assert_eq!( + Command::new("fastled") + .option(OptionSpec::value("timeout", ValueKind::f64()).default("never")) + .parse(["fastled"]), + Err(CommandError::InvalidSchema) + ); + assert_eq!( + Command::new("fastled") + .option(OptionSpec::flag("same")) + .subcommand(Command::new("child").option(OptionSpec::flag("same"))) + .parse(["fastled"]), + Err(CommandError::InvalidSchema) + ); +} + +#[test] +fn root_and_sibling_subcommands_only_collect_selected_schema_values() { + let schema = Command::new("fastled") + .option(OptionSpec::flag("quick")) + .subcommand(Command::new("one").option(OptionSpec::value("first", ValueKind::string()))) + .subcommand(Command::new("two").option(OptionSpec::value("second", ValueKind::string()))); + let root = schema.parse(["fastled", "--quick"]).unwrap(); + assert_eq!(root.flag("quick"), Some(true)); + assert_eq!(root.value("first"), None); + let selected = schema + .parse(["fastled", "two", "--second", "value"]) + .unwrap(); + assert_eq!(selected.command_path(), ["fastled", "two"]); + assert_eq!(selected.value("second"), Some("value")); + assert_eq!(selected.value("first"), None); +} + +#[test] +fn optional_and_repeated_values_and_option_relations_are_enforced() { + let schema = Command::new("fastled") + .option( + OptionSpec::value("init", ValueKind::string()) + .optional_value("__init__") + .conflicts("purge"), + ) + .option(OptionSpec::flag("purge").conflicts("init")) + .option(OptionSpec::flag("test")) + .option(OptionSpec::flag("check")) + .option( + OptionSpec::value("test-cmd", ValueKind::string()) + .repeated() + .requires_any(["test", "check"]), + ) + .exclusive_group("production-test", ["test", "check"]); + + let parsed = schema + .parse([ + "fastled", + "--init", + "--test", + "--test-cmd=first", + "--test-cmd", + "second", + ]) + .unwrap(); + assert_eq!(parsed.value("init"), Some("__init__")); + assert_eq!( + parsed.values("test-cmd").unwrap(), + &["first".to_owned(), "second".to_owned()] + ); + assert_eq!( + schema.parse(["fastled", "--test-cmd=first"]), + Err(CommandError::InvalidArguments) + ); + assert_eq!( + schema.parse(["fastled", "--test", "--check"]), + Err(CommandError::InvalidArguments) + ); + assert_eq!( + schema.parse(["fastled", "--init", "--purge"]), + Err(CommandError::InvalidArguments) + ); +} + +#[test] +fn defaulted_options_do_not_count_as_explicit_relation_inputs() { + let schema = Command::new("fastled") + .option(OptionSpec::flag("test")) + .option( + OptionSpec::value("timeout", ValueKind::f64()) + .default("120") + .requires_any(["test"]), + ); + assert!(schema.parse(["fastled"]).is_ok()); + assert_eq!( + schema.parse(["fastled", "--timeout", "10"]), + Err(CommandError::InvalidArguments) + ); +} + +#[test] +fn optional_positionals_do_not_hide_subcommands() { + let schema = Command::new("fastled") + .optional_positional("directory", ValueKind::string()) + .subcommand( + Command::new("toolchain") + .subcommand(Command::new("activate").positional("package-id", ValueKind::string())), + ); + + let directory = schema.parse(["fastled", "sketch"]).unwrap(); + assert_eq!(directory.value("directory"), Some("sketch")); + assert_eq!(directory.command_path(), ["fastled"]); + + let nested = schema + .parse(["fastled", "toolchain", "activate", "wasm-3.1"]) + .unwrap(); + assert_eq!(nested.command_path(), ["fastled", "toolchain", "activate"]); + assert_eq!(nested.value("directory"), None); + assert_eq!(nested.value("package-id"), Some("wasm-3.1")); + assert_eq!( + schema.parse(["fastled", "toolchain", "activate"]), + Err(CommandError::InvalidArguments) + ); +} + +#[test] +fn typed_scalars_keep_their_types_and_reject_invalid_input() { + let schema = Command::new("fastled") + .option(OptionSpec::value("timeout", ValueKind::f64()).default("120")) + .option(OptionSpec::value("count", ValueKind::u32())); + + let parsed = schema + .parse(["fastled", "--timeout", "1.5", "--count", "10"]) + .unwrap(); + assert_eq!(parsed.f64("timeout"), Some(1.5)); + assert_eq!(parsed.u32("count"), Some(10)); + assert_eq!( + schema.parse(["fastled", "--timeout", "not-a-float"]), + Err(CommandError::InvalidArguments) + ); + assert_eq!( + schema.parse(["fastled", "--timeout", "NaN"]), + Err(CommandError::InvalidArguments) + ); + assert_eq!( + schema.parse(["fastled", "--timeout", "inf"]), + Err(CommandError::InvalidArguments) + ); + assert_eq!( + schema.parse(["fastled", "--count", "-1"]), + Err(CommandError::InvalidArguments) + ); +} + +#[test] +fn help_is_rendered_without_exposing_the_parser_backend() { + let schema = Command::new("fastled") + .about("FastLED WASM compilation CLI") + .option(OptionSpec::flag("quick").help("Build quickly.")) + .option( + OptionSpec::value("link", ValueKind::enumeration(["static", "dynamic"])) + .help("Select static or dynamic linking."), + ) + .subcommand(Command::new("source").about("Manage cached source.")); + + let help = schema.render_help(); + assert!(help.contains("Usage: fastled [OPTIONS] [COMMAND]")); + assert!(help.contains("FastLED WASM compilation CLI")); + assert!(help.contains("--quick")); + assert!(help.contains("Select static or dynamic linking.")); + assert!(help.contains("source")); + assert!(!help.contains("clap")); +} + +#[test] +fn hidden_options_parse_but_are_absent_from_help_and_double_dash_is_literal() { + let schema = Command::new("fastled") + .option(OptionSpec::flag("internal").hidden()) + .optional_positional("directory", ValueKind::string()); + + let hidden = schema.parse(["fastled", "--internal"]).unwrap(); + assert_eq!(hidden.flag("internal"), Some(true)); + assert!(!schema.render_help().contains("internal")); + + let literal = schema.parse(["fastled", "--", "--not-an-option"]).unwrap(); + assert_eq!(literal.value("directory"), Some("--not-an-option")); +} + +#[test] +fn version_is_rendered_from_facade_owned_metadata() { + let schema = Command::new("fastled").version("2.0.20"); + assert_eq!(schema.render_version(), "fastled 2.0.20\n"); +} + +#[cfg(unix)] +#[test] +fn non_utf8_native_arguments_require_an_os_string_schema_value() { + use std::os::unix::ffi::OsStringExt; + + let string_schema = + Command::new("fastled").optional_positional("directory", ValueKind::string()); + assert_eq!( + string_schema.parse([ + std::ffi::OsString::from("fastled"), + std::ffi::OsString::from_vec(vec![0xff]) + ]), + Err(CommandError::InvalidArguments) + ); + + let os_schema = + Command::new("fastled").optional_positional("directory", ValueKind::os_string()); + let parsed = os_schema + .parse([ + std::ffi::OsString::from("fastled"), + std::ffi::OsString::from_vec(vec![0xff]), + ]) + .unwrap(); + assert_eq!( + parsed.os_value("directory").unwrap().as_encoded_bytes(), + [0xff] + ); + + let guarded = Command::new("fastled") + .option(OptionSpec::flag("enabled")) + .option( + OptionSpec::value("path", ValueKind::os_string()) + .repeated() + .requires_any(["enabled"]), + ); + let inline = std::ffi::OsString::from_vec(b"--path=\xff".to_vec()); + assert_eq!( + guarded.parse([std::ffi::OsString::from("fastled"), inline.clone()]), + Err(CommandError::InvalidArguments) + ); + let parsed = guarded + .parse([ + std::ffi::OsString::from("fastled"), + std::ffi::OsString::from("--enabled"), + inline, + ]) + .unwrap(); + assert_eq!( + parsed.os_values("path").unwrap()[0].as_encoded_bytes(), + b"\xff" + ); +} diff --git a/tests/config_toml.rs b/tests/config_toml.rs new file mode 100644 index 00000000..37bf2055 --- /dev/null +++ b/tests/config_toml.rs @@ -0,0 +1,60 @@ +#![cfg(feature = "config-toml")] + +use kernal_api::config::{Document, ParseError, Value, MAX_INPUT_BYTES, MAX_NODES}; + +#[test] +fn preserves_toml_values_without_exposing_parser_types() { + let document = Document::parse_toml("title = '日本語'\nn = 42\nok = true\nf = 1.5\nwhen = 1979-05-27\n[flags]\nitems = ['-O0', '-pthread']\n").unwrap(); + let Value::Table(root) = document.root() else { + panic!("root table") + }; + assert_eq!(root["title"], Value::String("日本語".into())); + assert_eq!(root["n"], Value::Integer(42)); + assert_eq!(root["ok"], Value::Boolean(true)); + assert_eq!(root["f"], Value::Float(1.5)); + assert_eq!(root["when"], Value::DateTime("1979-05-27".into())); + let Value::Table(flags) = &root["flags"] else { + panic!("flags table") + }; + assert_eq!( + flags["items"], + Value::Array(vec![ + Value::String("-O0".into()), + Value::String("-pthread".into()) + ]) + ); +} + +#[test] +fn rejects_invalid_documents_and_honors_input_bound() { + for source in ["x =", "x=1\nx=2", "x='unterminated"] { + assert!(matches!( + Document::parse_toml(source), + Err(ParseError::InvalidSyntax) + )); + } + let source = format!("#{}", " ".repeat(MAX_INPUT_BYTES - 1)); + assert!(Document::parse_toml(&source).is_ok()); + assert!(matches!( + Document::parse_toml(&(source + " ")), + Err(ParseError::InputTooLarge) + )); +} + +#[test] +fn decoded_node_and_depth_limits_are_exact() { + let source = format!("x=[{}]", vec!["0"; MAX_NODES - 2].join(",")); + assert!(Document::parse_toml(&source).is_ok()); + let source = format!("x=[{}]", vec!["0"; MAX_NODES - 1].join(",")); + assert!(matches!( + Document::parse_toml(&source), + Err(ParseError::TooManyNodes) + )); + let source = format!("x={}0{}", "[".repeat(31), "]".repeat(31)); + assert!(Document::parse_toml(&source).is_ok()); + let source = format!("x={}0{}", "[".repeat(32), "]".repeat(32)); + assert!(matches!( + Document::parse_toml(&source), + Err(ParseError::TooDeep) + )); +} diff --git a/tests/context_file_observation.rs b/tests/context_file_observation.rs index 3d701496..bfd5e615 100644 --- a/tests/context_file_observation.rs +++ b/tests/context_file_observation.rs @@ -3,8 +3,11 @@ use std::fs; use std::io::ErrorKind; +#[cfg(unix)] use kernal_api::platform::fs::{ canonical_context_path, context_path_metadata_no_follow, read_context_link, +}; +use kernal_api::platform::fs::{ read_context_regular_file_bounded, ContextPathKind, MAX_CONTEXT_REGULAR_FILE_BYTES, }; diff --git a/tests/facade_policy.rs b/tests/facade_policy.rs index 95e68606..1d78bb51 100644 --- a/tests/facade_policy.rs +++ b/tests/facade_policy.rs @@ -740,7 +740,7 @@ fn public_type_positions(source: &str) -> Vec<(usize, &str)> { } #[test] -fn json_is_confined_to_the_external_firefox_export() { +fn json_backend_is_confined_to_owned_document_and_firefox_adapters() { let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("src"); for path in rust_sources(&root) { let relative = path.strip_prefix(&root).expect("source below root"); @@ -750,9 +750,9 @@ fn json_is_confined_to_the_external_firefox_export() { assert!( matches!( normalized.as_str(), - "profile/export/firefox.rs" | "profile/tests.rs" + "json.rs" | "profile/export/firefox.rs" | "profile/tests.rs" ), - "{} uses JSON outside the Firefox export boundary", + "{} uses JSON outside the owned adapter boundaries", path.display() ); } diff --git a/tests/json_documents.rs b/tests/json_documents.rs new file mode 100644 index 00000000..40b64c11 --- /dev/null +++ b/tests/json_documents.rs @@ -0,0 +1,201 @@ +#![cfg(feature = "json")] + +use kernal_api::json::{encode, parse, Error, Layout, Value}; + +#[test] +fn member_parsing_retains_nested_duplicates_and_order() { + use kernal_api::json::parse_members; + let source = br#"{"a":null,"a":1,"nested":[{"x":false,"x":true}]}"#; + let value = parse_members(source).unwrap(); + assert_eq!( + value, + Value::ObjectMembers(vec![ + ("a".into(), Value::Null), + ("a".into(), Value::Signed(1)), + ( + "nested".into(), + Value::Array(vec![Value::ObjectMembers(vec![ + ("x".into(), Value::Bool(false)), + ("x".into(), Value::Bool(true)), + ])]) + ), + ]) + ); + assert_eq!(encode(&value, Layout::Compact).unwrap(), source); + assert_ne!(parse(source).unwrap(), value); +} + +#[test] +fn member_parser_bounds_count_repeated_values_and_depth() { + use kernal_api::json::{parse_members, MAX_DEPTH, MAX_INPUT_BYTES, MAX_NODES}; + let source = format!("{{{}\"x\":null}}", "\"x\":null,".repeat(MAX_NODES - 2)); + let Value::ObjectMembers(members) = parse_members(source.as_bytes()).unwrap() else { + panic!("member object") + }; + assert_eq!(members.len(), MAX_NODES - 1); + let source = format!("{{{}\"x\":null}}", "\"x\":null,".repeat(MAX_NODES - 1)); + assert_eq!(parse_members(source.as_bytes()), Err(Error::TooManyNodes)); + for depth in [MAX_DEPTH, MAX_DEPTH + 1] { + let source = format!("{}null{}", "[".repeat(depth), "]".repeat(depth)); + let result = parse_members(source.as_bytes()); + if depth == MAX_DEPTH { + assert!(result.is_ok()); + } else { + assert_eq!(result, Err(Error::TooDeep)); + } + } + let mut source = vec![b' '; MAX_INPUT_BYTES]; + source[0] = b'0'; + assert_eq!(parse_members(&source), Ok(Value::Signed(0))); + source.push(b' '); + assert_eq!(parse_members(&source), Err(Error::InputTooLarge)); + for source in [b"[".as_slice(), b"{} {}", b"\xff", b"{\"a\":1,}"] { + assert_eq!(parse_members(source), Err(Error::InvalidSyntax)); + } +} + +#[test] +fn member_parser_keeps_scalar_contract_and_decoded_key_spelling() { + use kernal_api::json::parse_members; + for source in [ + "null", + "true", + "-9223372036854775808", + "18446744073709551615", + "1.5", + "-0.0", + "\"日本\\ntext\"", + ] { + assert_eq!( + parse_members(source.as_bytes()).unwrap(), + parse(source.as_bytes()).unwrap() + ); + } + assert_eq!( + parse_members(br#"{"a":null,"\u0061":true}"#).unwrap(), + Value::ObjectMembers(vec![ + ("a".into(), Value::Null), + ("a".into(), Value::Bool(true)) + ]) + ); +} + +#[test] +fn member_parser_does_not_confuse_user_keys_with_private_number_markers() { + let key = "$serde_json::private::Number"; + let source = format!("{{\"{key}\":\"1.5\",\"{key}\":2.5}}"); + assert_eq!( + kernal_api::json::parse_members(source.as_bytes()).unwrap(), + Value::ObjectMembers(vec![ + (key.into(), Value::String("1.5".into())), + (key.into(), Value::Float(2.5)), + ]) + ); +} + +#[test] +fn json_values_preserve_integer_extrema_and_unicode() { + let value = + parse(br#"[-9223372036854775808,18446744073709551615,"\u65e5\u672c",null,true,1.5]"#) + .unwrap(); + assert_eq!( + value, + Value::Array(vec![ + Value::Signed(i64::MIN), + Value::Unsigned(u64::MAX), + Value::String("日本".into()), + Value::Null, + Value::Bool(true), + Value::Float(1.5) + ]) + ); + assert_eq!( + parse(&encode(&value, Layout::Compact).unwrap()).unwrap(), + value + ); +} + +#[test] +fn objects_have_last_key_wins_and_deterministic_layout() { + let value = parse(br#"{"z":1,"a":"x","z":2}"#).unwrap(); + assert_eq!( + encode(&value, Layout::Compact).unwrap(), + br#"{"a":"x","z":2}"# + ); + assert_eq!( + encode(&value, Layout::Pretty).unwrap(), + b"{\n \"a\": \"x\",\n \"z\": 2\n}" + ); +} + +#[test] +fn malformed_input_and_nonfinite_values_are_errors() { + for input in [b"[".as_slice(), b"{} trailing", b"\xff", b"[1,]"] { + assert_eq!(parse(input), Err(Error::InvalidSyntax)); + } + assert_eq!( + encode(&Value::Float(f64::NAN), Layout::Compact), + Err(Error::NonFiniteNumber) + ); +} + +#[test] +fn source_and_output_byte_limits_are_exact() { + use kernal_api::json::{MAX_INPUT_BYTES, MAX_OUTPUT_BYTES}; + let mut source = vec![b' '; MAX_INPUT_BYTES]; + source[0] = b'0'; + assert_eq!(parse(&source), Ok(Value::Signed(0))); + source.push(b' '); + assert_eq!(parse(&source), Err(Error::InputTooLarge)); + let value = Value::String("a".repeat(MAX_OUTPUT_BYTES - 2)); + assert_eq!( + encode(&value, Layout::Compact).unwrap().len(), + MAX_OUTPUT_BYTES + ); + assert_eq!( + encode( + &Value::String("a".repeat(MAX_OUTPUT_BYTES - 1)), + Layout::Compact + ), + Err(Error::OutputTooLarge) + ); + // Escaping expands bytes, and must be included in the output bound. + assert_eq!( + encode( + &Value::String("\n".repeat(MAX_OUTPUT_BYTES / 2)), + Layout::Compact + ), + Err(Error::OutputTooLarge) + ); +} + +#[test] +fn decoded_and_constructed_tree_limits_are_exact() { + use kernal_api::json::{MAX_DEPTH, MAX_NODES}; + let value = Value::Array(vec![Value::Null; MAX_NODES - 1]); + let encoded = encode(&value, Layout::Compact).unwrap(); + assert_eq!(parse(&encoded).unwrap(), value); + let oversized = Value::Array(vec![Value::Null; MAX_NODES]); + assert_eq!( + encode(&oversized, Layout::Compact), + Err(Error::TooManyNodes) + ); + let source = format!("[{}null]", "null,".repeat(MAX_NODES - 1)); + assert_eq!(parse(source.as_bytes()), Err(Error::TooManyNodes)); + let mut value = Value::Null; + for _ in 0..MAX_DEPTH { + value = Value::Array(vec![value]); + } + assert_eq!( + parse(&encode(&value, Layout::Compact).unwrap()).unwrap(), + value + ); + value = Value::Array(vec![value]); + assert_eq!(encode(&value, Layout::Compact), Err(Error::TooDeep)); + let source = format!( + "{}null{}", + "[".repeat(MAX_DEPTH + 1), + "]".repeat(MAX_DEPTH + 1) + ); + assert_eq!(parse(source.as_bytes()), Err(Error::TooDeep)); +} diff --git a/tests/posix_arguments.rs b/tests/posix_arguments.rs new file mode 100644 index 00000000..e4d5c2b8 --- /dev/null +++ b/tests/posix_arguments.rs @@ -0,0 +1,58 @@ +#![cfg(feature = "command-arguments")] + +use kernal_api::arguments::{ + parse_posix, ArgumentParseError, MAX_POSIX_ARGUMENTS, MAX_POSIX_INPUT_BYTES, +}; + +#[test] +fn tool_arguments_preserve_quoting_without_expansion() { + assert_eq!( + parse_posix("-I'/path with spaces' \"\" '日本語' '$HOME' '*.cpp' $(literal)").unwrap(), + [ + "-I/path with spaces", + "", + "日本語", + "$HOME", + "*.cpp", + "$(literal)" + ] + ); +} + +#[test] +fn escapes_comments_and_empty_input_keep_tool_output_semantics() { + assert_eq!(parse_posix(" # ignored\n").unwrap(), Vec::::new()); + assert_eq!( + parse_posix("a\\ b c\\\nd 'x#y' # ignored\nend").unwrap(), + ["a b", "cd", "x#y", "end"] + ); + assert_eq!( + parse_posix("'open"), + Err(ArgumentParseError::UnterminatedQuote) + ); + assert_eq!( + parse_posix("\"open"), + Err(ArgumentParseError::UnterminatedQuote) + ); + assert_eq!(parse_posix("a\0b"), Err(ArgumentParseError::ContainsNul)); +} + +#[test] +fn input_bytes_and_output_count_have_exact_limits() { + let limit = "é".repeat(MAX_POSIX_INPUT_BYTES / 2); + assert_eq!(parse_posix(&limit).unwrap(), std::slice::from_ref(&limit)); + assert_eq!( + parse_posix(&(limit + "x")), + Err(ArgumentParseError::InputTooLarge) + ); + assert_eq!( + parse_posix(&"x ".repeat(MAX_POSIX_ARGUMENTS)) + .unwrap() + .len(), + MAX_POSIX_ARGUMENTS + ); + assert_eq!( + parse_posix(&"x ".repeat(MAX_POSIX_ARGUMENTS + 1)), + Err(ArgumentParseError::TooManyArguments) + ); +} diff --git a/tests/source_cpp.rs b/tests/source_cpp.rs new file mode 100644 index 00000000..29fd4c76 --- /dev/null +++ b/tests/source_cpp.rs @@ -0,0 +1,108 @@ +#![cfg(feature = "source-cpp")] + +use kernal_api::source::{analyze_cpp, AnalysisError}; + +#[test] +fn extracts_definitions_without_arduino_policy() { + let functions = + analyze_cpp("void setup() {}\nint helper(int value = 1) { return value; }").unwrap(); + assert_eq!(functions.len(), 2); + assert_eq!(functions[0].signature, "void setup()"); + assert_eq!(functions[1].signature, "int helper(int value)"); + assert!(!functions[1].context.namespace); +} + +#[test] +fn preserves_scope_and_linkage_for_consumer_selection() { + let functions = analyze_cpp( + "namespace n { void f() {} }\nstruct S { void m() {} };\nextern \"C\" { void hook() {} }", + ) + .unwrap(); + assert_eq!(functions.len(), 3); + assert!(functions[0].context.namespace); + assert!(functions[1].context.aggregate); + assert!(functions[2].context.explicit_linkage); +} + +#[test] +fn rejects_incomplete_syntax_without_partial_results() { + assert_eq!( + analyze_cpp("void good() {}\nvoid bad("), + Err(AnalysisError::InvalidSyntax) + ); +} + +#[test] +fn strips_syntax_defaults_not_commas_inside_expressions() { + let functions = analyze_cpp( + r#"int helper(int x = (1 < 2 ? 3 : 4), const char* text = "a,b") { return x; }"#, + ) + .unwrap(); + assert_eq!( + functions[0].signature, + "int helper(int x, const char* text)" + ); + let functions = + analyze_cpp("template \nT f(T value = T{}) { return value; }").unwrap(); + assert_eq!( + functions[0].signature, + "template \nT f(T value)" + ); +} + +#[test] +fn enforces_source_and_traversal_bounds() { + use kernal_api::source::{MAX_DEPTH, MAX_SOURCE_BYTES}; + assert_eq!( + analyze_cpp(&" ".repeat(MAX_SOURCE_BYTES + 1)), + Err(AnalysisError::InputTooLarge) + ); + let source = format!( + "{}void f() {{}}{}", + "namespace n {".repeat(MAX_DEPTH + 1), + "}".repeat(MAX_DEPTH + 1) + ); + assert_eq!(analyze_cpp(&source), Err(AnalysisError::LimitExceeded)); +} + +#[test] +fn preserves_line_comment_terminators_when_removing_defaults() { + let functions = analyze_cpp( + "int f(int x // keep parameter comment\n = 1) // keep header comment\n { return x; }", + ) + .unwrap(); + assert_eq!( + functions[0].signature, + "int f(int x // keep parameter comment\n) // keep header comment\n" + ); + // Appending a declaration terminator must not put it inside a comment. + let declaration = format!("{};", functions[0].signature); + assert_eq!(analyze_cpp(&declaration).unwrap(), vec![]); +} + +#[test] +fn retains_attributes_function_pointer_parameters_and_source_ranges() { + let source = + "// prefix\n[[nodiscard]] int f(int (*callback)(int) = nullptr) noexcept { return 1; }"; + let functions = analyze_cpp(source).unwrap(); + assert_eq!( + functions[0].signature, + "[[nodiscard]] int f(int (*callback)(int)) noexcept" + ); + assert_eq!( + &source[functions[0].source_range.clone()], + "[[nodiscard]] int f(int (*callback)(int) = nullptr) noexcept " + ); +} + +#[test] +fn multiple_defaults_with_lambdas_and_braced_values_keep_parameter_order() { + let functions = analyze_cpp( + "void f(int x = [] { return 1; }(), Pair y = Pair{1, 2}, const char* z = \"é,終\") {}", + ) + .unwrap(); + assert_eq!( + functions[0].signature, + "void f(int x, Pair y, const char* z)" + ); +}