diff --git a/.github/scripts/write_artifact_manifest.py b/.github/scripts/write_artifact_manifest.py new file mode 100644 index 00000000000..06074691c77 --- /dev/null +++ b/.github/scripts/write_artifact_manifest.py @@ -0,0 +1,357 @@ +#!/usr/bin/env python3 +"""Write the shared, deterministic DeskForge producer manifest.""" + +import argparse +import hashlib +import json +import os +import re +import stat +import subprocess +from datetime import datetime +from pathlib import Path +from pathlib import PurePosixPath + +VERIFICATION_SCOPE = ( + "producer-reported source_sha, workflow_sha, workflow_ref, version, source_tree_sha, " + "recursive submodule commits, and delivered output file names, sizes, " + "and SHA-256 values" +) +CONTRACT = "deskforge.client-artifact-handoff-v1" +DIGEST_SCOPE = "sha256 covers public delivered output files; manifest.txt and declared private files are excluded" +MANIFEST_NAME = "manifest.txt" +PRIVATE_FILENAME = "custom_.txt" +BRIDGE_FILES = tuple( + sorted( + ( + "flutter/ios/Runner/bridge_generated.h", + "flutter/lib/generated_bridge.dart", + "flutter/lib/generated_bridge.freezed.dart", + "flutter/macos/Runner/bridge_generated.h", + "src/bridge_generated.io.rs", + "src/bridge_generated.rs", + ) + ) +) + + +def git_output(*args: str) -> str: + result = subprocess.run(["git", *args], check=True, capture_output=True, text=True) + return result.stdout.strip() + + +def source_tree_sha() -> str: + value = git_output("rev-parse", "HEAD^{tree}") + if not re.fullmatch(r"[0-9a-fA-F]{40,64}", value): + raise SystemExit("source tree identity is unavailable") + return value.lower() + + +def submodules() -> list[dict[str, str]]: + records: list[dict[str, str]] = [] + for line in git_output("submodule", "status", "--recursive").splitlines(): + if not line.strip(): + continue + if line[0] != " ": + raise SystemExit("recursive submodule checkout is not clean or exact") + fields = line[1:].strip().split() + if len(fields) < 2 or not re.fullmatch(r"[0-9a-fA-F]{40,64}", fields[0]): + raise SystemExit("recursive submodule identity is unavailable") + records.append({"path": fields[1], "commit_sha": fields[0].lower()}) + records.sort(key=lambda item: item["path"]) + return records + + +def publication_timestamp() -> int: + raw = os.environ.get("MANIFEST_PUBLICATION_TIMESTAMP", "") + if not raw: + raise SystemExit("publication timestamp is unavailable") + try: + value = int(datetime.fromisoformat(raw.replace("Z", "+00:00")).timestamp()) + except ValueError as exc: + raise SystemExit("publication timestamp is invalid") from exc + if value <= 0: + raise SystemExit("publication timestamp is invalid") + return value + + +def expected_names(platform: str, app_name: str, version: str) -> list[str]: + if ( + not app_name + or app_name in {".", ".."} + or app_name != app_name.strip() + or any(char in app_name for char in ("/", "\\", "\x00", "\r", "\n")) + ): + raise SystemExit("app_name must be a safe filename component") + if platform == "windows": + return [f"{app_name}.exe"] + if platform == "linux": + return sorted([f"{app_name}-{version}.deb", f"{app_name}-{version}-0.x86_64.rpm"]) + if platform == "android": + return [f"{app_name}.apk"] + if platform == "bridge": + if app_name != "rustdesk-bridge": + raise SystemExit("bridge producer app_name must be rustdesk-bridge") + return list(BRIDGE_FILES) + raise SystemExit(f"unsupported manifest platform {platform!r}") + + +def output_root(path: Path) -> Path: + try: + info = path.lstat() + except OSError as exc: + raise SystemExit(f"manifest output directory is unavailable: {exc}") from exc + if stat.S_ISLNK(info.st_mode) or not stat.S_ISDIR(info.st_mode): + raise SystemExit("manifest output must be a regular directory") + return path.resolve() + + +def safe_output_file(root: Path, name: str) -> Path: + relative = PurePosixPath(name) + if not safe_relative_name(name, allow_nested=True): + raise SystemExit(f"manifest output path escapes artifact output: {name!r}") + candidate = root.joinpath(*relative.parts) + try: + candidate.relative_to(root) + except ValueError as exc: + raise SystemExit(f"manifest output path escapes artifact output: {name!r}") from exc + + current = root + for part in relative.parts: + current /= part + try: + info = current.lstat() + except OSError as exc: + raise SystemExit(f"manifest output file is unavailable: {name!r}") from exc + if stat.S_ISLNK(info.st_mode): + raise SystemExit(f"manifest output contains a symlink: {name!r}") + if current != candidate and not stat.S_ISDIR(info.st_mode): + raise SystemExit(f"manifest output path contains a non-directory: {name!r}") + if current == candidate and not stat.S_ISREG(info.st_mode): + raise SystemExit(f"manifest output contains a non-regular file: {name!r}") + return candidate + + +def safe_relative_name(name: str, allow_nested: bool) -> bool: + """Return whether name is a canonical, non-escaping artifact path.""" + if ( + not name + or name != name.strip() + or any(char in name for char in ("\\", "\x00", "\r", "\n")) + or re.match(r"^[A-Za-z]:", name) + or name.startswith("/") + or name.startswith("//") + ): + return False + relative = PurePosixPath(name) + if relative.is_absolute() or relative.as_posix() != name: + return False + if any(part in {"", ".", ".."} for part in relative.parts): + return False + return allow_nested or len(relative.parts) == 1 + + +def validate_output_tree(root: Path, expected: set[str]) -> None: + allowed = expected | {MANIFEST_NAME, PRIVATE_FILENAME} + for current, directories, files in os.walk(root, topdown=True, followlinks=False): + directories.sort() + files.sort() + current_path = Path(current) + for directory in directories: + path = current_path / directory + info = path.lstat() + if stat.S_ISLNK(info.st_mode) or not stat.S_ISDIR(info.st_mode): + raise SystemExit(f"manifest output contains an unsafe directory: {path}") + for filename in files: + path = current_path / filename + info = path.lstat() + if stat.S_ISLNK(info.st_mode) or not stat.S_ISREG(info.st_mode): + raise SystemExit(f"manifest output contains an unsafe file: {path}") + relative = path.relative_to(root).as_posix() + if relative not in allowed: + raise SystemExit(f"unexpected final output file: {relative!r}") + + +def reject_duplicate_json_keys(pairs: list[tuple[str, object]]) -> dict[str, object]: + result: dict[str, object] = {} + for key, value in pairs: + if key in result: + raise ValueError(f"duplicate manifest key {key!r}") + result[key] = value + return result + + +def verify_bridge_artifact( + root: Path, + expected_source_sha: str, + expected_workflow_sha: str, + expected_workflow_ref: str, + expected_version: str, +) -> None: + """Verify a downloaded bridge artifact before copying files into the source tree.""" + root = output_root(root) + expected = set(BRIDGE_FILES) + validate_output_tree(root, expected) + manifest_path = safe_output_file(root, MANIFEST_NAME) + try: + manifest = json.loads( + manifest_path.read_text(encoding="utf-8"), object_pairs_hook=reject_duplicate_json_keys + ) + except (OSError, UnicodeDecodeError, json.JSONDecodeError, ValueError) as exc: + raise SystemExit(f"bridge artifact manifest is invalid: {exc}") from exc + if not isinstance(manifest, dict): + raise SystemExit("bridge artifact manifest must be a JSON object") + expected_manifest_keys = { + "schema", + "manifest_schema", + "schema_version", + "platform", + "app_name", + "output_filenames", + "source_sha", + "workflow_sha", + "workflow_ref", + "version", + "source_tree_sha", + "submodules", + "digest_scope", + "verification_scope", + "verification_result", + "publication_timestamp", + "handoff_contract", + "files", + "private_filenames", + } + if set(manifest) != expected_manifest_keys: + raise SystemExit("bridge artifact manifest schema fields are invalid") + source_sha = manifest.get("source_sha") + workflow_sha = manifest.get("workflow_sha") + if not isinstance(source_sha, str) or not isinstance(workflow_sha, str): + raise SystemExit("bridge artifact manifest identity fields are invalid") + if ( + manifest.get("schema") != "deskforge.client-artifact" + or manifest.get("manifest_schema") != "deskforge.client-artifact" + or manifest.get("schema_version") != 2 + or manifest.get("platform") != "bridge" + or manifest.get("app_name") != "rustdesk-bridge" + or source_sha.lower() != expected_source_sha.lower() + or workflow_sha.lower() != expected_workflow_sha.lower() + or manifest.get("workflow_ref") != expected_workflow_ref + or manifest.get("version") != expected_version + ): + raise SystemExit("bridge artifact manifest identity does not match the current workflow") + if manifest.get("output_filenames") != list(BRIDGE_FILES) or manifest.get("private_filenames") != []: + raise SystemExit("bridge artifact manifest output file contract is invalid") + records = manifest.get("files") + if not isinstance(records, list) or len(records) != len(BRIDGE_FILES): + raise SystemExit("bridge artifact manifest file records are invalid") + for record, name in zip(records, BRIDGE_FILES, strict=True): + if not isinstance(record, dict) or set(record) != {"name", "size", "sha256"}: + raise SystemExit("bridge artifact manifest file record schema is invalid") + if record["name"] != name or not safe_relative_name(name, allow_nested=True): + raise SystemExit("bridge artifact manifest contains an unsafe nested path") + size = record["size"] + digest = record["sha256"] + if not isinstance(size, int) or isinstance(size, bool) or size < 0: + raise SystemExit(f"bridge artifact file size is invalid: {name}") + if not isinstance(digest, str) or not re.fullmatch(r"[0-9a-f]{64}", digest): + raise SystemExit(f"bridge artifact file hash is invalid: {name}") + path = safe_output_file(root, name) + data = path.read_bytes() + if len(data) != size or hashlib.sha256(data).hexdigest() != digest: + raise SystemExit(f"bridge artifact file hash mismatch: {name}") + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--platform") + parser.add_argument("--app-name") + parser.add_argument("--version") + parser.add_argument("--output", type=Path, required=True) + parser.add_argument("--workflow-sha", required=True) + parser.add_argument("--workflow-ref", required=True) + parser.add_argument("--verify-bridge", action="store_true") + parser.add_argument("--expected-source-sha") + parser.add_argument("--expected-version") + args = parser.parse_args() + + if args.verify_bridge: + if not args.expected_source_sha or not args.expected_version: + raise SystemExit("bridge verification requires expected source and version identity") + verify_bridge_artifact( + args.output, + args.expected_source_sha, + args.workflow_sha, + args.workflow_ref, + args.expected_version, + ) + return + + if not args.platform or not args.app_name or not args.version: + raise SystemExit("manifest production requires platform, app name, and version") + output = output_root(args.output) + names = expected_names(args.platform, args.app_name, args.version) + validate_output_tree(output, set(names)) + paths = [safe_output_file(output, name) for name in names] + private_filenames: list[str] = [] + private_path = output / PRIVATE_FILENAME + if private_path.exists() or private_path.is_symlink(): + safe_output_file(output, PRIVATE_FILENAME) + private_filenames.append(PRIVATE_FILENAME) + file_records: list[dict[str, str | int]] = [] + for name, path in zip(names, paths, strict=True): + before = path.lstat() + data = path.read_bytes() + after = path.lstat() + if ( + stat.S_ISLNK(before.st_mode) + or not stat.S_ISREG(before.st_mode) + or stat.S_ISLNK(after.st_mode) + or not stat.S_ISREG(after.st_mode) + or before.st_size != after.st_size + or after.st_size != len(data) + ): + raise SystemExit(f"manifest output file changed during hashing: {name!r}") + file_records.append( + {"name": name, "size": len(data), "sha256": hashlib.sha256(data).hexdigest()} + ) + + manifest = { + "schema": "deskforge.client-artifact", + "manifest_schema": "deskforge.client-artifact", + "schema_version": 2, + "platform": args.platform, + "app_name": args.app_name, + "output_filenames": names, + "source_sha": os.environ["RQS_SOURCE_SHA"], + "workflow_sha": args.workflow_sha, + "workflow_ref": args.workflow_ref, + "version": args.version, + "source_tree_sha": source_tree_sha(), + "submodules": submodules(), + "digest_scope": DIGEST_SCOPE, + "verification_scope": VERIFICATION_SCOPE, + "verification_result": "reported", + "publication_timestamp": publication_timestamp(), + "handoff_contract": CONTRACT, + "files": file_records, + "private_filenames": private_filenames, + } + manifest_path = output / MANIFEST_NAME + try: + manifest_info = manifest_path.lstat() + except FileNotFoundError: + manifest_info = None + except OSError as exc: + raise SystemExit(f"manifest output manifest.txt is unavailable: {exc}") from exc + if manifest_info is not None and (stat.S_ISLNK(manifest_info.st_mode) or not stat.S_ISREG(manifest_info.st_mode)): + raise SystemExit("manifest output manifest.txt is not a regular file") + manifest_path.write_text( + json.dumps(manifest, ensure_ascii=False, indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + validate_output_tree(output, set(names)) + + +if __name__ == "__main__": + main() diff --git a/.github/workflows/bridge.yml b/.github/workflows/bridge.yml index 0a493bb29ae..114ad23da74 100644 --- a/.github/workflows/bridge.yml +++ b/.github/workflows/bridge.yml @@ -4,6 +4,15 @@ name: Build flutter-rust-bridge on: workflow_call: + inputs: + enc_payload: + description: 'Authenticated DFP1 provider payload; source identity and release metadata are required.' + required: false + type: string + default: '' + +permissions: + contents: read env: CARGO_EXPAND_VERSION: "1.0.95" @@ -34,10 +43,172 @@ jobs: artifact-name: "bridge-artifact-flutter-3.44", } steps: + - name: Resolve source checkout identity + shell: bash + env: + ENC: ${{ inputs.enc_payload }} + PAYLOAD_KEY: ${{ secrets.WORKFLOW_PAYLOAD_KEY }} + run: | + set -euo pipefail + RQS_PAYLOAD_MODE=encrypted + if [ -z "${ENC:-}" ]; then + echo "::error::manual/direct runs require an authenticated DFP1 payload; no build is permitted" + exit 1 + fi + if [ -z "${PAYLOAD_KEY:-}" ]; then + echo "::error::enc_payload provided but secret WORKFLOW_PAYLOAD_KEY is not set in this repo" + exit 1 + fi + decrypt_payload() { + local payload_dir magic material aes_key aes_iv ciphertext + payload_dir=$(mktemp -d) + magic=$(printf '%s' "$ENC" | base64 -d | dd bs=1 count=4 status=none) + if [ "$magic" = "DFP1" ]; then + ciphertext="$payload_dir/ciphertext" + if ! material=$(PAYLOAD_KEY="$PAYLOAD_KEY" ENC="$ENC" python3 - "$ciphertext" <<'PY' + import base64 + import hashlib + import hmac + import os + import pathlib + import sys + + raw = base64.b64decode(os.environ["ENC"], validate=True) + if len(raw) <= 4 + 16 + 32: + raise SystemExit("authenticated payload is truncated") + ciphertext = raw[20:-32] + if not ciphertext or len(ciphertext) % 16: + raise SystemExit("authenticated payload ciphertext is invalid") + derived = hashlib.pbkdf2_hmac("sha256", os.environ["PAYLOAD_KEY"].encode(), raw[4:20], 100000, 80) + expected = hmac.new(derived[48:], raw[:-32], hashlib.sha256).digest() + if not hmac.compare_digest(expected, raw[-32:]): + raise SystemExit("authenticated payload integrity check failed") + pathlib.Path(sys.argv[1]).write_bytes(ciphertext) + print(derived[:32].hex()) + print(derived[32:48].hex()) + PY + ); then + rm -rf "$payload_dir" + return 1 + fi + aes_key=$(printf '%s\n' "$material" | sed -n '1p') + aes_iv=$(printf '%s\n' "$material" | sed -n '2p') + if ! openssl enc -d -aes-256-cbc -K "$aes_key" -iv "$aes_iv" -in "$ciphertext"; then + rm -rf "$payload_dir" + return 1 + fi + else + echo "::error::enc_payload must use the authenticated DFP1 envelope" >&2 + rm -rf "$payload_dir" + return 1 + fi + rm -rf "$payload_dir" + } + decrypted=$(decrypt_payload) + RQS_VERSION=$(printf '%s' "$decrypted" | jq -r '.version // ""') + RQS_SOURCE_SHA=$(printf '%s' "$decrypted" | jq -r '.source_sha // ""') + RQS_WORKFLOW_REPO=$(printf '%s' "$decrypted" | jq -r '.workflow_repo // ""') + if ! printf '%s' "$decrypted" | jq -e '(.source_sha | type == "string") and (.workflow_repo | type == "string")' >/dev/null; then + echo "::error::encrypted payload source identity fields must be strings" + exit 1 + fi + if [ -z "$RQS_SOURCE_SHA" ]; then + echo "::error::encrypted payload is missing source_sha" + exit 1 + fi + if ! printf '%s' "$RQS_SOURCE_SHA" | grep -Eq '^[0-9a-fA-F]{40,64}$'; then + echo "::error::encrypted payload source_sha must be a hexadecimal commit SHA" + exit 1 + fi + if [ "${RQS_WORKFLOW_REPO,,}" != "${GITHUB_REPOSITORY,,}" ]; then + echo "::error::authenticated workflow repository does not match this fork" + exit 1 + fi + reject_control_chars() { + local field="$1" value="$2" + if [[ "$value" == *$'\n'* || "$value" == *$'\r'* ]] || LC_ALL=C printf '%s' "$value" | LC_ALL=C grep -q '[[:cntrl:]]'; then + echo "::error::$field contains unsafe control characters" + exit 1 + fi + } + validate_version() { + local value="$1" byte_length + byte_length=$(LC_ALL=C printf '%s' "$value" | wc -c) + if [ "$byte_length" -gt 32 ] || [[ ! "$value" =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-((0|[1-9][0-9]*)|([0-9A-Za-z-]*[A-Za-z-][0-9A-Za-z-]*))(\.((0|[1-9][0-9]*)|([0-9A-Za-z-]*[A-Za-z-][0-9A-Za-z-]*)))*)?$ ]]; then + echo "::error::RQS_VERSION must be a strict semantic version" + exit 1 + fi + } + reject_control_chars RQS_VERSION "$RQS_VERSION" + if [ -n "$RQS_VERSION" ]; then + validate_version "$RQS_VERSION" + fi + reject_control_chars RQS_SOURCE_SHA "$RQS_SOURCE_SHA" + reject_control_chars RQS_WORKFLOW_REPO "$RQS_WORKFLOW_REPO" + write_github_env() { + local name="$1" value="$2" + reject_control_chars "$name" "$value" + printf '%s=%s\n' "$name" "$value" >> "$GITHUB_ENV" + } + { + write_github_env RQS_PAYLOAD_MODE "$RQS_PAYLOAD_MODE" + write_github_env RQS_VERSION "$RQS_VERSION" + write_github_env RQS_SOURCE_SHA "$RQS_SOURCE_SHA" + write_github_env RQS_WORKFLOW_REPO "$RQS_WORKFLOW_REPO" + } + echo "bridge source checkout: SET" + - name: Checkout source code - uses: actions/checkout@v4 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: + ref: ${{ github.sha }} submodules: recursive + persist-credentials: false + + - name: Preserve workflow manifest helper + shell: bash + run: | + set -euo pipefail + source_helper=.github/scripts/write_artifact_manifest.py + helper_path="${RUNNER_TEMP}/deskforge-write_artifact_manifest.py" + test -f "$source_helper" || { echo "::error::workflow-owned manifest helper is missing"; exit 1; } + cp -- "$source_helper" "$helper_path" + chmod 700 "$helper_path" + printf 'MANIFEST_HELPER_PATH=%s\n' "$helper_path" >> "$GITHUB_ENV" + + - name: Checkout source commit + shell: bash + run: | + set -euo pipefail + if [ -z "${RQS_SOURCE_SHA:-}" ]; then + echo "::error::authenticated payload source_sha is required" + exit 1 + fi + if ! printf '%s' "$RQS_SOURCE_SHA" | grep -Eq '^[0-9a-fA-F]{40,64}$'; then + echo "::error::source_sha must be a hexadecimal commit SHA" + exit 1 + fi + GIT_CONFIG_COUNT=1 \ + GIT_CONFIG_KEY_0=http.extraheader \ + GIT_CONFIG_VALUE_0="Authorization: Bearer ${{ github.token }}" \ + git fetch --no-tags --depth=1 origin "$RQS_SOURCE_SHA" + git checkout --detach "$RQS_SOURCE_SHA" + git submodule sync --recursive + GIT_CONFIG_COUNT=1 \ + GIT_CONFIG_KEY_0=http.extraheader \ + GIT_CONFIG_VALUE_0="Authorization: Bearer ${{ github.token }}" \ + git submodule update --init --recursive + expected=$(printf '%s' "$RQS_SOURCE_SHA" | tr '[:upper:]' '[:lower:]') + actual=$(git rev-parse HEAD | tr '[:upper:]' '[:lower:]') + [ "$actual" = "$expected" ] || { echo "::error::source checkout SHA mismatch"; exit 1; } + + - name: Set deterministic source timestamp + shell: bash + run: | + set -euo pipefail + SOURCE_DATE_EPOCH=$(git show -s --format=%ct HEAD) + [[ "$SOURCE_DATE_EPOCH" =~ ^[0-9]+$ ]] || { echo "::error::invalid commit timestamp"; exit 1; } + printf 'SOURCE_DATE_EPOCH=%s\n' "$SOURCE_DATE_EPOCH" >> "$GITHUB_ENV" - name: Install prerequisites run: | @@ -59,25 +230,25 @@ jobs: wget - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@v1 + uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # v1 with: toolchain: ${{ env.RUST_VERSION }} targets: ${{ matrix.job.target }} components: "rustfmt" - - uses: Swatinem/rust-cache@v2 + - uses: Swatinem/rust-cache@63fed3e2fecf6f7b51dc6f043341b79ef82a9ae7 # v2.9.2 with: prefix-key: bridge-${{ matrix.job.os }} - name: Cache Bridge id: cache-bridge - uses: actions/cache@v3 + uses: actions/cache@6f8efc29b200d32929f49075959781ed54ec270c # v3.5.0 with: path: /tmp/flutter_rust_bridge key: bridge-${{ matrix.job.flutter-version }} - name: Install flutter - uses: subosito/flutter-action@v2 + uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2.23.0 with: channel: "stable" flutter-version: ${{ matrix.job.flutter-version }} @@ -100,17 +271,47 @@ jobs: - name: Run flutter rust bridge run: | - ~/.cargo/bin/flutter_rust_bridge_codegen --rust-input ./src/flutter_ffi.rs --dart-output ./flutter/lib/generated_bridge.dart --c-output ./flutter/macos/Runner/bridge_generated.h - cp ./flutter/macos/Runner/bridge_generated.h ./flutter/ios/Runner/bridge_generated.h + ~/.cargo/bin/flutter_rust_bridge_codegen --rust-input ./src/flutter_ffi.rs --dart-output ./flutter/lib/generated_bridge.dart --c-output ./flutter/macos/Runner/bridge_generated.h + cp ./flutter/macos/Runner/bridge_generated.h ./flutter/ios/Runner/bridge_generated.h + + - name: Stage generated bridge files + shell: bash + run: | + set -euo pipefail + bridge_files=( + flutter/ios/Runner/bridge_generated.h + flutter/lib/generated_bridge.dart + flutter/lib/generated_bridge.freezed.dart + flutter/macos/Runner/bridge_generated.h + src/bridge_generated.io.rs + src/bridge_generated.rs + ) + for file in "${bridge_files[@]}"; do + test -f "$file" || { echo "::error::generated bridge file is missing: $file"; exit 1; } + mkdir -p "bridge-output/$(dirname "$file")" + cp -- "$file" "bridge-output/$file" + test -f "bridge-output/$file" || { echo "::error::staged bridge file is missing: $file"; exit 1; } + done + + - name: Write deterministic bridge artifact manifest + shell: bash + env: + MANIFEST_PLATFORM: bridge + MANIFEST_WORKFLOW_SHA: ${{ github.sha }} + MANIFEST_WORKFLOW_REF: ${{ github.ref }} + run: | + set -euo pipefail + MANIFEST_PUBLICATION_TIMESTAMP=$(date -u '+%Y-%m-%dT%H:%M:%SZ') + export MANIFEST_PUBLICATION_TIMESTAMP + test -f "${MANIFEST_HELPER_PATH:-}" || { echo "::error::preserved manifest helper is unavailable"; exit 1; } + python3 "$MANIFEST_HELPER_PATH" \ + --platform "$MANIFEST_PLATFORM" --app-name rustdesk-bridge --version "$RQS_VERSION" \ + --output bridge-output --workflow-sha "$MANIFEST_WORKFLOW_SHA" --workflow-ref "$MANIFEST_WORKFLOW_REF" - name: Upload Artifact - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: ${{ matrix.job.artifact-name }} - path: | - ./src/bridge_generated.rs - ./src/bridge_generated.io.rs - ./flutter/lib/generated_bridge.dart - ./flutter/lib/generated_bridge.freezed.dart - ./flutter/macos/Runner/bridge_generated.h - ./flutter/ios/Runner/bridge_generated.h + path: bridge-output + retention-days: 7 + if-no-files-found: error diff --git a/.github/workflows/rustqs-android.yml b/.github/workflows/rustqs-android.yml index 7bd58b9ab5b..b721b2a30d2 100644 --- a/.github/workflows/rustqs-android.yml +++ b/.github/workflows/rustqs-android.yml @@ -1,18 +1,17 @@ name: rustqs android min test # ============================================================================ # DRAFT (B-012) — НЕ ВАЛИДИРОВАН РЕАЛЬНЫМ ПРОГОНОМ GITHUB ACTIONS. -# Перенос generator-android.yml в контракт форка (enc_payload + L1/L2/L3), по -# образцу github-build/rustqs-windows-min-test.yml / rustqs-linux.yml. Сборка одной +# Historical generator-android.yml reference adapted to the fork contract (enc_payload +# + L1/L2/L3); local github-build files are reference material only. Сборка одной # ABI (arm64-v8a) для min-test. Build-шаги (NDK/cargo-ndk/flutter build apk/пути) # требуют доводки на реальных прогонах. Бэкенд диспетчит platform=android сюда и # забирает артефакт `rustdesk-min-test-android`. # -# Контракт параметров идентичен windows/linux: -# enc_payload = base64(openssl aes-256-cbc -pbkdf2 -pass pass:$WORKFLOW_PAYLOAD_KEY) -# от JSON {server,key,app_name,custom_txt}; либо открытые inputs (debug). +# Контракт: единственный build path — authenticated DFP1 enc_payload от API. +# Direct/manual runs without a valid payload fail closed before checkout/build. # -# ОГРАНИЧЕНИЕ ЧЕРНОВИКА: способ вшивания custom_.txt в Android-клиент отличается от -# desktop (assets/flutter), здесь сделано best-effort и требует проверки. +# Android custom_.txt is packaged as a Flutter asset and handed to the native +# server from MainService before the server thread starts. # # Путь в форке: .github/workflows/rustqs-android.yml на ветке rustqs/min-test. # ============================================================================ @@ -21,35 +20,13 @@ on: workflow_dispatch: inputs: enc_payload: - description: 'Encrypted payload (base64 openssl aes-256-cbc -pbkdf2). Overrides open inputs below.' + description: 'Authenticated DFP1 payload from the API; manual runs without it fail closed.' required: false type: string default: '' - server: - description: '[Debug] RustDesk server (rendezvous host:port). Ignored if enc_payload set.' - required: false - type: string - default: '' - key: - description: '[Debug] RustDesk server public key (base64). Ignored if enc_payload set.' - required: false - type: string - default: '' - app_name: - description: '[Debug] Brand name. Ignored if enc_payload set.' - required: false - type: string - default: '' - custom_txt: - description: '[Debug] Base64 custom_.txt payload. Ignored if enc_payload set.' - required: false - type: string - default: '' - version: - description: 'RustDesk version for offline assets (e.g. 1.4.8). Ignored if enc_payload set.' - required: false - type: string - default: '1.4.8' + +permissions: + contents: read env: RUST_VERSION: "1.75" @@ -58,11 +35,13 @@ env: NDK_VERSION: "r28c" VCPKG_COMMIT_ID: "120deac3062162151622ca4860575a33844ba10b" VCPKG_BINARY_SOURCES: "clear;x-gha,readwrite" - VERSION: "${{ inputs.version || '1.4.8' }}" jobs: bridge: uses: ./.github/workflows/bridge.yml + with: + enc_payload: ${{ inputs.enc_payload }} + secrets: inherit build: needs: [bridge] @@ -79,13 +58,6 @@ jobs: docker-images: true swap-storage: false - - name: Export GitHub Actions cache environment variables - uses: actions/github-script@v9 - with: - script: | - core.exportVariable('ACTIONS_CACHE_URL', process.env.ACTIONS_CACHE_URL || ''); - core.exportVariable('ACTIONS_RUNTIME_TOKEN', process.env.ACTIONS_RUNTIME_TOKEN || ''); - - name: Install dependencies run: | sudo apt-get update @@ -121,54 +93,332 @@ jobs: tree \ wget - - name: Checkout source code - uses: actions/checkout@v7 - with: - submodules: recursive - - # Разрешение параметров (идентично windows/linux): enc_payload (prod) или open inputs. - - name: 'Resolve build config (decrypt or pass-through)' + # The API is the only source of build values; direct/manual fallback is + # intentionally absent and the guard runs before checkout. + - name: 'Resolve authenticated build config' shell: bash env: ENC: ${{ inputs.enc_payload }} PAYLOAD_KEY: ${{ secrets.WORKFLOW_PAYLOAD_KEY }} - IN_SERVER: ${{ inputs.server }} - IN_KEY: ${{ inputs.key }} - IN_APP: ${{ inputs.app_name }} - IN_CT: ${{ inputs.custom_txt }} - IN_VERSION: ${{ inputs.version }} run: | - set -eu - if [ -n "${ENC:-}" ]; then - if [ -z "${PAYLOAD_KEY:-}" ]; then + set -euo pipefail + RQS_PAYLOAD_MODE=encrypted + if [ -z "${ENC:-}" ]; then + echo "::error::manual/direct runs require an authenticated DFP1 payload; no build is permitted" + exit 1 + fi + if [ -z "${PAYLOAD_KEY:-}" ]; then echo "::error::enc_payload provided but secret WORKFLOW_PAYLOAD_KEY is not set in this repo" exit 1 fi - decrypted=$(printf '%s' "$ENC" | base64 -d \ - | openssl enc -d -aes-256-cbc -pbkdf2 -pass "pass:${PAYLOAD_KEY}") + decrypt_payload() { + local payload_dir magic material aes_key aes_iv ciphertext + payload_dir=$(mktemp -d) + magic=$(printf '%s' "$ENC" | base64 -d | dd bs=1 count=4 status=none) + if [ "$magic" = "DFP1" ]; then + ciphertext="$payload_dir/ciphertext" + if ! material=$(PAYLOAD_KEY="$PAYLOAD_KEY" ENC="$ENC" python3 - "$ciphertext" <<'PY' + import base64 + import hashlib + import hmac + import os + import pathlib + import sys + + raw = base64.b64decode(os.environ["ENC"], validate=True) + if len(raw) <= 4 + 16 + 32: + raise SystemExit("authenticated payload is truncated") + ciphertext = raw[20:-32] + if not ciphertext or len(ciphertext) % 16: + raise SystemExit("authenticated payload ciphertext is invalid") + derived = hashlib.pbkdf2_hmac("sha256", os.environ["PAYLOAD_KEY"].encode(), raw[4:20], 100000, 80) + expected = hmac.new(derived[48:], raw[:-32], hashlib.sha256).digest() + if not hmac.compare_digest(expected, raw[-32:]): + raise SystemExit("authenticated payload integrity check failed") + pathlib.Path(sys.argv[1]).write_bytes(ciphertext) + print(derived[:32].hex()) + print(derived[32:48].hex()) + PY + ); then + rm -rf "$payload_dir" + return 1 + fi + aes_key=$(printf '%s\n' "$material" | sed -n '1p') + aes_iv=$(printf '%s\n' "$material" | sed -n '2p') + if ! openssl enc -d -aes-256-cbc -K "$aes_key" -iv "$aes_iv" -in "$ciphertext"; then + rm -rf "$payload_dir" + return 1 + fi + else + echo "::error::enc_payload must use the authenticated DFP1 envelope" >&2 + rm -rf "$payload_dir" + return 1 + fi + rm -rf "$payload_dir" + } + decrypted=$(decrypt_payload) RQS_SERVER=$(printf '%s' "$decrypted" | jq -r '.server // ""') RQS_KEY=$(printf '%s' "$decrypted" | jq -r '.key // ""') - RQS_APP=$(printf '%s' "$decrypted" | jq -r '.app_name // ""') - RQS_CT=$(printf '%s' "$decrypted" | jq -r '.custom_txt // ""') + RQS_APP=$(printf '%s' "$decrypted" | jq -r '.app_name // ""') + RQS_ANDROID_APP_ID=$(printf '%s' "$decrypted" | jq -r '.android_app_id // ""') + RQS_CT=$(printf '%s' "$decrypted" | jq -r '.custom_txt // ""') RQS_VERSION=$(printf '%s' "$decrypted" | jq -r '.version // ""') - else - RQS_SERVER="${IN_SERVER:-}"; RQS_KEY="${IN_KEY:-}"; RQS_APP="${IN_APP:-}"; RQS_CT="${IN_CT:-}"; RQS_VERSION="${IN_VERSION:-}" + RQS_SOURCE_SHA=$(printf '%s' "$decrypted" | jq -r '.source_sha // ""') + RQS_WORKFLOW_REPO=$(printf '%s' "$decrypted" | jq -r '.workflow_repo // ""') + if ! printf '%s' "$decrypted" | jq -e ' + (.server == null or (.server | type == "string")) and + (.key == null or (.key | type == "string")) and + (.app_name == null or (.app_name | type == "string")) and + (.android_app_id | type == "string") and + (.custom_txt == null or (.custom_txt | type == "string")) and + (.version == null or (.version | type == "string")) and + (.source_sha == null or (.source_sha | type == "string")) and + (.workflow_repo | type == "string") + ' >/dev/null; then + echo "::error::encrypted payload env-bound fields must be strings" + exit 1 + fi + if [ "${RQS_WORKFLOW_REPO,,}" != "${GITHUB_REPOSITORY,,}" ]; then + echo "::error::authenticated workflow repository does not match this fork" + exit 1 + fi + reject_control_chars() { + local field="$1" value="$2" + if [[ "$value" == *$'\n'* || "$value" == *$'\r'* ]] || LC_ALL=C printf '%s' "$value" | LC_ALL=C grep -q '[[:cntrl:]]'; then + echo "::error::$field contains unsafe control characters" + exit 1 + fi + } + normalize_public_key() { + local value="$1" + while [[ "$value" == *$'\r' || "$value" == *$'\n' ]]; do + value="${value%?}" + done + printf '%s' "$value" + } + validate_public_key() { + local value="$1" + if ! PUBLIC_KEY="$value" python3 - <<'PY' + import base64 + import os + import re + + value = os.environ["PUBLIC_KEY"] + if not re.fullmatch(r"[A-Za-z0-9+/]{43}=", value): + raise SystemExit("public key must be padded standard base64") + try: + raw = base64.b64decode(value, validate=True) + except ValueError as exc: + raise SystemExit("public key is not valid standard base64") from exc + if len(raw) != 32 or base64.b64encode(raw).decode("ascii") != value: + raise SystemExit("public key must encode exactly 32 bytes canonically") + PY + then + echo "::error::RustDesk public key must be canonical padded base64 for 32 bytes" + exit 1 + fi + } + validate_version() { + local value="$1" byte_length + byte_length=$(LC_ALL=C printf '%s' "$value" | wc -c) + if [ "$byte_length" -gt 32 ] || [[ ! "$value" =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-((0|[1-9][0-9]*)|([0-9A-Za-z-]*[A-Za-z-][0-9A-Za-z-]*))(\.((0|[1-9][0-9]*)|([0-9A-Za-z-]*[A-Za-z-][0-9A-Za-z-]*)))*)?$ ]]; then + echo "::error::RQS_VERSION must be a strict semantic version" + exit 1 + fi + } + validate_app_name() { + local value="$1" byte_length + reject_control_chars app_name "$value" + byte_length=$(LC_ALL=C printf '%s' "$value" | wc -c) + if [ -z "$value" ] || [ "$value" = "." ] || [ "$value" = ".." ] || [ "$byte_length" -gt 128 ]; then + echo "::error::app_name must be a non-empty filename component of at most 128 bytes" + exit 1 + fi + if [[ "$value" == @* || "$value" == \?* ]]; then + echo "::error::app_name must not start with @ or ?" + exit 1 + fi + if [[ "$value" == */* || "$value" == *\\* || "$value" == *'<'* || "$value" == *'>'* || "$value" == *:* || "$value" == *'"'* || "$value" == *'|'* || "$value" == *'?'* || "$value" == *'*'* ]]; then + echo "::error::app_name contains unsafe filename characters" + exit 1 + fi + case "$value" in + *.|*' ') echo "::error::app_name must not end in a dot or space"; exit 1 ;; + esac + if is_windows_reserved_device_name "$value"; then + echo "::error::app_name uses a reserved Windows device name" + exit 1 + fi + } + validate_android_app_id() { + local value="$1" byte_length + reject_control_chars android_app_id "$value" + byte_length=$(LC_ALL=C printf '%s' "$value" | wc -c) + if [ "$byte_length" -gt 255 ] || [[ ! "$value" =~ ^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)+$ ]]; then + echo "::error::android_app_id must be a lowercase Java package identifier" + exit 1 + fi + } + is_windows_reserved_device_name() { + local value="$1" base + base="${value%%.*}" + while [[ "$base" == *" " || "$base" == *"." ]]; do + base="${base%?}" + done + case "${base^^}" in + CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9]) return 0 ;; + *) return 1 ;; + esac + } + write_github_env() { + local name="$1" value="$2" + reject_control_chars "$name" "$value" + printf '%s=%s\n' "$name" "$value" >> "$GITHUB_ENV" + } + if [ -n "$RQS_KEY" ]; then + RQS_KEY=$(normalize_public_key "$RQS_KEY") + fi + for field_value in \ + "RQS_SERVER=$RQS_SERVER" \ + "RQS_KEY=$RQS_KEY" \ + "RQS_CUSTOM_TXT=$RQS_CT" \ + "RQS_VERSION=$RQS_VERSION" \ + "RQS_SOURCE_SHA=$RQS_SOURCE_SHA" \ + "RQS_WORKFLOW_REPO=$RQS_WORKFLOW_REPO"; do + field_name=${field_value%%=*} + field_value=${field_value#*=} + reject_control_chars "$field_name" "$field_value" + done + validate_app_name "$RQS_APP" + validate_android_app_id "$RQS_ANDROID_APP_ID" + if [ -n "$RQS_KEY" ]; then + validate_public_key "$RQS_KEY" + fi + if [ -n "$RQS_VERSION" ]; then + validate_version "$RQS_VERSION" + fi + if ! printf '%s' "$decrypted" | jq -e '.source_sha | type == "string"' >/dev/null; then + echo "::error::encrypted payload source_sha must be a string" + exit 1 + fi + if [ -z "$RQS_SOURCE_SHA" ]; then + echo "::error::encrypted payload is missing source_sha" + exit 1 + fi + if ! printf '%s' "$RQS_SOURCE_SHA" | grep -Eq '^[0-9a-fA-F]{40,64}$'; then + echo "::error::encrypted payload source_sha must be a hexadecimal commit SHA" + exit 1 fi for v in "$RQS_KEY" "$RQS_CT"; do [ -n "$v" ] && echo "::add-mask::$v" || true; done - { - echo "RQS_SERVER=$RQS_SERVER" - echo "RQS_KEY=$RQS_KEY" - echo "RQS_APP_NAME=$RQS_APP" - echo "RQS_CUSTOM_TXT=$RQS_CT" - echo "RQS_VERSION=$RQS_VERSION" - } >> "$GITHUB_ENV" + write_github_env RQS_PAYLOAD_MODE "$RQS_PAYLOAD_MODE" + write_github_env RQS_SERVER "$RQS_SERVER" + write_github_env RQS_KEY "$RQS_KEY" + write_github_env RQS_APP_NAME "$RQS_APP" + write_github_env RQS_ANDROID_APP_ID "$RQS_ANDROID_APP_ID" + # Keep password-bearing custom_.txt content out of GITHUB_ENV. The + # exact source_sha checkout/integrity check below is the trusted-source + # gate; this restrictive path is shared only to the packaging step. + if [ -n "$RQS_CT" ]; then + custom_txt_file="${RUNNER_TEMP}/rustdesk-custom-${GITHUB_RUN_ID:-local}.txt" + cleanup_custom_txt_on_failure() { + status=$? + if [ "$status" -ne 0 ]; then rm -f -- "$custom_txt_file"; fi + trap - EXIT + exit "$status" + } + trap cleanup_custom_txt_on_failure EXIT + (umask 077; printf '%s' "$RQS_CT" > "$custom_txt_file") + if ! python3 - "$custom_txt_file" <<'PY' + import base64 + import json + import pathlib + import sys + + try: + decoded = base64.b64decode(pathlib.Path(sys.argv[1]).read_text(), validate=True) + value = json.loads(decoded) + except (ValueError, UnicodeDecodeError, json.JSONDecodeError) as exc: + raise SystemExit("custom_.txt does not match the Android native client config contract") from exc + if not isinstance(value, dict): + raise SystemExit("custom_.txt native client config must be a JSON object") + PY + then + echo "::error::custom_.txt does not match the Android native client config contract" + exit 1 + fi + chmod 600 "$custom_txt_file" + write_github_env RQS_CUSTOM_TXT_FILE "$custom_txt_file" + else + write_github_env RQS_CUSTOM_TXT_FILE "" + fi + write_github_env RQS_VERSION "$RQS_VERSION" + write_github_env RQS_SOURCE_SHA "$RQS_SOURCE_SHA" + write_github_env RQS_WORKFLOW_REPO "$RQS_WORKFLOW_REPO" + echo "config: mode=$RQS_PAYLOAD_MODE" + echo "config: source_sha=SET" + + - name: Checkout source code + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.sha }} + submodules: recursive + persist-credentials: false + + - name: Preserve workflow manifest helper + shell: bash + run: | + set -euo pipefail + source_helper=.github/scripts/write_artifact_manifest.py + helper_path="${RUNNER_TEMP}/deskforge-write_artifact_manifest.py" + test -f "$source_helper" || { echo "::error::workflow-owned manifest helper is missing"; exit 1; } + cp -- "$source_helper" "$helper_path" + chmod 700 "$helper_path" + printf 'MANIFEST_HELPER_PATH=%s\n' "$helper_path" >> "$GITHUB_ENV" + + - name: Checkout source commit + shell: bash + run: | + set -euo pipefail + if [ -z "${RQS_SOURCE_SHA:-}" ]; then + echo "::error::authenticated payload source_sha is required" + exit 1 + fi + if ! printf '%s' "$RQS_SOURCE_SHA" | grep -Eq '^[0-9a-fA-F]{40,64}$'; then + echo "::error::source_sha must be a hexadecimal commit SHA" + exit 1 + fi + GIT_CONFIG_COUNT=1 \ + GIT_CONFIG_KEY_0=http.extraheader \ + GIT_CONFIG_VALUE_0="Authorization: Bearer ${{ github.token }}" \ + git fetch --no-tags --depth=1 origin "$RQS_SOURCE_SHA" + git checkout --detach "$RQS_SOURCE_SHA" + git submodule sync --recursive + GIT_CONFIG_COUNT=1 \ + GIT_CONFIG_KEY_0=http.extraheader \ + GIT_CONFIG_VALUE_0="Authorization: Bearer ${{ github.token }}" \ + git submodule update --init --recursive + expected=$(printf '%s' "$RQS_SOURCE_SHA" | tr '[:upper:]' '[:lower:]') + actual=$(git rev-parse HEAD | tr '[:upper:]' '[:lower:]') + [ "$actual" = "$expected" ] || { echo "::error::source checkout SHA mismatch"; exit 1; } + + - name: Set deterministic source timestamp + shell: bash + run: | + set -euo pipefail + SOURCE_DATE_EPOCH=$(git show -s --format=%ct HEAD) + [[ "$SOURCE_DATE_EPOCH" =~ ^[0-9]+$ ]] || { echo "::error::invalid commit timestamp"; exit 1; } + printf 'SOURCE_DATE_EPOCH=%s\n' "$SOURCE_DATE_EPOCH" >> "$GITHUB_ENV" # Override VERSION from encrypted payload (takes precedence over workflow-level default). - name: 'Override VERSION from dispatch payload' if: env.RQS_VERSION != '' shell: bash run: | - echo "VERSION=$RQS_VERSION" >> "$GITHUB_ENV" + if [ "${#RQS_VERSION}" -gt 32 ] || [[ ! "$RQS_VERSION" =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-((0|[1-9][0-9]*)|([0-9A-Za-z-]*[A-Za-z-][0-9A-Za-z-]*))(\.((0|[1-9][0-9]*)|([0-9A-Za-z-]*[A-Za-z-][0-9A-Za-z-]*)))*)?$ ]]; then + echo "::error::RQS_VERSION must be a strict semantic version" + exit 1 + fi + printf '%s=%s\n' VERSION "$RQS_VERSION" >> "$GITHUB_ENV" echo "VERSION overridden from dispatch: $RQS_VERSION" # L1: server+key в config.rs (платформо-независимо). @@ -191,7 +441,7 @@ jobs: # L2: allowCustom (платформо-независимо). - name: 'L2 patch: allowCustom' - if: env.RQS_CUSTOM_TXT != '' + if: env.RQS_CUSTOM_TXT_FILE != '' shell: bash run: | set -eu @@ -208,51 +458,137 @@ jobs: set -eu esc=$(printf '%s' "$RQS_APP_NAME" | sed -e 's/[\/&]/\\&/g') sed -i -e "s|description = \"RustDesk Remote Desktop\"|description = \"${esc}\"|" Cargo.toml - find ./src/lang -name "*.rs" -exec sed -i -e "s|RustDesk|${esc}|" {} \; + find ./src/lang -name "*.rs" -exec sed -i -e "s|RustDesk|${esc}|" {} \; + + - name: 'Apply Android identity' + shell: bash + run: | + set -euo pipefail + test -n "${RQS_ANDROID_APP_ID:-}" || { echo "::error::android_app_id is required before Android build"; exit 1; } + test -n "${RQS_APP_NAME:-}" || { echo "::error::app_name is required before Android build"; exit 1; } + ANDROID_APP_ID="$RQS_ANDROID_APP_ID" ANDROID_APP_LABEL="$RQS_APP_NAME" python3 - <<'PY' + import os + import re + from pathlib import Path + from xml.sax.saxutils import escape + + def escape_android_string(value): + if value.startswith(("@", "?")): + raise SystemExit("Android app_name must not start with @ or ?") + value = value.replace("\\", "\\\\") + value = value.replace("'", "\\'").replace('"', '\\"') + return escape(value) + + app_id = os.environ["ANDROID_APP_ID"] + app_label = escape_android_string(os.environ["ANDROID_APP_LABEL"]) + gradle = Path("flutter/android/app/build.gradle") + if not re.fullmatch(r"[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)+", app_id) or len(app_id) > 255: + raise SystemExit("android_app_id failed the Android package contract") + gradle_text = gradle.read_text() + if 'namespace "com.carriez.flutter_hbb"' not in gradle_text: + raise SystemExit("Android namespace marker is missing") + if 'applicationId "com.carriez.flutter_hbb"' not in gradle_text: + raise SystemExit("Android Gradle applicationId marker is missing") + gradle.write_text(gradle_text.replace('applicationId "com.carriez.flutter_hbb"', f'applicationId "{app_id}"', 1)) - # L2 payload (best-effort для Android — требует проверки): кладём custom_.txt в - # flutter assets, чтобы клиент мог его прочитать после установки. + manifest = Path("flutter/android/app/src/main/AndroidManifest.xml") + manifest_text = manifest.read_text() + marker = 'package="com.carriez.flutter_hbb"' + if manifest_text.count(marker) != 1: + raise SystemExit("Android manifest package marker is missing or ambiguous") + + strings = Path("flutter/android/app/src/main/res/values/strings.xml") + strings_text = strings.read_text() + replaced = re.sub( + r'().*?()', + lambda match: f"{match.group(1)}{app_label}{match.group(2)}", + strings_text, + count=1, + ) + if replaced == strings_text: + raise SystemExit("Android app_name resource marker is missing") + strings.write_text(replaced) + PY + grep -F -q -- "applicationId \"$RQS_ANDROID_APP_ID\"" flutter/android/app/build.gradle + grep -F -q -- 'namespace "com.carriez.flutter_hbb"' flutter/android/app/build.gradle + grep -F -q -- 'package="com.carriez.flutter_hbb"' flutter/android/app/src/main/AndroidManifest.xml + + # L2 payload: Flutter packages assets/ below flutter_assets/ in the APK; + # MainService reads this asset and passes it to the native client contract. - name: 'L2 payload: place custom_.txt into flutter assets' - if: env.RQS_CUSTOM_TXT != '' shell: bash run: | set -eu - mkdir -p flutter/assets - printf '%s' "$RQS_CUSTOM_TXT" > flutter/assets/custom_.txt - ls -la flutter/assets/custom_.txt + if [ -n "${RQS_CUSTOM_TXT_FILE:-}" ]; then + mkdir -p flutter/assets + trap 'rm -f -- "$RQS_CUSTOM_TXT_FILE"' EXIT + cp -- "$RQS_CUSTOM_TXT_FILE" flutter/assets/custom_.txt + test -f flutter/assets/custom_.txt || { echo "FAIL: custom_.txt asset was not staged"; exit 1; } + ls -la flutter/assets/custom_.txt + else + test ! -e flutter/assets/custom_.txt || { echo "FAIL: absent custom_.txt payload has a stale Android asset"; exit 1; } + fi - name: Install flutter - uses: subosito/flutter-action@v2.12.0 + uses: subosito/flutter-action@2783a3f08e1baf891508463f8c6653c258246225 # v2.12.0 with: channel: "stable" flutter-version: ${{ env.FLUTTER_VERSION }} - name: Setup Android NDK id: setup-ndk - uses: nttld/setup-ndk@v1 + uses: nttld/setup-ndk@ed92fe6cadad69be94a966a7ee3271275e62f779 # v1 with: ndk-version: ${{ env.NDK_VERSION }} add-to-path: true - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@v1 + uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # v1 with: toolchain: ${{ env.RUST_VERSION }} targets: aarch64-linux-android components: "rustfmt" - - uses: Swatinem/rust-cache@v2 + - uses: Swatinem/rust-cache@63fed3e2fecf6f7b51dc6f043341b79ef82a9ae7 # v2.9.2 with: prefix-key: android-arm64 - name: Restore bridge files - uses: actions/download-artifact@v8 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: bridge-artifact - path: ./ + path: ${{ runner.temp }}/deskforge-bridge-artifact + + - name: Verify and restore bridge files + shell: bash + env: + BRIDGE_ARTIFACT_DIR: ${{ runner.temp }}/deskforge-bridge-artifact + BRIDGE_WORKFLOW_SHA: ${{ github.sha }} + BRIDGE_WORKFLOW_REF: ${{ github.ref }} + run: | + set -euo pipefail + test -f "${MANIFEST_HELPER_PATH:-}" || { echo "::error::preserved manifest helper is unavailable"; exit 1; } + python3 "$MANIFEST_HELPER_PATH" \ + --verify-bridge --output "$BRIDGE_ARTIFACT_DIR" \ + --expected-source-sha "$RQS_SOURCE_SHA" \ + --expected-version "$RQS_VERSION" \ + --workflow-sha "$BRIDGE_WORKFLOW_SHA" --workflow-ref "$BRIDGE_WORKFLOW_REF" + bridge_files=( + flutter/ios/Runner/bridge_generated.h + flutter/lib/generated_bridge.dart + flutter/lib/generated_bridge.freezed.dart + flutter/macos/Runner/bridge_generated.h + src/bridge_generated.io.rs + src/bridge_generated.rs + ) + for file in "${bridge_files[@]}"; do + mkdir -p "$(dirname "$file")" + cp -- "$BRIDGE_ARTIFACT_DIR/$file" "$file" + test -f "$file" || { echo "::error::restored bridge file is missing from source path: $file"; exit 1; } + done - name: Setup vcpkg with Github Actions binary cache - uses: lukka/run-vcpkg@v11 + uses: lukka/run-vcpkg@b1a0dd252f06b9e25b3c022a9a03bd7a427fb6a2 # v11.6 with: vcpkgDirectory: /opt/artifacts/vcpkg vcpkgGitCommitId: ${{ env.VCPKG_COMMIT_ID }} @@ -298,7 +634,7 @@ jobs: JAVA_HOME: /usr/lib/jvm/java-17-openjdk-amd64 run: | set -eu - APP="${RQS_APP_NAME:-rustdesk}" + APP="$RQS_APP_NAME" export PATH=/usr/lib/jvm/java-17-openjdk-amd64/bin:$PATH # Increase Gradle JVM memory for CI builds sed -i "s/org.gradle.jvmargs=-Xmx1024M/org.gradle.jvmargs=-Xmx2g/g" ./flutter/android/gradle.properties @@ -313,15 +649,59 @@ jobs: pushd flutter flutter pub get flutter build apk --release --target-platform android-arm64 --split-per-abi - popd - mkdir -p ./output - apk=$(find flutter/build/app/outputs/flutter-apk -name 'app-arm64-v8a-release.apk' | head -1) - [ -n "$apk" ] || { echo "FAIL: apk not found"; find flutter/build -name '*.apk'; exit 1; } - cp "$apk" "./output/${APP}.apk" - ls -lh ./output/ + popd + mkdir -p ./output + apk="flutter/build/app/outputs/flutter-apk/app-arm64-v8a-release.apk" + test -f "$apk" || { echo "FAIL: expected APK not found at $apk"; exit 1; } + apk_count=$(python3 -c 'from pathlib import Path; print(sum(1 for path in Path("flutter/build/app/outputs/flutter-apk").glob("*.apk") if path.is_file()))') + [ "$apk_count" = "1" ] || { echo "FAIL: expected exactly one arm64 APK, found $apk_count"; exit 1; } + if [ -n "${RQS_CUSTOM_TXT_FILE:-}" ]; then + test -f flutter/assets/custom_.txt || { echo "FAIL: payload custom_.txt asset is missing before APK publication"; exit 1; } + python3 - "$apk" <<'PY' + import sys + import zipfile + + apk = sys.argv[1] + required = "assets/flutter_assets/assets/custom_.txt" + with zipfile.ZipFile(apk) as archive: + if required not in archive.namelist(): + raise SystemExit("FAIL: custom_.txt is not packaged in Flutter Android assets") + PY + fi + cp "$apk" "./output/${APP}.apk" + ls -lh ./output/ + + - name: Assert exact output and write artifact manifest + shell: bash + env: + MANIFEST_PLATFORM: android + MANIFEST_WORKFLOW_SHA: ${{ github.sha }} + MANIFEST_WORKFLOW_REF: ${{ github.ref }} + run: | + set -euo pipefail + MANIFEST_PUBLICATION_TIMESTAMP=$(date -u '+%Y-%m-%dT%H:%M:%SZ') + export MANIFEST_PUBLICATION_TIMESTAMP + if [ -f flutter/assets/custom_.txt ]; then + cp -- flutter/assets/custom_.txt output/custom_.txt + fi + test -f "${MANIFEST_HELPER_PATH:-}" || { echo "::error::preserved manifest helper is unavailable"; exit 1; } + python3 "$MANIFEST_HELPER_PATH" \ + --platform "$MANIFEST_PLATFORM" --app-name "$RQS_APP_NAME" --version "$RQS_VERSION" \ + --output output --workflow-sha "$MANIFEST_WORKFLOW_SHA" --workflow-ref "$MANIFEST_WORKFLOW_REF" - name: Upload artifact - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: rustdesk-min-test-android path: output + retention-days: 7 + if-no-files-found: error + + - name: Cleanup sensitive custom_.txt + if: always() + shell: bash + run: | + set +e + if [ -n "${RQS_CUSTOM_TXT_FILE:-}" ]; then + rm -f -- "$RQS_CUSTOM_TXT_FILE" + fi diff --git a/.github/workflows/rustqs-linux.yml b/.github/workflows/rustqs-linux.yml index 8b71f65fa9b..2fcb196b754 100644 --- a/.github/workflows/rustqs-linux.yml +++ b/.github/workflows/rustqs-linux.yml @@ -7,9 +7,8 @@ name: rustqs linux min test # делалось для windows-min-test. Бэкенд уже умеет диспетчить platform=linux сюда и # забирать артефакт `rustdesk-min-test-linux`. # -# Контракт параметров идентичен rustqs-windows-min-test: -# enc_payload = base64(openssl aes-256-cbc -pbkdf2 -pass pass:$WORKFLOW_PAYLOAD_KEY) -# от JSON {server,key,app_name,custom_txt}; либо открытые inputs (debug). +# Контракт: единственный build path — authenticated DFP1 enc_payload от API. +# Direct/manual runs without a valid payload fail closed before checkout/build. # # Путь в форке: .github/workflows/rustqs-linux.yml на ветке rustqs/min-test. # ============================================================================ @@ -18,35 +17,13 @@ on: workflow_dispatch: inputs: enc_payload: - description: 'Encrypted payload (base64 openssl aes-256-cbc -pbkdf2). Overrides open inputs below.' + description: 'Authenticated DFP1 payload from the API; manual runs without it fail closed.' required: false type: string default: '' - server: - description: '[Debug] RustDesk server (rendezvous host:port). Ignored if enc_payload set.' - required: false - type: string - default: '' - key: - description: '[Debug] RustDesk server public key (base64). Ignored if enc_payload set.' - required: false - type: string - default: '' - app_name: - description: '[Debug] Brand name. Ignored if enc_payload set.' - required: false - type: string - default: '' - custom_txt: - description: '[Debug] Base64 custom_.txt payload. Ignored if enc_payload set.' - required: false - type: string - default: '' - version: - description: 'RustDesk version for offline assets (e.g. 1.4.8). Ignored if enc_payload set.' - required: false - type: string - default: '1.4.8' + +permissions: + contents: read env: RUST_VERSION: "1.75" @@ -54,11 +31,13 @@ env: FLUTTER_VERSION: "3.24.5" VCPKG_COMMIT_ID: "120deac3062162151622ca4860575a33844ba10b" VCPKG_BINARY_SOURCES: "clear;x-gha,readwrite" - VERSION: "${{ inputs.version || '1.4.8' }}" jobs: bridge: uses: ./.github/workflows/bridge.yml + with: + enc_payload: ${{ inputs.enc_payload }} + secrets: inherit build: needs: [bridge] @@ -69,62 +48,297 @@ jobs: sudo rm -rf /opt/ghc /usr/local/lib/android /usr/share/dotnet df -h - - name: Export GitHub Actions cache environment variables - uses: actions/github-script@v9 - with: - script: | - core.exportVariable('ACTIONS_CACHE_URL', process.env.ACTIONS_CACHE_URL || ''); - core.exportVariable('ACTIONS_RUNTIME_TOKEN', process.env.ACTIONS_RUNTIME_TOKEN || ''); - - - name: Checkout source code - uses: actions/checkout@v7 - with: - submodules: recursive - - # Разрешение параметров: расшифровать enc_payload (prod) или взять открытые inputs (debug). - # Идентично windows-min-test: на выходе env RQS_*, секреты замаскированы. - - name: 'Resolve build config (decrypt or pass-through)' + # The API is the only source of build values; direct/manual fallback is + # intentionally absent and the guard runs before checkout. + - name: 'Resolve authenticated build config' shell: bash env: ENC: ${{ inputs.enc_payload }} PAYLOAD_KEY: ${{ secrets.WORKFLOW_PAYLOAD_KEY }} - IN_SERVER: ${{ inputs.server }} - IN_KEY: ${{ inputs.key }} - IN_APP: ${{ inputs.app_name }} - IN_CT: ${{ inputs.custom_txt }} - IN_VERSION: ${{ inputs.version }} run: | - set -eu - if [ -n "${ENC:-}" ]; then - if [ -z "${PAYLOAD_KEY:-}" ]; then + set -euo pipefail + RQS_PAYLOAD_MODE=encrypted + if [ -z "${ENC:-}" ]; then + echo "::error::manual/direct runs require an authenticated DFP1 payload; no build is permitted" + exit 1 + fi + if [ -z "${PAYLOAD_KEY:-}" ]; then echo "::error::enc_payload provided but secret WORKFLOW_PAYLOAD_KEY is not set in this repo" exit 1 fi - decrypted=$(printf '%s' "$ENC" | base64 -d \ - | openssl enc -d -aes-256-cbc -pbkdf2 -pass "pass:${PAYLOAD_KEY}") + decrypt_payload() { + local payload_dir magic material aes_key aes_iv ciphertext + payload_dir=$(mktemp -d) + magic=$(printf '%s' "$ENC" | base64 -d | dd bs=1 count=4 status=none) + if [ "$magic" = "DFP1" ]; then + ciphertext="$payload_dir/ciphertext" + if ! material=$(PAYLOAD_KEY="$PAYLOAD_KEY" ENC="$ENC" python3 - "$ciphertext" <<'PY' + import base64 + import hashlib + import hmac + import os + import pathlib + import sys + + raw = base64.b64decode(os.environ["ENC"], validate=True) + if len(raw) <= 4 + 16 + 32: + raise SystemExit("authenticated payload is truncated") + ciphertext = raw[20:-32] + if not ciphertext or len(ciphertext) % 16: + raise SystemExit("authenticated payload ciphertext is invalid") + derived = hashlib.pbkdf2_hmac("sha256", os.environ["PAYLOAD_KEY"].encode(), raw[4:20], 100000, 80) + expected = hmac.new(derived[48:], raw[:-32], hashlib.sha256).digest() + if not hmac.compare_digest(expected, raw[-32:]): + raise SystemExit("authenticated payload integrity check failed") + pathlib.Path(sys.argv[1]).write_bytes(ciphertext) + print(derived[:32].hex()) + print(derived[32:48].hex()) + PY + ); then + rm -rf "$payload_dir" + return 1 + fi + aes_key=$(printf '%s\n' "$material" | sed -n '1p') + aes_iv=$(printf '%s\n' "$material" | sed -n '2p') + if ! openssl enc -d -aes-256-cbc -K "$aes_key" -iv "$aes_iv" -in "$ciphertext"; then + rm -rf "$payload_dir" + return 1 + fi + else + echo "::error::enc_payload must use the authenticated DFP1 envelope" >&2 + rm -rf "$payload_dir" + return 1 + fi + rm -rf "$payload_dir" + } + decrypted=$(decrypt_payload) RQS_SERVER=$(printf '%s' "$decrypted" | jq -r '.server // ""') RQS_KEY=$(printf '%s' "$decrypted" | jq -r '.key // ""') RQS_APP=$(printf '%s' "$decrypted" | jq -r '.app_name // ""') RQS_CT=$(printf '%s' "$decrypted" | jq -r '.custom_txt // ""') RQS_VERSION=$(printf '%s' "$decrypted" | jq -r '.version // ""') - else - RQS_SERVER="${IN_SERVER:-}"; RQS_KEY="${IN_KEY:-}"; RQS_APP="${IN_APP:-}"; RQS_CT="${IN_CT:-}"; RQS_VERSION="${IN_VERSION:-}" + RQS_SOURCE_SHA=$(printf '%s' "$decrypted" | jq -r '.source_sha // ""') + RQS_WORKFLOW_REPO=$(printf '%s' "$decrypted" | jq -r '.workflow_repo // ""') + if ! printf '%s' "$decrypted" | jq -e ' + (.server == null or (.server | type == "string")) and + (.key == null or (.key | type == "string")) and + (.app_name == null or (.app_name | type == "string")) and + (.custom_txt == null or (.custom_txt | type == "string")) and + (.version == null or (.version | type == "string")) and + (.source_sha == null or (.source_sha | type == "string")) and + (.workflow_repo | type == "string") + ' >/dev/null; then + echo "::error::encrypted payload env-bound fields must be strings" + exit 1 + fi + if [ "${RQS_WORKFLOW_REPO,,}" != "${GITHUB_REPOSITORY,,}" ]; then + echo "::error::authenticated workflow repository does not match this fork" + exit 1 + fi + reject_control_chars() { + local field="$1" value="$2" + if [[ "$value" == *$'\n'* || "$value" == *$'\r'* ]] || LC_ALL=C printf '%s' "$value" | LC_ALL=C grep -q '[[:cntrl:]]'; then + echo "::error::$field contains unsafe control characters" + exit 1 + fi + } + normalize_public_key() { + local value="$1" + while [[ "$value" == *$'\r' || "$value" == *$'\n' ]]; do + value="${value%?}" + done + printf '%s' "$value" + } + validate_public_key() { + local value="$1" + if ! PUBLIC_KEY="$value" python3 - <<'PY' + import base64 + import os + import re + + value = os.environ["PUBLIC_KEY"] + if not re.fullmatch(r"[A-Za-z0-9+/]{43}=", value): + raise SystemExit("public key must be padded standard base64") + try: + raw = base64.b64decode(value, validate=True) + except ValueError as exc: + raise SystemExit("public key is not valid standard base64") from exc + if len(raw) != 32 or base64.b64encode(raw).decode("ascii") != value: + raise SystemExit("public key must encode exactly 32 bytes canonically") + PY + then + echo "::error::RustDesk public key must be canonical padded base64 for 32 bytes" + exit 1 + fi + } + validate_version() { + local value="$1" byte_length + byte_length=$(LC_ALL=C printf '%s' "$value" | wc -c) + if [ "$byte_length" -gt 32 ] || [[ ! "$value" =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-((0|[1-9][0-9]*)|([0-9A-Za-z-]*[A-Za-z-][0-9A-Za-z-]*))(\.((0|[1-9][0-9]*)|([0-9A-Za-z-]*[A-Za-z-][0-9A-Za-z-]*)))*)?$ ]]; then + echo "::error::RQS_VERSION must be a strict semantic version" + exit 1 + fi + } + validate_app_name() { + local value="$1" byte_length + reject_control_chars app_name "$value" + byte_length=$(LC_ALL=C printf '%s' "$value" | wc -c) + if [ -z "$value" ] || [ "$value" = "." ] || [ "$value" = ".." ] || [ "$byte_length" -gt 128 ]; then + echo "::error::app_name must be a non-empty filename component of at most 128 bytes" + exit 1 + fi + if [[ "$value" == */* || "$value" == *\\* || "$value" == *'<'* || "$value" == *'>'* || "$value" == *:* || "$value" == *'"'* || "$value" == *'|'* || "$value" == *'?'* || "$value" == *'*'* ]]; then + echo "::error::app_name contains unsafe filename characters" + exit 1 + fi + case "$value" in + *.|*' ') echo "::error::app_name must not end in a dot or space"; exit 1 ;; + esac + if is_windows_reserved_device_name "$value"; then + echo "::error::app_name uses a reserved Windows device name" + exit 1 + fi + } + is_windows_reserved_device_name() { + local value="$1" base + base="${value%%.*}" + while [[ "$base" == *" " || "$base" == *"." ]]; do + base="${base%?}" + done + case "${base^^}" in + CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9]) return 0 ;; + *) return 1 ;; + esac + } + write_github_env() { + local name="$1" value="$2" + reject_control_chars "$name" "$value" + printf '%s=%s\n' "$name" "$value" >> "$GITHUB_ENV" + } + if [ -n "$RQS_KEY" ]; then + RQS_KEY=$(normalize_public_key "$RQS_KEY") + fi + for field_value in \ + "RQS_SERVER=$RQS_SERVER" \ + "RQS_KEY=$RQS_KEY" \ + "RQS_CUSTOM_TXT=$RQS_CT" \ + "RQS_VERSION=$RQS_VERSION" \ + "RQS_SOURCE_SHA=$RQS_SOURCE_SHA" \ + "RQS_WORKFLOW_REPO=$RQS_WORKFLOW_REPO"; do + field_name=${field_value%%=*} + field_value=${field_value#*=} + reject_control_chars "$field_name" "$field_value" + done + validate_app_name "$RQS_APP" + if [ -n "$RQS_KEY" ]; then + validate_public_key "$RQS_KEY" + fi + if [ -n "$RQS_VERSION" ]; then + validate_version "$RQS_VERSION" + fi + if ! printf '%s' "$decrypted" | jq -e '.source_sha | type == "string"' >/dev/null; then + echo "::error::encrypted payload source_sha must be a string" + exit 1 + fi + if [ -z "$RQS_SOURCE_SHA" ]; then + echo "::error::encrypted payload is missing source_sha" + exit 1 + fi + if ! printf '%s' "$RQS_SOURCE_SHA" | grep -Eq '^[0-9a-fA-F]{40,64}$'; then + echo "::error::encrypted payload source_sha must be a hexadecimal commit SHA" + exit 1 fi for v in "$RQS_KEY" "$RQS_CT"; do [ -n "$v" ] && echo "::add-mask::$v" || true; done - { - echo "RQS_SERVER=$RQS_SERVER" - echo "RQS_KEY=$RQS_KEY" - echo "RQS_APP_NAME=$RQS_APP" - echo "RQS_CUSTOM_TXT=$RQS_CT" - echo "RQS_VERSION=$RQS_VERSION" - } >> "$GITHUB_ENV" + write_github_env RQS_PAYLOAD_MODE "$RQS_PAYLOAD_MODE" + write_github_env RQS_SERVER "$RQS_SERVER" + write_github_env RQS_KEY "$RQS_KEY" + write_github_env RQS_APP_NAME "$RQS_APP" + # Keep password-bearing custom_.txt content out of GITHUB_ENV. The + # exact source_sha checkout/integrity check below is the trusted-source + # gate; this restrictive path is shared only to the packaging step. + if [ -n "$RQS_CT" ]; then + custom_txt_file="${RUNNER_TEMP}/rustdesk-custom-${GITHUB_RUN_ID:-local}.txt" + cleanup_custom_txt_on_failure() { + status=$? + if [ "$status" -ne 0 ]; then rm -f -- "$custom_txt_file"; fi + trap - EXIT + exit "$status" + } + trap cleanup_custom_txt_on_failure EXIT + (umask 077; printf '%s' "$RQS_CT" > "$custom_txt_file") + chmod 600 "$custom_txt_file" + write_github_env RQS_CUSTOM_TXT_FILE "$custom_txt_file" + else + write_github_env RQS_CUSTOM_TXT_FILE "" + fi + write_github_env RQS_VERSION "$RQS_VERSION" + write_github_env RQS_SOURCE_SHA "$RQS_SOURCE_SHA" + write_github_env RQS_WORKFLOW_REPO "$RQS_WORKFLOW_REPO" + echo "config: mode=$RQS_PAYLOAD_MODE" + echo "config: source_sha=SET" + + - name: Checkout source code + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.sha }} + submodules: recursive + persist-credentials: false + + - name: Preserve workflow manifest helper + shell: bash + run: | + set -euo pipefail + source_helper=.github/scripts/write_artifact_manifest.py + helper_path="${RUNNER_TEMP}/deskforge-write_artifact_manifest.py" + test -f "$source_helper" || { echo "::error::workflow-owned manifest helper is missing"; exit 1; } + cp -- "$source_helper" "$helper_path" + chmod 700 "$helper_path" + printf 'MANIFEST_HELPER_PATH=%s\n' "$helper_path" >> "$GITHUB_ENV" + + - name: Checkout source commit + shell: bash + run: | + set -euo pipefail + if [ -z "${RQS_SOURCE_SHA:-}" ]; then + echo "::error::authenticated payload source_sha is required" + exit 1 + fi + if ! printf '%s' "$RQS_SOURCE_SHA" | grep -Eq '^[0-9a-fA-F]{40,64}$'; then + echo "::error::source_sha must be a hexadecimal commit SHA" + exit 1 + fi + GIT_CONFIG_COUNT=1 \ + GIT_CONFIG_KEY_0=http.extraheader \ + GIT_CONFIG_VALUE_0="Authorization: Bearer ${{ github.token }}" \ + git fetch --no-tags --depth=1 origin "$RQS_SOURCE_SHA" + git checkout --detach "$RQS_SOURCE_SHA" + git submodule sync --recursive + GIT_CONFIG_COUNT=1 \ + GIT_CONFIG_KEY_0=http.extraheader \ + GIT_CONFIG_VALUE_0="Authorization: Bearer ${{ github.token }}" \ + git submodule update --init --recursive + expected=$(printf '%s' "$RQS_SOURCE_SHA" | tr '[:upper:]' '[:lower:]') + actual=$(git rev-parse HEAD | tr '[:upper:]' '[:lower:]') + [ "$actual" = "$expected" ] || { echo "::error::source checkout SHA mismatch"; exit 1; } + + - name: Set deterministic source timestamp + shell: bash + run: | + set -euo pipefail + SOURCE_DATE_EPOCH=$(git show -s --format=%ct HEAD) + [[ "$SOURCE_DATE_EPOCH" =~ ^[0-9]+$ ]] || { echo "::error::invalid commit timestamp"; exit 1; } + printf 'SOURCE_DATE_EPOCH=%s\n' "$SOURCE_DATE_EPOCH" >> "$GITHUB_ENV" # Override VERSION from encrypted payload (takes precedence over workflow-level default). - name: 'Override VERSION from dispatch payload' if: env.RQS_VERSION != '' shell: bash run: | - echo "VERSION=$RQS_VERSION" >> "$GITHUB_ENV" + if [ "${#RQS_VERSION}" -gt 32 ] || [[ ! "$RQS_VERSION" =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-((0|[1-9][0-9]*)|([0-9A-Za-z-]*[A-Za-z-][0-9A-Za-z-]*))(\.((0|[1-9][0-9]*)|([0-9A-Za-z-]*[A-Za-z-][0-9A-Za-z-]*)))*)?$ ]]; then + echo "::error::RQS_VERSION must be a strict semantic version" + exit 1 + fi + printf '%s=%s\n' VERSION "$RQS_VERSION" >> "$GITHUB_ENV" echo "VERSION overridden from dispatch: $RQS_VERSION" # L1: вшить сервер+ключ в config.rs (платформо-независимо, идентично windows). @@ -147,7 +361,7 @@ jobs: # L2: allowCustom — снять проверку подписи custom.txt (платформо-независимо). - name: 'L2 patch: allowCustom' - if: env.RQS_CUSTOM_TXT != '' + if: env.RQS_CUSTOM_TXT_FILE != '' shell: bash run: | set -eu @@ -172,7 +386,35 @@ jobs: uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: bridge-artifact - path: ./ + path: ${{ runner.temp }}/deskforge-bridge-artifact + + - name: Verify and restore bridge files + shell: bash + env: + BRIDGE_ARTIFACT_DIR: ${{ runner.temp }}/deskforge-bridge-artifact + BRIDGE_WORKFLOW_SHA: ${{ github.sha }} + BRIDGE_WORKFLOW_REF: ${{ github.ref }} + run: | + set -euo pipefail + test -f "${MANIFEST_HELPER_PATH:-}" || { echo "::error::preserved manifest helper is unavailable"; exit 1; } + python3 "$MANIFEST_HELPER_PATH" \ + --verify-bridge --output "$BRIDGE_ARTIFACT_DIR" \ + --expected-source-sha "$RQS_SOURCE_SHA" \ + --expected-version "$RQS_VERSION" \ + --workflow-sha "$BRIDGE_WORKFLOW_SHA" --workflow-ref "$BRIDGE_WORKFLOW_REF" + bridge_files=( + flutter/ios/Runner/bridge_generated.h + flutter/lib/generated_bridge.dart + flutter/lib/generated_bridge.freezed.dart + flutter/macos/Runner/bridge_generated.h + src/bridge_generated.io.rs + src/bridge_generated.rs + ) + for file in "${bridge_files[@]}"; do + mkdir -p "$(dirname "$file")" + cp -- "$BRIDGE_ARTIFACT_DIR/$file" "$file" + test -f "$file" || { echo "::error::restored bridge file is missing from source path: $file"; exit 1; } + done - name: Build dependencies (apt) run: | @@ -198,7 +440,7 @@ jobs: with: swap-size-gb: 12 - - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 + - uses: Swatinem/rust-cache@63fed3e2fecf6f7b51dc6f043341b79ef82a9ae7 # v2.9.2 with: prefix-key: ubuntu-24.04 @@ -208,7 +450,7 @@ jobs: sed -i 's/\["cdylib", "staticlib", "rlib"\]/\["cdylib"\]/g' Cargo.toml - name: Setup vcpkg with Github Actions binary cache - uses: lukka/run-vcpkg@b1a0dd252f06b9e25b3c022a9a03bd7a427fb6a2 # v11 + uses: lukka/run-vcpkg@b1a0dd252f06b9e25b3c022a9a03bd7a427fb6a2 # v11.6 with: vcpkgDirectory: /opt/artifacts/vcpkg vcpkgGitCommitId: ${{ env.VCPKG_COMMIT_ID }} @@ -232,7 +474,7 @@ jobs: shell: bash - name: Install flutter - uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2 + uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2.23.0 with: channel: "stable" flutter-version: ${{ env.FLUTTER_VERSION }} @@ -246,26 +488,21 @@ jobs: cd $(dirname $(dirname $(which flutter))) git apply flutter_3.24.4_dropdown_menu_enableFilter.diff - # Двухстадийная сборка (как в upstream flutter-build.yml): сначала cargo lib - # со всеми нужными фичами, затем build.py только для Flutter-UI + .deb. + # Historical two-stage build shape: first cargo lib with the required features, + # then build.py for the Flutter UI and packages. The old upstream flutter-build.yml + # reference is not the active workflow source. - name: Build rustdesk (Flutter Linux) run: | + set -euo pipefail export VCPKG_ROOT=/opt/artifacts/vcpkg export CARGO_INCREMENTAL=0 export DEB_ARCH=amd64 + if [ -n "${RQS_CUSTOM_TXT_FILE:-}" ]; then + test -f "$RQS_CUSTOM_TXT_FILE" || { echo "FAIL: private custom_.txt source is missing before Debian packaging"; exit 1; } + fi cargo build --locked --lib --features hwcodec,flutter,unix-file-copy-paste --release python3 ./build.py --flutter --skip-cargo - # L2 payload: положить custom_.txt рядом с бинарём в bundle (читается клиентом). - - name: 'L2 payload: place custom_.txt into bundle' - if: env.RQS_CUSTOM_TXT != '' - shell: bash - run: | - set -eu - dst=flutter/build/linux/x64/release/bundle/custom_.txt - printf '%s' "$RQS_CUSTOM_TXT" > "$dst" - ls -la "$dst" - - name: Install rpm run: | # Долгий vcpkg-build (ffmpeg, 5-10 мин) мог состарить apt-кэш — обновим. @@ -277,38 +514,73 @@ jobs: env: HBB: ${{ github.workspace }} run: | - set -eu - APP_RAW="${RQS_APP_NAME:-rustdesk}" - APP="$(printf '%s' "$APP_RAW" | tr -c 'A-Za-z0-9._+-' '-' | sed -e 's/^-*//' -e 's/-*$//')" - [ -n "$APP" ] || APP=rustdesk - VERSION=$(grep '^version =' Cargo.toml | head -1 | sed 's/.*"\(.*\)".*/\1/') - mkdir -p ./output - - # 1) .deb — создан build.py → build_flutter_deb() - for pkg in rustdesk-*.deb; do - [ -f "$pkg" ] || continue - new_name="$(echo "$pkg" | sed "s/^rustdesk/${APP}/")" - cp "$pkg" "./output/${new_name}" - echo "::notice::DEB: $pkg → output/${new_name}" - done + set -eu + APP="$RQS_APP_NAME" + VERSION=$(python3 - <<'PY' + import tomllib - # 2) .rpm — rpmbuild из того же flutter bundle - if [ -d flutter/build/linux/x64/release/bundle ]; then - sed -i "s/^Version:.*/Version: ${VERSION}/" res/rpm-flutter.spec - rpmbuild -ba res/rpm-flutter.spec - rpm_file=$(find "$HOME/rpmbuild/RPMS" -name 'rustdesk-*.rpm' | head -1) - if [ -n "$rpm_file" ]; then - new_name="${APP}-${VERSION}-0.x86_64.rpm" - cp "$rpm_file" "./output/${new_name}" - echo "::notice::RPM: ${new_name}" - fi - fi + with open("Cargo.toml", "rb") as cargo_file: + print(tomllib.load(cargo_file)["package"]["version"]) + PY + ) + [ -n "$RQS_VERSION" ] || { echo "FAIL: RQS_VERSION is required"; exit 1; } + [ "$VERSION" = "$RQS_VERSION" ] || { echo "FAIL: Cargo version $VERSION does not match RQS_VERSION $RQS_VERSION"; exit 1; } + mkdir -p ./output + + # 1) .deb — build.py deterministically creates rustdesk-${VERSION}.deb. + deb_source="rustdesk-${VERSION}.deb" + deb_output="./output/${APP}-${VERSION}.deb" + test -f "$deb_source" || { echo "FAIL: expected package not found at $deb_source"; exit 1; } + if [ -n "${RQS_CUSTOM_TXT_FILE:-}" ]; then + test -f flutter/build/linux/x64/release/bundle/custom_.txt || { echo "FAIL: custom_.txt is missing from the Linux bundle"; exit 1; } + dpkg-deb -c "$deb_source" | grep -Fq -- "./usr/share/rustdesk/custom_.txt" || { echo "FAIL: custom_.txt is missing from the Debian package"; exit 1; } + fi + cp -- "$deb_source" "$deb_output" + echo "::notice::DEB: $deb_source → $deb_output" + + # 2) .rpm — rpmbuild from the same Flutter bundle. + test -d flutter/build/linux/x64/release/bundle || { echo "FAIL: expected Flutter bundle is missing"; exit 1; } + sed -i "s/^Version:.*/Version: ${VERSION}/" res/rpm-flutter.spec + rpmbuild -ba res/rpm-flutter.spec + rpm_source="$HOME/rpmbuild/RPMS/x86_64/rustdesk-${VERSION}-0.x86_64.rpm" + rpm_output="./output/${APP}-${VERSION}-0.x86_64.rpm" + test -f "$rpm_source" || { echo "FAIL: expected package not found at $rpm_source"; exit 1; } + cp -- "$rpm_source" "$rpm_output" + echo "::notice::RPM: $rpm_output" - ls -lh ./output/ + ls -lh ./output/ + + - name: Assert exact output and write artifact manifest + shell: bash + env: + MANIFEST_PLATFORM: linux + MANIFEST_WORKFLOW_SHA: ${{ github.sha }} + MANIFEST_WORKFLOW_REF: ${{ github.ref }} + run: | + set -euo pipefail + MANIFEST_PUBLICATION_TIMESTAMP=$(date -u '+%Y-%m-%dT%H:%M:%SZ') + export MANIFEST_PUBLICATION_TIMESTAMP + if [ -f flutter/build/linux/x64/release/bundle/custom_.txt ]; then + cp -- flutter/build/linux/x64/release/bundle/custom_.txt output/custom_.txt + fi + test -f "${MANIFEST_HELPER_PATH:-}" || { echo "::error::preserved manifest helper is unavailable"; exit 1; } + python3 "$MANIFEST_HELPER_PATH" \ + --platform "$MANIFEST_PLATFORM" --app-name "$RQS_APP_NAME" --version "$RQS_VERSION" \ + --output output --workflow-sha "$MANIFEST_WORKFLOW_SHA" --workflow-ref "$MANIFEST_WORKFLOW_REF" - name: Upload artifact uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: rustdesk-min-test-linux path: output + retention-days: 7 if-no-files-found: error + + - name: Cleanup sensitive custom_.txt + if: always() + shell: bash + run: | + set +e + if [ -n "${RQS_CUSTOM_TXT_FILE:-}" ]; then + rm -f -- "$RQS_CUSTOM_TXT_FILE" + fi diff --git a/.github/workflows/rustqs-windows-min-test.yml b/.github/workflows/rustqs-windows-min-test.yml index 9f27cc1cb14..49e822eb55f 100644 --- a/.github/workflows/rustqs-windows-min-test.yml +++ b/.github/workflows/rustqs-windows-min-test.yml @@ -1,14 +1,11 @@ name: rustqs windows min test # Минимальный workflow сборки rustqs.exe (Flutter Windows) в форке rustdesk. -# Шаги — копия build-for-windows-flutter@1.4.7 + workflow_dispatch + три слоя вшивания +# Historical source note: the steps were adapted from build-for-windows-flutter@1.4.7. +# The current fork source/ref is 1.4.8; active dispatch uses authenticated DFP1 only. # (L1 config.rs server+key, L2 allowCustom+custom_.txt, L3 brand+rename exe). # -# Два режима передачи параметров (через workflow inputs): -# 1) enc_payload — base64(openssl aes-256-cbc -pbkdf2 -pass pass:$SECRET) от JSON -# {server,key,app_name,custom_txt}. Recommended для prod: значения не утекают в -# логи публичного рана. Требует GitHub Secret WORKFLOW_PAYLOAD_KEY в форке. -# 2) Открытые server/key/app_name/custom_txt — для дебага и тестирования. -# Удобно, но значения видны в логах рана. Не использовать на проде. +# Единственный build path: enc_payload — authenticated DFP1 AES-CBC + HMAC +# envelope from the API. Direct/manual runs without a valid payload fail closed. # # Запуск из gh: # gh api repos/$REPO/actions/workflows/rustqs-windows-min-test.yml/dispatches -X POST \ @@ -18,35 +15,13 @@ on: workflow_dispatch: inputs: enc_payload: - description: 'Encrypted payload (base64 openssl aes-256-cbc -pbkdf2). Overrides open inputs below.' + description: 'Authenticated DFP1 payload from the API; manual runs without it fail closed.' required: false type: string default: '' - server: - description: '[Debug] RustDesk server (rendezvous host:port). Ignored if enc_payload set.' - required: false - type: string - default: '' - key: - description: '[Debug] RustDesk server public key (base64). Ignored if enc_payload set.' - required: false - type: string - default: '' - app_name: - description: '[Debug] Brand name. Ignored if enc_payload set.' - required: false - type: string - default: '' - custom_txt: - description: '[Debug] Base64 custom_.txt payload. Ignored if enc_payload set.' - required: false - type: string - default: '' - version: - description: 'RustDesk version for offline assets (e.g. 1.4.8). Ignored if enc_payload set.' - required: false - type: string - default: '1.4.8' + +permissions: + contents: read env: RUST_VERSION: "1.75" @@ -54,11 +29,13 @@ env: FLUTTER_VERSION: "3.24.5" VCPKG_COMMIT_ID: "120deac3062162151622ca4860575a33844ba10b" VCPKG_BINARY_SOURCES: "clear;x-gha,readwrite" - VERSION: "${{ inputs.version || '1.4.8' }}" jobs: bridge: uses: ./.github/workflows/bridge.yml + with: + enc_payload: ${{ inputs.enc_payload }} + secrets: inherit topmost: uses: ./.github/workflows/third-party-RustDeskTempTopMostWindow.yml @@ -73,55 +50,237 @@ jobs: needs: [bridge, topmost] runs-on: windows-2022 steps: - - name: Export GitHub Actions cache environment variables - uses: actions/github-script@v9 - with: - script: | - core.exportVariable('ACTIONS_CACHE_URL', process.env.ACTIONS_CACHE_URL || ''); - core.exportVariable('ACTIONS_RUNTIME_TOKEN', process.env.ACTIONS_RUNTIME_TOKEN || ''); - - - name: Checkout source code - uses: actions/checkout@v7 - with: - submodules: recursive - - # Разрешение параметров сборки: либо расшифровать enc_payload (prod), - # либо взять открытые inputs (debug). На выходе — env vars RQS_*, скрытые - # из логов через ::add-mask::. - - name: 'Resolve build config (decrypt or pass-through)' + # Resolve the authenticated DFP1 build config. The former open-input debug + # fallback is historical/superseded. Output is env vars RQS_*, masked in logs. + - name: 'Resolve authenticated build config' shell: bash env: ENC: ${{ inputs.enc_payload }} PAYLOAD_KEY: ${{ secrets.WORKFLOW_PAYLOAD_KEY }} - IN_SERVER: ${{ inputs.server }} - IN_KEY: ${{ inputs.key }} - IN_APP: ${{ inputs.app_name }} - IN_CT: ${{ inputs.custom_txt }} - IN_VERSION: ${{ inputs.version }} run: | - set -eu + set -euo pipefail - if [ -n "${ENC:-}" ]; then - if [ -z "${PAYLOAD_KEY:-}" ]; then + RQS_PAYLOAD_MODE=encrypted + if [ -z "${ENC:-}" ]; then + echo "::error::manual/direct runs require an authenticated DFP1 payload; no build is permitted" + exit 1 + fi + if [ -z "${PAYLOAD_KEY:-}" ]; then echo "::error::enc_payload provided but secret WORKFLOW_PAYLOAD_KEY is not set in this repo" exit 1 fi echo "Resolve mode: ENCRYPTED (enc_payload, len ${#ENC})" - # base64 → openssl decrypt с PBKDF2 → JSON - decrypted=$(printf '%s' "$ENC" | base64 -d \ - | openssl enc -d -aes-256-cbc -pbkdf2 -pass "pass:${PAYLOAD_KEY}") + # DFP1 is authenticated encrypt-then-MAC; legacy/open envelopes are + # intentionally rejected by the active build workflow. + decrypt_payload() { + local payload_dir magic material aes_key aes_iv ciphertext + payload_dir=$(mktemp -d) + magic=$(printf '%s' "$ENC" | base64 -d | dd bs=1 count=4 status=none) + if [ "$magic" = "DFP1" ]; then + ciphertext="$payload_dir/ciphertext" + if ! material=$(PAYLOAD_KEY="$PAYLOAD_KEY" ENC="$ENC" python3 - "$ciphertext" <<'PY' + import base64 + import hashlib + import hmac + import os + import pathlib + import sys + + raw = base64.b64decode(os.environ["ENC"], validate=True) + if len(raw) <= 4 + 16 + 32: + raise SystemExit("authenticated payload is truncated") + ciphertext = raw[20:-32] + if not ciphertext or len(ciphertext) % 16: + raise SystemExit("authenticated payload ciphertext is invalid") + derived = hashlib.pbkdf2_hmac("sha256", os.environ["PAYLOAD_KEY"].encode(), raw[4:20], 100000, 80) + expected = hmac.new(derived[48:], raw[:-32], hashlib.sha256).digest() + if not hmac.compare_digest(expected, raw[-32:]): + raise SystemExit("authenticated payload integrity check failed") + pathlib.Path(sys.argv[1]).write_bytes(ciphertext) + print(derived[:32].hex()) + print(derived[32:48].hex()) + PY + ); then + rm -rf "$payload_dir" + return 1 + fi + aes_key=$(printf '%s\n' "$material" | sed -n '1p') + aes_iv=$(printf '%s\n' "$material" | sed -n '2p') + if ! openssl enc -d -aes-256-cbc -K "$aes_key" -iv "$aes_iv" -in "$ciphertext"; then + rm -rf "$payload_dir" + return 1 + fi + else + echo "::error::enc_payload must use the authenticated DFP1 envelope" >&2 + rm -rf "$payload_dir" + return 1 + fi + rm -rf "$payload_dir" + } + decrypted=$(decrypt_payload) RQS_SERVER=$(printf '%s' "$decrypted" | jq -r '.server // ""') RQS_KEY=$(printf '%s' "$decrypted" | jq -r '.key // ""') RQS_APP=$(printf '%s' "$decrypted" | jq -r '.app_name // ""') RQS_CT=$(printf '%s' "$decrypted" | jq -r '.custom_txt // ""') RQS_VERSION=$(printf '%s' "$decrypted" | jq -r '.version // ""') - else - echo "Resolve mode: OPEN inputs (debug)" - RQS_SERVER="${IN_SERVER:-}" - RQS_KEY="${IN_KEY:-}" - RQS_APP="${IN_APP:-}" - RQS_CT="${IN_CT:-}" - RQS_VERSION="${IN_VERSION:-}" + RQS_SOURCE_SHA=$(printf '%s' "$decrypted" | jq -r '.source_sha // ""') + RQS_WORKFLOW_REPO=$(printf '%s' "$decrypted" | jq -r '.workflow_repo // ""') + RQS_RELEASE_REPO=$(printf '%s' "$decrypted" | jq -r '.release_repo // ""') + RQS_RELEASE_ASSETS=$(printf '%s' "$decrypted" | jq -c '.release_assets // empty') + if ! printf '%s' "$decrypted" | jq -e ' + (.server == null or (.server | type == "string")) and + (.key == null or (.key | type == "string")) and + (.app_name == null or (.app_name | type == "string")) and + (.custom_txt == null or (.custom_txt | type == "string")) and + (.version == null or (.version | type == "string")) and + (.source_sha == null or (.source_sha | type == "string")) and + (.release_repo == null or (.release_repo | type == "string")) and + (.workflow_repo | type == "string") + ' >/dev/null; then + echo "::error::encrypted payload env-bound fields must be strings" + exit 1 + fi + if [ "${RQS_WORKFLOW_REPO,,}" != "${GITHUB_REPOSITORY,,}" ]; then + echo "::error::authenticated workflow repository does not match this fork" + exit 1 + fi + + reject_control_chars() { + local field="$1" value="$2" + if [[ "$value" == *$'\n'* || "$value" == *$'\r'* ]] || LC_ALL=C printf '%s' "$value" | LC_ALL=C grep -q '[[:cntrl:]]'; then + echo "::error::$field contains unsafe control characters" + exit 1 + fi + } + normalize_public_key() { + local value="$1" + while [[ "$value" == *$'\r' || "$value" == *$'\n' ]]; do + value="${value%?}" + done + printf '%s' "$value" + } + validate_public_key() { + local value="$1" + if ! PUBLIC_KEY="$value" python3 - <<'PY' + import base64 + import os + import re + + value = os.environ["PUBLIC_KEY"] + if not re.fullmatch(r"[A-Za-z0-9+/]{43}=", value): + raise SystemExit("public key must be padded standard base64") + try: + raw = base64.b64decode(value, validate=True) + except ValueError as exc: + raise SystemExit("public key is not valid standard base64") from exc + if len(raw) != 32 or base64.b64encode(raw).decode("ascii") != value: + raise SystemExit("public key must encode exactly 32 bytes canonically") + PY + then + echo "::error::RustDesk public key must be canonical padded base64 for 32 bytes" + exit 1 + fi + } + validate_version() { + local value="$1" byte_length + byte_length=$(LC_ALL=C printf '%s' "$value" | wc -c) + if [ "$byte_length" -gt 32 ] || [[ ! "$value" =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-((0|[1-9][0-9]*)|([0-9A-Za-z-]*[A-Za-z-][0-9A-Za-z-]*))(\.((0|[1-9][0-9]*)|([0-9A-Za-z-]*[A-Za-z-][0-9A-Za-z-]*)))*)?$ ]]; then + echo "::error::RQS_VERSION must be a strict semantic version" + exit 1 + fi + } + validate_app_name() { + local value="$1" byte_length + reject_control_chars app_name "$value" + byte_length=$(LC_ALL=C printf '%s' "$value" | wc -c) + if [ -z "$value" ] || [ "$value" = "." ] || [ "$value" = ".." ] || [ "$byte_length" -gt 128 ]; then + echo "::error::app_name must be a non-empty filename component of at most 128 bytes" + exit 1 + fi + if [[ "$value" == */* || "$value" == *\\* || "$value" == *'<'* || "$value" == *'>'* || "$value" == *:* || "$value" == *'"'* || "$value" == *'|'* || "$value" == *'?'* || "$value" == *'*'* ]]; then + echo "::error::app_name contains unsafe filename characters" + exit 1 + fi + case "$value" in + *.|*' ') echo "::error::app_name must not end in a dot or space"; exit 1 ;; + esac + if is_windows_reserved_device_name "$value"; then + echo "::error::app_name uses a reserved Windows device name" + exit 1 + fi + } + is_windows_reserved_device_name() { + local value="$1" base + base="${value%%.*}" + while [[ "$base" == *" " || "$base" == *"." ]]; do + base="${base%?}" + done + case "${base^^}" in + CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9]) return 0 ;; + *) return 1 ;; + esac + } + write_github_env() { + local name="$1" value="$2" + reject_control_chars "$name" "$value" + printf '%s=%s\n' "$name" "$value" >> "$GITHUB_ENV" + } + if [ -n "$RQS_KEY" ]; then + RQS_KEY=$(normalize_public_key "$RQS_KEY") + fi + for field_value in \ + "RQS_SERVER=$RQS_SERVER" \ + "RQS_KEY=$RQS_KEY" \ + "RQS_CUSTOM_TXT=$RQS_CT" \ + "RQS_VERSION=$RQS_VERSION" \ + "RQS_SOURCE_SHA=$RQS_SOURCE_SHA" \ + "RQS_WORKFLOW_REPO=$RQS_WORKFLOW_REPO" \ + "RQS_RELEASE_REPO=$RQS_RELEASE_REPO" \ + "RQS_RELEASE_ASSETS=$RQS_RELEASE_ASSETS"; do + field_name=${field_value%%=*} + field_value=${field_value#*=} + reject_control_chars "$field_name" "$field_value" + done + validate_app_name "$RQS_APP" + if [ -n "$RQS_KEY" ]; then + validate_public_key "$RQS_KEY" + fi + if [ -n "$RQS_VERSION" ]; then + validate_version "$RQS_VERSION" + fi + + if ! printf '%s' "$decrypted" | jq -e '.source_sha | type == "string"' >/dev/null; then + echo "::error::encrypted payload source_sha must be a string" + exit 1 + fi + if [ -z "$RQS_SOURCE_SHA" ]; then + echo "::error::encrypted payload is missing source_sha" + exit 1 + fi + if ! printf '%s' "$RQS_SOURCE_SHA" | grep -Eq '^[0-9a-fA-F]{40,64}$'; then + echo "::error::encrypted payload source_sha must be a hexadecimal commit SHA" + exit 1 + fi + if [[ ! "$RQS_RELEASE_REPO" =~ ^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$ ]]; then + echo "::error::encrypted payload release_repo is missing or malformed" + exit 1 + fi + release_owner=${RQS_RELEASE_REPO%%/*} + release_name=${RQS_RELEASE_REPO#*/} + if [ "$release_owner" = "." ] || [ "$release_owner" = ".." ] || [ "$release_name" = "." ] || [ "$release_name" = ".." ]; then + echo "::error::encrypted payload release_repo contains an invalid path segment" + exit 1 + fi + if [ -z "$RQS_RELEASE_ASSETS" ] || ! printf '%s\n' "$RQS_RELEASE_ASSETS" | jq -e --argjson required '["windows-x64-release.zip", "usbmmidd_v2.zip", "rustdesk_printer_driver_v4-1.4.zip", "printer_driver_adapter.zip"]' ' + type == "array" and length == ($required | length) + and ([.[].name] | sort) == ($required | sort) + and ([.[].id] | unique | length) == ([.[].id] | length) + and all(.[]; (.id | type == "number") and (.id == (.id | floor)) and (.id > 0) + and (.name | type == "string") + and (.digest | type == "string" and test("(?i)^sha256:[0-9a-f]{64}$"))) + ' >/dev/null; then + echo "::error::encrypted payload release asset metadata is missing or malformed" + exit 1 fi # Маскируем чувствительные значения в логах ДО экспорта в $GITHUB_ENV. @@ -130,13 +289,36 @@ jobs: if [ -n "$v" ]; then echo "::add-mask::$v"; fi done - { - echo "RQS_SERVER=$RQS_SERVER" - echo "RQS_KEY=$RQS_KEY" - echo "RQS_APP_NAME=$RQS_APP" - echo "RQS_CUSTOM_TXT=$RQS_CT" - echo "RQS_VERSION=$RQS_VERSION" - } >> "$GITHUB_ENV" + write_github_env RQS_PAYLOAD_MODE "$RQS_PAYLOAD_MODE" + write_github_env RQS_SERVER "$RQS_SERVER" + write_github_env RQS_KEY "$RQS_KEY" + write_github_env RQS_APP_NAME "$RQS_APP" + # Keep password-bearing custom_.txt content out of GITHUB_ENV. The + # exact source_sha checkout/integrity check below is the trusted-source + # gate; this restrictive path is shared only to the packaging step. + if [ -n "$RQS_CT" ]; then + custom_txt_file="${RUNNER_TEMP}/rustdesk-custom-${GITHUB_RUN_ID:-local}.txt" + if command -v cygpath >/dev/null 2>&1 && [[ "$custom_txt_file" == *:* ]]; then + custom_txt_file=$(cygpath -u "$custom_txt_file") + fi + cleanup_custom_txt_on_failure() { + status=$? + if [ "$status" -ne 0 ]; then rm -f -- "$custom_txt_file"; fi + trap - EXIT + exit "$status" + } + trap cleanup_custom_txt_on_failure EXIT + (umask 077; printf '%s' "$RQS_CT" > "$custom_txt_file") + chmod 600 "$custom_txt_file" + write_github_env RQS_CUSTOM_TXT_FILE "$custom_txt_file" + else + write_github_env RQS_CUSTOM_TXT_FILE "" + fi + write_github_env RQS_VERSION "$RQS_VERSION" + write_github_env RQS_SOURCE_SHA "$RQS_SOURCE_SHA" + write_github_env RQS_WORKFLOW_REPO "$RQS_WORKFLOW_REPO" + write_github_env RQS_RELEASE_REPO "$RQS_RELEASE_REPO" + write_github_env RQS_RELEASE_ASSETS "$RQS_RELEASE_ASSETS" # сводка (без значений): что задано, что пусто echo "config: server=$([ -n "$RQS_SERVER" ] && echo SET || echo empty)" @@ -144,13 +326,74 @@ jobs: echo "config: app_name=$([ -n "$RQS_APP" ] && echo SET || echo empty)" echo "config: custom_txt=$([ -n "$RQS_CT" ] && echo SET || echo empty)" echo "config: version=$([ -n "$RQS_VERSION" ] && echo "$RQS_VERSION" || echo empty)" + echo "config: source_sha=SET" + echo "config: release_repo=$([ -n "$RQS_RELEASE_REPO" ] && echo SET || echo empty)" + + - name: Checkout source code + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.sha }} + submodules: recursive + persist-credentials: false + + - name: Preserve workflow manifest helper + shell: bash + run: | + set -euo pipefail + source_helper=.github/scripts/write_artifact_manifest.py + helper_path="${RUNNER_TEMP}/deskforge-write_artifact_manifest.py" + if command -v cygpath >/dev/null 2>&1 && [[ "$helper_path" == *:* ]]; then + helper_path=$(cygpath -u "$helper_path") + fi + test -f "$source_helper" || { echo "::error::workflow-owned manifest helper is missing"; exit 1; } + cp -- "$source_helper" "$helper_path" + chmod 700 "$helper_path" + printf 'MANIFEST_HELPER_PATH=%s\n' "$helper_path" >> "$GITHUB_ENV" + + - name: Checkout source commit + shell: bash + run: | + set -euo pipefail + if [ -z "${RQS_SOURCE_SHA:-}" ]; then + echo "::error::authenticated payload source_sha is required" + exit 1 + fi + if ! printf '%s' "$RQS_SOURCE_SHA" | grep -Eq '^[0-9a-fA-F]{40,64}$'; then + echo "::error::source_sha must be a hexadecimal commit SHA" + exit 1 + fi + GIT_CONFIG_COUNT=1 \ + GIT_CONFIG_KEY_0=http.extraheader \ + GIT_CONFIG_VALUE_0="Authorization: Bearer ${{ github.token }}" \ + git fetch --no-tags --depth=1 origin "$RQS_SOURCE_SHA" + git checkout --detach "$RQS_SOURCE_SHA" + git submodule sync --recursive + GIT_CONFIG_COUNT=1 \ + GIT_CONFIG_KEY_0=http.extraheader \ + GIT_CONFIG_VALUE_0="Authorization: Bearer ${{ github.token }}" \ + git submodule update --init --recursive + expected=$(printf '%s' "$RQS_SOURCE_SHA" | tr '[:upper:]' '[:lower:]') + actual=$(git rev-parse HEAD | tr '[:upper:]' '[:lower:]') + [ "$actual" = "$expected" ] || { echo "::error::source checkout SHA mismatch"; exit 1; } + + - name: Set deterministic source timestamp + shell: bash + run: | + set -euo pipefail + SOURCE_DATE_EPOCH=$(git show -s --format=%ct HEAD) + [[ "$SOURCE_DATE_EPOCH" =~ ^[0-9]+$ ]] || { echo "::error::invalid commit timestamp"; exit 1; } + printf 'SOURCE_DATE_EPOCH=%s\n' "$SOURCE_DATE_EPOCH" >> "$GITHUB_ENV" # Override VERSION from encrypted payload (takes precedence over workflow-level default). - name: 'Override VERSION from dispatch payload' if: env.RQS_VERSION != '' shell: bash run: | - echo "VERSION=$RQS_VERSION" >> "$GITHUB_ENV" + if [ "${#RQS_VERSION}" -gt 32 ] || [[ ! "$RQS_VERSION" =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-((0|[1-9][0-9]*)|([0-9A-Za-z-]*[A-Za-z-][0-9A-Za-z-]*))(\.((0|[1-9][0-9]*)|([0-9A-Za-z-]*[A-Za-z-][0-9A-Za-z-]*)))*)?$ ]]; then + echo "::error::RQS_VERSION must be a strict semantic version" + exit 1 + fi + printf '%s=%s\n' VERSION "$RQS_VERSION" >> "$GITHUB_ENV" echo "VERSION overridden from dispatch: $RQS_VERSION" # L1: вшить сервер+ключ в config.rs ДО любой сборки. Опционально. @@ -175,7 +418,7 @@ jobs: # L2 step A (PRE-BUILD): allowCustom — снять проверку подписи custom.txt + переименовать на custom_.txt - name: 'L2 patch: allowCustom (remove signature check, custom.txt → custom_.txt)' - if: env.RQS_CUSTOM_TXT != '' + if: env.RQS_CUSTOM_TXT_FILE != '' shell: bash run: | set -eu @@ -219,27 +462,127 @@ jobs: echo "L3: brand → ${APP} (BINARY_NAME left as 'rustdesk' for packer)" - name: Restore bridge files - uses: actions/download-artifact@v8 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: bridge-artifact - path: ./ + path: ${{ runner.temp }}/deskforge-bridge-artifact + + - name: Verify and restore bridge files + shell: bash + env: + BRIDGE_ARTIFACT_DIR: ${{ runner.temp }}/deskforge-bridge-artifact + BRIDGE_WORKFLOW_SHA: ${{ github.sha }} + BRIDGE_WORKFLOW_REF: ${{ github.ref }} + run: | + set -euo pipefail + test -f "${MANIFEST_HELPER_PATH:-}" || { echo "::error::preserved manifest helper is unavailable"; exit 1; } + python3 "$MANIFEST_HELPER_PATH" \ + --verify-bridge --output "$BRIDGE_ARTIFACT_DIR" \ + --expected-source-sha "$RQS_SOURCE_SHA" \ + --expected-version "$RQS_VERSION" \ + --workflow-sha "$BRIDGE_WORKFLOW_SHA" --workflow-ref "$BRIDGE_WORKFLOW_REF" + bridge_files=( + flutter/ios/Runner/bridge_generated.h + flutter/lib/generated_bridge.dart + flutter/lib/generated_bridge.freezed.dart + flutter/macos/Runner/bridge_generated.h + src/bridge_generated.io.rs + src/bridge_generated.rs + ) + for file in "${bridge_files[@]}"; do + mkdir -p "$(dirname "$file")" + cp -- "$BRIDGE_ARTIFACT_DIR/$file" "$file" + test -f "$file" || { echo "::error::restored bridge file is missing from source path: $file"; exit 1; } + done - name: Install LLVM and Clang - uses: KyleMayes/install-llvm-action@v1 + uses: KyleMayes/install-llvm-action@1a3da29f56261a1e1f937ec88f0856a9b8321d7e # v1 with: version: ${{ env.LLVM_VERSION }} - name: Install flutter - uses: subosito/flutter-action@v2.12.0 + uses: subosito/flutter-action@2783a3f08e1baf891508463f8c6653c258246225 # v2.12.0 with: channel: "stable" flutter-version: ${{ env.FLUTTER_VERSION }} + - name: Verify and download offline release assets + shell: pwsh + env: + GITHUB_TOKEN: ${{ github.token }} + run: | + $ErrorActionPreference = 'Stop' + $requiredNames = @( + 'windows-x64-release.zip', + 'usbmmidd_v2.zip', + 'rustdesk_printer_driver_v4-1.4.zip', + 'printer_driver_adapter.zip' + ) + if ([string]::IsNullOrWhiteSpace($env:RQS_RELEASE_REPO) -or $env:RQS_RELEASE_REPO -notmatch '^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$') { + throw 'provider-derived release repository is missing or malformed' + } + $repoParts = $env:RQS_RELEASE_REPO.Split('/') + if ($repoParts.Count -ne 2 -or $repoParts[0] -in @('.', '..') -or $repoParts[1] -in @('.', '..')) { + throw 'provider-derived release repository contains an invalid path segment' + } + if ([string]::IsNullOrWhiteSpace($env:RQS_RELEASE_ASSETS)) { + throw 'provider-derived release asset metadata is missing' + } + try { + $releaseAssets = @($env:RQS_RELEASE_ASSETS | ConvertFrom-Json) + } catch { + throw "provider-derived release asset metadata is invalid: $($_.Exception.Message)" + } + if ($releaseAssets.Count -ne $requiredNames.Count) { + throw "provider-derived release asset metadata must contain exactly $($requiredNames.Count) assets" + } + $expectedByName = @{} + foreach ($requiredName in $requiredNames) { + $matchingAssets = @($releaseAssets | Where-Object { $_.name -eq $requiredName }) + if ($matchingAssets.Count -ne 1) { + throw "required release asset '$requiredName' is missing or ambiguous" + } + $expected = $matchingAssets[0] + if ([int64]$expected.id -le 0 -or [string]::IsNullOrWhiteSpace([string]$expected.name) -or [string]$expected.digest -notmatch '(?i)^sha256:[0-9a-f]{64}$') { + throw "required release asset '$requiredName' has invalid provider identity" + } + $expectedByName[$requiredName] = $expected + } + + $apiHeaders = @{ + Authorization = "Bearer $env:GITHUB_TOKEN" + Accept = 'application/vnd.github+json' + 'X-GitHub-Api-Version' = '2022-11-28' + } + $downloadHeaders = @{ + Authorization = "Bearer $env:GITHUB_TOKEN" + Accept = 'application/octet-stream' + } + function Download-VerifiedReleaseAsset([string]$name) { + $expected = $expectedByName[$name] + $assetURI = "https://api.github.com/repos/$($env:RQS_RELEASE_REPO)/releases/assets/$([int64]$expected.id)" + $remote = Invoke-RestMethod -UseBasicParsing -Headers $apiHeaders -Uri $assetURI -Method Get -ConnectionTimeoutSeconds 30 -OperationTimeoutSeconds 120 + if ([int64]$remote.id -ne [int64]$expected.id -or [string]$remote.name -cne $name -or [string]$remote.digest -ine [string]$expected.digest) { + throw "provider release asset identity mismatch for '$name'" + } + $path = Join-Path (Get-Location) $name + $null = Invoke-WebRequest -UseBasicParsing -Headers $downloadHeaders -Uri $assetURI -Method Get -OutFile $path -ConnectionTimeoutSeconds 30 -OperationTimeoutSeconds 120 + $actualDigest = (Get-FileHash -Path $path -Algorithm SHA256).Hash.ToLowerInvariant() + $expectedDigest = ([string]$expected.digest).Substring(7).ToLowerInvariant() + if ($actualDigest -cne $expectedDigest) { + throw "SHA-256 mismatch for '$name'" + } + Write-Output "verified release asset: $name" + } + + foreach ($requiredName in $requiredNames) { + Download-VerifiedReleaseAsset $requiredName + } + - name: Replace engine with rustdesk custom flutter engine run: | flutter doctor -v flutter precache --windows - Invoke-WebRequest -Uri https://github.com/bashrusakh/rustdesk/releases/download/offline-assets-${{ env.VERSION }}/windows-x64-release.zip -OutFile windows-x64-release.zip Expand-Archive -Path windows-x64-release.zip -DestinationPath windows-x64-release mv -Force windows-x64-release/* C:/hostedtoolcache/windows/flutter/stable-${{ env.FLUTTER_VERSION }}-x64/bin/cache/artifacts/engine/windows-x64-release/ @@ -251,18 +594,18 @@ jobs: [[ "3.24.5" == ${{env.FLUTTER_VERSION}} ]] && git apply flutter_3.24.4_dropdown_menu_enableFilter.diff - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@v1 + uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # v1 with: toolchain: ${{ env.RUST_VERSION }} targets: x86_64-pc-windows-msvc components: "rustfmt" - - uses: Swatinem/rust-cache@v2 + - uses: Swatinem/rust-cache@63fed3e2fecf6f7b51dc6f043341b79ef82a9ae7 # v2.9.2 with: prefix-key: windows-2022 - name: Setup vcpkg with Github Actions binary cache - uses: lukka/run-vcpkg@v11 + uses: lukka/run-vcpkg@b1a0dd252f06b9e25b3c022a9a03bd7a427fb6a2 # v11.6 with: vcpkgDirectory: C:\vcpkg vcpkgGitCommitId: ${{ env.VCPKG_COMMIT_ID }} @@ -287,39 +630,21 @@ jobs: python3 .\build.py --portable --hwcodec --flutter --vram --skip-portable-pack $RELEASE = "flutter/build/windows/x64/runner/Release" - # usbmmidd_v2 — виртуальный дисплей. Из release ФОРКА (суверенно). → в Release/ - Invoke-WebRequest -Uri https://github.com/bashrusakh/rustdesk/releases/download/offline-assets-${{ env.VERSION }}/usbmmidd_v2.zip -OutFile usbmmidd_v2.zip - Expand-Archive usbmmidd_v2.zip -DestinationPath . + # usbmmidd_v2 — виртуальный дисплей. Из release ФОРКА (суверенно). → в Release/ + Expand-Archive usbmmidd_v2.zip -DestinationPath . Remove-Item -Path usbmmidd_v2\Win32 -Recurse Remove-Item -Path "usbmmidd_v2\deviceinstaller64.exe", "usbmmidd_v2\deviceinstaller.exe", "usbmmidd_v2\usbmmidd.bat" mv -Force .\usbmmidd_v2 "$RELEASE/" - # Printer driver + adapter из release форка. → в Release/ - try { - Invoke-WebRequest -Uri https://github.com/bashrusakh/rustdesk/releases/download/offline-assets-${{ env.VERSION }}/rustdesk_printer_driver_v4-1.4.zip -OutFile rustdesk_printer_driver_v4-1.4.zip - Invoke-WebRequest -Uri https://github.com/bashrusakh/rustdesk/releases/download/offline-assets-${{ env.VERSION }}/printer_driver_adapter.zip -OutFile printer_driver_adapter.zip - Invoke-WebRequest -Uri https://github.com/bashrusakh/rustdesk/releases/download/offline-assets-${{ env.VERSION }}/sha256sums -OutFile sha256sums - - $checksum_driver = (Select-String -Path .\sha256sums -Pattern '^([a-fA-F0-9]{64}) \*rustdesk_printer_driver_v4-1.4\.zip$').Matches.Groups[1].Value - $downloadsum_driver = Get-FileHash -Path rustdesk_printer_driver_v4-1.4.zip -Algorithm SHA256 - $checksum_adapter = (Select-String -Path .\sha256sums -Pattern '^([a-fA-F0-9]{64}) \*printer_driver_adapter\.zip$').Matches.Groups[1].Value - $downloadsum_adapter = Get-FileHash -Path printer_driver_adapter.zip -Algorithm SHA256 - if ($checksum_driver -eq $downloadsum_driver.Hash -and $checksum_adapter -eq $downloadsum_adapter.Hash) { - Write-Output "printer driver+adapter checksums match, extracting" - Expand-Archive rustdesk_printer_driver_v4-1.4.zip -DestinationPath . - mkdir "$RELEASE/drivers" - mv -Force .\rustdesk_printer_driver_v4-1.4 "$RELEASE/drivers/RustDeskPrinterDriver" - Expand-Archive printer_driver_adapter.zip -DestinationPath . - mv -Force .\printer_driver_adapter.dll "$RELEASE/" - } else { - Write-Output "checksum mismatch — ignoring printer files" - } - } catch { - Write-Host "Ignore the printer driver error." - } + # Printer driver + adapter из release форка. → в Release/ + Expand-Archive rustdesk_printer_driver_v4-1.4.zip -DestinationPath . + mkdir "$RELEASE/drivers" + mv -Force .\rustdesk_printer_driver_v4-1.4 "$RELEASE/drivers/RustDeskPrinterDriver" + Expand-Archive printer_driver_adapter.zip -DestinationPath . + mv -Force .\printer_driver_adapter.dll "$RELEASE/" - name: Download RustDeskTempTopMostWindow artifacts (→ Release/) - uses: actions/download-artifact@v8 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: topmostwindow-artifacts-x64 path: "./flutter/build/windows/x64/runner/Release" @@ -328,48 +653,74 @@ jobs: # попал ВНУТРЬ single-exe. После self-extract клиента, custom_.txt будет лежать # рядом с rustdesk.exe в TEMP-папке (там его и читает read_custom_client). - name: 'L2 payload: place custom_.txt into Release/ (will be packed inside exe)' - if: env.RQS_CUSTOM_TXT != '' + if: env.RQS_CUSTOM_TXT_FILE != '' shell: bash run: | set -eu dst=flutter/build/windows/x64/runner/Release/custom_.txt - printf '%s' "$RQS_CUSTOM_TXT" > "$dst" + trap 'rm -f -- "$RQS_CUSTOM_TXT_FILE"' EXIT + cp -- "$RQS_CUSTOM_TXT_FILE" "$dst" ls -la "$dst" echo "L2: custom_.txt placed inside Release/ (size $(wc -c < "$dst"))" # L4: portable-pack (single self-extracting exe). # generate.py сжимает ВСЁ из -f папки в data.bin → embedded в Rust packer-binary. - # Финал: target/release/rustdesk-portable-packer.exe (~30 MB). Переименуем в + # Финал: libs/portable/target/release/rustdesk-portable-packer.exe (~30 MB). Переименуем в # {appname}.exe и положим в ./output/ для upload-artifact. - name: 'L4 portable-pack: build single-binary self-extracting exe' shell: bash run: | set -eu - APP="${RQS_APP_NAME:-rustdesk}" + APP="$RQS_APP_NAME" ls flutter/build/windows/x64/runner/Release/ | head pushd libs/portable - pip3 install -r requirements.txt + pip3 install --require-hashes -r requirements.txt # тот же CLI что вызывает build.py (когда --portable БЕЗ --skip-portable-pack). # generate.py сам в конце вызывает `cargo build --locked --release` для # portable-packer Rust-проекта (см. build_portable() в generate.py). - python3 ./generate.py \ - -f ../../flutter/build/windows/x64/runner/Release/ \ - -o . \ - -e ../../flutter/build/windows/x64/runner/Release/rustdesk.exe - popd - mkdir -p ./output - src="./target/release/rustdesk-portable-packer.exe" - if [ ! -f "$src" ]; then - # Cargo мог быть в libs/portable/target — fallback - src="./libs/portable/target/release/rustdesk-portable-packer.exe" + python3 ./generate.py \ + -f ../../flutter/build/windows/x64/runner/Release/ \ + -o . \ + -e ../../flutter/build/windows/x64/runner/Release/rustdesk.exe + popd + mkdir -p ./output + src="./libs/portable/target/release/rustdesk-portable-packer.exe" + test -f "$src" || { echo "FAIL: expected portable-packer.exe not found at $src"; exit 1; } + cp "$src" "./output/${APP}.exe" + ls -lh "./output/" + echo "L4: ${APP}.exe size = $(stat -c%s "./output/${APP}.exe" 2>/dev/null || wc -c < "./output/${APP}.exe")" + + - name: Assert exact output and write artifact manifest + shell: bash + env: + MANIFEST_PLATFORM: windows + MANIFEST_WORKFLOW_SHA: ${{ github.sha }} + MANIFEST_WORKFLOW_REF: ${{ github.ref }} + run: | + set -euo pipefail + MANIFEST_PUBLICATION_TIMESTAMP=$(date -u '+%Y-%m-%dT%H:%M:%SZ') + export MANIFEST_PUBLICATION_TIMESTAMP + if [ -f flutter/build/windows/x64/runner/Release/custom_.txt ]; then + cp -- flutter/build/windows/x64/runner/Release/custom_.txt output/custom_.txt fi - ls -lh "$src" || { echo "FAIL: portable-packer.exe not found"; find . -name "rustdesk-portable-packer.exe" 2>/dev/null; exit 1; } - cp "$src" "./output/${APP}.exe" - ls -lh "./output/" - echo "L4: ${APP}.exe size = $(stat -c%s "./output/${APP}.exe" 2>/dev/null || wc -c < "./output/${APP}.exe")" + test -f "${MANIFEST_HELPER_PATH:-}" || { echo "::error::preserved manifest helper is unavailable"; exit 1; } + python3 "$MANIFEST_HELPER_PATH" \ + --platform "$MANIFEST_PLATFORM" --app-name "$RQS_APP_NAME" --version "$RQS_VERSION" \ + --output output --workflow-sha "$MANIFEST_WORKFLOW_SHA" --workflow-ref "$MANIFEST_WORKFLOW_REF" - name: Upload unsigned - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: rustdesk-min-test-windows path: output + retention-days: 7 + if-no-files-found: error + + - name: Cleanup sensitive custom_.txt + if: always() + shell: bash + run: | + set +e + if [ -n "${RQS_CUSTOM_TXT_FILE:-}" ]; then + rm -f -- "$RQS_CUSTOM_TXT_FILE" + fi diff --git a/.github/workflows/third-party-RustDeskTempTopMostWindow.yml b/.github/workflows/third-party-RustDeskTempTopMostWindow.yml index 4f79e7b1c50..f79a7770b13 100644 --- a/.github/workflows/third-party-RustDeskTempTopMostWindow.yml +++ b/.github/workflows/third-party-RustDeskTempTopMostWindow.yml @@ -1,4 +1,4 @@ -name: build RustDeskTempTopMostWindow +name: build RustDeskTempTopMostWindow on: workflow_call: @@ -27,6 +27,8 @@ on: type: string default: 'Windows10' +permissions: {} + env: project_path: WindowInjection/WindowInjection.vcxproj @@ -54,6 +56,8 @@ jobs: uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 if: ${{ inputs.upload-artifact }} with: - name: topmostwindow-artifacts-${{ inputs.platform }} - path: | - ./${{ env.build_output_dir }}/WindowInjection.dll + name: topmostwindow-artifacts-${{ inputs.platform }} + path: | + ./${{ env.build_output_dir }}/WindowInjection.dll + retention-days: 7 + if-no-files-found: error diff --git a/README.md b/README.md index ae5c8d37caf..b2541182669 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ BuildDockerStructure • - Snapshot
+ Snapshot
[Українська] | [česky] | [中文] | [Magyar] | [Español] | [فارسی] | [Français] | [Deutsch] | [Polski] | [Indonesian] | [Suomi] | [മലയാളം] | [日本語] | [Nederlands] | [Italiano] | [Русский] | [Português (Brasil)] | [Esperanto] | [한국어] | [العربي] | [Tiếng Việt] | [Dansk] | [Ελληνικά] | [Türkçe] | [Norsk] | [Română]
We need your help to translate this README, RustDesk UI and RustDesk Doc to your native language

@@ -38,7 +38,7 @@ RustDesk welcomes contribution from everyone. See [CONTRIBUTING.md](docs/CONTRIB ## Dependencies -Desktop versions use Flutter or Sciter (deprecated) for GUI, this tutorial is for Sciter only, since it is easier and more friendly to start. Check out our [CI](https://github.com/rustdesk/rustdesk/blob/master/.github/workflows/flutter-build.yml) for building Flutter version. +Desktop versions use Flutter or Sciter (deprecated) for GUI, this tutorial is for Sciter only, since it is easier and more friendly to start. For this fork's current client-build reference, see the [fork-specific Windows workflow](.github/workflows/rustqs-windows-min-test.yml). The workflow file is a source reference; it is not a release or support claim. Please download Sciter dynamic library yourself. @@ -179,4 +179,3 @@ Please ensure that you run these commands from the root of the RustDesk reposito ![File Transfer](https://github.com/rustdesk/rustdesk/assets/28412477/39511ad3-aa9a-4f8c-8947-1cce286a46ad) ![TCP Tunneling](https://github.com/rustdesk/rustdesk/assets/28412477/78e8708f-e87e-4570-8373-1360033ea6c5) - diff --git a/build.py b/build.py index 9579618574f..4935529f605 100755 --- a/build.py +++ b/build.py @@ -316,12 +316,26 @@ def ffi_bindgen_function_refactor(): 'sed -i "s/ffi.NativeFunctionBuild • DockerStructure • - Snapshot
+ Snapshot
[English] | [Українська] | [česky] | [中文] | [Magyar] | [Español] | [فارسی] | [Français] | [Deutsch] | [Polski] | [Indonesian] | [Suomi] | [മലയാളം] | [日本語] | [Nederlands] | [Italiano] | [Русский] | [Português (Brasil)] | [Esperanto] | [한국어] | [Tiếng Việt] | [Ελληνικά]
لغتك الأم, Doc و RustDesk UI, README نحن بحاجة إلى مساعدتك لترجمة هذا

@@ -162,6 +162,7 @@ RustDesk يرجى التأكد من أنك تنفذ هذه الأوامر من - **[flutter](https://github.com/rustdesk/rustdesk/tree/master/flutter)**: رمز الهاتف المحمول - **[flutter/web/js](https://github.com/rustdesk/rustdesk/tree/master/flutter/web/js)**:Flutter لعميل الويب الخاص ب Javascript + ## لقطات ![image](https://user-images.githubusercontent.com/71636191/113112362-ae4deb80-923b-11eb-957d-ff88daad4f06.png) diff --git a/docs/README-CS.md b/docs/README-CS.md index b208414fefe..8ae66d3648a 100644 --- a/docs/README-CS.md +++ b/docs/README-CS.md @@ -4,7 +4,7 @@ Sestavení ze zdrojových kódůDockerStruktura • - Ukázky
+ Ukázky
[English] | [Українська] | [中文] | [Magyar] | [Español] | [فارسی] | [Français] | [Deutsch] | [Polski] | [Indonesian] | [Suomi] | [മലയാളം] | [日本語] | [Nederlands] | [Italiano] | [Русский] | [Português (Brasil)] | [Esperanto] | [한국어] | [العربي] | [Tiếng Việt] | [Ελληνικά]
Potřebujeme Vaši pomoc s překladem tohoto README, uživatelského rozhraní aplikace RustDesk a dokumentace k ní do vašeho jazyka

diff --git a/docs/README-DE.md b/docs/README-DE.md index ba8894411f9..d6074df7b8a 100644 --- a/docs/README-DE.md +++ b/docs/README-DE.md @@ -4,7 +4,7 @@ DockerDateistrukturScreenshots
- [Українська] | [česky] | [中文] | [Magyar] | [Español] | [فارسی] | [Français] | [Deutsch] | [Polski] | [Indonesian] | [Suomi] | [മലയാളം] | [日本語] | [Nederlands] | [Italiano] | [Русский] | [Português (Brasil)] | [Esperanto] | [한국어] | [العربي] | [Tiếng Việt] | [Dansk] | [Ελληνικά] | [Türkçe] | [Norsk] | [Română]
+ [Українська] | [česky] | [中文] | [Magyar] | [Español] | [فارسی] | [Français] | [Deutsch] | [Polski] | [Indonesian] | [Suomi] | [മലയാളം] | [日本語] | [Nederlands] | [Italiano] | [Русский] | [Português (Brasil)] | [Esperanto] | [한국어] | [العربي] | [Tiếng Việt] | [Dansk] | [Ελληνικά] | [Türkçe] | [Norsk] | [Română]
Wir brauchen Ihre Hilfe, um dieses README, die RustDesk-Benutzeroberfläche und die Dokumentation in Ihre Muttersprache zu übersetzen.

@@ -179,4 +179,3 @@ Bitte stellen Sie sicher, dass Sie diese Befehle im Stammverzeichnis des RustDes ![Dateiübertragung](https://github.com/rustdesk/rustdesk/assets/28412477/39511ad3-aa9a-4f8c-8947-1cce286a46ad) ![TCP-Tunneling](https://github.com/rustdesk/rustdesk/assets/28412477/78e8708f-e87e-4570-8373-1360033ea6c5) - diff --git a/docs/README-ES.md b/docs/README-ES.md index da939bd7b85..f46f64b042a 100644 --- a/docs/README-ES.md +++ b/docs/README-ES.md @@ -36,7 +36,7 @@ RustDesk agradece la contribución de todo el mundo. Lee [`docs/CONTRIBUTING.md` ## Dependencias -Las versiones de escritorio utilizan Flutter o Sciter (obsoleto) para GUI, este tutorial es sólo para Sciter, ya que es más fácil y más amigable para empezar. Echa un vistazo a nuestro [CI](https://github.com/rustdesk/rustdesk/blob/master/.github/workflows/flutter-build.yml) para la construcción de la versión Flutter. +Las versiones de escritorio utilizan Flutter o Sciter (obsoleto) para GUI, este tutorial es sólo para Sciter, ya que es más fácil y más amigable para empezar. Consulta el [workflow de Windows específico del fork](../.github/workflows/rustqs-windows-min-test.yml) como referencia actual de compilación; el archivo es solo una referencia al código fuente, no una declaración de lanzamiento o soporte. Por favor descarga la librería dinámica de Sciter tú mismo. diff --git a/docs/README-FI.md b/docs/README-FI.md index 4c167978cde..abb2701c997 100644 --- a/docs/README-FI.md +++ b/docs/README-FI.md @@ -4,7 +4,7 @@ RakennaDockerRakenne • - Tilannevedos
+ Kuvakaappaukset
[English] | [Українська] | [česky] | [中文] | [Magyar] | [Español] | [فارسی] | [Français] | [Deutsch] | [Polski] | [Indonesian] | [മലയാളം] | [日本語] | [Nederlands] | [Italiano] | [Русский] | [Português (Brasil)] | [Esperanto] | [한국어] | [العربي] | [Tiếng Việt] | [Ελληνικά]
Tarvitsemme apua tämän README-tiedoston kääntämiseksi äidinkielellesi

@@ -137,6 +137,7 @@ Varmista, että suoritat näitä komentoja RustDesktop-tietovaraston juurihakemi - **[src/rendezvous_mediator.rs](https://github.com/rustdesk/rustdesk/tree/master/src/rendezvous_mediator.rs)**: Communicate with [rustdesk-server](https://github.com/rustdesk/rustdesk-server), wait for remote direct (TCP hole punching) or relayed connection - **[src/platform](https://github.com/rustdesk/rustdesk/tree/master/src/platform)**: platform specific code + ## Tilannekuvat ![image](https://user-images.githubusercontent.com/71636191/113112362-ae4deb80-923b-11eb-957d-ff88daad4f06.png) diff --git a/docs/README-ID.md b/docs/README-ID.md index 7b63d0e7eea..24b9a637fb6 100644 --- a/docs/README-ID.md +++ b/docs/README-ID.md @@ -4,7 +4,7 @@ BuildDockerStructure • - Snapshot
+ Snapshot
[English] | [Українська] | [česky] | [中文] | [Magyar] | [Español] | [فارسی] | [Français] | [Deutsch] | [Polski] | [Suomi] | [മലയാളം] | [日本語] | [Nederlands] | [Italiano] | [Русский] | [Português (Brasil)] | [Esperanto] | [한국어] | [العربي] | [Tiếng Việt] | [Ελληνικά]
Kami membutuhkan bantuanmu untuk menterjemahkan file README dan RustDesk UI ke Bahasa Indonesia

@@ -155,6 +155,7 @@ Harap pastikan bahwa kamu menjalankan perintah ini dari repositori root RustDesk - **[src/rendezvous_mediator.rs](https://github.com/rustdesk/rustdesk/tree/master/src/rendezvous_mediator.rs)**: Komunikasi dengan [rustdesk-server](https://github.com/rustdesk/rustdesk-server), menunggu untuk remote direct (TCP hole punching) atau relayed connection - **[src/platform](https://github.com/rustdesk/rustdesk/tree/master/src/platform)**: kode khusus platform + ## Snapshots ![image](https://user-images.githubusercontent.com/71636191/113112362-ae4deb80-923b-11eb-957d-ff88daad4f06.png) diff --git a/docs/README-IT.md b/docs/README-IT.md index 0393ee6c7a4..7535c041dea 100644 --- a/docs/README-IT.md +++ b/docs/README-IT.md @@ -33,7 +33,7 @@ RustDesk accoglie il contributo di tutti. Per ulteriori informazioni su come ini ## Dipendenze -Le versioni desktop utilizzano Flutter o Sciter (deprecato) per l'interfaccia utente, questo tutorial è solo per Sciter, poiché è più facile per iniziare. Controlla il nostro [CI](https://github.com/rustdesk/rustdesk/blob/master/.github/workflows/flutter-build.yml) per la compilazione della versione Flutter. +Le versioni desktop utilizzano Flutter o Sciter (deprecato) per l'interfaccia utente, questo tutorial è solo per Sciter, poiché è più facile per iniziare. Per il riferimento attuale alla compilazione del client in questo fork, consulta il [workflow Windows](../.github/workflows/rustqs-windows-min-test.yml); il file è solo un riferimento al codice sorgente, non una dichiarazione di rilascio o supporto. Scarica la libreria dinamica Sciter. diff --git a/docs/README-JP.md b/docs/README-JP.md index c9f75640b0c..72ef62c17b8 100644 --- a/docs/README-JP.md +++ b/docs/README-JP.md @@ -4,7 +4,7 @@ BuildDockerStructure • - Snapshot
+ Snapshot
[Українська] | [česky] | [中文] | [Magyar] | [Español] | [فارسی] | [Français] | [Deutsch] | [Polski] | [Indonesian] | [Suomi] | [മലയാളം] | [日本語] | [Nederlands] | [Italiano] | [Русский] | [Português (Brasil)] | [Esperanto] | [한국어] | [العربي] | [Tiếng Việt] | [Dansk] | [Ελληνικά] | [Türkçe]
READMEやRustDesk UIRustDesk Docの翻訳者を歓迎します!

@@ -32,7 +32,7 @@ RustDeskは皆さんの貢献を歓迎します。 ## 依存関係 -デスクトップ版ではGUIにFlutterまたはSciter(非推奨)を使用しますが、チュートリアルでは分かりやすく、簡単なSciterのみを対象に解説しています。Flutterでのビルド方法については[CI](https://github.com/rustdesk/rustdesk/blob/master/.github/workflows/flutter-build.yml)をご覧ください。 +デスクトップ版ではGUIにFlutterまたはSciter(非推奨)を使用しますが、チュートリアルでは分かりやすく、簡単なSciterのみを対象に解説しています。この fork の現在のクライアントビルド参照は [Windows workflow](../.github/workflows/rustqs-windows-min-test.yml) です。これはソース参照であり、リリースやサポートの主張ではありません。 Sciter dynamic libraryを事前にダウンロードしてください。 @@ -172,6 +172,7 @@ target/release/rustdesk > **:不正使用に関する免責事項**
> RustDeskの開発者は、このソフトウェアの非倫理的または違法な使用を容認または支持しません。不正アクセス、不正な制御、またはプライバシーの侵害などの不正使用は、当社のガイドラインに厳密に違反します。開発者は、アプリケーションの不正使用に対して一切の責任を負いません。 + ## スクリーンショット ![Connection Manager](https://github.com/rustdesk/rustdesk/assets/28412477/db82d4e7-c4bc-4823-8e6f-6af7eadf7651) diff --git a/docs/README-KR.md b/docs/README-KR.md index d7d3cf43e42..907df73065c 100644 --- a/docs/README-KR.md +++ b/docs/README-KR.md @@ -38,7 +38,7 @@ RustDesk는 모든 분들의 기여를 환영합니다. 시작하는 데 도움 ## 종속성 -데스크톱 버전은 GUI로 Flutter 또는 Sciter (더 이상 지원되지 않음)를 사용하며, 이 자습서는 시작하기 더 쉽고 친숙한 Sciter 전용입니다. Flutter 버전 빌드는 [CI](https://github.com/rustdesk/rustdesk/blob/master/.github/workflows/flutter-build.yml)을 확인하세요. +데스크톱 버전은 GUI로 Flutter 또는 Sciter (더 이상 지원되지 않음)를 사용하며, 이 자습서는 시작하기 더 쉽고 친숙한 Sciter 전용입니다. 이 fork의 현재 클라이언트 빌드 참조는 [Windows workflow](../.github/workflows/rustqs-windows-min-test.yml)입니다. 이 파일은 소스 참조일 뿐 릴리스 또는 지원을 의미하지 않습니다. Sciter 동적 라이브러리를 직접 다운로드하세요. @@ -179,4 +179,3 @@ RustDesk 리포지토리의 루트에서 이러한 명령을 실행하고 있는 ![File Transfer](https://github.com/rustdesk/rustdesk/assets/28412477/39511ad3-aa9a-4f8c-8947-1cce286a46ad) ![TCP Tunneling](https://github.com/rustdesk/rustdesk/assets/28412477/78e8708f-e87e-4570-8373-1360033ea6c5) - diff --git a/docs/README-ML.md b/docs/README-ML.md index 225d7b952f9..c352eff7f2f 100644 --- a/docs/README-ML.md +++ b/docs/README-ML.md @@ -4,7 +4,7 @@ BuildDockerStructure • - Snapshot
+ Snapshot
[English] | [Українська] | [česky] | [中文] | [Magyar] | [Español] | [فارسی] | [Français] | [Deutsch] | [Polski] | [Indonesian] | [Suomi] | [日本語] | [Nederlands] | [Italiano] | [Русский] | [Português (Brasil)] | [Esperanto] | [한국어] | [العربي] | [Tiếng Việt] | [Ελληνικά]
ഈ README നിങ്ങളുടെ മാതൃഭാഷയിലേക്ക് വിവർത്തനം ചെയ്യാൻ ഞങ്ങൾക്ക് നിങ്ങളുടെ സഹായം ആവശ്യമാണ്

@@ -137,6 +137,7 @@ RustDesk റിപ്പോസിറ്ററിയുടെ റൂട്ടി - **[src/rendezvous_mediator.rs](https://github.com/rustdesk/rustdesk/tree/master/src/rendezvous_mediator.rs)**: Communicate with [rustdesk-server](https://github.com/rustdesk/rustdesk-server), wait for remote direct (TCP hole punching) or relayed connection - **[src/platform](https://github.com/rustdesk/rustdesk/tree/master/src/platform)**: platform specific code + ## സ്നാപ്പ്ഷോട്ടുകൾ ![image](https://user-images.githubusercontent.com/71636191/113112362-ae4deb80-923b-11eb-957d-ff88daad4f06.png) diff --git a/docs/README-NL.md b/docs/README-NL.md index 45d68b20ed1..d7d67f426cd 100644 --- a/docs/README-NL.md +++ b/docs/README-NL.md @@ -4,7 +4,7 @@ BouwenDockerStructuur • - Snapshot
+ Snapshot
[English] | [Українська] | [česky] | [中文] | [Magyar] | [Español] | [فارسی] | [Français] | [Deutsch] | [Polski] | [Indonesian] | [Suomi] | [മലയാളം] | [日本語] | [Italiano] | [Русский] | [Português (Brasil)] | [Esperanto] | [한국어] | [العربي] | [Tiếng Việt] | [Ελληνικά]
Wij hebben uw hulp nodig om dit README bestand te vertalen, RustDesk UI en Doc naar uw moedertaal

@@ -157,6 +157,7 @@ Zorg ervoor dat je deze commando's van de root van de RustDesk-repository uitvoe - **[src/rendezvous_mediator.rs](https://github.com/rustdesk/rustdesk/tree/master/src/rendezvous_mediator.rs)**: Communicatie met [rustdesk-server](https://github.com/rustdesk/rustdesk-server), afwachten van redirect op afstand (TCP hole punching) of een relayed verbinding - **[src/platform](https://github.com/rustdesk/rustdesk/tree/master/src/platform)**: platformspecifieke code + ## Snapshot ![image](https://user-images.githubusercontent.com/71636191/113112362-ae4deb80-923b-11eb-957d-ff88daad4f06.png) diff --git a/docs/README-NO.md b/docs/README-NO.md index 1352e8aedda..0744f2e78c6 100644 --- a/docs/README-NO.md +++ b/docs/README-NO.md @@ -4,8 +4,8 @@ BuildDockerStruktur • - Snapshot
- [Українська] | [česky] | [中文] | [Magyar] | [Español] | [فارسی] | [Français] | [Deutsch] | [Polski] | [Indonesian] | [Suomi] | [മലയാളം] | [日本語] | [Nederlands] | [Italiano] | [Русский] | [Português (Brasil)] | [Esperanto] | [한국어] | [العربي] | [Tiếng Việt] | [Dansk] | [Ελληνικά] | [Türkçe] | [Norsk
+ Snapshot
+ [Українська] | [česky] | [中文] | [Magyar] | [Español] | [فارسی] | [Français] | [Deutsch] | [Polski] | [Indonesian] | [Suomi] | [മലയാളം] | [日本語] | [Nederlands] | [Italiano] | [Русский] | [Português (Brasil)] | [Esperanto] | [한국어] | [العربي] | [Tiếng Việt] | [Dansk] | [Ελληνικά] | [Türkçe] | [Norsk]
Vi trenger din hjelp til å oversette denne README-en, RustDesk UI og RustDesk Doc tid ditt morsmål

@@ -34,7 +34,7 @@ RustDesk er velkommen for bidrag fra alle. Se [CONTRIBUTING.md](CONTRIBUTING-NO. ## Avhengigheter -Desktop versjoner bruker Flutter eller Sciter (avviklet) for GUI, denne veiledningen er bare for Sciter, grunnet att det er letter og en mer venlig start. Skjekk ut vår [CI](https://github.com/rustdesk/rustdesk/blob/master/.github/workflows/flutter-build.yml) for bygging av Flutter versjonen. +Desktop versjoner bruker Flutter eller Sciter (avviklet) for GUI, denne veiledningen er bare for Sciter, grunnet at det er lettere og en mer vennlig start. Se forkets [Windows-arbeidsflyt](../.github/workflows/rustqs-windows-min-test.yml) som gjeldende byggereferanse; filen er bare en kildekodereferanse, ikke en lanserings- eller støtteerklæring. Venligst last ned Sciters dynamiske bibliotek selv. @@ -165,6 +165,7 @@ Venligst pass på att du kjører disse kommandoene fra roten av RustDesk reposit - **[flutter](https://github.com/rustdesk/rustdesk/tree/master/flutter)**: Flutter kode for desktop og mobil - **[flutter/web/js](https://github.com/rustdesk/rustdesk/tree/master/flutter/web/js)**: JavaScript for Flutter nettsted klient + ## Skjermbilder ![Tilkoblings Manager](https://github.com/rustdesk/rustdesk/assets/28412477/db82d4e7-c4bc-4823-8e6f-6af7eadf7651) @@ -174,4 +175,3 @@ Venligst pass på att du kjører disse kommandoene fra roten av RustDesk reposit ![Fil Overføring](https://github.com/rustdesk/rustdesk/assets/28412477/39511ad3-aa9a-4f8c-8947-1cce286a46ad) ![TCP Tunneling](https://github.com/rustdesk/rustdesk/assets/28412477/78e8708f-e87e-4570-8373-1360033ea6c5) - diff --git a/docs/README-PTBR.md b/docs/README-PTBR.md index 2b4c1e6c2d0..aa923d6183c 100644 --- a/docs/README-PTBR.md +++ b/docs/README-PTBR.md @@ -4,7 +4,7 @@ DockerEstruturaCapturas de Tela
- [Inglês] | [Ucraniano] | [Tcheco] | [Chinês] | [Húngaro] | [Espanhol] | [Persa] | [Francês] | [Alemão] | [Polonês] | [Indonésio] | [Finlandês] | [Malaiala] | [Japonês] | [Holandês] | [Italiano] | [Russo] | [Esperanto] | [Coreano] | [Árabe] | [Vietnamita] | [Dinamarquês] | [Grego] | [Turco] | [Norueguês] | [Romeno]
+ [Inglês] | [Ucraniano] | [Tcheco] | [Chinês] | [Húngaro] | [Espanhol] | [Persa] | [Francês] | [Alemão] | [Polonês] | [Indonésio] | [Finlandês] | [Malaiala] | [Japonês] | [Holandês] | [Italiano] | [Russo] | [Esperanto] | [Coreano] | [Árabe] | [Vietnamita] | [Dinamarquês] | [Grego] | [Turco] | [Norueguês] | [Romeno]
Precisamos da sua ajuda para traduzir este README, a Interface do RustDesk e a Documentação do RustDesk para o seu idioma nativo

@@ -21,7 +21,7 @@ Mais uma solução de desktop remoto, escrita em Rust. Funciona imediatamente, s ![image](https://user-images.githubusercontent.com/71636191/171661982-430285f0-2e12-4b1d-9957-4a58e375304d.png) -O RustDesk acolhe a contribuição de todos. Veja [CONTRIBUTING.md](docs/CONTRIBUTING.md) para ajuda em como começar. +O RustDesk acolhe a contribuição de todos. Veja [CONTRIBUTING.md](CONTRIBUTING.md) para ajuda em como começar. [**Perguntas Frequentes (FAQ)**](https://github.com/rustdesk/rustdesk/wiki/FAQ) @@ -38,7 +38,7 @@ O RustDesk acolhe a contribuição de todos. Veja [CONTRIBUTING.md](docs/CONTRIB ## Dependências -As versões de desktop usam Flutter ou Sciter (descontinuado) para a interface gráfica (GUI). Este tutorial é apenas para o Sciter, por ser mais fácil e amigável para começar. Verifique nosso [CI](https://github.com/rustdesk/rustdesk/blob/master/.github/workflows/flutter-build.yml) para instruções de compilação da versão em Flutter. +As versões de desktop usam Flutter ou Sciter (descontinuado) para a interface gráfica (GUI). Este tutorial é apenas para o Sciter, por ser mais fácil e amigável para começar. Consulte o [workflow Windows específico do fork](../.github/workflows/rustqs-windows-min-test.yml) como referência atual de compilação; o arquivo é apenas uma referência de código-fonte, não uma declaração de lançamento ou suporte. Por favor, faça o download da biblioteca dinâmica do Sciter por conta própria. diff --git a/docs/README-RO.md b/docs/README-RO.md index be7ecf164cf..4c2d2b40798 100644 --- a/docs/README-RO.md +++ b/docs/README-RO.md @@ -3,7 +3,7 @@ ConstruireDockerStructură • - Capturi
+ Capturi
[Українська] | [česky] | [中文] | [Magyar] | [Español] | [فارسی] | [Français] | [Deutsch] | [Polski] | [Indonesian] | [Suomi] | [മലയാളം] | [日本語] | [Nederlands] | [Italiano] | [Русский] | [Português (Brasil)] | [Esperanto] | [한국어] | [العربي] | [Tiếng Việt] | [Dansk] | [Ελληνικά] | [Türkçe] | [Norsk] | [Română]
Avem nevoie de ajutorul tău pentru a traduce acest README, RustDesk UI și RustDesk Doc în limba ta maternă

@@ -38,7 +38,7 @@ RustDesk primește contribuții de la oricine. Vezi [CONTRIBUTING.md](../docs/CO ## Dependențe -Versiunile desktop folosesc Flutter sau Sciter (depreciat) pentru interfață; acest ghid este pentru Sciter doar, deoarece este mai ușor și mai prietenos pentru început. Vezi [CI](https://github.com/rustdesk/rustdesk/blob/master/.github/workflows/flutter-build.yml) pentru construire cu Flutter. +Versiunile desktop folosesc Flutter sau Sciter (depreciat) pentru interfață; acest ghid este pentru Sciter doar, deoarece este mai ușor și mai prietenos pentru început. Vezi [workflow-ul Windows al fork-ului](../.github/workflows/rustqs-windows-min-test.yml) ca referință actuală pentru compilarea clientului; fișierul este doar o referință la sursă, nu o declarație de lansare sau suport. Te rugăm să descarci singur librăria dinamică Sciter. diff --git a/docs/README-RU.md b/docs/README-RU.md index 928faad07f7..967ec8ad474 100644 --- a/docs/README-RU.md +++ b/docs/README-RU.md @@ -40,9 +40,9 @@ RustDesk приветствует вклад каждого. Ознакомьт ## Зависимости -Для ПК-версии используются библиотеки Flutter или Sciter (устаревшее) для графического интерфейса. Данное руководство подразумевает работу с Sciter, так как он более простой в использовании и с ним легче начать работу. Вы можете также посмотреть на механизм нашего [CI](https://github.com/rustdesk/rustdesk/blob/master/.github/workflows/flutter-build.yml) для сборок на Flutter. +Для ПК-версии используются библиотеки Flutter или Sciter (устаревшее) для графического интерфейса. Данное руководство подразумевает работу с Sciter, так как он более простой в использовании и с ним легче начать работу. Текущий справочник сборки клиента в этом fork — [Windows workflow](../.github/workflows/rustqs-windows-min-test.yml); это ссылка на исходный файл, а не заявление о релизе или поддержке. -Загрузите динамическую библиотеку Flutter самостоятельно. +Загрузите динамическую библиотеку Sciter самостоятельно. [Windows](https://raw.githubusercontent.com/c-smile/sciter-sdk/master/bin.win/x64/sciter.dll) | [Linux](https://raw.githubusercontent.com/c-smile/sciter-sdk/master/bin.lnx/x64/libsciter-gtk.so) | @@ -180,4 +180,4 @@ target/release/rustdesk ![Передача файлов](https://github.com/rustdesk/rustdesk/assets/28412477/39511ad3-aa9a-4f8c-8947-1cce286a46ad) -![TCP-туннелирование](https://github.com/rustdesk/rustdesk/assets/28412477/78e8708f-e87e-4570-8373-1360033ea6c5) \ No newline at end of file +![TCP-туннелирование](https://github.com/rustdesk/rustdesk/assets/28412477/78e8708f-e87e-4570-8373-1360033ea6c5) diff --git a/docs/README-TR.md b/docs/README-TR.md index 99c961e8b21..c009743cc23 100644 --- a/docs/README-TR.md +++ b/docs/README-TR.md @@ -5,8 +5,8 @@ DerlemeDocker ile DerlemeDosya Yapısı • - Ekran Görüntüleri
- [Українська] | [česky] | [中文] | [Magyar] | [Español] | [فارسی] | [Français] | [Deutsch] | [Polski] | [Indonesian] | [Suomi] | [മലയാളം] | [日本語] | [Nederlands] | [Italiano] | [Русский] | [Português (Brasil)] | [Esperanto] | [한국어] | [العربي] | [Tiếng Việt] | [Dansk] | [Ελληνικά]
+ Ekran Görüntüleri
+ [Українська] | [česky] | [中文] | [Magyar] | [Español] | [فارسی] | [Français] | [Deutsch] | [Polski] | [Indonesian] | [Suomi] | [മലയാളം] | [日本語] | [Nederlands] | [Italiano] | [Русский] | [Português (Brasil)] | [Esperanto] | [한국어] | [العربي] | [Tiếng Việt] | [Dansk] | [Ελληνικά]
README, RustDesk UI ve RustDesk Dökümantasyonu'nu ana dilinize çevirmemiz için yardımınıza ihtiyacımız var

@@ -37,7 +37,7 @@ RustDesk, herkesin katkısına açıktır. Başlamak için [CONTRIBUTING.md](CON ## Gereksinimler -Masaüstü sürümleri GUI için; [Sciter](https://sciter.com/)(kaldırılacak) veya Flutter kullanır. Sciter daha kolay ve başlamak için daha dostcanlısı, bundan dolayı bu kılavuz sadece Sciter içindir. Flutter sürümünü derlemek için [CI](https://github.com/rustdesk/rustdesk/blob/master/.github/workflows/flutter-build.yml)'ımıza bakın. +Masaüstü sürümleri GUI için; [Sciter](https://sciter.com/)(kaldırılacak) veya Flutter kullanır. Sciter daha kolay ve başlamak için daha dostcanlısı, bundan dolayı bu kılavuz sadece Sciter içindir. Bu fork'un güncel istemci derleme referansı için [Windows iş akışına](../.github/workflows/rustqs-windows-min-test.yml) bakın; dosya yalnızca kaynak referansıdır, sürüm veya destek iddiası değildir. Lütfen Sciter dinamik kütüphanesini kendiniz indirin. @@ -169,6 +169,7 @@ Lütfen bu komutları RustDesk reposunun root klasöründe çalıştırdığın - **[flutter/web/js](https://github.com/rustdesk/rustdesk/tree/master/flutter/web/v1/js)**: Flutter web istemcisi için JavaScript + ## Ekran Görüntüleri ![image](https://user-images.githubusercontent.com/71636191/113112362-ae4deb80-923b-11eb-957d-ff88daad4f06.png) diff --git a/docs/README-UA.md b/docs/README-UA.md index eb4c9edec04..05a5b52904d 100644 --- a/docs/README-UA.md +++ b/docs/README-UA.md @@ -31,7 +31,7 @@ RustDesk вітає внесок кожного. Ознайомтеся з [CONT ## Залежності -Стільничні версії використовують Flutter чи Sciter (застаріле) для графічного інтерфейсу. Ця інструкція лише для Sciter, оскільки він є більш простим та дружнім для початківців. Перегляньте [CI](https://github.com/rustdesk/rustdesk/blob/master/.github/workflows/flutter-build.yml) для збірки версії на Flutter. +Стільничні версії використовують Flutter чи Sciter (застаріле) для графічного інтерфейсу. Ця інструкція лише для Sciter, оскільки він є більш простим та дружнім для початківців. Перегляньте [Windows workflow цього fork](../.github/workflows/rustqs-windows-min-test.yml) як поточний довідник збірки; це посилання на джерело, а не заява про реліз чи підтримку. Будь ласка, завантажте динамічну бібліотеку Sciter самостійно. @@ -171,4 +171,3 @@ target/release/rustdesk ![Передача файлів](https://github.com/rustdesk/rustdesk/assets/28412477/39511ad3-aa9a-4f8c-8947-1cce286a46ad) ![Тунелювання TCP](https://github.com/rustdesk/rustdesk/assets/28412477/78e8708f-e87e-4570-8373-1360033ea6c5) - diff --git a/docs/README-VN.md b/docs/README-VN.md index 38cdc10fb8e..9b449b2c863 100644 --- a/docs/README-VN.md +++ b/docs/README-VN.md @@ -6,7 +6,7 @@ BuildDockerStructure • - Snapshot
+ Snapshot
[English] | [Українська] | [česky] | [中文] | [Magyar] | [Español] | [فارسی] | [Français] | [Deutsch] | [Polski] | [Indonesian] | [Suomi] | [മലയാളം] | [日本語] | [Nederlands] | [Italiano] | [Русский] | [Português (Brasil)] | [Esperanto] | [한국어] | [العربي] | [Ελληνικά]
Chúng tôi rất hoan nghênh sự hỗ trợ của bạn trong việc dịch trang README, trang giao diện người dùng của RustDesk - RustDesk UI và trang tài liệu của RustDesk - RustDesk Doc sang Tiếng Việt

@@ -31,7 +31,7 @@ RustDesk là một phần mềm điểu khiển máy tính từ xa mã nguồn m ## Dependencies -Phiên bản máy tính sử dụng __Flutter__ hoặc __Sciter__ (đã lỗi thời) cho giao diện người dùng (GUI). Hướng dẫn này chỉ áp dụng cho phiên bản Sciter, vì nó thân thiện và dễ bắt đầu hơn. Hãy kiểm tra [CI](https://github.com/rustdesk/rustdesk/blob/master/.github/workflows/flutter-build.yml) của chúng tôi để xây dựng phiên bản Flutter. +Phiên bản máy tính sử dụng __Flutter__ hoặc __Sciter__ (đã lỗi thời) cho giao diện người dùng (GUI). Hướng dẫn này chỉ áp dụng cho phiên bản Sciter, vì nó thân thiện và dễ bắt đầu hơn. Tham khảo [workflow Windows của fork](../.github/workflows/rustqs-windows-min-test.yml) để xem quy trình xây dựng hiện tại; tệp này chỉ là tham chiếu nguồn, không phải tuyên bố phát hành hay hỗ trợ. Vui lòng tự tải thư viện `Sciter` về máy theo hướng dẫn cho từng hệ điều hành. @@ -150,6 +150,7 @@ Hãy đảm bảo rằng bạn đang chạy các lệnh này từ gốc của th - **[flutter](https://github.com/rustdesk/rustdesk/tree/master/flutter)**: Mã Flutter dành máy tính và điện thoại - **[flutter/web/js](https://github.com/rustdesk/rustdesk/tree/master/flutter/web/js)**: Mã JavaScript dành cho giao diện trên web bằng Flutter + ## Snapshot ![image](https://user-images.githubusercontent.com/71636191/113112362-ae4deb80-923b-11eb-957d-ff88daad4f06.png) diff --git a/docs/README-ZH.md b/docs/README-ZH.md index 9328e52e944..ed3f493024c 100644 --- a/docs/README-ZH.md +++ b/docs/README-ZH.md @@ -36,7 +36,7 @@ RustDesk 期待各位的贡献. 如何参与开发? 详情请看 [CONTRIBUTING-Z ## 依赖 -桌面版本使用 Flutter 或 Sciter(已弃用)作为 GUI,本教程仅适用于 Sciter,因为它更简单且更易于上手。查看我们的[CI](https://github.com/rustdesk/rustdesk/blob/master/.github/workflows/flutter-build.yml)以构建 Flutter 版本。 +桌面版本使用 Flutter 或 Sciter(已弃用)作为 GUI,本教程仅适用于 Sciter,因为它更简单且更易于上手。此 fork 的当前客户端构建参考位于[Windows 工作流](../.github/workflows/rustqs-windows-min-test.yml);该文件是源代码参考,不代表发布或支持声明。 请自行下载Sciter动态库。 diff --git a/flutter/android/app/build.gradle b/flutter/android/app/build.gradle index 830cbc2ddc1..ff91b4c9b67 100644 --- a/flutter/android/app/build.gradle +++ b/flutter/android/app/build.gradle @@ -82,6 +82,7 @@ protobuf { } android { + namespace "com.carriez.flutter_hbb" compileSdkVersion 34 sourceSets { main.java.srcDirs += 'src/main/kotlin' diff --git a/flutter/android/app/src/main/AndroidManifest.xml b/flutter/android/app/src/main/AndroidManifest.xml index f4788af4cfb..61b12e23d50 100644 --- a/flutter/android/app/src/main/AndroidManifest.xml +++ b/flutter/android/app/src/main/AndroidManifest.xml @@ -25,7 +25,7 @@ diff --git a/flutter/android/app/src/main/kotlin/com/carriez/flutter_hbb/MainService.kt b/flutter/android/app/src/main/kotlin/com/carriez/flutter_hbb/MainService.kt index 7bb16a00ad6..0958e5e33e2 100644 --- a/flutter/android/app/src/main/kotlin/com/carriez/flutter_hbb/MainService.kt +++ b/flutter/android/app/src/main/kotlin/com/carriez/flutter_hbb/MainService.kt @@ -43,6 +43,8 @@ import kotlin.concurrent.thread import org.json.JSONException import org.json.JSONObject import java.nio.ByteBuffer +import java.io.FileNotFoundException +import java.io.IOException import kotlin.math.max import kotlin.math.min @@ -223,6 +225,20 @@ class MainService : Service() { // audio private val audioRecordHandle = AudioRecordHandle(this, { isStart }, { isAudioStart }) + private fun readBundledCustomClientConfig(): String? { + return try { + // Flutter assets are stored below this prefix in the Android APK. + applicationContext.assets.open("flutter_assets/assets/custom_.txt").bufferedReader(Charsets.UTF_8).use { + it.readText() + } + } catch (_: FileNotFoundException) { + "" + } catch (e: IOException) { + Log.e(logTag, "Failed to read bundled custom client config", e) + null + } + } + // notification private lateinit var notificationManager: NotificationManager private lateinit var notificationChannel: String @@ -243,7 +259,16 @@ class MainService : Service() { // keep the config dir same with flutter val prefs = applicationContext.getSharedPreferences(KEY_SHARED_PREFERENCES, FlutterActivity.MODE_PRIVATE) val configPath = prefs.getString(KEY_APP_DIR_CONFIG_PATH, "") ?: "" - FFI.startServer(configPath, "") + val customClientConfig = readBundledCustomClientConfig() ?: run { + Log.e(logTag, "Bundled custom client config is unreadable; refusing to start") + stopSelf() + return + } + if (!FFI.startServer(configPath, customClientConfig)) { + Log.e(logTag, "Bundled custom client config was rejected; refusing to start") + stopSelf() + return + } createForegroundNotification() } diff --git a/flutter/android/app/src/main/kotlin/ffi.kt b/flutter/android/app/src/main/kotlin/ffi.kt index e3c9d9830d4..6fdce6f71f9 100644 --- a/flutter/android/app/src/main/kotlin/ffi.kt +++ b/flutter/android/app/src/main/kotlin/ffi.kt @@ -15,7 +15,7 @@ object FFI { external fun init(ctx: Context) external fun onAppStart(ctx: Context) external fun setClipboardManager(clipboardManager: RdClipboardManager) - external fun startServer(app_dir: String, custom_client_config: String) + external fun startServer(app_dir: String, custom_client_config: String): Boolean external fun startService() external fun onVideoFrameUpdate(buf: ByteBuffer) external fun onAudioFrameUpdate(buf: ByteBuffer) diff --git a/flutter/build_fdroid.sh b/flutter/build_fdroid.sh index 26ba697e86c..e26bc4c63b6 100755 --- a/flutter/build_fdroid.sh +++ b/flutter/build_fdroid.sh @@ -3,6 +3,11 @@ # # Script to build F-Droid release of RustDesk # +# LEGACY: this F-Droid builder still reads the historical +# .github/workflows/flutter-build.yml, which is absent from the current fork. It is +# not part of the active rustqs workflow path; the historical references below are +# intentionally not treated as current workflow configuration. +# # Copyright (C) 2024, The RustDesk Authors # 2024, Vasyl Gello # diff --git a/libs/portable/build.rs b/libs/portable/build.rs index 74e7cc70e4c..dfcb5055640 100644 --- a/libs/portable/build.rs +++ b/libs/portable/build.rs @@ -1,4 +1,5 @@ fn main() { + println!("cargo:rerun-if-changed=app_metadata.toml"); #[cfg(windows)] { use std::io::Write; diff --git a/libs/portable/generate.py b/libs/portable/generate.py index d5468a5dc7f..d39fb7056cb 100755 --- a/libs/portable/generate.py +++ b/libs/portable/generate.py @@ -5,7 +5,8 @@ import subprocess from hashlib import md5 import brotli -import datetime +from datetime import datetime, timezone +import time # 4GB maximum length_count = 4 @@ -19,14 +20,16 @@ def generate_md5_table(folder: str, level) -> dict: res: dict = dict() curdir = os.curdir os.chdir(folder) - for root, _, files in os.walk('.'): + for root, directories, files in os.walk('.'): + directories.sort() + files.sort() # remove ./ for f in files: md5_generator = md5() full_path = os.path.join(root, f) print(f"Processing {full_path}...") - f = open(full_path, "rb") - content = f.read() + with open(full_path, "rb") as file_handle: + content = file_handle.read() content_compressed = brotli.compress( content, quality=level) md5_generator.update(content) @@ -40,7 +43,7 @@ def write_package_metadata(md5_table: dict, output_folder: str, exe: str): output_path = os.path.join(output_folder, "data.bin") with open(output_path, "wb") as f: f.write("rustdesk".encode(encoding=encoding)) - for path in md5_table.keys(): + for path in sorted(md5_table): (compressed_data, md5_code) = md5_table[path] data_length = len(compressed_data) path = path.encode(encoding=encoding) @@ -59,10 +62,41 @@ def write_package_metadata(md5_table: dict, output_folder: str, exe: str): f.write(exe.encode(encoding='utf-8')) print(f"Metadata has been written to {output_path}") +def app_metadata_timestamp_ms() -> int: + """Return the reproducible timestamp used by the portable packer. + + SOURCE_DATE_EPOCH is Unix time in seconds and is converted directly to + milliseconds so the existing app_metadata.toml schema is unchanged. A + missing epoch is deterministic by default; wall-clock metadata is only + available for explicitly non-reproducible local debug builds. + """ + source_date_epoch = os.environ.get("SOURCE_DATE_EPOCH") + if source_date_epoch is not None: + if (not source_date_epoch or + not source_date_epoch.lstrip("+-").isdigit()): + raise ValueError( + "SOURCE_DATE_EPOCH must be a signed Unix timestamp") + epoch_seconds = int(source_date_epoch) + if epoch_seconds < 0: + raise ValueError( + "SOURCE_DATE_EPOCH must be non-negative for app metadata") + try: + datetime.fromtimestamp(epoch_seconds, timezone.utc) + except (OverflowError, OSError, ValueError) as exc: + raise ValueError( + "SOURCE_DATE_EPOCH is outside the supported timestamp range" + ) from exc + return epoch_seconds * 1000 + + if os.environ.get("RUSTDESK_NON_REPRODUCIBLE_DEBUG") == "1": + return max(0, time.time_ns() // 1_000_000) + return 0 + + def write_app_metadata(output_folder: str): output_path = os.path.join(output_folder, "app_metadata.toml") with open(output_path, "w") as f: - f.write(f"timestamp = {int(datetime.datetime.now().timestamp() * 1000)}\n") + f.write(f"timestamp = {app_metadata_timestamp_ms()}\n") print(f"App metadata has been written to {output_path}") def build_portable(output_folder: str, target: str): diff --git a/libs/portable/requirements.txt b/libs/portable/requirements.txt index ac6cebc8203..090a7283d5c 100644 --- a/libs/portable/requirements.txt +++ b/libs/portable/requirements.txt @@ -1 +1,13 @@ -brotli \ No newline at end of file +# PyPI JSON metadata: https://pypi.org/pypi/Brotli/1.2.0/json +# Windows x64 wheels for the supported CPython versions on the GitHub runner. +brotli==1.2.0 \ + --hash=sha256:d206a36b4140fbb5373bf1eb73fb9de589bb06afd0d22376de23c5e91d0ab35f \ + --hash=sha256:f8d635cafbbb0c61327f942df2e3f474dde1cff16c3cd0580564774eaba1ee13 \ + --hash=sha256:865cedc7c7c303df5fad14a57bc5db1d4f4f9b2b4d0a7523ddd206f00c121a16 \ + --hash=sha256:14ef29fc5f310d34fc7696426071067462c9292ed98b5ff5a27ac70a200e5470 \ + --hash=sha256:1ce223652fd4ed3eb2b7f78fbea31c52314baecfac68db44037bb4167062a937 \ + --hash=sha256:ef87b8ab2704da227e83a246356a2b179ef826f550f794b2c52cddb4efbd0196 \ + --hash=sha256:022426c9e99fd65d9475dce5c195526f04bb8be8907607e27e747893f6ee3e24 \ + --hash=sha256:b35c13ce241abdd44cb8ca70683f20c0c079728a36a996297adb5334adfc1c44 \ + --hash=sha256:b63daa43d82f0cdabf98dee215b375b4058cce72871fd07934f179885aad16e8 \ + --hash=sha256:e7c0af964e0b4e3412a0ebf341ea26ec767fa0b4cf81abb5e897c9338b5ad6a3 diff --git a/src/common.rs b/src/common.rs index 69e3ec3045d..e404f4f9daf 100644 --- a/src/common.rs +++ b/src/common.rs @@ -2179,25 +2179,25 @@ pub fn get_dst_align_rgba() -> usize { 1 } -pub fn read_custom_client(config: &str) { +pub fn read_custom_client(config: &str) -> bool { let Ok(data) = decode64(config) else { log::error!("Failed to decode custom client config"); - return; + return false; }; const KEY: &str = "5Qbwsde3unUcJBtrx9ZkvUmwFNoExHzpryHuPUdqlWM="; let Some(pk) = get_rs_pk(KEY) else { log::error!("Failed to parse public key of custom client"); - return; + return false; }; let Ok(data) = sign::verify(&data, &pk) else { log::error!("Failed to dec custom client config"); - return; + return false; }; let Ok(mut data) = serde_json::from_slice::>(&data) else { log::error!("Failed to parse custom client config"); - return; + return false; }; if let Some(app_name) = data.remove("app-name") { @@ -2250,6 +2250,7 @@ pub fn read_custom_client(config: &str) { .insert(k, v.to_owned()); }; } + true } #[inline] diff --git a/src/flutter_ffi.rs b/src/flutter_ffi.rs index 9595ddd3160..282b3561e20 100644 --- a/src/flutter_ffi.rs +++ b/src/flutter_ffi.rs @@ -3063,19 +3063,25 @@ pub mod server_side { _class: JClass, app_dir: JString, custom_client_config: JString, - ) { + ) -> jboolean { log::debug!("startServer from jvm"); let mut env = env; - if let Ok(app_dir) = env.get_string(&app_dir) { - *config::APP_DIR.write().unwrap() = app_dir.into(); - } - if let Ok(custom_client_config) = env.get_string(&custom_client_config) { - if !custom_client_config.is_empty() { - let custom_client_config: String = custom_client_config.into(); - crate::read_custom_client(&custom_client_config); - } + let Ok(app_dir) = env.get_string(&app_dir) else { + log::error!("Failed to read app directory from jvm"); + return 0; + }; + *config::APP_DIR.write().unwrap() = app_dir.into(); + let Ok(custom_client_config) = env.get_string(&custom_client_config) else { + log::error!("Failed to read custom client config from jvm"); + return 0; + }; + let custom_client_config: String = custom_client_config.into(); + if !custom_client_config.is_empty() && !crate::read_custom_client(&custom_client_config) { + log::error!("Failed to apply custom client config from jvm"); + return 0; } std::thread::spawn(move || start_server(true)); + 1 } #[no_mangle] diff --git a/tests/test_android_manifest_contract.py b/tests/test_android_manifest_contract.py new file mode 100644 index 00000000000..e2573410e38 --- /dev/null +++ b/tests/test_android_manifest_contract.py @@ -0,0 +1,78 @@ +#!/usr/bin/env python3 +"""Static Android namespace, manifest-component, and applicationId contract.""" + +import re +import xml.etree.ElementTree as ET +from pathlib import Path +from xml.sax.saxutils import escape + + +ROOT = Path(__file__).resolve().parents[1] +BASE_PACKAGE = "com.carriez.flutter_hbb" +ANDROID_NAME = "{http://schemas.android.com/apk/res/android}name" + + +def kotlin_classes() -> set[str]: + classes: set[str] = set() + for source in (ROOT / "flutter/android/app/src/main/kotlin").rglob("*.kt"): + package_match = re.search(r"^package\s+([\w.]+)", source.read_text(), re.MULTILINE) + if not package_match: + continue + package = package_match.group(1) + for match in re.finditer(r"\b(?:class|object|interface)\s+(\w+)", source.read_text()): + classes.add(f"{package}.{match.group(1)}") + return classes + + +def main() -> None: + gradle = (ROOT / "flutter/android/app/build.gradle").read_text() + if 'namespace "com.carriez.flutter_hbb"' not in gradle: + raise AssertionError("Android namespace must remain bound to the Kotlin package") + if 'applicationId "com.carriez.flutter_hbb"' not in gradle: + raise AssertionError("Android Gradle applicationId marker is missing") + + workflow = (ROOT / ".github/workflows/rustqs-android.yml").read_text() + if "manifest.write_text" in workflow or 'package=\\"$RQS_ANDROID_APP_ID\\"' in workflow: + raise AssertionError("Android workflow must not rewrite the manifest package") + for marker in ( + 'gradle.write_text(gradle_text.replace(\'applicationId "com.carriez.flutter_hbb"\'', + 'grep -F -q -- \'package="com.carriez.flutter_hbb"\'', + 'value = value.replace("\\\\", "\\\\\\\\")', + 'value = value.replace("\'", "\\\\\'").replace(\'"\', \'\\\\"\')', + 'raise SystemExit("Android app_name must not start with @ or ?")', + ): + if marker not in workflow: + raise AssertionError(f"Android workflow contract is missing {marker!r}") + + classes = kotlin_classes() + main_manifest = ROOT / "flutter/android/app/src/main/AndroidManifest.xml" + if 'android:label="@string/app_name"' not in main_manifest.read_text(): + raise AssertionError("Android manifest must use the authored app_name resource") + for manifest_path in ( + main_manifest, + ROOT / "flutter/android/app/src/debug/AndroidManifest.xml", + ROOT / "flutter/android/app/src/profile/AndroidManifest.xml", + ): + root = ET.parse(manifest_path).getroot() + if root.attrib.get("package") != BASE_PACKAGE: + raise AssertionError(f"{manifest_path}: manifest namespace changed unexpectedly") + for element in root.iter(): + name = element.attrib.get(ANDROID_NAME) + if not name or not name.startswith("."): + continue + resolved = f"{BASE_PACKAGE}{name}" + if resolved not in classes: + raise AssertionError(f"{manifest_path}: component {name!r} resolves to missing {resolved}") + + app_label = "O'Reilly \\\"Client\\\" & " + app_label = app_label.replace("\\", "\\\\").replace("'", "\\'").replace('"', '\\"') + app_label = escape(app_label) + generated = f'{app_label}' + ET.fromstring(generated) + for marker in ("O\\'Reilly", '\\\\\\"Client\\\\\\"', "\\\\", "&", "<", ">"): + if marker not in generated: + raise AssertionError(f"Android app_name escaping is missing {marker!r}") + + +if __name__ == "__main__": + main() diff --git a/tests/test_portable_reproducibility.py b/tests/test_portable_reproducibility.py new file mode 100644 index 00000000000..e3e9aaa66b5 --- /dev/null +++ b/tests/test_portable_reproducibility.py @@ -0,0 +1,120 @@ +import importlib.util +import os +import sys +import tempfile +import types +import unittest +from pathlib import Path +from unittest import mock + + +ROOT = Path(__file__).resolve().parents[1] +GENERATE_PATH = ROOT / "libs" / "portable" / "generate.py" +BUILD_RS_PATH = ROOT / "libs" / "portable" / "build.rs" + + +def load_generate_module(): + fake_brotli = types.SimpleNamespace(compress=lambda content, quality: content) + with mock.patch.dict(sys.modules, {"brotli": fake_brotli}): + spec = importlib.util.spec_from_file_location("portable_generate", GENERATE_PATH) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +generate = load_generate_module() + + +class PortableReproducibilityTests(unittest.TestCase): + def test_source_date_epoch_is_written_as_utc_milliseconds(self): + with mock.patch.dict( + os.environ, + {"SOURCE_DATE_EPOCH": "1704067200"}, + clear=True, + ), tempfile.TemporaryDirectory() as output: + generate.write_app_metadata(output) + metadata = Path(output, "app_metadata.toml").read_text() + + self.assertEqual(metadata, "timestamp = 1704067200000\n") + + def test_repeated_generation_is_byte_identical(self): + with mock.patch.dict( + os.environ, + {"SOURCE_DATE_EPOCH": "0"}, + clear=True, + ), tempfile.TemporaryDirectory() as output: + generate.write_app_metadata(output) + first = Path(output, "app_metadata.toml").read_bytes() + generate.write_app_metadata(output) + second = Path(output, "app_metadata.toml").read_bytes() + + self.assertEqual(first, second) + + def test_shuffled_traversal_has_stable_package_data_order(self): + with tempfile.TemporaryDirectory() as source, tempfile.TemporaryDirectory() as first_output, tempfile.TemporaryDirectory() as second_output: + source_path = Path(source) + (source_path / "z-last.txt").write_bytes(b"last") + (source_path / "a-first.txt").write_bytes(b"first") + (source_path / "nested").mkdir() + (source_path / "nested" / "m-middle.txt").write_bytes(b"middle") + + real_walk = os.walk + + def shuffled_walk(path): + for root, directories, files in real_walk(path): + yield root, list(reversed(directories)), list(reversed(files)) + + with mock.patch.object(generate.os, "walk", side_effect=shuffled_walk): + shuffled_table = generate.generate_md5_table(source, 5) + generate.write_package_metadata(shuffled_table, first_output, "./z-last.txt") + + ordered_table = generate.generate_md5_table(source, 5) + generate.write_package_metadata(ordered_table, second_output, "./z-last.txt") + + self.assertEqual( + Path(first_output, "data.bin").read_bytes(), + Path(second_output, "data.bin").read_bytes(), + ) + + def test_missing_epoch_uses_deterministic_default(self): + with mock.patch.dict(os.environ, {}, clear=True): + self.assertEqual(generate.app_metadata_timestamp_ms(), 0) + + def test_wall_clock_requires_explicit_debug_opt_in(self): + with mock.patch.dict( + os.environ, + {"RUSTDESK_NON_REPRODUCIBLE_DEBUG": "1"}, + clear=True, + ), mock.patch.object(generate.time, "time_ns", return_value=1234567890123): + self.assertEqual(generate.app_metadata_timestamp_ms(), 1234567) + + def test_invalid_epoch_does_not_echo_input(self): + secret_value = "not-a-valid-epoch-secret" + with mock.patch.dict( + os.environ, + {"SOURCE_DATE_EPOCH": secret_value}, + clear=True, + ): + with self.assertRaisesRegex(ValueError, "signed Unix timestamp") as raised: + generate.app_metadata_timestamp_ms() + + self.assertNotIn(secret_value, str(raised.exception)) + + def test_pack_metadata_reruns_when_app_metadata_changes(self): + build_rs = BUILD_RS_PATH.read_text() + self.assertIn( + 'println!("cargo:rerun-if-changed=app_metadata.toml");', + build_rs, + ) + + def test_generator_has_no_unconditional_wall_clock_timestamp(self): + source = GENERATE_PATH.read_text() + self.assertNotIn("datetime.now", source) + self.assertIn('os.environ.get("SOURCE_DATE_EPOCH")', source) + self.assertIn("RUSTDESK_NON_REPRODUCIBLE_DEBUG", source) + self.assertIn('== "1"', source) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_workflow_input_contract.sh b/tests/test_workflow_input_contract.sh new file mode 100755 index 00000000000..762a59be449 --- /dev/null +++ b/tests/test_workflow_input_contract.sh @@ -0,0 +1,649 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) +python3 - "$repo_root" <<'PY' +import subprocess +import sys +import tempfile +import re +import shlex +import base64 +import hashlib +import hmac +import importlib.util +import json +import os +from pathlib import Path + +import yaml + +root = Path(sys.argv[1]) +manifest_writer = (root / ".github" / "scripts" / "write_artifact_manifest.py").read_text() +workflow_names = [ + "bridge.yml", + "rustqs-windows-min-test.yml", + "rustqs-linux.yml", + "rustqs-android.yml", +] +bridge_files = ( + "flutter/ios/Runner/bridge_generated.h", + "flutter/lib/generated_bridge.dart", + "flutter/lib/generated_bridge.freezed.dart", + "flutter/macos/Runner/bridge_generated.h", + "src/bridge_generated.io.rs", + "src/bridge_generated.rs", +) + + +def run_blocks_with_shell(document): + if isinstance(document, dict): + if isinstance(document.get("run"), str): + yield document["run"], document.get("shell", "") + for value in document.values(): + yield from run_blocks_with_shell(value) + elif isinstance(document, list): + for value in document: + yield from run_blocks_with_shell(value) + + +def uses_values(document): + if isinstance(document, dict): + for key, value in document.items(): + if key == "uses" and isinstance(value, str): + yield value + yield from uses_values(value) + elif isinstance(document, list): + for value in document: + yield from uses_values(value) + + +def bash_contract(workflow): + text = workflow.read_text() + parsed = yaml.safe_load(text) + blocks = [] + for index, (block, shell) in enumerate(run_blocks_with_shell(parsed)): + blocks.append(block) + if shell and "bash" not in str(shell): + continue + if not shell and workflow.name == "rustqs-windows-min-test.yml": + continue + normalized = re.sub(r"\$\{\{.*?\}\}", "placeholder", block) + check = subprocess.run( + ["bash", "-n"], input=normalized, text=True, capture_output=True + ) + if check.returncode != 0: + raise AssertionError( + f"{workflow.name}: bash syntax failed for run block {index}: {check.stderr}" + ) + for block in blocks: + if "reject_control_chars() {" in block: + return block + raise AssertionError(f"{workflow.name}: no bash input contract found") + + +for name in workflow_names: + workflow = root / ".github" / "workflows" / name + text = workflow.read_text() + contract_text = text + manifest_writer + parsed = yaml.safe_load(text) + trigger = parsed.get("on", parsed.get(True, {})) + inputs = trigger.get("workflow_dispatch", trigger.get("workflow_call", {})).get("inputs", {}) + if set(inputs) != {"enc_payload"}: + raise AssertionError(f"{name}: only authenticated enc_payload may be a workflow input, got {set(inputs)!r}") + if "Salted__" in text or "RQS_PAYLOAD_MODE=open" in text or "event SHA fallback" in text: + raise AssertionError(f"{name}: legacy/open/manual fallback remains in active workflow") + if "manual/direct runs require an authenticated DFP1 payload" not in text: + raise AssertionError(f"{name}: manual/direct fail-closed guard is missing") + if "workflow_repo" not in text or "authenticated workflow repository does not match this fork" not in text: + raise AssertionError(f"{name}: authenticated workflow repository binding is missing") + if parsed_permissions := parsed.get("permissions"): + if parsed_permissions != {"contents": "read"}: + raise AssertionError(f"{name}: permissions must remain contents: read, got {parsed_permissions!r}") + else: + raise AssertionError(f"{name}: explicit read-only permissions are required") + for action in uses_values(parsed): + if action.startswith("./"): + continue + if not re.search(r"@[0-9a-fA-F]{40}$", action): + raise AssertionError(f"{name}: third-party action is not pinned to a commit: {action}") + if "ACTIONS_RUNTIME_TOKEN" in text or "core.exportVariable" in text: + raise AssertionError(f"{name}: runtime cache token must not be exported job-wide") + if name != "bridge.yml": + if 'write_github_env RQS_CUSTOM_TXT "$RQS_CT"' in text: + raise AssertionError(f"{name}: custom_.txt content must not be persisted in GITHUB_ENV") + if "RQS_CUSTOM_TXT_FILE" not in text: + raise AssertionError(f"{name}: restrictive custom_.txt file handoff is missing") + if "cp --" not in text or "output/custom_.txt" not in text: + raise AssertionError(f"{name}: private custom_.txt is not copied beside public output for manifest declaration") + created_at = text.index("custom_txt_file=") + trap_at = text.index("trap cleanup_custom_txt_on_failure EXIT", created_at) + written_at = text.index('printf \'%s\' "$RQS_CT" > "$custom_txt_file"', trap_at) + if not created_at < trap_at < written_at: + raise AssertionError(f"{name}: failure cleanup trap must be installed before custom_.txt creation") + if "- name: Cleanup sensitive custom_.txt" not in text or "if: always()" not in text: + raise AssertionError(f"{name}: always-run sensitive custom_.txt cleanup is missing") + if "GITHUB_ENV" not in text: + raise AssertionError(f"{name}: missing environment-file handoff") + if "reject_control_chars() {" not in text: + raise AssertionError(f"{name}: missing control-character validator") + if "write_github_env() {" not in text: + raise AssertionError(f"{name}: missing safe environment-file writer") + if "printf '%s=%s\\n'" not in text: + raise AssertionError(f"{name}: safe printf environment writer is missing") + if 'echo "RQS_' in text and '>> "$GITHUB_ENV"' in text: + raise AssertionError(f"{name}: raw echo-to-GITHUB_ENV contract regressed") + if "persist-credentials: false" not in text: + raise AssertionError(f"{name}: checkout must not persist credentials") + submodule_command = re.compile( + r"GIT_CONFIG_COUNT=1\s+\\\s+" + r"GIT_CONFIG_KEY_0=http\.extraheader\s+\\\s+" + r"GIT_CONFIG_VALUE_0=\"Authorization: Bearer \$\{\{ github\.token \}\}\"\s+\\\s+" + r"git submodule update --init --recursive" + ) + if not submodule_command.search(text): + raise AssertionError(f"{name}: submodule update is missing the process-scoped GitHub token header") + if "git config --global" in text or "git config --local" in text: + raise AssertionError(f"{name}: submodule authentication must not persist Git credentials") + if 'echo "${{ github.token }}"' in text or "printf '%s' \"${{ github.token }}\"" in text: + raise AssertionError(f"{name}: workflow must not print the GitHub token") + for marker in ( + 'DFP1', + 'hmac.compare_digest', + 'SOURCE_DATE_EPOCH=$(git show -s --format=%ct HEAD)', + '"submodule", "status", "--recursive"', + 'source_tree_sha', + '"submodules"', + '"manifest_schema": "deskforge.client-artifact"', + '"schema_version": 2', + '"verification_scope"', + '"verification_result": "reported"', + '"publication_timestamp"', + '"private_filenames"', + 'deskforge.client-artifact-handoff-v1', + '"size":', + ): + if marker not in contract_text: + raise AssertionError(f"{name}: authenticated/reproducible payload marker {marker!r} is missing") + manifest_timestamp_producers = re.findall( + r"MANIFEST_PUBLICATION_TIMESTAMP=\$\(date -u '\+%Y-%m-%dT%H:%M:%SZ'\)", text + ) + if len(manifest_timestamp_producers) != 1: + raise AssertionError( + f"{name}: expected exactly one runtime publication timestamp producer, " + f"found {len(manifest_timestamp_producers)}" + ) + if "github.run_started_at" in text: + raise AssertionError(f"{name}: unsupported github.run_started_at publication timestamp remains") + if '"verification_result": "verified"' in text: + raise AssertionError(f"{name}: producer self-report must not be labelled verified") + if ".github/scripts/write_artifact_manifest.py" not in text: + raise AssertionError(f"{name}: shared producer manifest writer is missing") + for marker in ( + "Preserve workflow manifest helper", + "source_helper=.github/scripts/write_artifact_manifest.py", + 'test -f "${MANIFEST_HELPER_PATH:-}"', + 'python3 "$MANIFEST_HELPER_PATH"', + ): + if marker not in text: + raise AssertionError(f"{name}: workflow-owned manifest helper marker {marker!r} is missing") + preserve_at = text.index("- name: Preserve workflow manifest helper") + source_checkout_at = text.index("- name: Checkout source commit") + invoke_at = text.index('python3 "$MANIFEST_HELPER_PATH"') + if not preserve_at < source_checkout_at < invoke_at: + raise AssertionError(f"{name}: manifest helper must be preserved before source checkout and invoked afterward") + +for name in workflow_names[1:]: + text = (root / ".github" / "workflows" / name).read_text() + for marker in ( + "validate_app_name() {", + "app_name must be a non-empty filename component", + "app_name uses a reserved Windows device name", + "RQS_KEY=$RQS_KEY", + "RQS_APP_NAME \"$RQS_APP\"", + ): + if marker not in text: + raise AssertionError(f"{name}: missing {marker!r}") + if 'RQS_APP_NAME:-rustdesk' in text or 'RQS_APP_NAME:-rustqs' in text: + raise AssertionError(f"{name}: app_name is silently normalized at an output primitive") + + restore_start = text.index("- name: Restore bridge files") + verify_start = text.index("- name: Verify and restore bridge files", restore_start) + assertion_start = text.index("bridge_files=", verify_start) + restore_end = text.index("- name:", verify_start + 1) + restore_contract = text[restore_start:restore_end] + for path in bridge_files: + if path not in restore_contract: + raise AssertionError(f"{name}: bridge restore assertion does not cover {path}") + if "actions/download-artifact@" not in restore_contract or "BRIDGE_ARTIFACT_DIR" not in restore_contract: + raise AssertionError(f"{name}: bridge artifact is not verified from a temporary directory") + if "--verify-bridge" not in restore_contract or "--expected-version" not in restore_contract or 'cp -- "$BRIDGE_ARTIFACT_DIR/$file" "$file"' not in restore_contract: + raise AssertionError(f"{name}: bridge manifest verification must precede source restoration") + +bridge_text = (root / ".github" / "workflows" / "bridge.yml").read_text() +stage_start = bridge_text.index("- name: Stage generated bridge files") +stage_end = bridge_text.index("- name:", stage_start + 1) +stage_contract = bridge_text[stage_start:stage_end] +for path in bridge_files: + if path not in stage_contract or f'"bridge-output/$file"' not in stage_contract: + raise AssertionError(f"bridge.yml: bridge-output staging does not cover {path}") +if "test -f \"$file\"" not in stage_contract or "test -f \"bridge-output/$file\"" not in stage_contract: + raise AssertionError("bridge.yml: bridge-output population assertions are missing") + + +def execute_contract(block, app, key, version="1.2.3", android_app_id="com.example.rustqs"): + with tempfile.TemporaryDirectory() as runner_temp, tempfile.NamedTemporaryFile() as env_file: + setup = f"""\ +set -euo pipefail +GITHUB_ENV={shlex.quote(env_file.name)} +RUNNER_TEMP={shlex.quote(runner_temp)} +GITHUB_RUN_ID=workflow-contract +RQS_SERVER='id.example:21116' +RQS_KEY={shlex.quote(key)} +RQS_APP={shlex.quote(app)} +RQS_CT='YWJj' +RQS_VERSION={shlex.quote(version)} +RQS_SOURCE_SHA='{'a' * 40}' +RQS_WORKFLOW_REPO='owner/repo' +RQS_RELEASE_REPO='owner/repo' +RQS_RELEASE_ASSETS='[]' +RQS_PAYLOAD_MODE='open' +RQS_ANDROID_APP_ID={shlex.quote(android_app_id)} +""" + start = block.index("reject_control_chars() {") + if "if ! printf '%s' \"$decrypted\" | jq -e '.source_sha" in block[start:]: + end = block.index("if ! printf '%s' \"$decrypted\" | jq -e '.source_sha", start) + else: + writer = block.index("write_github_env() {", start) + end = block.index("\n}\n", writer) + len("\n}\n") + contract = block[start:end] + result = subprocess.run( + ["bash", "-s"], + input=setup + contract + "\nwrite_github_env TEST_VALUE \"$RQS_KEY\"\n", + text=True, + capture_output=True, + ) + return result, Path(env_file.name).read_text() + + +def make_authenticated_payload(key, plaintext): + salt = b"0123456789abcdef" + derived = hashlib.pbkdf2_hmac("sha256", key.encode(), salt, 100000, 80) + padding = 16 - (len(plaintext) % 16) + padded = plaintext + bytes([padding]) * padding + encrypted = subprocess.run( + ["openssl", "enc", "-aes-256-cbc", "-K", derived[:32].hex(), "-iv", derived[32:48].hex(), "-nopad"], + input=padded, + capture_output=True, + check=True, + ).stdout + signed = b"DFP1" + salt + encrypted + return signed + hmac.new(derived[48:], signed, hashlib.sha256).digest() + + +def execute_payload_contract(block, encoded, key): + start = block.index("decrypt_payload() {") + end = block.index("decrypted=$(decrypt_payload)", start) + function = block[start:end] + script = "set -euo pipefail\n" + function + 'decrypted=$(decrypt_payload)\nprintf "%s" "$decrypted"\n' + return subprocess.run( + ["bash", "-s"], + input=script, + text=True, + capture_output=True, + env={**os.environ, "ENC": encoded, "PAYLOAD_KEY": key}, + ) + + +for name in workflow_names: + block = bash_contract(root / ".github" / "workflows" / name) + valid_key = "5Qbwsde3unUcJBtrx9ZkvUmwFNoExHzpryHuPUdqlWM=" + safe_key = valid_key if name == "bridge.yml" else valid_key + "\r\n" + safe, env_output = execute_contract(block, "My RustDesk 客户端", safe_key) + if safe.returncode != 0: + raise AssertionError(f"{name}: safe payload rejected: {safe.stderr}") + if env_output != f"TEST_VALUE={valid_key}\n": + raise AssertionError(f"{name}: safe payload was changed or split: {env_output!r}") + + cases = (("version", "1.2.3\r"),) + if name != "bridge.yml": + cases = (("key", "public\nkey"), ("key", "public-key"), ("app_name", "../rustqs"), ("app_name", "@rustqs"), ("app_name", "?rustqs"), ("version", "../../etc"), ("version", "1.2"), ("version", "1.2.3-01"), ("version", "1.2.3+build"), ("version", "1.2.3\r")) + for field, value in cases: + app = value if field == "app_name" else "rustqs" + key = value if field == "key" else "public/key+==" + result, _ = execute_contract(block, app, key, value if field == "version" else "1.2.3") + if result.returncode == 0: + raise AssertionError(f"{name}: unsafe {field} was accepted") + + if name != "bridge.yml": + for reserved in ("CON", "PRN", "AUX", "NUL", "COM1", "LPT9", "con.txt", "LPT1 .exe"): + result, _ = execute_contract(block, reserved, valid_key) + if result.returncode == 0: + raise AssertionError(f"{name}: reserved app_name {reserved!r} was accepted") + for safe_app in ("CONSOLE", "COM10", "My RustDesk 客户端"): + result, _ = execute_contract(block, safe_app, valid_key) + if result.returncode != 0: + raise AssertionError(f"{name}: safe app_name {safe_app!r} was rejected: {result.stderr}") + if name == "rustqs-android.yml": + for invalid_id in ("", "com", "../escape", "Com.Example.App", "com.example..app", "com.example/app"): + result, _ = execute_contract(block, "rustqs", valid_key, android_app_id=invalid_id) + if result.returncode == 0: + raise AssertionError(f"{name}: invalid android_app_id {invalid_id!r} was accepted") + + for safe_version in ("1.2.3", "1.2.3-rc.1"): + result, _ = execute_contract(block, "rustqs", valid_key, safe_version) + if result.returncode != 0: + raise AssertionError(f"{name}: safe version {safe_version} was rejected: {result.stderr}") + + payload_key = "workflow-contract-key" + plaintext = b'{"version":"1.2.3","source_sha":"' + (b"a" * 40) + b'","workflow_repo":"owner/repo"}' + envelope = make_authenticated_payload(payload_key, plaintext) + good = execute_payload_contract(block, base64.b64encode(envelope).decode(), payload_key) + if good.returncode != 0 or good.stdout != plaintext.decode(): + raise AssertionError(f"{name}: authenticated payload did not decrypt: {good.stderr}") + tampered = bytearray(envelope) + tampered[-1] ^= 1 + bad = execute_payload_contract(block, base64.b64encode(tampered).decode(), payload_key) + if bad.returncode == 0: + raise AssertionError(f"{name}: tampered authenticated payload was accepted") + + legacy = base64.b64encode(b"Salted__legacy-payload").decode() + legacy_result = execute_payload_contract(block, legacy, payload_key) + if legacy_result.returncode == 0: + raise AssertionError(f"{name}: legacy unauthenticated payload was accepted") + + +android_workflow = root / ".github" / "workflows" / "rustqs-android.yml" +android_text = android_workflow.read_text() +main_service = (root / "flutter" / "android" / "app" / "src" / "main" / "kotlin" / "com" / "carriez" / "flutter_hbb" / "MainService.kt").read_text() +ffi_kt = (root / "flutter" / "android" / "app" / "src" / "main" / "kotlin" / "ffi.kt").read_text() +flutter_ffi = (root / "src" / "flutter_ffi.rs").read_text() +common_rs = (root / "src" / "common.rs").read_text() +for marker in ( + 'assets.open("flutter_assets/assets/custom_.txt")', + 'FFI.startServer(configPath, customClientConfig)', + 'Bundled custom client config is unreadable; refusing to start', + 'external fun startServer(app_dir: String, custom_client_config: String): Boolean', + 'pub unsafe extern "system" fn Java_ffi_FFI_startServer', + ') -> jboolean {', + 'if !custom_client_config.is_empty() && !crate::read_custom_client(&custom_client_config)', + 'pub fn read_custom_client(config: &str) -> bool', +): + if marker not in main_service + ffi_kt + flutter_ffi + common_rs: + raise AssertionError(f"Android custom-client runtime contract is missing {marker!r}") +for marker in ( + 'RQS_ANDROID_APP_ID=$(printf', + 'validate_android_app_id() {', + 'android_app_id must be a lowercase Java package identifier', + 'app_name must not start with @ or ?', + 'write_github_env RQS_ANDROID_APP_ID "$RQS_ANDROID_APP_ID"', + "Apply Android identity", + 'package="com.carriez.flutter_hbb"', + 'applicationId "com.carriez.flutter_hbb"', + 'assets/flutter_assets/assets/custom_.txt', + 'if [ -n "${RQS_CUSTOM_TXT_FILE:-}" ]; then', + 'custom_.txt is not packaged in Flutter Android assets', + 'custom_.txt does not match the Android native client config contract', + 'custom_.txt native client config must be a JSON object', +): + if marker not in android_text: + raise AssertionError(f"rustqs-android.yml: APK custom-client packaging check is missing {marker!r}") +if "best-effort" in android_text: + raise AssertionError("rustqs-android.yml: custom-client packaging must not be best-effort") +if main_service.index("readBundledCustomClientConfig()") > main_service.index("FFI.startServer(configPath, customClientConfig)"): + raise AssertionError("MainService starts the native server before reading custom_.txt") +if android_text.index("custom_.txt is not packaged in Flutter Android assets") > android_text.index('cp "$apk" "./output/${APP}.apk"'): + raise AssertionError("rustqs-android.yml copies the APK to output before checking the custom asset") +if "test -f flutter/assets/custom_.txt" not in android_text: + raise AssertionError("rustqs-android.yml does not require the staged custom_.txt asset") +manifest_text = (root / "flutter" / "android" / "app" / "src" / "main" / "AndroidManifest.xml").read_text() +if 'android:label="@string/app_name"' not in manifest_text: + raise AssertionError("Android manifest does not use the authored app_name resource") + +linux_workflow = root / ".github" / "workflows" / "rustqs-linux.yml" +linux_text = linux_workflow.read_text() +build_py = (root / "build.py").read_text() +build_call = linux_text.index("python3 ./build.py --flutter --skip-cargo") +source_guard = linux_text.index('test -f "$RQS_CUSTOM_TXT_FILE"') +if source_guard > build_call: + raise AssertionError("Linux workflow must verify private custom_.txt before build.py packaging") +if "L2 payload: place custom_.txt into bundle" in linux_text: + raise AssertionError("Linux workflow must not stage custom_.txt after Debian package creation") +flutter_build = build_py.index("flutter build linux --release") +stage_custom = build_py.index("stage_custom_txt_for_linux_bundle(", flutter_build) +bundle_copy = build_py.index("cp -r {flutter_build_dir}/*", stage_custom) +if not flutter_build < stage_custom < bundle_copy: + raise AssertionError("build.py must stage custom_.txt between Flutter build and Debian bundle copy") +package_flow = build_py.index("def build_deb_from_folder") +package_stage = build_py.index("stage_custom_txt_for_linux_bundle(", package_flow) +package_copy = build_py.index("cp -r ../{binary_folder}/*", package_stage) +if not package_stage < package_copy: + raise AssertionError("build.py --package must stage custom_.txt before copying the binary folder") +deb_assertion = linux_text.index('dpkg-deb -c "$deb_source"') +deb_copy = linux_text.index('cp -- "$deb_source" "$deb_output"') +if deb_assertion > deb_copy: + raise AssertionError("Linux workflow must assert custom_.txt membership before publishing the Debian artifact") +for marker in ("rpmbuild -ba res/rpm-flutter.spec", "output/custom_.txt", "Cleanup sensitive custom_.txt"): + if marker not in linux_text: + raise AssertionError(f"Linux RPM/private-manifest contract is missing {marker!r}") + + +def run_manifest_writer(output, app_name="rustqs", platform="windows"): + environment = { + **os.environ, + "RQS_SOURCE_SHA": "a" * 40, + "MANIFEST_PUBLICATION_TIMESTAMP": "2026-08-10T12:00:00Z", + } + return subprocess.run( + [ + sys.executable, + str(root / ".github" / "scripts" / "write_artifact_manifest.py"), + "--platform", + platform, + "--app-name", + app_name, + "--version", + "1.2.3", + "--output", + str(output), + "--workflow-sha", + "b" * 40, + "--workflow-ref", + "rustqs/min-test", + ], + cwd=root, + env=environment, + text=True, + capture_output=True, + ) + + +def run_manifest_writer_with_mocked_provenance(output, platform="windows", app_name="rustqs"): + module_path = root / ".github" / "scripts" / "write_artifact_manifest.py" + spec = importlib.util.spec_from_file_location("deskforge_manifest_writer", module_path) + module = importlib.util.module_from_spec(spec) + assert spec.loader is not None + spec.loader.exec_module(module) + module.source_tree_sha = lambda: "a" * 40 + module.submodules = lambda: [] + old_argv = sys.argv + old_source_sha = os.environ.get("RQS_SOURCE_SHA") + old_timestamp = os.environ.get("MANIFEST_PUBLICATION_TIMESTAMP") + sys.argv = [ + str(module_path), + "--platform", + platform, + "--app-name", + app_name, + "--version", + "1.2.3", + "--output", + str(output), + "--workflow-sha", + "b" * 40, + "--workflow-ref", + "rustqs/min-test", + ] + os.environ["RQS_SOURCE_SHA"] = "a" * 40 + os.environ["MANIFEST_PUBLICATION_TIMESTAMP"] = "2026-08-10T12:00:00Z" + try: + module.main() + except SystemExit as exc: + return exc.code if isinstance(exc.code, int) else 1 + finally: + sys.argv = old_argv + if old_source_sha is None: + os.environ.pop("RQS_SOURCE_SHA", None) + else: + os.environ["RQS_SOURCE_SHA"] = old_source_sha + if old_timestamp is None: + os.environ.pop("MANIFEST_PUBLICATION_TIMESTAMP", None) + else: + os.environ["MANIFEST_PUBLICATION_TIMESTAMP"] = old_timestamp + return 0 + + +def verify_bridge_artifact(output, source_sha="a" * 40, workflow_sha="b" * 40, workflow_ref="rustqs/min-test", version="1.2.3"): + module_path = root / ".github" / "scripts" / "write_artifact_manifest.py" + spec = importlib.util.spec_from_file_location("deskforge_bridge_verifier", module_path) + module = importlib.util.module_from_spec(spec) + assert spec.loader is not None + spec.loader.exec_module(module) + try: + module.verify_bridge_artifact(Path(output), source_sha, workflow_sha, workflow_ref, version) + except SystemExit as exc: + return exc.code if isinstance(exc.code, int) else 1 + return 0 + + +def verify_bridge_artifact_with_cli(output): + return subprocess.run( + [ + sys.executable, + str(root / ".github" / "scripts" / "write_artifact_manifest.py"), + "--verify-bridge", + "--output", + str(output), + "--expected-source-sha", + "a" * 40, + "--expected-version", + "1.2.3", + "--workflow-sha", + "b" * 40, + "--workflow-ref", + "rustqs/min-test", + ], + cwd=root, + text=True, + capture_output=True, + ) + + +with tempfile.TemporaryDirectory() as output_dir: + output = Path(output_dir) + (output / "rustqs.exe").write_bytes(b"safe") + result = run_manifest_writer_with_mocked_provenance(output) + if result != 0: + raise AssertionError(f"manifest writer rejected a regular bounded output: {result}") + manifest = json.loads((output / "manifest.txt").read_text()) + if manifest["schema_version"] != 2 or manifest["verification_result"] != "reported" or manifest["private_filenames"] != []: + raise AssertionError(f"manifest writer emitted an invalid v2 report: {manifest}") + +with tempfile.TemporaryDirectory() as output_dir: + output = Path(output_dir) + (output / "rustqs.exe").write_bytes(b"safe") + (output / "custom_.txt").write_bytes(b"private settings") + result = run_manifest_writer_with_mocked_provenance(output) + if result != 0: + raise AssertionError(f"manifest writer rejected declared private custom_.txt: {result}") + manifest = json.loads((output / "manifest.txt").read_text()) + if manifest["private_filenames"] != ["custom_.txt"] or any(file["name"] == "custom_.txt" for file in manifest["files"]): + raise AssertionError(f"private custom_.txt was not separated from public files: {manifest}") + +with tempfile.TemporaryDirectory() as output_dir: + output = Path(output_dir) + (output / "rustqs.exe").write_bytes(b"safe") + (output / "secret.txt").write_bytes(b"unexpected secret") + result = run_manifest_writer_with_mocked_provenance(output) + if result == 0 or (output / "manifest.txt").exists(): + raise AssertionError("manifest writer accepted an unlisted secret output file") + +with tempfile.TemporaryDirectory() as output_dir: + output = Path(output_dir) + outside = output.parent / "outside.exe" + outside.write_bytes(b"outside") + (output / "rustqs.exe").symlink_to(outside) + result = run_manifest_writer_with_mocked_provenance(output) + if result == 0 or (output / "manifest.txt").exists(): + raise AssertionError("manifest writer accepted an escaping symlink or wrote before rejecting it") + +with tempfile.TemporaryDirectory() as output_dir: + output = Path(output_dir) + os.mkfifo(output / "rustqs.exe") + result = run_manifest_writer_with_mocked_provenance(output) + if result == 0 or (output / "manifest.txt").exists(): + raise AssertionError("manifest writer accepted a special file or wrote before rejecting it") + +with tempfile.TemporaryDirectory() as output_dir: + output = Path(output_dir) + (output / "rustqs.exe").write_bytes(b"safe") + result = run_manifest_writer_with_mocked_provenance(output, app_name="../escape") + if result == 0 or (output / "manifest.txt").exists(): + raise AssertionError("manifest writer accepted an output path escaping the artifact directory") + +with tempfile.TemporaryDirectory() as output_dir: + output = Path(output_dir) + for name in bridge_files: + path = output / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(name.encode()) + result = run_manifest_writer_with_mocked_provenance(output, "bridge", "rustdesk-bridge") + if result != 0: + raise AssertionError(f"manifest writer rejected safe nested bridge output: {result}") + manifest = json.loads((output / "manifest.txt").read_text()) + if manifest["platform"] != "bridge" or manifest["output_filenames"] != sorted(bridge_files): + raise AssertionError(f"bridge manifest output contract is invalid: {manifest}") + if verify_bridge_artifact_with_cli(output).returncode != 0: + raise AssertionError("bridge manifest verifier rejected valid identity and hashes") + (output / bridge_files[0]).write_bytes(b"tampered") + if verify_bridge_artifact(output) == 0: + raise AssertionError("bridge manifest verifier accepted a tampered nested file") + +for platform, app_name, names in ( + ("windows", "rustqs", ["rustqs.exe"]), + ("linux", "rustqs", ["rustqs-1.2.3-0.x86_64.rpm", "rustqs-1.2.3.deb"]), + ("android", "rustqs", ["rustqs.apk"]), +): + with tempfile.TemporaryDirectory() as output_dir: + output = Path(output_dir) + for name in names: + (output / name).write_bytes(name.encode()) + (output / "custom_.txt").write_bytes(b"private settings") + result = run_manifest_writer_with_mocked_provenance(output, platform, app_name) + if result != 0: + raise AssertionError(f"{platform}: manifest writer rejected public/private compatibility set: {result}") + manifest = json.loads((output / "manifest.txt").read_text()) + if manifest["output_filenames"] != names or manifest["private_filenames"] != ["custom_.txt"]: + raise AssertionError(f"{platform}: manifest file separation is invalid: {manifest}") + +with tempfile.TemporaryDirectory() as output_dir: + output = Path(output_dir) + (output / "rustqs.exe").write_bytes(b"safe") + (output / "extra.bin").write_bytes(b"extra") + result = run_manifest_writer_with_mocked_provenance(output) + if result == 0 or (output / "manifest.txt").exists(): + raise AssertionError("manifest writer accepted an extra final-platform output") + +with tempfile.TemporaryDirectory() as output_dir: + output = Path(output_dir) + for name in bridge_files: + path = output / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(name.encode()) + (output / "flutter/escape.dart").symlink_to(output / bridge_files[0]) + result = run_manifest_writer_with_mocked_provenance(output, "bridge", "rustdesk-bridge") + if result == 0 or (output / "manifest.txt").exists(): + raise AssertionError("manifest writer accepted an unsafe bridge symlink") + +print("workflow input/YAML/shell contract checks passed") +PY