diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6ce9901 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +/target/ +/.ultimate-tool/ +/fixtures/rust-mos-smoke/target/ +/fixtures/rust-mos-smoke/build/ diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..ed23999 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,1815 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "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 = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cc" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5add81bb678e6cb321aff7fa0dc7689ad82b112dbc032cea19f91d6b8e3582b9" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "chacha20" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core", +] + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "clap" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[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_derive" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-channel" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" + +[[package]] +name = "futures-io" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4577ecaa3c4f96589d473f679a71b596316f6641bc350038b962a5daf0085d7a" + +[[package]] +name = "futures-sink" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e34418ac499d6305c2fb5ad0ed2f6ac998c5f8ca209b4510f7f94242c647e307" + +[[package]] +name = "futures-task" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" + +[[package]] +name = "futures-util" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" +dependencies = [ + "futures-core", + "futures-io", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi", + "rand_core", + "wasm-bindgen", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9f41fd6a08e4d4ec69df65976da761afd5ad5e58a9d4acb46bd1c953a9e3ff2" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hyper" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d22053281f852e11534f5198498373cbb59295120a20771d90f7ed1897490a72" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.99" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "142bc4740e452c1e57ade0cbc129f139c9093e354346f0872ef985f4f5cf5f11" +dependencies = [ + "cfg-if", + "futures-util", + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quinn" +version = "0.11.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560" +dependencies = [ + "bytes", + "getrandom 0.4.3", + "lru-slab", + "rand", + "rand_pcg", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.61.2", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core", +] + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "thiserror" +version = "2.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "socket2", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "toml" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_parser" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +dependencies = [ + "winnow", +] + +[[package]] +name = "toml_writer" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-core", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "ultimate-tool" +version = "0.1.0" +dependencies = [ + "clap", + "serde_json", + "ultimate-tool-core", +] + +[[package]] +name = "ultimate-tool-core" +version = "0.1.0" +dependencies = [ + "chrono", + "libc", + "png", + "reqwest", + "serde", + "serde_json", + "sha2", + "tempfile", + "thiserror", + "toml", + "uuid", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid" +version = "1.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ed04576f974d2b2fba0f38c51dbc5518011e38c36bf1143164be765528fd409" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.72" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9473dbd2991ae90b6291c3c32c30c6187ac49aa32f9905d1cce280ec1e110b0f" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "916151b09da36bd82f6615cbf3a419e2f0ba23a03c6160e8e92eb6bd4aa1dec6" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "299047362ccbfce148b67ab7e73349f77748e00c8296f9542adfad2ad82c5c5e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.119", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a929b2c61f11ba3e9bc35b50c1f25cb38e0e892c0c231ae2b8cf78d5dad4437" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.99" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d621441cfc37b84979402712047321980c178f299193a3589d05b99e8763436" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-roots" +version = "1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf85cb06032201fa7c6f829d7db5a7e5aa45bcc0655327713065f6f0576731bf" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "windows-core" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba6d44ec8c2591c134257ce647b7ea6b20335bf6379a27dac5f1641fcf59f99" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-result", + "windows-strings", + "windows-targets", +] + +[[package]] +name = "windows-implement" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bbd5b46c938e506ecbce286b6628a02171d56153ba733b6c741fc627ec9579b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053c4c462dc91d3b1504c6fe5a726dd15e216ba718e84a0e46a88fbe5ded3515" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d1043d8214f791817bab27572aaa8af63732e11bf84aa21a45a78d6c317ae0e" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-strings" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10" +dependencies = [ + "windows-result", + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winnow" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09dac053f1cd375980747450bfc7250c264eaae0583872e845c0c7cd578872b5" + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..5b05064 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,30 @@ +[workspace] +members = [ + "crates/ultimate-tool-core", + "crates/ultimate-tool-cli", +] +resolver = "2" + +[workspace.package] +edition = "2024" +version = "0.1.0" + +[workspace.dependencies] +chrono = { version = "0.4.45", features = ["serde"] } +clap = { version = "4.6.1", features = ["derive"] } +libc = "0.2.189" +png = "0.18.1" +reqwest = { version = "0.12.28", default-features = false, features = ["blocking", "json", "rustls-tls"] } +serde = { version = "1.0.229", features = ["derive"] } +serde_json = "1.0.151" +sha2 = "0.10.9" +tempfile = "3.27.0" +thiserror = "2.0.19" +toml = "1.1.0" +uuid = { version = "1.23.0", features = ["v4", "serde"] } + +[workspace.lints.rust] +unsafe_op_in_unsafe_fn = "deny" + +[workspace.lints.clippy] +all = "warn" diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..57084b2 --- /dev/null +++ b/Makefile @@ -0,0 +1,19 @@ +.PHONY: check validate fixture test-fake test-vice + +check: + cargo fmt --all -- --check + cargo clippy --workspace --all-targets -- -D warnings + cargo test --workspace + git diff --check + +validate: + cargo run -q -p ultimate-tool -- validate + +fixture: + $(MAKE) -C fixtures/rust-mos-smoke build + +test-fake: + cargo run -q -p ultimate-tool -- test smoke --target fake + +test-vice: + cargo run -q -p ultimate-tool -- test smoke --target vice diff --git a/README.md b/README.md index 15f0293..dd43d4f 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,106 @@ # Ultimate Tool -Ultimate Tool is a proposed modern, hardware-in-the-loop development platform -for the Commodore 64. +Ultimate Tool is an early modern, hardware-in-the-loop development platform for +the Commodore 64. Its purpose is to make ambitious C64 projects easier to attempt, understand, test, and finish. It will join modern build tools, VICE, C64 Ultimate hardware, real-hardware evidence, human-facing tools, and optional AI workflows behind one deterministic development core. +## Current v0.1 slice + +The repository now contains a CLI-driven walking skeleton that: + +- Builds one tiny Rust-MOS project with a pinned container toolchain +- Validates the resulting PRG load address, size, and SHA-256 +- Runs the same artifact against a deterministic fake target or VICE 3.8 +- Can probe and run it on an explicitly identified C64 Ultimate +- Observes ready, log, assertion, pass, and fail events through a versioned + C64-memory mailbox +- Verifies a stable visible screen marker +- Writes immutable, checksummed evidence beneath `.ultimate-tool/runs/` + +The core is a Rust library used directly by the CLI. A long-running service, +web dashboard, MCP interface, UCI event transport, and broader toolchain support +remain later milestones. + +## Requirements + +- Rust 1.93 or a compatible stable toolchain +- Docker +- The pinned image + `ghcr.io/koalabuttz/rust-mos:ac2fb2277-4537158-4aaa40e16` +- VICE `x64sc` 3.8 for emulator acceptance +- For hardware runs, a C64 Ultimate on a trusted LAN with Web Remote Control + enabled + +## Quick start + +```sh +cargo run -q -p ultimate-tool -- validate +cargo run -q -p ultimate-tool -- test smoke --target fake +cargo run -q -p ultimate-tool -- test smoke --target vice +``` + +The matrix form builds once and sends the identical PRG to both targets: + +```sh +cargo run -q -p ultimate-tool -- test smoke \ + --target vice \ + --target ultimate \ + --device DEVICE_HOST \ + --expect-device-id DEVICE_ID \ + --allow-reset +``` + +Use `probe` first to obtain and verify the identity: + +```sh +cargo run -q -p ultimate-tool -- probe ultimate --device DEVICE_HOST +``` + +If the Ultimate has a network password, place it in `C64U_PASSWORD`; never put +it in the manifest or command line: + +```sh +read -rsp 'Ultimate password: ' C64U_PASSWORD +export C64U_PASSWORD +``` + +`--allow-reset` is required on every hardware test. `run_prg` resets the C64, +DMA-loads the PRG, and starts it. Ultimate Tool verifies the intended device +before that mutation and never retries an ambiguously accepted deployment. + +## Evidence + +Every target attempt receives a separate run directory, including build or +runtime failures. A completed bundle includes: + +- `run.json` and normalized `events.jsonl` +- Manifest and exact artifact snapshots +- Build and target logs +- Artifact load address, size, SHA-256, toolchain tag, and local image digest +- Target identity, capabilities, confirmations, mutations, and checkpoints +- Raw mailbox, screen, color, decoded text, and VICE PNG evidence +- `SHA256SUMS` + +Hardware evidence records REST transport separately from observable program +success. Emulator evidence is never reported as physical-device proof. + +## Development + +```sh +make check +make fixture +make test-fake +make test-vice +``` + +The project contract is [`c64dev.toml`](c64dev.toml). The C64/host wire layout +is documented in +[`docs/MAILBOX_PROTOCOL.md`](docs/MAILBOX_PROTOCOL.md). + ## Product documents - [Accepted developer-platform decision](DEVELOPER_PLATFORM_VISION.md) diff --git a/c64dev.toml b/c64dev.toml new file mode 100644 index 0000000..3d1d191 --- /dev/null +++ b/c64dev.toml @@ -0,0 +1,30 @@ +schema_version = 1 + +[project] +name = "ultimate-tool-smoke" + +[build] +command = ["make", "build"] +working_directory = "fixtures/rust-mos-smoke" +artifact = "fixtures/rust-mos-smoke/build/ultimate-tool-smoke.prg" +toolchain = "ghcr.io/koalabuttz/rust-mos:ac2fb2277-4537158-4aaa40e16" + +[diagnostics] +address = 49152 +length = 256 +screen_address = 1024 +color_address = 55296 + +[vice] +executable = "x64sc" +poll_interval_ms = 50 + +[ultimate] +poll_interval_ms = 250 +request_timeout_ms = 8000 + +[[tests]] +name = "smoke" +timeout_ms = 10000 +expected_terminal = "pass" +expected_screen = "ULTIMATE TOOL SMOKE" diff --git a/crates/ultimate-tool-cli/Cargo.toml b/crates/ultimate-tool-cli/Cargo.toml new file mode 100644 index 0000000..de0d455 --- /dev/null +++ b/crates/ultimate-tool-cli/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "ultimate-tool" +version.workspace = true +edition.workspace = true +publish = false + +[[bin]] +name = "ultimate-tool" +path = "src/main.rs" + +[dependencies] +clap.workspace = true +serde_json.workspace = true +ultimate-tool-core = { path = "../ultimate-tool-core" } + +[lints] +workspace = true diff --git a/crates/ultimate-tool-cli/src/main.rs b/crates/ultimate-tool-cli/src/main.rs new file mode 100644 index 0000000..803ad11 --- /dev/null +++ b/crates/ultimate-tool-cli/src/main.rs @@ -0,0 +1,155 @@ +use std::{path::PathBuf, process::ExitCode, time::Duration}; + +use clap::{Args, Parser, Subcommand, ValueEnum}; +use ultimate_tool_core::{ + LoadedManifest, ProbeRequest, RunOutcome, TargetKind, TestRequest, probe_ultimate, + run_test_matrix, target::fake::FakeScenario, +}; + +#[derive(Debug, Parser)] +#[command(version, about = "Hardware-in-the-loop C64 development")] +struct Cli { + #[command(subcommand)] + command: Command, +} + +#[derive(Debug, Subcommand)] +enum Command { + /// Validate a c64dev.toml project contract. + Validate(ManifestArgs), + /// Probe a target without mutating it. + Probe { + #[command(subcommand)] + target: ProbeTarget, + }, + /// Build once and execute a named test on one or more targets. + Test(TestArgs), +} + +#[derive(Debug, Args)] +struct ManifestArgs { + #[arg(long, default_value = "c64dev.toml")] + manifest: PathBuf, +} + +#[derive(Debug, Subcommand)] +enum ProbeTarget { + /// Read C64 Ultimate REST identity and version information. + Ultimate { + #[arg(long)] + device: String, + #[arg(long, default_value_t = 8_000)] + timeout_ms: u64, + }, +} + +#[derive(Debug, Args)] +struct TestArgs { + test: String, + #[arg(long, default_value = "c64dev.toml")] + manifest: PathBuf, + #[arg(long, value_enum, required = true)] + target: Vec, + #[arg(long)] + device: Option, + #[arg(long)] + expect_device_id: Option, + #[arg(long)] + expect_hostname: Option, + #[arg(long)] + allow_reset: bool, + #[arg(long, value_enum, default_value_t = FakeChoice::Pass, hide = true)] + fake_scenario: FakeChoice, +} + +#[derive(Debug, Clone, Copy, ValueEnum)] +enum TargetChoice { + Fake, + Vice, + Ultimate, +} + +#[derive(Debug, Clone, Copy, ValueEnum)] +enum FakeChoice { + Pass, + Fail, + Timeout, + Malformed, +} + +fn main() -> ExitCode { + match execute(Cli::parse()) { + Ok(code) => code, + Err(error) => { + eprintln!("error: {error}"); + ExitCode::from(error.exit_code()) + } + } +} + +fn execute(cli: Cli) -> ultimate_tool_core::Result { + match cli.command { + Command::Validate(arguments) => { + let manifest = LoadedManifest::load(arguments.manifest)?; + println!( + "valid: {} (schema {})", + manifest.manifest.project.name, manifest.manifest.schema_version + ); + Ok(ExitCode::SUCCESS) + } + Command::Probe { + target: ProbeTarget::Ultimate { device, timeout_ms }, + } => { + let identity = probe_ultimate(&ProbeRequest { + host: device, + request_timeout: Duration::from_millis(timeout_ms), + })?; + println!( + "{}", + serde_json::to_string_pretty(&identity).expect("target identity serializes") + ); + Ok(ExitCode::SUCCESS) + } + Command::Test(arguments) => { + let summaries = run_test_matrix(&TestRequest { + manifest_path: arguments.manifest, + test: arguments.test, + targets: arguments + .target + .into_iter() + .map(|target| match target { + TargetChoice::Fake => TargetKind::Fake, + TargetChoice::Vice => TargetKind::Vice, + TargetChoice::Ultimate => TargetKind::Ultimate, + }) + .collect(), + device: arguments.device, + expected_device_id: arguments.expect_device_id, + expected_hostname: arguments.expect_hostname, + allow_reset: arguments.allow_reset, + fake_scenario: match arguments.fake_scenario { + FakeChoice::Pass => FakeScenario::Pass, + FakeChoice::Fail => FakeScenario::Fail, + FakeChoice::Timeout => FakeScenario::Timeout, + FakeChoice::Malformed => FakeScenario::Malformed, + }, + })?; + let mut exit_code = 0_u8; + for summary in summaries { + println!( + "{}: {:?} evidence={}", + summary.target, + summary.outcome, + summary.directory.display() + ); + if let Some(error) = summary.error { + println!(" {error}"); + } + if summary.outcome != RunOutcome::Passed { + exit_code = exit_code.max(summary.exit_code); + } + } + Ok(ExitCode::from(exit_code)) + } + } +} diff --git a/crates/ultimate-tool-core/Cargo.toml b/crates/ultimate-tool-core/Cargo.toml new file mode 100644 index 0000000..6ff0f76 --- /dev/null +++ b/crates/ultimate-tool-core/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "ultimate-tool-core" +version.workspace = true +edition.workspace = true +publish = false + +[dependencies] +chrono.workspace = true +libc.workspace = true +png.workspace = true +reqwest.workspace = true +serde.workspace = true +serde_json.workspace = true +sha2.workspace = true +thiserror.workspace = true +toml.workspace = true +uuid.workspace = true + +[dev-dependencies] +tempfile.workspace = true + +[lints] +workspace = true diff --git a/crates/ultimate-tool-core/src/artifact.rs b/crates/ultimate-tool-core/src/artifact.rs new file mode 100644 index 0000000..412e5e6 --- /dev/null +++ b/crates/ultimate-tool-core/src/artifact.rs @@ -0,0 +1,73 @@ +use std::{fs, path::PathBuf}; + +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; + +use crate::{Error, Result}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Artifact { + pub source_path: PathBuf, + pub bundle_name: String, + pub size: u64, + pub load_address: u16, + pub sha256: String, + #[serde(skip)] + pub bytes: Vec, +} + +impl Artifact { + pub fn load(path: PathBuf) -> Result { + let bytes = fs::read(&path) + .map_err(|error| Error::Artifact(format!("cannot read {}: {error}", path.display())))?; + if bytes.len() < 3 { + return Err(Error::Artifact(format!( + "{} is not a PRG with a load address and payload", + path.display() + ))); + } + let size = u64::try_from(bytes.len()) + .map_err(|_| Error::Artifact("artifact size does not fit u64".into()))?; + let load_address = u16::from_le_bytes([bytes[0], bytes[1]]); + let sha256 = format!("{:x}", Sha256::digest(&bytes)); + let file_name = path + .file_name() + .and_then(|name| name.to_str()) + .ok_or_else(|| Error::Artifact("artifact has no UTF-8 file name".into()))? + .to_owned(); + Ok(Self { + source_path: path, + bundle_name: format!("artifact-{file_name}"), + size, + load_address, + sha256, + bytes, + }) + } +} + +#[cfg(test)] +mod tests { + use std::io::Write; + + use tempfile::NamedTempFile; + + use super::*; + + #[test] + fn reads_prg_identity() { + let mut file = NamedTempFile::new().unwrap(); + file.write_all(&[0x01, 0x08, 0xea]).unwrap(); + let artifact = Artifact::load(file.path().to_path_buf()).unwrap(); + assert_eq!(artifact.load_address, 0x0801); + assert_eq!(artifact.size, 3); + assert_eq!(artifact.sha256.len(), 64); + } + + #[test] + fn rejects_truncated_prg() { + let mut file = NamedTempFile::new().unwrap(); + file.write_all(&[0x01, 0x08]).unwrap(); + assert!(Artifact::load(file.path().to_path_buf()).is_err()); + } +} diff --git a/crates/ultimate-tool-core/src/build.rs b/crates/ultimate-tool-core/src/build.rs new file mode 100644 index 0000000..934bafc --- /dev/null +++ b/crates/ultimate-tool-core/src/build.rs @@ -0,0 +1,102 @@ +use std::process::Command; + +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +use crate::{Error, LoadedManifest, artifact::Artifact}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BuildExecution { + pub command: Vec, + pub working_directory: String, + pub toolchain: String, + pub toolchain_digest: Option, + pub started_at: DateTime, + pub finished_at: DateTime, + pub exit_code: Option, + pub success: bool, + pub stdout: String, + pub stderr: String, +} + +pub fn execute_build( + manifest: &LoadedManifest, +) -> (BuildExecution, std::result::Result) { + let build = &manifest.manifest.build; + let started_at = Utc::now(); + let toolchain_digest = inspect_container_digest(&build.toolchain); + let output = Command::new(&build.command[0]) + .args(&build.command[1..]) + .current_dir(manifest.build_working_directory()) + .output(); + let output = match output { + Ok(output) => output, + Err(error) => { + let execution = BuildExecution { + command: build.command.clone(), + working_directory: build.working_directory.display().to_string(), + toolchain: build.toolchain.clone(), + toolchain_digest, + started_at, + finished_at: Utc::now(), + exit_code: None, + success: false, + stdout: String::new(), + stderr: error.to_string(), + }; + return ( + execution, + Err(Error::Build(format!( + "cannot execute {:?} in {}: {error}", + build.command, + manifest.build_working_directory().display() + ))), + ); + } + }; + let execution = BuildExecution { + command: build.command.clone(), + working_directory: build.working_directory.display().to_string(), + toolchain: build.toolchain.clone(), + toolchain_digest, + started_at, + finished_at: Utc::now(), + exit_code: output.status.code(), + success: output.status.success(), + stdout: String::from_utf8_lossy(&output.stdout).into_owned(), + stderr: String::from_utf8_lossy(&output.stderr).into_owned(), + }; + if !execution.success { + let error = Error::Build(format!( + "command exited with {}; stderr: {}", + execution + .exit_code + .map_or_else(|| "signal".into(), |code| code.to_string()), + concise(&execution.stderr) + )); + return (execution, Err(error)); + } + let artifact = Artifact::load(manifest.artifact_path()); + (execution, artifact) +} + +fn inspect_container_digest(toolchain: &str) -> Option { + Command::new("docker") + .args(["image", "inspect", "--format", "{{.Id}}", toolchain]) + .output() + .ok() + .filter(|output| output.status.success()) + .map(|output| String::from_utf8_lossy(&output.stdout).trim().to_owned()) + .filter(|digest| !digest.is_empty()) +} + +fn concise(value: &str) -> String { + const LIMIT: usize = 400; + if value.chars().count() <= LIMIT { + value.trim().to_owned() + } else { + let mut output = value.chars().take(LIMIT).collect::(); + output.push_str("..."); + output + } +} diff --git a/crates/ultimate-tool-core/src/config.rs b/crates/ultimate-tool-core/src/config.rs new file mode 100644 index 0000000..5c283b9 --- /dev/null +++ b/crates/ultimate-tool-core/src/config.rs @@ -0,0 +1,286 @@ +use std::{ + fs, + path::{Component, Path, PathBuf}, +}; + +use serde::{Deserialize, Serialize}; + +use crate::{Error, Result}; + +pub const MANIFEST_SCHEMA_VERSION: u32 = 1; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Manifest { + pub schema_version: u32, + pub project: ProjectConfig, + pub build: BuildConfig, + pub diagnostics: DiagnosticsConfig, + pub vice: ViceConfig, + pub ultimate: UltimateConfig, + pub tests: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ProjectConfig { + pub name: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct BuildConfig { + pub command: Vec, + pub working_directory: PathBuf, + pub artifact: PathBuf, + pub toolchain: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct DiagnosticsConfig { + pub address: u16, + pub length: usize, + pub screen_address: u16, + pub color_address: u16, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ViceConfig { + pub executable: PathBuf, + pub poll_interval_ms: u64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct UltimateConfig { + pub poll_interval_ms: u64, + pub request_timeout_ms: u64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct TestConfig { + pub name: String, + pub timeout_ms: u64, + pub expected_terminal: ExpectedTerminal, + pub expected_screen: String, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ExpectedTerminal { + Pass, + Fail, +} + +#[derive(Debug, Clone)] +pub struct LoadedManifest { + pub manifest: Manifest, + pub path: PathBuf, + pub project_root: PathBuf, + pub source: String, +} + +impl LoadedManifest { + pub fn load(path: impl AsRef) -> Result { + let requested = path.as_ref(); + let path = fs::canonicalize(requested).map_err(|error| { + Error::Configuration(format!( + "cannot resolve manifest {}: {error}", + requested.display() + )) + })?; + let project_root = path + .parent() + .ok_or_else(|| Error::Configuration("manifest has no parent directory".into()))? + .to_path_buf(); + let source = fs::read_to_string(&path).map_err(|error| { + Error::Configuration(format!("cannot read {}: {error}", path.display())) + })?; + let manifest: Manifest = toml::from_str(&source).map_err(|error| { + Error::Configuration(format!("cannot parse {}: {error}", path.display())) + })?; + let loaded = Self { + manifest, + path, + project_root, + source, + }; + loaded.validate()?; + Ok(loaded) + } + + pub fn validate(&self) -> Result<()> { + let manifest = &self.manifest; + if manifest.schema_version != MANIFEST_SCHEMA_VERSION { + return Err(Error::Configuration(format!( + "unsupported schema_version {}; expected {MANIFEST_SCHEMA_VERSION}", + manifest.schema_version + ))); + } + if manifest.project.name.trim().is_empty() { + return Err(Error::Configuration( + "project.name must not be empty".into(), + )); + } + if manifest.build.command.is_empty() || manifest.build.command.iter().any(String::is_empty) + { + return Err(Error::Configuration( + "build.command must contain non-empty arguments".into(), + )); + } + validate_relative_path(&manifest.build.working_directory, "build.working_directory")?; + validate_relative_path(&manifest.build.artifact, "build.artifact")?; + if manifest.build.toolchain.trim().is_empty() { + return Err(Error::Configuration( + "build.toolchain must not be empty".into(), + )); + } + if manifest.diagnostics.length != crate::diagnostic::MAILBOX_SIZE { + return Err(Error::Configuration(format!( + "diagnostics.length must be {} for protocol v1", + crate::diagnostic::MAILBOX_SIZE + ))); + } + if manifest.vice.poll_interval_ms == 0 || manifest.ultimate.poll_interval_ms == 0 { + return Err(Error::Configuration( + "target poll intervals must be greater than zero".into(), + )); + } + if manifest.ultimate.request_timeout_ms == 0 { + return Err(Error::Configuration( + "ultimate.request_timeout_ms must be greater than zero".into(), + )); + } + if manifest.tests.is_empty() { + return Err(Error::Configuration( + "at least one [[tests]] entry is required".into(), + )); + } + let mut names = std::collections::BTreeSet::new(); + for test in &manifest.tests { + if test.name.trim().is_empty() || !names.insert(test.name.as_str()) { + return Err(Error::Configuration( + "test names must be non-empty and unique".into(), + )); + } + if test.timeout_ms == 0 { + return Err(Error::Configuration(format!( + "test {} has a zero timeout", + test.name + ))); + } + if test.expected_screen.trim().is_empty() { + return Err(Error::Configuration(format!( + "test {} has an empty expected_screen", + test.name + ))); + } + } + Ok(()) + } + + pub fn test(&self, name: &str) -> Result<&TestConfig> { + self.manifest + .tests + .iter() + .find(|test| test.name == name) + .ok_or_else(|| Error::Configuration(format!("unknown test {name:?}"))) + } + + #[must_use] + pub fn build_working_directory(&self) -> PathBuf { + self.project_root + .join(&self.manifest.build.working_directory) + } + + #[must_use] + pub fn artifact_path(&self) -> PathBuf { + self.project_root.join(&self.manifest.build.artifact) + } +} + +fn validate_relative_path(path: &Path, field: &str) -> Result<()> { + if path.as_os_str().is_empty() + || path.is_absolute() + || path.components().any(|component| { + matches!( + component, + Component::ParentDir | Component::RootDir | Component::Prefix(_) + ) + }) + { + return Err(Error::Configuration(format!( + "{field} must be a non-empty project-relative path without '..'" + ))); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn manifest() -> Manifest { + Manifest { + schema_version: 1, + project: ProjectConfig { + name: "smoke".into(), + }, + build: BuildConfig { + command: vec!["make".into(), "build".into()], + working_directory: "fixtures/smoke".into(), + artifact: "fixtures/smoke/build/smoke.prg".into(), + toolchain: "pinned".into(), + }, + diagnostics: DiagnosticsConfig { + address: 0xc000, + length: 256, + screen_address: 0x0400, + color_address: 0xd800, + }, + vice: ViceConfig { + executable: "x64sc".into(), + poll_interval_ms: 50, + }, + ultimate: UltimateConfig { + poll_interval_ms: 250, + request_timeout_ms: 8_000, + }, + tests: vec![TestConfig { + name: "smoke".into(), + timeout_ms: 10_000, + expected_terminal: ExpectedTerminal::Pass, + expected_screen: "ULTIMATE TOOL SMOKE".into(), + }], + } + } + + #[test] + fn rejects_parent_directory_escape() { + let mut value = manifest(); + value.build.artifact = "../outside.prg".into(); + let loaded = LoadedManifest { + manifest: value, + path: "/tmp/project/c64dev.toml".into(), + project_root: "/tmp/project".into(), + source: String::new(), + }; + assert!(loaded.validate().is_err()); + } + + #[test] + fn rejects_wrong_mailbox_length() { + let mut value = manifest(); + value.diagnostics.length = 255; + let loaded = LoadedManifest { + manifest: value, + path: "/tmp/project/c64dev.toml".into(), + project_root: "/tmp/project".into(), + source: String::new(), + }; + assert!(loaded.validate().is_err()); + } +} diff --git a/crates/ultimate-tool-core/src/diagnostic.rs b/crates/ultimate-tool-core/src/diagnostic.rs new file mode 100644 index 0000000..0c8df41 --- /dev/null +++ b/crates/ultimate-tool-core/src/diagnostic.rs @@ -0,0 +1,225 @@ +use serde::{Deserialize, Serialize}; + +use crate::{Error, Result}; + +pub const MAILBOX_SIZE: usize = 256; +pub const HEADER_SIZE: usize = 16; +pub const RECORD_SIZE: usize = 60; +pub const RECORD_COUNT: usize = 4; +pub const PAYLOAD_SIZE: usize = 52; +pub const MAGIC: &[u8; 4] = b"UTD1"; +pub const VERSION: u8 = 1; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum TerminalState { + Running, + Pass, + Fail, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum EventKind { + Ready, + Log, + AssertionPass, + AssertionFail, + TestPass, + TestFail, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct DiagnosticEvent { + pub sequence: u16, + pub kind: EventKind, + pub flags: u8, + pub code: u16, + pub message: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MailboxSnapshot { + pub published_sequence: u16, + pub terminal: TerminalState, + pub events: Vec, +} + +pub fn parse_stable(first: &[u8], second: &[u8]) -> Result { + let first_header = parse_header(first)?; + let second_header = parse_header(second)?; + if first_header != second_header { + return Err(Error::Protocol( + "mailbox changed between stability reads".into(), + )); + } + parse_with_header(second, second_header) +} + +fn parse_header(bytes: &[u8]) -> Result<(u16, TerminalState)> { + if bytes.len() != MAILBOX_SIZE { + return Err(Error::Protocol(format!( + "mailbox has {} bytes; expected {MAILBOX_SIZE}", + bytes.len() + ))); + } + if &bytes[0..4] != MAGIC { + return Err(Error::Protocol("mailbox magic is not UTD1".into())); + } + if bytes[4] != VERSION { + return Err(Error::Protocol(format!( + "unsupported mailbox version {}", + bytes[4] + ))); + } + if usize::from(bytes[5]) != HEADER_SIZE + || usize::from(bytes[6]) != RECORD_SIZE + || usize::from(bytes[7]) != RECORD_COUNT + { + return Err(Error::Protocol( + "mailbox layout does not match protocol v1".into(), + )); + } + let published_sequence = u16::from_le_bytes([bytes[8], bytes[9]]); + let terminal = match bytes[10] { + 0 => TerminalState::Running, + 1 => TerminalState::Pass, + 2 => TerminalState::Fail, + other => { + return Err(Error::Protocol(format!("unknown terminal state {other}"))); + } + }; + Ok((published_sequence, terminal)) +} + +fn parse_with_header( + bytes: &[u8], + (published_sequence, terminal): (u16, TerminalState), +) -> Result { + let mut events = Vec::new(); + for index in 0..RECORD_COUNT { + let offset = HEADER_SIZE + index * RECORD_SIZE; + let sequence = u16::from_le_bytes([bytes[offset], bytes[offset + 1]]); + if sequence == 0 || sequence_is_newer(sequence, published_sequence) { + continue; + } + let kind = match bytes[offset + 2] { + 1 => EventKind::Ready, + 2 => EventKind::Log, + 3 => EventKind::AssertionPass, + 4 => EventKind::AssertionFail, + 5 => EventKind::TestPass, + 6 => EventKind::TestFail, + other => { + return Err(Error::Protocol(format!( + "unknown event kind {other} at sequence {sequence}" + ))); + } + }; + let payload_length = usize::from(bytes[offset + 6]); + if payload_length > PAYLOAD_SIZE { + return Err(Error::Protocol(format!( + "event {sequence} payload length {payload_length} exceeds {PAYLOAD_SIZE}" + ))); + } + let payload = &bytes[offset + 8..offset + 8 + payload_length]; + let message = String::from_utf8(payload.to_vec()).map_err(|error| { + Error::Protocol(format!("event {sequence} payload is not UTF-8: {error}")) + })?; + events.push(DiagnosticEvent { + sequence, + kind, + flags: bytes[offset + 3], + code: u16::from_le_bytes([bytes[offset + 4], bytes[offset + 5]]), + message, + }); + } + events.sort_by_key(|event| published_sequence.wrapping_sub(event.sequence)); + events.reverse(); + Ok(MailboxSnapshot { + published_sequence, + terminal, + events, + }) +} + +#[must_use] +pub fn sequence_is_newer(candidate: u16, previous: u16) -> bool { + candidate != previous && candidate.wrapping_sub(previous) < 0x8000 +} + +pub(crate) fn encode_fixture( + published_sequence: u16, + terminal: TerminalState, + events: &[(u16, EventKind, u16, &str)], +) -> [u8; MAILBOX_SIZE] { + let mut bytes = [0_u8; MAILBOX_SIZE]; + bytes[0..4].copy_from_slice(MAGIC); + bytes[4] = VERSION; + bytes[5] = HEADER_SIZE as u8; + bytes[6] = RECORD_SIZE as u8; + bytes[7] = RECORD_COUNT as u8; + bytes[8..10].copy_from_slice(&published_sequence.to_le_bytes()); + bytes[10] = match terminal { + TerminalState::Running => 0, + TerminalState::Pass => 1, + TerminalState::Fail => 2, + }; + for &(sequence, kind, code, message) in events { + let index = usize::from(sequence.wrapping_sub(1) % RECORD_COUNT as u16); + let offset = HEADER_SIZE + index * RECORD_SIZE; + bytes[offset..offset + 2].copy_from_slice(&sequence.to_le_bytes()); + bytes[offset + 2] = match kind { + EventKind::Ready => 1, + EventKind::Log => 2, + EventKind::AssertionPass => 3, + EventKind::AssertionFail => 4, + EventKind::TestPass => 5, + EventKind::TestFail => 6, + }; + bytes[offset + 4..offset + 6].copy_from_slice(&code.to_le_bytes()); + bytes[offset + 6] = message.len() as u8; + bytes[offset + 8..offset + 8 + message.len()].copy_from_slice(message.as_bytes()); + } + bytes +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_ordered_terminal_events() { + let bytes = encode_fixture( + 4, + TerminalState::Pass, + &[ + (1, EventKind::Ready, 0, "ready"), + (2, EventKind::Log, 0, "log"), + (3, EventKind::AssertionPass, 7, "assert"), + (4, EventKind::TestPass, 0, "pass"), + ], + ); + let snapshot = parse_stable(&bytes, &bytes).unwrap(); + assert_eq!(snapshot.terminal, TerminalState::Pass); + assert_eq!(snapshot.events[0].sequence, 1); + assert_eq!(snapshot.events[3].kind, EventKind::TestPass); + } + + #[test] + fn rejects_torn_snapshot() { + let first = encode_fixture( + 1, + TerminalState::Running, + &[(1, EventKind::Ready, 0, "ready")], + ); + let second = encode_fixture(2, TerminalState::Running, &[(2, EventKind::Log, 0, "log")]); + assert!(parse_stable(&first, &second).is_err()); + } + + #[test] + fn sequence_comparison_wraps() { + assert!(sequence_is_newer(0, u16::MAX)); + assert!(!sequence_is_newer(u16::MAX, 0)); + } +} diff --git a/crates/ultimate-tool-core/src/evidence.rs b/crates/ultimate-tool-core/src/evidence.rs new file mode 100644 index 0000000..60a1648 --- /dev/null +++ b/crates/ultimate-tool-core/src/evidence.rs @@ -0,0 +1,387 @@ +use std::{ + fs, + io::Write, + path::{Path, PathBuf}, +}; + +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use uuid::Uuid; + +use crate::{ + Error, Result, + artifact::Artifact, + build::BuildExecution, + config::LoadedManifest, + diagnostic::DiagnosticEvent, + model::{ + Checkpoint, FinalState, Mutation, RunOutcome, ScreenCapture, TargetIdentity, TargetKind, + }, +}; + +pub const RUN_SCHEMA_VERSION: u32 = 1; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GitState { + pub revision: Option, + pub dirty: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ArtifactRecord { + pub original_path: String, + pub bundled_path: String, + pub size: u64, + pub load_address: u16, + pub sha256: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RunRecord { + pub schema_version: u32, + pub run_id: Uuid, + pub started_at: DateTime, + pub finished_at: Option>, + pub project: String, + pub test: String, + pub target_kind: TargetKind, + pub outcome: RunOutcome, + pub first_error: Option, + pub git: GitState, + pub build: BuildExecution, + pub artifact: Option, + pub target: Option, + pub confirmations: Vec, + pub mutations: Vec, + pub checkpoints: Vec, + pub events: Vec, + pub final_state: FinalState, + pub retry_count: u32, + pub recovery_count: u32, + pub evidence_files: Vec, +} + +pub struct RunRecorder { + directory: PathBuf, + record: RunRecord, +} + +impl RunRecorder { + pub fn create( + manifest: &LoadedManifest, + test: &str, + target_kind: TargetKind, + build: BuildExecution, + artifact: Option<&Artifact>, + ) -> Result { + let run_id = Uuid::new_v4(); + let timestamp = Utc::now().format("%Y%m%dT%H%M%S%.3fZ"); + let directory = manifest + .project_root + .join(".ultimate-tool/runs") + .join(format!("{timestamp}-{run_id}-{target_kind}")); + fs::create_dir_all(&directory)?; + fs::write(directory.join("manifest.toml"), &manifest.source)?; + fs::write(directory.join("build.stdout.log"), &build.stdout)?; + fs::write(directory.join("build.stderr.log"), &build.stderr)?; + if let Some(artifact) = artifact { + let bundled_artifact = directory.join(&artifact.bundle_name); + fs::copy(&artifact.source_path, &bundled_artifact)?; + } + let git = inspect_git(&manifest.project_root); + let record = RunRecord { + schema_version: RUN_SCHEMA_VERSION, + run_id, + started_at: Utc::now(), + finished_at: None, + project: manifest.manifest.project.name.clone(), + test: test.to_owned(), + target_kind, + outcome: RunOutcome::Pending, + first_error: None, + git, + build, + artifact: artifact.map(|artifact| ArtifactRecord { + original_path: artifact.source_path.display().to_string(), + bundled_path: artifact.bundle_name.clone(), + size: artifact.size, + load_address: artifact.load_address, + sha256: artifact.sha256.clone(), + }), + target: None, + confirmations: Vec::new(), + mutations: Vec::new(), + checkpoints: Vec::new(), + events: Vec::new(), + final_state: FinalState { + terminal: None, + visible_text: None, + cpu_resumed: None, + last_observation: None, + }, + retry_count: 0, + recovery_count: 0, + evidence_files: { + let mut files = vec![ + "manifest.toml".into(), + "build.stdout.log".into(), + "build.stderr.log".into(), + ]; + if let Some(artifact) = artifact { + files.push(artifact.bundle_name.clone()); + } + files + }, + }; + let recorder = Self { directory, record }; + recorder.flush()?; + Ok(recorder) + } + + #[must_use] + pub fn directory(&self) -> &Path { + &self.directory + } + + pub fn set_target(&mut self, target: TargetIdentity) -> Result<()> { + self.record.target = Some(target); + self.flush() + } + + pub fn confirm(&mut self, confirmation: impl Into) -> Result<()> { + self.record.confirmations.push(confirmation.into()); + self.flush() + } + + pub fn checkpoint( + &mut self, + name: impl Into, + passed: bool, + detail: impl Into, + ) -> Result<()> { + self.record.checkpoints.push(Checkpoint { + name: name.into(), + passed, + detail: detail.into(), + timestamp: Utc::now(), + }); + self.flush() + } + + pub fn replace_events(&mut self, events: Vec) -> Result<()> { + self.record.events = events; + let mut contents = String::new(); + for event in &self.record.events { + contents.push_str( + &serde_json::to_string(event) + .map_err(|error| Error::Evidence(error.to_string()))?, + ); + contents.push('\n'); + } + fs::write(self.directory.join("events.jsonl"), contents)?; + add_file_once(&mut self.record.evidence_files, "events.jsonl"); + self.flush() + } + + pub fn write_mailbox(&mut self, name: &str, bytes: &[u8]) -> Result<()> { + fs::write(self.directory.join(name), bytes)?; + add_file_once(&mut self.record.evidence_files, name); + self.flush() + } + + pub fn write_screen(&mut self, screen: &ScreenCapture) -> Result<()> { + fs::write(self.directory.join("screen.bin"), &screen.characters)?; + fs::write(self.directory.join("color.bin"), &screen.colors)?; + fs::write(self.directory.join("screen.txt"), &screen.text)?; + for name in ["screen.bin", "color.bin", "screen.txt"] { + add_file_once(&mut self.record.evidence_files, name); + } + self.record.final_state.visible_text = Some(screen.text.clone()); + self.flush() + } + + pub fn register_existing_file(&mut self, name: &str) -> Result<()> { + if self.directory.join(name).is_file() { + add_file_once(&mut self.record.evidence_files, name); + self.flush()?; + } + Ok(()) + } + + pub fn set_runtime_details( + &mut self, + mutations: Vec, + retry_count: u32, + recovery_count: u32, + cpu_resumed: Option, + ) -> Result<()> { + self.record.mutations = mutations; + self.record.retry_count = retry_count; + self.record.recovery_count = recovery_count; + self.record.final_state.cpu_resumed = cpu_resumed; + self.flush() + } + + pub fn set_terminal(&mut self, terminal: impl Into) -> Result<()> { + self.record.final_state.terminal = Some(terminal.into()); + self.flush() + } + + pub fn finish( + &mut self, + outcome: RunOutcome, + first_error: Option, + last_observation: Option, + ) -> Result<()> { + if self.record.first_error.is_none() { + self.record.first_error = first_error; + } + self.record.outcome = outcome; + self.record.finished_at = Some(Utc::now()); + self.record.final_state.last_observation = last_observation; + self.flush()?; + self.write_checksums() + } + + #[must_use] + pub fn outcome(&self) -> RunOutcome { + self.record.outcome + } + + fn flush(&self) -> Result<()> { + let bytes = serde_json::to_vec_pretty(&self.record) + .map_err(|error| Error::Evidence(error.to_string()))?; + atomic_write(&self.directory.join("run.json"), &bytes) + } + + fn write_checksums(&mut self) -> Result<()> { + add_file_once(&mut self.record.evidence_files, "run.json"); + self.flush()?; + let mut names = self.record.evidence_files.clone(); + names.sort(); + names.dedup(); + let mut lines = String::new(); + for name in names { + let bytes = fs::read(self.directory.join(&name))?; + lines.push_str(&format!("{:x} {name}\n", Sha256::digest(bytes))); + } + fs::write(self.directory.join("SHA256SUMS"), lines)?; + Ok(()) + } +} + +fn inspect_git(project_root: &Path) -> GitState { + let revision = std::process::Command::new("git") + .args(["rev-parse", "HEAD"]) + .current_dir(project_root) + .output() + .ok() + .filter(|output| output.status.success()) + .map(|output| String::from_utf8_lossy(&output.stdout).trim().to_owned()); + let dirty = std::process::Command::new("git") + .args(["status", "--porcelain"]) + .current_dir(project_root) + .output() + .ok() + .filter(|output| output.status.success()) + .map(|output| !output.stdout.is_empty()); + GitState { revision, dirty } +} + +fn atomic_write(path: &Path, bytes: &[u8]) -> Result<()> { + let parent = path + .parent() + .ok_or_else(|| Error::Evidence(format!("{} has no parent", path.display())))?; + let temporary = parent.join(format!( + ".{}.tmp-{}", + path.file_name() + .and_then(|name| name.to_str()) + .unwrap_or("evidence"), + Uuid::new_v4() + )); + let mut file = fs::File::create(&temporary)?; + file.write_all(bytes)?; + file.sync_all()?; + fs::rename(&temporary, path)?; + Ok(()) +} + +fn add_file_once(files: &mut Vec, name: &str) { + if !files.iter().any(|existing| existing == name) { + files.push(name.to_owned()); + } +} + +#[cfg(test)] +mod tests { + use std::io::Write; + + use tempfile::{NamedTempFile, tempdir}; + + use super::*; + use crate::{ + config::{ + BuildConfig, DiagnosticsConfig, Manifest, ProjectConfig, UltimateConfig, ViceConfig, + }, + model::TargetKind, + }; + + #[test] + fn writes_a_run_even_before_completion() { + let root = tempdir().unwrap(); + let manifest_path = root.path().join("c64dev.toml"); + fs::write(&manifest_path, "schema_version = 1\n").unwrap(); + let loaded = LoadedManifest { + manifest: Manifest { + schema_version: 1, + project: ProjectConfig { + name: "test".into(), + }, + build: BuildConfig { + command: vec!["true".into()], + working_directory: ".".into(), + artifact: "smoke.prg".into(), + toolchain: "test".into(), + }, + diagnostics: DiagnosticsConfig { + address: 0xc000, + length: 256, + screen_address: 0x0400, + color_address: 0xd800, + }, + vice: ViceConfig { + executable: "x64sc".into(), + poll_interval_ms: 50, + }, + ultimate: UltimateConfig { + poll_interval_ms: 250, + request_timeout_ms: 1000, + }, + tests: vec![], + }, + path: manifest_path, + project_root: root.path().to_path_buf(), + source: "schema_version = 1\n".into(), + }; + let mut artifact_file = NamedTempFile::new().unwrap(); + artifact_file.write_all(&[1, 8, 0xea]).unwrap(); + let artifact = Artifact::load(artifact_file.path().to_path_buf()).unwrap(); + let build = BuildExecution { + command: vec!["true".into()], + working_directory: ".".into(), + toolchain: "test".into(), + toolchain_digest: None, + started_at: Utc::now(), + finished_at: Utc::now(), + exit_code: Some(0), + success: true, + stdout: String::new(), + stderr: String::new(), + }; + let recorder = + RunRecorder::create(&loaded, "smoke", TargetKind::Fake, build, Some(&artifact)) + .unwrap(); + assert!(recorder.directory().join("run.json").is_file()); + } +} diff --git a/crates/ultimate-tool-core/src/lease.rs b/crates/ultimate-tool-core/src/lease.rs new file mode 100644 index 0000000..16fe057 --- /dev/null +++ b/crates/ultimate-tool-core/src/lease.rs @@ -0,0 +1,73 @@ +use std::{ + fs::{self, File, OpenOptions}, + os::fd::AsRawFd, + path::{Path, PathBuf}, +}; + +use crate::{Error, Result}; + +pub struct TargetLease { + file: File, + path: PathBuf, +} + +impl TargetLease { + pub fn acquire(project_root: &Path, key: &str) -> Result { + let directory = project_root.join(".ultimate-tool/locks"); + fs::create_dir_all(&directory)?; + let safe_key = key + .chars() + .map(|character| { + if character.is_ascii_alphanumeric() || matches!(character, '-' | '_') { + character + } else { + '_' + } + }) + .collect::(); + let path = directory.join(format!("{safe_key}.lock")); + let file = OpenOptions::new() + .create(true) + .truncate(false) + .read(true) + .write(true) + .open(&path)?; + // SAFETY: flock only observes the valid file descriptor owned by `file`. + let result = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) }; + if result != 0 { + return Err(Error::Lease(format!( + "another local session holds {}", + path.display() + ))); + } + Ok(Self { file, path }) + } + + #[must_use] + pub fn path(&self) -> &Path { + &self.path + } +} + +impl Drop for TargetLease { + fn drop(&mut self) { + // SAFETY: the descriptor remains valid until the struct finishes dropping. + let _ = unsafe { libc::flock(self.file.as_raw_fd(), libc::LOCK_UN) }; + } +} + +#[cfg(test)] +mod tests { + use tempfile::tempdir; + + use super::*; + + #[test] + fn prevents_parallel_local_sessions() { + let root = tempdir().unwrap(); + let first = TargetLease::acquire(root.path(), "vice").unwrap(); + assert!(TargetLease::acquire(root.path(), "vice").is_err()); + drop(first); + assert!(TargetLease::acquire(root.path(), "vice").is_ok()); + } +} diff --git a/crates/ultimate-tool-core/src/lib.rs b/crates/ultimate-tool-core/src/lib.rs new file mode 100644 index 0000000..cb57c03 --- /dev/null +++ b/crates/ultimate-tool-core/src/lib.rs @@ -0,0 +1,61 @@ +//! Deterministic core for Ultimate Tool. + +pub mod artifact; +pub mod build; +pub mod config; +pub mod diagnostic; +pub mod evidence; +pub mod lease; +pub mod model; +pub mod runner; +pub mod target; + +pub use config::{LoadedManifest, Manifest}; +pub use evidence::RunRecorder; +pub use model::{RunOutcome, TargetKind}; +pub use runner::{ProbeRequest, TestRequest, probe_ultimate, run_test_matrix}; + +use thiserror::Error; + +/// Errors retain the failing development-loop layer for useful CLI reporting. +#[derive(Debug, Error)] +pub enum Error { + #[error("configuration: {0}")] + Configuration(String), + #[error("build: {0}")] + Build(String), + #[error("artifact: {0}")] + Artifact(String), + #[error("target preflight: {0}")] + Preflight(String), + #[error("target transport: {0}")] + Transport(String), + #[error("deployment: {0}")] + Deployment(String), + #[error("program protocol: {0}")] + Protocol(String), + #[error("program assertion: {0}")] + Assertion(String), + #[error("program timeout: {0}")] + Timeout(String), + #[error("evidence: {0}")] + Evidence(String), + #[error("target lease: {0}")] + Lease(String), + #[error(transparent)] + Io(#[from] std::io::Error), +} + +impl Error { + #[must_use] + pub const fn exit_code(&self) -> u8 { + match self { + Self::Configuration(_) | Self::Build(_) | Self::Artifact(_) => 2, + Self::Preflight(_) | Self::Transport(_) | Self::Deployment(_) | Self::Lease(_) => 3, + Self::Protocol(_) | Self::Assertion(_) | Self::Timeout(_) => 4, + Self::Evidence(_) | Self::Io(_) => 5, + } + } +} + +pub type Result = std::result::Result; diff --git a/crates/ultimate-tool-core/src/model.rs b/crates/ultimate-tool-core/src/model.rs new file mode 100644 index 0000000..bdbcd77 --- /dev/null +++ b/crates/ultimate-tool-core/src/model.rs @@ -0,0 +1,88 @@ +use std::{fmt, str::FromStr}; + +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum TargetKind { + Fake, + Vice, + Ultimate, +} + +impl fmt::Display for TargetKind { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(match self { + Self::Fake => "fake", + Self::Vice => "vice", + Self::Ultimate => "ultimate", + }) + } +} + +impl FromStr for TargetKind { + type Err = String; + + fn from_str(value: &str) -> std::result::Result { + match value { + "fake" => Ok(Self::Fake), + "vice" => Ok(Self::Vice), + "ultimate" => Ok(Self::Ultimate), + _ => Err(format!("unknown target {value:?}")), + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum RunOutcome { + Pending, + Passed, + Failed, + Error, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TargetIdentity { + pub kind: TargetKind, + pub address: Option, + pub product: String, + pub hostname: Option, + pub unique_id: Option, + pub firmware_version: Option, + pub fpga_version: Option, + pub core_version: Option, + pub api_version: Option, + pub capabilities: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Checkpoint { + pub name: String, + pub passed: bool, + pub detail: String, + pub timestamp: DateTime, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Mutation { + pub action: String, + pub detail: String, + pub timestamp: DateTime, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ScreenCapture { + pub characters: Vec, + pub colors: Vec, + pub text: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FinalState { + pub terminal: Option, + pub visible_text: Option, + pub cpu_resumed: Option, + pub last_observation: Option, +} diff --git a/crates/ultimate-tool-core/src/runner.rs b/crates/ultimate-tool-core/src/runner.rs new file mode 100644 index 0000000..c0a84a9 --- /dev/null +++ b/crates/ultimate-tool-core/src/runner.rs @@ -0,0 +1,542 @@ +use std::{ + collections::BTreeMap, + path::PathBuf, + thread, + time::{Duration, Instant}, +}; + +use crate::{ + Error, LoadedManifest, Result, + artifact::Artifact, + build::{BuildExecution, execute_build}, + config::{ExpectedTerminal, TestConfig}, + diagnostic::{DiagnosticEvent, EventKind, TerminalState, parse_stable}, + evidence::RunRecorder, + lease::TargetLease, + model::{RunOutcome, TargetIdentity, TargetKind}, + target::{ + Target, + fake::{FakeScenario, FakeTarget}, + ultimate::UltimateTarget, + vice::ViceTarget, + }, +}; + +#[derive(Debug, Clone)] +pub struct TestRequest { + pub manifest_path: PathBuf, + pub test: String, + pub targets: Vec, + pub device: Option, + pub expected_device_id: Option, + pub expected_hostname: Option, + pub allow_reset: bool, + pub fake_scenario: FakeScenario, +} + +#[derive(Debug, Clone)] +pub struct ProbeRequest { + pub host: String, + pub request_timeout: Duration, +} + +#[derive(Debug, Clone)] +pub struct RunSummary { + pub target: TargetKind, + pub outcome: RunOutcome, + pub directory: PathBuf, + pub artifact_sha256: Option, + pub error: Option, + pub exit_code: u8, +} + +pub fn probe_ultimate(request: &ProbeRequest) -> Result { + let password = std::env::var("C64U_PASSWORD").ok(); + let mut target = UltimateTarget::new(&request.host, request.request_timeout, password)?; + target.probe() +} + +pub fn run_test_matrix(request: &TestRequest) -> Result> { + if request.targets.is_empty() { + return Err(Error::Configuration( + "at least one --target is required".into(), + )); + } + let manifest = LoadedManifest::load(&request.manifest_path)?; + let test = manifest.test(&request.test)?.clone(); + validate_hardware_request(request)?; + let (build, artifact_result) = execute_build(&manifest); + let artifact_error = artifact_result.as_ref().err().map(ToString::to_string); + let artifact_error_code = artifact_result + .as_ref() + .err() + .map_or(0, |error| error.exit_code()); + let artifact = artifact_result.ok(); + let mut summaries = Vec::new(); + for &target_kind in &request.targets { + let mut recorder = RunRecorder::create( + &manifest, + &request.test, + target_kind, + build.clone(), + artifact.as_ref(), + )?; + if let Some(error) = &artifact_error { + recorder.checkpoint("build", false, error)?; + recorder.finish(RunOutcome::Error, Some(error.clone()), None)?; + summaries.push(RunSummary { + target: target_kind, + outcome: RunOutcome::Error, + directory: recorder.directory().to_path_buf(), + artifact_sha256: None, + error: Some(error.clone()), + exit_code: artifact_error_code, + }); + continue; + } + let artifact = artifact.as_ref().expect("artifact error handled above"); + recorder.checkpoint( + "build", + true, + format!( + "{} bytes, load ${:04X}, SHA-256 {}", + artifact.size, artifact.load_address, artifact.sha256 + ), + )?; + let summary = run_one( + &manifest, + &test, + request, + target_kind, + build.clone(), + artifact, + recorder, + )?; + summaries.push(summary); + } + Ok(summaries) +} + +fn run_one( + manifest: &LoadedManifest, + test: &TestConfig, + request: &TestRequest, + target_kind: TargetKind, + _build: BuildExecution, + artifact: &Artifact, + mut recorder: RunRecorder, +) -> Result { + let mut target: Box = match target_kind { + TargetKind::Fake => Box::new(FakeTarget::new(request.fake_scenario)), + TargetKind::Vice => Box::new(ViceTarget::new( + manifest.manifest.vice.executable.clone(), + recorder.directory(), + )), + TargetKind::Ultimate => { + let host = request + .device + .as_deref() + .ok_or_else(|| Error::Configuration("--device is required".into()))?; + Box::new(UltimateTarget::new( + host, + Duration::from_millis(manifest.manifest.ultimate.request_timeout_ms), + std::env::var("C64U_PASSWORD").ok(), + )?) + } + }; + + let result = run_target( + manifest, + test, + request, + target_kind, + artifact, + &mut recorder, + target.as_mut(), + ); + if result.is_err() && !target.mutations().is_empty() { + match target.capture_screen( + manifest.manifest.diagnostics.screen_address, + manifest.manifest.diagnostics.color_address, + ) { + Ok(screen) => { + recorder.write_screen(&screen)?; + recorder.checkpoint( + "failure_visible_state", + true, + "captured best-effort screen evidence after failure", + )?; + } + Err(error) => { + recorder.checkpoint( + "failure_visible_state", + false, + format!("best-effort screen capture failed: {error}"), + )?; + } + } + } + let finish_error = target.finish().err(); + for name in ["vice.stdout.log", "vice.stderr.log", "vice.png"] { + recorder.register_existing_file(name)?; + } + recorder.set_runtime_details( + target.mutations(), + target.retry_count(), + target.recovery_count(), + target.cpu_resumed(), + )?; + + let final_result = match (result, finish_error) { + (Ok(observation), None) => { + recorder.finish(RunOutcome::Passed, None, Some(observation))?; + (RunOutcome::Passed, None, 0) + } + (Ok(observation), Some(error)) => { + let message = format!("{error}"); + let exit_code = error.exit_code(); + recorder.finish(RunOutcome::Error, Some(message.clone()), Some(observation))?; + (RunOutcome::Error, Some(message), exit_code) + } + (Err(error), finish_error) => { + let mut exit_code = error.exit_code(); + let mut message = error.to_string(); + if let Some(finish_error) = finish_error { + exit_code = exit_code.max(finish_error.exit_code()); + message.push_str(&format!("; cleanup failed: {finish_error}")); + } + let outcome = if matches!( + error, + Error::Assertion(_) | Error::Timeout(_) | Error::Protocol(_) + ) { + RunOutcome::Failed + } else { + RunOutcome::Error + }; + recorder.finish(outcome, Some(message.clone()), None)?; + (outcome, Some(message), exit_code) + } + }; + Ok(RunSummary { + target: target_kind, + outcome: final_result.0, + directory: recorder.directory().to_path_buf(), + artifact_sha256: Some(artifact.sha256.clone()), + error: final_result.1, + exit_code: final_result.2, + }) +} + +fn run_target( + manifest: &LoadedManifest, + test: &TestConfig, + request: &TestRequest, + target_kind: TargetKind, + artifact: &Artifact, + recorder: &mut RunRecorder, + target: &mut dyn Target, +) -> Result { + let identity = target.probe()?; + recorder.set_target(identity.clone())?; + recorder.checkpoint("target_identity", true, identity_detail(&identity))?; + if target_kind == TargetKind::Ultimate { + UltimateTarget::verify_expected_identity( + &identity, + request.expected_device_id.as_deref(), + request.expected_hostname.as_deref(), + )?; + recorder.confirm("explicit --allow-reset supplied for this invocation")?; + recorder.confirm(format!( + "intended device verified as {}", + identity + .unique_id + .as_deref() + .or(identity.hostname.as_deref()) + .unwrap_or("unknown") + ))?; + } + let _lease = if target_kind == TargetKind::Fake { + None + } else { + Some(TargetLease::acquire( + &manifest.project_root, + &target.identity_key(), + )?) + }; + target.deploy_and_start(artifact)?; + recorder.checkpoint( + "deployment_transport", + true, + "artifact accepted by target; program result not yet assumed", + )?; + let poll_ms = match target_kind { + TargetKind::Fake => 1, + TargetKind::Vice => manifest.manifest.vice.poll_interval_ms, + TargetKind::Ultimate => manifest.manifest.ultimate.poll_interval_ms, + }; + let deadline = Instant::now() + Duration::from_millis(test.timeout_ms); + let mut events = BTreeMap::::new(); + let mut last_mailbox = None; + let mut consecutive_protocol_errors = 0_u8; + let mut seen_valid_mailbox = false; + let snapshot = loop { + let first = target.read_memory( + manifest.manifest.diagnostics.address, + manifest.manifest.diagnostics.length, + )?; + thread::sleep(Duration::from_millis(5)); + let second = target.read_memory( + manifest.manifest.diagnostics.address, + manifest.manifest.diagnostics.length, + )?; + match parse_stable(&first, &second) { + Ok(snapshot) => { + seen_valid_mailbox = true; + consecutive_protocol_errors = 0; + for event in &snapshot.events { + events.insert(event.sequence, event.clone()); + } + last_mailbox = Some(second); + if snapshot.terminal != TerminalState::Running { + break snapshot; + } + } + Err(error) => { + consecutive_protocol_errors += 1; + if consecutive_protocol_errors >= 3 && seen_valid_mailbox { + return Err(error); + } + } + } + if Instant::now() >= deadline { + if let Some(bytes) = &last_mailbox { + recorder.write_mailbox("mailbox-last.bin", bytes)?; + } + recorder.replace_events(events.into_values().collect())?; + return Err(Error::Timeout(format!( + "test {:?} did not reach a terminal mailbox state in {} ms", + test.name, test.timeout_ms + ))); + } + thread::sleep(Duration::from_millis(poll_ms)); + }; + let last_mailbox = last_mailbox.expect("terminal snapshot has mailbox bytes"); + recorder.write_mailbox("mailbox-final.bin", &last_mailbox)?; + let ordered_events = events.into_values().collect::>(); + recorder.replace_events(ordered_events.clone())?; + validate_required_events(&ordered_events, snapshot.terminal)?; + recorder.set_terminal(format!("{:?}", snapshot.terminal).to_lowercase())?; + recorder.checkpoint( + "program_terminal", + true, + format!( + "observed {:?} at sequence {}", + snapshot.terminal, snapshot.published_sequence + ), + )?; + let screen = target.capture_screen( + manifest.manifest.diagnostics.screen_address, + manifest.manifest.diagnostics.color_address, + )?; + recorder.write_screen(&screen)?; + if !screen.text.contains(&test.expected_screen) { + return Err(Error::Assertion(format!( + "screen does not contain {:?}; observed {:?}", + test.expected_screen, screen.text + ))); + } + recorder.checkpoint( + "visible_screen", + true, + format!("screen contains {:?}", test.expected_screen), + )?; + let expected = match test.expected_terminal { + ExpectedTerminal::Pass => TerminalState::Pass, + ExpectedTerminal::Fail => TerminalState::Fail, + }; + if snapshot.terminal != expected { + return Err(Error::Assertion(format!( + "expected terminal {expected:?}, observed {:?}", + snapshot.terminal + ))); + } + Ok(format!( + "observable {:?} with screen marker {:?}", + snapshot.terminal, test.expected_screen + )) +} + +fn validate_hardware_request(request: &TestRequest) -> Result<()> { + if !request.targets.contains(&TargetKind::Ultimate) { + return Ok(()); + } + if request.device.as_deref().is_none_or(str::is_empty) { + return Err(Error::Configuration( + "--device is required for the ultimate target".into(), + )); + } + if !request.allow_reset { + return Err(Error::Configuration( + "--allow-reset is required for every ultimate test invocation".into(), + )); + } + match ( + request.expected_device_id.as_deref(), + request.expected_hostname.as_deref(), + ) { + (Some(_), Some(_)) => Err(Error::Configuration( + "use only one of --expect-device-id or --expect-hostname".into(), + )), + (None, None) => Err(Error::Configuration( + "hardware mutation requires --expect-device-id or --expect-hostname".into(), + )), + _ => Ok(()), + } +} + +fn validate_required_events(events: &[DiagnosticEvent], terminal: TerminalState) -> Result<()> { + let has = |kind| events.iter().any(|event| event.kind == kind); + if !has(EventKind::Ready) { + return Err(Error::Protocol("terminal run has no ready event".into())); + } + if !has(EventKind::Log) { + return Err(Error::Protocol("terminal run has no log event".into())); + } + if !events.iter().any(|event| { + matches!( + event.kind, + EventKind::AssertionPass | EventKind::AssertionFail + ) + }) { + return Err(Error::Protocol( + "terminal run has no assertion event".into(), + )); + } + let terminal_event = match terminal { + TerminalState::Pass => EventKind::TestPass, + TerminalState::Fail => EventKind::TestFail, + TerminalState::Running => { + return Err(Error::Protocol( + "required events checked before terminal state".into(), + )); + } + }; + if !has(terminal_event) { + return Err(Error::Protocol(format!( + "terminal run has no {terminal_event:?} event" + ))); + } + Ok(()) +} + +fn identity_detail(identity: &TargetIdentity) -> String { + format!( + "{} hostname={} id={} firmware={} core={}", + identity.product, + identity.hostname.as_deref().unwrap_or("-"), + identity.unique_id.as_deref().unwrap_or("-"), + identity.firmware_version.as_deref().unwrap_or("-"), + identity.core_version.as_deref().unwrap_or("-") + ) +} + +#[cfg(test)] +mod tests { + use std::fs; + + use tempfile::tempdir; + + use super::*; + + #[test] + fn hardware_requires_fresh_explicit_consent() { + let request = TestRequest { + manifest_path: "c64dev.toml".into(), + test: "smoke".into(), + targets: vec![TargetKind::Ultimate], + device: Some("c64u".into()), + expected_device_id: Some("id".into()), + expected_hostname: None, + allow_reset: false, + fake_scenario: FakeScenario::Pass, + }; + assert!(validate_hardware_request(&request).is_err()); + } + + #[test] + fn build_failure_still_writes_a_complete_run_record() { + let root = tempdir().unwrap(); + let manifest = root.path().join("c64dev.toml"); + fs::write(&manifest, manifest_source("false", 20)).unwrap(); + let summaries = run_test_matrix(&TestRequest { + manifest_path: manifest, + test: "smoke".into(), + targets: vec![TargetKind::Fake], + device: None, + expected_device_id: None, + expected_hostname: None, + allow_reset: false, + fake_scenario: FakeScenario::Pass, + }) + .unwrap(); + assert_eq!(summaries[0].outcome, RunOutcome::Error); + let record = fs::read_to_string(summaries[0].directory.join("run.json")).unwrap(); + assert!(record.contains("\"artifact\": null")); + assert!(record.contains("\"outcome\": \"error\"")); + assert!(summaries[0].directory.join("SHA256SUMS").is_file()); + } + + #[test] + fn timeout_is_a_failed_program_run_with_evidence() { + let root = tempdir().unwrap(); + let manifest = root.path().join("c64dev.toml"); + fs::write(&manifest, manifest_source("true", 20)).unwrap(); + fs::write(root.path().join("smoke.prg"), [0x01, 0x08, 0xea]).unwrap(); + let summaries = run_test_matrix(&TestRequest { + manifest_path: manifest, + test: "smoke".into(), + targets: vec![TargetKind::Fake], + device: None, + expected_device_id: None, + expected_hostname: None, + allow_reset: false, + fake_scenario: FakeScenario::Timeout, + }) + .unwrap(); + assert_eq!(summaries[0].outcome, RunOutcome::Failed); + let record = fs::read_to_string(summaries[0].directory.join("run.json")).unwrap(); + assert!(record.contains("program timeout")); + assert!(record.contains("mailbox-last.bin")); + } + + fn manifest_source(command: &str, timeout_ms: u64) -> String { + format!( + r#" +schema_version = 1 +[project] +name = "test" +[build] +command = ["{command}"] +working_directory = "." +artifact = "smoke.prg" +toolchain = "test" +[diagnostics] +address = 49152 +length = 256 +screen_address = 1024 +color_address = 55296 +[vice] +executable = "x64sc" +poll_interval_ms = 1 +[ultimate] +poll_interval_ms = 1 +request_timeout_ms = 100 +[[tests]] +name = "smoke" +timeout_ms = {timeout_ms} +expected_terminal = "pass" +expected_screen = "ULTIMATE TOOL SMOKE" +"# + ) + } +} diff --git a/crates/ultimate-tool-core/src/target/fake.rs b/crates/ultimate-tool-core/src/target/fake.rs new file mode 100644 index 0000000..21ea1d1 --- /dev/null +++ b/crates/ultimate-tool-core/src/target/fake.rs @@ -0,0 +1,149 @@ +use chrono::Utc; + +use crate::{ + Result, + artifact::Artifact, + diagnostic::{EventKind, TerminalState}, + model::{Mutation, ScreenCapture, TargetIdentity, TargetKind}, + target::{Target, decode_screen}, +}; + +#[derive(Debug, Clone, Copy)] +pub enum FakeScenario { + Pass, + Fail, + Timeout, + Malformed, +} + +pub struct FakeTarget { + scenario: FakeScenario, + reads: u32, + mutations: Vec, +} + +impl FakeTarget { + #[must_use] + pub const fn new(scenario: FakeScenario) -> Self { + Self { + scenario, + reads: 0, + mutations: Vec::new(), + } + } + + fn mailbox(&self) -> Vec { + let bytes = match self.scenario { + FakeScenario::Pass => crate::diagnostic::encode_fixture( + 4, + TerminalState::Pass, + &[ + (1, EventKind::Ready, 0, "ready"), + (2, EventKind::Log, 0, "smoke log"), + (3, EventKind::AssertionPass, 1, "mailbox visible"), + (4, EventKind::TestPass, 0, "pass"), + ], + ), + FakeScenario::Fail => crate::diagnostic::encode_fixture( + 4, + TerminalState::Fail, + &[ + (1, EventKind::Ready, 0, "ready"), + (2, EventKind::Log, 0, "forced failure follows"), + (3, EventKind::AssertionFail, 7, "forced failure"), + (4, EventKind::TestFail, 7, "fail"), + ], + ), + FakeScenario::Timeout => crate::diagnostic::encode_fixture( + 1, + TerminalState::Running, + &[(1, EventKind::Ready, 0, "ready")], + ), + FakeScenario::Malformed => [0_u8; crate::diagnostic::MAILBOX_SIZE], + }; + bytes.to_vec() + } +} + +impl Target for FakeTarget { + fn identity_key(&self) -> String { + "fake".into() + } + + fn probe(&mut self) -> Result { + Ok(TargetIdentity { + kind: TargetKind::Fake, + address: None, + product: "deterministic fake target".into(), + hostname: None, + unique_id: Some("fake-v1".into()), + firmware_version: None, + fpga_version: None, + core_version: None, + api_version: Some("1".into()), + capabilities: vec![ + "deploy_prg".into(), + "memory_read".into(), + "screen_memory".into(), + ], + }) + } + + fn deploy_and_start(&mut self, _artifact: &Artifact) -> Result<()> { + self.mutations.push(Mutation { + action: "fake_deploy".into(), + detail: "deterministic in-memory deployment".into(), + timestamp: Utc::now(), + }); + Ok(()) + } + + fn read_memory(&mut self, _address: u16, _length: usize) -> Result> { + self.reads += 1; + Ok(self.mailbox()) + } + + fn capture_screen( + &mut self, + _screen_address: u16, + _color_address: u16, + ) -> Result { + let mut characters = vec![32_u8; 1000]; + let message = match self.scenario { + FakeScenario::Fail => "ULTIMATE TOOL SMOKE FAIL", + _ => "ULTIMATE TOOL SMOKE PASS", + }; + for (index, character) in message.bytes().enumerate() { + characters[index] = match character { + b'A'..=b'Z' => character - b'A' + 1, + other => other, + }; + } + let text = decode_screen(&characters); + Ok(ScreenCapture { + characters, + colors: vec![1; 1000], + text, + }) + } + + fn finish(&mut self) -> Result<()> { + Ok(()) + } + + fn mutations(&self) -> Vec { + self.mutations.clone() + } + + fn retry_count(&self) -> u32 { + 0 + } + + fn recovery_count(&self) -> u32 { + 0 + } + + fn cpu_resumed(&self) -> Option { + Some(true) + } +} diff --git a/crates/ultimate-tool-core/src/target/mod.rs b/crates/ultimate-tool-core/src/target/mod.rs new file mode 100644 index 0000000..70e4aaa --- /dev/null +++ b/crates/ultimate-tool-core/src/target/mod.rs @@ -0,0 +1,41 @@ +pub mod fake; +pub mod ultimate; +pub mod vice; + +use crate::{ + Result, + artifact::Artifact, + model::{Mutation, ScreenCapture, TargetIdentity}, +}; + +pub trait Target { + fn identity_key(&self) -> String; + fn probe(&mut self) -> Result; + fn deploy_and_start(&mut self, artifact: &Artifact) -> Result<()>; + fn read_memory(&mut self, address: u16, length: usize) -> Result>; + fn capture_screen(&mut self, screen_address: u16, color_address: u16) -> Result; + fn finish(&mut self) -> Result<()>; + fn mutations(&self) -> Vec; + fn retry_count(&self) -> u32; + fn recovery_count(&self) -> u32; + fn cpu_resumed(&self) -> Option; +} + +pub fn decode_screen(bytes: &[u8]) -> String { + bytes + .chunks(40) + .map(|row| { + row.iter() + .map(|value| match value & 0x7f { + 0 => '@', + code @ 1..=26 => char::from(code + 64), + code @ 32..=63 | code @ 65..=90 => char::from(code), + _ => ' ', + }) + .collect::() + .trim_end() + .to_owned() + }) + .collect::>() + .join("\n") +} diff --git a/crates/ultimate-tool-core/src/target/ultimate.rs b/crates/ultimate-tool-core/src/target/ultimate.rs new file mode 100644 index 0000000..fef1f97 --- /dev/null +++ b/crates/ultimate-tool-core/src/target/ultimate.rs @@ -0,0 +1,610 @@ +use std::{thread, time::Duration}; + +use chrono::Utc; +use reqwest::{ + StatusCode, + blocking::{Client, Response}, + header::{ACCEPT, CONTENT_TYPE, HeaderMap, HeaderValue}, +}; +use serde_json::Value; + +use crate::{ + Error, Result, + artifact::Artifact, + model::{Mutation, ScreenCapture, TargetIdentity, TargetKind}, + target::{Target, decode_screen}, +}; + +pub struct UltimateTarget { + host: String, + base_url: String, + client: Client, + password: Option, + identity: Option, + mutations: Vec, + retry_count: u32, + cpu_resumed: Option, +} + +impl UltimateTarget { + pub fn new(host: &str, timeout: Duration, password: Option) -> Result { + validate_host(host)?; + let client = Client::builder() + .timeout(timeout) + .build() + .map_err(|error| Error::Preflight(format!("cannot build REST client: {error}")))?; + Ok(Self { + host: host.to_owned(), + base_url: format!("http://{host}"), + client, + password, + identity: None, + mutations: Vec::new(), + retry_count: 0, + cpu_resumed: None, + }) + } + + pub fn verify_expected_identity( + identity: &TargetIdentity, + expected_id: Option<&str>, + expected_hostname: Option<&str>, + ) -> Result<()> { + if let Some(expected) = expected_id { + if identity.unique_id.as_deref() != Some(expected) { + return Err(Error::Preflight(format!( + "device unique_id mismatch: expected {expected:?}, observed {:?}", + identity.unique_id + ))); + } + return Ok(()); + } + if let Some(expected) = expected_hostname { + if identity.hostname.as_deref() != Some(expected) { + return Err(Error::Preflight(format!( + "device hostname mismatch: expected {expected:?}, observed {:?}", + identity.hostname + ))); + } + return Ok(()); + } + Err(Error::Preflight( + "hardware mutation requires --expect-device-id or --expect-hostname".into(), + )) + } + + fn headers(&self) -> Result { + let mut headers = HeaderMap::new(); + headers.insert(ACCEPT, HeaderValue::from_static("application/json")); + if let Some(password) = &self.password { + let value = HeaderValue::from_str(password).map_err(|_| { + Error::Preflight("C64U_PASSWORD contains invalid header bytes".into()) + })?; + headers.insert("X-Password", value); + } + Ok(headers) + } + + fn get_json(&mut self, path: &str) -> Result { + let mut last_error = None; + for attempt in 0..=2 { + let response = self + .client + .get(format!("{}{path}", self.base_url)) + .headers(self.headers()?) + .send(); + match response { + Ok(response) if response.status().is_success() => { + return parse_json_response(response, path); + } + Ok(response) => { + let status = response.status(); + let error = response_error(response, path); + if attempt < 2 && retryable_read_status(status) { + self.retry_count += 1; + last_error = Some(error); + thread::sleep(Duration::from_millis(250 * (attempt + 1))); + continue; + } + return Err(error); + } + Err(error) => { + let mapped = Error::Transport(format!("GET {path} failed: {error}")); + if attempt < 2 { + self.retry_count += 1; + last_error = Some(mapped); + thread::sleep(Duration::from_millis(250 * (attempt + 1))); + continue; + } + return Err(mapped); + } + } + } + Err(last_error.unwrap_or_else(|| Error::Transport(format!("GET {path} failed")))) + } + + fn put_action(&mut self, path: &str, action: &str) -> Result<()> { + let response = self + .client + .put(format!("{}{path}", self.base_url)) + .headers(self.headers()?) + .send() + .map_err(|error| Error::Transport(format!("PUT {path} failed: {error}")))?; + parse_action_response(response, path)?; + self.mutations.push(Mutation { + action: action.into(), + detail: path.into(), + timestamp: Utc::now(), + }); + Ok(()) + } + + fn read_memory_once( + &mut self, + address: u16, + length: usize, + ) -> std::result::Result, ReadMemoryFailure> { + let path = format!("/v1/machine:readmem?address={address:04X}&length={length}"); + let response = self + .client + .get(format!("{}{}", self.base_url, path)) + .headers(self.headers().map_err(|error| ReadMemoryFailure { + error, + retryable: false, + })?) + .send() + .map_err(|error| ReadMemoryFailure { + error: Error::Transport(format!("GET {path} failed: {error}")), + retryable: true, + })?; + if !response.status().is_success() { + let status = response.status(); + return Err(ReadMemoryFailure { + error: response_error(response, &path), + retryable: retryable_read_status(status), + }); + } + let content_type = response + .headers() + .get(CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .unwrap_or_default() + .to_owned(); + let bytes = response + .bytes() + .map_err(|error| ReadMemoryFailure { + error: Error::Transport(format!("readmem body failed: {error}")), + retryable: true, + })? + .to_vec(); + if content_type.contains("json") { + return Err(ReadMemoryFailure { + error: json_body_error(&bytes, &path), + retryable: false, + }); + } + if bytes.len() != length { + return Err(ReadMemoryFailure { + error: Error::Transport(format!( + "readmem ${address:04X} returned {} bytes; expected {length}", + bytes.len() + )), + retryable: false, + }); + } + Ok(bytes) + } +} + +impl Target for UltimateTarget { + fn identity_key(&self) -> String { + self.identity + .as_ref() + .and_then(|identity| identity.unique_id.clone()) + .map_or_else( + || format!("ultimate-{}", self.host), + |id| format!("ultimate-{id}"), + ) + } + + fn probe(&mut self) -> Result { + let rest = self.get_json("/v1/version")?; + let device = self.get_json("/v1/info")?; + let identity = TargetIdentity { + kind: TargetKind::Ultimate, + address: Some(self.host.clone()), + product: json_string(&device, "product").unwrap_or_else(|| "C64 Ultimate".into()), + hostname: json_string(&device, "hostname"), + unique_id: json_string(&device, "unique_id"), + firmware_version: json_string(&device, "firmware_version"), + fpga_version: json_string(&device, "fpga_version"), + core_version: json_string(&device, "core_version"), + api_version: json_string(&rest, "version"), + capabilities: vec![ + "identity".into(), + "deploy_prg_reset".into(), + "memory_read_dma".into(), + "pause_resume".into(), + "screen_memory".into(), + ], + }; + self.identity = Some(identity.clone()); + Ok(identity) + } + + fn deploy_and_start(&mut self, artifact: &Artifact) -> Result<()> { + let path = "/v1/runners:run_prg"; + let response = self + .client + .post(format!("{}{path}", self.base_url)) + .headers(self.headers()?) + .header(CONTENT_TYPE, "application/octet-stream") + .body(artifact.bytes.clone()) + .send() + .map_err(|error| { + Error::Deployment(format!( + "POST {path} failed with ambiguous acceptance; not retried: {error}" + )) + })?; + if !response.status().is_success() { + let error = response_error(response, path); + return Err(Error::Deployment(format!( + "{error}; mutation was not retried" + ))); + } + parse_action_response(response, path) + .map_err(|error| Error::Deployment(format!("{error}; mutation was not retried")))?; + self.mutations.push(Mutation { + action: "run_prg".into(), + detail: format!( + "reset, DMA-load, and run {} ({})", + artifact.bundle_name, artifact.sha256 + ), + timestamp: Utc::now(), + }); + Ok(()) + } + + fn read_memory(&mut self, address: u16, length: usize) -> Result> { + let mut last_error = None; + for attempt in 0..=2 { + match self.read_memory_once(address, length) { + Ok(bytes) => return Ok(bytes), + Err(failure) => { + if attempt == 2 || !failure.retryable { + return Err(failure.error); + } + self.retry_count += 1; + last_error = Some(failure.error); + thread::sleep(Duration::from_millis(250 * (attempt + 1))); + } + } + } + Err(last_error.unwrap_or_else(|| Error::Transport("readmem failed".into()))) + } + + fn capture_screen(&mut self, screen_address: u16, color_address: u16) -> Result { + self.put_action("/v1/machine:pause", "pause")?; + self.cpu_resumed = Some(false); + let capture = (|| { + let characters = self.read_memory(screen_address, 1000)?; + let colors = self.read_memory(color_address, 1000)?; + let text = decode_screen(&characters); + Ok(ScreenCapture { + characters, + colors, + text, + }) + })(); + let resume = self.put_action("/v1/machine:resume", "resume"); + if resume.is_ok() { + self.cpu_resumed = Some(true); + } + match (capture, resume) { + (Ok(capture), Ok(())) => Ok(capture), + (Err(error), Ok(())) => Err(error), + (Ok(_), Err(error)) => Err(error), + (Err(capture_error), Err(resume_error)) => Err(Error::Transport(format!( + "{capture_error}; additionally failed to resume CPU: {resume_error}" + ))), + } + } + + fn finish(&mut self) -> Result<()> { + if self.cpu_resumed == Some(false) { + self.put_action("/v1/machine:resume", "resume_cleanup")?; + self.cpu_resumed = Some(true); + } + Ok(()) + } + + fn mutations(&self) -> Vec { + self.mutations.clone() + } + + fn retry_count(&self) -> u32 { + self.retry_count + } + + fn recovery_count(&self) -> u32 { + 0 + } + + fn cpu_resumed(&self) -> Option { + self.cpu_resumed + } +} + +struct ReadMemoryFailure { + error: Error, + retryable: bool, +} + +fn validate_host(host: &str) -> Result<()> { + if host.is_empty() + || host.chars().any(char::is_whitespace) + || host.chars().any(|character| "/@?#".contains(character)) + { + return Err(Error::Configuration( + "device must be a hostname or IPv4 address".into(), + )); + } + Ok(()) +} + +fn retryable_read_status(status: StatusCode) -> bool { + matches!(status.as_u16(), 408 | 409 | 429) || status.is_server_error() +} + +fn parse_json_response(response: Response, path: &str) -> Result { + let value: Value = response + .json() + .map_err(|error| Error::Transport(format!("{path} returned invalid JSON: {error}")))?; + validate_errors(&value, path)?; + Ok(value) +} + +fn parse_action_response(response: Response, path: &str) -> Result<()> { + let bytes = response + .bytes() + .map_err(|error| Error::Transport(format!("{path} response failed: {error}")))?; + if bytes.is_empty() { + return Ok(()); + } + let value: Value = serde_json::from_slice(&bytes) + .map_err(|error| Error::Transport(format!("{path} returned invalid JSON: {error}")))?; + validate_errors(&value, path) +} + +fn validate_errors(value: &Value, path: &str) -> Result<()> { + if let Some(errors) = value.get("errors").and_then(Value::as_array) { + let messages = errors + .iter() + .filter_map(|item| item.as_str().map(str::to_owned)) + .collect::>(); + if !messages.is_empty() { + return Err(Error::Transport(format!( + "{path} reported errors: {}", + messages.join("; ") + ))); + } + } + Ok(()) +} + +fn response_error(response: Response, path: &str) -> Error { + let status = response.status(); + let reason = status.canonical_reason().unwrap_or("HTTP error"); + let body = response.bytes().map_or_else( + |error| format!(""), + |bytes| String::from_utf8_lossy(&bytes).into_owned(), + ); + let detail = serde_json::from_str::(&body) + .ok() + .and_then(|value| { + value.get("errors").and_then(Value::as_array).map(|errors| { + errors + .iter() + .filter_map(Value::as_str) + .collect::>() + .join("; ") + }) + }) + .filter(|detail| !detail.is_empty()) + .unwrap_or_else(|| body.trim().to_owned()); + let password_hint = if status == StatusCode::FORBIDDEN { + " (check C64U_PASSWORD)" + } else { + "" + }; + Error::Transport(format!( + "{path} HTTP {} {reason}: {detail}{password_hint}", + status.as_u16() + )) +} + +fn json_body_error(bytes: &[u8], path: &str) -> Error { + serde_json::from_slice::(bytes).map_or_else( + |_| Error::Transport(format!("{path} returned JSON instead of memory bytes")), + |value| { + let errors = value + .get("errors") + .and_then(Value::as_array) + .map(|items| { + items + .iter() + .filter_map(Value::as_str) + .collect::>() + .join("; ") + }) + .unwrap_or_default(); + Error::Transport(format!("{path} returned JSON errors: {errors}")) + }, + ) +} + +fn json_string(value: &Value, key: &str) -> Option { + value.get(key).and_then(|field| match field { + Value::String(value) => Some(value.clone()), + Value::Number(value) => Some(value.to_string()), + Value::Bool(value) => Some(value.to_string()), + _ => None, + }) +} + +#[cfg(test)] +mod tests { + use std::{ + fs, + io::{Read, Write}, + net::TcpListener, + sync::{Arc, Mutex}, + thread, + }; + + use tempfile::tempdir; + + use super::*; + + #[test] + fn identity_must_match_exactly() { + let identity = TargetIdentity { + kind: TargetKind::Ultimate, + address: Some("device".into()), + product: "Ultimate 64".into(), + hostname: Some("Terakura".into()), + unique_id: Some("8D927F".into()), + firmware_version: None, + fpga_version: None, + core_version: None, + api_version: None, + capabilities: vec![], + }; + assert!(UltimateTarget::verify_expected_identity(&identity, Some("8D927F"), None).is_ok()); + assert!(UltimateTarget::verify_expected_identity(&identity, Some("wrong"), None).is_err()); + } + + #[test] + fn rejects_url_shaped_device() { + assert!(UltimateTarget::new("http://device", Duration::from_secs(1), None).is_err()); + } + + #[test] + fn run_prg_preserves_404_body_and_is_not_retried() { + let (host, requests, server) = serve(vec![http_response( + "404 Not Found", + r#"{"errors":["Cannot open file"]}"#, + "application/json", + )]); + let root = tempdir().unwrap(); + let path = root.path().join("smoke.prg"); + fs::write(&path, [0x01, 0x08, 0xea]).unwrap(); + let artifact = Artifact::load(path).unwrap(); + let mut target = UltimateTarget::new(&host, Duration::from_secs(1), None).unwrap(); + let error = target.deploy_and_start(&artifact).unwrap_err().to_string(); + server.join().unwrap(); + assert!(error.contains("Cannot open file")); + assert!(error.contains("not retried")); + assert_eq!(requests.lock().unwrap().len(), 1); + } + + #[test] + fn probe_checks_errors_array_on_http_200() { + let (host, requests, server) = serve(vec![http_response( + "200 OK", + r#"{"version":"0.1","errors":["service unhappy"]}"#, + "application/json", + )]); + let mut target = UltimateTarget::new(&host, Duration::from_secs(1), None).unwrap(); + let error = target.probe().unwrap_err().to_string(); + server.join().unwrap(); + assert!(error.contains("service unhappy")); + assert_eq!(requests.lock().unwrap().len(), 1); + } + + #[test] + fn probe_sends_password_header_without_exposing_it_in_identity() { + let responses = vec![ + http_response( + "200 OK", + r#"{"version":"0.1","errors":[]}"#, + "application/json", + ), + http_response( + "200 OK", + r#"{"product":"Ultimate 64","hostname":"test","unique_id":"abc","errors":[]}"#, + "application/json", + ), + ]; + let (host, requests, server) = serve(responses); + let mut target = + UltimateTarget::new(&host, Duration::from_secs(1), Some("secret".into())).unwrap(); + let identity = target.probe().unwrap(); + server.join().unwrap(); + assert_eq!(identity.unique_id.as_deref(), Some("abc")); + assert!( + requests + .lock() + .unwrap() + .iter() + .all(|request| request.to_ascii_lowercase().contains("x-password: secret")) + ); + assert!(!serde_json::to_string(&identity).unwrap().contains("secret")); + } + + fn serve(responses: Vec>) -> (String, Arc>>, thread::JoinHandle<()>) { + let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap(); + let address = listener.local_addr().unwrap(); + let requests = Arc::new(Mutex::new(Vec::new())); + let captured = Arc::clone(&requests); + let server = thread::spawn(move || { + for response in responses { + let (mut stream, _) = listener.accept().unwrap(); + stream + .set_read_timeout(Some(Duration::from_secs(1))) + .unwrap(); + let mut bytes = Vec::new(); + let mut chunk = [0_u8; 4096]; + loop { + match stream.read(&mut chunk) { + Ok(0) => break, + Ok(length) => { + bytes.extend_from_slice(&chunk[..length]); + if request_is_complete(&bytes) { + break; + } + } + Err(_) => break, + } + } + captured + .lock() + .unwrap() + .push(String::from_utf8_lossy(&bytes).into_owned()); + stream.write_all(&response).unwrap(); + } + }); + (address.to_string(), requests, server) + } + + fn request_is_complete(bytes: &[u8]) -> bool { + let Some(header_end) = bytes.windows(4).position(|window| window == b"\r\n\r\n") else { + return false; + }; + let headers = String::from_utf8_lossy(&bytes[..header_end]).to_ascii_lowercase(); + let content_length = headers + .lines() + .find_map(|line| line.strip_prefix("content-length:")) + .and_then(|value| value.trim().parse::().ok()) + .unwrap_or(0); + bytes.len() >= header_end + 4 + content_length + } + + fn http_response(status: &str, body: &str, content_type: &str) -> Vec { + format!( + "HTTP/1.1 {status}\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ) + .into_bytes() + } +} diff --git a/crates/ultimate-tool-core/src/target/vice.rs b/crates/ultimate-tool-core/src/target/vice.rs new file mode 100644 index 0000000..ed58420 --- /dev/null +++ b/crates/ultimate-tool-core/src/target/vice.rs @@ -0,0 +1,410 @@ +use std::{ + fs::File, + io::{Read, Write}, + net::{TcpListener, TcpStream}, + path::{Path, PathBuf}, + process::{Child, Command, Stdio}, + thread, + time::{Duration, Instant}, +}; + +use chrono::Utc; + +use crate::{ + Error, Result, + artifact::Artifact, + model::{Mutation, ScreenCapture, TargetIdentity, TargetKind}, + target::{Target, decode_screen}, +}; + +const MONITOR_API_VERSION: u8 = 2; + +pub struct ViceTarget { + executable: PathBuf, + evidence_directory: PathBuf, + child: Option, + monitor: Option, + screenshot_path: PathBuf, + mutations: Vec, + request_id: u32, +} + +impl ViceTarget { + #[must_use] + pub fn new(executable: PathBuf, evidence_directory: &Path) -> Self { + Self { + executable, + evidence_directory: evidence_directory.to_path_buf(), + child: None, + monitor: None, + screenshot_path: evidence_directory.join("vice.png"), + mutations: Vec::new(), + request_id: 1, + } + } + + fn monitor_mut(&mut self) -> Result<&mut BinaryMonitor> { + self.monitor + .as_mut() + .ok_or_else(|| Error::Transport("VICE binary monitor is not connected".into())) + } + + fn next_request_id(&mut self) -> u32 { + let request = self.request_id; + self.request_id = self.request_id.wrapping_add(1); + request + } + + fn wait_for_monitor(&mut self, port: u16) -> Result<()> { + let deadline = Instant::now() + Duration::from_secs(8); + loop { + match TcpStream::connect(("127.0.0.1", port)) { + Ok(stream) => { + stream.set_read_timeout(Some(Duration::from_secs(5)))?; + stream.set_write_timeout(Some(Duration::from_secs(5)))?; + self.monitor = Some(BinaryMonitor { stream }); + return Ok(()); + } + Err(error) if Instant::now() < deadline => { + if let Some(status) = self + .child + .as_mut() + .and_then(|child| child.try_wait().ok()) + .flatten() + { + return Err(Error::Transport(format!( + "VICE exited before monitor connection with {status}" + ))); + } + let _ = error; + thread::sleep(Duration::from_millis(50)); + } + Err(error) => { + return Err(Error::Transport(format!( + "VICE binary monitor did not listen on 127.0.0.1:{port}: {error}" + ))); + } + } + } + } + + fn memory_get(&mut self, address: u16, length: usize) -> Result> { + let length_u16 = u16::try_from(length) + .map_err(|_| Error::Transport(format!("VICE read length {length} exceeds u16")))?; + let end = address + .checked_add(length_u16.saturating_sub(1)) + .ok_or_else(|| Error::Transport("VICE read wraps $FFFF".into()))?; + let mut body = Vec::with_capacity(8); + body.push(0); + body.extend_from_slice(&address.to_le_bytes()); + body.extend_from_slice(&end.to_le_bytes()); + body.push(0); + body.extend_from_slice(&0_u16.to_le_bytes()); + let request = self.next_request_id(); + let response = self.monitor_mut()?.request(request, 0x01, &body)?; + if response.len() < 2 { + return Err(Error::Transport( + "VICE memory response is missing its length".into(), + )); + } + let returned = usize::from(u16::from_le_bytes([response[0], response[1]])); + if returned != length || response.len() != length + 2 { + return Err(Error::Transport(format!( + "VICE memory response has {returned} bytes; expected {length}" + ))); + } + let bytes = response[2..].to_vec(); + let resume_id = self.next_request_id(); + self.monitor_mut()?.request(resume_id, 0xaa, &[])?; + Ok(bytes) + } + + fn capture_display_png(&mut self) -> Result<()> { + let request = self.next_request_id(); + let response = self.monitor_mut()?.request(request, 0x84, &[1, 0])?; + if response.len() < 21 { + return Err(Error::Transport( + "VICE display response is shorter than its fixed fields".into(), + )); + } + let width = usize::from(u16::from_le_bytes([response[4], response[5]])); + let height = usize::from(u16::from_le_bytes([response[6], response[7]])); + let bits_per_pixel = response[16]; + let fields_length = + u32::from_le_bytes([response[0], response[1], response[2], response[3]]); + let fields_length = usize::try_from(fields_length) + .map_err(|_| Error::Transport("VICE display fields length overflows usize".into()))?; + let buffer_length = + u32::from_le_bytes([response[17], response[18], response[19], response[20]]); + let buffer_length = usize::try_from(buffer_length) + .map_err(|_| Error::Transport("VICE display length overflows usize".into()))?; + let data_offset = 4 + fields_length + 4; + let four_byte_tail_omission = response.len() + 4 == data_offset + buffer_length; + if bits_per_pixel != 8 + || width.checked_mul(height) != Some(buffer_length) + || data_offset < 21 + || (response.len() != data_offset + buffer_length && !four_byte_tail_omission) + { + return Err(Error::Transport(format!( + "unsupported VICE display buffer: {width}x{height}, {bits_per_pixel} bpp, \ + {buffer_length} data bytes, {fields_length} field bytes, {} total bytes", + response.len() + ))); + } + let mut pixels = response[data_offset..].to_vec(); + if four_byte_tail_omission { + pixels.resize(buffer_length, 0); + } + write_indexed_png( + &self.screenshot_path, + u32::try_from(width) + .map_err(|_| Error::Evidence("VICE display width overflows u32".into()))?, + u32::try_from(height) + .map_err(|_| Error::Evidence("VICE display height overflows u32".into()))?, + &pixels, + )?; + let resume_id = self.next_request_id(); + self.monitor_mut()?.request(resume_id, 0xaa, &[])?; + Ok(()) + } + + fn quit(&mut self) { + if self.monitor.is_some() { + let request = self.next_request_id(); + let _ = self.monitor_mut().and_then(|monitor| { + monitor.request(request, 0xbb, &[])?; + Ok(()) + }); + } + self.monitor = None; + if let Some(mut child) = self.child.take() { + let deadline = Instant::now() + Duration::from_secs(5); + while Instant::now() < deadline { + match child.try_wait() { + Ok(Some(_)) => return, + Ok(None) => thread::sleep(Duration::from_millis(50)), + Err(_) => break, + } + } + let _ = child.kill(); + let _ = child.wait(); + } + } +} + +impl Target for ViceTarget { + fn identity_key(&self) -> String { + "vice".into() + } + + fn probe(&mut self) -> Result { + let output = Command::new(&self.executable) + .arg("-version") + .output() + .map_err(|error| { + Error::Preflight(format!( + "cannot execute {}: {error}", + self.executable.display() + )) + })?; + if !output.status.success() { + return Err(Error::Preflight(format!( + "{} -version failed: {}", + self.executable.display(), + String::from_utf8_lossy(&output.stderr).trim() + ))); + } + let version = format!( + "{}{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ) + .trim() + .to_owned(); + Ok(TargetIdentity { + kind: TargetKind::Vice, + address: Some("127.0.0.1".into()), + product: "VICE x64sc".into(), + hostname: None, + unique_id: Some("local-vice".into()), + firmware_version: Some(version), + fpga_version: None, + core_version: None, + api_version: Some(MONITOR_API_VERSION.to_string()), + capabilities: vec![ + "deploy_prg".into(), + "memory_read".into(), + "screen_memory".into(), + "exit_screenshot".into(), + ], + }) + } + + fn deploy_and_start(&mut self, artifact: &Artifact) -> Result<()> { + let listener = TcpListener::bind(("127.0.0.1", 0))?; + let port = listener.local_addr()?.port(); + drop(listener); + let stdout = File::create(self.evidence_directory.join("vice.stdout.log"))?; + let stderr = File::create(self.evidence_directory.join("vice.stderr.log"))?; + let monitor_address = format!("ip4://127.0.0.1:{port}"); + let child = Command::new(&self.executable) + .args([ + "-default", + "-silent", + "-sounddev", + "dummy", + "-warp", + "-autostart-warp", + "-autostartprgmode", + "1", + "-binarymonitor", + "-binarymonitoraddress", + ]) + .arg(&monitor_address) + .arg("-exitscreenshot") + .arg(&self.screenshot_path) + .arg("-autostart") + .arg(&artifact.source_path) + .stdout(Stdio::from(stdout)) + .stderr(Stdio::from(stderr)) + .spawn() + .map_err(|error| Error::Deployment(format!("cannot launch VICE: {error}")))?; + self.child = Some(child); + self.mutations.push(Mutation { + action: "vice_autostart".into(), + detail: format!("autostart {} ({})", artifact.bundle_name, artifact.sha256), + timestamp: Utc::now(), + }); + self.wait_for_monitor(port) + } + + fn read_memory(&mut self, address: u16, length: usize) -> Result> { + self.memory_get(address, length) + } + + fn capture_screen(&mut self, screen_address: u16, color_address: u16) -> Result { + let characters = self.memory_get(screen_address, 1000)?; + let colors = self.memory_get(color_address, 1000)?; + self.capture_display_png()?; + let text = decode_screen(&characters); + Ok(ScreenCapture { + characters, + colors, + text, + }) + } + + fn finish(&mut self) -> Result<()> { + self.quit(); + Ok(()) + } + + fn mutations(&self) -> Vec { + self.mutations.clone() + } + + fn retry_count(&self) -> u32 { + 0 + } + + fn recovery_count(&self) -> u32 { + 0 + } + + fn cpu_resumed(&self) -> Option { + Some(true) + } +} + +impl Drop for ViceTarget { + fn drop(&mut self) { + self.quit(); + } +} + +struct BinaryMonitor { + stream: TcpStream, +} + +impl BinaryMonitor { + fn request(&mut self, request_id: u32, command: u8, body: &[u8]) -> Result> { + let body_length = u32::try_from(body.len()) + .map_err(|_| Error::Transport("VICE monitor body is too large".into()))?; + let mut packet = Vec::with_capacity(11 + body.len()); + packet.push(0x02); + packet.push(MONITOR_API_VERSION); + packet.extend_from_slice(&body_length.to_le_bytes()); + packet.extend_from_slice(&request_id.to_le_bytes()); + packet.push(command); + packet.extend_from_slice(body); + self.stream.write_all(&packet)?; + for _ in 0..64 { + let response = self.read_response()?; + if response.request_id == request_id { + if response.error != 0 { + return Err(Error::Transport(format!( + "VICE command ${command:02X} returned error ${:02X}", + response.error + ))); + } + return Ok(response.body); + } + } + Err(Error::Transport(format!( + "VICE command ${command:02X} response was lost among monitor events" + ))) + } + + fn read_response(&mut self) -> Result { + let mut header = [0_u8; 12]; + self.stream.read_exact(&mut header)?; + if header[0] != 0x02 || header[1] != MONITOR_API_VERSION { + return Err(Error::Transport( + "VICE monitor returned an invalid header".into(), + )); + } + let body_length = u32::from_le_bytes([header[2], header[3], header[4], header[5]]); + let body_length = usize::try_from(body_length) + .map_err(|_| Error::Transport("VICE response length overflows usize".into()))?; + if body_length > 16 * 1024 * 1024 { + return Err(Error::Transport(format!( + "VICE response body is implausibly large: {body_length}" + ))); + } + let mut body = vec![0_u8; body_length]; + self.stream.read_exact(&mut body)?; + Ok(MonitorResponse { + error: header[7], + request_id: u32::from_le_bytes([header[8], header[9], header[10], header[11]]), + body, + }) + } +} + +struct MonitorResponse { + error: u8, + request_id: u32, + body: Vec, +} + +fn write_indexed_png(path: &Path, width: u32, height: u32, pixels: &[u8]) -> Result<()> { + const VICE_PALETTE: [u8; 48] = [ + 0x00, 0x00, 0x00, 0xff, 0xff, 0xff, 0x81, 0x33, 0x38, 0x75, 0xce, 0xc8, 0x8e, 0x3c, 0x97, + 0x56, 0xac, 0x4d, 0x2e, 0x2c, 0x9b, 0xed, 0xf1, 0x71, 0x8e, 0x50, 0x29, 0x55, 0x38, 0x00, + 0xc4, 0x6c, 0x71, 0x4a, 0x4a, 0x4a, 0x7b, 0x7b, 0x7b, 0xa9, 0xff, 0x9f, 0x70, 0x6d, 0xeb, + 0xb2, 0xb2, 0xb2, + ]; + let file = File::create(path)?; + let mut encoder = png::Encoder::new(file, width, height); + encoder.set_color(png::ColorType::Indexed); + encoder.set_depth(png::BitDepth::Eight); + encoder.set_palette(VICE_PALETTE.to_vec()); + let mut writer = encoder + .write_header() + .map_err(|error| Error::Evidence(format!("cannot write VICE PNG header: {error}")))?; + let indexed = pixels.iter().map(|pixel| pixel & 0x0f).collect::>(); + writer + .write_image_data(&indexed) + .map_err(|error| Error::Evidence(format!("cannot write VICE PNG pixels: {error}")))?; + Ok(()) +} diff --git a/docs/MAILBOX_PROTOCOL.md b/docs/MAILBOX_PROTOCOL.md new file mode 100644 index 0000000..80843b9 --- /dev/null +++ b/docs/MAILBOX_PROTOCOL.md @@ -0,0 +1,63 @@ +# Ultimate Tool Diagnostic Mailbox v1 + +The v0.1 C64 fixture exposes structured events in a 256-byte, versioned memory +region. The project manifest declares its address; the smoke fixture reserves +`$C000-$C0FF` as a NOLOAD linker region. + +All multibyte integers are little-endian. + +## Header + +| Offset | Size | Meaning | +| --- | ---: | --- | +| 0 | 4 | ASCII magic `UTD1` | +| 4 | 1 | Protocol version, currently `1` | +| 5 | 1 | Header size, `16` | +| 6 | 1 | Record size, `60` | +| 7 | 1 | Record count, `4` | +| 8 | 2 | Latest published sequence | +| 10 | 1 | Terminal state: `0` running, `1` pass, `2` fail | +| 11 | 5 | Reserved and zero | + +## Event records + +Four records begin at offset 16. Sequence `n` uses record +`(n - 1) mod 4`. + +| Record offset | Size | Meaning | +| --- | ---: | --- | +| 0 | 2 | Event sequence | +| 2 | 1 | Event kind | +| 3 | 1 | Flags | +| 4 | 2 | Project-defined numeric code | +| 6 | 1 | Payload length, at most 52 | +| 7 | 1 | Reserved and zero | +| 8 | 52 | UTF-8-compatible ASCII/PETSCII payload | + +Event kinds are: + +1. ready +2. log +3. assertion pass +4. assertion fail +5. test pass +6. test fail + +## Publication and reading + +The C64 writes a record's metadata and payload first, its record sequence +second, and the header sequence last. Terminal state is written before the +final header sequence. + +The host reads the complete mailbox twice. It accepts the snapshot only when +both headers have the same published sequence and every referenced record has a +valid sequence and payload length. Events are deduplicated by sequence. + +Before the first valid magic appears, adapters treat mailbox contents as +program startup state until the test timeout. Once a valid mailbox has been +observed, three consecutive malformed stable reads constitute a protocol +failure. + +The smoke fixture does not use UCI, so bounded DMA reads do not race an active +UCI transfer. Future SDK transports must negotiate an application-level +quiescent checkpoint before mixing UCI activity with host DMA. diff --git a/fixtures/rust-mos-smoke/.cargo/config.toml b/fixtures/rust-mos-smoke/.cargo/config.toml new file mode 100644 index 0000000..06c6bf6 --- /dev/null +++ b/fixtures/rust-mos-smoke/.cargo/config.toml @@ -0,0 +1,5 @@ +[build] +target = "mos-c64-none" + +[unstable] +build-std = ["core"] diff --git a/fixtures/rust-mos-smoke/Cargo.lock b/fixtures/rust-mos-smoke/Cargo.lock new file mode 100644 index 0000000..d168598 --- /dev/null +++ b/fixtures/rust-mos-smoke/Cargo.lock @@ -0,0 +1,7 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "ultimate-tool-smoke" +version = "0.1.0" diff --git a/fixtures/rust-mos-smoke/Cargo.toml b/fixtures/rust-mos-smoke/Cargo.toml new file mode 100644 index 0000000..851b7d9 --- /dev/null +++ b/fixtures/rust-mos-smoke/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "ultimate-tool-smoke" +version = "0.1.0" +edition = "2021" +publish = false + +[dependencies] + +[profile.release] +codegen-units = 1 +lto = "fat" +opt-level = "z" +panic = "abort" +strip = true diff --git a/fixtures/rust-mos-smoke/Makefile b/fixtures/rust-mos-smoke/Makefile new file mode 100644 index 0000000..94c8945 --- /dev/null +++ b/fixtures/rust-mos-smoke/Makefile @@ -0,0 +1,27 @@ +RUST_MOS_IMAGE ?= ghcr.io/koalabuttz/rust-mos:ac2fb2277-4537158-4aaa40e16 +PRG := build/ultimate-tool-smoke.prg +TARGET_PRG := target/mos-c64-none/release/ultimate-tool-smoke +MAX_PRG_BYTES := 8192 + +DOCKER_RUST_MOS = docker run --rm \ + --user "$(shell id -u):$(shell id -g)" \ + --env CARGO_HOME=/tmp/ultimate-tool-cargo \ + --volume "$(CURDIR):/work" \ + --workdir /work \ + "$(RUST_MOS_IMAGE)" + +.PHONY: build clean + +build: + mkdir -p build + $(DOCKER_RUST_MOS) cargo build --release --target mos-c64-none + cp "$(TARGET_PRG)" "$(PRG)" + @prg_bytes="$$(wc -c < "$(PRG)")"; \ + if [ "$$prg_bytes" -gt "$(MAX_PRG_BYTES)" ]; then \ + echo "$(PRG) is $$prg_bytes bytes; expected at most $(MAX_PRG_BYTES)"; \ + exit 1; \ + fi + +clean: + $(DOCKER_RUST_MOS) cargo clean + rm -f "$(PRG)" diff --git a/fixtures/rust-mos-smoke/build.rs b/fixtures/rust-mos-smoke/build.rs new file mode 100644 index 0000000..1c1fc1e --- /dev/null +++ b/fixtures/rust-mos-smoke/build.rs @@ -0,0 +1,4 @@ +fn main() { + println!("cargo:rustc-link-arg=-Tlink.ld"); + println!("cargo:rerun-if-changed=link.ld"); +} diff --git a/fixtures/rust-mos-smoke/link.ld b/fixtures/rust-mos-smoke/link.ld new file mode 100644 index 0000000..3da60d0 --- /dev/null +++ b/fixtures/rust-mos-smoke/link.ld @@ -0,0 +1,56 @@ +/* Ultimate Tool v0.1 smoke fixture. + * + * The diagnostic mailbox is addressable at $C000 but is NOLOAD, so reserving + * it does not inflate the PRG with the gap between code and diagnostics. + */ + +__basic_zp_start = 0x0002; +__basic_zp_end = 0x0090; + +MEMORY { + ram (rw) : ORIGIN = 0x0801, LENGTH = 0xB7FF + mailbox (rw) : ORIGIN = 0xC000, LENGTH = 0x0100 + scratch (rw) : ORIGIN = 0xC100, LENGTH = 0x0F00 +} + +__rc0 = __basic_zp_start; +INCLUDE imag-regs.ld +__basic_zp_size = __basic_zp_end - __basic_zp_start; +MEMORY { zp : ORIGIN = __rc31 + 1, LENGTH = __basic_zp_end - (__rc31 + 1) } + +INPUT(basic-header.o) +INPUT(unmap-basic.o) + +REGION_ALIAS("c_readonly", ram) +REGION_ALIAS("c_writeable", ram) + +__stack = 0xD000; + +SECTIONS { + .basic_header : { *(.basic_header) } + INCLUDE zp.ld + INCLUDE text.ld + INCLUDE rodata.ld + INCLUDE data.ld + INCLUDE bss.ld + + .ultimate_tool_mailbox (NOLOAD) : { + __ultimate_tool_mailbox_start = .; + KEEP(*(.ultimate_tool_mailbox)) + __ultimate_tool_mailbox_end = .; + } > mailbox + + .noinit (NOLOAD) : { INCLUDE noinit-sections.ld } > scratch +} + +ASSERT(__ultimate_tool_mailbox_start == 0xC000, + "Ultimate Tool mailbox must start at $C000") +ASSERT(__ultimate_tool_mailbox_end - __ultimate_tool_mailbox_start == 0x0100, + "Ultimate Tool mailbox must be exactly 256 bytes") +ASSERT(ADDR(.bss) + SIZEOF(.bss) <= 0xC000, + "loaded fixture sections overlap the diagnostic mailbox") + +OUTPUT_FORMAT { + SHORT(ORIGIN(ram)) + TRIM(ram) +} diff --git a/fixtures/rust-mos-smoke/src/main.rs b/fixtures/rust-mos-smoke/src/main.rs new file mode 100644 index 0000000..870c7cb --- /dev/null +++ b/fixtures/rust-mos-smoke/src/main.rs @@ -0,0 +1,131 @@ +#![no_main] +#![no_std] + +use core::panic::PanicInfo; + +const MAILBOX_SIZE: usize = 256; +const HEADER_SIZE: usize = 16; +const RECORD_SIZE: usize = 60; +const RECORD_COUNT: usize = 4; +const SCREEN: *mut u8 = 0x0400 as *mut u8; +const COLOR: *mut u8 = 0xD800 as *mut u8; +const BORDER: *mut u8 = 0xD020 as *mut u8; +const BACKGROUND: *mut u8 = 0xD021 as *mut u8; + +#[link_section = ".ultimate_tool_mailbox"] +#[used] +static mut MAILBOX: [u8; MAILBOX_SIZE] = [0; MAILBOX_SIZE]; + +const FINAL_MAILBOX: [u8; MAILBOX_SIZE] = final_mailbox(); + +#[no_mangle] +pub extern "C" fn main(_argc: isize, _argv: *const *const u8) -> isize { + clear_screen(); + write_screen(0, 0, b"ULTIMATE TOOL SMOKE"); + write_screen(2, 0, b"READY"); + publish_mailbox(); + write_screen(2, 0, b"PASS "); + loop { + core::hint::spin_loop(); + } +} + +fn publish_mailbox() { + for offset in 0..MAILBOX_SIZE { + mailbox_write( + offset, + if offset == 8 || offset == 9 || offset == 10 { + 0 + } else { + FINAL_MAILBOX[offset] + }, + ); + } + // Publish terminal state and sequence only after all records are stable. + mailbox_write(10, FINAL_MAILBOX[10]); + mailbox_write(8, FINAL_MAILBOX[8]); + mailbox_write(9, FINAL_MAILBOX[9]); +} + +fn mailbox_write(offset: usize, value: u8) { + unsafe { + let base = core::ptr::addr_of_mut!(MAILBOX).cast::(); + core::ptr::write_volatile(base.add(offset), value); + } +} + +fn clear_screen() { + unsafe { + core::ptr::write_volatile(BORDER, 6); + core::ptr::write_volatile(BACKGROUND, 6); + for offset in 0..1000 { + core::ptr::write_volatile(SCREEN.add(offset), 32); + core::ptr::write_volatile(COLOR.add(offset), 1); + } + } +} + +fn write_screen(row: usize, column: usize, text: &[u8]) { + let start = row * 40 + column; + unsafe { + for (offset, character) in text.iter().copied().enumerate() { + let screen_code = match character { + b'A'..=b'Z' => character - b'A' + 1, + other => other, + }; + core::ptr::write_volatile(SCREEN.add(start + offset), screen_code); + } + } +} + +const fn final_mailbox() -> [u8; MAILBOX_SIZE] { + let mut bytes = [0_u8; MAILBOX_SIZE]; + bytes[0] = b'U'; + bytes[1] = b'T'; + bytes[2] = b'D'; + bytes[3] = b'1'; + bytes[4] = 1; + bytes[5] = HEADER_SIZE as u8; + bytes[6] = RECORD_SIZE as u8; + bytes[7] = RECORD_COUNT as u8; + bytes[8] = 4; + bytes[9] = 0; + bytes[10] = 1; + put_event(&mut bytes, 0, 1, 1, 0, b"ready"); + put_event(&mut bytes, 1, 2, 2, 0, b"smoke log"); + put_event(&mut bytes, 2, 3, 3, 1, b"mailbox visible"); + put_event(&mut bytes, 3, 4, 5, 0, b"pass"); + bytes +} + +const fn put_event( + bytes: &mut [u8; MAILBOX_SIZE], + index: usize, + sequence: u16, + kind: u8, + code: u16, + message: &[u8], +) { + let offset = HEADER_SIZE + index * RECORD_SIZE; + bytes[offset] = sequence as u8; + bytes[offset + 1] = (sequence >> 8) as u8; + bytes[offset + 2] = kind; + bytes[offset + 4] = code as u8; + bytes[offset + 5] = (code >> 8) as u8; + bytes[offset + 6] = message.len() as u8; + let mut payload_offset = 0; + while payload_offset < message.len() { + bytes[offset + 8 + payload_offset] = message[payload_offset]; + payload_offset += 1; + } +} + +#[panic_handler] +fn panic(_info: &PanicInfo) -> ! { + unsafe { + core::ptr::write_volatile(BORDER, 2); + } + loop { + core::hint::spin_loop(); + } +}