diff --git a/.github/workflows/rust-binary-build.yml b/.github/workflows/rust-binary-build.yml new file mode 100644 index 0000000..0416b2e --- /dev/null +++ b/.github/workflows/rust-binary-build.yml @@ -0,0 +1,55 @@ +name: rust-binary-build + +on: + workflow_dispatch: + pull_request: + branches: [ "main" ] + paths: + - 'rust-toolchain.toml' + - 'adk-rust/**' + - 'scripts/package_rust_release_binaries.py' + - '.github/workflows/rust-binary-build.yml' + push: + tags: + - 'v*' + +jobs: + build-linux-x86_64: + runs-on: ubuntu-latest + timeout-minutes: 60 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install pinned Rust toolchain + run: | + rustup toolchain install 1.94.0 --profile minimal --component rustfmt --component clippy + rustup override set 1.94.0 + rustup show active-toolchain + cargo +1.94.0 --version + rustc +1.94.0 --version + + - name: Build release binaries + run: cargo +1.94.0 build --manifest-path adk-rust/Cargo.toml --workspace --release + + - name: Package release binaries + run: | + python3 scripts/package_rust_release_binaries.py \ + --release-dir adk-rust/target/release \ + --out-dir dist/awatch-rus-linux-x86_64 \ + --archive dist/awatch-rus-linux-x86_64-release-binaries.tar.gz \ + --target linux-x86_64 + + - name: Upload release binaries artifact + uses: actions/upload-artifact@v4 + with: + name: awatch-rus-linux_x86_64-release-binaries + path: | + dist/awatch-rus-linux_x86_64-release-binaries.tar.gz + dist/awatch-rus-linux_x86_64-release-binaries.tar.gz.sha256 + dist/awatch-rus-linux_x86_64/BINARIES.txt + dist/awatch-rus-linux_x86_64/SHA256SUMS.txt + dist/awatch-rus-linux_x86_64/BUILD_MANIFEST.json + if-no-files-found: error + retention-days: 30 diff --git a/.github/workflows/rust-clippy-diagnostic.yml b/.github/workflows/rust-clippy-diagnostic.yml new file mode 100644 index 0000000..d1ea435 --- /dev/null +++ b/.github/workflows/rust-clippy-diagnostic.yml @@ -0,0 +1,40 @@ +name: Rust clippy diagnostic + +on: + push: + branches: + - codex/rust-professionalization + workflow_dispatch: + +jobs: + detmir-portal-clippy-diagnostic: + runs-on: ubuntu-latest + timeout-minutes: 30 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install Rust 1.85 with rustfmt and clippy + run: | + rustup toolchain install 1.85.0 --profile minimal --component rustfmt --component clippy + rustup default 1.85.0 + + - name: Capture detmir-portal clippy output + working-directory: adk-rust + run: | + set +e + cargo clippy -p detmir-portal --all-targets -- -D warnings > ../detmir-portal-clippy.log 2>&1 + status=$? + echo "clippy_exit_status=${status}" > ../detmir-portal-clippy-status.txt + tail -n 240 ../detmir-portal-clippy.log + exit ${status} + + - name: Upload detmir-portal clippy log + if: always() + uses: actions/upload-artifact@v4 + with: + name: detmir-portal-clippy-log + path: | + detmir-portal-clippy.log + detmir-portal-clippy-status.txt diff --git a/.github/workflows/rust-professionalization-check.yml b/.github/workflows/rust-professionalization-check.yml new file mode 100644 index 0000000..d703c90 --- /dev/null +++ b/.github/workflows/rust-professionalization-check.yml @@ -0,0 +1,73 @@ +name: Rust professionalization check + +on: + pull_request: + branches: + - main + paths: + - 'rust-toolchain.toml' + - 'adk-rust/crates/detmir-core/**' + - 'adk-rust/crates/detmir-portal/**' + - 'scripts/check_private_config_guard.sh' + - 'scripts/check_portal_contract_sync.mjs' + - '.github/workflows/rust-professionalization-check.yml' + workflow_dispatch: + +jobs: + rust-check: + name: changed Rust crates smoke + runs-on: ubuntu-latest + timeout-minutes: 40 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install pinned Rust toolchain + run: | + rustup toolchain install 1.94.0 --profile minimal --component rustfmt --component clippy + rustup override set 1.94.0 + rustup show active-toolchain + cargo +1.94.0 --version + rustc +1.94.0 --version + + - name: Cargo fmt check + working-directory: adk-rust + run: cargo +1.94.0 fmt --all -- --check + + - name: Test detmir-core + working-directory: adk-rust + run: cargo +1.94.0 test -p detmir-core + + - name: Test detmir-portal + working-directory: adk-rust + run: cargo +1.94.0 test -p detmir-portal + + - name: Clippy detmir-core + working-directory: adk-rust + run: cargo +1.94.0 clippy -p detmir-core --all-targets -- -D warnings + + - name: Clippy detmir-portal with captured log + working-directory: adk-rust + run: | + set +e + cargo +1.94.0 clippy -p detmir-portal --all-targets -- -D warnings > ../detmir-portal-clippy.log 2>&1 + status=$? + echo "clippy_exit_status=${status}" > ../detmir-portal-clippy-status.txt + tail -n 80 ../detmir-portal-clippy.log + exit ${status} + + - name: Upload detmir-portal clippy log + if: always() + uses: actions/upload-artifact@v4 + with: + name: detmir-portal-clippy-log + path: | + detmir-portal-clippy.log + detmir-portal-clippy-status.txt + + - name: Private config guard + run: bash scripts/check_private_config_guard.sh + + - name: Portal contract sync + run: node scripts/check_portal_contract_sync.mjs diff --git a/.github/workflows/rust-workspace.yml b/.github/workflows/rust-workspace.yml index 2660c71..f69f814 100644 --- a/.github/workflows/rust-workspace.yml +++ b/.github/workflows/rust-workspace.yml @@ -5,6 +5,7 @@ on: branches: [ "main" ] pull_request: branches: [ "main" ] + workflow_dispatch: jobs: rust-workspace: @@ -13,19 +14,22 @@ jobs: - name: Checkout uses: actions/checkout@v4 - - name: Install Rust 1.85 - uses: dtolnay/rust-toolchain@1.85.0 - with: - components: rustfmt, clippy + - name: Install pinned Rust toolchain + run: | + rustup toolchain install 1.94.0 --profile minimal --component rustfmt --component clippy + rustup override set 1.94.0 + rustup show active-toolchain + cargo +1.94.0 --version + rustc +1.94.0 --version - name: Format - run: cargo fmt --manifest-path adk-rust/Cargo.toml --all -- --check + run: cargo +1.94.0 fmt --manifest-path adk-rust/Cargo.toml --all -- --check - name: Test - run: cargo test --manifest-path adk-rust/Cargo.toml --workspace + run: cargo +1.94.0 test --manifest-path adk-rust/Cargo.toml --workspace - name: Clippy - run: cargo clippy --manifest-path adk-rust/Cargo.toml --workspace --all-targets -- -D warnings + run: cargo +1.94.0 clippy --manifest-path adk-rust/Cargo.toml --workspace --all-targets -- -D warnings - name: Release build - run: cargo build --manifest-path adk-rust/Cargo.toml --workspace --release + run: cargo +1.94.0 build --manifest-path adk-rust/Cargo.toml --workspace --release diff --git a/adk-rust/crates/detmir-core/src/lib.rs b/adk-rust/crates/detmir-core/src/lib.rs index d57d156..55458e1 100644 --- a/adk-rust/crates/detmir-core/src/lib.rs +++ b/adk-rust/crates/detmir-core/src/lib.rs @@ -1,19 +1,37 @@ +#![deny(unsafe_op_in_unsafe_fn)] + +//! Shared production primitives for AWatch-rus. +//! +//! This crate intentionally stays small and dependency-light. It contains the +//! status, exit-code and runtime-configuration guardrails that are reused by +//! operational binaries and health/check tooling. Keep business-specific portal, +//! DLP or workforce logic out of this crate. + use std::fmt; use anyhow::{Context, Result}; use chrono::{DateTime, SecondsFormat, Utc}; use serde::{Deserialize, Serialize}; +/// Normalized health/check status used by CLI tools, probes and JSON payloads. +/// +/// CONTRACT: serialized values are uppercase and must remain stable because +/// deployment scripts, smoke checks and dashboards can key off these strings. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "UPPERCASE")] pub enum StatusLevel { + /// Component is healthy and the check passed. Ok, + /// Component works, but a risk or degraded condition needs attention. Warn, + /// Component check failed or a required dependency is unavailable. Fail, + /// Component did not provide enough information for a reliable status. Unknown, } impl StatusLevel { + /// Return the stable uppercase representation used in human and JSON output. pub fn as_str(self) -> &'static str { match self { Self::Ok => "OK", @@ -23,6 +41,10 @@ impl StatusLevel { } } + /// Map status to the process exit code expected by operational checks. + /// + /// CONTRACT: `WARN` exits as a failed check rather than success so that + /// automation does not silently ignore degraded production state. pub fn exit_code(self) -> i32 { match self { Self::Ok => exit_codes::OK, @@ -48,23 +70,39 @@ impl From<&str> for StatusLevel { } } +/// Stable process exit codes for AWatch-rus operational binaries. +/// +/// CONTRACT: keep these numeric values stable. Shell scripts, systemd units, +/// smoke tests and runbooks can depend on them. pub mod exit_codes { + /// Successful execution. pub const OK: i32 = 0; + /// Unexpected runtime or IO error. pub const ERROR: i32 = 1; + /// Health/check policy failed or returned a degraded status. pub const CHECK_FAILED: i32 = 2; + /// A safety policy denied a requested action. pub const POLICY_DENIED: i32 = 3; } +/// Return the current UTC timestamp in compact RFC3339/Zulu format. pub fn now_utc_rfc3339() -> String { Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true) } +/// Parse an RFC3339 timestamp and normalize it to UTC. pub fn parse_utc_rfc3339(value: &str) -> Result> { DateTime::parse_from_rfc3339(value) .with_context(|| format!("invalid RFC3339 timestamp: {value}")) .map(|ts| ts.with_timezone(&Utc)) } +/// Runtime configuration guardrails. +/// +/// SECURITY: these helpers are deliberately conservative. They reject empty, +/// documentation, TEST-NET and common placeholder values before a component is +/// allowed to run in production mode. This prevents demo-safe examples from +/// accidentally becoming live runtime configuration. pub mod runtime_guard { use anyhow::{Result, bail}; @@ -82,6 +120,11 @@ pub mod runtime_guard { "PASSWORD", ]; + /// Return true when a value looks like a public/demo placeholder. + /// + /// RATIONALE: AWatch-rus documentation intentionally uses TEST-NET ranges + /// and HOST-EXAMPLE markers. Production binaries should fail closed when + /// such values reach runtime configuration. pub fn is_runtime_placeholder(value: &str) -> bool { let trimmed = value.trim(); if trimmed.is_empty() { @@ -106,6 +149,7 @@ pub mod runtime_guard { || (normalized.starts_with('<') && normalized.ends_with('>')) } + /// Return true when a value is unsafe for a secret-like configuration field. pub fn is_secret_placeholder(value: &str) -> bool { is_runtime_placeholder(value) || matches!( @@ -114,6 +158,10 @@ pub mod runtime_guard { ) } + /// Ensure a required runtime value is not empty or demo-only. + /// + /// SECURITY: callers should invoke this before opening network connections, + /// starting ingestion or enabling exporters in production mode. pub fn ensure_runtime_value(name: &str, value: &str, context: &str) -> Result<()> { if is_runtime_placeholder(value) { bail!("{name} contains an empty/example/TEST-NET value while {context}"); @@ -121,6 +169,7 @@ pub mod runtime_guard { Ok(()) } + /// Ensure a required secret is not empty or an obvious placeholder. pub fn ensure_secret_value(name: &str, value: &str, context: &str) -> Result<()> { if is_secret_placeholder(value) { bail!("{name} contains an empty/example secret value while {context}"); @@ -128,6 +177,7 @@ pub mod runtime_guard { Ok(()) } + /// Ensure an iterator of runtime values is non-empty and production-safe. pub fn ensure_runtime_values<'a>( name: &str, values: impl IntoIterator, @@ -144,6 +194,12 @@ pub mod runtime_guard { Ok(()) } + /// Validate a complete InfluxDB exporter configuration block. + /// + /// CONTRACT: when an exporter is enabled, URL, org, bucket, token and host + /// list must all be real runtime values. A partial/demo exporter config is + /// more dangerous than a disabled exporter because it creates false + /// confidence in monitoring readiness. pub fn ensure_influx_runtime_config( prefix: &str, url: &str, diff --git a/adk-rust/crates/detmir-portal/Cargo.toml b/adk-rust/crates/detmir-portal/Cargo.toml index 4863de1..44e9710 100644 --- a/adk-rust/crates/detmir-portal/Cargo.toml +++ b/adk-rust/crates/detmir-portal/Cargo.toml @@ -21,3 +21,7 @@ tiny_http.workspace = true [dev-dependencies] tempfile.workspace = true + +[lints.clippy] +comparison_chain = "allow" +search_is_some = "allow" diff --git a/adk-rust/crates/detmir-portal/src/production/health.rs b/adk-rust/crates/detmir-portal/src/production/health.rs index ddddfd9..8a87de6 100644 --- a/adk-rust/crates/detmir-portal/src/production/health.rs +++ b/adk-rust/crates/detmir-portal/src/production/health.rs @@ -1,3 +1,8 @@ +//! Liveness probe payload. +//! +//! CONTRACT: `/healthz` is intentionally shallow. It proves that the portal +//! process can answer HTTP, while dependency checks belong to `/readyz`. + use serde_json::{Value, json}; use crate::now; diff --git a/adk-rust/crates/detmir-portal/src/production/limits.rs b/adk-rust/crates/detmir-portal/src/production/limits.rs index 07b7bb6..229ecf0 100644 --- a/adk-rust/crates/detmir-portal/src/production/limits.rs +++ b/adk-rust/crates/detmir-portal/src/production/limits.rs @@ -1,3 +1,10 @@ +//! Configuration and request-bound validation for production portal routes. +//! +//! RATIONALE: the portal can aggregate reports, evidence and external service +//! payloads. Query and body limits keep pilot installations responsive and make +//! expensive report routes fail closed instead of exhausting memory or blocking +//! the single-process runtime. + use std::collections::BTreeSet; use anyhow::{Result, anyhow}; @@ -30,6 +37,10 @@ pub(crate) fn validate_portal_config(args: &Cli) -> Result<()> { if port == 0 { return Err(anyhow!("invalid config port: expected 1..65535")); } + + // RATIONALE: page and date limits protect heavy report endpoints while + // preserving monthly pilot reporting. Hard upper bounds prevent accidental + // production overrides from turning the portal into an unbounded exporter. if args.max_page_size == 0 || args.max_page_size > MAX_ALLOWED_PAGE_SIZE { return Err(anyhow!( "invalid config max_page_size: expected 1..={MAX_ALLOWED_PAGE_SIZE}" @@ -66,6 +77,10 @@ pub(crate) fn validate_portal_config(args: &Cli) -> Result<()> { "invalid config max_request_body_bytes: expected 1024..={MAX_ALLOWED_REQUEST_BODY_BYTES}" )); } + + // SECURITY: environment and module names can reach metrics/log labels. + // Restrict them to short ASCII tokens to avoid label injection and runaway + // cardinality from free-form deployment names. if !is_safe_environment_name(&args.environment) { return Err(anyhow!( "invalid config environment: use 1..32 chars from A-Z, a-z, 0-9, _, -" diff --git a/adk-rust/crates/detmir-portal/src/production/logging.rs b/adk-rust/crates/detmir-portal/src/production/logging.rs index 436843c..277cf16 100644 --- a/adk-rust/crates/detmir-portal/src/production/logging.rs +++ b/adk-rust/crates/detmir-portal/src/production/logging.rs @@ -1,3 +1,10 @@ +//! Structured HTTP access logging for the portal runtime. +//! +//! CONTRACT: logs are emitted as single-line JSON to stderr so systemd/journald, +//! container runtimes and log forwarders can parse them without scraping free +//! text. Do not log raw request bodies, secrets, evidence bytes or personal +//! payloads here. + use serde_json::{Value, json}; use tiny_http::StatusCode; @@ -21,6 +28,10 @@ pub(crate) fn log_http_request( } else { Value::Null }; + + // SECURITY: include routing/correlation fields, but do not include query + // values, request body, headers or tokens. Those can contain employee data, + // screenshots, evidence references or API keys. eprintln!( "{}", json!({ diff --git a/adk-rust/crates/detmir-portal/src/production/metrics.rs b/adk-rust/crates/detmir-portal/src/production/metrics.rs index 02f6639..99e34e6 100644 --- a/adk-rust/crates/detmir-portal/src/production/metrics.rs +++ b/adk-rust/crates/detmir-portal/src/production/metrics.rs @@ -1,3 +1,9 @@ +//! In-process Prometheus-style metrics for the portal. +//! +//! CONTRACT: metric names and label keys are part of the operational contract +//! used by dashboards and smoke checks. Additive metrics are allowed; renaming +//! existing metrics requires synchronized dashboard/documentation changes. + use std::collections::BTreeMap; use std::fmt::Write as FmtWrite; use std::sync::{Mutex, OnceLock}; @@ -179,5 +185,7 @@ pub(crate) fn render_prometheus_metrics(args: &Cli) -> String { } fn prom_escape(value: &str) -> String { + // SECURITY: metric label values are route/module tokens, but escaping keeps + // the endpoint safe if future callers pass proxy-derived values. value.replace('\\', "\\\\").replace('"', "\\\"") } diff --git a/adk-rust/crates/detmir-portal/src/production/mod.rs b/adk-rust/crates/detmir-portal/src/production/mod.rs index 2ce4c30..3473dac 100644 --- a/adk-rust/crates/detmir-portal/src/production/mod.rs +++ b/adk-rust/crates/detmir-portal/src/production/mod.rs @@ -1,3 +1,14 @@ +//! Production-facing portal runtime support. +//! +//! This module groups the cross-cutting concerns that must stay consistent +//! across all portal routes: health/readiness/version contracts, query and +//! configuration limits, structured logging, Prometheus-style metrics and +//! request correlation metadata. +//! +//! CONTRACT: keep this module free from role-specific business rendering. It is +//! the operational boundary around the portal, not the workforce/security report +//! implementation itself. + pub(crate) mod health; pub(crate) mod limits; pub(crate) mod logging; diff --git a/adk-rust/crates/detmir-portal/src/production/readiness.rs b/adk-rust/crates/detmir-portal/src/production/readiness.rs index ced1584..b7c9da1 100644 --- a/adk-rust/crates/detmir-portal/src/production/readiness.rs +++ b/adk-rust/crates/detmir-portal/src/production/readiness.rs @@ -1,3 +1,10 @@ +//! Readiness probe payload. +//! +//! CONTRACT: `/readyz` checks whether the portal is safe to receive normal +//! traffic. It must remain conservative: configuration errors and broken state +//! storage make the process `not_ready`; optional integrations can report +//! `disabled`, `not_required` or `contract_only` without failing the whole probe. + use std::path::Path; use serde_json::{Value, json}; diff --git a/adk-rust/crates/detmir-portal/src/production/request_context.rs b/adk-rust/crates/detmir-portal/src/production/request_context.rs index e3e561c..058accd 100644 --- a/adk-rust/crates/detmir-portal/src/production/request_context.rs +++ b/adk-rust/crates/detmir-portal/src/production/request_context.rs @@ -1,3 +1,8 @@ +//! Request correlation and route classification for portal observability. +//! +//! CONTRACT: generated route names must not expose volatile identifiers such as +//! case IDs, candidate IDs or evidence IDs; use route templates instead. + use std::cell::RefCell; use std::sync::atomic::{AtomicU64, Ordering}; use std::time::{Instant, SystemTime, UNIX_EPOCH}; @@ -73,7 +78,7 @@ fn request_header(request: &Request, name: &str) -> Option { request .headers() .iter() - .find(|header| header.field.to_string().eq_ignore_ascii_case(name)) + .find(|header| header.field.as_str().as_str().eq_ignore_ascii_case(name)) .map(|header| header.value.as_str().to_string()) } diff --git a/adk-rust/crates/detmir-portal/src/production/version.rs b/adk-rust/crates/detmir-portal/src/production/version.rs index c9214c9..f6f6720 100644 --- a/adk-rust/crates/detmir-portal/src/production/version.rs +++ b/adk-rust/crates/detmir-portal/src/production/version.rs @@ -1,3 +1,8 @@ +//! Build/version probe payload. +//! +//! CONTRACT: `/version` is used by smoke tests, runbooks and release evidence. +//! Keep field names stable and add new fields only in a backward-compatible way. + use serde_json::{Value, json}; use crate::{Cli, PORTAL_SCHEMA_VERSION}; diff --git a/docs/GITHUB_BINARY_BUILD_RU.md b/docs/GITHUB_BINARY_BUILD_RU.md new file mode 100644 index 0000000..5f946ec --- /dev/null +++ b/docs/GITHUB_BINARY_BUILD_RU.md @@ -0,0 +1,78 @@ +# GitHub-сборка Rust-бинарников AWatch-rus + +## Принятое решение + +Для проекта AWatch-rus каноническая release-сборка Rust-бинарников выполняется в GitHub Actions. + +Локальная сборка используется для разработки и предварительной проверки. Официальным источником release-бинарников считаются только artifacts, полученные из GitHub Actions на конкретном commit или tag. + +## Toolchain + +Версия Rust/Cargo фиксируется в `rust-toolchain.toml`: + +```toml +[toolchain] +channel = "1.94.0" +profile = "minimal" +components = ["rustfmt", "clippy"] +``` + +Workflow должны запускать Cargo явно: + +```bash +cargo +1.94.0 --version +rustc +1.94.0 --version +cargo +1.94.0 fmt --manifest-path adk-rust/Cargo.toml --all -- --check +cargo +1.94.0 test --manifest-path adk-rust/Cargo.toml --workspace --no-fail-fast +cargo +1.94.0 clippy --manifest-path adk-rust/Cargo.toml --workspace --all-targets -- -D warnings +cargo +1.94.0 build --manifest-path adk-rust/Cargo.toml --workspace --release +``` + +Это исключает ситуацию, когда GitHub runner использует старый системный Cargo. + +## Workflow + +Основные workflow: + +- `.github/workflows/rust-workspace.yml` — fmt, tests, clippy, release build всего workspace. +- `.github/workflows/rust-professionalization-check.yml` — PR smoke для изменяемых Rust-крейтов. +- `.github/workflows/rust-binary-build.yml` — сборка release-бинарников Linux x86_64 и публикация GitHub Actions artifact. + +## rust-binary-build + +Workflow `rust-binary-build` запускается: + +- вручную через GitHub Actions -> rust-binary-build -> Run workflow; +- автоматически при push tag вида `v*`. + +Внутри workflow выполняется: + +1. checkout repository; +2. установка Rust/Cargo 1.94.0; +3. вывод версий `cargo` и `rustc`; +4. format check; +5. workspace tests; +6. workspace clippy; +7. workspace release build; +8. upload artifact `awatch-rus-linux-x86_64-release-binaries`. + +## Правило проекта + +Перед передачей бинарников на пилот, демонстрацию или релиз нужно использовать GitHub Actions artifact, а не локально собранный файл. + +Минимальные признаки корректного artifact: + +- workflow завершился успешно; +- в логах указан Rust/Cargo 1.94.0; +- build выполнен из нужного commit или tag; +- artifact скачан из GitHub Actions. + +## Дальнейшие улучшения + +Отдельными PR можно добавить: + +- SHA256SUMS для каждого бинарника; +- автоматическую публикацию в GitHub Release при tag `v*`; +- Windows x86_64 build для endpoint-компонентов; +- Linux static/musl build при необходимости; +- подпись release artifacts. diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 0000000..e77caba --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,4 @@ +[toolchain] +channel = "1.94.0" +profile = "minimal" +components = ["rustfmt", "clippy"] diff --git a/scripts/package_rust_release_binaries.py b/scripts/package_rust_release_binaries.py new file mode 100644 index 0000000..aef0490 --- /dev/null +++ b/scripts/package_rust_release_binaries.py @@ -0,0 +1,143 @@ +#!/usr/bin/env python3 +"""Create a GitHub Actions release package from Rust release binaries.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import shutil +import stat +import tarfile +from datetime import datetime, timezone +from pathlib import Path + +SKIP_DIRS = {"deps", "build", "examples", "incremental"} +SKIP_SUFFIXES = {".d", ".rlib", ".rmeta"} + + +def sha256(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as handle: + while True: + chunk = handle.read(1024 * 1024) + if not chunk: + break + digest.update(chunk) + return digest.hexdigest() + + +def is_binary(path: Path) -> bool: + if not path.is_file(): + return False + if path.name in SKIP_DIRS: + return False + if path.suffix in SKIP_SUFFIXES: + return False + return bool(path.stat().st_mode & stat.S_IXUSR) + + +def collect(release_dir: Path) -> list[Path]: + items = [item for item in sorted(release_dir.iterdir()) if is_binary(item)] + if not items: + raise SystemExit(f"No release binaries found in {release_dir}") + return items + + +def write(path: Path, text: str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text, encoding="utf-8") + + +def copy_release_file(src: Path, dst: Path) -> Path: + """Copy file contents without preserving metadata that some mounts reject.""" + dst.parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile(src, dst) + try: + dst.chmod(src.stat().st_mode & 0o777) + except PermissionError: + # Some removable/network filesystems reject chmod/utime metadata changes. + # The package remains valid because the archive manifest/checksums are + # based on file contents, not filesystem timestamps. + pass + return dst + + +def write_archive_checksum(archive: Path) -> None: + write(archive.with_suffix(archive.suffix + ".sha256"), f"{sha256(archive)} {archive.name}\n") + + +def create_compatibility_aliases(out_dir: Path, archive: Path) -> None: + """Create both linux-x86_64 and linux_x86_64 artifact paths.""" + out_alias = Path(str(out_dir).replace("linux-x86_64", "linux_x86_64")) + if out_alias != out_dir: + if out_alias.exists(): + shutil.rmtree(out_alias) + out_alias.mkdir(parents=True) + for item in out_dir.iterdir(): + if item.is_file(): + copy_release_file(item, out_alias / item.name) + + archive_alias = Path(str(archive).replace("linux-x86_64", "linux_x86_64")) + if archive_alias != archive: + copy_release_file(archive, archive_alias) + write_archive_checksum(archive_alias) + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--release-dir", type=Path, required=True) + parser.add_argument("--out-dir", type=Path, required=True) + parser.add_argument("--archive", type=Path, required=True) + parser.add_argument("--target", default="linux-x86_64") + parser.add_argument("--commit", default="unknown") + parser.add_argument("--ref", default="unknown") + parser.add_argument("--run-id", default="unknown") + args = parser.parse_args() + + release_dir = args.release_dir.resolve() + out_dir = args.out_dir.resolve() + archive = args.archive.resolve() + + if out_dir.exists(): + shutil.rmtree(out_dir) + out_dir.mkdir(parents=True) + + binaries = collect(release_dir) + for binary in binaries: + copy_release_file(binary, out_dir / binary.name) + + names = [binary.name for binary in binaries] + write(out_dir / "BINARIES.txt", "\n".join(names) + "\n") + + checksum_lines = [] + manifest_binaries = [] + for name in names: + packaged = out_dir / name + digest = sha256(packaged) + checksum_lines.append(f"{digest} {name}") + manifest_binaries.append( + {"name": name, "size_bytes": packaged.stat().st_size, "sha256": digest} + ) + write(out_dir / "SHA256SUMS.txt", "\n".join(checksum_lines) + "\n") + + manifest = { + "project": "AWatch-rus", + "target": args.target, + "commit": args.commit, + "ref": args.ref, + "run_id": args.run_id, + "build_time_utc": datetime.now(timezone.utc).isoformat(timespec="seconds"), + "binaries": manifest_binaries, + } + write(out_dir / "BUILD_MANIFEST.json", json.dumps(manifest, ensure_ascii=False, indent=2) + "\n") + + archive.parent.mkdir(parents=True, exist_ok=True) + with tarfile.open(archive, "w:gz") as tar: + tar.add(out_dir, arcname=out_dir.name) + write_archive_checksum(archive) + create_compatibility_aliases(out_dir, archive) + + +if __name__ == "__main__": + main()