diff --git a/.gitattributes b/.gitattributes index 348290c..a5e632c 100644 --- a/.gitattributes +++ b/.gitattributes @@ -9,6 +9,7 @@ *.yml text eol=lf *.md text eol=lf *.toml text eol=lf +*.lock text eol=lf *.py text eol=lf *.rs text eol=lf LICENSE text eol=lf diff --git a/.github/workflows/ci-conformance.yml b/.github/workflows/ci-conformance.yml new file mode 100644 index 0000000..7ef81c9 --- /dev/null +++ b/.github/workflows/ci-conformance.yml @@ -0,0 +1,45 @@ +name: Bootstrap conformance + +on: + push: + branches: [main] + paths: + - "implementations/**" + - "spec/**" + - "tools/conformance.py" + - ".github/workflows/ci-conformance.yml" + pull_request: + paths: + - "implementations/**" + - "spec/**" + - "tools/conformance.py" + - ".github/workflows/ci-conformance.yml" + workflow_dispatch: + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-node@v6 + with: + node-version: 22 + cache: npm + cache-dependency-path: implementations/node/package-lock.json + - uses: actions/setup-python@v6 + with: + python-version: "3.13" + - run: npm ci + working-directory: implementations/node + - run: npm run build + working-directory: implementations/node + - run: cargo build --locked + working-directory: implementations/rust + - run: >- + python tools/conformance.py + --node implementations/node/dist/cli.js + --python + --rust implementations/rust/target/debug/cubic-rs diff --git a/.github/workflows/ci-python.yml b/.github/workflows/ci-python.yml new file mode 100644 index 0000000..4f2e75a --- /dev/null +++ b/.github/workflows/ci-python.yml @@ -0,0 +1,46 @@ +name: Python CI + +on: + push: + branches: [main] + paths: + - "implementations/python/**" + - "spec/**" + - ".github/workflows/ci-python.yml" + pull_request: + paths: + - "implementations/python/**" + - "spec/**" + - ".github/workflows/ci-python.yml" + workflow_dispatch: + +permissions: + contents: read + +defaults: + run: + working-directory: implementations/python + +jobs: + test: + name: ${{ matrix.os }} / Python ${{ matrix.python }} + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [windows-latest, ubuntu-latest, macos-latest] + python: ["3.10", "3.13"] + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-python@v6 + with: + python-version: ${{ matrix.python }} + cache: pip + cache-dependency-path: implementations/python/pyproject.toml + - run: python -m pip install --upgrade build + - run: python -m unittest discover -s tests -v + env: + PYTHONPATH: src + - run: python -m build + - run: python -m pip install . + - run: cubic-py --version diff --git a/.github/workflows/ci-rust.yml b/.github/workflows/ci-rust.yml new file mode 100644 index 0000000..2b05af1 --- /dev/null +++ b/.github/workflows/ci-rust.yml @@ -0,0 +1,38 @@ +name: Rust CI + +on: + push: + branches: [main] + paths: + - "implementations/rust/**" + - "spec/**" + - ".github/workflows/ci-rust.yml" + pull_request: + paths: + - "implementations/rust/**" + - "spec/**" + - ".github/workflows/ci-rust.yml" + workflow_dispatch: + +permissions: + contents: read + +defaults: + run: + working-directory: implementations/rust + +jobs: + test: + name: ${{ matrix.os }} / stable + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [windows-latest, ubuntu-latest, macos-latest] + steps: + - uses: actions/checkout@v6 + - run: cargo fmt --check + - run: cargo clippy --all-targets -- -D warnings + - run: cargo test --locked + - run: cargo build --release --locked + - run: cargo package --locked diff --git a/README.md b/README.md index 0755efe..2667624 100644 --- a/README.md +++ b/README.md @@ -8,12 +8,12 @@ DevTools HTTP API. | Implementation | Status | Package | Command | | --- | --- | --- | --- | | [Node.js](implementations/node) | Beta candidate | `@tim-1e/holocubic-cli` | `cubic` | -| Python | Planned experiment | Not published | `cubic-py` | -| Rust | Planned experiment | Not published | `cubic-rs` | +| [Python](implementations/python) | Bootstrap experiment | Not published | `cubic-py` | +| [Rust](implementations/rust) | Bootstrap experiment | Not published | `cubic-rs` | -Node.js is the current reference implementation. Python and Rust builds will -be evaluated against the same API and CLI compatibility contract before they -are considered stable. +Node.js is the current reference implementation. Python and Rust currently +implement only the read-only bootstrap handshake and are evaluated against the +same compatibility fixture before they are considered stable. ## Shared contract @@ -22,6 +22,9 @@ The deployed firmware API and compatibility rules are documented in designs, but filesystem safety, HTTP behavior, JSON output, and exit-code semantics should remain compatible. +The first shared black-box slice is documented in +[`spec/cli-bootstrap-v1.md`](spec/cli-bootstrap-v1.md). + ## Node.js quick start ```sh diff --git a/implementations/node/docs/RELEASE_CHECKLIST.md b/implementations/node/docs/RELEASE_CHECKLIST.md index e6ad819..2ea01e0 100644 --- a/implementations/node/docs/RELEASE_CHECKLIST.md +++ b/implementations/node/docs/RELEASE_CHECKLIST.md @@ -19,7 +19,7 @@ This checklist intentionally stops before `npm publish`. - [x] `npm run verify:package` - [x] Windows local verification - [x] Clean Linux verification with official Node 22 and 24 Docker images -- [ ] GitHub Actions Windows/Ubuntu/macOS matrix +- [x] GitHub Actions Windows/Ubuntu/macOS matrix - [x] Real-device E2E with cleanup ## Package review diff --git a/implementations/node/docs/TEST_RESULTS.md b/implementations/node/docs/TEST_RESULTS.md index 046de01..6b18bb1 100644 --- a/implementations/node/docs/TEST_RESULTS.md +++ b/implementations/node/docs/TEST_RESULTS.md @@ -61,6 +61,7 @@ Date: 2026-07-14 - The repository's `.github/workflows/ci-node.yml` covers Windows, Ubuntu, and macOS with Node 22 and 24. - The workflow passes `actionlint` locally. -- macOS execution remains pending until the repository is pushed to GitHub. +- The first pushed matrix completed successfully on Windows, Ubuntu, and macOS + for both Node 22 and Node 24. - npm publication was not attempted. The registry currently returns E404 for `@tim-1e/holocubic-cli`; npm scope ownership still needs user confirmation. diff --git a/implementations/python/LICENSE b/implementations/python/LICENSE new file mode 100644 index 0000000..519e300 --- /dev/null +++ b/implementations/python/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Tim-1e + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/implementations/python/README.md b/implementations/python/README.md new file mode 100644 index 0000000..b413b05 --- /dev/null +++ b/implementations/python/README.md @@ -0,0 +1,21 @@ +# HoloCubic CLI — Python experiment + +This package is an experimental Python implementation of the shared HoloCubic +CLI bootstrap contract. It currently supports version output and the read-only +device handshake; use the Node.js implementation for file and app operations. + +The `Private :: Do Not Upload` classifier intentionally prevents accidental +PyPI publication while the package name and release policy are under review. + +```sh +python -m pip install . +cubic-py --version +cubic-py --host 192.0.2.42 --json info +``` + +Development checks: + +```sh +python -m unittest discover -s tests -v +python -m build +``` diff --git a/implementations/python/pyproject.toml b/implementations/python/pyproject.toml new file mode 100644 index 0000000..5fc1e18 --- /dev/null +++ b/implementations/python/pyproject.toml @@ -0,0 +1,26 @@ +[build-system] +requires = ["hatchling>=1.26,<2"] +build-backend = "hatchling.build" + +[project] +name = "holocubic-cli-python" +version = "0.1.0a1" +description = "Experimental Python CLI for the HoloCubic DevTools API" +readme = "README.md" +requires-python = ">=3.10" +license = "MIT" +license-files = ["LICENSE"] +authors = [{ name = "Tim-1e" }] +keywords = ["holocubic", "esp32", "cli", "devtools"] +classifiers = ["Private :: Do Not Upload"] +dependencies = [] + +[project.scripts] +cubic-py = "holocubic_cli_python.cli:main" + +[project.urls] +Repository = "https://github.com/Tim-1e/holocubic-cli" +Issues = "https://github.com/Tim-1e/holocubic-cli/issues" + +[tool.hatch.build.targets.wheel] +packages = ["src/holocubic_cli_python"] diff --git a/implementations/python/src/holocubic_cli_python/__init__.py b/implementations/python/src/holocubic_cli_python/__init__.py new file mode 100644 index 0000000..dd4e34a --- /dev/null +++ b/implementations/python/src/holocubic_cli_python/__init__.py @@ -0,0 +1,3 @@ +"""Experimental Python implementation of the HoloCubic CLI.""" + +__version__ = "0.1.0a1" diff --git a/implementations/python/src/holocubic_cli_python/__main__.py b/implementations/python/src/holocubic_cli_python/__main__.py new file mode 100644 index 0000000..eb53e2f --- /dev/null +++ b/implementations/python/src/holocubic_cli_python/__main__.py @@ -0,0 +1,3 @@ +from .cli import main + +raise SystemExit(main()) diff --git a/implementations/python/src/holocubic_cli_python/cli.py b/implementations/python/src/holocubic_cli_python/cli.py new file mode 100644 index 0000000..43dce6c --- /dev/null +++ b/implementations/python/src/holocubic_cli_python/cli.py @@ -0,0 +1,44 @@ +import argparse +import json +import os +import sys +from collections.abc import Sequence + +from . import __version__ +from .client import fetch_info + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(prog="cubic-py", description="Experimental Python HoloCubic CLI") + parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}") + parser.add_argument("--host", help="device host or DevTools URL") + parser.add_argument("--timeout", type=int, default=60_000, metavar="MILLISECONDS") + parser.add_argument("--json", action="store_true", dest="as_json") + subcommands = parser.add_subparsers(dest="command", required=True) + subcommands.add_parser("info", help="show device capabilities and transfer limits") + return parser + + +def main(argv: Sequence[str] | None = None) -> int: + parser = build_parser() + args = parser.parse_args(argv) + host = args.host or os.environ.get("CUBIC_HOST") + if not host: + parser.error("a device target is required through --host or CUBIC_HOST") + + try: + info = fetch_info(host, args.timeout) + except (ValueError, RuntimeError) as error: + print(f"cubic-py: {error}", file=sys.stderr) + return 1 + + if args.as_json: + print(json.dumps(info, ensure_ascii=False, separators=(",", ":"))) + else: + print(f"URL: {info['url']}") + print(f"Version: {info['version'] or 'unknown'}") + print(f"API: v{info['api_version']}") + print(f"Root: {info['root_path']}") + print(f"Chunk size: {info['chunk_size']} bytes") + print(f"Max file: {info['max_file_size']} bytes") + return 0 diff --git a/implementations/python/src/holocubic_cli_python/client.py b/implementations/python/src/holocubic_cli_python/client.py new file mode 100644 index 0000000..e4fc66e --- /dev/null +++ b/implementations/python/src/holocubic_cli_python/client.py @@ -0,0 +1,85 @@ +import json +from typing import Any +from urllib.error import HTTPError, URLError +from urllib.request import Request, urlopen + +from .url import normalize_device_url + +LEGACY_CAPABILITIES = [ + "fs.list", + "fs.stat", + "fs.read", + "fs.write", + "fs.mkdir", + "fs.rename", + "fs.remove", + "fs.rmdir", + "apps.list", + "devrun.read", + "devrun.save", + "devrun.run", +] + + +def _positive_integer(value: Any, field: str) -> int: + if isinstance(value, bool) or not isinstance(value, int) or value <= 0: + raise ValueError(f"Device response has an invalid {field}.") + return value + + +def public_info(raw: Any, url: str) -> dict[str, Any]: + if not isinstance(raw, dict) or raw.get("ok") is not True: + raise ValueError("Device handshake did not return ok=true.") + + api_version = raw.get("api_version", 1) + if isinstance(api_version, bool) or not isinstance(api_version, int) or api_version != 1: + raise ValueError(f"Unsupported DevTools API version: {api_version}.") + if raw.get("root_path") != "/sd": + raise ValueError("Device response has an invalid root_path.") + + chunk_size = _positive_integer(raw.get("chunk_size"), "chunk_size") + max_file_size = _positive_integer(raw.get("max_file_size"), "max_file_size") + run_app_id = raw.get("run_app_id") + run_app_main = raw.get("run_app_main") + if not isinstance(run_app_id, str) or not isinstance(run_app_main, str): + raise ValueError("Device response is missing run app metadata.") + if not run_app_main.startswith("/sd/"): + raise ValueError("Device response has an invalid run_app_main.") + + capabilities = raw.get("capabilities") + if not isinstance(capabilities, list) or not all(isinstance(item, str) for item in capabilities): + capabilities = LEGACY_CAPABILITIES + max_code_bytes = raw.get("max_code_bytes", 192 * 1024) + max_code_bytes = _positive_integer(max_code_bytes, "max_code_bytes") + + return { + "name": None, + "url": url, + "version": raw.get("version") if isinstance(raw.get("version"), str) else None, + "api_version": api_version, + "route_base": "/devtools", + "root_path": "/sd", + "chunk_size": chunk_size, + "max_file_size": max_file_size, + "max_code_bytes": max_code_bytes, + "run_app_id": run_app_id, + "run_app_main": run_app_main, + "capabilities": capabilities, + } + + +def fetch_info(host: str, timeout_ms: int = 60_000) -> dict[str, Any]: + if timeout_ms <= 0: + raise ValueError("Timeout must be greater than zero.") + base_url = normalize_device_url(host) + request = Request(f"{base_url}/api/info", headers={"Accept": "application/json"}) + try: + with urlopen(request, timeout=timeout_ms / 1000) as response: + raw = json.load(response) + except HTTPError as error: + raise RuntimeError(f"Device returned HTTP {error.code} for GET /api/info.") from error + except URLError as error: + raise RuntimeError(f"Could not connect to HoloCubic: {error.reason}") from error + except (json.JSONDecodeError, UnicodeDecodeError) as error: + raise RuntimeError("Device returned invalid JSON for GET /api/info.") from error + return public_info(raw, base_url) diff --git a/implementations/python/src/holocubic_cli_python/url.py b/implementations/python/src/holocubic_cli_python/url.py new file mode 100644 index 0000000..ba0e503 --- /dev/null +++ b/implementations/python/src/holocubic_cli_python/url.py @@ -0,0 +1,36 @@ +from urllib.parse import SplitResult, urlsplit, urlunsplit + + +def normalize_device_url(value: str) -> str: + trimmed = value.strip() + if not trimmed: + raise ValueError("Device host cannot be empty.") + if "\0" in trimmed: + raise ValueError("Device host contains an invalid NUL character.") + + candidate = trimmed if "://" in trimmed else f"http://{trimmed}" + try: + parsed = urlsplit(candidate) + _ = parsed.port + except ValueError as error: + raise ValueError(f"Invalid device host: {value}") from error + + if parsed.scheme not in {"http", "https"}: + raise ValueError(f"Unsupported device URL scheme: {parsed.scheme}") + if not parsed.hostname: + raise ValueError(f"Invalid device host: {value}") + if parsed.username is not None or parsed.password is not None: + raise ValueError("Credentials are not allowed in the device URL.") + if parsed.query or parsed.fragment: + raise ValueError("Device URL must not contain a query string or fragment.") + + path = parsed.path.rstrip("/") + if path in {"", "/devtools"}: + path = "/devtools" + elif path == "/devtools/api": + path = "/devtools" + else: + raise ValueError("Device URL path must be /devtools or /devtools/api.") + + normalized = SplitResult(parsed.scheme.lower(), parsed.netloc, path, "", "") + return urlunsplit(normalized) diff --git a/implementations/python/tests/test_client.py b/implementations/python/tests/test_client.py new file mode 100644 index 0000000..2925aea --- /dev/null +++ b/implementations/python/tests/test_client.py @@ -0,0 +1,53 @@ +import json +import threading +import unittest +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + +from holocubic_cli_python.client import fetch_info, public_info + + +FIXTURE = { + "ok": True, + "version": "conformance-fixture", + "root_path": "/sd", + "chunk_size": 262144, + "max_file_size": 67108864, + "run_app_id": "devrun", + "run_app_main": "/sd/apps/devrun/main.lua", +} + + +class Handler(BaseHTTPRequestHandler): + def do_GET(self) -> None: + if self.path != "/devtools/api/info": + self.send_error(404) + return + body = json.dumps(FIXTURE).encode() + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def log_message(self, _format: str, *args: object) -> None: + del args + + +class ClientTests(unittest.TestCase): + def test_fetches_and_normalizes_legacy_info(self) -> None: + server = ThreadingHTTPServer(("127.0.0.1", 0), Handler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + info = fetch_info(f"127.0.0.1:{server.server_port}") + finally: + server.shutdown() + server.server_close() + thread.join() + self.assertEqual(info["api_version"], 1) + self.assertEqual(info["root_path"], "/sd") + self.assertIn("fs.read", info["capabilities"]) + + def test_rejects_invalid_handshake(self) -> None: + with self.assertRaises(ValueError): + public_info({"ok": True, **{key: value for key, value in FIXTURE.items() if key != "root_path"}}, "http://host/devtools") diff --git a/implementations/python/tests/test_url.py b/implementations/python/tests/test_url.py new file mode 100644 index 0000000..f1caef6 --- /dev/null +++ b/implementations/python/tests/test_url.py @@ -0,0 +1,16 @@ +import unittest + +from holocubic_cli_python.url import normalize_device_url + + +class NormalizeDeviceUrlTests(unittest.TestCase): + def test_normalizes_supported_forms(self) -> None: + self.assertEqual(normalize_device_url("192.0.2.42"), "http://192.0.2.42/devtools") + self.assertEqual(normalize_device_url("http://host/devtools/api/"), "http://host/devtools") + self.assertEqual(normalize_device_url("https://host:8443/devtools"), "https://host:8443/devtools") + + def test_rejects_unsafe_forms(self) -> None: + invalid = ["", "ftp://host", "http://user:pass@host", "http://host/other", "http://host?x=1"] + for value in invalid: + with self.subTest(value=value), self.assertRaises(ValueError): + normalize_device_url(value) diff --git a/implementations/rust/Cargo.lock b/implementations/rust/Cargo.lock new file mode 100644 index 0000000..58f939e --- /dev/null +++ b/implementations/rust/Cargo.lock @@ -0,0 +1,969 @@ +# 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 = "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 = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cc" +version = "1.2.67" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17dd265a7d0f31ef544e1b20e03add05d3b45b491b633b10d67145d2acc1a38" +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 = "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", +] + +[[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 = "cookie" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" +dependencies = [ + "percent-encoding", + "time", + "version_check", +] + +[[package]] +name = "cookie_store" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fc4bff745c9b4c7fb1e97b25d13153da2bc7796260141df62378998d070207f" +dependencies = [ + "cookie", + "document-features", + "idna", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "time", + "url", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "powerfmt", +] + +[[package]] +name = "displaydoc" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[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 = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[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 = "holocubic-cli-rust" +version = "0.1.0-alpha.1" +dependencies = [ + "clap", + "serde_json", + "ureq", + "url", +] + +[[package]] +name = "http" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "icu_collections" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +dependencies = [ + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" + +[[package]] +name = "icu_properties" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" + +[[package]] +name = "icu_provider" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +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.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +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 = "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 = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[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 = "num-conv" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" + +[[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 = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustls" +version = "0.23.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c54fcab019b409d04215d3a17cb438fd7fbf192ee61461f20f4fe18704bc138" +dependencies = [ + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "764899a24af3980067ee14bc143654f297b22eaebfe3c7b6b211920a5a59b046" +dependencies = [ + "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 = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "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 = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "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.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "time" +version = "0.3.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9e442fc33d7fdb45aa9bfeb312c095964abdf596f7567261062b2a7107aaabd" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b36ee98fd31ec7426d599183e8fe26932a8dc1fb76ddb6214d05493377d34ca" + +[[package]] +name = "time-macros" +version = "0.2.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e552d1249bf61ac2a52db88179fd0673def1e1ad8243a00d9ec9ed71fee3dd" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[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 = "ureq" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dea7109cdcd5864d4eeb1b58a1648dc9bf520360d7af16ec26d0a9354bafcfc0" +dependencies = [ + "base64", + "cookie_store", + "flate2", + "log", + "percent-encoding", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "ureq-proto", + "utf8-zero", + "webpki-roots", +] + +[[package]] +name = "ureq-proto" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e994ba84b0bd1b1b0cf92878b7ef898a5c1760108fe7b6010327e274917a808c" +dependencies = [ + "base64", + "http", + "httparse", + "log", +] + +[[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-zero" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e" + +[[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 = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[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-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[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 = "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", + "synstructure", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "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", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/implementations/rust/Cargo.toml b/implementations/rust/Cargo.toml new file mode 100644 index 0000000..d9c78e2 --- /dev/null +++ b/implementations/rust/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "holocubic-cli-rust" +version = "0.1.0-alpha.1" +edition = "2024" +rust-version = "1.85" +description = "Experimental Rust CLI for the HoloCubic DevTools API" +license = "MIT" +readme = "README.md" +repository = "https://github.com/Tim-1e/holocubic-cli" +homepage = "https://github.com/Tim-1e/holocubic-cli/tree/main/implementations/rust" +keywords = ["holocubic", "esp32", "cli", "devtools"] +publish = false + +[[bin]] +name = "cubic-rs" +path = "src/main.rs" + +[dependencies] +clap = { version = "4.6.1", features = ["derive"] } +serde_json = "1.0.150" +ureq = { version = "3.3.0", features = ["json"] } +url = "2.5.8" diff --git a/implementations/rust/LICENSE b/implementations/rust/LICENSE new file mode 100644 index 0000000..519e300 --- /dev/null +++ b/implementations/rust/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Tim-1e + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/implementations/rust/README.md b/implementations/rust/README.md new file mode 100644 index 0000000..792d9da --- /dev/null +++ b/implementations/rust/README.md @@ -0,0 +1,22 @@ +# HoloCubic CLI — Rust experiment + +This crate is an experimental Rust implementation of the shared HoloCubic CLI +bootstrap contract. It currently supports version output and the read-only +device handshake; use the Node.js implementation for file and app operations. + +`publish = false` intentionally prevents accidental crates.io publication while +the crate name and release policy are under review. + +```sh +cargo run -- --version +cargo run -- --host 192.0.2.42 --json info +``` + +Development checks: + +```sh +cargo fmt --check +cargo clippy --all-targets -- -D warnings +cargo test +cargo package +``` diff --git a/implementations/rust/src/lib.rs b/implementations/rust/src/lib.rs new file mode 100644 index 0000000..74ffbc2 --- /dev/null +++ b/implementations/rust/src/lib.rs @@ -0,0 +1,165 @@ +use serde_json::{Value, json}; +use url::Url; + +pub const LEGACY_CAPABILITIES: [&str; 12] = [ + "fs.list", + "fs.stat", + "fs.read", + "fs.write", + "fs.mkdir", + "fs.rename", + "fs.remove", + "fs.rmdir", + "apps.list", + "devrun.read", + "devrun.save", + "devrun.run", +]; + +pub fn normalize_device_url(value: &str) -> Result { + let trimmed = value.trim(); + if trimmed.is_empty() { + return Err("Device host cannot be empty.".into()); + } + if trimmed.contains('\0') { + return Err("Device host contains an invalid NUL character.".into()); + } + + let candidate = if trimmed.contains("://") { + trimmed.to_owned() + } else { + format!("http://{trimmed}") + }; + let mut url = Url::parse(&candidate).map_err(|_| format!("Invalid device host: {value}"))?; + if url.scheme() != "http" && url.scheme() != "https" { + return Err(format!("Unsupported device URL scheme: {}", url.scheme())); + } + if url.host_str().is_none() { + return Err(format!("Invalid device host: {value}")); + } + if !url.username().is_empty() || url.password().is_some() { + return Err("Credentials are not allowed in the device URL.".into()); + } + if url.query().is_some() || url.fragment().is_some() { + return Err("Device URL must not contain a query string or fragment.".into()); + } + + match url.path().trim_end_matches('/') { + "" | "/devtools" | "/devtools/api" => url.set_path("/devtools"), + _ => return Err("Device URL path must be /devtools or /devtools/api.".into()), + } + Ok(url.to_string().trim_end_matches('/').to_owned()) +} + +fn positive_integer(object: &serde_json::Map, field: &str) -> Result { + object + .get(field) + .and_then(Value::as_u64) + .filter(|value| *value > 0) + .ok_or_else(|| format!("Device response has an invalid {field}.")) +} + +pub fn public_info(raw: &Value, url: &str) -> Result { + let object = raw + .as_object() + .ok_or_else(|| "Device returned an invalid info response.".to_owned())?; + if object.get("ok").and_then(Value::as_bool) != Some(true) { + return Err("Device handshake did not return ok=true.".into()); + } + + let api_version = object + .get("api_version") + .and_then(Value::as_u64) + .unwrap_or(1); + if api_version != 1 { + return Err(format!("Unsupported DevTools API version: {api_version}.")); + } + if object.get("root_path").and_then(Value::as_str) != Some("/sd") { + return Err("Device response has an invalid root_path.".into()); + } + + let chunk_size = positive_integer(object, "chunk_size")?; + let max_file_size = positive_integer(object, "max_file_size")?; + let max_code_bytes = match object.get("max_code_bytes") { + Some(_) => positive_integer(object, "max_code_bytes")?, + None => 192 * 1024, + }; + let run_app_id = object + .get("run_app_id") + .and_then(Value::as_str) + .ok_or_else(|| "Device response is missing run_app_id.".to_owned())?; + let run_app_main = object + .get("run_app_main") + .and_then(Value::as_str) + .ok_or_else(|| "Device response is missing run_app_main.".to_owned())?; + if !run_app_main.starts_with("/sd/") { + return Err("Device response has an invalid run_app_main.".into()); + } + + let capabilities = object + .get("capabilities") + .and_then(Value::as_array) + .filter(|items| items.iter().all(Value::is_string)) + .cloned() + .unwrap_or_else(|| LEGACY_CAPABILITIES.iter().map(|item| json!(item)).collect()); + + Ok(json!({ + "name": Value::Null, + "url": url, + "version": object.get("version").and_then(Value::as_str), + "api_version": api_version, + "route_base": "/devtools", + "root_path": "/sd", + "chunk_size": chunk_size, + "max_file_size": max_file_size, + "max_code_bytes": max_code_bytes, + "run_app_id": run_app_id, + "run_app_main": run_app_main, + "capabilities": capabilities, + })) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn normalizes_supported_urls() { + assert_eq!( + normalize_device_url("192.0.2.42").unwrap(), + "http://192.0.2.42/devtools" + ); + assert_eq!( + normalize_device_url("http://host/devtools/api/").unwrap(), + "http://host/devtools" + ); + } + + #[test] + fn rejects_unsafe_urls() { + for input in [ + "", + "ftp://host", + "http://user:pass@host", + "http://host/other", + "http://host?x=1", + ] { + assert!(normalize_device_url(input).is_err(), "{input}"); + } + } + + #[test] + fn validates_legacy_info() { + let raw = json!({ + "ok": true, + "root_path": "/sd", + "chunk_size": 262144, + "max_file_size": 67108864, + "run_app_id": "devrun", + "run_app_main": "/sd/apps/devrun/main.lua" + }); + let info = public_info(&raw, "http://host/devtools").unwrap(); + assert_eq!(info["api_version"], 1); + assert_eq!(info["capabilities"].as_array().unwrap().len(), 12); + } +} diff --git a/implementations/rust/src/main.rs b/implementations/rust/src/main.rs new file mode 100644 index 0000000..98e3422 --- /dev/null +++ b/implementations/rust/src/main.rs @@ -0,0 +1,99 @@ +use std::env; +use std::process::ExitCode; +use std::time::Duration; + +use clap::error::ErrorKind; +use clap::{CommandFactory, Parser, Subcommand}; +use holocubic_cli_rust::{normalize_device_url, public_info}; +use serde_json::Value; +use ureq::Agent; + +#[derive(Parser)] +#[command(name = "cubic-rs", version, about = "Experimental Rust HoloCubic CLI")] +struct Cli { + #[arg(long, global = true)] + host: Option, + #[arg( + long, + global = true, + default_value_t = 60_000, + value_name = "MILLISECONDS" + )] + timeout: u64, + #[arg(long, global = true)] + json: bool, + #[command(subcommand)] + command: Commands, +} + +#[derive(Subcommand)] +enum Commands { + /// Show device capabilities and transfer limits. + Info, +} + +fn main() -> ExitCode { + let cli = Cli::parse(); + let host = cli + .host + .or_else(|| env::var("CUBIC_HOST").ok()) + .unwrap_or_else(|| { + Cli::command() + .error( + ErrorKind::MissingRequiredArgument, + "a device target is required through --host or CUBIC_HOST", + ) + .exit() + }); + + match run(&cli.command, &host, cli.timeout, cli.json) { + Ok(()) => ExitCode::SUCCESS, + Err(error) => { + eprintln!("cubic-rs: {error}"); + ExitCode::FAILURE + } + } +} + +fn run(command: &Commands, host: &str, timeout_ms: u64, json_output: bool) -> Result<(), String> { + if timeout_ms == 0 { + return Err("Timeout must be greater than zero.".into()); + } + let base_url = normalize_device_url(host)?; + match command { + Commands::Info => { + let config = Agent::config_builder() + .timeout_global(Some(Duration::from_millis(timeout_ms))) + .build(); + let agent = Agent::new_with_config(config); + let mut response = agent + .get(&format!("{base_url}/api/info")) + .call() + .map_err(|error| format!("Could not connect to HoloCubic: {error}"))?; + let raw: Value = response.body_mut().read_json().map_err(|error| { + format!("Device returned invalid JSON for GET /api/info: {error}") + })?; + let info = public_info(&raw, &base_url)?; + if json_output { + println!( + "{}", + serde_json::to_string(&info).map_err(|error| error.to_string())? + ); + } else { + println!("URL: {}", info["url"].as_str().unwrap_or("unknown")); + println!( + "Version: {}", + info["version"].as_str().unwrap_or("unknown") + ); + println!("API: v{}", info["api_version"]); + println!( + "Root: {}", + info["root_path"].as_str().unwrap_or("unknown") + ); + println!("Chunk size: {} bytes", info["chunk_size"]); + println!("Max file: {} bytes", info["max_file_size"]); + } + } + } + Ok(()) +} diff --git a/spec/cli-bootstrap-v1.md b/spec/cli-bootstrap-v1.md new file mode 100644 index 0000000..239027d --- /dev/null +++ b/spec/cli-bootstrap-v1.md @@ -0,0 +1,23 @@ +# CLI bootstrap compatibility v1 + +This contract is the first cross-language compatibility slice. Passing it does +not imply support for recursive transfer, mutation, DevRun, or app workflows. + +Each implementation must provide: + +- a unique executable name (`cubic`, `cubic-py`, or `cubic-rs`); +- `--version` with exit code 0; +- `--host --json info` and the `CUBIC_HOST` fallback; +- target URL normalization compatible with `api-v1.md`; +- a validated `GET /devtools/api/info` handshake; +- JSON output with the Node reference implementation's public info fields; +- exit code 0 for success, 1 for connection/response failures, and 2 for usage + errors. + +Current conformance levels: + +| Implementation | Bootstrap v1 | Full filesystem v1 | +| --- | --- | --- | +| Node.js | Yes | Yes | +| Python | Experimental | No | +| Rust | Experimental | No | diff --git a/spec/fixtures/info-v1.json b/spec/fixtures/info-v1.json new file mode 100644 index 0000000..3b8df8a --- /dev/null +++ b/spec/fixtures/info-v1.json @@ -0,0 +1,9 @@ +{ + "ok": true, + "version": "conformance-fixture", + "root_path": "/sd", + "chunk_size": 262144, + "max_file_size": 67108864, + "run_app_id": "devrun", + "run_app_main": "/sd/apps/devrun/main.lua" +} diff --git a/tools/conformance.py b/tools/conformance.py new file mode 100644 index 0000000..d83d9cb --- /dev/null +++ b/tools/conformance.py @@ -0,0 +1,118 @@ +import argparse +import json +import os +import subprocess +import sys +import threading +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +FIXTURE = json.loads((ROOT / "spec" / "fixtures" / "info-v1.json").read_text(encoding="utf-8")) + + +class Handler(BaseHTTPRequestHandler): + def do_GET(self) -> None: + if self.path != "/devtools/api/info": + self.send_error(404) + return + body = json.dumps(FIXTURE).encode() + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def log_message(self, _format: str, *args: object) -> None: + del args + + +def run_implementation(name: str, command: list[str], host: str, env: dict[str, str]) -> dict[str, object]: + result = subprocess.run( + [*command, "--host", host, "--json", "info"], + cwd=ROOT, + env=env, + text=True, + encoding="utf-8", + capture_output=True, + timeout=30, + check=False, + ) + if result.returncode != 0: + raise AssertionError(f"{name} exited {result.returncode}: {result.stderr}") + try: + payload = json.loads(result.stdout) + except json.JSONDecodeError as error: + raise AssertionError(f"{name} returned invalid JSON: {result.stdout}") from error + required = { + "url", + "api_version", + "route_base", + "root_path", + "chunk_size", + "max_file_size", + "max_code_bytes", + "run_app_id", + "run_app_main", + "capabilities", + } + if not isinstance(payload, dict) or not required.issubset(payload): + raise AssertionError(f"{name} omitted required public info fields: {payload}") + return payload + + +def main() -> int: + parser = argparse.ArgumentParser(description="Run the HoloCubic bootstrap contract") + parser.add_argument("--node", type=Path) + parser.add_argument("--python", action="store_true", dest="python_impl") + parser.add_argument("--rust", type=Path) + args = parser.parse_args() + if not any((args.node, args.python_impl, args.rust)): + parser.error("select at least one implementation") + + implementations: list[tuple[str, list[str], dict[str, str]]] = [] + base_env = os.environ.copy() + if args.node: + implementations.append(("node", ["node", str(args.node.resolve())], base_env)) + if args.python_impl: + python_env = base_env.copy() + python_source = ROOT / "implementations" / "python" / "src" + python_env["PYTHONPATH"] = os.pathsep.join(filter(None, [str(python_source), python_env.get("PYTHONPATH")])) + implementations.append(("python", [sys.executable, "-m", "holocubic_cli_python"], python_env)) + if args.rust: + implementations.append(("rust", [str(args.rust.resolve())], base_env)) + + server = ThreadingHTTPServer(("127.0.0.1", 0), Handler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + host = f"127.0.0.1:{server.server_port}" + outputs = [run_implementation(name, command, host, env) for name, command, env in implementations] + finally: + server.shutdown() + server.server_close() + thread.join() + + comparable_fields = [ + "api_version", + "route_base", + "root_path", + "chunk_size", + "max_file_size", + "max_code_bytes", + "run_app_id", + "run_app_main", + "capabilities", + ] + reference = {field: outputs[0][field] for field in comparable_fields} + for output in outputs[1:]: + candidate = {field: output[field] for field in comparable_fields} + if candidate != reference: + raise AssertionError(f"implementation output differs from reference\n{reference}\n{candidate}") + print(f"Bootstrap conformance passed for {len(outputs)} implementation(s).") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())