diff --git a/.gitattributes b/.gitattributes index 26caf6ba6..5974c7541 100644 --- a/.gitattributes +++ b/.gitattributes @@ -25,6 +25,7 @@ Makefile text eol=lf *.bmodel binary *.onnx binary *.so binary +*.so.* binary *.a binary *.png binary *.jpg binary diff --git a/.github/workflows/nightly-build-test-sophon.yml b/.github/workflows/nightly-build-test-sophon.yml index 58249e53e..a1ae5c5ba 100644 --- a/.github/workflows/nightly-build-test-sophon.yml +++ b/.github/workflows/nightly-build-test-sophon.yml @@ -14,6 +14,11 @@ jobs: build-sophon: runs-on: ubuntu-latest timeout-minutes: 60 + outputs: + candidate_guard_sha256: ${{ steps.candidate_guard_runtime.outputs.sha256 }} + candidate_tests_sha256: ${{ steps.candidate_test_binary.outputs.sha256 }} + env: + COSMO_MODEL_GUARD_BUILD_PROFILE: public-runtime # Specify the public container image for the build environment container: @@ -26,7 +31,7 @@ jobs: submodules: recursive # Recommended if the repository uses git submodules # Enforce project standards: use the unified build script instead of cmake directly. - # A single configure builds the clean production package and cosmo-tests + # A single configure builds the clean public-runtime package and cosmo-tests # together, sharing one set of compiled OBJECT libraries; coverage is off # by default (COSMO_ENABLE_COVERAGE), so the package binary stays clean. - name: Build package and tests @@ -41,13 +46,62 @@ jobs: name: sophon-build-package # Path where the built binaries/packages are generated by scripts/build.sh path: build/install/ + overwrite: true retention-days: 3 + - name: Record Candidate Test Binary + id: candidate_test_binary + run: | + set -euo pipefail + binary="build/cosmo-tests" + if [ ! -f "$binary" ] || [ -L "$binary" ]; then + echo "Candidate test binary is not a regular file: $binary" >&2 + exit 1 + fi + digest="$(sha256sum "$binary" | awk '{print $1}')" + if [ "${#digest}" -ne 64 ] || [[ "$digest" == *[!0-9a-f]* ]]; then + echo "Invalid Candidate test binary SHA-256: $digest" >&2 + exit 1 + fi + echo "sha256=$digest" >> "$GITHUB_OUTPUT" + echo "Candidate test binary SHA-256: $digest" + - name: Upload Test Binary uses: actions/upload-artifact@v7 with: name: sophon-tests-binary path: build/cosmo-tests + if-no-files-found: error + overwrite: true + retention-days: 1 + + - name: Record Candidate Guard Runtime + id: candidate_guard_runtime + run: | + set -euo pipefail + runtime="build/install/lib/libcosmo_model_guard.so.2.0.0" + if [ ! -f "$runtime" ] || [ -L "$runtime" ]; then + echo "Candidate Guard runtime is not a regular file: $runtime" >&2 + exit 1 + fi + digest="$(sha256sum "$runtime" | awk '{print $1}')" + if [ "${#digest}" -ne 64 ] || [[ "$digest" == *[!0-9a-f]* ]]; then + echo "Invalid Candidate Guard SHA-256: $digest" >&2 + exit 1 + fi + echo "sha256=$digest" >> "$GITHUB_OUTPUT" + echo "Candidate Guard SHA-256: $digest" + + # Keep the candidate Guard runtime independent from the large package and + # test-binary cache. overwrite=true makes a full job re-run replace the + # artifact, while a failed-job re-run still downloads the build's artifact. + - name: Upload Candidate Guard Runtime + uses: actions/upload-artifact@v7 + with: + name: sophon-model-guard-runtime + path: build/install/lib/libcosmo_model_guard.so.2.0.0 + if-no-files-found: error + overwrite: true retention-days: 1 test-sophon: @@ -61,34 +115,100 @@ jobs: COSMO_CATCH2_RESULTS_DIR: test-results/catch2 COSMO_CATCH2_REPORTER: compact COSMO_SOPHON_LD_LIBRARY_PATH: /appfs/cosmo_wander/cwai_data/lib:/data:/usr/lib + COSMO_CANDIDATE_RUNTIME_DIR: ${{ github.workspace }}/candidate-runtime + COSMO_CANDIDATE_GUARD_SHA256: ${{ needs.build-sophon.outputs.candidate_guard_sha256 }} + COSMO_CANDIDATE_TESTS_SHA256: ${{ needs.build-sophon.outputs.candidate_tests_sha256 }} steps: + # The test binary may be restored from the persistent device cache, but + # the Guard library must always come from this build's current artifact. + - name: Prepare Candidate Guard Runtime Directory + run: | + set -euo pipefail + expected_dir="$GITHUB_WORKSPACE/candidate-runtime" + if [ "$COSMO_CANDIDATE_RUNTIME_DIR" != "$expected_dir" ]; then + echo "Refusing unexpected candidate runtime directory: $COSMO_CANDIDATE_RUNTIME_DIR" >&2 + exit 1 + fi + rm -rf -- "$COSMO_CANDIDATE_RUNTIME_DIR" + mkdir -p -- "$COSMO_CANDIDATE_RUNTIME_DIR" + + - name: Download Candidate Guard Runtime + uses: actions/download-artifact@v8 + with: + name: sophon-model-guard-runtime + path: candidate-runtime + + - name: Verify Candidate Guard Runtime + run: | + set -euo pipefail + runtime="$COSMO_CANDIDATE_RUNTIME_DIR/libcosmo_model_guard.so.2.0.0" + if [ ! -f "$runtime" ] || [ -L "$runtime" ]; then + echo "Downloaded Candidate Guard runtime is not a regular file: $runtime" >&2 + exit 1 + fi + if [ "${#COSMO_CANDIDATE_GUARD_SHA256}" -ne 64 ] \ + || [[ "$COSMO_CANDIDATE_GUARD_SHA256" == *[!0-9a-f]* ]]; then + echo "Missing or invalid expected Candidate Guard SHA-256." >&2 + exit 1 + fi + actual_sha256="$(sha256sum "$runtime" | awk '{print $1}')" + if [ "$actual_sha256" != "$COSMO_CANDIDATE_GUARD_SHA256" ]; then + echo "Candidate Guard SHA-256 mismatch." >&2 + echo "Expected: $COSMO_CANDIDATE_GUARD_SHA256" >&2 + echo "Actual: $actual_sha256" >&2 + exit 1 + fi + ln -s -- "libcosmo_model_guard.so.2.0.0" \ + "$COSMO_CANDIDATE_RUNTIME_DIR/libcosmo_model_guard.so.2" + ln -s -- "libcosmo_model_guard.so.2" \ + "$COSMO_CANDIDATE_RUNTIME_DIR/libcosmo_model_guard.so" + test "$(readlink "$COSMO_CANDIDATE_RUNTIME_DIR/libcosmo_model_guard.so.2")" \ + = "libcosmo_model_guard.so.2.0.0" + test "$(readlink "$COSMO_CANDIDATE_RUNTIME_DIR/libcosmo_model_guard.so")" \ + = "libcosmo_model_guard.so.2" + echo "Candidate Guard runtime verified: $actual_sha256" + # The Download step below is the dominant wall-clock cost on this # self-hosted device (fetching the artifact from GitHub over a slow link # routinely takes 7-13 min, vs seconds-to-minutes for the tests). On # "Re-run failed jobs" the whole job re-runs, so we cache the binary on - # the device's persistent filesystem keyed by GITHUB_RUN_ID (stable across - # re-run attempts) and skip re-downloading on a cache hit. - # CAVEAT: "Re-run all jobs" rebuilds the binary under the same artifact - # name; this cache would then serve a stale binary. Before a full re-run, - # clear the cache dir on the device (rm -rf $HOME/cosmo-ci-cache). + # the device's persistent filesystem keyed by both GITHUB_RUN_ID and the + # build-produced SHA-256. A full job re-run therefore cannot reuse a stale + # binary, while a failed-job re-run can still skip the large download. - name: Restore Test Binary From Device Cache id: binary-cache run: | set -euo pipefail + if [ "${#COSMO_CANDIDATE_TESTS_SHA256}" -ne 64 ] \ + || [[ "$COSMO_CANDIDATE_TESTS_SHA256" == *[!0-9a-f]* ]]; then + echo "Missing or invalid expected Candidate test binary SHA-256." >&2 + exit 1 + fi # $HOME persists across runs and is outside the per-job workspace # GitHub cleans, so a file saved here survives across attempts. # Override the dir by setting COSMO_TEST_BINARY_CACHE_DIR in env. cache_dir="${COSMO_TEST_BINARY_CACHE_DIR:-$HOME/cosmo-ci-cache}" mkdir -p "$cache_dir" - cached="$cache_dir/cosmo-tests-${GITHUB_RUN_ID}" - if [ -f "$cached" ]; then - cp "$cached" ./cosmo-tests - echo "cache-hit=true" >> "$GITHUB_OUTPUT" - echo "Restored cosmo-tests from $cached (skipping artifact download)." + cached="$cache_dir/cosmo-tests-${GITHUB_RUN_ID}-${COSMO_CANDIDATE_TESTS_SHA256}" + if [ -f "$cached" ] && [ ! -L "$cached" ]; then + cached_sha256="$(sha256sum "$cached" | awk '{print $1}')" + if [ "$cached_sha256" = "$COSMO_CANDIDATE_TESTS_SHA256" ]; then + cp -- "$cached" ./cosmo-tests + restored_sha256="$(sha256sum ./cosmo-tests | awk '{print $1}')" + if [ "$restored_sha256" != "$COSMO_CANDIDATE_TESTS_SHA256" ]; then + echo "Restored Candidate test binary SHA-256 mismatch." >&2 + exit 1 + fi + echo "cache-hit=true" >> "$GITHUB_OUTPUT" + echo "Restored verified cosmo-tests from $cached (skipping artifact download)." + else + echo "cache-hit=false" >> "$GITHUB_OUTPUT" + echo "Ignoring corrupt test-binary cache entry: $cached" + fi else echo "cache-hit=false" >> "$GITHUB_OUTPUT" - echo "No cached binary for run ${GITHUB_RUN_ID}; will download." + echo "No verified cache entry for run ${GITHUB_RUN_ID} and SHA ${COSMO_CANDIDATE_TESTS_SHA256}; will download." fi - name: Download Test Binary @@ -98,19 +218,42 @@ jobs: name: sophon-tests-binary path: . + - name: Verify Candidate Test Binary + run: | + set -euo pipefail + if [ ! -f ./cosmo-tests ] || [ -L ./cosmo-tests ]; then + echo "Candidate test binary is not a regular file." >&2 + exit 1 + fi + actual_sha256="$(sha256sum ./cosmo-tests | awk '{print $1}')" + if [ "$actual_sha256" != "$COSMO_CANDIDATE_TESTS_SHA256" ]; then + echo "Candidate test binary SHA-256 mismatch." >&2 + echo "Expected: $COSMO_CANDIDATE_TESTS_SHA256" >&2 + echo "Actual: $actual_sha256" >&2 + exit 1 + fi + echo "Candidate test binary verified: $actual_sha256" + - name: Save Test Binary To Device Cache if: steps.binary-cache.outputs.cache-hit != 'true' && success() run: | set -euo pipefail cache_dir="${COSMO_TEST_BINARY_CACHE_DIR:-$HOME/cosmo-ci-cache}" - cached="$cache_dir/cosmo-tests-${GITHUB_RUN_ID}" - cp ./cosmo-tests "$cached" + cached="$cache_dir/cosmo-tests-${GITHUB_RUN_ID}-${COSMO_CANDIDATE_TESTS_SHA256}" + pending="${cached}.pending-${GITHUB_RUN_ATTEMPT}" + cp --remove-destination -- ./cosmo-tests "$pending" + pending_sha256="$(sha256sum "$pending" | awk '{print $1}')" + if [ "$pending_sha256" != "$COSMO_CANDIDATE_TESTS_SHA256" ]; then + echo "Refusing to cache test binary with mismatched SHA-256." >&2 + exit 1 + fi + mv -f -- "$pending" "$cached" # Prune to the 5 most-recent entries; never touch the current run. # `|| true`: grep exits 1 when only the current run's file exists # (nothing left after filtering), which under `set -o pipefail` would # otherwise fail this step. Pruning is best-effort cleanup. ls -t "$cache_dir"/cosmo-tests-* 2>/dev/null \ - | grep -v -F -- "cosmo-tests-${GITHUB_RUN_ID}" \ + | grep -v -F -- "$cached" \ | tail -n +6 \ | xargs -r rm -f || true echo "Saved cosmo-tests to $cached; cache pruned." @@ -118,12 +261,35 @@ jobs: - name: Add Execution Permission run: chmod +x ./cosmo-tests + - name: Verify Candidate Guard Resolution + run: | + set -euo pipefail + export LD_LIBRARY_PATH="$COSMO_CANDIDATE_RUNTIME_DIR:$COSMO_SOPHON_LD_LIBRARY_PATH:${LD_LIBRARY_PATH:-}" + if ! command -v ldd >/dev/null 2>&1; then + echo "Required command is missing: ldd" >&2 + exit 1 + fi + resolved="$(ldd ./cosmo-tests \ + | awk '$1 == "libcosmo_model_guard.so.2" && $2 == "=>" {print $3; exit}')" + if [ -z "$resolved" ]; then + echo "cosmo-tests did not resolve libcosmo_model_guard.so.2." >&2 + exit 1 + fi + expected_real="$(readlink -f \ + "$COSMO_CANDIDATE_RUNTIME_DIR/libcosmo_model_guard.so.2.0.0")" + resolved_real="$(readlink -f "$resolved")" + if [ "$resolved_real" != "$expected_real" ]; then + echo "cosmo-tests resolved a non-candidate Guard runtime: $resolved" >&2 + exit 1 + fi + echo "cosmo-tests resolves Candidate Guard runtime: $resolved_real" + - name: Run Catch2 Tests run: | set +e set -uo pipefail - export LD_LIBRARY_PATH="$COSMO_SOPHON_LD_LIBRARY_PATH:${LD_LIBRARY_PATH:-}" + export LD_LIBRARY_PATH="$COSMO_CANDIDATE_RUNTIME_DIR:$COSMO_SOPHON_LD_LIBRARY_PATH:${LD_LIBRARY_PATH:-}" shard_count="$COSMO_CATCH2_SHARDS" shard_timeout_seconds="$COSMO_CATCH2_SHARD_TIMEOUT_SECONDS" diff --git a/3rd/libsophon-0.4.11/lib/tpu_module/libbm1688_kernel_module.so b/3rd/libsophon-0.4.11/lib/tpu_module/libbm1688_kernel_module.so new file mode 100644 index 000000000..d400eb530 Binary files /dev/null and b/3rd/libsophon-0.4.11/lib/tpu_module/libbm1688_kernel_module.so differ diff --git a/CMakeLists.txt b/CMakeLists.txt index f300d44cf..4ae9e81ce 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -143,17 +143,267 @@ else() set(COSMO_MODEL_GUARD OFF) endif() +# The production bootstrap embeds the public half of the release signing key. +# It is separate from Model Guard device-certificate verification. +set(COSMO_RELEASE_PUBLIC_KEY_OBJECT "" CACHE FILEPATH + "Absolute path to the controlled AArch64 release public-key object") +set(COSMO_MODEL_GUARD_BUILD_PROFILE "public-runtime" CACHE STRING + "Model Guard build profile: public-runtime or production-release") +set(COSMO_EDGE_SOURCE_COMMIT "" CACHE STRING + "Exact lower-case Edge commit embedded in a SOURCE package identity") +set_property(CACHE COSMO_MODEL_GUARD_BUILD_PROFILE PROPERTY STRINGS + public-runtime production-release) +option(COSMO_LEGACY_MIGRATION_PACKAGE + "Build an old-main-compatible bridge package with the historical MD5 name" OFF) +set(COSMO_PACKAGE_MODELS "include" CACHE STRING + "Package preset models: include or preserve the installed model directory") +set_property(CACHE COSMO_PACKAGE_MODELS PROPERTY STRINGS include preserve) +if(NOT COSMO_PACKAGE_MODELS STREQUAL "include" AND + NOT COSMO_PACKAGE_MODELS STREQUAL "preserve") + message(FATAL_ERROR "COSMO_PACKAGE_MODELS must be include or preserve") +endif() +option(COSMO_REQUIRE_RELEASE_BOOTSTRAP + "Fail configuration unless the production release bootstrap can be linked" OFF) + +set(COSMO_MODEL_GUARD_BUILD_PROFILES public-runtime production-release) +if(NOT COSMO_MODEL_GUARD_BUILD_PROFILE IN_LIST + COSMO_MODEL_GUARD_BUILD_PROFILES) + message(FATAL_ERROR + "Unsupported COSMO_MODEL_GUARD_BUILD_PROFILE=" + "${COSMO_MODEL_GUARD_BUILD_PROFILE}. Expected public-runtime or " + "production-release.") +endif() +if(COSMO_MODEL_GUARD_BUILD_PROFILE STREQUAL "public-runtime") + if(COSMO_REQUIRE_RELEASE_BOOTSTRAP) + message(FATAL_ERROR + "COSMO_MODEL_GUARD_BUILD_PROFILE=public-runtime conflicts with " + "COSMO_REQUIRE_RELEASE_BOOTSTRAP=ON") + endif() + if(NOT "${COSMO_RELEASE_PUBLIC_KEY_OBJECT}" STREQUAL "") + message(FATAL_ERROR + "COSMO_RELEASE_PUBLIC_KEY_OBJECT is valid only with " + "COSMO_MODEL_GUARD_BUILD_PROFILE=production-release") + endif() +elseif(NOT COSMO_REQUIRE_RELEASE_BOOTSTRAP) + message(FATAL_ERROR + "COSMO_MODEL_GUARD_BUILD_PROFILE=production-release requires " + "COSMO_REQUIRE_RELEASE_BOOTSTRAP=ON") +endif() + if(COSMO_MODEL_GUARD) - # .so is pre-installed in system image at /usr/lib/ or provided in prebuild/ - find_library(MODEL_GUARD_LIB cosmo_model_guard - PATHS /usr/lib ${CMAKE_SOURCE_DIR}/prebuild - NO_DEFAULT_PATH) - if(MODEL_GUARD_LIB) - message(STATUS "Model guard enabled: ${MODEL_GUARD_LIB}") - else() - message(WARNING "COSMO_MODEL_GUARD=ON but libcosmo_model_guard.so not found — " - "encrypted models will fail at runtime") + set(COSMO_MODEL_GUARD_SDK_ROOT + "${CMAKE_SOURCE_DIR}/prebuild/model-guard-v2" + CACHE PATH "Verified Cosmo Model Guard v2 SDK root") + get_filename_component(COSMO_MODEL_GUARD_SDK_ROOT + "${COSMO_MODEL_GUARD_SDK_ROOT}" ABSOLUTE) + set(MODEL_GUARD_V2_HEADER + "${COSMO_MODEL_GUARD_SDK_ROOT}/include/cosmo_model_guard_v2.h") + set(MODEL_GUARD_V2_LIBRARY + "${COSMO_MODEL_GUARD_SDK_ROOT}/lib/libcosmo_model_guard.so.2.0.0") + if(COSMO_MODEL_GUARD_BUILD_PROFILE STREQUAL "production-release") + set(MODEL_GUARD_V2_PROVISION_TOOL + "${COSMO_MODEL_GUARD_SDK_ROOT}/bin/cosmo-model-provision") + endif() + set(MODEL_GUARD_V2_TEST_FIXTURE_MARKER + "${COSMO_MODEL_GUARD_SDK_ROOT}/share/cosmo-model-guard/TEST_FIXTURE_DO_NOT_DEPLOY") + + set(COSMO_PYTHON3_EXECUTABLE "/usr/bin/python3") + if(NOT EXISTS "${COSMO_PYTHON3_EXECUTABLE}" OR + IS_DIRECTORY "${COSMO_PYTHON3_EXECUTABLE}" OR + NOT CMAKE_READELF OR NOT EXISTS "${CMAKE_READELF}" OR + NOT CMAKE_NM OR NOT EXISTS "${CMAKE_NM}") + message(FATAL_ERROR + "The protected Sophon build requires Python 3 and the target " + "readelf/nm tools to verify the Model Guard v2 SDK") + endif() + + set(MODEL_GUARD_V2_VERIFY_ARGUMENTS + --admission-profile "${COSMO_MODEL_GUARD_BUILD_PROFILE}" + --sdk-root "${COSMO_MODEL_GUARD_SDK_ROOT}" + --readelf "${CMAKE_READELF}" + --nm "${CMAKE_NM}") + if(EXISTS "${MODEL_GUARD_V2_TEST_FIXTURE_MARKER}") + message(FATAL_ERROR + "A marked Model Guard test fixture cannot satisfy either public-runtime " + "or production-release SDK admission") + endif() + + set(MODEL_GUARD_V2_ADMISSION_INPUTS + "${MODEL_GUARD_V2_HEADER}" + "${MODEL_GUARD_V2_LIBRARY}" + "${CMAKE_SOURCE_DIR}/scripts/verify_model_guard_v2_sdk.py") + if(COSMO_MODEL_GUARD_BUILD_PROFILE STREQUAL "production-release") + list(APPEND MODEL_GUARD_V2_ADMISSION_INPUTS + "${MODEL_GUARD_V2_PROVISION_TOOL}") + endif() + set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS + ${MODEL_GUARD_V2_ADMISSION_INPUTS}) + + execute_process( + COMMAND "${COSMO_PYTHON3_EXECUTABLE}" -I -B + "${CMAKE_SOURCE_DIR}/scripts/verify_model_guard_v2_sdk.py" + ${MODEL_GUARD_V2_VERIFY_ARGUMENTS} + RESULT_VARIABLE MODEL_GUARD_V2_VERIFY_RESULT + OUTPUT_VARIABLE MODEL_GUARD_V2_VERIFY_OUTPUT + ERROR_VARIABLE MODEL_GUARD_V2_VERIFY_ERROR + OUTPUT_STRIP_TRAILING_WHITESPACE + ERROR_STRIP_TRAILING_WHITESPACE + ) + if(NOT MODEL_GUARD_V2_VERIFY_RESULT EQUAL 0) + message(FATAL_ERROR + "COSMO_MODEL_GUARD requires a usable v2 SDK.\n" + "${MODEL_GUARD_V2_VERIFY_ERROR}") + endif() + + string(REGEX MATCH "(^|\n)verified_sdk_root=([^\n]+)" + MODEL_GUARD_V2_VERIFIED_ROOT_LINE "${MODEL_GUARD_V2_VERIFY_OUTPUT}") + set(MODEL_GUARD_V2_VERIFIED_SDK_ROOT "${CMAKE_MATCH_2}") + if(NOT IS_ABSOLUTE "${MODEL_GUARD_V2_VERIFIED_SDK_ROOT}" OR + NOT EXISTS "${MODEL_GUARD_V2_VERIFIED_SDK_ROOT}") + message(FATAL_ERROR "Model Guard verifier did not return a valid SDK root") + endif() + set(MODEL_GUARD_V2_HEADER + "${MODEL_GUARD_V2_VERIFIED_SDK_ROOT}/include/cosmo_model_guard_v2.h") + set(MODEL_GUARD_V2_LIBRARY + "${MODEL_GUARD_V2_VERIFIED_SDK_ROOT}/lib/libcosmo_model_guard.so.2.0.0") + if(COSMO_MODEL_GUARD_BUILD_PROFILE STREQUAL "production-release") + set(MODEL_GUARD_V2_PROVISION_TOOL + "${MODEL_GUARD_V2_VERIFIED_SDK_ROOT}/bin/cosmo-model-provision") + endif() + add_library(cosmo_model_guard_v2 SHARED IMPORTED GLOBAL) + set_target_properties(cosmo_model_guard_v2 PROPERTIES + IMPORTED_LOCATION "${MODEL_GUARD_V2_LIBRARY}" + IMPORTED_SONAME "libcosmo_model_guard.so.2" + INTERFACE_INCLUDE_DIRECTORIES "${MODEL_GUARD_V2_VERIFIED_SDK_ROOT}/include" + ) + message(STATUS "Verified Model Guard v2 SDK:\n${MODEL_GUARD_V2_VERIFY_OUTPUT}") + message(STATUS + "Model Guard build profile: ${COSMO_MODEL_GUARD_BUILD_PROFILE}") +endif() + +# The production bootstrap embeds the release signing public key. Public builds +# emit the installable SOURCE package without this formal OTA entry point. +set(COSMO_HAS_RELEASE_BOOTSTRAP OFF) +if(COSMO_MODEL_GUARD_BUILD_PROFILE STREQUAL "production-release") + if(NOT COSMO_MODEL_GUARD) + message(FATAL_ERROR + "COSMO_MODEL_GUARD_BUILD_PROFILE=production-release requires the " + "protected Sophon build") + endif() + if(NOT COSMO_TARGET_ARCH STREQUAL "aarch64" OR + NOT COSMO_NN_USE_SOPHON_BACKEND) + message(FATAL_ERROR + "The production release bootstrap is valid only for Sophon/AArch64") endif() + if("${COSMO_RELEASE_PUBLIC_KEY_OBJECT}" STREQUAL "" OR + NOT IS_ABSOLUTE "${COSMO_RELEASE_PUBLIC_KEY_OBJECT}") + message(FATAL_ERROR + "production-release requires an absolute " + "COSMO_RELEASE_PUBLIC_KEY_OBJECT") + endif() + set(COSMO_RELEASE_PUBLIC_KEY_LINK_OBJECT + "${COSMO_RELEASE_PUBLIC_KEY_OBJECT}") + set(COSMO_RELEASE_PYTHON3_EXECUTABLE "/usr/bin/python3") + if(NOT EXISTS "${COSMO_RELEASE_PYTHON3_EXECUTABLE}" OR + IS_DIRECTORY "${COSMO_RELEASE_PYTHON3_EXECUTABLE}" OR + NOT CMAKE_READELF OR NOT EXISTS "${CMAKE_READELF}" OR + NOT CMAKE_NM OR NOT EXISTS "${CMAKE_NM}") + message(FATAL_ERROR + "Release bootstrap configuration requires Python 3 and target readelf/nm") + endif() + execute_process( + COMMAND "${COSMO_RELEASE_PYTHON3_EXECUTABLE}" -I -B + "${CMAKE_SOURCE_DIR}/scripts/verify_release_public_key_object.py" + --object "${COSMO_RELEASE_PUBLIC_KEY_LINK_OBJECT}" + --readelf "${CMAKE_READELF}" + --nm "${CMAKE_NM}" + RESULT_VARIABLE COSMO_RELEASE_KEY_VERIFY_RESULT + OUTPUT_VARIABLE COSMO_RELEASE_KEY_VERIFY_OUTPUT + ERROR_VARIABLE COSMO_RELEASE_KEY_VERIFY_ERROR + OUTPUT_STRIP_TRAILING_WHITESPACE + ERROR_STRIP_TRAILING_WHITESPACE + ) + if(NOT COSMO_RELEASE_KEY_VERIFY_RESULT EQUAL 0) + message(FATAL_ERROR + "The production release public-key object is missing or invalid.\n" + "${COSMO_RELEASE_KEY_VERIFY_ERROR}") + endif() + set(COSMO_RELEASE_KEY_REVERIFY_DEPENDS + "${COSMO_RELEASE_PUBLIC_KEY_OBJECT}" + "${COSMO_RELEASE_PUBLIC_KEY_LINK_OBJECT}" + "${CMAKE_SOURCE_DIR}/scripts/verify_release_public_key_object.py") + set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS + ${COSMO_RELEASE_KEY_REVERIFY_DEPENDS}) + add_custom_target(cosmo_release_public_key_reverify ALL + COMMAND "${COSMO_RELEASE_PYTHON3_EXECUTABLE}" -I -B + "${CMAKE_SOURCE_DIR}/scripts/verify_release_public_key_object.py" + --object "${COSMO_RELEASE_PUBLIC_KEY_LINK_OBJECT}" + --readelf "${CMAKE_READELF}" + --nm "${CMAKE_NM}" + DEPENDS ${COSMO_RELEASE_KEY_REVERIFY_DEPENDS} + COMMENT "Re-verifying the controlled release public-key object" + VERBATIM) + set_source_files_properties("${COSMO_RELEASE_PUBLIC_KEY_LINK_OBJECT}" PROPERTIES + EXTERNAL_OBJECT TRUE) + set(COSMO_HAS_RELEASE_BOOTSTRAP ON) + message(STATUS "Verified production release trust anchor:\n${COSMO_RELEASE_KEY_VERIFY_OUTPUT}") +else() + message(WARNING + "The SOURCE package (internal profile: public-runtime) does not build " + "or install cosmo-release-bootstrap and is not a signed production release") +endif() + +# A production install reopens its signing trust anchor immediately before +# copying files. Model Guard SDK admission is performed once at configure time. +function(cosmo_append_install_command_argument output_name argument) + set(argument_delimiter "=") + while("${argument}" MATCHES "]${argument_delimiter}]") + string(APPEND argument_delimiter "=") + endwhile() + set(quoted_argument + "[${argument_delimiter}[${argument}]${argument_delimiter}]") + set(${output_name} + "${${output_name}}\n ${quoted_argument}" + PARENT_SCOPE) +endfunction() + +if(COSMO_HAS_RELEASE_BOOTSTRAP) + string(CONCAT COSMO_INSTALL_RELEASE_KEY_REVERIFY_CODE + "message(STATUS \"Re-verifying release public key before installation\")\n" + "execute_process(\n" + " COMMAND") + foreach(COSMO_RELEASE_KEY_INSTALL_VERIFY_ARGUMENT IN ITEMS + "${COSMO_RELEASE_PYTHON3_EXECUTABLE}" + "-I" + "-B" + "${CMAKE_SOURCE_DIR}/scripts/verify_release_public_key_object.py" + "--object" + "${COSMO_RELEASE_PUBLIC_KEY_LINK_OBJECT}" + "--readelf" + "${CMAKE_READELF}" + "--nm" + "${CMAKE_NM}") + cosmo_append_install_command_argument( + COSMO_INSTALL_RELEASE_KEY_REVERIFY_CODE + "${COSMO_RELEASE_KEY_INSTALL_VERIFY_ARGUMENT}") + endforeach() + string(APPEND COSMO_INSTALL_RELEASE_KEY_REVERIFY_CODE [=[ + + RESULT_VARIABLE release_key_install_verify_result + OUTPUT_VARIABLE release_key_install_verify_output + ERROR_VARIABLE release_key_install_verify_error + OUTPUT_STRIP_TRAILING_WHITESPACE + ERROR_STRIP_TRAILING_WHITESPACE +) +if(NOT release_key_install_verify_result EQUAL 0) + message(FATAL_ERROR + "Install-time release public-key verification failed before any file was copied.\n" + "${release_key_install_verify_output}\n" + "${release_key_install_verify_error}") +endif() +message(STATUS "Install-time release public-key verification passed") +]=]) + install(CODE "${COSMO_INSTALL_RELEASE_KEY_REVERIFY_CODE}") endif() message(STATUS "=======================") @@ -170,6 +420,13 @@ message(STATUS "\tTarget: ${COSMO_TARGET_ARCH}") set(THIRDPARTY_INSTALL_PREFIX ${CMAKE_BINARY_DIR}/thirdparty_install CACHE PATH "thirdparty install dir") add_custom_target(third_build) +# Third-party projects must not inherit wall-clock or ephemeral container +# identity in release artifacts. +set(COSMO_REPRODUCIBLE_BUILD_EPOCH 1784776233) +set(COSMO_REPRODUCIBLE_BUILD_UTC "2026-07-23 03:10:33") +set(COSMO_REPRODUCIBLE_BUILD_UNAME + "Linux cosmo-build 0.0.0 #1 SMP ${COSMO_TARGET_ARCH} GNU/Linux") + # Pre-create include directories for all ExternalProject-managed libraries. # CMake validates INTERFACE_INCLUDE_DIRECTORIES paths at configure time, but # ExternalProject only installs headers at build time. Without this, a clean @@ -190,11 +447,11 @@ include(cmake/uuid.cmake) include(cmake/uSockets.cmake) include(cmake/mp4v2.cmake) include(cmake/openssl.cmake) +include(cmake/cryptopp.cmake) include(cmake/curl.cmake) include(cmake/mqtt.cmake) include(cmake/event.cmake) include(cmake/ffmpeg.cmake) -include(cmake/cryptopp.cmake) include(cmake/uWebSockets.cmake) include(cmake/tokenizers.cmake) if(COSMO_NN_USE_SOPHON_BACKEND) @@ -205,7 +462,6 @@ if(COSMO_NN_USE_CPU_BACKEND) endif() include(cmake/pcap.cmake) include(cmake/srs.cmake) - #Header - only third - party libraries add_library(nlohmann INTERFACE) target_include_directories(nlohmann SYSTEM INTERFACE ${CMAKE_SOURCE_DIR}/3rd/include) @@ -308,6 +564,79 @@ set(COMMON_LIBS pthread dl m ) +########################################################## +# Embedded-key first-release bootstrap +########################################################## +set(COSMO_RELEASE_BOOTSTRAP_VERIFIER_SOURCE + ${CMAKE_SOURCE_DIR}/src/bootstrap/ReleaseBootstrapVerifier.cc) + +if(COSMO_HAS_RELEASE_BOOTSTRAP) + add_executable(cosmo-release-bootstrap + ${CMAKE_SOURCE_DIR}/src/bootstrap/main.cc + ${CMAKE_SOURCE_DIR}/src/bootstrap/ReleaseBootstrap.cc + ${COSMO_RELEASE_BOOTSTRAP_VERIFIER_SOURCE} + ${COSMO_RELEASE_PUBLIC_KEY_LINK_OBJECT}) + add_dependencies(cosmo-release-bootstrap + third_build + cosmo_release_public_key_reverify) + target_include_directories(cosmo-release-bootstrap PRIVATE ${CMAKE_SOURCE_DIR}/src) + target_compile_options(cosmo-release-bootstrap PRIVATE ${COSMO_STRICT_WARNINGS}) + target_link_libraries(cosmo-release-bootstrap PRIVATE openssl_crypto) + target_link_options(cosmo-release-bootstrap PRIVATE + -Wl,-z,relro -Wl,-z,now -Wl,-z,noexecstack + -Wl,--enable-new-dtags) + # Both installed copies use a private sibling lib directory. In + # particular, the stable factory copy must not traverse the mutable + # install-root lib facade while first-release migration is in progress. + set_target_properties(cosmo-release-bootstrap PROPERTIES + BUILD_RPATH "${OPENSSL_INSTALL_DIR}/lib" + BUILD_RPATH_USE_ORIGIN FALSE + INSTALL_RPATH "\$ORIGIN/../lib" + INSTALL_RPATH_USE_LINK_PATH FALSE) +endif() + +if(BUILD_TESTS) + # This explicit, non-installed target uses only the RFC 8032 public fixture. + # It is intentionally separate from cosmo-tests and from the production + # trust-anchor target so test material can never satisfy the package gate. + add_executable(cosmo-release-bootstrap-verifier-tests EXCLUDE_FROM_ALL + ${CMAKE_SOURCE_DIR}/test/ReleaseBootstrapVerifierStandaloneTest.cc + ${CMAKE_SOURCE_DIR}/test/fixtures/ReleaseBootstrapTestKey.cc + ${COSMO_RELEASE_BOOTSTRAP_VERIFIER_SOURCE}) + add_dependencies(cosmo-release-bootstrap-verifier-tests third_build) + target_include_directories(cosmo-release-bootstrap-verifier-tests PRIVATE + ${CMAKE_SOURCE_DIR}/src) + target_compile_options(cosmo-release-bootstrap-verifier-tests PRIVATE + ${COSMO_STRICT_WARNINGS}) + target_link_libraries(cosmo-release-bootstrap-verifier-tests PRIVATE openssl_crypto) + set_target_properties(cosmo-release-bootstrap-verifier-tests PROPERTIES + INSTALL_RPATH "\$ORIGIN/../lib" + BUILD_WITH_INSTALL_RPATH ON) + + # Compile the complete bootstrap/migration implementation for AArch64 even + # when controlled production trust objects are unavailable. The public + # RFC 8032 fixture key is confined to this explicitly named, non-installed, + # EXCLUDE_FROM_ALL target and can never satisfy COSMO_HAS_RELEASE_BOOTSTRAP + # or any package/install dependency. + add_executable(cosmo-release-bootstrap-test-fixture EXCLUDE_FROM_ALL + ${CMAKE_SOURCE_DIR}/src/bootstrap/main.cc + ${CMAKE_SOURCE_DIR}/src/bootstrap/ReleaseBootstrap.cc + ${COSMO_RELEASE_BOOTSTRAP_VERIFIER_SOURCE} + ${CMAKE_SOURCE_DIR}/test/fixtures/ReleaseBootstrapTestKey.cc) + add_dependencies(cosmo-release-bootstrap-test-fixture third_build) + target_include_directories(cosmo-release-bootstrap-test-fixture PRIVATE + ${CMAKE_SOURCE_DIR}/src) + target_compile_options(cosmo-release-bootstrap-test-fixture PRIVATE + ${COSMO_STRICT_WARNINGS}) + target_link_libraries(cosmo-release-bootstrap-test-fixture PRIVATE openssl_crypto) + target_link_options(cosmo-release-bootstrap-test-fixture PRIVATE + -Wl,-z,relro -Wl,-z,now -Wl,-z,noexecstack + -Wl,--enable-new-dtags) + set_target_properties(cosmo-release-bootstrap-test-fixture PROPERTIES + INSTALL_RPATH "\$ORIGIN/../lib" + BUILD_WITH_INSTALL_RPATH ON) +endif() + #Backend - specific link libraries if(COSMO_NN_USE_SOPHON_BACKEND) list(APPEND COMMON_LIBS bmlib bmrt bmcv bmvd bmvenc) @@ -332,9 +661,7 @@ if(COSMO_NN_USE_SOPHON_BACKEND) endif() if(COSMO_MODEL_GUARD) target_compile_definitions(${EXECUTABLE_NAME} PRIVATE COSMO_HAS_MODEL_GUARD=1) - if(MODEL_GUARD_LIB) - target_link_libraries(${EXECUTABLE_NAME} PRIVATE ${MODEL_GUARD_LIB}) - endif() + target_link_libraries(${EXECUTABLE_NAME} PRIVATE cosmo_model_guard_v2) endif() if(COSMO_NN_USE_CPU_BACKEND) target_compile_definitions(${EXECUTABLE_NAME} PRIVATE @@ -350,6 +677,8 @@ target_link_libraries(${EXECUTABLE_NAME} PRIVATE ${COMMON_LIBS}) ########################################################## if(BUILD_TESTS) file(GLOB TEST_SRC "test/*.cc" "test/*.cpp" "test/mock/*.cc") + list(FILTER TEST_SRC EXCLUDE REGEX + ".*ReleaseBootstrapVerifierStandaloneTest\\.cc$") #App sources without main.cc set(APP_SRC_NO_MAIN ${APP_SRC}) @@ -370,9 +699,7 @@ if(BUILD_TESTS) endif() if(COSMO_MODEL_GUARD) target_compile_definitions(cosmo-tests PRIVATE COSMO_HAS_MODEL_GUARD=1) - if(MODEL_GUARD_LIB) - target_link_libraries(cosmo-tests PRIVATE ${MODEL_GUARD_LIB}) - endif() + target_link_libraries(cosmo-tests PRIVATE cosmo_model_guard_v2) endif() if(COSMO_NN_USE_CPU_BACKEND) target_compile_definitions(cosmo-tests PRIVATE @@ -413,6 +740,22 @@ install(TARGETS ${EXECUTABLE_NAME} PERMISSIONS OWNER_EXECUTE OWNER_WRITE OWNER_READ GROUP_WRITE GROUP_READ GROUP_EXECUTE ) +if(COSMO_HAS_RELEASE_BOOTSTRAP) + # Keep the signed-release payload member required by the release schema. + install(TARGETS cosmo-release-bootstrap + DESTINATION bin + PERMISSIONS OWNER_EXECUTE OWNER_WRITE OWNER_READ + GROUP_READ GROUP_EXECUTE WORLD_READ WORLD_EXECUTE + ) + # This second copy is outside every movable facade and remains executable + # while bin/scripts/lib are being migrated or restored. + install(TARGETS cosmo-release-bootstrap + DESTINATION .release-bootstrap/bin + PERMISSIONS OWNER_EXECUTE OWNER_WRITE OWNER_READ + GROUP_READ GROUP_EXECUTE WORLD_READ WORLD_EXECUTE + ) +endif() + # Nginx configuration (uses system nginx, no custom binary) install(DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}/nginx/conf DESTINATION bin/nginx_conf @@ -434,6 +777,9 @@ install(DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}/data/ PATTERN "netplan" EXCLUDE PATTERN "resource" EXCLUDE PATTERN "test-video" EXCLUDE + PATTERN "__pycache__" EXCLUDE + PATTERN "*.pyc" EXCLUDE + PATTERN "*.pyo" EXCLUDE ) # Font install(FILES ${CMAKE_CURRENT_SOURCE_DIR}/data/SOURCEHANSANSCN-REGULAR.OTF @@ -444,11 +790,18 @@ install(FILES ${CMAKE_CURRENT_SOURCE_DIR}/data/SOURCEHANSANSCN-REGULAR.OTF # Resource (models, algorithm, layout, etc. from external repository) if(DEFINED RESOURCE_DIR AND IS_DIRECTORY "${RESOURCE_DIR}") message(STATUS "Resource directory: ${RESOURCE_DIR}") - install(DIRECTORY ${RESOURCE_DIR}/ - DESTINATION resource - FILE_PERMISSIONS OWNER_WRITE OWNER_READ GROUP_WRITE GROUP_READ - PATTERN ".git" EXCLUDE - ) + if(COSMO_PACKAGE_MODELS STREQUAL "include") + install(DIRECTORY ${RESOURCE_DIR}/ DESTINATION resource + FILE_PERMISSIONS OWNER_WRITE OWNER_READ GROUP_WRITE GROUP_READ + PATTERN ".git" EXCLUDE + PATTERN "model.nn" PERMISSIONS OWNER_WRITE OWNER_READ GROUP_READ WORLD_READ + PATTERN "__pycache__" EXCLUDE PATTERN "*.pyc" EXCLUDE PATTERN "*.pyo" EXCLUDE) + else() + install(DIRECTORY ${RESOURCE_DIR}/ DESTINATION resource + FILE_PERMISSIONS OWNER_WRITE OWNER_READ GROUP_WRITE GROUP_READ + PATTERN ".git" EXCLUDE PATTERN "models" EXCLUDE + PATTERN "__pycache__" EXCLUDE PATTERN "*.pyc" EXCLUDE PATTERN "*.pyo" EXCLUDE) + endif() else() message(STATUS "No resource directory specified — package will not contain models") endif() @@ -462,10 +815,143 @@ install(DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}/scripts/ PATTERN "static_analysis*" EXCLUDE PATTERN "install-hooks*" EXCLUDE PATTERN "pre-commit" EXCLUDE + PATTERN "__pycache__" EXCLUDE + PATTERN "*.pyc" EXCLUDE + PATTERN "*.pyo" EXCLUDE + PATTERN "package_md5_rename.sh" EXCLUDE + PATTERN "restore-symlinks.sh" EXCLUDE + PATTERN "sync-source-volume.sh" EXCLUDE + PATTERN "release_updater.sh" EXCLUDE + PATTERN "release_health_check.sh" EXCLUDE + PATTERN "install.sh" EXCLUDE + PATTERN "start.sh" EXCLUDE + PATTERN "inte_run_start.sh" EXCLUDE + PATTERN "source_health_check.sh" EXCLUDE + PATTERN "source_run_start.sh" EXCLUDE PATTERN "*.sh" PERMISSIONS OWNER_EXECUTE OWNER_WRITE OWNER_READ GROUP_READ GROUP_EXECUTE ) +set(_COSMO_SOURCE_PACKAGE OFF) +if(COSMO_MODEL_GUARD AND + COSMO_MODEL_GUARD_BUILD_PROFILE STREQUAL "public-runtime") + set(_COSMO_SOURCE_PACKAGE ON) +endif() +string(LENGTH "${COSMO_EDGE_SOURCE_COMMIT}" _COSMO_EDGE_SOURCE_COMMIT_LENGTH) +if(_COSMO_SOURCE_PACKAGE AND + (NOT _COSMO_EDGE_SOURCE_COMMIT_LENGTH EQUAL 40 OR + COSMO_EDGE_SOURCE_COMMIT MATCHES "[^0-9a-f]")) + message(FATAL_ERROR + "SOURCE packaging requires COSMO_EDGE_SOURCE_COMMIT as an exact " + "lower-case 40-hex Edge commit") +endif() + +# These are signed-release lifecycle entry points, not SOURCE runtime helpers. +# Reinstall them explicitly everywhere except the protected SOURCE package so +# existing production and CPU package contents retain their previous behavior. +if(NOT _COSMO_SOURCE_PACKAGE) + install(PROGRAMS + ${CMAKE_CURRENT_SOURCE_DIR}/scripts/install.sh + ${CMAKE_CURRENT_SOURCE_DIR}/scripts/start.sh + ${CMAKE_CURRENT_SOURCE_DIR}/scripts/inte_run_start.sh + DESTINATION scripts + PERMISSIONS OWNER_EXECUTE OWNER_WRITE OWNER_READ + GROUP_READ GROUP_EXECUTE + ) +endif() + +if(_COSMO_SOURCE_PACKAGE) + # SOURCE-only installation assets. They deliberately do not commission a + # device or participate in the signed production release transaction. + install(PROGRAMS + ${CMAKE_CURRENT_SOURCE_DIR}/install-device.sh + DESTINATION . + PERMISSIONS OWNER_EXECUTE OWNER_WRITE OWNER_READ + GROUP_READ GROUP_EXECUTE WORLD_READ WORLD_EXECUTE + ) + install(PROGRAMS + ${CMAKE_CURRENT_SOURCE_DIR}/scripts/source_health_check.sh + ${CMAKE_CURRENT_SOURCE_DIR}/scripts/source_run_start.sh + DESTINATION scripts + PERMISSIONS OWNER_EXECUTE OWNER_WRITE OWNER_READ + GROUP_READ GROUP_EXECUTE WORLD_READ WORLD_EXECUTE + ) + install(FILES + ${CMAKE_CURRENT_SOURCE_DIR}/config/systemd/cosmo-source.service + DESTINATION share/cosmo-source + RENAME cosmo.service + PERMISSIONS OWNER_WRITE OWNER_READ GROUP_READ WORLD_READ + ) +else() + # Formal release transaction entry points are not SOURCE installation + # assets. Keep the controlled production payload unchanged while ensuring + # an ordinary source build cannot be mistaken for the signed update path. + install(PROGRAMS + ${CMAKE_CURRENT_SOURCE_DIR}/scripts/release_updater.sh + ${CMAKE_CURRENT_SOURCE_DIR}/scripts/release_health_check.sh + DESTINATION scripts + PERMISSIONS OWNER_EXECUTE OWNER_WRITE OWNER_READ GROUP_READ GROUP_EXECUTE + ) + install(FILES + ${CMAKE_CURRENT_SOURCE_DIR}/scripts/release_updater.py + ${CMAKE_CURRENT_SOURCE_DIR}/scripts/release_bootstrap_backend.py + DESTINATION scripts + PERMISSIONS OWNER_WRITE OWNER_READ GROUP_READ WORLD_READ + ) + + if(COSMO_MODEL_GUARD_BUILD_PROFILE STREQUAL "production-release") + # Factory setup installs this stable unit only after the device-bound + # Guard certificate and the first signed release have been staged. + install(FILES + ${CMAKE_CURRENT_SOURCE_DIR}/config/systemd/cosmo.service + DESTINATION share/cosmo-factory + PERMISSIONS OWNER_WRITE OWNER_READ GROUP_READ WORLD_READ + ) + endif() +endif() + +# One-time bridge consumed by the historical main-branch start.sh contract: +# scripts/install.sh receives only the log-file path. Install this last so it +# intentionally replaces the signed-release command wrapper in migration builds. +if(COSMO_LEGACY_MIGRATION_PACKAGE) + install(PROGRAMS + ${CMAKE_CURRENT_SOURCE_DIR}/scripts/legacy_migration_install.sh + DESTINATION scripts RENAME install.sh + PERMISSIONS OWNER_EXECUTE OWNER_WRITE OWNER_READ GROUP_READ GROUP_EXECUTE) + install(PROGRAMS + ${CMAKE_CURRENT_SOURCE_DIR}/scripts/start.sh + ${CMAKE_CURRENT_SOURCE_DIR}/scripts/inte_run_start.sh + DESTINATION scripts + PERMISSIONS OWNER_EXECUTE OWNER_WRITE OWNER_READ GROUP_READ GROUP_EXECUTE) +endif() + +if(COSMO_HAS_RELEASE_BOOTSTRAP) + # Factory bootstrap/recovery never imports through the movable scripts + # facade. These stable copies are opened and identity-checked by FD. + install(FILES + ${CMAKE_CURRENT_SOURCE_DIR}/scripts/release_updater.py + ${CMAKE_CURRENT_SOURCE_DIR}/scripts/release_bootstrap_backend.py + DESTINATION .release-bootstrap/scripts + PERMISSIONS OWNER_WRITE OWNER_READ GROUP_READ WORLD_READ + ) + install(PROGRAMS + ${CMAKE_CURRENT_SOURCE_DIR}/scripts/release_health_check.sh + DESTINATION .release-bootstrap/scripts + PERMISSIONS OWNER_EXECUTE OWNER_WRITE OWNER_READ + GROUP_READ GROUP_EXECUTE WORLD_READ WORLD_EXECUTE + ) + # The verifier's sole private dynamic dependency is resolved from this + # directory by its exact $ORIGIN/../lib DT_RUNPATH. Copy only the verified + # SONAME member; a linker alias is not needed at runtime and would widen + # the factory recovery inventory. + install(FILES + ${OPENSSL_CRYPTO_LIB} + DESTINATION .release-bootstrap/lib + PERMISSIONS OWNER_WRITE OWNER_READ OWNER_EXECUTE + GROUP_READ GROUP_EXECUTE WORLD_READ WORLD_EXECUTE + ) +endif() + # Netplan failsafe config (deployed to scripts/ to keep device-side path unchanged) install(DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}/data/netplan/ DESTINATION scripts @@ -495,20 +981,106 @@ install(FILES "${CMAKE_CURRENT_BINARY_DIR}/version.txt" PERMISSIONS OWNER_WRITE OWNER_READ GROUP_READ WORLD_READ ) -# Install model guard shared library to lib/ (used at runtime via dlopen or direct link) -if(COSMO_MODEL_GUARD AND MODEL_GUARD_LIB) - install(FILES ${MODEL_GUARD_LIB} +# Install the exact verified compatibility member. The linker aliases are +# recreated inside the package; no unversioned host/system library is copied. +if(COSMO_MODEL_GUARD) + install(FILES "${MODEL_GUARD_V2_LIBRARY}" DESTINATION lib PERMISSIONS OWNER_WRITE OWNER_READ OWNER_EXECUTE GROUP_READ GROUP_EXECUTE ) + if(COSMO_MODEL_GUARD_BUILD_PROFILE STREQUAL "production-release") + install(PROGRAMS "${MODEL_GUARD_V2_PROVISION_TOOL}" + DESTINATION bin + RENAME cosmo-model-provision + PERMISSIONS OWNER_WRITE OWNER_READ OWNER_EXECUTE + GROUP_READ GROUP_EXECUTE + WORLD_READ WORLD_EXECUTE + ) + endif() + install(FILES "${MODEL_GUARD_V2_HEADER}" + DESTINATION share/cosmo-model-guard + PERMISSIONS OWNER_WRITE OWNER_READ GROUP_READ WORLD_READ + ) + install(CODE [[ + set(model_guard_lib_dir "$ENV{DESTDIR}${CMAKE_INSTALL_PREFIX}/lib") + file(CREATE_LINK libcosmo_model_guard.so.2.0.0 + "${model_guard_lib_dir}/libcosmo_model_guard.so.2" + SYMBOLIC RESULT model_guard_soname_link_result) + if(NOT model_guard_soname_link_result STREQUAL "0") + message(FATAL_ERROR + "Cannot create Model Guard SONAME link: ${model_guard_soname_link_result}") + endif() + file(CREATE_LINK libcosmo_model_guard.so.2 + "${model_guard_lib_dir}/libcosmo_model_guard.so" + SYMBOLIC RESULT model_guard_link_link_result) + if(NOT model_guard_link_link_result STREQUAL "0") + message(FATAL_ERROR + "Cannot create Model Guard linker alias: ${model_guard_link_link_result}") + endif() + ]]) else() - # Create empty lib directory to satisfy legacy upgrade checks on target device + # CPU-only development packages retain the historical empty directory. file(MAKE_DIRECTORY "${CMAKE_CURRENT_BINARY_DIR}/lib") install(DIRECTORY "${CMAKE_CURRENT_BINARY_DIR}/lib" DESTINATION . ) endif() +if(_COSMO_SOURCE_PACKAGE) + # This identity travels inside the extracted payload. It deliberately does + # not contain the outer tar SHA-256, which exists only after CPack has + # produced the archive and is recorded in the distribution filename. + set(COSMO_SOURCE_IDENTITY_INSTALL_CODE [=[ + set(source_identity_root "$ENV{DESTDIR}${CMAKE_INSTALL_PREFIX}") + set(source_identity_engine + "${source_identity_root}/bin/cosmo-engine") + set(source_identity_version + "${source_identity_root}/bin/version.txt") + foreach(source_identity_input IN ITEMS + "${source_identity_engine}" + "${source_identity_version}") + if(NOT EXISTS "${source_identity_input}" OR + IS_DIRECTORY "${source_identity_input}" OR + IS_SYMLINK "${source_identity_input}") + message(FATAL_ERROR + "SOURCE build-identity input is missing or unsafe: " + "${source_identity_input}") + endif() + endforeach() + + file(READ "${source_identity_version}" source_identity_version_text) + if(NOT source_identity_version_text STREQUAL "@PACKAGE_VERSION@\n") + message(FATAL_ERROR + "SOURCE version.txt differs from the configured package version") + endif() + file(SHA256 "${source_identity_engine}" source_identity_engine_sha256) + + string(CONCAT source_identity_hash_input + "cosmo-source-build-identity-v2\n" + "edge_commit=@COSMO_EDGE_SOURCE_COMMIT@\n" + "version=@PACKAGE_VERSION@\n" + "engine_sha256=${source_identity_engine_sha256}\n") + string(SHA256 source_identity_build_id + "${source_identity_hash_input}") + set(source_identity_directory + "${source_identity_root}/share/cosmo-source") + set(source_identity_file + "${source_identity_directory}/build-identity.env") + file(MAKE_DIRECTORY "${source_identity_directory}") + file(WRITE "${source_identity_file}" + "format=cosmo-source-build-identity-v2\n" + "edge_commit=@COSMO_EDGE_SOURCE_COMMIT@\n" + "version=@PACKAGE_VERSION@\n" + "engine_sha256=${source_identity_engine_sha256}\n" + "build_identity=${source_identity_build_id}\n") + message(STATUS + "Installed SOURCE build identity: ${source_identity_build_id}") + ]=]) + string(CONFIGURE "${COSMO_SOURCE_IDENTITY_INSTALL_CODE}" + COSMO_SOURCE_IDENTITY_INSTALL_CODE @ONLY) + install(CODE "${COSMO_SOURCE_IDENTITY_INSTALL_CODE}") +endif() + set(CPACK_PACKAGE_NAME "${PACKAGE_PREFIX}") set(CPACK_PACKAGE_VENDOR "Nanjing Cosmo Wander AI Technology Co., Ltd.") set(CPACK_PACKAGE_VERSION "${VER_MAJOR}.${VER_MINOR}.${VER_PATCH}") @@ -524,10 +1096,26 @@ set(CPACK_OUTPUT_FILE_PREFIX "${CMAKE_BINARY_DIR}/packages") include(CPack) +set(COSMO_PACKAGE_TARGETS ${EXECUTABLE_NAME} web_frontend) +if(COSMO_HAS_RELEASE_BOOTSTRAP) + list(APPEND COSMO_PACKAGE_TARGETS cosmo-release-bootstrap) +endif() add_custom_target(package_all + # CPack names are content-addressed only after generation. Clear artifacts + # from an earlier profile before creating and exporting this build's output. + COMMAND "${CMAKE_COMMAND}" -E remove_directory + "${CMAKE_BINARY_DIR}/packages" + COMMAND "${CMAKE_COMMAND}" -E make_directory + "${CMAKE_BINARY_DIR}/packages" COMMAND cpack - COMMAND bash ${CMAKE_SOURCE_DIR}/scripts/package_md5_rename.sh - ${CMAKE_BINARY_DIR}/packages ${PACKAGE_NAME} - DEPENDS ${EXECUTABLE_NAME} web_frontend - COMMENT "Creating package with MD5 in filename" + COMMAND bash "${CMAKE_SOURCE_DIR}/scripts/package_md5_rename.sh" + "${CMAKE_BINARY_DIR}/packages" "${PACKAGE_NAME}" + "${COSMO_MODEL_GUARD_BUILD_PROFILE}" + "${COSMO_REPRODUCIBLE_BUILD_EPOCH}" + "${COSMO_LEGACY_MIGRATION_PACKAGE}" + DEPENDS ${COSMO_PACKAGE_TARGETS} + COMMENT "Creating package with SHA-256 in filename" ) +if(COSMO_HAS_RELEASE_BOOTSTRAP) + add_dependencies(package_all cosmo_release_public_key_reverify) +endif() diff --git a/README.md b/README.md index 934a1b8bc..91903ce76 100644 --- a/README.md +++ b/README.md @@ -185,9 +185,9 @@ docker compose -f docker-compose.x86.windows.yml up -d --build After startup, follow the [Scenario Configuration tutorial](docs/en/tutorials/02-scenario-config/scenario-config.md) to set up your first AI detection scenario. -### Option B: Sophon Edge Device +### Option B: Sophon Target Build -Use this path for NPU-accelerated deployment. +Use this path to cross-compile and validate the NPU-accelerated aarch64 runtime. ```bash # 1. Clone @@ -196,40 +196,44 @@ git clone https://github.com/cosmo-wander-ai/cosmo-edge.git # git clone https://gitee.com/cosmo-wander-ai/cosmo-edge.git cd cosmo-edge -# 2. Build the Sophon/aarch64 package +# 2. Build the SOURCE package (internal profile: public-runtime) docker compose -f docker-compose.sophon.yml run --rm cosmo-sophon-package -# 3. View exported release packages -ls -lh build_output/ -# The output package will be named like: cosmo-V-.tar.gz - -# 4. Copy the package to the Sophon edge device (replace with actual IP, default is 192.168.100.1) -scp build_output/cosmo-V*.tar.gz root@:/tmp/ - -# 5. SSH to the device, extract the package, and run the installation script -ssh root@ -cd /tmp -tar -zxvf cosmo-V*.tar.gz -sudo bash scripts/install.sh - -# 6. Reboot the device to start the services -sudo reboot +# 3. View the exported build artifact +ls -lh build_output/public-runtime/ +# Filename: ...-SOURCE---.tar.gz ``` +> The SOURCE package is installable for source-modified CosmoEdge deployments, +> but it is not a signed production release and cannot commission a blank +> device. Protected preset models require one device-bound certificate installed +> by a separate authorized workflow; there are no per-model licenses. The +> package excludes production provisioning, release bootstrap, private trust +> material, and signing transaction entry points. +> The archive name records the base Edge commit, the packaged build identity, +> and the outer archive SHA-256. After extraction, `install-device.sh status` +> reports the package version, Edge commit, build identity, and service state; +> it does not claim to recover the no-longer-available outer archive digest. + On Windows PowerShell to build the package: ```powershell .\scripts\build_sophon_package.ps1 ``` -After installing the package and rebooting the device: +The PowerShell entry point uses the same internal profile and writes the SOURCE +package to `build_output/public-runtime/`. See the +[Build Guide](docs/en/guide/build.md) for the SOURCE/controlled-release +boundaries. + +After installing a SOURCE or signed release and rebooting the device: - **Default IP**: `192.168.100.1` (ensure your computer is configured with a static IP in the `192.168.100.x` subnet to connect directly) - **Web Console URL**: `http://192.168.100.1` - **Default Username**: `admin` - **Default Password**: `admin` (it is highly recommended to change this password after your first login) -This path builds a release package and installs it on a Sophon device. For teams that need production hardware, certified CosmoEdge devices include preconfigured Sophon acceleration, production model packages, and deployment support. See [CosmoEdge-ready devices](#cosmoedge-ready-devices). +For teams that need production hardware, certified CosmoEdge devices include preconfigured Sophon acceleration, production model packages, and deployment support. See [CosmoEdge-ready devices](#cosmoedge-ready-devices). Initial Onboarding Guide
diff --git a/README.zh-CN.md b/README.zh-CN.md index 203ce384f..521686c61 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -187,9 +187,9 @@ docker compose -f docker-compose.x86.windows.yml up -d --build 启动后,参照 [场景配置教程](docs/tutorials/02-scenario-config/scenario-config.md) 设置你的第一个 AI 检测场景。 -### 方案 B:Sophon 边缘设备 +### 方案 B:Sophon 目标构建 -该路径用于 Sophon NPU 加速部署。 +该路径用于交叉编译和验证 Sophon NPU 加速的 aarch64 运行时。 ```bash # 1. Clone @@ -198,39 +198,47 @@ git clone https://github.com/cosmo-wander-ai/cosmo-edge.git # git clone https://gitee.com/cosmo-wander-ai/cosmo-edge.git cd cosmo-edge -# 2. 构建 Sophon/aarch64 发布包 +# 2. 使用默认 public-runtime 配置构建 docker compose -f docker-compose.sophon.yml run --rm cosmo-sophon-package -# 3. 查看导出的发布包 -ls -lh build_output/ -# 输出的包名格式如:cosmo-V-.tar.gz +# 3. 查看导出的构建产物 +ls -lh build_output/public-runtime/ +# 文件名包含:-SOURCE---.tar.gz +``` -# 4. 将安装包拷贝到 Sophon 边缘设备上(将 替换为设备的实际 IP,默认是 192.168.100.1) -scp build_output/cosmo-V*.tar.gz root@:/tmp/ +> 默认产物是可以直接安装的 SOURCE 源码构建,但不是正式签名发布包。它包含 +> Guard 运行库和受保护 preset,不包含设备证书签发工具、私钥或正式发布入口。 +> 设备必须另行安装一张与本机绑定的设备证书;该证书授权使用同一产品模型密钥 +> 发布的当前及以后全部 preset,不存在逐模型 license。 -# 5. SSH 登录设备,解压并执行 install.sh 安装脚本 -ssh root@ -cd /tmp -tar -zxvf cosmo-V*.tar.gz -sudo bash scripts/install.sh +将 SOURCE 包复制到设备并解压,然后在解压后的包目录执行: -# 6. 重启设备以启动服务 -sudo reboot +```bash +sudo ./install-device.sh install +sudo ./install-device.sh status ``` +安装器会在需要时创建 `/appfs/cosmo_wander`,直接替换 +`/appfs/cosmo_wander/cwai_data`,不备份旧应用,也不提供回滚命令。它不会读取 +或修改 `/data/cwaiuserdata/model-guard/device-certificate.bin`。 + 在 Windows PowerShell 下构建发布包: ```powershell .\scripts\build_sophon_package.ps1 ``` -安装完成并重启设备后: +PowerShell 入口使用相同的默认配置,输出到 +`build_output/public-runtime/`。配置边界详见[构建指南](docs/guide/build.md)。 +需要官方签名发布和 OTA 时,仍应使用独立的受控 `production-release` 流程。 + +安装 SOURCE 或正式签名发布包并重启设备后: - **默认 IP**:`192.168.100.1`(请确保你的电脑与设备处于同一网段,例如配置静态 IP 为 `192.168.100.x`) - **登录地址**:`http://192.168.100.1` - **默认用户名**:`admin` - **默认密码**:`admin`(首次登录后建议修改) -该路径会构建发布包并安装到 Sophon 设备。需要生产硬件时,认证 CosmoEdge 设备可提供预配置 Sophon 加速、生产模型包和部署支持。参见 [CosmoEdge-ready 设备](#cosmoedge-ready-设备)。 +需要生产硬件时,认证 CosmoEdge 设备可提供预配置 Sophon 加速、生产模型包和部署支持。参见 [CosmoEdge-ready 设备](#cosmoedge-ready-设备)。 初始引导指南
diff --git a/cmake/cryptopp.cmake b/cmake/cryptopp.cmake index 6e956f0ae..f0ed413f8 100644 --- a/cmake/cryptopp.cmake +++ b/cmake/cryptopp.cmake @@ -1,5 +1,6 @@ set(CRYPTOPP_SOURCE_DIR ${CMAKE_CURRENT_SOURCE_DIR}/3rd/cryptopp-cmake-CRYPTOPP_8_9_0) -set(CRYPTOPP_INSTALL_DIR ${THIRDPARTY_INSTALL_PREFIX}/cryptopp) +set(CRYPTOPP_BUILD_INSTALL_DIR ${THIRDPARTY_INSTALL_PREFIX}/cryptopp) +set(CRYPTOPP_INSTALL_DIR ${CRYPTOPP_BUILD_INSTALL_DIR}) set(CRYPTOPP_HEADERS ${CRYPTOPP_INSTALL_DIR}/include) set(CRYPTOPP_LIB ${CRYPTOPP_INSTALL_DIR}/lib/libcryptopp.a) @@ -11,7 +12,7 @@ ExternalProject_Add( CMAKE_ARGS -DCMAKE_TOOLCHAIN_FILE=${CMAKE_TOOLCHAIN_FILE} -DCMAKE_BUILD_TYPE=${CMAKE_BUILD_TYPE} - -DCMAKE_INSTALL_PREFIX=${CRYPTOPP_INSTALL_DIR} + -DCMAKE_INSTALL_PREFIX=${CRYPTOPP_BUILD_INSTALL_DIR} -DCRYPTOPP_SOURCES=${CMAKE_CURRENT_SOURCE_DIR}/3rd/cryptopp-CRYPTOPP_8_9_0 -DCRYPTOPP_BUILD_TESTING=OFF -DCRYPTOPP_BUILD_DOCUMENTATION=OFF diff --git a/cmake/curl.cmake b/cmake/curl.cmake index 0f12b63e7..ee90fb23a 100644 --- a/cmake/curl.cmake +++ b/cmake/curl.cmake @@ -2,6 +2,7 @@ set(CURL_SOURCE_DIR ${CMAKE_CURRENT_SOURCE_DIR}/3rd/curl-8.17.0) set(CURL_INSTALL_DIR ${THIRDPARTY_INSTALL_PREFIX}/curl) set(CURL_HEADERS ${CURL_INSTALL_DIR}/include) set(CURL_LIB ${CURL_INSTALL_DIR}/lib/libcurl.so) +set(CURL_EXTERNAL_DEPENDS openssl_external) ExternalProject_Add( curl_external @@ -12,7 +13,10 @@ ExternalProject_Add( -DCMAKE_TOOLCHAIN_FILE=${CMAKE_TOOLCHAIN_FILE} -DCMAKE_BUILD_TYPE=${CMAKE_BUILD_TYPE} -DCMAKE_INSTALL_PREFIX=${CURL_INSTALL_DIR} - -DOPENSSL_ROOT_DIR=${THIRDPARTY_INSTALL_PREFIX}/openssl + -DOPENSSL_ROOT_DIR=${OPENSSL_INSTALL_DIR} + -DOPENSSL_INCLUDE_DIR=${OPENSSL_HEADERS} + -DOPENSSL_SSL_LIBRARY=${OPENSSL_SSL_LIB} + -DOPENSSL_CRYPTO_LIBRARY=${OPENSSL_CRYPTO_LIB} -DBUILD_SHARED_LIBS=ON -DCURL_USE_LIBPSL=OFF # Cross-compilation skips curl's host CA auto-detection. This path is @@ -28,7 +32,7 @@ ExternalProject_Add( INSTALL_COMMAND ${CMAKE_COMMAND} --build . --target install - DEPENDS openssl_external + DEPENDS ${CURL_EXTERNAL_DEPENDS} UPDATE_COMMAND "" BUILD_ALWAYS OFF diff --git a/cmake/device.cmake b/cmake/device.cmake index 234c7a65a..ef9cdb10d 100644 --- a/cmake/device.cmake +++ b/cmake/device.cmake @@ -1,4 +1,3 @@ -# set(DEVICE_ROOT_DIR ${CMAKE_CURRENT_SOURCE_DIR}) set(DEVICE_ROOT_DIR ${CMAKE_CURRENT_SOURCE_DIR}/3rd/libsophon-0.4.11) set(DEVICE_HEADERS ${DEVICE_ROOT_DIR}/include) set(DEVICE_LIB_DIR ${DEVICE_ROOT_DIR}/lib) @@ -46,7 +45,7 @@ set_target_properties(bmvenc PROPERTIES INTERFACE_INCLUDE_DIRECTORIES "${DEVICE_HEADERS}" ) -install(DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}/3rd/libsophon-0.4.11/lib/ +install(DIRECTORY ${DEVICE_ROOT_DIR}/lib/ DESTINATION lib FILES_MATCHING PATTERN "*so*" diff --git a/cmake/event.cmake b/cmake/event.cmake index 8d79e3693..61120a2c0 100644 --- a/cmake/event.cmake +++ b/cmake/event.cmake @@ -1,4 +1,5 @@ -set(EVENT_SOURCE_DIR ${CMAKE_CURRENT_SOURCE_DIR}/3rd/libevent-2.1.12-stable) +set(EVENT_ORIGINAL_SOURCE_DIR ${CMAKE_CURRENT_SOURCE_DIR}/3rd/libevent-2.1.12-stable) +set(EVENT_SOURCE_DIR ${CMAKE_BINARY_DIR}/event_source) set(EVENT_INSTALL_DIR ${THIRDPARTY_INSTALL_PREFIX}/event) set(EVENT_HEADERS ${EVENT_INSTALL_DIR}/include) set(EVENT_LIB ${EVENT_INSTALL_DIR}/lib/libevent.so) @@ -6,17 +7,31 @@ set(EVENT_CORE_LIB ${EVENT_INSTALL_DIR}/lib/libevent_core.so) set(EVENT_EXTRA_LIB ${EVENT_INSTALL_DIR}/lib/libevent_extra.so) set(EVENT_OPENSSL_LIB ${EVENT_INSTALL_DIR}/lib/libevent_openssl.so) set(EVENT_PTHREADS_LIB ${EVENT_INSTALL_DIR}/lib/libevent_pthreads.so) +set(EVENT_EXTERNAL_DEPENDS openssl_external) ExternalProject_Add( event_external SOURCE_DIR ${EVENT_SOURCE_DIR} + BINARY_DIR ${CMAKE_BINARY_DIR}/event_build + + DOWNLOAD_COMMAND + ${CMAKE_COMMAND} -E rm -rf + COMMAND ${CMAKE_COMMAND} -E copy_directory + ${EVENT_ORIGINAL_SOURCE_DIR} + + PATCH_COMMAND + patch --batch --forward -p1 + -i ${CMAKE_CURRENT_SOURCE_DIR}/cmake/libevent-relative-rpath.patch CMAKE_ARGS -DCMAKE_TOOLCHAIN_FILE=${CMAKE_TOOLCHAIN_FILE} -DCMAKE_BUILD_TYPE=${CMAKE_BUILD_TYPE} -DCMAKE_INSTALL_PREFIX=${EVENT_INSTALL_DIR} - -DOPENSSL_ROOT_DIR=${THIRDPARTY_INSTALL_PREFIX}/openssl + -DOPENSSL_ROOT_DIR=${OPENSSL_INSTALL_DIR} + -DOPENSSL_INCLUDE_DIR=${OPENSSL_HEADERS} + -DOPENSSL_SSL_LIBRARY=${OPENSSL_SSL_LIB} + -DOPENSSL_CRYPTO_LIBRARY=${OPENSSL_CRYPTO_LIB} -DEVENT__LIBRARY_TYPE=SHARED -DEVENT__DISABLE_DEBUG_MODE=ON -DEVENT__ENABLE_VERBOSE_DEBUG=OFF @@ -27,7 +42,7 @@ ExternalProject_Add( INSTALL_COMMAND ${CMAKE_COMMAND} --build . --target install - DEPENDS openssl_external + DEPENDS ${EVENT_EXTERNAL_DEPENDS} UPDATE_COMMAND "" BUILD_ALWAYS OFF @@ -80,4 +95,4 @@ install(DIRECTORY ${EVENT_INSTALL_DIR}/lib/ FILES_MATCHING PATTERN "*event*" PATTERN "*so*" -) \ No newline at end of file +) diff --git a/cmake/libevent-relative-rpath.patch b/cmake/libevent-relative-rpath.patch new file mode 100644 index 000000000..3a1b6172d --- /dev/null +++ b/cmake/libevent-relative-rpath.patch @@ -0,0 +1,5 @@ +--- a/cmake/AddEventLibrary.cmake ++++ b/cmake/AddEventLibrary.cmake +@@ -162 +162 @@ +- INSTALL_RPATH "${CMAKE_INSTALL_PREFIX}/lib") ++ INSTALL_RPATH "$ORIGIN") diff --git a/cmake/mqtt.cmake b/cmake/mqtt.cmake index 6b4a8d2f8..56d4f7dbb 100644 --- a/cmake/mqtt.cmake +++ b/cmake/mqtt.cmake @@ -3,31 +3,41 @@ set(MQTT_INSTALL_DIR ${THIRDPARTY_INSTALL_PREFIX}/mqtt) set(MQTT_HEADERS ${MQTT_INSTALL_DIR}/include) set(MQTT_C_LIB ${MQTT_INSTALL_DIR}/lib/libpaho-mqtt3c.so) set(MQTT_CS_LIB ${MQTT_INSTALL_DIR}/lib/libpaho-mqtt3cs.so) +set(MQTT_EXTERNAL_DEPENDS openssl_external) + +set(MQTT_CONFIGURE_ARGS + -DCMAKE_TOOLCHAIN_FILE=${CMAKE_TOOLCHAIN_FILE} + -DCMAKE_BUILD_TYPE=${CMAKE_BUILD_TYPE} + -DCMAKE_INSTALL_PREFIX=${MQTT_INSTALL_DIR} + -DCMAKE_INSTALL_LIBDIR=lib + -DOPENSSL_ROOT_DIR=${OPENSSL_INSTALL_DIR} + -DOPENSSL_INCLUDE_DIR=${OPENSSL_HEADERS} + -DOPENSSL_SSL_LIBRARY=${OPENSSL_SSL_LIB} + -DOPENSSL_CRYPTO_LIBRARY=${OPENSSL_CRYPTO_LIB} + -DPAHO_WITH_SSL=ON + -DPAHO_WITH_LIBUUID=OFF + -DPAHO_ENABLE_TESTING=OFF + -DPAHO_BUILD_SAMPLES=OFF + -DPAHO_BUILD_DEB_PACKAGE=OFF + -DPAHO_BUILD_DOCUMENTATION=OFF + "-G${CMAKE_GENERATOR}" + +) ExternalProject_Add( mqtt_external SOURCE_DIR ${MQTT_SOURCE_DIR} - CMAKE_ARGS - -DCMAKE_TOOLCHAIN_FILE=${CMAKE_TOOLCHAIN_FILE} - -DCMAKE_BUILD_TYPE=${CMAKE_BUILD_TYPE} - -DCMAKE_INSTALL_PREFIX=${MQTT_INSTALL_DIR} - -DCMAKE_INSTALL_LIBDIR=lib - -DOPENSSL_ROOT_DIR=${THIRDPARTY_INSTALL_PREFIX}/openssl - -DOPENSSL_INCLUDE_DIR=${THIRDPARTY_INSTALL_PREFIX}/openssl/include - -DOPENSSL_SSL_LIBRARY=${THIRDPARTY_INSTALL_PREFIX}/openssl/lib/libssl.so - -DOPENSSL_CRYPTO_LIBRARY=${THIRDPARTY_INSTALL_PREFIX}/openssl/lib/libcrypto.so - -DPAHO_WITH_SSL=ON - -DPAHO_WITH_LIBUUID=OFF - -DPAHO_ENABLE_TESTING=OFF - -DPAHO_BUILD_SAMPLES=OFF - -DPAHO_BUILD_DEB_PACKAGE=OFF - -DPAHO_BUILD_DOCUMENTATION=OFF + # Paho's configure step embeds string(TIMESTAMP) in both shared + # libraries. CMake honors SOURCE_DATE_EPOCH for that operation. + CONFIGURE_COMMAND ${CMAKE_COMMAND} -E env + SOURCE_DATE_EPOCH=${COSMO_REPRODUCIBLE_BUILD_EPOCH} + ${CMAKE_COMMAND} ${MQTT_CONFIGURE_ARGS} INSTALL_COMMAND ${CMAKE_COMMAND} --build . --target install - DEPENDS openssl_external + DEPENDS ${MQTT_EXTERNAL_DEPENDS} UPDATE_COMMAND "" BUILD_ALWAYS OFF diff --git a/cmake/normalize_srs_build_metadata.cmake b/cmake/normalize_srs_build_metadata.cmake new file mode 100644 index 000000000..fdab10d5c --- /dev/null +++ b/cmake/normalize_srs_build_metadata.cmake @@ -0,0 +1,41 @@ +foreach(required_variable IN ITEMS + SRS_AUTO_HEADERS + SRS_BUILD_EPOCH + SRS_BUILD_DATE + SRS_BUILD_UNAME) + if(NOT DEFINED ${required_variable} OR "${${required_variable}}" STREQUAL "") + message(FATAL_ERROR "${required_variable} is required") + endif() +endforeach() + +if(NOT EXISTS "${SRS_AUTO_HEADERS}" OR IS_DIRECTORY "${SRS_AUTO_HEADERS}") + message(FATAL_ERROR "SRS generated header is missing: ${SRS_AUTO_HEADERS}") +endif() + +file(READ "${SRS_AUTO_HEADERS}" srs_auto_headers) + +function(replace_srs_build_define define_name define_value) + string(REGEX MATCHALL + "#define ${define_name} \"[^\"]*\"" + matching_defines + "${srs_auto_headers}") + list(LENGTH matching_defines matching_define_count) + if(NOT matching_define_count EQUAL 1) + message(FATAL_ERROR + "Expected exactly one ${define_name} in ${SRS_AUTO_HEADERS}") + endif() + + list(GET matching_defines 0 matching_define) + string(REPLACE + "${matching_define}" + "#define ${define_name} \"${define_value}\"" + srs_auto_headers + "${srs_auto_headers}") + set(srs_auto_headers "${srs_auto_headers}" PARENT_SCOPE) +endfunction() + +replace_srs_build_define(SRS_BUILD_TS "${SRS_BUILD_EPOCH}") +replace_srs_build_define(SRS_BUILD_DATE "${SRS_BUILD_DATE}") +replace_srs_build_define(SRS_UNAME "${SRS_BUILD_UNAME}") + +file(WRITE "${SRS_AUTO_HEADERS}" "${srs_auto_headers}") diff --git a/cmake/openssl.cmake b/cmake/openssl.cmake index bef6ddee1..65b18546d 100644 --- a/cmake/openssl.cmake +++ b/cmake/openssl.cmake @@ -1,12 +1,26 @@ set(OPENSSL_ORIGINAL_SOURCE_DIR ${CMAKE_CURRENT_SOURCE_DIR}/3rd/openssl-3.5.3) set(OPENSSL_SOURCE_DIR ${OPENSSL_ORIGINAL_SOURCE_DIR}) -set(OPENSSL_INSTALL_DIR ${THIRDPARTY_INSTALL_PREFIX}/openssl) +set(OPENSSL_BUILD_INSTALL_DIR ${THIRDPARTY_INSTALL_PREFIX}/openssl) +set(OPENSSL_INSTALL_DIR ${OPENSSL_BUILD_INSTALL_DIR}) set(OPENSSL_HEADERS ${OPENSSL_INSTALL_DIR}/include) -set(OPENSSL_SSL_LIB ${OPENSSL_INSTALL_DIR}/lib/libssl.so) -set(OPENSSL_CRYPTO_LIB ${OPENSSL_INSTALL_DIR}/lib/libcrypto.so) +if(COSMO_MODEL_GUARD) + set(OPENSSL_SSL_LIB ${OPENSSL_INSTALL_DIR}/lib/libssl.so.3) + set(OPENSSL_CRYPTO_LIB ${OPENSSL_INSTALL_DIR}/lib/libcrypto.so.3) +else() + set(OPENSSL_SSL_LIB ${OPENSSL_INSTALL_DIR}/lib/libssl.so) + set(OPENSSL_CRYPTO_LIB ${OPENSSL_INSTALL_DIR}/lib/libcrypto.so) +endif() set(OPENSSL_DOWNLOAD_COMMAND "") set(OPENSSL_PATCH_COMMAND ${CMAKE_COMMAND} -E true) +# OpenSSL embeds its build time in libcrypto. Keep the dependency byte-for-byte +# compatible with the formally admitted Guard SDK instead of inheriting the +# wall clock of each clean build. +set(OPENSSL_REPRODUCIBLE_ENV + ${CMAKE_COMMAND} -E env + SOURCE_DATE_EPOCH=${COSMO_REPRODUCIBLE_BUILD_EPOCH} +) + if(COSMO_TARGET_ARCH STREQUAL "x86_64") set(OPENSSL_SOURCE_DIR ${CMAKE_BINARY_DIR}/openssl_source) set(OPENSSL_DOWNLOAD_COMMAND @@ -17,7 +31,7 @@ if(COSMO_TARGET_ARCH STREQUAL "x86_64") endif() set(OPENSSL_COMMON_CONFIGURE_ARGS - --prefix=${OPENSSL_INSTALL_DIR} + --prefix=${OPENSSL_BUILD_INSTALL_DIR} --openssldir=/usr/local/ssl --libdir=lib --release @@ -49,10 +63,12 @@ ExternalProject_Add( DOWNLOAD_COMMAND ${OPENSSL_DOWNLOAD_COMMAND} PATCH_COMMAND ${OPENSSL_PATCH_COMMAND} - CONFIGURE_COMMAND ${OPENSSL_CONFIGURE_COMMAND} + CONFIGURE_COMMAND ${OPENSSL_REPRODUCIBLE_ENV} + ${OPENSSL_CONFIGURE_COMMAND} - BUILD_COMMAND $(MAKE) - INSTALL_COMMAND $(MAKE) install_sw + BUILD_COMMAND ${OPENSSL_REPRODUCIBLE_ENV} ${CMAKE_MAKE_PROGRAM} + INSTALL_COMMAND ${OPENSSL_REPRODUCIBLE_ENV} + ${CMAKE_MAKE_PROGRAM} install_sw UPDATE_COMMAND "" BUILD_ALWAYS OFF @@ -78,8 +94,15 @@ set_target_properties(openssl_crypto PROPERTIES ) add_dependencies(openssl_crypto openssl_external) -install(DIRECTORY ${OPENSSL_INSTALL_DIR}/lib/ - DESTINATION lib - FILES_MATCHING - PATTERN "*so*" -) +if(COSMO_MODEL_GUARD) + install(FILES + ${OPENSSL_INSTALL_DIR}/lib/libcrypto.so.3 + ${OPENSSL_INSTALL_DIR}/lib/libssl.so.3 + DESTINATION lib) +else() + install(DIRECTORY ${OPENSSL_INSTALL_DIR}/lib/ + DESTINATION lib + FILES_MATCHING + PATTERN "*so*" + ) +endif() diff --git a/cmake/srs.cmake b/cmake/srs.cmake index 03126df9f..dd6633198 100644 --- a/cmake/srs.cmake +++ b/cmake/srs.cmake @@ -58,6 +58,12 @@ ExternalProject_Add( --srtp-nasm=off --utest=off --jobs=4 + COMMAND ${CMAKE_COMMAND} + "-DSRS_AUTO_HEADERS=/objs/srs_auto_headers.hpp" + "-DSRS_BUILD_EPOCH=${COSMO_REPRODUCIBLE_BUILD_EPOCH}" + "-DSRS_BUILD_DATE=${COSMO_REPRODUCIBLE_BUILD_UTC}" + "-DSRS_BUILD_UNAME=${COSMO_REPRODUCIBLE_BUILD_UNAME}" + -P ${CMAKE_CURRENT_SOURCE_DIR}/cmake/normalize_srs_build_metadata.cmake BUILD_COMMAND $(MAKE) BUILD_IN_SOURCE ON diff --git a/cmake/web_frontend.cmake b/cmake/web_frontend.cmake index 1a223df02..5f2348da0 100644 --- a/cmake/web_frontend.cmake +++ b/cmake/web_frontend.cmake @@ -4,34 +4,58 @@ find_program(NPM_EXECUTABLE npm REQUIRED) set(WEB_BUILD_DIR ${CMAKE_BINARY_DIR}/web) set(WEB_SRC_DIR ${CMAKE_CURRENT_SOURCE_DIR}/src/web) +set(WEB_STAGE_DIR ${WEB_BUILD_DIR}/web_unified) set(WEB_STAMP ${WEB_BUILD_DIR}/web_unified.stamp) +if(DEFINED RESOURCE_DIR AND NOT "${RESOURCE_DIR}" STREQUAL "") + set(WEB_RESOURCE_DIR ${RESOURCE_DIR}) +else() + set(WEB_RESOURCE_DIR ${CMAKE_CURRENT_SOURCE_DIR}/data/resource/aiboxresource) +endif() -file(GLOB_RECURSE WEB_SRC_FILES +file(GLOB_RECURSE WEB_SRC_FILES CONFIGURE_DEPENDS ${WEB_SRC_DIR}/src/* - ${WEB_SRC_DIR}/index.html + ${WEB_SRC_DIR}/public/* + ${WEB_SRC_DIR}/scripts/* ) list(APPEND WEB_SRC_FILES + ${CMAKE_CURRENT_LIST_FILE} + ${CMAKE_CURRENT_SOURCE_DIR}/docs/i18n/GLOSSARY.md + ${CMAKE_CURRENT_SOURCE_DIR}/docs/i18n/SHORT-SCOPES.md + ${WEB_RESOURCE_DIR}/i18n/resource.en-US.json + ${WEB_RESOURCE_DIR}/i18n/resource.zh-CN.json + ${WEB_SRC_DIR}/.npmrc + ${WEB_SRC_DIR}/index.html + ${WEB_SRC_DIR}/package-lock.json ${WEB_SRC_DIR}/package.json ${WEB_SRC_DIR}/vite.config.js ) -file(MAKE_DIRECTORY ${WEB_BUILD_DIR}/web_unified) +file(MAKE_DIRECTORY ${WEB_BUILD_DIR}) add_custom_command( OUTPUT ${WEB_STAMP} DEPENDS ${WEB_SRC_FILES} - COMMAND ${CMAKE_COMMAND} -E copy_directory ${WEB_SRC_DIR} ${WEB_BUILD_DIR}/web_unified - COMMAND ${CMAKE_COMMAND} -E rm -f ${WEB_BUILD_DIR}/web_unified/package-lock.json - # copy_directory dereferences node_modules/.bin symlinks. Restore them when a cached - # dependency tree is present so npm can execute ESM command-line tools offline. - COMMAND ${CMAKE_COMMAND} -DWEB_SRC_DIR=${WEB_SRC_DIR} -DWEB_STAGING_DIR=${WEB_BUILD_DIR}/web_unified - -P ${CMAKE_CURRENT_SOURCE_DIR}/cmake/repair_web_node_bins.cmake - COMMAND ${NPM_EXECUTABLE} install --loglevel=error - COMMAND chmod -R +x node_modules/.bin - COMMAND ${NPM_EXECUTABLE} run build - COMMAND ${CMAKE_COMMAND} -E touch ${WEB_STAMP} - WORKING_DIRECTORY ${WEB_BUILD_DIR}/web_unified + COMMAND ${CMAKE_COMMAND} -E remove_directory "${WEB_STAGE_DIR}" + COMMAND ${CMAKE_COMMAND} -E make_directory "${WEB_STAGE_DIR}" + COMMAND ${CMAKE_COMMAND} -E copy_directory "${WEB_SRC_DIR}/src" "${WEB_STAGE_DIR}/src" + COMMAND ${CMAKE_COMMAND} -E copy_directory "${WEB_SRC_DIR}/public" "${WEB_STAGE_DIR}/public" + COMMAND ${CMAKE_COMMAND} -E copy_directory "${WEB_SRC_DIR}/scripts" "${WEB_STAGE_DIR}/scripts" + COMMAND ${CMAKE_COMMAND} -E copy_if_different "${WEB_SRC_DIR}/.npmrc" "${WEB_STAGE_DIR}/.npmrc" + COMMAND ${CMAKE_COMMAND} -E copy_if_different "${WEB_SRC_DIR}/index.html" "${WEB_STAGE_DIR}/index.html" + COMMAND ${CMAKE_COMMAND} -E copy_if_different "${WEB_SRC_DIR}/package-lock.json" "${WEB_STAGE_DIR}/package-lock.json" + COMMAND ${CMAKE_COMMAND} -E copy_if_different "${WEB_SRC_DIR}/package.json" "${WEB_STAGE_DIR}/package.json" + COMMAND ${CMAKE_COMMAND} -E copy_if_different "${WEB_SRC_DIR}/vite.config.js" "${WEB_STAGE_DIR}/vite.config.js" + COMMAND ${CMAKE_COMMAND} -E chdir "${WEB_STAGE_DIR}" + ${NPM_EXECUTABLE} ci --include=dev --loglevel=error --no-audit --no-fund + COMMAND ${CMAKE_COMMAND} -E chdir "${WEB_STAGE_DIR}" + ${CMAKE_COMMAND} -E env "AIBOX_RESOURCE_DIR=${WEB_RESOURCE_DIR}" + ${NPM_EXECUTABLE} run resource-i18n:check + COMMAND ${CMAKE_COMMAND} -E chdir "${WEB_STAGE_DIR}" + ${CMAKE_COMMAND} -E env "COSMO_REPO_ROOT=${CMAKE_CURRENT_SOURCE_DIR}" + ${NPM_EXECUTABLE} run build + COMMAND ${CMAKE_COMMAND} -E touch "${WEB_STAMP}" COMMENT "Building unified web frontend (Vue 3 + Vite)..." + VERBATIM ) add_custom_target(web_frontend ALL DEPENDS ${WEB_STAMP}) add_dependencies(web_frontend ${EXECUTABLE_NAME}) diff --git a/config/systemd/cosmo-source.service b/config/systemd/cosmo-source.service new file mode 100644 index 000000000..60ed5677b --- /dev/null +++ b/config/systemd/cosmo-source.service @@ -0,0 +1,18 @@ +[Unit] +Description=Cosmo Edge AI Engine (SOURCE runtime) +After=bmrt_setup.service network-online.target +Wants=network-online.target + +[Service] +Type=simple +User=root +Environment=COSMO_SOURCE_RUNTIME=1 +WorkingDirectory=/appfs/cosmo_wander/cwai_data +ExecStart=/appfs/cosmo_wander/cwai_data/scripts/source_run_start.sh +ExecStop=/appfs/cosmo_wander/cwai_data/scripts/stop.sh +Restart=on-failure +RestartSec=10 +TimeoutStopSec=30 + +[Install] +WantedBy=multi-user.target diff --git a/config/systemd/cosmo.service b/config/systemd/cosmo.service new file mode 100644 index 000000000..bf674841b --- /dev/null +++ b/config/systemd/cosmo.service @@ -0,0 +1,17 @@ +[Unit] +Description=Cosmo Edge AI Engine +After=bmrt_setup.service network-online.target +Wants=network-online.target + +[Service] +Type=simple +User=root +WorkingDirectory=/appfs/cosmo_wander/cwai_data +ExecStart=/appfs/cosmo_wander/cwai_data/scripts/inte_run_start.sh +ExecStop=/appfs/cosmo_wander/cwai_data/scripts/stop.sh +Restart=on-failure +RestartSec=10 +TimeoutStopSec=30 + +[Install] +WantedBy=multi-user.target diff --git a/data/resource/aiboxresource/models/prod_BM1688_6047042_YOLOV8n_V1.0.0/model.nn b/data/resource/aiboxresource/models/prod_BM1688_6047042_YOLOV8n_V1.0.0/model.nn index 5b8cdfcba..e76947fa1 100644 Binary files a/data/resource/aiboxresource/models/prod_BM1688_6047042_YOLOV8n_V1.0.0/model.nn and b/data/resource/aiboxresource/models/prod_BM1688_6047042_YOLOV8n_V1.0.0/model.nn differ diff --git a/data/resource/aiboxresource/models/prod_BM1688_7486163_helmet_V1.0.0/model.nn b/data/resource/aiboxresource/models/prod_BM1688_7486163_helmet_V1.0.0/model.nn index 53b9cbe92..8e49db452 100644 Binary files a/data/resource/aiboxresource/models/prod_BM1688_7486163_helmet_V1.0.0/model.nn and b/data/resource/aiboxresource/models/prod_BM1688_7486163_helmet_V1.0.0/model.nn differ diff --git a/docker-compose.sophon.yml b/docker-compose.sophon.yml index afeb69c73..ebdcb64d4 100644 --- a/docker-compose.sophon.yml +++ b/docker-compose.sophon.yml @@ -6,13 +6,30 @@ services: - .:/workspace - ./build_output:/build_output working_dir: /workspace + environment: + COSMO_MODEL_GUARD_BUILD_PROFILE: "${COSMO_MODEL_GUARD_BUILD_PROFILE:-public-runtime}" + COSMO_EDGE_SOURCE_COMMIT: "${COSMO_EDGE_SOURCE_COMMIT:-}" + COSMO_LEGACY_MIGRATION_PACKAGE: "${COSMO_LEGACY_MIGRATION_PACKAGE:-OFF}" + COSMO_PACKAGE_MODELS: "${COSMO_PACKAGE_MODELS:-include}" command: > bash -lc " set -e; - echo 'Starting cross-compilation...'; + if [ \"$${COSMO_MODEL_GUARD_BUILD_PROFILE}\" = public-runtime ]; then + package_variant=SOURCE; + else + package_variant=$${COSMO_MODEL_GUARD_BUILD_PROFILE}; + fi; + echo \"Starting $${package_variant} cross-compilation...\"; ./scripts/build.sh -T -m data/resource/aiboxresource; - mkdir -p /build_output; - cp -f build/packages/* /build_output/ 2>/dev/null || echo 'Warning: No packages found to copy.'; - ls -lh /build_output; + output_dir=/build_output/$${COSMO_MODEL_GUARD_BUILD_PROFILE}; + rm -rf -- \"$${output_dir}\"; + mkdir -p \"$${output_dir}\"; + set -- build/packages/*.tar.gz; + if [ \"$${#}\" -ne 1 ] || [ ! -f \"$${1}\" ]; then + echo 'ERROR: expected exactly one package artifact' >&2; + exit 1; + fi; + cp -f -- \"$${1}\" \"$${output_dir}/\"; + ls -lh \"$${output_dir}\"; echo 'Build finished.' " diff --git a/docs/en/guide/build.md b/docs/en/guide/build.md index a7a4f9c9c..2941498c0 100644 --- a/docs/en/guide/build.md +++ b/docs/en/guide/build.md @@ -1,6 +1,6 @@ --- title: Build Guide -description: Confirmed build paths for x86 Docker, Sophon release packages, CPU test builds, and docs. +description: Confirmed build paths for x86 Docker, Sophon artifacts, CPU test builds, and docs. prev: text: Documentation Home link: /en/ @@ -21,7 +21,7 @@ This page documents build paths that are confirmed and available in the reposito | Target | Entry Point | Notes | | --- | --- | --- | | x86 Docker runtime | `docker-compose.x86.yml` / `docker-compose.x86.windows.yml` | Starts the containerized development/runtime environment. | -| Sophon release package | `docker compose -f docker-compose.sophon.yml run --rm cosmo-sophon-package` | Creates the target-device release package. | +| Sophon SOURCE package | `docker compose -f docker-compose.sophon.yml run --rm cosmo-sophon-package` | Cross-compiles the installable source-build package. | | CPU test build | `scripts/build_cpu_test.sh` | Builds `cosmo-tests` for x86 CPU validation. | | Documentation site | `npm ci` and `npm run docs:build` | Builds this VitePress site. | @@ -66,7 +66,10 @@ After build: - Runtime data stored in Docker volume `cosmo-x86-data`. - Resource directory mounted to Docker volume `cosmo-x86-app-resource`. -## Sophon Release Package +## Sophon Artifacts + +The public entry point defaults to +`COSMO_MODEL_GUARD_BUILD_PROFILE=public-runtime`: ```bash docker compose -f docker-compose.sophon.yml run --rm cosmo-sophon-package @@ -78,6 +81,71 @@ Windows PowerShell: .\scripts\build_sophon_package.ps1 ``` +The two supported profiles are deliberately isolated: + +| Profile | Intended use | Output directory | Deployment status | +| --- | --- | --- | --- | +| SOURCE (`public-runtime`, default) | Public aarch64 compile, link, package, and test validation using the tracked runtime SDK | `build_output/public-runtime/` | Installable source build; not a signed production release | +| `production-release` | Controlled release build with the complete production SDK, provisioning tool, release public key, and release bootstrap inputs | `build_output/production-release/` | Emits a `FACTORY-BASE` only for blank-device setup; OTA still requires a signed release | + +The SOURCE archive name ends with +`-SOURCE---.tar.gz`. It includes +the runtime Guard SDK and SOURCE installation assets, but no provisioner, +release bootstrap, private signing material, or signing transaction entry +point. It can update application code on a device prepared separately; it +cannot commission a blank device or be renamed into a signed release. + +### Install a SOURCE Build on a Device + +Verify the outer archive SHA-256 against the digest in its filename before +extracting it. Then change to the single extracted package directory and run: + +```bash +sudo ./install-device.sh install +sudo ./install-device.sh status +``` + +The installer creates `/appfs/cosmo_wander` when needed, validates the +extracted payload, stops `cosmo.service`, deletes the existing `cwai_data`, +installs the new tree, and starts the SOURCE service. It does not create an +application backup and provides no rollback command. If the final health check +fails, the command reports failure and leaves the newly installed tree in +place for direct diagnosis or reinstall. `status` reports the active mode, +build ID, base Edge commit, package version, and service state. + +On a configured device, the existing +`/data/cwaiuserdata/model-guard/device-certificate.bin` remains in place. That +single device-bound certificate authorizes all current and future preset models +published under the product model key; no per-model licenses exist. On a blank +device, SOURCE can install the application and service, but protected presets +remain unavailable until the separate authorized Guard workflow installs the +certificate. SOURCE `install` and `status` do not access +`/data/cwaiuserdata/model-guard`. + +Maintainers select the production profile only inside the controlled release +environment. The base Compose file alone intentionally cannot do this: an +approved override must mount the complete SDK and each public trust input +read-only and set all required production variables: + +```bash +COSMO_MODEL_GUARD_BUILD_PROFILE=production-release \ + docker compose -f docker-compose.sophon.yml \ + -f /path/to/approved-production.override.yml \ + run --rm cosmo-sophon-package +``` + +The PowerShell entry point validates the same profile value for output +selection, but setting that value alone does not provide the controlled inputs; +it fails closed unless the organization's approved release automation supplies +them. + +The `production-release` CPack artifact is not an OTA archive and is rejected +by the updater. It may be used only as a SHA-256-pinned `FACTORY-BASE` in the +controlled blank-device procedure. Normal installation and upgrades still use +the signed release archive emitted by the offline release process. Release +signing keys must never be placed in the repository or passed to the ordinary +Compose build. + This path is from: - `docker-compose.sophon.yml` @@ -87,9 +155,9 @@ This path is from: Confirmed behavior: - Base image uses the pre-built GHCR image: `ghcr.io/cosmo-wander-ai/cosmo_edge-build-env_sophon:v1` (unified build environment, speeding up local start time). -- Builds with `scripts/build.sh -m data/resource/aiboxresource`. -- Exports the release package only (does not start services). -- Package output under `build_output/`. +- Builds the package and `cosmo-tests` with `scripts/build.sh -T -m data/resource/aiboxresource`. +- Exports build artifacts only (does not start services). +- Keeps profile outputs separate under `build_output//`. ## CPU Test Build diff --git a/docs/en/guide/deployment.md b/docs/en/guide/deployment.md index 27ad284cf..f673988cd 100644 --- a/docs/en/guide/deployment.md +++ b/docs/en/guide/deployment.md @@ -121,37 +121,80 @@ Optional or handled by presence: - `lib` - `resource` -Upgrade package filename pattern: +The upgrade package filename must match one of these patterns: ```text cosmo-V..-<32-char-md5>.tar.gz +cosmo-release-.tar.gz ``` The web console performs a local upgrade as follows: 1. Query device status and record the current Linux `bootId`. 2. Transfer the package in chunks according to live device capabilities while showing actual upload progress. -3. Validate the filename, MD5, archive safety, package layout, and live disk budget. -4. After a Sophon reboot, validate the MD5 again, install the release package, and start the services. +3. For a legacy MD5 package, validate its filename, MD5, archive safety, package layout, and live disk budget. For a signed release, preserve and stage the archive byte-for-byte. +4. After a Sophon reboot, the startup script revalidates and installs a legacy MD5 package; the trusted updater verifies a signed release's signature, manifest, payload, and rollback state before installation. 5. Return to login after observing a new `bootId`. If reboot invalidates the login session, first observe the device offline and then require an authentication response from the recovered service before returning to login. The 15-minute recovery wait is a UI timeout; it does not cancel an upgrade already running on the device. Keep power connected and inspect device networking and systemd logs if it expires. After signing in again, verify the software version against the release package; UI recovery proves reboot and service recovery, not version acceptance. +## Direct SOURCE Installation + +SOURCE deploys a locally modified and rebuilt CosmoEdge. It is not a signed +production release and does not contain the device-certificate provisioning +tool. On a configured device, copy and extract the SOURCE archive, then run: + +```bash +sudo ./install-device.sh install +sudo ./install-device.sh status +``` + +A blank device first needs +`/data/cwaiuserdata/model-guard/device-certificate.bin`, installed through the +separate controlled workflow. One device-bound certificate authorizes every +current and future preset published under the same product model key; there are +no per-model licenses. + +`install` creates `/appfs/cosmo_wander` when needed, stops `cosmo.service`, +deletes the old `/appfs/cosmo_wander/cwai_data`, installs the new application +and SOURCE systemd unit, and then checks service and HTTP health. It creates no +application backup and exposes no rollback or restore command. A failed health +check leaves the new application tree in place for diagnosis. The SOURCE +installer never accesses the Guard certificate. + +`status` is read-only and prints `mode`, `edge_commit`, `version`, +`build_identity`, `service_active`, and `service_enabled`. + ## systemd Service -`scripts/install.sh` creates: +The SOURCE installer uses: + +```text +share/cosmo-source/cosmo.service + -> /etc/systemd/system/cosmo.service + +ExecStart=/appfs/cosmo_wander/cwai_data/scripts/source_run_start.sh +``` + +During blank-device setup, install this file from the controlled +`FACTORY-BASE`: ```text -/etc/systemd/system/cosmo.service +share/cosmo-factory/cosmo.service + -> /etc/systemd/system/cosmo.service ``` Service start command: ```text -ExecStart=${INSTALLPATH}/scripts/inte_run_start.sh +ExecStart=/appfs/cosmo_wander/cwai_data/scripts/inte_run_start.sh ``` -The service runs as `root` with `Restart=on-failure`. A fatal initialization exception returns a non-zero status so systemd retries it instead of treating the process as a clean stop. +The current `scripts/install.sh` handles signed release transactions only; it +does not create the systemd unit. Enable the service only after the +device-bound Model Guard certificate and the first signed release archive are +in place. There are no per-model licenses. The service runs as `root` with +`Restart=on-failure`. Some Sophon images restore the persistent data tree to the appliance administrator at boot. The upload staging service therefore allows `sessions` to inherit the owner of an immediate parent that is not writable by group/other, while still requiring: diff --git a/docs/en/guide/troubleshooting.md b/docs/en/guide/troubleshooting.md index 64f31a28c..3e4cc7c81 100644 --- a/docs/en/guide/troubleshooting.md +++ b/docs/en/guide/troubleshooting.md @@ -61,7 +61,7 @@ The x86 Compose file publishes: If a port is occupied, you can modify the host port in `docker-compose.x86.yml` (or `docker-compose.x86.windows.yml` on Windows), or stop the service that occupies the port. -## No Release Package in `build_output/` +## No Build Artifact in `build_output/` Use the full run command: @@ -81,9 +81,22 @@ For the Sophon path, use: ```bash docker compose -f docker-compose.sophon.yml run --rm cosmo-sophon-package +ls -lh build_output/public-runtime/ ``` -Note: `docker compose build` only builds the image and does not necessarily execute the container command that exports the release package. +Sophon output is not written directly to `build_output/`. It is isolated by +`COSMO_MODEL_GUARD_BUILD_PROFILE`: + +- SOURCE build (internal profile `public-runtime`): `build_output/public-runtime/`; +- controlled production build: `build_output/production-release/`. + +The default filename contains +`-SOURCE---`. It is an installable +source build, not a signed production release. Protected models still require +a device-bound certificate provisioned through the separate authorized +workflow. + +Note: `docker compose build` only builds the image and does not necessarily execute the container command that exports an artifact. ## Sophon Build Failure @@ -99,6 +112,43 @@ Common causes: - Network issues preventing apt/npm/cargo mirror downloads — check `SOPHON_APT_MIRROR` and related environment variables. - Insufficient disk space — the build requires approximately 3GB. +- An unsupported `COSMO_MODEL_GUARD_BUILD_PROFILE` value — only + `public-runtime` and `production-release` are accepted. +- Selecting `production-release` outside the controlled release environment — + missing production SDK, provisioning, release-public-key, or bootstrap inputs is + rejected by design. Use SOURCE for ordinary source-code builds; do not bypass + the formal release checks. + +## Protected Presets Do Not Load + +The device needs exactly one Guard state file: + +```text +/data/cwaiuserdata/model-guard/device-certificate.bin +``` + +Check certificate status and service logs first: + +```bash +sudo test -f /data/cwaiuserdata/model-guard/device-certificate.bin +sudo journalctl -u cosmo.service -b --no-pager -n 200 +``` + +If the controlled provisioner is still present in its temporary device +directory, run `sudo /temporary-directory/cosmo-model-provision status` to +validate the certificate against the live device. The SOURCE package does not +provide that tool. + +- `-2001` (`CMG_V2_CERTIFICATE_UNAVAILABLE`) means the certificate is missing + or unreadable. +- `-2002` (`CMG_V2_CERTIFICATE_REJECTED`) means the certificate is malformed, + has an invalid signature, or was issued for another device. + +Do not create per-model licenses or copy another device's certificate. Create +a fresh request on this device, issue its certificate in the controlled +offline environment, and run +`cosmo-model-provision install --certificate `. +The SOURCE installer does not create, delete, or repair this certificate. ## nginx / SRS / cosmo-engine Not Started @@ -130,10 +180,10 @@ On a Sophon device, inspect: ```bash systemctl status cosmo --no-pager -l journalctl -u cosmo -b --no-pager -n 200 -stat -c '%F %a %U:%G %n' /data/cwaiuserdata/upload/sessions ``` -Normally `cosmo.service` is `active (running)` and the staging root is a real directory with mode `0700`. A fatal initialization exception exits non-zero so `Restart=on-failure` can retry. Do not bypass the checks by recursively widening permissions on all of `/data/cwaiuserdata`. +Normally `cosmo.service` is `active (running)`. A fatal initialization +exception exits non-zero so `Restart=on-failure` can retry. ## Documentation Site Build Fails diff --git a/docs/en/reference/api.md b/docs/en/reference/api.md index 83d8dd5bd..9affff3d2 100644 --- a/docs/en/reference/api.md +++ b/docs/en/reference/api.md @@ -146,6 +146,13 @@ Control-plane JSON requests are limited to 1 MB by default. A regular single mul ### Upgrade Recovery Status +The upgrade request accepts an `uploadId` whose original filename matches either +`cosmo-V..-<32-char-md5>.tar.gz` or +`cosmo-release-.tar.gz`. The backend validates and extracts a legacy +MD5 package before reboot. It stages a signed release byte-for-byte so the +trusted updater can authenticate its signature, manifest, payload, and rollback +state after reboot. + `POST /gtw/cwai/System/QueryDeviceStatus` returns these fields on success: | Field | Meaning | diff --git a/docs/guide/build.md b/docs/guide/build.md index 0a0fcccb4..b33166abe 100644 --- a/docs/guide/build.md +++ b/docs/guide/build.md @@ -1,6 +1,6 @@ --- title: 构建指南 -description: x86 Docker、Sophon 发布包和 CPU 测试构建路径。 +description: x86 Docker、Sophon 构建产物和 CPU 测试构建路径。 prev: text: 文档首页 link: / @@ -21,7 +21,7 @@ next: | 路径 | 用途 | 是否启动服务 | 输出 | | --- | --- | --- | --- | | x86 Docker 开发运行环境 | 首次体验、开发评估、生成 x86 发布包 | 是 | `build_output/` | -| Sophon 发布包构建 | 生成 aarch64/Sophon 部署包 | 否 | `build_output/` | +| Sophon SOURCE 构建 | 交叉编译可安装的源码构建包 | 否 | `build_output/public-runtime/` | | CPU 测试构建 | 构建 `cosmo-tests` | 否 | `build_cpu/cosmo-tests` | ## x86 Docker 开发运行环境 @@ -63,7 +63,10 @@ docker compose -f docker-compose.x86.windows.yml up -d --build - 运行数据保存在 Docker volume `cosmo-x86-data`。 - 资源目录挂载到 Docker volume `cosmo-x86-app-resource`。 -## Sophon 发布包构建 +## Sophon 构建产物 + +公开构建入口默认使用 +`COSMO_MODEL_GUARD_BUILD_PROFILE=public-runtime`: Linux / Bash: @@ -77,6 +80,63 @@ Windows PowerShell: .\scripts\build_sophon_package.ps1 ``` +两个支持的配置使用相互隔离的输出目录: + +| 配置 | 用途 | 输出目录 | 部署状态 | +| --- | --- | --- | --- | +| SOURCE(内部配置 `public-runtime`,默认) | 使用仓库内运行时 SDK 完成公开的 aarch64 编译、链接、打包和测试验证 | `build_output/public-runtime/` | 可安装的源码构建,不是正式签名发布 | +| `production-release` | 在受控环境中使用完整正式 SDK、设备初始化工具、发布信任身份和发布引导输入构建 | `build_output/production-release/` | 输出仅供空机首装的 `FACTORY-BASE`;OTA 仍需离线签名发布包 | + +SOURCE 归档文件名以 +`-SOURCE---.tar.gz` 结尾。 +它包含 Guard 运行时 SDK 和 SOURCE 安装资产,但不包含 provisioner、发布引导、 +私有签名材料或生产签名事务入口。它可以在已经单独完成设备准备的机器上安装修改后 +的应用代码,但不能初始化空白设备,也不能通过重命名变成正式签名发布。 + +### 在设备上安装 SOURCE 构建 + +解压前,先将归档文件名中的 SHA-256 与归档文件的实际 SHA-256 对比。解压后 +进入唯一的包目录并执行: + +```bash +sudo ./install-device.sh install +sudo ./install-device.sh status +``` + +设备没有 `/appfs` 时,安装器会自动创建 `/appfs/cosmo_wander`,验证解压后的 +payload,停止 `cosmo.service`,删除现有 `cwai_data`,安装新应用树并启动 SOURCE +服务。它不创建应用备份,也不提供回滚命令。最终健康检查失败时,命令会报告失败 +并保留新安装的应用树,便于直接诊断或重新安装。`status` 会显示当前模式、build +ID、Edge 基准 commit、包版本和服务状态。 + +设备已经完成配置时, +`/data/cwaiuserdata/model-guard/device-certificate.bin` 保持不变。这一张与本机 +绑定的设备证书授权加载使用同一产品模型密钥发布的当前及以后全部 preset 模型, +不存在逐模型 license。空白设备上 SOURCE 可以安装应用和服务,但受保护 preset +必须先通过独立的受控授权流程安装设备证书后才能运行。SOURCE 的 `install` 和 +`status` 始终不修改 +`/data/cwaiuserdata/model-guard`。 + +维护人员只能在受控发布环境中选择正式配置。仅使用基础 Compose 文件会按 +设计失败;必须通过审核后的 override 以只读方式分别挂载完整 SDK 和各项公开 +信任输入,并设置全部正式构建变量: + +```bash +COSMO_MODEL_GUARD_BUILD_PROFILE=production-release \ + docker compose -f docker-compose.sophon.yml \ + -f /path/to/approved-production.override.yml \ + run --rm cosmo-sophon-package +``` + +PowerShell 入口会验证同一个配置变量并据此选择输出目录,但只设置该变量并 +不能提供受控输入;除非组织的正式发布自动化补充这些输入,否则构建会安全 +失败。 + +`production-release` 生成的 CPack 产物不是 OTA 包,设备更新器不会接受它。 +它只能按登记的 SHA-256 在受控空机首装流程中作为 `FACTORY-BASE`。正常安装和 +升级仍须由离线发布流程生成设备接受的已签名发布归档。发布私钥不得写入仓库, +也不得传给普通 Compose 构建。 + 该路径来自: - `docker-compose.sophon.yml` @@ -86,9 +146,9 @@ Windows PowerShell: 已确认行为: - 基础镜像使用预先构建的 GHCR 镜像:`ghcr.io/cosmo-wander-ai/cosmo_edge-build-env_sophon:v1`(统一的编译环境,加速了本地启动时间)。 -- 使用 `scripts/build.sh -m data/resource/aiboxresource` 构建(生产包不启用 dev mode,故不传 `-t`)。 -- 只导出发布包,不启动服务。 -- 发布包导出到 `build_output/`。 +- 使用 `scripts/build.sh -T -m data/resource/aiboxresource` 构建发布候选产物和 `cosmo-tests`(不启用 dev mode,故不传 `-t`)。 +- 只导出构建产物,不启动服务。 +- 各配置的输出隔离到 `build_output//`。 ## CPU 测试构建 diff --git a/docs/guide/deployment.md b/docs/guide/deployment.md index 1748ed2da..2c3141616 100644 --- a/docs/guide/deployment.md +++ b/docs/guide/deployment.md @@ -121,37 +121,72 @@ COSMO_STREAM_HTTP_PORT=18088 - `lib` - `resource` -升级包文件名匹配: +升级包文件名必须匹配以下一种格式: ```text cosmo-V..-<32-char-md5>.tar.gz +cosmo-release-.tar.gz ``` Web 控制台的本地升级流程如下: 1. 查询设备状态并记录当前 Linux `bootId`。 2. 按设备返回的上传能力分片传输安装包,界面显示实际上传百分比。 -3. 后端校验文件名、MD5、归档安全、目录结构和实时磁盘预算。 -4. Sophon 设备重启后,启动脚本再次校验 MD5、安装发布包并启动服务。 +3. 对旧 MD5 包,后端校验文件名、MD5、归档安全、目录结构和实时磁盘预算;对签名发布包,后端保持归档字节不变并原样暂存。 +4. Sophon 设备重启后,旧包由启动脚本再次校验 MD5 后安装;签名发布包由受信任更新器校验签名、清单、载荷和回滚状态后安装。 5. 页面在看到新的 `bootId` 后返回登录页。如果重启使登录会话失效,则必须先观察到设备离线,再收到新服务的鉴权响应,才能判定服务已恢复并返回登录页。 页面等待恢复的 15 分钟是交互超时,不会中止设备端已经开始的升级。超时后应保持供电,并通过设备网络和 systemd 日志确认状态。重新登录后还应核对软件版本与本次发布包;页面恢复只证明重启与服务恢复,不替代版本验收。 +## SOURCE 直接安装 + +SOURCE 用于部署自行修改并重新构建的 CosmoEdge。它不是正式签名发布包,也不包含 +设备证书签发工具。在已配置设备上,复制并解压 SOURCE 归档后执行: + +```bash +sudo ./install-device.sh install +sudo ./install-device.sh status +``` + +空白设备应先通过独立的受控流程安装 +`/data/cwaiuserdata/model-guard/device-certificate.bin`。一张与本机绑定的证书 +授权当前及以后使用同一产品模型密钥发布的全部 preset,不需要逐模型 license。 + +`install` 会创建缺失的 `/appfs/cosmo_wander`,停止 `cosmo.service`,删除原 +`/appfs/cosmo_wander/cwai_data`,安装新应用和 SOURCE systemd 单元,然后执行 +服务及 HTTP 健康检查。它不备份旧应用,不提供回滚或恢复命令;健康检查失败时 +保留新应用树供排查。SOURCE 安装器不会访问 Guard 证书。 + +`status` 是只读命令,输出 `mode`、`edge_commit`、`version`、 +`build_identity`、`service_active` 和 `service_enabled`。 + ## systemd 服务 -`scripts/install.sh` 会创建: +SOURCE 安装器使用: + +```text +share/cosmo-source/cosmo.service + -> /etc/systemd/system/cosmo.service + +ExecStart=/appfs/cosmo_wander/cwai_data/scripts/source_run_start.sh +``` + +空机首装时,从正式 `FACTORY-BASE` 安装: ```text -/etc/systemd/system/cosmo.service +share/cosmo-factory/cosmo.service + -> /etc/systemd/system/cosmo.service ``` 服务启动命令: ```text -ExecStart=${INSTALLPATH}/scripts/inte_run_start.sh +ExecStart=/appfs/cosmo_wander/cwai_data/scripts/inte_run_start.sh ``` -服务以 `root` 运行并使用 `Restart=on-failure`。致命初始化异常会返回非零状态,使 systemd 能够重试,而不会把异常退出误判为正常停止。 +当前 `scripts/install.sh` 只负责已签名发布事务,不创建 systemd unit。应在设备 +绑定证书和首个签名发布包都就位后再启用服务;不存在逐模型 license。服务以 +`root` 运行并使用 `Restart=on-failure`。 部分 Sophon 系统会在启动时把持久化数据树的属主恢复为设备管理账户。上传暂存服务允许 `sessions` 目录继承一个不可被 group/other 写入的直接父目录属主,同时继续要求: diff --git a/docs/guide/troubleshooting.md b/docs/guide/troubleshooting.md index a0df6922b..1408dec18 100644 --- a/docs/guide/troubleshooting.md +++ b/docs/guide/troubleshooting.md @@ -61,7 +61,7 @@ x86 Compose 会发布: 如果端口被占用,可以修改 `docker-compose.x86.yml` (或 Windows 上的 `docker-compose.x86.windows.yml`) 的主机端口,或停止占用端口的服务。 -## `build_output/` 没有发布包 +## `build_output/` 没有构建产物 使用完整运行命令: @@ -81,9 +81,21 @@ Sophon 路径使用: ```bash docker compose -f docker-compose.sophon.yml run --rm cosmo-sophon-package +ls -lh build_output/public-runtime/ ``` -注意:`docker compose build` 只构建镜像,不一定执行导出发布包的容器命令。 +Sophon 产物不会直接写在 `build_output/` 根目录,而是按 +`COSMO_MODEL_GUARD_BUILD_PROFILE` 隔离: + +- SOURCE 构建(内部配置 `public-runtime`):`build_output/public-runtime/`; +- 受控正式构建:`build_output/production-release/`。 + +默认文件名包含 +`-SOURCE---`。它是可安装的源码 +构建,但不是正式签名发布;受保护 preset 模型仍要求通过独立的受控授权流程安装 +一张与本机绑定的设备证书,不存在逐模型 license。 + +注意:`docker compose build` 只构建镜像,不一定执行导出产物的容器命令。 ## Sophon 构建失败 @@ -99,6 +111,39 @@ docker compose -f docker-compose.sophon.yml run --rm cosmo-sophon-package 2>&1 | - 网络问题导致 apt/npm/cargo 镜像下载失败 — 检查 `SOPHON_APT_MIRROR` 等环境变量。 - 磁盘空间不足 — 构建过程需要约 3GB 空间。 +- `COSMO_MODEL_GUARD_BUILD_PROFILE` 取值不受支持——只接受 + `public-runtime` 和 `production-release`。 +- 在非受控发布环境选择 `production-release`——缺少正式 SDK、设备初始化、 + 信任身份、签发者或发布引导输入时按设计拒绝构建。普通源码修改应使用 + SOURCE,不要绕过正式发布检查。 + +## 受保护 preset 无法加载 + +设备只需要以下一个 Guard 状态文件: + +```text +/data/cwaiuserdata/model-guard/device-certificate.bin +``` + +先检查证书状态和服务日志: + +```bash +sudo test -f /data/cwaiuserdata/model-guard/device-certificate.bin +sudo journalctl -u cosmo.service -b --no-pager -n 200 +``` + +如果受控 provisioner 仍在设备的临时目录,还可以运行 +`sudo /临时目录/cosmo-model-provision status` 直接校验证书和本机绑定;SOURCE +包本身不提供该工具。 + +- `-2001`(`CMG_V2_CERTIFICATE_UNAVAILABLE`):证书文件不存在或无法读取。 +- `-2002`(`CMG_V2_CERTIFICATE_REJECTED`):证书损坏、签名无效,或证书不是 + 为本机签发。 + +不要生成逐模型 license,也不要复制另一台设备的证书。使用本机生成的新请求在 +受控离线环境重新签发证书,再执行 +`cosmo-model-provision install --certificate <证书绝对路径>`。SOURCE 安装器 +不会创建、删除或修复该证书。 ## nginx / SRS / cosmo-engine 未启动 diff --git a/docs/reference/api.md b/docs/reference/api.md index f380740b4..8e53bef00 100644 --- a/docs/reference/api.md +++ b/docs/reference/api.md @@ -146,6 +146,12 @@ QueryLogs ### 升级恢复状态 +升级请求接受 `uploadId`,其原始文件名必须匹配 +`cosmo-V..-<32-char-md5>.tar.gz` 或 +`cosmo-release-.tar.gz`。后端会在重启前校验并解压旧 MD5 +包;签名发布包则保持字节不变并原样暂存,由受信任更新器在重启后校验签名、 +清单、载荷和回滚状态。 + `POST /gtw/cwai/System/QueryDeviceStatus` 成功时返回: | 字段 | 语义 | diff --git a/install-device.sh b/install-device.sh new file mode 100755 index 000000000..c5e672430 --- /dev/null +++ b/install-device.sh @@ -0,0 +1,225 @@ +#!/bin/bash +set -euo pipefail + +IFS=$' \t\n' +PATH='/usr/sbin:/usr/bin:/sbin:/bin' +export IFS PATH + +readonly SERVICE_NAME='cosmo.service' + +fail() { + echo "[SOURCE-INSTALL] ERROR: $*" >&2 + exit 1 +} + +log() { + echo "[SOURCE-INSTALL] $*" +} + +if [ "${COSMO_SOURCE_INSTALL_TESTING:-}" = '1' ]; then + test_root="${COSMO_SOURCE_INSTALL_TEST_ROOT:-}" + case "$test_root" in + /*) ;; + *) fail "test root must be absolute" ;; + esac + [ "$test_root" != '/' ] && [ -d "$test_root" ] || + fail "test root is invalid" + active_root="${test_root}/appfs/cosmo_wander/cwai_data" + service_unit="${test_root}/etc/systemd/system/${SERVICE_NAME}" + systemctl_command="${COSMO_SOURCE_INSTALL_TEST_SYSTEMCTL:-}" + health_check_command="${COSMO_SOURCE_INSTALL_TEST_HEALTH_CHECK:-}" + health_timeout="${COSMO_SOURCE_INSTALL_TEST_HEALTH_TIMEOUT:-1}" + [ -x "$systemctl_command" ] || fail "test systemctl is unavailable" + [ -x "$health_check_command" ] || fail "test health check is unavailable" +else + active_root='/appfs/cosmo_wander/cwai_data' + service_unit="/etc/systemd/system/${SERVICE_NAME}" + health_check_command='' + health_timeout=30 + if [ -x /usr/bin/systemctl ]; then + systemctl_command='/usr/bin/systemctl' + elif [ -x /bin/systemctl ]; then + systemctl_command='/bin/systemctl' + else + fail "systemctl is unavailable" + fi +fi + +case "$health_timeout" in + '' | *[!0-9]*) fail "health timeout must be a positive integer" ;; +esac +[ "$health_timeout" -gt 0 ] || fail "health timeout must be positive" + +readonly active_root service_unit systemctl_command health_check_command +readonly health_timeout +readonly active_parent="${active_root%/*}" +readonly staging_root="${active_parent}/.cosmo-source-staging.$$" + +script_path="$(readlink -f -- "$0")" +[ -n "$script_path" ] || fail "cannot resolve installer path" +readonly payload_root="${script_path%/*}" + +systemctl_run() { + "$systemctl_command" "$@" +} + +manifest_value() { + local file="$1" key="$2" + awk -F= -v key="$key" ' + $1 == key { + if (found || NF < 2) { + exit 2 + } + value = substr($0, length(key) + 2) + found = 1 + } + END { + if (!found || value == "") { + exit 1 + } + print value + } + ' "$file" +} + +validate_payload() { + local path + for path in \ + bin/cosmo-engine \ + bin/version.txt \ + lib/libcosmo_model_guard.so.2.0.0 \ + scripts/run_start.sh \ + scripts/source_run_start.sh \ + scripts/source_health_check.sh \ + scripts/stop.sh \ + share/cosmo-source/build-identity.env \ + share/cosmo-source/cosmo.service + do + [ -f "${payload_root}/${path}" ] || + fail "SOURCE payload is missing ${path}" + done + for path in \ + bin/cosmo-engine \ + scripts/run_start.sh \ + scripts/source_run_start.sh \ + scripts/source_health_check.sh \ + scripts/stop.sh + do + [ -x "${payload_root}/${path}" ] || + fail "SOURCE payload is not executable: ${path}" + done + +} + +prepare_staging() { + mkdir -p -- "$active_parent" + [ ! -e "$staging_root" ] && [ ! -L "$staging_root" ] || + fail "staging path already exists: ${staging_root}" + mkdir -- "$staging_root" + cp -a -- "${payload_root}/." "${staging_root}/" +} + +cleanup_staging() { + if [ -n "${staging_root:-}" ] && + { [ -e "$staging_root" ] || [ -L "$staging_root" ]; }; then + rm -rf -- "$staging_root" + fi +} + +start_and_check() { + local health_script="$1" attempt=0 + systemctl_run daemon-reload + systemctl_run enable "$SERVICE_NAME" + systemctl_run restart "$SERVICE_NAME" + while [ "$attempt" -lt "$health_timeout" ]; do + if systemctl_run is-active --quiet "$SERVICE_NAME" && + "$health_script"; then + return 0 + fi + attempt=$((attempt + 1)) + [ "$attempt" -ge "$health_timeout" ] || sleep 1 + done + return 1 +} + +install_action() { + validate_payload + prepare_staging + trap cleanup_staging EXIT + + systemctl_run stop "$SERVICE_NAME" >/dev/null 2>&1 || true + rm -rf -- "$active_root" + mv -- "$staging_root" "$active_root" + trap - EXIT + + mkdir -p -- "${service_unit%/*}" + cp -- "${active_root}/share/cosmo-source/cosmo.service" "$service_unit" + + local health_script="${active_root}/scripts/source_health_check.sh" + if [ -n "$health_check_command" ]; then + health_script="$health_check_command" + fi + if ! start_and_check "$health_script"; then + fail "SOURCE runtime was installed but failed its health check" + fi + + local build_id edge_commit + build_id="$( + manifest_value \ + "${active_root}/share/cosmo-source/build-identity.env" \ + build_identity + )" + edge_commit="$( + manifest_value \ + "${active_root}/share/cosmo-source/build-identity.env" \ + edge_commit + )" + log "SOURCE runtime installed at ${active_root}" + log "build_id=${build_id} edge_commit=${edge_commit}" + log "No application backup was created; Guard certificate state was not accessed" +} + +print_service_status() { + if systemctl_run is-active --quiet "$SERVICE_NAME" >/dev/null 2>&1; then + echo 'service_active=yes' + else + echo 'service_active=no' + fi + if systemctl_run is-enabled --quiet "$SERVICE_NAME" >/dev/null 2>&1; then + echo 'service_enabled=yes' + else + echo 'service_enabled=no' + fi +} + +status_action() { + local identity="${active_root}/share/cosmo-source/build-identity.env" + if [ ! -f "$identity" ]; then + echo 'mode=unmanaged' + print_service_status + return 0 + fi + echo 'mode=source' + for key in edge_commit version build_identity; do + printf '%s=%s\n' "$key" "$(manifest_value "$identity" "$key")" + done + print_service_status +} + +usage() { + echo "Usage: $0 {install|status}" >&2 +} + +[ "$#" -eq 1 ] || { + usage + exit 2 +} + +case "$1" in + install) install_action ;; + status) status_action ;; + *) + usage + exit 2 + ;; +esac diff --git a/prebuild/model-guard-v2/README.md b/prebuild/model-guard-v2/README.md new file mode 100644 index 000000000..7577e048c --- /dev/null +++ b/prebuild/model-guard-v2/README.md @@ -0,0 +1,44 @@ +# Cosmo Model Guard v2 SDK + +This directory exposes the public, consumer-facing portion of the formally +built Model Guard v2 SDK used by CosmoEdge: + +- `include/cosmo_model_guard_v2.h` +- `lib/libcosmo_model_guard.so*` + +The checked-in AArch64 shared library has the `v2-only` runtime compatibility +profile. It does not expose the legacy Model Guard ABI. + +The default CosmoEdge build profile remains `public-runtime` for automation +compatibility; its user-facing artifact is the SOURCE package. The public SDK +and SOURCE package contain the runtime library and public header, but no +`bin/cosmo-model-provision` or private signing material. A configured device +needs only +`/data/cwaiuserdata/model-guard/device-certificate.bin` to authorize all +current and future preset models published under the product model key. There +are no per-model licenses. SOURCE cannot commission a blank device or construct +or sign a formal production release. + +`bin/cosmo-model-provision` is an offline device-initialization tool and is not +part of the public runtime SDK. It remains ignored by Git and must not be +force-added. Selecting `production-release` does not create or recover any +signing key. + +This public repository does not contain the private Model Guard source, +production signing keys, device secrets, or the complete controlled inputs +required to reconstruct, sign, or deploy a production package. This README +does not grant or alter artifact licensing or redistribution rights; those +require separately approved terms from the artifact owner. + +## Verification + +The canonical public Sophon build invokes +`scripts/verify_model_guard_v2_sdk.py` for the checked-in runtime SDK: + +```bash +/usr/bin/python3 -I -B scripts/verify_model_guard_v2_sdk.py \ + --admission-profile public-runtime \ + --sdk-root "$MODEL_GUARD_SDK_ROOT" \ + --readelf "$AARCH64_READELF" \ + --nm "$AARCH64_NM" +``` diff --git a/prebuild/model-guard-v2/include/cosmo_model_guard_v2.h b/prebuild/model-guard-v2/include/cosmo_model_guard_v2.h new file mode 100644 index 000000000..f7a438c66 --- /dev/null +++ b/prebuild/model-guard-v2/include/cosmo_model_guard_v2.h @@ -0,0 +1,169 @@ +#ifndef COSMO_MODEL_GUARD_V2_H_ +#define COSMO_MODEL_GUARD_V2_H_ + +#include +#include + +#include + +#ifndef CMG_V2_API +#if defined(__GNUC__) || defined(__clang__) +#define CMG_V2_API __attribute__((visibility("default"))) +#else +#define CMG_V2_API +#endif +#endif + +#define CMG_V2_ABI_MAJOR UINT32_C(2) + +typedef struct CmgV2Artifact CmgV2Artifact; + +typedef int32_t CmgV2Status; + +#define CMG_V2_OK ((CmgV2Status)0) + +#define CMG_V2_FORMAT_INVALID ((CmgV2Status) - 1001) +#define CMG_V2_FORMAT_UNSUPPORTED ((CmgV2Status) - 1002) +#define CMG_V2_FORMAT_SOURCE_MISMATCH ((CmgV2Status) - 1003) +#define CMG_V2_FORMAT_LIMIT ((CmgV2Status) - 1004) + +#define CMG_V2_LICENSE_UNAVAILABLE ((CmgV2Status) - 2001) +#define CMG_V2_LICENSE_REJECTED ((CmgV2Status) - 2002) +/* v2.3 names for the same frozen ABI status values. */ +#define CMG_V2_CERTIFICATE_UNAVAILABLE CMG_V2_LICENSE_UNAVAILABLE +#define CMG_V2_CERTIFICATE_REJECTED CMG_V2_LICENSE_REJECTED + +#define CMG_V2_CRYPTO_FAILED ((CmgV2Status) - 3001) + +#define CMG_V2_RESOURCE_INVALID_ARGUMENT ((CmgV2Status) - 4001) +#define CMG_V2_RESOURCE_INVALID_STATE ((CmgV2Status) - 4002) +#define CMG_V2_RESOURCE_IO ((CmgV2Status) - 4003) +#define CMG_V2_RESOURCE_NO_MEMORY ((CmgV2Status) - 4004) +#define CMG_V2_RESOURCE_BUSY ((CmgV2Status) - 4005) +#define CMG_V2_RESOURCE_INTERNAL ((CmgV2Status) - 4006) +#define CMG_V2_RESOURCE_ABI_MISMATCH ((CmgV2Status) - 4007) + +#define CMG_V2_BACKEND_FAILED ((CmgV2Status) - 5001) + +typedef uint32_t CmgV2SourceFormat; + +#define CMG_V2_SOURCE_COSMO_NN_V1 ((CmgV2SourceFormat)UINT32_C(1)) +#define CMG_V2_SOURCE_RAW_BMODEL ((CmgV2SourceFormat)UINT32_C(2)) + +typedef uint32_t CmgV2SophonLoadFlags; + +#define CMG_V2_SOPHON_SHARE_MEM ((CmgV2SophonLoadFlags)UINT32_C(0x00000001)) + +#define CMG_V2_ARTIFACT_INFO_SIZE UINT32_C(72) +#define CMG_V2_SOPHON_LOAD_OPTIONS_SIZE UINT32_C(16) + +typedef struct CmgV2ArtifactInfo { + /* In: caller capacity. Out: bytes defined and written by the guard. */ + uint32_t struct_size; + uint32_t source_format; + uint32_t segment_count; + uint32_t reserved; + uint8_t artifact_id[16]; + uint64_t generation; + uint8_t model_identity_sha256[32]; +} CmgV2ArtifactInfo; + +typedef struct CmgV2SophonLoadOptions { + /* In: at least CMG_V2_SOPHON_LOAD_OPTIONS_SIZE; v2 reads only this prefix. */ + uint32_t struct_size; + uint32_t flags; + uint32_t reserved[2]; +} CmgV2SophonLoadOptions; + +#ifdef __cplusplus +extern "C" { +#endif + +/* + * On every failure, *out_artifact is NULL. A successful open has already + * authenticated the core and the device preset certificate, checked the live + * device binding, and derived the preset-model content key. + */ +CMG_V2_API CmgV2Status CmgV2OpenArtifact( + const char *installed_model_path, CmgV2SourceFormat expected_source_format, + CmgV2Artifact **out_artifact); + +/* + * The caller zero-initializes out_info and sets struct_size to its capacity. + * This ABI version requires at least CMG_V2_ARTIFACT_INFO_SIZE bytes, writes + * only the known 72-byte prefix, and returns struct_size == 72 on success. + */ +CMG_V2_API CmgV2Status CmgV2GetArtifactInfo(const CmgV2Artifact *artifact, + CmgV2ArtifactInfo *out_info); + +/* + * options may be NULL for defaults. Otherwise struct_size must be at least + * CMG_V2_SOPHON_LOAD_OPTIONS_SIZE; larger structures are accepted but v2 reads + * only the known 16-byte prefix. Known reserved fields must be zero and unknown + * flags are rejected. On every failure, *out_bmrt is NULL. On success, + * ownership of the bmrt handle transfers to the caller. bm_handle is borrowed + * and must outlive the transferred bmrt handle. + */ +CMG_V2_API CmgV2Status CmgV2LoadSophonSegment( + CmgV2Artifact *artifact, bm_handle_t bm_handle, uint32_t segment_index, + const CmgV2SophonLoadOptions *options, void **out_bmrt); + +/* NULL is accepted. A non-NULL artifact must be closed exactly once. */ +CMG_V2_API void CmgV2CloseArtifact(CmgV2Artifact *artifact); + +#ifdef __cplusplus +} +#endif + +#if defined(__cplusplus) +#define CMG_V2_STATIC_ASSERT(condition, message) \ + static_assert((condition), message) +#define CMG_V2_ALIGNOF(type) alignof(type) +#else +#define CMG_V2_STATIC_ASSERT(condition, message) \ + _Static_assert((condition), message) +#define CMG_V2_ALIGNOF(type) _Alignof(type) +#endif + +CMG_V2_STATIC_ASSERT(sizeof(CmgV2Status) == 4, "CmgV2Status must be 32 bits"); +CMG_V2_STATIC_ASSERT(sizeof(CmgV2SourceFormat) == 4, + "CmgV2SourceFormat must be 32 bits"); +CMG_V2_STATIC_ASSERT(sizeof(CmgV2SophonLoadFlags) == 4, + "CmgV2SophonLoadFlags must be 32 bits"); + +CMG_V2_STATIC_ASSERT(sizeof(CmgV2ArtifactInfo) == CMG_V2_ARTIFACT_INFO_SIZE, + "CmgV2ArtifactInfo ABI size mismatch"); +CMG_V2_STATIC_ASSERT(CMG_V2_ALIGNOF(CmgV2ArtifactInfo) == 8, + "CmgV2ArtifactInfo ABI alignment mismatch"); +CMG_V2_STATIC_ASSERT(offsetof(CmgV2ArtifactInfo, struct_size) == 0, + "CmgV2ArtifactInfo.struct_size ABI offset mismatch"); +CMG_V2_STATIC_ASSERT(offsetof(CmgV2ArtifactInfo, source_format) == 4, + "CmgV2ArtifactInfo.source_format ABI offset mismatch"); +CMG_V2_STATIC_ASSERT(offsetof(CmgV2ArtifactInfo, segment_count) == 8, + "CmgV2ArtifactInfo.segment_count ABI offset mismatch"); +CMG_V2_STATIC_ASSERT(offsetof(CmgV2ArtifactInfo, reserved) == 12, + "CmgV2ArtifactInfo.reserved ABI offset mismatch"); +CMG_V2_STATIC_ASSERT(offsetof(CmgV2ArtifactInfo, artifact_id) == 16, + "CmgV2ArtifactInfo.artifact_id ABI offset mismatch"); +CMG_V2_STATIC_ASSERT(offsetof(CmgV2ArtifactInfo, generation) == 32, + "CmgV2ArtifactInfo.generation ABI offset mismatch"); +CMG_V2_STATIC_ASSERT( + offsetof(CmgV2ArtifactInfo, model_identity_sha256) == 40, + "CmgV2ArtifactInfo.model_identity_sha256 ABI offset mismatch"); + +CMG_V2_STATIC_ASSERT(sizeof(CmgV2SophonLoadOptions) == + CMG_V2_SOPHON_LOAD_OPTIONS_SIZE, + "CmgV2SophonLoadOptions ABI size mismatch"); +CMG_V2_STATIC_ASSERT(CMG_V2_ALIGNOF(CmgV2SophonLoadOptions) == 4, + "CmgV2SophonLoadOptions ABI alignment mismatch"); +CMG_V2_STATIC_ASSERT(offsetof(CmgV2SophonLoadOptions, struct_size) == 0, + "CmgV2SophonLoadOptions.struct_size ABI offset mismatch"); +CMG_V2_STATIC_ASSERT(offsetof(CmgV2SophonLoadOptions, flags) == 4, + "CmgV2SophonLoadOptions.flags ABI offset mismatch"); +CMG_V2_STATIC_ASSERT(offsetof(CmgV2SophonLoadOptions, reserved) == 8, + "CmgV2SophonLoadOptions.reserved ABI offset mismatch"); + +#undef CMG_V2_ALIGNOF +#undef CMG_V2_STATIC_ASSERT + +#endif /* COSMO_MODEL_GUARD_V2_H_ */ diff --git a/prebuild/model-guard-v2/lib/libcosmo_model_guard.so b/prebuild/model-guard-v2/lib/libcosmo_model_guard.so new file mode 120000 index 000000000..17fca575c --- /dev/null +++ b/prebuild/model-guard-v2/lib/libcosmo_model_guard.so @@ -0,0 +1 @@ +libcosmo_model_guard.so.2 \ No newline at end of file diff --git a/prebuild/model-guard-v2/lib/libcosmo_model_guard.so.2 b/prebuild/model-guard-v2/lib/libcosmo_model_guard.so.2 new file mode 120000 index 000000000..0017e842e --- /dev/null +++ b/prebuild/model-guard-v2/lib/libcosmo_model_guard.so.2 @@ -0,0 +1 @@ +libcosmo_model_guard.so.2.0.0 \ No newline at end of file diff --git a/prebuild/model-guard-v2/lib/libcosmo_model_guard.so.2.0.0 b/prebuild/model-guard-v2/lib/libcosmo_model_guard.so.2.0.0 new file mode 100644 index 000000000..a4cf07abc Binary files /dev/null and b/prebuild/model-guard-v2/lib/libcosmo_model_guard.so.2.0.0 differ diff --git a/scripts/build.sh b/scripts/build.sh index 448667c3b..bd3e740c7 100755 --- a/scripts/build.sh +++ b/scripts/build.sh @@ -1,6 +1,22 @@ #!/bin/bash +set -euo pipefail + export LC_ALL=C.UTF-8 +COSMO_MODEL_GUARD_BUILD_PROFILE="${COSMO_MODEL_GUARD_BUILD_PROFILE:-public-runtime}" +case "${COSMO_MODEL_GUARD_BUILD_PROFILE}" in + public-runtime) + PACKAGE_VARIANT="SOURCE" + ;; + production-release) + PACKAGE_VARIANT="production-release" + ;; + *) + echo "ERROR: COSMO_MODEL_GUARD_BUILD_PROFILE must be public-runtime or production-release" >&2 + exit 1 + ;; +esac + # ── Parse options ── # -t = dev mode (disable watchdog); -T = also build cosmo-tests in this pass. RESOURCE_DIR="" @@ -15,9 +31,37 @@ while getopts "m:tT" opt; do esac done -if [ -z "${PROJECT_ROOT_PATH:-}" ] -then - PROJECT_ROOT_PATH=$(cd `dirname $0`; pwd)/.. +if [ -z "${PROJECT_ROOT_PATH:-}" ]; then + PROJECT_ROOT_PATH="$(cd "$(dirname "$0")/.." && pwd -P)" +fi + +if [ "${COSMO_MODEL_GUARD_BUILD_PROFILE}" = "public-runtime" ]; then + COSMO_EDGE_SOURCE_COMMIT="${COSMO_EDGE_SOURCE_COMMIT:-}" + if [ -z "${COSMO_EDGE_SOURCE_COMMIT}" ]; then + if ! command -v git >/dev/null 2>&1; then + echo "ERROR: SOURCE packaging requires Git or an explicit COSMO_EDGE_SOURCE_COMMIT" >&2 + exit 1 + fi + if ! COSMO_EDGE_SOURCE_COMMIT="$( + git -c safe.directory="${PROJECT_ROOT_PATH}" \ + -C "${PROJECT_ROOT_PATH}" rev-parse --verify 'HEAD^{commit}' + )"; then + echo "ERROR: cannot resolve the Edge commit; set COSMO_EDGE_SOURCE_COMMIT explicitly" >&2 + exit 1 + fi + fi + if [ "${#COSMO_EDGE_SOURCE_COMMIT}" -ne 40 ]; then + echo "ERROR: COSMO_EDGE_SOURCE_COMMIT must be lower-case 40-hex" >&2 + exit 1 + fi + case "${COSMO_EDGE_SOURCE_COMMIT}" in + *[!0-9a-f]*) + echo "ERROR: COSMO_EDGE_SOURCE_COMMIT must be lower-case 40-hex" >&2 + exit 1 + ;; + *) ;; + esac + export COSMO_EDGE_SOURCE_COMMIT fi if [ -z "${RESOURCE_DIR}" ]; then @@ -31,25 +75,43 @@ if [ ! -d "${RESOURCE_DIR}" ]; then exit 1 fi -BUILD_DIR=${PROJECT_ROOT_PATH}/build -INSTALL_DIR=${BUILD_DIR}/install - -if [ -d ${INSTALL_DIR} ] -then - rm -rf ${INSTALL_DIR} +BUILD_DIR="${PROJECT_ROOT_PATH}/build" +INSTALL_DIR="${BUILD_DIR}/install" +PACKAGE_DIR="${BUILD_DIR}/packages" +COSMO_GUARD_SDK_DIR="${COSMO_MODEL_GUARD_SDK_ROOT:-${PROJECT_ROOT_PATH}/prebuild/model-guard-v2}" +MODEL_GUARD_PROFILE_ARGS=( + -DCOSMO_MODEL_GUARD_BUILD_PROFILE="${COSMO_MODEL_GUARD_BUILD_PROFILE}" + -DCOSMO_EDGE_SOURCE_COMMIT="${COSMO_EDGE_SOURCE_COMMIT:-}" + -DCOSMO_LEGACY_MIGRATION_PACKAGE="${COSMO_LEGACY_MIGRATION_PACKAGE:-OFF}" + -DCOSMO_PACKAGE_MODELS="${COSMO_PACKAGE_MODELS:-include}" +) +RELEASE_BOOTSTRAP_ARGS=( + -DCOSMO_REQUIRE_RELEASE_BOOTSTRAP="${COSMO_REQUIRE_RELEASE_BOOTSTRAP:-OFF}" + -DCOSMO_RELEASE_PUBLIC_KEY_OBJECT="${COSMO_RELEASE_PUBLIC_KEY_OBJECT:-}" +) +if [ -d "${INSTALL_DIR}" ]; then + rm -rf -- "${INSTALL_DIR}" fi -mkdir -p ${BUILD_DIR} -cd ${BUILD_DIR} +mkdir -p "${BUILD_DIR}" +cd "${BUILD_DIR}" echo "Dev mode: ${DEV_MODE}" echo "Resource dir: ${RESOURCE_DIR}" -echo "Configuring..." +echo "Package variant: ${PACKAGE_VARIANT}" +echo "Internal Model Guard build profile: ${COSMO_MODEL_GUARD_BUILD_PROFILE}" +if [ "${COSMO_MODEL_GUARD_BUILD_PROFILE}" = "public-runtime" ]; then + echo "Edge source commit: ${COSMO_EDGE_SOURCE_COMMIT}" +fi +echo "Configuring protected build..." cmake -DCMAKE_BUILD_TYPE=Release \ - -DCMAKE_INSTALL_PREFIX=${INSTALL_DIR} \ - -DBUILD_TESTS=${BUILD_TESTS_FLAG} \ - -DCOSMO_DEV_MODE=${DEV_MODE} \ + -DCMAKE_INSTALL_PREFIX="${INSTALL_DIR}" \ + -DBUILD_TESTS="${BUILD_TESTS_FLAG}" \ + -DCOSMO_DEV_MODE="${DEV_MODE}" \ + -DCOSMO_MODEL_GUARD_SDK_ROOT="${COSMO_GUARD_SDK_DIR}" \ -DRESOURCE_DIR="${RESOURCE_DIR}" \ + "${MODEL_GUARD_PROFILE_ARGS[@]}" \ + "${RELEASE_BOOTSTRAP_ARGS[@]}" \ .. # Symlink compile_commands.json to project root for IDE and static analysis tools @@ -59,9 +121,121 @@ echo "Building Cosmo ..." build_targets=(--target install) if [ "${BUILD_TESTS_FLAG}" = "ON" ]; then echo "Also building cosmo-tests in this pass..." - build_targets+=(--target cosmo-tests) + build_targets+=( + --target cosmo-tests + --target cosmo-release-bootstrap-test-fixture + --target cosmo-release-bootstrap-verifier-tests + ) +fi +cmake --build . "${build_targets[@]}" -j"$(nproc)" + +echo "Auditing installed AArch64 ELF paths..." +unsafe_elf_path=0 +while IFS= read -r -d '' installed_file; do + if aarch64-linux-gnu-readelf -hW "${installed_file}" >/dev/null 2>&1; then + dynamic_metadata=$(aarch64-linux-gnu-readelf -dW "${installed_file}") + if grep -Eq '/workspace|thirdparty_install|3rd/libsophon' \ + <<<"${dynamic_metadata}" + then + echo "ERROR: installed ELF dynamic metadata leaks a build-only path: ${installed_file}" >&2 + unsafe_elf_path=1 + fi + fi +done < <(find "${INSTALL_DIR}" -type f -print0) +if [ "${unsafe_elf_path}" -ne 0 ]; then + exit 1 +fi + +if [ "${BUILD_TESTS_FLAG}" = "ON" ]; then + echo "Auditing isolated AArch64 release-bootstrap test images..." + for bootstrap_test_image in \ + "${BUILD_DIR}/cosmo-release-bootstrap-test-fixture" \ + "${BUILD_DIR}/cosmo-release-bootstrap-verifier-tests" + do + test -f "${bootstrap_test_image}" + aarch64-linux-gnu-readelf -hW "${bootstrap_test_image}" | + grep -Eq 'Machine:[[:space:]]+AArch64$' + bootstrap_dynamic=$(aarch64-linux-gnu-readelf -dW "${bootstrap_test_image}") + grep -Fq '(RUNPATH)' <<<"${bootstrap_dynamic}" + grep -Fq '[$ORIGIN/../lib]' <<<"${bootstrap_dynamic}" + grep -Fq 'Shared library: [libcrypto.so.3]' <<<"${bootstrap_dynamic}" + if grep -Fq '(RPATH)' <<<"${bootstrap_dynamic}" || + grep -Fq '/workspace' <<<"${bootstrap_dynamic}"; then + echo "ERROR: release-bootstrap test image has an unsafe dynamic path: ${bootstrap_test_image}" >&2 + exit 1 + fi + done + test ! -e "${INSTALL_DIR}/bin/cosmo-release-bootstrap-test-fixture" + test ! -e "${INSTALL_DIR}/bin/cosmo-release-bootstrap-verifier-tests" + + echo "Running protected-build security regression suites..." + /usr/bin/python3 -I -B "${PROJECT_ROOT_PATH}/test/test_package_profile.py" + /usr/bin/python3 -I -B "${PROJECT_ROOT_PATH}/test/test_verify_model_guard_v2_sdk.py" + /usr/bin/python3 -I -B "${PROJECT_ROOT_PATH}/test/test_release_health_check.py" + /usr/bin/python3 -I -B "${PROJECT_ROOT_PATH}/test/test_release_updater.py" +fi + +installed_python_cache="$( + find "${INSTALL_DIR}" \ + \( -name __pycache__ -o -name '*.pyc' -o -name '*.pyo' \) \ + -print -quit +)" +if [ -n "${installed_python_cache}" ]; then + printf 'ERROR: installed payload contains Python bytecode cache: %q\n' \ + "${installed_python_cache}" >&2 + exit 1 +fi + +if [ "${COSMO_MODEL_GUARD_BUILD_PROFILE}" = "production-release" ]; then + echo "Validating blank-device factory base..." + for facade in bin files font lib resource scripts web; do + test -d "${INSTALL_DIR}/${facade}" + test ! -L "${INSTALL_DIR}/${facade}" + done + test ! -e "${INSTALL_DIR}/current" + test ! -e "${INSTALL_DIR}/.releases" + test ! -e "${INSTALL_DIR}/.release-state" + + factory_service="${INSTALL_DIR}/share/cosmo-factory/cosmo.service" + test -f "${factory_service}" + test ! -L "${factory_service}" + grep -Fxq 'User=root' "${factory_service}" + grep -Fxq \ + 'WorkingDirectory=/appfs/cosmo_wander/cwai_data' \ + "${factory_service}" + grep -Fxq \ + 'ExecStart=/appfs/cosmo_wander/cwai_data/scripts/inte_run_start.sh' \ + "${factory_service}" + if grep -Eiq 'model[- ]guard|RequiresMountsFor' "${factory_service}"; then + echo "ERROR: factory service contains a forbidden state/mount dependency" >&2 + exit 1 + fi fi -cmake --build . "${build_targets[@]}" -j$(nproc) echo "Packaging..." cmake --build . --target package_all + +shopt -s nullglob +package_artifacts=("${PACKAGE_DIR}"/*.tar.gz) +shopt -u nullglob +if [ "${#package_artifacts[@]}" -ne 1 ] || + [ ! -f "${package_artifacts[0]:-}" ] || + [ -L "${package_artifacts[0]:-}" ]; then + echo "ERROR: packaging must produce exactly one regular archive" >&2 + exit 1 +fi + +package_verify_args=() +if [ "${COSMO_LEGACY_MIGRATION_PACKAGE:-OFF}" = "ON" ]; then + package_verify_args+=(--legacy-migration) +fi +/usr/bin/python3 -I -B \ + "${PROJECT_ROOT_PATH}/scripts/verify_package_contents.py" \ + --archive "${package_artifacts[0]}" \ + --build-profile "${COSMO_MODEL_GUARD_BUILD_PROFILE}" \ + "${package_verify_args[@]}" + +package_sha256="$(sha256sum -- "${package_artifacts[0]}")" +package_sha256="${package_sha256%% *}" +echo "Verified ${PACKAGE_VARIANT} package: ${package_artifacts[0]}" +echo "Package SHA-256: ${package_sha256}" diff --git a/scripts/build_release_bundle.py b/scripts/build_release_bundle.py new file mode 100755 index 000000000..2ebe00e6d --- /dev/null +++ b/scripts/build_release_bundle.py @@ -0,0 +1,1659 @@ +#!/usr/bin/python3 -I +"""Create a signed, deterministic Cosmo release bundle (offline only). + +The Ed25519 private key is accepted only on inherited file descriptor 3. The +descriptor must be a pipe or an anonymous ``memfd`` carrying every write, +grow, shrink, and seal seal; command-line paths, environment variables, and +ordinary key files are deliberately unsupported. + +This tool is an offline release step. The ordinary Docker package build emits +the installable SOURCE package, not a signed production release. Only this +separate controlled ceremony creates a signed release archive. +""" + +from __future__ import annotations + +import argparse +import base64 +import contextlib +import ctypes +import dataclasses +import errno +import fcntl +import gzip +import hashlib +import hmac +import importlib.machinery +import importlib.util +import io +import json +import os +import re +import stat +import struct +import subprocess +import sys +import tarfile +import tempfile +from pathlib import Path +from typing import Any, Mapping, Sequence + + +sys.dont_write_bytecode = True +SCRIPT_DIR = Path(__file__).resolve().parent +UPDATER_PATH = SCRIPT_DIR / "release_updater.py" +loader = importlib.machinery.SourceFileLoader("cosmo_release_updater", str(UPDATER_PATH)) +spec = importlib.util.spec_from_loader(loader.name, loader) +if spec is None: + raise RuntimeError("cannot load release updater schema") +schema = importlib.util.module_from_spec(spec) +sys.modules[loader.name] = schema +loader.exec_module(schema) + + +class PackagerError(RuntimeError): + pass + + +BOOTSTRAP_TRUST_SYMBOL_SIZES = { + "cosmo_release_public_key_raw_v1": 32, + "cosmo_release_public_key_id_v1": 16, + "cosmo_release_public_key_pem_sha256_v1": 32, +} +MODEL_GUARD_TRUST_SYMBOL_SIZES = { + "cmg_product_pepper_bundle_v1": 64, + "cmg_commissioning_public_key_bundle_v1": 64, +} +MODEL_GUARD_TRUST_SECTIONS = { + "product_pepper_bundle": (".cmg.trust.product.v1", 64), + "commissioning_public_key_bundle": (".cmg.trust.commissioning.v1", 64), +} +TEST_FIXTURE_MARKER_NAME = "TEST_FIXTURE_DO_NOT_DEPLOY" +TEST_FIXTURE_MARKER_CONTENT = b"COSMO_MODEL_GUARD_V2_TEST_FIXTURE_DO_NOT_DEPLOY\n" +BOOTSTRAP_REQUIRED_NEEDED = {"libcrypto.so.3"} +BOOTSTRAP_ALLOWED_NEEDED = BOOTSTRAP_REQUIRED_NEEDED | { + "libstdc++.so.6", + "libgcc_s.so.1", + "libc.so.6", + "ld-linux-aarch64.so.1", +} +EXPECTED_BOOTSTRAP_RUNPATH = "$ORIGIN/../lib" +MAX_BOOTSTRAP_ELF_BYTES = 64 * 1024 * 1024 +MAX_MODEL_GUARD_ELF_BYTES = 64 * 1024 * 1024 + + +def _fail(message: str) -> "NoReturn": + raise PackagerError(message) + + +def _require_isolated_entrypoint() -> None: + """Reject accidental unsafe launch modes before entering the ceremony. + + This check only detects misuse after Python has started. Protection from + startup imports such as ``sitecustomize`` comes from the fixed ``-I`` + interpreter argument in the shebang and every supported launch command. + """ + if sys.flags.isolated != 1: + _fail( + "release ceremony must be launched with /usr/bin/python3 -I -B" + ) + + +def _directory_identity(info: os.stat_result) -> tuple[int, ...]: + return (info.st_dev, info.st_ino) + + +@dataclasses.dataclass(frozen=True) +class ControlledOutput: + parent: Path + directory_fd: int + directory_identity: tuple[int, ...] + output_name: str + temporary_name: str + + @property + def path(self) -> Path: + return self.parent / self.output_name + + +def _directory_entry_absent( + directory_fd: int, name: str, description: str +) -> None: + try: + os.stat(name, dir_fd=directory_fd, follow_symlinks=False) + except FileNotFoundError: + return + except OSError as error: + raise PackagerError(f"cannot inspect {description}") from error + _fail(f"{description} already exists") + + +def _recheck_controlled_output(target: ControlledOutput) -> None: + try: + descriptor_info = os.fstat(target.directory_fd) + path_info = os.lstat(target.parent) + except OSError as error: + raise PackagerError("release output parent became unavailable") from error + if ( + _directory_identity(descriptor_info) != target.directory_identity + or _directory_identity(path_info) != target.directory_identity + ): + _fail("release output parent changed during the ceremony") + + +def _temporary_output_name(output_name: str) -> str: + return f".{output_name}.tmp-{os.getpid()}-{os.urandom(16).hex()}" + + +def _prepare_controlled_output(value: str) -> ControlledOutput: + output = Path(value) + if ( + not output.is_absolute() + or output.name in ("", ".", "..") + or os.path.normpath(os.fspath(output)) != os.fspath(output) + ): + _fail("release output path must be absolute and canonical") + parent = output.parent + try: + path_info = os.stat(parent) + except OSError as error: + raise PackagerError( + "release output parent must already exist" + ) from error + if ( + not stat.S_ISDIR(path_info.st_mode) + ): + _fail("release output parent must be a directory") + try: + directory_fd = os.open( + parent, + os.O_RDONLY + | os.O_DIRECTORY + | os.O_CLOEXEC, + ) + except OSError as error: + raise PackagerError("release output parent open failed") from error + try: + descriptor_info = os.fstat(directory_fd) + identity = _directory_identity(path_info) + if _directory_identity(descriptor_info) != identity: + _fail("release output parent changed while opening") + temporary_name = _temporary_output_name(output.name) + if temporary_name in ("", ".", "..") or "/" in temporary_name: + _fail("release temporary output name rejected") + _directory_entry_absent( + directory_fd, output.name, "release output" + ) + _directory_entry_absent( + directory_fd, temporary_name, "release temporary output" + ) + return ControlledOutput( + parent, + directory_fd, + identity, + output.name, + temporary_name, + ) + except BaseException: + os.close(directory_fd) + raise + + +def _validate_output_before_secret(target: ControlledOutput) -> None: + """Repeat every output invariant immediately before consuming fd 3.""" + _recheck_controlled_output(target) + _directory_entry_absent( + target.directory_fd, target.output_name, "release output" + ) + _directory_entry_absent( + target.directory_fd, + target.temporary_name, + "release temporary output", + ) + + +def _rename_output_noreplace( + directory_fd: int, old_name: str, new_name: str +) -> None: + renameat2 = getattr(ctypes.CDLL(None, use_errno=True), "renameat2", None) + if renameat2 is None: + _fail("renameat2(RENAME_NOREPLACE) is required for release output") + result = renameat2( + ctypes.c_int(directory_fd), + ctypes.c_char_p(os.fsencode(old_name)), + ctypes.c_int(directory_fd), + ctypes.c_char_p(os.fsencode(new_name)), + ctypes.c_uint(1), + ) + if result != 0: + error_number = ctypes.get_errno() + raise PackagerError( + "atomic no-replace release publication failed" + ) from OSError(error_number, os.strerror(error_number)) + + +def _unlink_owned_output( + target: ControlledOutput, + name: str, + expected: os.stat_result, +) -> None: + try: + current = os.stat( + name, dir_fd=target.directory_fd, follow_symlinks=False + ) + except FileNotFoundError: + return + except OSError: + return + if ( + current.st_dev, + current.st_ino, + ) != ( + expected.st_dev, + expected.st_ino, + ): + return + try: + os.unlink(name, dir_fd=target.directory_fd) + except OSError: + pass + + +def _sha256_file(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as stream: + while True: + block = stream.read(1024 * 1024) + if not block: + break + digest.update(block) + return digest.hexdigest() + + +def _metadata_snapshot(info: os.stat_result) -> tuple[int, ...]: + return ( + info.st_dev, + info.st_ino, + info.st_size, + info.st_mtime_ns, + ) + + +def _checked_file_bytes(path: Path, maximum_size: int, description: str) -> bytes: + info = os.stat(path) + if ( + not stat.S_ISREG(info.st_mode) + or info.st_size <= 0 + or info.st_size > maximum_size + ): + _fail(f"{description} type or size rejected") + fd = os.open(path, os.O_RDONLY | os.O_CLOEXEC) + try: + before = os.fstat(fd) + if _metadata_snapshot(before) != _metadata_snapshot(info): + _fail(f"{description} changed before open") + output = bytearray() + while len(output) < before.st_size: + block = os.read(fd, min(1024 * 1024, before.st_size - len(output))) + if not block: + _fail(f"{description} was truncated while reading") + output.extend(block) + if os.read(fd, 1): + _fail(f"{description} grew while reading") + after = os.fstat(fd) + finally: + os.close(fd) + current = os.stat(path) + if ( + _metadata_snapshot(after) != _metadata_snapshot(before) + or _metadata_snapshot(current) != _metadata_snapshot(before) + ): + _fail(f"{description} changed while reading") + return bytes(output) + + +def _run( + arguments: Sequence[str], + *, + pass_fds: Sequence[int] = (), + input_data: bytes | None = None, + timeout_seconds: int = 30, + trusted_error_output: bool = False, +) -> bytes: + try: + result = subprocess.run( + list(arguments), + input=input_data, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + close_fds=True, + pass_fds=tuple(pass_fds), + env={"LC_ALL": "C", "PATH": "/usr/bin:/bin"}, + timeout=timeout_seconds, + ) + except (OSError, subprocess.TimeoutExpired) as error: + raise PackagerError(f"release tool failed: {arguments[0]}") from error + if result.returncode != 0: + detail = "" + if trusted_error_output: + detail_text = result.stderr[:2048].decode( + "utf-8", + "backslashreplace", + ).strip() + if detail_text: + detail = f": {detail_text}" + _fail( + "release tool rejected input: " + f"{Path(arguments[0]).name} (exit {result.returncode}){detail}" + ) + return result.stdout + + +def _signing_key_fd_is_inherited() -> bool: + try: + os.fstat(3) + except OSError as error: + if error.errno == errno.EBADF: + return False + raise PackagerError( + "release signing key fd could not be inspected" + ) from error + return True + + +def _read_signing_key_fd(inherited: bool) -> bytearray: + if not inherited: + raise PackagerError( + "release signing key must be supplied on inherited fd 3" + ) + try: + info = os.fstat(3) + except OSError as error: + raise PackagerError("release signing key must be supplied on inherited fd 3") from error + key = bytearray() + try: + is_pipe = False + if stat.S_ISFIFO(info.st_mode): + try: + descriptor_link = os.readlink("/proc/self/fd/3") + except OSError: + descriptor_link = "" + is_pipe = ( + re.fullmatch(r"pipe:\[[1-9][0-9]*\]", descriptor_link) + is not None + ) + is_sealed_memfd = False + if stat.S_ISREG(info.st_mode) and info.st_nlink == 0: + try: + seals = fcntl.fcntl(3, fcntl.F_GET_SEALS) + except OSError: + seals = 0 + required = ( + fcntl.F_SEAL_SEAL + | fcntl.F_SEAL_SHRINK + | fcntl.F_SEAL_GROW + | fcntl.F_SEAL_WRITE + ) + is_sealed_memfd = seals & required == required + if not (is_pipe or is_sealed_memfd): + _fail( + "release signing key fd must be an anonymous pipe or fully " + "sealed memfd" + ) + while len(key) <= 16 * 1024: + block = os.read( + 3, + min(4096, 16 * 1024 + 1 - len(key)), + ) + if not block: + break + key.extend(block) + except BaseException: + for index in range(len(key)): + key[index] = 0 + raise + finally: + # fd 3 is a one-shot ceremony channel. In particular, close a sealed + # memfd after consumption so later code cannot seek back to the key. + try: + os.close(3) + except OSError: + pass + if not key or len(key) > 16 * 1024: + for index in range(len(key)): + key[index] = 0 + _fail("release signing key length rejected") + return key + + +def _sealed_memfd(data: bytearray) -> int: + if not hasattr(os, "memfd_create"): + _fail("memfd_create is required for release signing") + fd = os.memfd_create("cosmo-release-signing-key", os.MFD_CLOEXEC | os.MFD_ALLOW_SEALING) + try: + offset = 0 + while offset < len(data): + offset += os.write(fd, data[offset:]) + os.lseek(fd, 0, os.SEEK_SET) + seals = fcntl.F_SEAL_SEAL | fcntl.F_SEAL_SHRINK | fcntl.F_SEAL_GROW | fcntl.F_SEAL_WRITE + fcntl.fcntl(fd, fcntl.F_ADD_SEALS, seals) + return fd + except BaseException: + os.close(fd) + raise + finally: + for index in range(len(data)): + data[index] = 0 + + +def _readonly_memfd(name: str, data: bytes) -> int: + if not hasattr(os, "memfd_create"): + _fail("memfd_create is required for release signing") + fd = os.memfd_create(name, os.MFD_CLOEXEC | os.MFD_ALLOW_SEALING) + try: + offset = 0 + while offset < len(data): + offset += os.write(fd, data[offset:]) + os.lseek(fd, 0, os.SEEK_SET) + seals = fcntl.F_SEAL_SEAL | fcntl.F_SEAL_SHRINK | fcntl.F_SEAL_GROW | fcntl.F_SEAL_WRITE + fcntl.fcntl(fd, fcntl.F_ADD_SEALS, seals) + return fd + except BaseException: + os.close(fd) + raise + + +def _canonical_ed25519_public_key( + openssl: Path, + public_key: Path, + description: str, +) -> tuple[bytes, str, bytes]: + pem = _checked_file_bytes(public_key, 16 * 1024, description) + pem_fd = _readonly_memfd("cosmo-release-public-key", pem) + try: + key_path = f"/proc/self/fd/{pem_fd}" + canonical_pem = _run( + (str(openssl), "pkey", "-pubin", "-in", key_path, "-pubout"), + pass_fds=(pem_fd,), + ) + os.lseek(pem_fd, 0, os.SEEK_SET) + der = _run( + ( + str(openssl), + "pkey", + "-pubin", + "-in", + key_path, + "-outform", + "DER", + ), + pass_fds=(pem_fd,), + ) + finally: + os.close(pem_fd) + if pem != canonical_pem: + _fail( + f"{description} PEM is not in the canonical OpenSSL encoding" + ) + # RFC 8410 Ed25519 SubjectPublicKeyInfo is 44 bytes and has this fixed prefix. + if len(der) != 44 or der[:12] != bytes.fromhex("302a300506032b6570032100"): + _fail( + f"{description} is not a canonical RFC 8410 Ed25519 " + "SubjectPublicKeyInfo" + ) + if not any(der[-32:]): + _fail(f"{description} raw Ed25519 key is zero") + return der[-32:], hashlib.sha256(pem).hexdigest(), pem + + +def _public_key_identity( + openssl: Path, public_key: Path +) -> tuple[str, str, dict[str, bytes], bytes]: + raw_public_key, pem_sha256, pem = _canonical_ed25519_public_key( + openssl, + public_key, + "release public key", + ) + key_id = hashlib.sha256( + b"cosmo-release-key-id-v1" + + (1).to_bytes(2, "big") + + raw_public_key + ).hexdigest()[:32] + if set(key_id) == {"0"}: + _fail("derived release key ID is zero") + values = { + "cosmo_release_public_key_raw_v1": raw_public_key, + "cosmo_release_public_key_id_v1": bytes.fromhex(key_id), + "cosmo_release_public_key_pem_sha256_v1": hashlib.sha256(pem).digest(), + } + return key_id, pem_sha256, values, pem + + +def _private_elf_object_symbols( + path: Path, + expected_sizes: Mapping[str, int], + *, + description: str, + maximum_size: int, + expected_binding: int, + expected_visibility: int, +) -> dict[str, bytes]: + """Read exact private objects from an AArch64 ELF without executing it.""" + info = os.stat(path) + if ( + not stat.S_ISREG(info.st_mode) + or info.st_size < 64 + or info.st_size > maximum_size + ): + _fail(f"{description} ELF type or size rejected") + data = path.read_bytes() + if len(data) != info.st_size: + _fail(f"{description} ELF changed while reading") + if data[:16] != b"\x7fELF\x02\x01\x01\x00" + b"\x00" * 8: + _fail(f"{description} must be a little-endian ELF64 image") + try: + header = struct.unpack_from("<16sHHIQQQIHHHHHH", data, 0) + except struct.error as error: + raise PackagerError(f"{description} ELF header is truncated") from error + ( + _, + elf_type, + machine, + version, + _, + _, + section_offset, + _, + elf_header_size, + _, + _, + section_entry_size, + section_count, + section_name_index, + ) = header + if ( + elf_type not in (2, 3) + or machine != 183 + or version != 1 + or elf_header_size != 64 + or section_entry_size != 64 + or section_count == 0 + or section_name_index >= section_count + or section_offset > len(data) + or section_count > (len(data) - section_offset) // section_entry_size + ): + _fail(f"{description} AArch64 ELF section header rejected") + + sections: list[tuple[int, int, int, int, int, int, int, int, int, int]] = [] + try: + for index in range(section_count): + sections.append( + struct.unpack_from( + " len(data) or size > len(data) - offset): + _fail(f"{description} ELF section extent rejected") + + found: dict[str, bytes] = {} + encoded_names = {candidate.encode("ascii") for candidate in expected_sizes} + for section in sections: + _, section_type, _, _, offset, size, string_index, _, _, entry_size = section + if section_type not in (2, 11): + continue + if ( + entry_size != 24 + or size % entry_size != 0 + or string_index >= len(sections) + or sections[string_index][1] != 3 + ): + _fail(f"{description} ELF symbol table rejected") + string_offset = sections[string_index][4] + string_size = sections[string_index][5] + strings = data[string_offset : string_offset + string_size] + for symbol_offset in range(offset, offset + size, entry_size): + try: + name_offset, symbol_info, symbol_other, symbol_section, value, symbol_size = ( + struct.unpack_from("= len(strings): + _fail(f"{description} ELF symbol name offset rejected") + name_end = strings.find(b"\x00", name_offset) + if name_end < 0: + _fail(f"{description} ELF symbol name is unterminated") + name_bytes = strings[name_offset:name_end] + if name_bytes not in encoded_names: + continue + name = name_bytes.decode("ascii") + if section_type == 11: + _fail(f"{description} exposes a private trust symbol dynamically") + if name in found: + _fail(f"{description} contains a duplicate trust symbol") + if ( + symbol_info >> 4 != expected_binding + or symbol_info & 0xF != 1 + or symbol_other != expected_visibility + or symbol_size != expected_sizes[name] + or symbol_section == 0 + or symbol_section >= len(sections) + ): + _fail(f"{description} trust symbol metadata rejected") + target = sections[symbol_section] + _, target_type, target_flags, target_address, target_offset, target_size, _, _, _, _ = target + if ( + target_type != 1 + or target_flags & 0x2 == 0 + or target_flags & (0x1 | 0x4) + or value < target_address + or value - target_address > target_size + or symbol_size > target_size - (value - target_address) + ): + _fail(f"{description} trust symbol section rejected") + file_offset = target_offset + value - target_address + if file_offset > len(data) or symbol_size > len(data) - file_offset: + _fail(f"{description} trust symbol extent rejected") + found[name] = data[file_offset : file_offset + symbol_size] + if set(found) != set(expected_sizes): + _fail(f"{description} is missing its private trust symbols") + return found + + +def _bootstrap_trust_symbols(path: Path) -> dict[str, bytes]: + return _private_elf_object_symbols( + path, + BOOTSTRAP_TRUST_SYMBOL_SIZES, + description="release bootstrap", + maximum_size=MAX_BOOTSTRAP_ELF_BYTES, + expected_binding=1, # STB_GLOBAL + expected_visibility=2, # STV_HIDDEN + ) + + +def _parse_elf64( + data: bytes, + *, + description: str, + expected_type: int, + require_program_headers: bool, +) -> tuple[list[dict[str, int | bytes]], list[dict[str, int]]]: + if len(data) < 64 or len(data) > MAX_MODEL_GUARD_ELF_BYTES: + _fail(f"{description} ELF size rejected") + if data[:16] != b"\x7fELF\x02\x01\x01\x00" + b"\x00" * 8: + _fail(f"{description} must be a little-endian ELF64 image") + try: + header = struct.unpack_from("<16sHHIQQQIHHHHHH", data, 0) + except struct.error as error: + raise PackagerError(f"{description} ELF header is truncated") from error + ( + _, + elf_type, + machine, + version, + _, + program_offset, + section_offset, + _, + elf_header_size, + program_entry_size, + program_count, + section_entry_size, + section_count, + section_name_index, + ) = header + if ( + elf_type != expected_type + or machine != 183 + or version != 1 + or elf_header_size != 64 + or section_entry_size != 64 + or section_count == 0 + or section_count > 4096 + or section_name_index >= section_count + or section_offset > len(data) + or section_count > (len(data) - section_offset) // section_entry_size + ): + _fail(f"{description} AArch64 ELF header rejected") + if require_program_headers: + if ( + program_entry_size != 56 + or program_count == 0 + or program_count > 512 + or program_offset > len(data) + or program_count > (len(data) - program_offset) // program_entry_size + ): + _fail(f"{description} ELF program header rejected") + elif program_count != 0: + if ( + program_entry_size != 56 + or program_count > 512 + or program_offset > len(data) + or program_count > (len(data) - program_offset) // program_entry_size + ): + _fail(f"{description} ELF program header rejected") + + raw_sections: list[tuple[int, int, int, int, int, int, int, int, int, int]] = [] + try: + for index in range(section_count): + raw_sections.append( + struct.unpack_from( + " len(data) or size > len(data) - offset): + _fail(f"{description} ELF section extent rejected") + name_section = raw_sections[section_name_index] + if name_section[1] != 3: + _fail(f"{description} ELF section-name table rejected") + name_data = data[name_section[4] : name_section[4] + name_section[5]] + sections: list[dict[str, int | bytes]] = [] + for section in raw_sections: + name_offset = section[0] + if name_offset >= len(name_data): + _fail(f"{description} ELF section name offset rejected") + name_end = name_data.find(b"\x00", name_offset) + if name_end < 0: + _fail(f"{description} ELF section name is unterminated") + sections.append( + { + "name": name_data[name_offset:name_end], + "type": section[1], + "flags": section[2], + "address": section[3], + "offset": section[4], + "size": section[5], + "link": section[6], + "info": section[7], + "alignment": section[8], + "entry_size": section[9], + } + ) + + programs: list[dict[str, int]] = [] + try: + for index in range(program_count): + values = struct.unpack_from( + " len(data) + or file_size > len(data) - offset + or program["memory_size"] < file_size + ): + _fail(f"{description} ELF program extent rejected") + return sections, programs + + +def _model_guard_trust_sections(data: bytes) -> dict[str, bytes]: + sections, programs = _parse_elf64( + data, + description="release Model Guard", + expected_type=3, # ET_DYN + require_program_headers=True, + ) + found: dict[str, bytes] = {} + extents: list[tuple[int, int]] = [] + address_extents: list[tuple[int, int]] = [] + if any(section["name"] == b".symtab" for section in sections): + _fail("release Model Guard must be stripped of its static symbol table") + for logical_name, (section_name, expected_size) in MODEL_GUARD_TRUST_SECTIONS.items(): + encoded_name = section_name.encode("ascii") + matches = [section for section in sections if section["name"] == encoded_name] + if len(matches) != 1: + _fail(f"release Model Guard trust section count rejected: {section_name}") + section = matches[0] + if ( + section["type"] != 1 + or section["flags"] != 0x2 + or section["size"] != expected_size + or section["alignment"] not in (1, 2, 4, 8, 16) + ): + _fail(f"release Model Guard trust section metadata rejected: {section_name}") + section_offset = int(section["offset"]) + section_address = int(section["address"]) + covering_loads = [] + for program in programs: + if program["type"] != 1: + continue + if ( + program["offset"] <= section_offset + and expected_size <= program["file_size"] - (section_offset - program["offset"]) + and program["vaddr"] <= section_address + and expected_size + <= program["memory_size"] - (section_address - program["vaddr"]) + and section_offset - program["offset"] + == section_address - program["vaddr"] + ): + covering_loads.append(program) + if len(covering_loads) != 1 or covering_loads[0]["flags"] != 0x4: + _fail( + "release Model Guard trust section is not in exactly one " + f"read-only non-executable PT_LOAD: {section_name}" + ) + owner = covering_loads[0] + page_size = 0x10000 + trust_page_start = section_address & ~(page_size - 1) + trust_page_end = ( + section_address + expected_size + page_size - 1 + ) & ~(page_size - 1) + for program in programs: + if program["type"] != 1 or program is owner: + continue + program_start = program["vaddr"] & ~(page_size - 1) + program_end = ( + program["vaddr"] + program["memory_size"] + page_size - 1 + ) & ~(page_size - 1) + if program_start < trust_page_end and trust_page_start < program_end: + _fail( + "release Model Guard trust section overlaps another " + f"PT_LOAD at BM1688 page granularity: {section_name}" + ) + extent = (section_offset, section_offset + expected_size) + if any(extent[0] < other[1] and other[0] < extent[1] for other in extents): + _fail("release Model Guard trust sections overlap") + extents.append(extent) + address_extents.append( + (section_address, section_address + expected_size) + ) + found[logical_name] = data[extent[0] : extent[1]] + + # A trust section that is read-only in the file is not immutable at runtime + # if the dynamic loader is instructed to rewrite it. The loader follows + # PT_DYNAMIC, not section-header metadata, so derive every active relocation + # table from the unique PT_DYNAMIC program header. + dynamic_programs = [program for program in programs if program["type"] == 2] + if len(dynamic_programs) != 1: + _fail("release Model Guard must have exactly one PT_DYNAMIC") + dynamic_program = dynamic_programs[0] + if dynamic_program["file_size"] % 16 != 0: + _fail("release Model Guard PT_DYNAMIC extent rejected") + dynamic_entries: list[tuple[int, int]] = [] + found_dynamic_end = False + for offset in range( + dynamic_program["offset"], + dynamic_program["offset"] + dynamic_program["file_size"], + 16, + ): + tag, value = struct.unpack_from(" bytes: + matches = [ + program + for program in programs + if program["type"] == 1 + and program["vaddr"] <= address + and size <= program["file_size"] - (address - program["vaddr"]) + ] + if len(matches) != 1: + _fail("release Model Guard relocation table mapping rejected") + program = matches[0] + offset = program["offset"] + address - program["vaddr"] + return data[offset : offset + size] + + def inspect_relocations(address_tag: int, size_tag: int, entry_tag: int, expected: int) -> None: + present = [tag in dynamic_values for tag in (address_tag, size_tag, entry_tag)] + if not any(present): + return + if not all(present): + _fail("release Model Guard dynamic relocation contract is incomplete") + size = dynamic_values[size_tag] + if dynamic_values[entry_tag] != expected or size % expected != 0: + _fail("release Model Guard dynamic relocation entry size rejected") + table = relocation_bytes(dynamic_values[address_tag], size) + for offset in range(0, len(table), expected): + relocation_target = struct.unpack_from(" tuple[str, bytes]: + sections, _ = _parse_elf64( + data, + description="release public-key object", + expected_type=1, # ET_REL + require_program_headers=False, + ) + expected_name = b".rodata.cosmo_release_key" + matches = [section for section in sections if section["name"] == expected_name] + if len(matches) != 1: + _fail("release public-key object trust section count rejected") + section = matches[0] + if ( + section["type"] != 1 + or section["flags"] != 0x2 + or section["address"] != 0 + or section["size"] != 80 + or section["alignment"] not in (1, 2, 4, 8, 16) + ): + _fail("release public-key object trust section metadata rejected") + for candidate in sections: + if ( + candidate is not section + and candidate["size"] != 0 + and int(candidate["flags"]) & 0x2 + ): + _fail("release public-key object has unexpected allocated content") + offset = int(section["offset"]) + value = data[offset : offset + 80] + raw_key, encoded_key_id, pem_sha256 = value[:32], value[32:48], value[48:] + key_id = hashlib.sha256( + b"cosmo-release-key-id-v1" + (1).to_bytes(2, "big") + raw_key + ).digest()[:16] + der = bytes.fromhex("302a300506032b6570032100") + raw_key + encoded = base64.b64encode(der) + pem = b"-----BEGIN PUBLIC KEY-----\n" + encoded + b"\n-----END PUBLIC KEY-----\n" + if ( + not any(raw_key) + or not hmac.compare_digest(key_id, encoded_key_id) + or not hmac.compare_digest(hashlib.sha256(pem).digest(), pem_sha256) + ): + _fail("release public-key object identity rejected") + return key_id.hex(), pem + +def _verify_ed25519_signature( + openssl: Path, + public_key_pem: bytes, + message: bytes, + signature: bytes, + description: str, +) -> None: + if len(signature) != 64: + _fail(f"{description} signature length rejected") + try: + with contextlib.ExitStack() as descriptors: + key_fd = _readonly_memfd( + "cosmo-signature-public-key", public_key_pem + ) + descriptors.callback(os.close, key_fd) + message_fd = _readonly_memfd( + "cosmo-signature-message", message + ) + descriptors.callback(os.close, message_fd) + signature_fd = _readonly_memfd( + "cosmo-signature-value", signature + ) + descriptors.callback(os.close, signature_fd) + _run( + ( + str(openssl), + "pkeyutl", + "-verify", + "-pubin", + "-inkey", + f"/proc/self/fd/{key_fd}", + "-rawin", + "-in", + f"/proc/self/fd/{message_fd}", + "-sigfile", + f"/proc/self/fd/{signature_fd}", + ), + pass_fds=(key_fd, message_fd, signature_fd), + ) + except PackagerError as error: + raise PackagerError(f"{description} signature rejected") from error + + +def _validate_product_pepper_bundle(value: bytes) -> None: + if ( + len(value) != 64 + or value[:4] != b"CMPB" + or value[4:6] != b"\x00\x01" + or value[6:8] != b"\x00\x40" + or any(value[8:16]) + ): + _fail("release Model Guard product-pepper bundle format rejected") + key_id = value[16:32] + pepper = value[32:64] + derived = hashlib.sha256( + b"cosmo-product-pepper-key-id-v1" + pepper + ).digest()[:16] + if not any(key_id) or not any(pepper) or key_id != derived: + _fail("release Model Guard product-pepper record rejected") + + +def _validate_commissioning_public_key_bundle(value: bytes) -> None: + if ( + len(value) != 64 + or value[:4] != b"CMKB" + or value[4:6] != b"\x00\x01" + or value[6:8] != b"\x00\x40" + or any(value[8:16]) + ): + _fail("release Model Guard commissioning-key bundle format rejected") + key_id = value[16:32] + public_key = value[32:64] + derived = hashlib.sha256( + b"cosmo-commissioning-key-id-v1" + b"\x00\x01" + public_key + ).digest()[:16] + if not any(key_id) or not any(public_key) or key_id != derived: + _fail("release Model Guard commissioning-key record rejected") + + +def audit_model_guard_trust_objects(image: bytes) -> dict[str, bytes]: + values = _model_guard_trust_sections(image) + _validate_product_pepper_bundle(values["product_pepper_bundle"]) + _validate_commissioning_public_key_bundle( + values["commissioning_public_key_bundle"] + ) + return values + + +def _audit_bootstrap_dynamic_contract(path: Path, readelf: Path) -> None: + dynamic = schema._run_tool((str(readelf), "-dW", str(path))).decode( + "utf-8", "replace" + ) + needed = re.findall(r"\(NEEDED\).*\[([^\]]+)\]", dynamic) + runpaths = re.findall(r"\(RUNPATH\).*\[([^\]]+)\]", dynamic) + if runpaths != [EXPECTED_BOOTSTRAP_RUNPATH] or "(RPATH)" in dynamic: + _fail("release bootstrap RUNPATH/RPATH contract rejected") + needed_set = set(needed) + if ( + len(needed_set) != len(needed) + or not BOOTSTRAP_REQUIRED_NEEDED.issubset(needed_set) + or not needed_set.issubset(BOOTSTRAP_ALLOWED_NEEDED) + ): + _fail(f"release bootstrap NEEDED set rejected: {sorted(needed_set)}") + + +def _collect_payload(payload: Path) -> tuple[list[Mapping[str, Any]], dict[str, Path]]: + if payload.is_symlink() or not payload.is_dir(): + _fail("payload root must be a real directory") + entries: list[Mapping[str, Any]] = [] + sources: dict[str, Path] = {} + for root, directories, files in os.walk(payload, topdown=True, followlinks=False): + directories.sort(key=lambda item: item.encode("utf-8")) + files.sort(key=lambda item: item.encode("utf-8")) + current = Path(root) + for name in list(directories): + path = current / name + relative = path.relative_to(payload).as_posix() + if name == TEST_FIXTURE_MARKER_NAME: + _fail(f"Model Guard test-fixture marker is forbidden in a release: {relative}") + if name == "__pycache__": + _fail(f"Python bytecode cache directory is forbidden in a release: {relative}") + schema._canonical_relative_path(relative, "payload directory") + info = os.lstat(path) + if stat.S_ISLNK(info.st_mode): + directories.remove(name) + target = schema._safe_symlink_target(relative, os.readlink(path)) + entries.append({"path": relative, "target": target, "type": "symlink"}) + sources[relative] = path + elif not stat.S_ISDIR(info.st_mode): + _fail(f"payload directory entry has a forbidden type: {relative}") + else: + entries.append({"mode": 0o755, "path": relative, "type": "directory"}) + sources[relative] = path + for name in files: + path = current / name + relative = path.relative_to(payload).as_posix() + if name == TEST_FIXTURE_MARKER_NAME: + _fail(f"Model Guard test-fixture marker is forbidden in a release: {relative}") + if name.endswith((".pyc", ".pyo")): + _fail(f"Python bytecode is forbidden in a release: {relative}") + schema._canonical_relative_path(relative, "payload file") + info = os.lstat(path) + if stat.S_ISLNK(info.st_mode): + target = schema._safe_symlink_target(relative, os.readlink(path)) + entries.append({"path": relative, "target": target, "type": "symlink"}) + elif stat.S_ISREG(info.st_mode): + if ( + info.st_size == len(TEST_FIXTURE_MARKER_CONTENT) + and path.read_bytes() == TEST_FIXTURE_MARKER_CONTENT + ): + _fail( + "Model Guard test-fixture marker content is forbidden " + f"in a release: {relative}" + ) + mode = 0o755 if stat.S_IMODE(info.st_mode) & 0o111 else 0o644 + entries.append( + { + "mode": mode, + "path": relative, + "sha256": _sha256_file(path), + "size": info.st_size, + "type": "file", + } + ) + else: + _fail(f"payload object type rejected: {relative}") + sources[relative] = path + entries.sort(key=lambda entry: str(entry["path"]).encode("utf-8")) + schema._validate_payload_manifest({"entries": entries, "format": schema.PAYLOAD_FORMAT}) + return entries, sources + + +def _copy_snapshot_regular( + source: Path, destination: Path, expected: Mapping[str, Any] +) -> None: + info = os.stat(source) + if not stat.S_ISREG(info.st_mode): + _fail(f"payload file changed type before snapshot: {expected['path']}") + source_fd = os.open(source, os.O_RDONLY | os.O_CLOEXEC) + destination_fd = os.open( + destination, + os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_CLOEXEC | os.O_NOFOLLOW, + 0o600, + ) + digest = hashlib.sha256() + copied = 0 + try: + before = os.fstat(source_fd) + if _metadata_snapshot(before) != _metadata_snapshot(info): + _fail(f"payload file changed before snapshot open: {expected['path']}") + while True: + block = os.read(source_fd, 1024 * 1024) + if not block: + break + digest.update(block) + copied += len(block) + offset = 0 + while offset < len(block): + offset += os.write(destination_fd, block[offset:]) + os.fdatasync(destination_fd) + after = os.fstat(source_fd) + finally: + os.close(destination_fd) + os.close(source_fd) + current = os.stat(source) + if ( + _metadata_snapshot(after) != _metadata_snapshot(before) + or _metadata_snapshot(current) != _metadata_snapshot(before) + or copied != expected["size"] + or digest.hexdigest() != expected["sha256"] + ): + _fail(f"payload file changed while snapshotting: {expected['path']}") + os.chmod(destination, int(expected["mode"])) + + +def _snapshot_payload(source: Path, destination: Path) -> None: + entries, sources = _collect_payload(source) + destination.mkdir(mode=0o700) + for entry in entries: + relative = str(entry["path"]) + target = destination / relative + source_path = sources[relative] + if entry["type"] == "directory": + target.mkdir(mode=0o700) + elif entry["type"] == "symlink": + current_target = os.readlink(source_path) + if current_target != entry["target"]: + _fail(f"payload symlink changed while snapshotting: {relative}") + os.symlink(current_target, target) + else: + _copy_snapshot_regular(source_path, target, entry) + snapshot_entries, _ = _collect_payload(destination) + if snapshot_entries != entries: + _fail("private payload snapshot differs from the admitted source tree") + + +def _audit_for_manifest( + payload: Path, + nm: Path, +) -> tuple[list[str], str]: + guard = payload / f"lib/{schema.GUARD_REAL_FILENAME}" + output = schema._run_tool((str(nm), "-D", "--defined-only", "--format=posix", str(guard))).decode( + "utf-8", "replace" + ) + exports = sorted( + fields[0].split("@", 1)[0] + for line in output.splitlines() + if (fields := line.split()) + and len(fields) >= 2 + and fields[1].upper() != "A" + and not fields[0].startswith("_") + ) + if exports != list(schema.REQUIRED_GUARD_EXPORTS): + _fail("release guard exports do not match the frozen v2 ABI") + guard_image = _checked_file_bytes( + guard, MAX_MODEL_GUARD_ELF_BYTES, "release Model Guard" + ) + audit_model_guard_trust_objects(guard_image) + return exports, schema._exports_digest(exports) + + +def _tar_info(name: str, mode: int, object_type: bytes, size: int = 0, target: str = "") -> tarfile.TarInfo: + info = tarfile.TarInfo(name) + info.mode = mode + info.uid = 0 + info.gid = 0 + info.uname = "root" + info.gname = "root" + info.mtime = 0 + info.type = object_type + info.size = size + info.linkname = target + return info + + +def _write_bundle( + output: ControlledOutput, + manifest_bytes: bytes, + signature: bytes, + payload_bytes: bytes, + entries: Sequence[Mapping[str, Any]], + sources: Mapping[str, Path], +) -> None: + _recheck_controlled_output(output) + _directory_entry_absent( + output.directory_fd, output.output_name, "release output" + ) + _directory_entry_absent( + output.directory_fd, + output.temporary_name, + "release temporary output", + ) + fd = os.open( + output.temporary_name, + os.O_WRONLY + | os.O_CREAT + | os.O_EXCL + | os.O_CLOEXEC + | os.O_NOFOLLOW, + 0o600, + dir_fd=output.directory_fd, + ) + temporary_info = os.fstat(fd) + try: + with os.fdopen(fd, "wb", closefd=False) as raw, gzip.GzipFile( + filename="", mode="wb", fileobj=raw, mtime=0, compresslevel=9 + ) as compressed, tarfile.open(fileobj=compressed, mode="w|", format=tarfile.PAX_FORMAT) as bundle: + bundle.addfile(_tar_info("meta", 0o755, tarfile.DIRTYPE)) + for name, data in ( + ("meta/compatibility.manifest.json", manifest_bytes), + ("meta/compatibility.manifest.sig", signature), + ("meta/payload.files.json", payload_bytes), + ): + bundle.addfile(_tar_info(name, 0o600, tarfile.REGTYPE, len(data)), io.BytesIO(data)) + bundle.addfile(_tar_info("payload", 0o755, tarfile.DIRTYPE)) + for entry in entries: + name = f"payload/{entry['path']}" + if entry["type"] == "directory": + bundle.addfile(_tar_info(name, entry["mode"], tarfile.DIRTYPE)) + elif entry["type"] == "symlink": + bundle.addfile(_tar_info(name, 0o777, tarfile.SYMTYPE, target=entry["target"])) + else: + with sources[str(entry["path"])].open("rb") as source: + bundle.addfile(_tar_info(name, entry["mode"], tarfile.REGTYPE, entry["size"]), source) + os.fdatasync(fd) + except BaseException: + _unlink_owned_output( + output, output.temporary_name, temporary_info + ) + raise + finally: + os.close(fd) + published = False + try: + _recheck_controlled_output(output) + current_temporary = os.stat( + output.temporary_name, + dir_fd=output.directory_fd, + follow_symlinks=False, + ) + if ( + current_temporary.st_dev, + current_temporary.st_ino, + ) != ( + temporary_info.st_dev, + temporary_info.st_ino, + ): + _fail("release temporary output changed before publication") + _directory_entry_absent( + output.directory_fd, output.output_name, "release output" + ) + _rename_output_noreplace( + output.directory_fd, + output.temporary_name, + output.output_name, + ) + published = True + published_info = os.stat( + output.output_name, + dir_fd=output.directory_fd, + follow_symlinks=False, + ) + if ( + published_info.st_dev, + published_info.st_ino, + published_info.st_mode, + ) != ( + temporary_info.st_dev, + temporary_info.st_ino, + temporary_info.st_mode, + ): + _fail("published release output identity mismatch") + os.fsync(output.directory_fd) + _recheck_controlled_output(output) + except BaseException: + _unlink_owned_output( + output, + output.output_name if published else output.temporary_name, + temporary_info, + ) + try: + os.fsync(output.directory_fd) + except OSError: + pass + raise + +def _build_bundle_from_snapshot( + arguments: argparse.Namespace, + payload: Path, + output: ControlledOutput, + public_key: Path, + signing_key_fd_inherited: bool, +) -> None: + openssl = Path("/usr/bin/openssl") + readelf = Path("/usr/bin/aarch64-linux-gnu-readelf") + nm = Path("/usr/bin/aarch64-linux-gnu-nm") + for tool in (openssl, readelf, nm): + if not tool.is_file(): + _fail(f"required fixed release tool is missing: {tool}") + schema._validate_root_owned_tool(tool) + + release_id = schema._validate_release_id(arguments.release_id) + generation = schema._require_uint(arguments.generation, "release generation", 1) + entries, sources = _collect_payload(payload) + payload_value = {"entries": entries, "format": schema.PAYLOAD_FORMAT} + payload_bytes = schema._canonical_json(payload_value) + key_id, key_sha256, expected_bootstrap_trust, public_key_pem = ( + _public_key_identity(openssl, public_key) + ) + exports, exports_sha256 = _audit_for_manifest(payload, nm) + + regular_required_paths = { + "bin/cosmo-engine", + schema.RELEASE_BOOTSTRAP_PATH, + schema.MODEL_PROVISION_PATH, + f"lib/{schema.GUARD_REAL_FILENAME}", + "lib/libbmrt.so", + "lib/libbmrt.so.1.0", + "lib/libbmlib.so", + "lib/libbmlib.so.0", + "lib/libcrypto.so.3", + "lib/libssl.so.3", + schema.GUARD_HEADER_PATH, + *schema.REQUIRED_RELEASE_SCRIPTS, + } + symlink_targets = { + f"lib/{schema.GUARD_SONAME}": schema.GUARD_REAL_FILENAME, + "lib/libcosmo_model_guard.so": schema.GUARD_SONAME, + } + required_paths = regular_required_paths | set(symlink_targets) + if not required_paths.issubset(sources): + missing = ", ".join(sorted(required_paths - set(sources))) + _fail(f"release payload is missing required compatibility files: {missing}") + entry_by_path = {str(entry["path"]): entry for entry in entries} + entry_types = {path: str(entry["type"]) for path, entry in entry_by_path.items()} + for path in regular_required_paths: + if entry_types.get(path) != "file": + _fail(f"required release file must be a single-link regular file: {path}") + for path, target in symlink_targets.items(): + if entry_types.get(path) != "symlink" or entry_by_path[path]["target"] != target: + _fail(f"required release symlink target rejected: {path}") + if entry_by_path["bin/cosmo-engine"]["mode"] != 0o755: + _fail("cosmo-engine must be executable in the release payload") + if entry_by_path[schema.RELEASE_BOOTSTRAP_PATH]["mode"] != 0o755: + _fail("cosmo-release-bootstrap must be executable in the release payload") + if entry_by_path[schema.MODEL_PROVISION_PATH]["mode"] != 0o755: + _fail("cosmo-model-provision must be executable in the release payload") + bootstrap = payload / schema.RELEASE_BOOTSTRAP_PATH + _audit_bootstrap_dynamic_contract(bootstrap, readelf) + if _bootstrap_trust_symbols(bootstrap) != expected_bootstrap_trust: + _fail("release bootstrap trust anchor differs from the release signing key") + for path in schema.REQUIRED_RELEASE_SCRIPTS: + expected_modes = (0o644, 0o755) if path.endswith(".py") else (0o755,) + if entry_by_path[path]["mode"] not in expected_modes: + _fail(f"required release script mode rejected: {path}") + for path in (schema.GUARD_HEADER_PATH,): + if entry_by_path[path]["mode"] != 0o644: + _fail(f"release compatibility metadata mode rejected: {path}") + for directory in schema.FACADE_DIRECTORIES: + if entry_types.get(directory) != "directory": + _fail(f"release payload is missing required facade directory: {directory}") + # Audit the immutable private payload snapshot before the signing key is + # read. This keeps plaintext presets from ever reaching the + # release-signing boundary. + schema._scan_preset_models(payload) + header_bytes = _checked_file_bytes( + payload / schema.GUARD_HEADER_PATH, + schema.MAX_GUARD_HEADER_BYTES, + "release Model Guard header", + ) + schema._validate_model_guard_header(header_bytes) + manifest: dict[str, Any] = { + "edge": { + "compatibility_id": "0" * 64, + "path": "bin/cosmo-engine", + "sha256": _sha256_file(payload / "bin/cosmo-engine"), + }, + "device_certificate_schema": 1, + "format": schema.FORMAT, + "model_guard": { + "exports": exports, + "exports_sha256": exports_sha256, + "header_path": schema.GUARD_HEADER_PATH, + "header_sha256": _sha256_file(payload / schema.GUARD_HEADER_PATH), + "path": f"lib/{schema.GUARD_REAL_FILENAME}", + "sha256": _sha256_file(payload / f"lib/{schema.GUARD_REAL_FILENAME}"), + }, + "payload_manifest_sha256": hashlib.sha256(payload_bytes).hexdigest(), + "release_generation": generation, + "release_id": release_id, + "release_key": {"id": key_id, "public_key_sha256": key_sha256}, + } + manifest["edge"]["compatibility_id"] = schema._compatibility_id(manifest) + schema._validate_compatibility_manifest(manifest) + manifest_bytes = schema._canonical_json(manifest) + + _validate_output_before_secret(output) + signing_key = _read_signing_key_fd(signing_key_fd_inherited) + with contextlib.ExitStack() as descriptors: + key_fd = _sealed_memfd(signing_key) + descriptors.callback(os.close, key_fd) + message_fd = _readonly_memfd( + "cosmo-release-manifest", manifest_bytes + ) + descriptors.callback(os.close, message_fd) + signature = _run( + ( + str(openssl), + "pkeyutl", + "-sign", + "-inkey", + f"/proc/self/fd/{key_fd}", + "-rawin", + "-in", + f"/proc/self/fd/{message_fd}", + ), + pass_fds=(key_fd, message_fd), + ) + if len(signature) != 64: + _fail("release signer did not produce an Ed25519 signature") + _verify_ed25519_signature( + openssl, + public_key_pem, + manifest_bytes, + signature, + "release manifest", + ) + _write_bundle(output, manifest_bytes, signature, payload_bytes, entries, sources) + print(f"Signed release: {output.path}") + print(f"Release manifest SHA-256: {hashlib.sha256(manifest_bytes).hexdigest()}") + +def _snapshot_controlled_file( + source: Path, + destination: Path, + maximum_size: int, + description: str, + *, + executable: bool = False, +) -> Path: + source_info = source.lstat() + if executable and not source_info.st_mode & stat.S_IXUSR: + _fail(f"{description} must be owner-executable") + data = _checked_file_bytes(source, maximum_size, description) + destination.parent.mkdir(mode=0o700, parents=True, exist_ok=True) + os.chmod(destination.parent, 0o700) + mode = 0o500 if executable else 0o400 + descriptor = os.open( + destination, + os.O_WRONLY + | os.O_CREAT + | os.O_EXCL + | os.O_CLOEXEC + | os.O_NOFOLLOW, + mode, + ) + try: + offset = 0 + while offset < len(data): + offset += os.write(descriptor, data[offset:]) + os.fdatasync(descriptor) + finally: + os.close(descriptor) + return destination + + +def build_bundle(arguments: argparse.Namespace) -> None: + signing_key_fd_inherited = _signing_key_fd_is_inherited() + source_payload = Path(arguments.payload).resolve(strict=True) + public_key = Path(arguments.release_public_key).resolve(strict=True) + output = _prepare_controlled_output(arguments.output) + try: + with tempfile.TemporaryDirectory(prefix="cosmo-release-payload-snapshot-") as temporary: + snapshot_parent = Path(temporary) + os.chmod(snapshot_parent, 0o700) + snapshot_payload = snapshot_parent / "payload" + _snapshot_payload(source_payload, snapshot_payload) + snapshot_controls = snapshot_parent / "release-controls" + snapshot_public_key = _snapshot_controlled_file( + public_key, + snapshot_controls / "release-public-key.pem", + 16 * 1024, + "release public key", + ) + _build_bundle_from_snapshot( + arguments, + snapshot_payload, + output, + snapshot_public_key, + signing_key_fd_inherited, + ) + finally: + os.close(output.directory_fd) + + +def main(argv: Sequence[str]) -> int: + try: + _require_isolated_entrypoint() + except PackagerError as error: + print(f"release packaging failed: {error}", file=sys.stderr) + return 1 + parser = argparse.ArgumentParser(description="Create a signed Cosmo release archive") + parser.add_argument("--payload", required=True) + parser.add_argument("--output", required=True) + parser.add_argument("--release-id", required=True) + parser.add_argument("--generation", required=True, type=int) + parser.add_argument("--release-public-key", required=True) + arguments = parser.parse_args(argv) + try: + build_bundle(arguments) + except (PackagerError, schema.ReleaseError) as error: + print(f"release packaging failed: {error}", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:])) diff --git a/scripts/build_release_public_key_object.py b/scripts/build_release_public_key_object.py new file mode 100755 index 000000000..60b0d528a --- /dev/null +++ b/scripts/build_release_public_key_object.py @@ -0,0 +1,192 @@ +#!/usr/bin/python3 +"""Generate the AArch64 release-verifier trust-anchor object. + +The public key is not secret, but production must inject it as a generated ELF +object rather than accepting a runtime configuration file. The main CMake +integration must require this object and fail configuration/linking when it is +absent. This generator never supplies a default or test key. +""" + +from __future__ import annotations + +import argparse +import hashlib +import os +import re +import stat +import subprocess +import sys +import tempfile +from pathlib import Path +from typing import Sequence + + +OPENSSL = Path("/usr/bin/openssl") +ASSEMBLER = Path("/usr/bin/aarch64-linux-gnu-as") +READELF = Path("/usr/bin/aarch64-linux-gnu-readelf") + + +class GenerationError(RuntimeError): + pass + + +def _require_isolated_entrypoint() -> None: + if sys.flags.isolated != 1: + raise GenerationError( + "release public-key object generation must be launched with " + "/usr/bin/python3 -I -B" + ) + + +def _run(arguments: Sequence[str]) -> bytes: + try: + result = subprocess.run( + list(arguments), + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + close_fds=True, + env={"LC_ALL": "C", "PATH": "/usr/bin:/bin"}, + timeout=30, + ) + except (OSError, subprocess.TimeoutExpired) as error: + raise GenerationError(f"required tool failed: {arguments[0]}") from error + if result.returncode != 0: + raise GenerationError(f"required tool rejected input: {Path(arguments[0]).name}") + return result.stdout + + +def _validate_tool(path: Path) -> None: + info = os.stat(path) + if not stat.S_ISREG(info.st_mode): + raise GenerationError(f"tool is not a regular file: {path}") + + +def _assembly_bytes(label: str, data: bytes) -> str: + values = ",".join(f"0x{value:02x}" for value in data) + return ( + f".hidden {label}\n" + f".global {label}\n" + f".type {label}, %object\n" + f".size {label}, {len(data)}\n" + f"{label}:\n" + f".byte {values}\n" + ) + + +def generate(public_key: Path, output: Path) -> None: + for tool in (OPENSSL, ASSEMBLER, READELF): + _validate_tool(tool) + key_info = os.stat(public_key) + if not stat.S_ISREG(key_info.st_mode): + raise GenerationError("release public key is not a regular file") + pem = public_key.read_bytes() + if not pem or len(pem) > 16 * 1024: + raise GenerationError("release public key size rejected") + canonical_pem = _run((str(OPENSSL), "pkey", "-pubin", "-in", str(public_key), "-pubout")) + if pem != canonical_pem: + raise GenerationError("release public key PEM is not in the canonical OpenSSL encoding") + der = _run((str(OPENSSL), "pkey", "-pubin", "-in", str(public_key), "-outform", "DER")) + prefix = bytes.fromhex("302a300506032b6570032100") + if len(der) != 44 or der[: len(prefix)] != prefix: + raise GenerationError("release public key must be Ed25519") + raw_key = der[-32:] + key_id = hashlib.sha256( + b"cosmo-release-key-id-v1" + (1).to_bytes(2, "big") + raw_key + ).digest()[:16] + pem_digest = hashlib.sha256(pem).digest() + if not any(key_id): + raise GenerationError("derived release key ID is zero") + + output_parent = output.parent.resolve(strict=True) + parent_info = os.stat(output_parent) + if not stat.S_ISDIR(parent_info.st_mode): + raise GenerationError("output parent must be a directory") + if output.exists() or output.is_symlink(): + raise GenerationError("release public-key object output already exists") + + source = ( + '.section .rodata.cosmo_release_key,"a",%progbits\n' + ".balign 16\n" + + _assembly_bytes("cosmo_release_public_key_raw_v1", raw_key) + + ".balign 16\n" + + _assembly_bytes("cosmo_release_public_key_id_v1", key_id) + + ".balign 16\n" + + _assembly_bytes("cosmo_release_public_key_pem_sha256_v1", pem_digest) + + '.section .note.GNU-stack,"",%progbits\n' + ) + + with tempfile.TemporaryDirectory(prefix="cosmo-release-key-object-", dir=output_parent) as temporary: + root = Path(temporary) + source_path = root / "release-key.s" + object_path = root / "release-key.o" + source_fd = os.open( + source_path, + os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_CLOEXEC | os.O_NOFOLLOW, + 0o600, + ) + try: + os.write(source_fd, source.encode("ascii")) + os.fdatasync(source_fd) + finally: + os.close(source_fd) + _run((str(ASSEMBLER), "-o", str(object_path), str(source_path))) + header = _run((str(READELF), "-hW", str(object_path))).decode("utf-8", "replace") + if ( + re.search(r"^\s*Type:\s+REL \(Relocatable file\)\s*$", header, re.MULTILINE) is None + or re.search(r"^\s*Machine:\s+AArch64\s*$", header, re.MULTILINE) is None + ): + raise GenerationError("generated trust-anchor object is not AArch64 relocatable ELF") + object_info = os.lstat(object_path) + if not stat.S_ISREG(object_info.st_mode): + raise GenerationError("generated trust-anchor object type rejected") + destination_fd = os.open( + output, + os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_CLOEXEC | os.O_NOFOLLOW, + 0o600, + ) + try: + with object_path.open("rb") as source_stream: + while True: + block = source_stream.read(65536) + if not block: + break + offset = 0 + while offset < len(block): + offset += os.write(destination_fd, block[offset:]) + os.fdatasync(destination_fd) + except BaseException: + try: + os.unlink(output) + except OSError: + pass + raise + finally: + os.close(destination_fd) + directory_fd = os.open(output_parent, os.O_RDONLY | os.O_DIRECTORY | os.O_CLOEXEC) + try: + os.fsync(directory_fd) + finally: + os.close(directory_fd) + + +def main(argv: Sequence[str]) -> int: + try: + _require_isolated_entrypoint() + except GenerationError as error: + print(f"release public-key object generation failed: {error}", file=sys.stderr) + return 1 + parser = argparse.ArgumentParser(description="Generate the release trust-anchor AArch64 object") + parser.add_argument("--public-key", required=True) + parser.add_argument("--output", required=True) + arguments = parser.parse_args(argv) + try: + generate(Path(arguments.public_key).resolve(strict=True), Path(arguments.output).resolve(strict=False)) + except (GenerationError, OSError) as error: + print(f"release public-key object generation failed: {error}", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:])) diff --git a/scripts/build_sophon_package.ps1 b/scripts/build_sophon_package.ps1 index 7994012d2..6e56c7d8b 100644 --- a/scripts/build_sophon_package.ps1 +++ b/scripts/build_sophon_package.ps1 @@ -15,12 +15,28 @@ $ErrorActionPreference = "Stop" # Subsequent builds copy only changed files. # # Prerequisites: Docker Desktop -# Output: build_output/cosmo-*.tar.gz +# Output: build_output//cosmo-*.tar.gz # ============================================================================= $VolumeName = "cosmo-sophon-source" $ComposeFile = "docker-compose.sophon.yml" $OverrideFile = "docker-compose.sophon.override.yml" +$BuildProfile = $env:COSMO_MODEL_GUARD_BUILD_PROFILE +if ([string]::IsNullOrWhiteSpace($BuildProfile)) { + $BuildProfile = "public-runtime" +} +if ($BuildProfile -notin @("public-runtime", "production-release")) { + throw "COSMO_MODEL_GUARD_BUILD_PROFILE must be public-runtime or production-release" +} +$env:COSMO_MODEL_GUARD_BUILD_PROFILE = $BuildProfile +$PackageVariant = if ($BuildProfile -eq "public-runtime") { + "SOURCE" +} else { + $BuildProfile +} +if ($BuildProfile -eq "production-release") { + Write-Warning "production-release requires organization-approved controlled Compose inputs; selecting the profile alone fails closed." +} # ── Helpers ────────────────────────────────────────────────────────────────── @@ -52,6 +68,25 @@ $scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path $projectRoot = [System.IO.Path]::GetFullPath((Join-Path $scriptDir "..")) $dockerSrc = ConvertTo-DockerPath $projectRoot +if ($BuildProfile -eq "public-runtime") { + $SourceCommit = $env:COSMO_EDGE_SOURCE_COMMIT + if ([string]::IsNullOrWhiteSpace($SourceCommit)) { + if (-not (Get-Command git -ErrorAction SilentlyContinue)) { + throw "SOURCE packaging requires Git or an explicit COSMO_EDGE_SOURCE_COMMIT" + } + $ResolvedCommit = & git -C $projectRoot rev-parse --verify 'HEAD^{commit}' + if ($LASTEXITCODE -ne 0) { + throw "Cannot resolve the Edge commit; set COSMO_EDGE_SOURCE_COMMIT explicitly" + } + $SourceCommit = ([string]$ResolvedCommit).Trim() + } + if ($SourceCommit -notmatch '^[0-9a-f]{40}$') { + throw "COSMO_EDGE_SOURCE_COMMIT must be lower-case 40-hex" + } + $env:COSMO_EDGE_SOURCE_COMMIT = $SourceCommit + Write-Host "Edge source commit: $SourceCommit" +} + Write-Step "Step 1/5 - Checking Docker" try { Invoke-Docker info } catch { Write-Error "Docker is not running. Start Docker Desktop, then re-run."; exit 1 } Write-Host "Docker is ready" @@ -87,7 +122,7 @@ Invoke-Docker run --rm ` alpine ` sh /workspace/scripts/restore-symlinks.sh -Write-Step "Step 4/5 - Running Sophon cross-compilation" +Write-Step "Step 4/5 - Running Sophon cross-compilation ($PackageVariant)" # Generate a compose override that swaps the bind mount for our named volume. # The override REPLACES the volumes list; we keep ./build_output as a bind @@ -117,7 +152,7 @@ try { } Write-Step "Step 5/5 - Build output" -$outputDir = Join-Path $projectRoot "build_output" +$outputDir = Join-Path (Join-Path $projectRoot "build_output") $BuildProfile if (Test-Path $outputDir) { $packages = Get-ChildItem $outputDir -Filter "*.tar.gz" if ($packages) { @@ -125,10 +160,16 @@ if (Test-Path $outputDir) { Write-Host " $($pkg.Name) ($('{0:N0}' -f $pkg.Length) bytes)" -ForegroundColor Green } } else { - Write-Warning "No .tar.gz found in build_output/" + Write-Warning "No .tar.gz found in build_output/$BuildProfile/" } } else { - Write-Warning "build_output/ directory not found" + Write-Warning "build_output/$BuildProfile/ directory not found" +} + +if ($BuildProfile -eq "public-runtime") { + Write-Host "SOURCE package created. Protected preset models require one separately provisioned device-bound certificate." +} else { + Write-Warning "production-release artifacts are candidates only; controlled offline signing is still required before deployment." } Write-Host "`n=== Sophon build completed ===" -ForegroundColor Green diff --git a/scripts/build_test.sh b/scripts/build_test.sh index 14ba41434..5389f11d5 100755 --- a/scripts/build_test.sh +++ b/scripts/build_test.sh @@ -1,26 +1,8 @@ #!/bin/bash +set -euo pipefail -if [ -z $PROJECT_ROOT_PATH ] -then - PROJECT_ROOT_PATH=$(cd `dirname $0`; pwd)/.. -fi - -BUILD_DIR=${PROJECT_ROOT_PATH}/build - -mkdir -p ${BUILD_DIR} -cd ${BUILD_DIR} - -echo "Configuring with tests enabled..." -cmake -DCMAKE_BUILD_TYPE=Release \ - -DBUILD_TESTS=ON \ - .. - -# Symlink compile_commands.json to project root for IDE and static analysis tools -ln -sf "${BUILD_DIR}/compile_commands.json" "${PROJECT_ROOT_PATH}/compile_commands.json" 2>/dev/null || true - -echo "Building cosmo-tests..." -cmake --build . --target cosmo-tests -j$(nproc) - -echo "" -echo "Build complete: ${BUILD_DIR}/cosmo-tests" - +# Keep one authoritative Sophon build path. A clean test build uses the same +# configure-time Guard SDK admission and packaging profile as the final build. +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)" +export COSMO_MODEL_GUARD_BUILD_PROFILE="${COSMO_MODEL_GUARD_BUILD_PROFILE:-public-runtime}" +exec "${SCRIPT_DIR}/build.sh" -T "$@" diff --git a/scripts/common.sh b/scripts/common.sh index 8639ff8e9..4222d8429 100644 --- a/scripts/common.sh +++ b/scripts/common.sh @@ -8,6 +8,10 @@ COSMO_LOG_DIR="${COSMO_DATA_DIR}/log/logs" COSMO_INSTALL_DIR="/appfs/cosmo_wander/cwai_data" COSMO_UPGRADE_DIR="${COSMO_DATA_DIR}/upgrade" COSMO_NGINX_TMP_DIR="${COSMO_DATA_DIR}/tmp" +COSMO_RELEASES_DIR="${COSMO_INSTALL_DIR}/.releases" +COSMO_RELEASE_CURRENT="${COSMO_INSTALL_DIR}/current" +COSMO_RELEASE_STATE_DIR="${COSMO_INSTALL_DIR}/.release-state" +COSMO_MODEL_GUARD_STATE_DIR="${COSMO_DATA_DIR}/model-guard" # Upgrade signal files COSMO_HW_UPGRADE_SIGN="${COSMO_DATA_DIR}/mqttHWUpgradeApp" @@ -36,7 +40,15 @@ ensure_runtime_dirs() { mkdir -p "${COSMO_NGINX_TMP_DIR}/nginx_scgi" mkdir -p "${COSMO_UPGRADE_DIR}" - mkdir -p "${COSMO_INSTALL_DIR}/bin/nginx_conf/logs" + # Versioned release trees are immutable before activation. Runtime nginx + # state is created only below the already-active release. + if [ -L "${COSMO_RELEASE_CURRENT}" ] && [ -d "${COSMO_RELEASE_CURRENT}/bin/nginx_conf" ]; then + mkdir -p "${COSMO_RELEASE_CURRENT}/bin/nginx_conf/logs" + elif [ -d "${COSMO_INSTALL_DIR}/bin/nginx_conf" ]; then + # Legacy layout remains bootable until the factory bootstrap installs + # the first trust-anchored release state. + mkdir -p "${COSMO_INSTALL_DIR}/bin/nginx_conf/logs" + fi } # ── Process helpers ── diff --git a/scripts/format_check.sh b/scripts/format_check.sh index 9b3dc5d62..d82bce154 100755 --- a/scripts/format_check.sh +++ b/scripts/format_check.sh @@ -63,7 +63,7 @@ collect_files() { if [[ "$STAGED_ONLY" == true ]]; then git -C "$PROJECT_ROOT" diff --cached --name-only --diff-filter=ACMR \ | grep -E '\.(h|cc)$' \ - | grep -vE '(^|/)3rd/' \ + | grep -vE '(^|/)(3rd|prebuild)/' \ | while read -r f; do echo "$PROJECT_ROOT/$f"; done else find "$PROJECT_ROOT/src" "$PROJECT_ROOT/test" \ diff --git a/scripts/install.sh b/scripts/install.sh old mode 100644 new mode 100755 index cd76ef264..a52d43999 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -1,121 +1,52 @@ #!/bin/bash -set -e +set -eu +IFS=$' \t\n' +PATH='/usr/sbin:/usr/bin:/sbin:/bin' +export IFS PATH -# Enter the directory where the install script is located (before calling stop.sh) -SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -cd "$SCRIPT_DIR" +# Trusted release-transaction wrapper. This file is always executed from the +# currently active release; an incoming archive's install script is never run. + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)" # shellcheck source=common.sh . "${SCRIPT_DIR}/common.sh" -# Stop running processes first -./stop.sh - -# WARNING: Do not modify INSTALLPATH - it is the production deployment root -INSTALLPATH="${COSMO_INSTALL_DIR}" - -INSTALL_SUCCESS_SIGN="${COSMO_UPGRADE_SIGN}" - -logFile="${1:-/dev/null}" -logTag="[INSTALL]" - -echo "${logTag} Install Start" >> "$logFile" -echo "${logTag} script=$0, logFile=$1" >> "$logFile" -echo "${logTag} script dir: $SCRIPT_DIR" >> "$logFile" - -echo "Install path is ${INSTALLPATH}" - -echo "${logTag} Installing files..." >> "$logFile" -echo "Installing files..." - -# Ensure install path exists and is non-empty -if [ -z "${INSTALLPATH}" ]; then - echo "${logTag} ERROR: INSTALLPATH is empty, aborting!" >> "$logFile" +updater="${SCRIPT_DIR}/release_updater.sh" +if [ ! -f "$updater" ]; then + echo "[INSTALL] release updater is unavailable" >&2 exit 1 fi -mkdir -p "${INSTALLPATH}" - -# Remove old directories -PACKAGE_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" -for dir in bin lib scripts web font files; do - if [ -d "${INSTALLPATH}/${dir}" ]; then - rm -rf "${INSTALLPATH:?}/${dir}" - fi -done - -# Install new files (skip missing directories) -for dir in bin lib scripts web font files resource; do - if [ -d "${PACKAGE_DIR}/${dir}" ]; then - if [ "$dir" = "resource" ]; then - echo "${logTag} Overwriting ${dir}..." >> "$logFile" - if [ "${CLEAN_RESOURCE:-0}" = "1" ] && [ -d "${INSTALLPATH}/resource" ]; then - echo "${logTag} CLEAN_RESOURCE=1, removing ${INSTALLPATH}/resource before install" >> "$logFile" - rm -rf "${INSTALLPATH:?}/resource" - fi - mkdir -p "${INSTALLPATH}/resource" - cp -rf "${PACKAGE_DIR}/resource/." "${INSTALLPATH}/resource/" - else - mv -f "${PACKAGE_DIR}/${dir}" "${INSTALLPATH}/" +action="${1:-}" +case "$action" in + prepare) + if [ "$#" -lt 2 ] || [ "$#" -gt 3 ]; then + echo "Usage: $0 prepare [logfile]" >&2 + exit 2 fi - else - echo "${logTag} WARNING: ${dir} not found in package, skipping" >> "$logFile" - fi -done - -# Setup static file symlinks -mkdir -p "${INSTALLPATH}/web/staticfile" -rm -f "${INSTALLPATH}/web/staticfile/httpInterface.html" -rm -f "${INSTALLPATH}/web/staticfile/mqttInterface.html" -ln -sf "${INSTALLPATH}/files/Interface/ai-box-interface_v1.0.html" "${INSTALLPATH}/web/staticfile/httpInterface.html" -ln -sf "${INSTALLPATH}/files/Interface/mqtt_v1.0.html" "${INSTALLPATH}/web/staticfile/mqttInterface.html" - -mkdir -p "${INSTALLPATH}/bin/nginx_conf/logs" - -# Remove install script from deployed location (self-cleanup) -rm -f "${INSTALLPATH}/scripts/install.sh" - -echo "Install files Done." -echo "${logTag} Install files Done." >> "$logFile" - -# Setup systemd auto-start service -SERVICE_FILE="/etc/systemd/system/cosmo.service" -SERVICE_LINK="/etc/systemd/system/multi-user.target.wants/cosmo.service" - -echo "${logTag} Setting up systemd auto-start service..." >> "$logFile" - -cat > "$SERVICE_FILE" <> "$logFile" -echo "systemd service [cosmo] installed and enabled." - -# Upgrade completion marker for MQTT reporting -mkdir -p "$(dirname "$INSTALL_SUCCESS_SIGN")" -touch "$INSTALL_SUCCESS_SIGN" -sync - -echo "${logTag} Install End." -echo "${logTag} Install End." >> "$logFile" + archive="$2" + log_file="${3:-/dev/null}" + cosmo_log "INSTALL" "Validating and staging signed compatibility release" "$log_file" + "$updater" prepare "$archive" + cosmo_log "INSTALL" "Signed release staged; active release is unchanged" "$log_file" + ;; + run-pending-health) + if [ "$#" -ne 3 ]; then + echo "Usage: $0 run-pending-health " >&2 + exit 2 + fi + "$updater" "$action" "$2" "$3" + ;; + activate|commit-healthy|rollback|recover|active-path|pending-path|pending-health-script) + if [ "$#" -ne 1 ]; then + echo "Usage: $0 ${action}" >&2 + exit 2 + fi + "$updater" "$action" + ;; + *) + echo "Usage: $0 {prepare [logfile]|activate|commit-healthy|rollback|recover|active-path|pending-path|pending-health-script|run-pending-health }" >&2 + exit 2 + ;; +esac diff --git a/scripts/inte_run_start.sh b/scripts/inte_run_start.sh old mode 100644 new mode 100755 index 07238cfc8..4de5e827b --- a/scripts/inte_run_start.sh +++ b/scripts/inte_run_start.sh @@ -14,5 +14,13 @@ ensure_runtime_dirs cosmo_log "BOOT" "Starting Cosmo services..." +# Keep the historical systemd ExecStart path stable. Once factory bootstrap +# has created the atomic release pointer, dispatch into that exact active +# release; the compatibility transaction never rewrites the systemd unit. +if [ -L "$COSMO_RELEASE_CURRENT" ] && [ -x "$COSMO_RELEASE_CURRENT/scripts/start.sh" ]; then + cd "$COSMO_RELEASE_CURRENT/scripts" || exit 1 + exec "$COSMO_RELEASE_CURRENT/scripts/start.sh" start +fi + cd "$SCRIPT_DIR" || exit 1 -"$SCRIPT_DIR/start.sh" start +exec "$SCRIPT_DIR/start.sh" start diff --git a/scripts/legacy_migration_install.sh b/scripts/legacy_migration_install.sh new file mode 100644 index 000000000..3d4c676be --- /dev/null +++ b/scripts/legacy_migration_install.sh @@ -0,0 +1,60 @@ +#!/bin/bash +set -euo pipefail + +IFS=$' \t\n' +PATH='/usr/sbin:/usr/bin:/sbin:/bin' +export IFS PATH + +fail() { + echo "[MIGRATION] ERROR: $*" >&2 + exit 1 +} + +[ "$#" -le 1 ] || fail "legacy entry accepts only the optional log file" +log_file="${1:-/dev/null}" + +script_path="$(readlink -f -- "$0")" +[ -n "$script_path" ] || fail "cannot resolve installer path" +payload_root="${script_path%/scripts/install.sh}" +[ "$payload_root" != "$script_path" ] || fail "installer is outside the package scripts directory" + +if [ -n "${COSMO_MIGRATION_TEST_ROOT:-}" ]; then + case "$COSMO_MIGRATION_TEST_ROOT" in /*) ;; *) fail "test root must be absolute" ;; esac + [ "$COSMO_MIGRATION_TEST_ROOT" != / ] || fail "test root is invalid" + active_root="${COSMO_MIGRATION_TEST_ROOT}/appfs/cosmo_wander/cwai_data" +else + active_root='/appfs/cosmo_wander/cwai_data' +fi +active_parent="${active_root%/*}" +staging_root="${active_parent}/.cosmo-migration-staging.$$" +backup_root="${active_parent}/.cosmo-migration-backup" + +[ -f "${payload_root}/bin/cosmo-engine" ] || fail "package is missing bin/cosmo-engine" +[ -f "${payload_root}/scripts/start.sh" ] || [ -n "${COSMO_MIGRATION_TEST_ROOT:-}" ] || + fail "package is missing scripts/start.sh" + +mkdir -p -- "$active_parent" +[ ! -e "$staging_root" ] && [ ! -L "$staging_root" ] || fail "staging path already exists" +mkdir -- "$staging_root" +trap 'rm -rf -- "$staging_root"' EXIT +cp -a -- "${payload_root}/." "$staging_root/" + +# A model-less migration means preserve, not delete. A package that contains +# resource/models remains authoritative and replaces the installed models. +if [ ! -d "${staging_root}/resource/models" ] && [ -d "${active_root}/resource/models" ]; then + mkdir -p -- "${staging_root}/resource" + cp -a -- "${active_root}/resource/models" "${staging_root}/resource/models" +fi + +[ ! -e "$backup_root" ] && [ ! -L "$backup_root" ] || fail "stale migration backup exists" +if [ -e "$active_root" ] || [ -L "$active_root" ]; then + mv -- "$active_root" "$backup_root" +fi +if ! mv -- "$staging_root" "$active_root"; then + [ ! -e "$backup_root" ] || mv -- "$backup_root" "$active_root" + fail "cannot activate migrated application" +fi +trap - EXIT +rm -rf -- "$backup_root" +sync +printf '[MIGRATION] installed legacy-compatible bridge at %s\n' "$active_root" >>"$log_file" diff --git a/scripts/package_md5_rename.sh b/scripts/package_md5_rename.sh index 62cd8167f..bcd6fa5c4 100755 --- a/scripts/package_md5_rename.sh +++ b/scripts/package_md5_rename.sh @@ -1,21 +1,185 @@ #!/bin/bash -# 打包后计算 MD5 并嵌入文件名 -# 用法: package_md5_rename.sh -set -e +# Label the ordinary CPack output according to its user-facing package variant. +# +# public-runtime is intentionally retained as the internal build profile for +# compatibility with existing automation. Its user-facing artifact is SOURCE: +# an installable source-build package that is not a signed production release. +# The controlled production CPack output may seed a blank device, but it is not +# an updater archive. A filename checksum is not authentication and must never +# be accepted by the updater. +set -euo pipefail -PACKAGES_DIR="$1" -PACKAGE_NAME="$2" -ORIG="${PACKAGES_DIR}/${PACKAGE_NAME}.tar.gz" +if [ "$#" -lt 4 ] || [ "$#" -gt 5 ]; then + echo "Usage: $0 [legacy-migration]" >&2 + exit 2 +fi + +packages_dir="$1" +package_name="$2" +build_profile="$3" +build_epoch="$4" +legacy_migration="${5:-OFF}" +original="${packages_dir}/${package_name}.tar.gz" + +if [ ! -f "$original" ] || [ -L "$original" ]; then + echo "CPack artifact not found or has an unsafe type: $original" >&2 + exit 1 +fi + +case "$build_epoch" in + "" | *[!0-9]*) + echo "Build epoch must be a non-negative integer" >&2 + exit 2 + ;; + *) ;; +esac + +# CPack preserves build-time metadata in TGZ output. Repack its locally +# generated tree with stable metadata before naming and hashing the artifact. +if ! tar -tzf "$original" | + awk -v root="$package_name" ' + BEGIN { count = 0 } + { + count++ + if (substr($0, 1, 1) == "/" || + $0 ~ /(^|\/)\.\.(\/|$)/ || + ($0 != root && index($0, root "/") != 1)) { + exit 1 + } + } + END { if (count == 0) exit 1 } + '; then + echo "CPack artifact has an unexpected archive layout" >&2 + exit 1 +fi + +normalization_root="$( + mktemp -d "${packages_dir}/.cosmo-package-normalize.XXXXXX" +)" +cleanup() { + rm -rf -- "$normalization_root" +} +trap cleanup EXIT -if [ ! -f "$ORIG" ]; then - echo "Error: $ORIG not found" +extract_root="${normalization_root}/extract" +normalized="${normalization_root}/${package_name}.tar.gz" +mkdir -p -- "$extract_root" +tar -xzf "$original" -C "$extract_root" +if [ ! -d "${extract_root}/${package_name}" ] || + [ -L "${extract_root}/${package_name}" ]; then + echo "CPack artifact does not contain the expected package root" >&2 exit 1 fi -# 清理旧包,只保留当前构建的 -find "$PACKAGES_DIR" -name "*.tar.gz" ! -name "$(basename "$ORIG")" -delete 2>/dev/null || true +tar --sort=name \ + --format=gnu \ + --mtime="@${build_epoch}" \ + --owner=0 \ + --group=0 \ + --numeric-owner \ + -C "$extract_root" \ + -cf - \ + -- "$package_name" | + gzip -n >"$normalized" +mv -- "$normalized" "$original" -MD5=$(md5sum "$ORIG" | cut -d' ' -f1) -NEW="${PACKAGES_DIR}/${PACKAGE_NAME}-${MD5}.tar.gz" -mv "$ORIG" "$NEW" -echo "Package: $(basename "$NEW")" +digest="$(sha256sum -- "$original")" +digest="${digest%% *}" +if [ "${#digest}" -ne 64 ]; then + echo "Cannot calculate CPack artifact SHA-256" >&2 + exit 1 +fi +case "$digest" in + *[!0-9a-f]*) + echo "Cannot calculate CPack artifact SHA-256" >&2 + exit 1 + ;; + *) ;; +esac + +if [ "$legacy_migration" = "ON" ]; then + md5_digest="$(md5sum -- "$original")" + md5_digest="${md5_digest%% *}" + labeled="${packages_dir}/${package_name}-${md5_digest}.tar.gz" + mv -- "$original" "$labeled" + echo "Legacy migration package: $(basename "$labeled")" + echo "Archive SHA-256: ${digest}" + exit 0 +fi + +case "$build_profile" in + public-runtime) + label="SOURCE" + description="SOURCE package" + guidance="Install with the SOURCE workflow; protected preset models require one separately provisioned device-bound certificate." + identity_members="$( + tar -tzf "$original" | + awk '/\/share\/cosmo-source\/build-identity\.env$/ { print }' + )" + identity_member_count="$( + printf '%s\n' "$identity_members" | sed '/^$/d' | wc -l + )" + if [ "$identity_member_count" -ne 1 ]; then + echo "SOURCE package must contain exactly one build identity" >&2 + exit 1 + fi + identity_record="$(tar -xOzf "$original" -- "$identity_members")" + edge_commit="$( + printf '%s\n' "$identity_record" | + sed -n 's/^edge_commit=//p' + )" + build_identity="$( + printf '%s\n' "$identity_record" | + sed -n 's/^build_identity=//p' + )" + if [ "${#edge_commit}" -ne 40 ] || + [ "${#build_identity}" -ne 64 ]; then + echo "SOURCE package build identity is malformed" >&2 + exit 1 + fi + case "${edge_commit}${build_identity}" in + *[!0-9a-f]*) + echo "SOURCE package build identity is malformed" >&2 + exit 1 + ;; + *) ;; + esac + identity_label="-${edge_commit}-${build_identity}" + ;; + production-release) + label="FACTORY-BASE" + description="Controlled factory base" + guidance="Use only for a hash-verified blank-device install; the updater still requires a signed release." + identity_label="" + ;; + *) + echo "Unsupported build profile: $build_profile" >&2 + exit 2 + ;; +esac + +labeled="${packages_dir}/${package_name}-${label}${identity_label}-${digest}.tar.gz" +if [ -e "$labeled" ] || [ -L "$labeled" ]; then + if [ -f "$labeled" ] && [ ! -L "$labeled" ] && cmp -s -- "$original" "$labeled"; then + rm -f -- "$original" + echo "${description} unchanged: $(basename "$labeled")" + if [ "$build_profile" = "public-runtime" ]; then + echo "Edge commit: ${edge_commit}" + echo "Build identity: ${build_identity}" + fi + echo "Archive SHA-256: ${digest}" + echo "$guidance" + exit 0 + fi + echo "Conflicting package output already exists: $labeled" >&2 + exit 1 +fi +mv -- "$original" "$labeled" + +echo "${description}: $(basename "$labeled")" +if [ "$build_profile" = "public-runtime" ]; then + echo "Edge commit: ${edge_commit}" + echo "Build identity: ${build_identity}" +fi +echo "Archive SHA-256: ${digest}" +echo "$guidance" diff --git a/scripts/release_bootstrap_backend.py b/scripts/release_bootstrap_backend.py new file mode 100644 index 000000000..672004594 --- /dev/null +++ b/scripts/release_bootstrap_backend.py @@ -0,0 +1,391 @@ +#!/usr/bin/python3 +"""Private backend for the stable embedded-key factory bootstrap. + +The script is loaded from an already-open descriptor by the C++ verifier. It +has exactly two internal modes and is not a public updater CLI. +""" + +from __future__ import annotations + +import errno +import gzip +import importlib.machinery +import importlib.util +import os +import socket +import stat +import struct +import sys +from pathlib import Path +from typing import NoReturn + + +sys.dont_write_bytecode = True +INSTALL_ROOT = Path("/appfs/cosmo_wander/cwai_data") +PERSISTENT_ROOT = Path("/data/cwaiuserdata/model-guard") +STABLE_ROOT = INSTALL_ROOT / ".release-bootstrap" +ARCHIVE_FD = 3 +CHANNEL_FD = 4 +BACKEND_SOURCE_FD = 5 +UPDATER_SOURCE_FD = 6 +BACKEND_SOURCE_PATH = f"/proc/self/fd/{BACKEND_SOURCE_FD}" +UPDATER_SOURCE_PATH = f"/proc/self/fd/{UPDATER_SOURCE_FD}" +INSTALL_MODE = "--install" +RECOVER_MODE = "--recover" +REQUEST_MAGIC = 0x43425231 # CBR1 +APPROVAL_MAGIC = 0x43424131 # CBA1 +MAXIMUM_MANIFEST = 128 * 1024 +MAXIMUM_PEM = 16 * 1024 +MAXIMUM_SCRIPT = 8 * 1024 * 1024 +MAXIMUM_CHANNEL_ALLOCATION = MAXIMUM_MANIFEST + 1024 +MAXIMUM_ARCHIVE = 128 * 1024 * 1024 * 1024 +CANONICAL_GZIP_HEADER = b"\x1f\x8b\x08\x00\x00\x00\x00\x00\x02\xff" + + +class BackendError(RuntimeError): + pass + + +def _fail(message: str) -> NoReturn: + raise BackendError(message) + + +def _regular_info( + info: os.stat_result, + *, + executable: bool, + maximum_size: int, +) -> bool: + return ( + stat.S_ISREG(info.st_mode) + and info.st_size > 0 + and info.st_size <= maximum_size + and (not executable or bool(info.st_mode & 0o111)) + ) + + +def _validate_descriptor( + descriptor: int, + *, + executable: bool, + maximum_size: int, +) -> None: + descriptor_info = os.fstat(descriptor) + if not _regular_info( + descriptor_info, + executable=executable, + maximum_size=maximum_size, + ): + _fail("bootstrap component descriptor rejected") + + +def _read_exact(channel: socket.socket, size: int) -> bytes: + if not isinstance(size, int) or size < 0 or size > MAXIMUM_CHANNEL_ALLOCATION: + _fail("embedded verifier requested an oversized channel allocation") + output = bytearray(size) + view = memoryview(output) + offset = 0 + while offset < size: + count = channel.recv_into(view[offset:], size - offset) + if count <= 0: + _fail("embedded verifier closed its channel") + offset += count + return bytes(output) + + +def _send_all(channel: socket.socket, data: bytes) -> None: + if len(data) > MAXIMUM_CHANNEL_ALLOCATION: + _fail("bootstrap metadata exceeds its verifier channel limit") + view = memoryview(data) + while view: + count = channel.send(view) + if count <= 0: + _fail("cannot send metadata to the embedded verifier") + view = view[count:] + + +def _authenticate_parent(channel: socket.socket, operation: str) -> None: + expected_arguments = [BACKEND_SOURCE_PATH, operation] + if ( + os.getuid() != 0 + or os.geteuid() != 0 + or os.getgid() != 0 + or os.getegid() != 0 + or sys.argv != expected_arguments + ): + _fail("bootstrap backend invocation rejected") + if channel.getsockopt(socket.SOL_SOCKET, socket.SO_TYPE) != socket.SOCK_STREAM: + _fail("bootstrap backend channel type rejected") + + parent = os.getppid() + if parent <= 1: + _fail("bootstrap backend parent is unavailable") + credential_size = struct.calcsize("3i") + credentials = channel.getsockopt(socket.SOL_SOCKET, socket.SO_PEERCRED, credential_size) + peer_pid, peer_uid, peer_gid = struct.unpack("3i", credentials) + if peer_pid != parent or peer_uid != 0 or peer_gid != 0: + _fail("bootstrap backend peer credentials rejected") + + _validate_descriptor( + BACKEND_SOURCE_FD, + executable=False, + maximum_size=MAXIMUM_SCRIPT, + ) + _validate_descriptor( + UPDATER_SOURCE_FD, + executable=False, + maximum_size=MAXIMUM_SCRIPT, + ) + + +def _require_recovery_descriptor_layout() -> None: + try: + os.fstat(ARCHIVE_FD) + except OSError as error: + if error.errno == errno.EBADF: + return + raise + _fail("bootstrap recovery inherited an unexpected archive descriptor") + + +def _read_stream_exact(stream, size: int) -> bytes: + if size < 0 or size > MAXIMUM_CHANNEL_ALLOCATION: + _fail("bootstrap archive requested an oversized parser allocation") + output = bytearray(size) + view = memoryview(output) + offset = 0 + while offset < size: + count = stream.readinto(view[offset:]) + if count is None or count <= 0: + _fail("bootstrap archive ended before signed metadata") + offset += count + return bytes(output) + + +def _tar_string(field: bytes, description: str) -> str: + terminator = field.find(b"\0") + if terminator < 0 or any(field[terminator + 1 :]): + _fail(f"bootstrap archive {description} is not canonically terminated") + try: + return field[:terminator].decode("ascii", "strict") + except UnicodeError as error: + raise BackendError(f"bootstrap archive {description} is not ASCII") from error + + +def _tar_octal(field: bytes, description: str, maximum: int) -> int: + value = field.rstrip(b"\0 ").lstrip(b" ") + if not value or any(byte < ord("0") or byte > ord("7") for byte in value): + _fail(f"bootstrap archive {description} is not canonical octal") + parsed = int(value, 8) + if parsed > maximum: + _fail(f"bootstrap archive {description} exceeds its limit") + return parsed + + +def _read_canonical_tar_header(stream) -> tuple[str, bytes, int, int]: + header = _read_stream_exact(stream, 512) + if header == bytes(512): + _fail("bootstrap archive ended before signed metadata") + stored_checksum = _tar_octal(header[148:156], "header checksum", 255 * 512) + checksum_header = header[:148] + b" " * 8 + header[156:] + if sum(checksum_header) != stored_checksum: + _fail("bootstrap archive header checksum rejected") + if header[257:263] != b"ustar\0" or header[263:265] != b"00" or any(header[345:500]): + _fail("bootstrap archive signed metadata must use canonical ustar headers") + canonical_owner = b"root" + bytes(28) + if ( + any(header[157:257]) + or header[265:329] != canonical_owner + canonical_owner + or any(header[329:345]) + or any(header[500:512]) + ): + _fail("bootstrap archive signed metadata header contains unexpected fields") + name = _tar_string(header[0:100], "member name") + mode = _tar_octal(header[100:108], "member mode", 0o7777) + if _tar_octal(header[108:116], "member uid", 0) != 0: + _fail("bootstrap archive signed metadata uid rejected") + if _tar_octal(header[116:124], "member gid", 0) != 0: + _fail("bootstrap archive signed metadata gid rejected") + size = _tar_octal(header[124:136], "member size", MAXIMUM_MANIFEST) + if _tar_octal(header[136:148], "member mtime", 0) != 0: + _fail("bootstrap archive signed metadata mtime rejected") + return name, header[156:157], size, mode + + +def _read_canonical_tar_member(stream, expected_name: str, maximum: int) -> bytes: + name, object_type, size, mode = _read_canonical_tar_header(stream) + if name != expected_name or object_type != b"0" or mode != 0o600 or size <= 0 or size > maximum: + _fail("bootstrap archive signed metadata order or type rejected") + value = _read_stream_exact(stream, size) + padding_size = (-size) % 512 + if padding_size and any(_read_stream_exact(stream, padding_size)): + _fail("bootstrap archive signed metadata padding rejected") + return value + + +def _read_signed_metadata(archive_fd: int) -> tuple[bytes, bytes]: + info = os.fstat(archive_fd) + if ( + not stat.S_ISREG(info.st_mode) + or info.st_size <= 0 + or info.st_size > MAXIMUM_ARCHIVE + ): + _fail("bootstrap archive FD type or size rejected") + os.lseek(archive_fd, 0, os.SEEK_SET) + try: + with os.fdopen(os.dup(archive_fd), "rb") as raw: + # The offline builder fixes filename="", mtime=0, level=9 and no + # optional gzip fields. Requiring that header before handing the + # stream to gzip prevents attacker-sized FNAME/FCOMMENT parsing. + if _read_stream_exact(raw, len(CANONICAL_GZIP_HEADER)) != CANONICAL_GZIP_HEADER: + _fail("bootstrap archive gzip header is not canonical") + raw.seek(0) + with gzip.GzipFile(fileobj=raw, mode="rb") as bundle: + name, object_type, size, mode = _read_canonical_tar_header(bundle) + if name != "meta/" or object_type != b"5" or size != 0 or mode != 0o755: + _fail("bootstrap archive canonical metadata directory is missing") + manifest = _read_canonical_tar_member( + bundle, + "meta/compatibility.manifest.json", + MAXIMUM_MANIFEST, + ) + signature = _read_canonical_tar_member( + bundle, + "meta/compatibility.manifest.sig", + 64, + ) + except (EOFError, gzip.BadGzipFile, OSError) as error: + raise BackendError("bootstrap archive metadata parsing failed") from error + if len(signature) != 64: + _fail("bootstrap archive signed metadata is incomplete") + os.lseek(archive_fd, 0, os.SEEK_SET) + return manifest, signature + + +def _load_updater(): + _validate_descriptor( + UPDATER_SOURCE_FD, + executable=False, + maximum_size=MAXIMUM_SCRIPT, + ) + loader = importlib.machinery.SourceFileLoader( + "cosmo_release_updater", + UPDATER_SOURCE_PATH, + ) + specification = importlib.util.spec_from_loader(loader.name, loader) + if specification is None: + _fail("cannot load the trusted release transaction implementation") + module = importlib.util.module_from_spec(specification) + sys.modules[loader.name] = module + loader.exec_module(module) + return module + + +def _validate_committed_release(installed: object) -> Path: + if not isinstance(installed, Path): + _fail("bootstrap transaction returned a non-path result") + expected_parent = INSTALL_ROOT / ".releases" + if not installed.is_absolute() or installed.parent != expected_parent or not installed.name: + _fail("bootstrap transaction returned an unexpected release path") + return installed + + +def _new_updater(release_module): + paths = release_module.ReleasePaths( + install_root=INSTALL_ROOT, + model_guard_state_root=PERSISTENT_ROOT, + ) + return release_module.ReleaseUpdater(paths) + + +def _install(channel: socket.socket) -> int: + manifest, signature = _read_signed_metadata(ARCHIVE_FD) + request = struct.pack("!III", REQUEST_MAGIC, len(manifest), len(signature)) + _send_all(channel, request + manifest + signature) + + response = _read_exact(channel, 8) + magic, pem_size = struct.unpack("!II", response) + if magic != APPROVAL_MAGIC or pem_size <= 0 or pem_size > MAXIMUM_PEM: + _fail("embedded verifier did not approve the release manifest") + raw_key = _read_exact(channel, 32) + key_id = _read_exact(channel, 16) + pem_sha256 = _read_exact(channel, 32) + public_key = _read_exact(channel, pem_size) + if channel.recv(1): + _fail("embedded verifier protocol has trailing data") + channel.close() + + release = _load_updater() + updater = _new_updater(release) + try: + installed = updater.bootstrap_from_embedded_verifier( + ARCHIVE_FD, + manifest, + signature, + public_key, + raw_key, + key_id, + pem_sha256, + ) + except BaseException: + recovered = None + try: + recovered = updater.recover_failed_bootstrap() + except BaseException: + pass + if recovered is None: + raise + installed = recovered + print(_validate_committed_release(installed)) + return 0 + + +def _recover(channel: socket.socket) -> int: + _require_recovery_descriptor_layout() + channel.close() + release = _load_updater() + updater = _new_updater(release) + had_pending_bootstrap = updater.paths.bootstrap_journal_file.exists() + recovered = updater.recover_failed_bootstrap() + print(_recovery_result(had_pending_bootstrap, recovered)) + return 0 + + +def _recovery_result(had_pending_bootstrap: bool, recovered: Path | None) -> str: + """Render the two successful, explicit bootstrap recovery outcomes.""" + if recovered is not None: + return str(_validate_committed_release(recovered)) + if had_pending_bootstrap: + return "legacy-restored" + _fail("factory bootstrap recovery did not find a pending transaction") + + +def main() -> int: + if sys.argv == [BACKEND_SOURCE_PATH, INSTALL_MODE]: + operation = INSTALL_MODE + elif sys.argv == [BACKEND_SOURCE_PATH, RECOVER_MODE]: + operation = RECOVER_MODE + else: + _fail("bootstrap backend accepts only exact --install or --recover invocation") + + channel = socket.socket(fileno=CHANNEL_FD) + _authenticate_parent(channel, operation) + if operation == INSTALL_MODE: + return _install(channel) + return _recover(channel) + + +if __name__ == "__main__": + try: + if sys.flags.isolated != 1: + _fail( + "release bootstrap backend must be launched with " + "/usr/bin/python3 -I -B" + ) + raise SystemExit(main()) + except (BackendError, OSError) as error: + print(f"release bootstrap rejected operation: {error}", file=sys.stderr) + raise SystemExit(1) + except Exception: + print("release bootstrap rejected operation: trusted transaction failed", file=sys.stderr) + raise SystemExit(1) diff --git a/scripts/release_health_check.sh b/scripts/release_health_check.sh new file mode 100755 index 000000000..2cee5ffd3 --- /dev/null +++ b/scripts/release_health_check.sh @@ -0,0 +1,181 @@ +#!/bin/bash +set -eu +IFS=$' \t\n' +PATH='/usr/sbin:/usr/bin:/sbin:/bin' +export IFS PATH + +# Validate the exact activated engine/guard pair before the updater commits its +# durable release generation. This script intentionally has no environment or +# command-line override for the install root, timeout, port, or guard SONAME. + +if [ "$#" -ne 2 ]; then + echo "Usage: $0 " >&2 + exit 2 +fi + +runner_pid="$1" +expected_release="$2" +install_root="/appfs/cosmo_wander/cwai_data" +expected_prefix="${install_root}/.releases/" +guard_soname="libcosmo_model_guard.so.2" +timeout_seconds=60 +http_port=8000 +http_port_hex="$(printf '%04X' "$http_port")" +required_stable_samples=3 + +engine_owns_http_listener() { + checked_pid="$1" + for descriptor in "/proc/${checked_pid}/fd/"*; do + [ -L "$descriptor" ] || continue + descriptor_target="$(readlink -- "$descriptor" 2>/dev/null || true)" + case "$descriptor_target" in + socket:\[*\]) + socket_inode="${descriptor_target#socket:[}" + socket_inode="${socket_inode%]}" + ;; + *) + continue + ;; + esac + for socket_table in /proc/net/tcp /proc/net/tcp6; do + [ -r "$socket_table" ] || continue + if awk -v inode="$socket_inode" -v port="$http_port_hex" ' + NR > 1 && toupper($2) ~ (":" port "$") && $4 == "0A" && $10 == inode { + found = 1 + } + END { exit(found ? 0 : 1) } + ' "$socket_table"; then + return 0 + fi + done + done + return 1 +} + +engine_http_responds() { + /usr/bin/python3 -I -B -c ' +import socket + +request = b"HEAD / HTTP/1.0\r\nHost: localhost\r\nConnection: close\r\n\r\n" +addresses = ( + (socket.AF_INET, ("127.0.0.1", 8000)), + (socket.AF_INET6, ("::1", 8000, 0, 0)), +) +for family, address in addresses: + try: + with socket.socket(family, socket.SOCK_STREAM) as connection: + connection.settimeout(1.0) + connection.connect(address) + connection.sendall(request) + response = connection.recv(64) + if response.startswith(b"HTTP/"): + raise SystemExit(0) + except OSError: + pass +raise SystemExit(1) +' /dev/null 2>&1 +} + +runner_is_live() { + checked_pid="$1" + kill -0 "$checked_pid" 2>/dev/null || return 1 + [ -r "/proc/${checked_pid}/stat" ] || return 1 + process_state="$(awk '{ print $3 }' "/proc/${checked_pid}/stat" 2>/dev/null || true)" + case "$process_state" in + ''|Z|X) return 1 ;; + *) return 0 ;; + esac +} + +case "$runner_pid" in + ''|*[!0-9]*) + echo "Release health check received an invalid runner PID" >&2 + exit 1 + ;; +esac + +case "$expected_release" in + "${expected_prefix}"[a-z0-9]*) ;; + *) + echo "Release health check rejected the candidate path" >&2 + exit 1 + ;; +esac + +case "${expected_release#"${expected_prefix}"}" in + *[!a-z0-9._-]*|'') + echo "Release health check rejected the candidate ID" >&2 + exit 1 + ;; +esac + +if [ -L "$expected_release" ] || [ ! -d "$expected_release" ]; then + echo "Release health check rejected the candidate directory" >&2 + exit 1 +fi + +expected_engine="$(readlink -f -- "${expected_release}/bin/cosmo-engine")" +expected_guard="$(readlink -f -- "${expected_release}/lib/${guard_soname}")" +if [ -z "$expected_engine" ] || [ -z "$expected_guard" ]; then + echo "Release health check cannot resolve the signed compatibility pair" >&2 + exit 1 +fi + +deadline=$((SECONDS + timeout_seconds)) +stable_samples=0 +stable_engine_pid="" +while [ "$SECONDS" -lt "$deadline" ]; do + if ! runner_is_live "$runner_pid"; then + echo "Candidate startup process exited before health acceptance" >&2 + exit 1 + fi + + engine_pid="" + for candidate_pid in $(pidof cosmo-engine 2>/dev/null || true); do + candidate_exe="$(readlink -f -- "/proc/${candidate_pid}/exe" 2>/dev/null || true)" + if [ "$candidate_exe" = "$expected_engine" ]; then + if [ -n "$engine_pid" ]; then + echo "Multiple candidate engine processes are running" >&2 + exit 1 + fi + engine_pid="$candidate_pid" + fi + done + + if [ -n "$engine_pid" ] && [ -r "/proc/${engine_pid}/maps" ]; then + mapped_guard=0 + while IFS= read -r map_line; do + case "$map_line" in + *" ${expected_guard}") + mapped_guard=1 + break + ;; + esac + done < "/proc/${engine_pid}/maps" + + if [ "$mapped_guard" -eq 1 ] && \ + engine_owns_http_listener "$engine_pid" && \ + engine_http_responds; then + if [ "$stable_engine_pid" = "$engine_pid" ]; then + stable_samples=$((stable_samples + 1)) + else + stable_engine_pid="$engine_pid" + stable_samples=1 + fi + if [ "$stable_samples" -ge "$required_stable_samples" ]; then + exit 0 + fi + else + stable_engine_pid="" + stable_samples=0 + fi + else + stable_engine_pid="" + stable_samples=0 + fi + + sleep 1 +done + +echo "Candidate did not sustain engine/guard/HTTP health for ${required_stable_samples} consecutive samples within ${timeout_seconds}s" >&2 +exit 1 diff --git a/scripts/release_updater.py b/scripts/release_updater.py new file mode 100755 index 000000000..59ff2f98d --- /dev/null +++ b/scripts/release_updater.py @@ -0,0 +1,3357 @@ +#!/usr/bin/python3 +"""Signed, journaled Cosmo release updater. + +The production command line has fixed roots and fixed tool paths. Test code may +import this file and construct :class:`ReleaseUpdater` with isolated paths, but +there is deliberately no environment-variable or command-line trust override. +""" + +from __future__ import annotations + +import argparse +import contextlib +import ctypes +import dataclasses +import errno +import fcntl +import hashlib +import json +import os +import re +import signal +import stat +import struct +import subprocess +import sys +import tarfile +import tempfile +import time +import unicodedata +import uuid +from pathlib import Path +from typing import Any, BinaryIO, Callable, Iterable, Iterator, Mapping, Sequence + + +FORMAT = "cosmo-release-compatibility-v3" +PAYLOAD_FORMAT = "cosmo-release-payload-v1" +STATE_FORMAT = "cosmo-release-state-v2" +JOURNAL_FORMAT = "cosmo-release-transaction-v2" +BOOTSTRAP_JOURNAL_FORMAT = "cosmo-release-bootstrap-transaction-v1" +PUBLICATION_FORMAT = "cosmo-release-publication-v1" +MODEL_IDENTITY_DOMAIN = b"cosmo-model-identity-v1" +CEM_V2_SOURCE_FORMATS = { + 1: "cosmo-nn-v1", + 2: "raw-bmodel", +} +CEM_V2_TARGET_PLATFORM = "sophon-bm1688-aarch64" +GUARD_SONAME = "libcosmo_model_guard.so.2" +GUARD_REAL_FILENAME = "libcosmo_model_guard.so.2.0.0" +GUARD_HEADER_PATH = "share/cosmo-model-guard/cosmo_model_guard_v2.h" +RELEASE_BOOTSTRAP_PATH = "bin/cosmo-release-bootstrap" +MODEL_PROVISION_PATH = "bin/cosmo-model-provision" +REQUIRED_GUARD_EXPORTS = ( + "CmgV2CloseArtifact", + "CmgV2GetArtifactInfo", + "CmgV2LoadSophonSegment", + "CmgV2OpenArtifact", +) +FACADE_DIRECTORIES = ("bin", "files", "font", "lib", "resource", "scripts", "web") +REQUIRED_RELEASE_SCRIPTS = ( + "scripts/common.sh", + "scripts/install.sh", + "scripts/inte_run_start.sh", + "scripts/release_bootstrap_backend.py", + "scripts/release_health_check.sh", + "scripts/release_updater.py", + "scripts/release_updater.sh", + "scripts/run_start.sh", + "scripts/start.sh", + "scripts/stop.sh", +) +SIGNED_CANDIDATE_SCRIPT_NAMES = frozenset( + ("run_start.sh", "release_health_check.sh", "stop.sh") +) +MAX_ARCHIVE_ENTRIES = 100_000 +MAX_ARCHIVE_BYTES = 128 * 1024 * 1024 * 1024 +MAX_MANIFEST_BYTES = 128 * 1024 +MAX_PAYLOAD_MANIFEST_BYTES = 32 * 1024 * 1024 +MAX_HEALTH_SCRIPT_BYTES = 128 * 1024 +MAX_GUARD_HEADER_BYTES = 128 * 1024 +CEM_V2_CORE_PREAMBLE_SIZE = 112 +CEM_V2_MAX_CORE_BYTES = 16 * 1024 * 1024 * 1024 +CEM_V2_MAX_MANIFEST_BYTES = 1024 * 1024 +CEM_V2_MAX_SEGMENTS = 8 +CEM_V2_MAX_CHUNKS = 65536 +CEM_V2_MIN_NOMINAL_CHUNK_BYTES = 1024 * 1024 +CEM_V2_MAX_CHUNK_PLAIN_BYTES = 16 * 1024 * 1024 +CEM_V2_MAX_SEGMENT_PLAIN_BYTES = 1 << 31 +CEM_V2_GCM_TAG_BYTES = 16 +MAX_PATH_BYTES = 4096 +MAX_COMPONENT_BYTES = 255 +LEGACY_RESTART_TIMEOUT_SECONDS = 60 +HEALTH_RUNNER_STOP_TIMEOUT_SECONDS = 10 +CANDIDATE_HEALTH_TIMEOUT_SECONDS = 70 +MANAGED_PROCESS_NAMES = frozenset(("cosmo-engine", "srs", "nginx")) +PUBLICATION_MARKER_PATH = "meta/release-transaction.json" +RENAME_NOREPLACE = 1 +RENAME_EXCHANGE = 2 +AT_FDCWD = -100 + + +def _is_preset_model_payload_path(path: str) -> bool: + """Return whether *path* is a packaged preset ``model.nn`` resource.""" + return path.startswith("resource/models/") and path.endswith("/model.nn") + + +def _is_device_authorization_state(path: str) -> bool: + return Path(path.lower()).name == "device-certificate.bin" + + +class ReleaseError(RuntimeError): + """A fail-closed release validation or transaction error.""" + + +class InjectedInterruption(RuntimeError): + """Test-only simulated power loss.""" + + +@dataclasses.dataclass(frozen=True) +class ReleasePaths: + install_root: Path + model_guard_state_root: Path + openssl: Path = Path("/usr/bin/openssl") + + @property + def releases(self) -> Path: + return self.install_root / ".releases" + + @property + def current(self) -> Path: + return self.install_root / "current" + + @property + def state_dir(self) -> Path: + return self.install_root / ".release-state" + + @property + def state_file(self) -> Path: + return self.state_dir / "compatibility.state.json" + + @property + def journal_file(self) -> Path: + return self.state_dir / "transaction.json" + + @property + def transactions(self) -> Path: + return self.state_dir / "transactions" + + @property + def lock_file(self) -> Path: + return self.state_dir / "update.lock" + + @property + def bootstrap_journal_file(self) -> Path: + return self.state_dir / "bootstrap-transaction.json" + + @property + def legacy_backup(self) -> Path: + return self.state_dir / "legacy-layout" + + @property + def stable_health_script(self) -> Path: + return ( + self.install_root + / ".release-bootstrap" + / "scripts" + / "release_health_check.sh" + ) + + +PRODUCTION_PATHS = ReleasePaths( + install_root=Path("/appfs/cosmo_wander/cwai_data"), + model_guard_state_root=Path("/data/cwaiuserdata/model-guard"), +) + + +def _fail(message: str) -> "NoReturn": + raise ReleaseError(message) + + +def _sha256_bytes(data: bytes) -> str: + return hashlib.sha256(data).hexdigest() + + +def _sha256_file(path: Path) -> str: + digest = hashlib.sha256() + fd = os.open(path, os.O_RDONLY | os.O_CLOEXEC) + try: + with os.fdopen(fd, "rb", closefd=False) as stream: + while True: + block = stream.read(1024 * 1024) + if not block: + break + digest.update(block) + finally: + os.close(fd) + return digest.hexdigest() + + +def _canonical_json(value: Any) -> bytes: + return ( + json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":")) + "\n" + ).encode("utf-8") + + +def _canonical_ascii_json(value: Any) -> bytes: + return ( + json.dumps(value, ensure_ascii=True, sort_keys=True, separators=(",", ":")) + "\n" + ).encode("ascii") + + +def _publication_marker_bytes( + transaction_id: str, + release_id: str, + manifest_sha256: str, + archive_sha256: str, +) -> bytes: + return _canonical_json( + { + "archive_sha256": archive_sha256, + "format": PUBLICATION_FORMAT, + "manifest_sha256": manifest_sha256, + "release_id": release_id, + "transaction_id": transaction_id, + } + ) + + +def _strict_json(data: bytes, maximum: int, description: str, *, ascii_canonical: bool = False) -> Any: + if not data or len(data) > maximum: + _fail(f"{description} has an invalid size") + + def object_pairs(pairs: Sequence[tuple[str, Any]]) -> dict[str, Any]: + result: dict[str, Any] = {} + for key, value in pairs: + if key in result: + _fail(f"{description} contains duplicate JSON key {key!r}") + result[key] = value + return result + + try: + value = json.loads( + data.decode("utf-8", "strict"), + object_pairs_hook=object_pairs, + parse_constant=lambda token: _fail(f"{description} contains {token}"), + ) + except (UnicodeError, json.JSONDecodeError) as error: + raise ReleaseError(f"{description} is not canonical JSON") from error + canonical = _canonical_ascii_json(value) if ascii_canonical else _canonical_json(value) + if canonical != data: + _fail(f"{description} is not in canonical encoding") + return value + + +def _strict_pretty_ascii_json(data: bytes, maximum: int, description: str) -> Any: + """Parse the frozen, human-reviewed ABI JSON without parser ambiguity.""" + if not data or len(data) > maximum: + _fail(f"{description} has an invalid size") + + def object_pairs(pairs: Sequence[tuple[str, Any]]) -> dict[str, Any]: + result: dict[str, Any] = {} + for key, value in pairs: + if key in result: + _fail(f"{description} contains duplicate JSON key {key!r}") + result[key] = value + return result + + try: + value = json.loads( + data.decode("ascii", "strict"), + object_pairs_hook=object_pairs, + parse_constant=lambda token: _fail(f"{description} contains {token}"), + ) + except (UnicodeError, json.JSONDecodeError) as error: + raise ReleaseError(f"{description} is not canonical JSON") from error + canonical = (json.dumps(value, ensure_ascii=True, indent=2) + "\n").encode("ascii") + if canonical != data: + _fail(f"{description} is not in canonical encoding") + return value + + +def _require_object(value: Any, keys: set[str], description: str) -> Mapping[str, Any]: + if not isinstance(value, dict) or set(value) != keys: + _fail(f"{description} has an invalid schema") + return value + + +def _require_string(value: Any, description: str, pattern: str | None = None) -> str: + if not isinstance(value, str): + _fail(f"{description} must be a string") + if pattern is not None and re.fullmatch(pattern, value) is None: + _fail(f"{description} has an invalid value") + return value + + +def _require_uint(value: Any, description: str, minimum: int = 0, maximum: int = (1 << 63) - 1) -> int: + if isinstance(value, bool) or not isinstance(value, int) or value < minimum or value > maximum: + _fail(f"{description} has an invalid integer value") + return value + + +def _require_hex(value: Any, length: int, description: str, nonzero: bool = False) -> str: + text = _require_string(value, description, rf"[0-9a-f]{{{length}}}") + if nonzero and set(text) == {"0"}: + _fail(f"{description} must be nonzero") + return text + + +def _validate_model_guard_header(header: bytes) -> None: + if not header or len(header) > MAX_GUARD_HEADER_BYTES or b"\x00" in header: + _fail("Model Guard v2 header size/content rejected") + try: + text = header.decode("utf-8", "strict") + except UnicodeError as error: + raise ReleaseError("Model Guard v2 header is not UTF-8") from error + required_lines = { + "#define CMG_V2_ABI_MAJOR UINT32_C(2)", + "#define CMG_V2_ARTIFACT_INFO_SIZE UINT32_C(72)", + "#define CMG_V2_SOPHON_LOAD_OPTIONS_SIZE UINT32_C(16)", + } + if not required_lines.issubset(set(text.splitlines())): + _fail("Model Guard v2 header constants do not match the frozen ABI") + for function in REQUIRED_GUARD_EXPORTS: + if len(re.findall(rf"\b{re.escape(function)}\s*\(", text)) != 1: + _fail(f"Model Guard v2 header declaration rejected: {function}") + + +def _canonical_relative_path(value: Any, description: str = "path") -> str: + path = _require_string(value, description) + if not path or path.startswith("/") or "\\" in path or unicodedata.normalize("NFC", path) != path: + _fail(f"{description} is not canonical") + try: + encoded = path.encode("utf-8", "strict") + except UnicodeError as error: + raise ReleaseError(f"{description} is not valid UTF-8") from error + if len(encoded) > MAX_PATH_BYTES: + _fail(f"{description} is too long") + components = path.split("/") + if any( + component in ("", ".", "..") + or len(component.encode("utf-8")) > MAX_COMPONENT_BYTES + or any(ord(character) < 0x20 or ord(character) == 0x7F for character in component) + for component in components + ): + _fail(f"{description} contains an unsafe component") + return path + + +def _safe_symlink_target(path: str, target_value: Any) -> str: + target = _canonical_relative_path(target_value, f"symlink target for {path}") + if target.startswith("/"): + _fail(f"symlink target for {path} is absolute") + parent = Path(path).parent + resolved = Path(os.path.normpath(str(parent / target))) + resolved_text = resolved.as_posix() + _canonical_relative_path(resolved_text, f"resolved symlink target for {path}") + return target + + +def _validate_release_id(value: Any) -> str: + return _require_string(value, "release_id", r"[a-z0-9][a-z0-9._-]{0,63}") + + +def _regular_file(path: Path, maximum: int | None = None) -> os.stat_result: + try: + info = os.stat(path) + except OSError as error: + raise ReleaseError(f"required file is unavailable: {path}") from error + if not stat.S_ISREG(info.st_mode): + _fail(f"required path is not a regular file: {path}") + if maximum is not None and info.st_size > maximum: + _fail(f"file too large: {path}") + return info + + +def _directory(path: Path, create: bool = False, mode: int = 0o700) -> os.stat_result: + if create: + path.mkdir(mode=mode, parents=True, exist_ok=True) + try: + info = os.stat(path) + except OSError as error: + raise ReleaseError(f"required directory is unavailable: {path}") from error + if not stat.S_ISDIR(info.st_mode): + _fail(f"required path is not a directory: {path}") + return info + + +def _read_with_digest( + path: Path, + maximum: int, + *, + retain_data: bool, +) -> tuple[bytes | None, str]: + info = _regular_file(path, maximum) + fd = os.open(path, os.O_RDONLY | os.O_CLOEXEC) + try: + current = os.fstat(fd) + if not _same_file_snapshot(info, current): + _fail(f"file changed while opening: {path}") + digest = hashlib.sha256() + data = bytearray() if retain_data else None + total = 0 + while total <= maximum: + block = os.read(fd, min(1024 * 1024, maximum + 1 - total)) + if not block: + break + digest.update(block) + total += len(block) + if data is not None: + data.extend(block) + current = os.fstat(fd) + try: + path_current = os.stat(path) + except OSError as error: + raise ReleaseError(f"file changed while reading: {path}") from error + if ( + total != info.st_size + or total > maximum + or not _same_file_snapshot(info, current) + or not _same_file_snapshot(info, path_current) + ): + _fail(f"file changed or exceeded limit: {path}") + return (bytes(data) if data is not None else None), digest.hexdigest() + finally: + os.close(fd) + + +def _read_exact(path: Path, maximum: int) -> bytes: + data, _ = _read_with_digest(path, maximum, retain_data=True) + if data is None: + _fail("internal secure-read invariant failed") + return data + + +def _sha256_limited(path: Path, maximum: int) -> str: + _, digest = _read_with_digest(path, maximum, retain_data=False) + return digest + + +def _atomic_write(path: Path, data: bytes, mode: int) -> None: + _directory(path.parent) + name = f".{path.name}.tmp-{uuid.uuid4().hex}" + temporary = path.parent / name + flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_CLOEXEC | os.O_NOFOLLOW + fd = os.open(temporary, flags, mode) + try: + offset = 0 + while offset < len(data): + offset += os.write(fd, data[offset:]) + os.fdatasync(fd) + current = os.fstat(fd) + if not stat.S_ISREG(current.st_mode): + _fail(f"temporary state file rejected: {temporary}") + os.fchmod(fd, mode) + except BaseException: + with contextlib.suppress(OSError): + os.unlink(temporary) + raise + finally: + os.close(fd) + os.replace(temporary, path) + _fsync_directory(path.parent) + + +def _fsync_directory(path: Path) -> None: + fd = os.open(path, os.O_RDONLY | os.O_DIRECTORY | os.O_CLOEXEC) + try: + os.fsync(fd) + finally: + os.close(fd) + + +def _rename_noreplace(source: Path, destination: Path) -> None: + libc = ctypes.CDLL(None, use_errno=True) + renameat2 = getattr(libc, "renameat2", None) + if renameat2 is None: + if destination.exists() or destination.is_symlink(): + raise FileExistsError(errno.EEXIST, "destination exists", str(destination)) + os.rename(source, destination) + return + result = renameat2( + ctypes.c_int(AT_FDCWD), + ctypes.c_char_p(os.fsencode(source)), + ctypes.c_int(AT_FDCWD), + ctypes.c_char_p(os.fsencode(destination)), + ctypes.c_uint(RENAME_NOREPLACE), + ) + if result != 0: + error_number = ctypes.get_errno() + raise OSError(error_number, os.strerror(error_number), str(destination)) + + +def _rename_exchange(first: Path, second: Path) -> None: + """Atomically exchange two existing directory entries. + + First-release facade migration relies on this primitive to ensure the + historical systemd entry path is never absent, even if power is lost at an + instruction boundary. There is deliberately no non-atomic fallback. + """ + libc = ctypes.CDLL(None, use_errno=True) + renameat2 = getattr(libc, "renameat2", None) + if renameat2 is None: + _fail("atomic facade exchange is unavailable") + result = renameat2( + ctypes.c_int(AT_FDCWD), + ctypes.c_char_p(os.fsencode(first)), + ctypes.c_int(AT_FDCWD), + ctypes.c_char_p(os.fsencode(second)), + ctypes.c_uint(RENAME_EXCHANGE), + ) + if result != 0: + error_number = ctypes.get_errno() + raise ReleaseError("atomic facade exchange failed") from OSError( + error_number, os.strerror(error_number) + ) + + +def _remove_private_tree(path: Path, required_parent: Path) -> None: + """Remove one explicit transaction/release tree without following links.""" + parent = path.parent + if parent != required_parent or path.name in ("", ".", ".."): + _fail("refusing to remove a path outside the controlled parent") + try: + root_info = os.lstat(path) + except FileNotFoundError: + return + if not stat.S_ISDIR(root_info.st_mode): + _fail(f"refusing to remove unexpected tree: {path}") + for current_root, directories, files in os.walk(path, topdown=False, followlinks=False): + current = Path(current_root) + for name in files: + candidate = current / name + info = os.lstat(candidate) + if stat.S_ISDIR(info.st_mode): + _fail(f"unexpected object in controlled tree: {candidate}") + os.unlink(candidate) + for name in directories: + candidate = current / name + info = os.lstat(candidate) + if stat.S_ISLNK(info.st_mode): + os.unlink(candidate) + elif stat.S_ISDIR(info.st_mode): + os.rmdir(candidate) + else: + _fail(f"unexpected object in controlled tree: {candidate}") + os.rmdir(path) + _fsync_directory(required_parent) + + +def _run_tool(arguments: Sequence[str], input_data: bytes | None = None) -> bytes: + try: + result = subprocess.run( + list(arguments), + input=input_data, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + env={"LC_ALL": "C", "PATH": "/usr/bin:/bin"}, + close_fds=True, + timeout=30, + ) + except (OSError, subprocess.TimeoutExpired) as error: + raise ReleaseError(f"required validation tool failed: {arguments[0]}") from error + if result.returncode != 0: + _fail(f"validation tool rejected input: {Path(arguments[0]).name}") + return result.stdout + + +def _validate_executable_tool(path: Path) -> None: + if not path.is_absolute(): + _fail("validation tool path must be absolute") + try: + info = os.stat(path) + except OSError as error: + raise ReleaseError(f"required validation tool is unavailable: {path}") from error + if ( + not stat.S_ISREG(info.st_mode) + or not stat.S_IMODE(info.st_mode) & 0o111 + ): + _fail(f"validation tool type or executable mode rejected: {path}") + + +def _validate_root_owned_tool(path: Path) -> None: + _validate_executable_tool(path) + + +def _verify_ed25519(openssl: Path, public_key: Path, message: Path, signature: Path) -> None: + _validate_executable_tool(openssl) + _run_tool( + ( + str(openssl), + "pkeyutl", + "-verify", + "-pubin", + "-inkey", + str(public_key), + "-rawin", + "-in", + str(message), + "-sigfile", + str(signature), + ) + ) + + +def _validate_payload_manifest(value: Any) -> tuple[Mapping[str, Any], ...]: + root = _require_object(value, {"entries", "format"}, "payload manifest") + if root["format"] != PAYLOAD_FORMAT or not isinstance(root["entries"], list): + _fail("payload manifest header rejected") + entries: list[Mapping[str, Any]] = [] + previous: bytes | None = None + seen: set[str] = set() + types: dict[str, str] = {} + symlink_targets: dict[str, str] = {} + for index, raw_entry in enumerate(root["entries"]): + if not isinstance(raw_entry, dict) or "type" not in raw_entry: + _fail(f"payload entry {index} is invalid") + entry_type = raw_entry["type"] + if entry_type == "file": + entry = _require_object(raw_entry, {"mode", "path", "sha256", "size", "type"}, "file entry") + _require_uint(entry["mode"], "file mode", 0, 0o777) + _require_uint(entry["size"], "file size", 0, MAX_ARCHIVE_BYTES) + _require_hex(entry["sha256"], 64, "file sha256") + elif entry_type == "directory": + entry = _require_object(raw_entry, {"mode", "path", "type"}, "directory entry") + _require_uint(entry["mode"], "directory mode", 0, 0o777) + elif entry_type == "symlink": + entry = _require_object(raw_entry, {"path", "target", "type"}, "symlink entry") + else: + _fail("payload entry type rejected") + path = _canonical_relative_path(entry["path"], "payload path") + if path == "meta" or path.startswith("meta/"): + _fail("payload may not create release metadata") + if _is_device_authorization_state(path): + _fail( + "payload contains device-specific Model Guard state: " + f"{path}" + ) + encoded = path.encode("utf-8") + if previous is not None and encoded <= previous: + _fail("payload entries are not uniquely byte-sorted") + previous = encoded + if path in seen: + _fail("duplicate payload path") + seen.add(path) + types[path] = entry_type + if entry_type == "symlink": + symlink_targets[path] = _safe_symlink_target(path, entry["target"]) + entries.append(entry) + for entry in entries: + path = str(entry["path"]) + parent = Path(path).parent + while parent != Path("."): + if types.get(parent.as_posix()) != "directory": + _fail(f"payload parent directory is not declared: {parent}") + parent = parent.parent + if entry["type"] == "symlink": + current = path + visited: set[str] = set() + while True: + if current in visited: + _fail(f"payload symlink cycle rejected: {path}") + visited.add(current) + target = symlink_targets.get(current) + if target is None: + _fail(f"symlink target is not a declared regular file: {path}") + resolved = Path(os.path.normpath(str(Path(current).parent / target))).as_posix() + resolved_type = types.get(resolved) + if resolved_type == "file": + break + if resolved_type != "symlink": + _fail(f"symlink target is not a declared regular file: {path}") + current = resolved + return tuple(entries) + + +def _validate_compatibility_manifest(value: Any) -> Mapping[str, Any]: + """Validate the single-certificate v3 schema for every release.""" + root = _require_object( + value, + { + "edge", + "device_certificate_schema", + "format", + "model_guard", + "payload_manifest_sha256", + "release_generation", + "release_id", + "release_key", + }, + "compatibility manifest", + ) + if root["format"] != FORMAT: + _fail("compatibility manifest format rejected") + _validate_release_id(root["release_id"]) + _require_uint(root["release_generation"], "release_generation", 1) + if ( + _require_uint( + root["device_certificate_schema"], + "device certificate schema", + 1, + 1, + ) + != 1 + ): + _fail("device certificate schema rejected") + _require_hex(root["payload_manifest_sha256"], 64, "payload manifest sha256", nonzero=True) + + release_key = _require_object(root["release_key"], {"id", "public_key_sha256"}, "release key") + _require_hex(release_key["id"], 32, "release key id", nonzero=True) + _require_hex(release_key["public_key_sha256"], 64, "release public key sha256", nonzero=True) + + edge = _require_object( + root["edge"], + {"compatibility_id", "path", "sha256"}, + "edge compatibility", + ) + if edge["path"] != "bin/cosmo-engine": + _fail("edge compatibility path rejected") + _require_hex(edge["sha256"], 64, "edge sha256", nonzero=True) + _require_hex(edge["compatibility_id"], 64, "edge compatibility id", nonzero=True) + + guard = _require_object( + root["model_guard"], + { + "exports", + "exports_sha256", + "header_path", + "header_sha256", + "path", + "sha256", + }, + "model guard compatibility", + ) + if ( + guard["path"] != f"lib/{GUARD_REAL_FILENAME}" + or guard["header_path"] != GUARD_HEADER_PATH + ): + _fail("model guard bundle rejected") + _require_hex(guard["sha256"], 64, "guard sha256", nonzero=True) + _require_hex(guard["header_sha256"], 64, "guard header sha256", nonzero=True) + _require_hex(guard["exports_sha256"], 64, "guard exports sha256", nonzero=True) + if guard["exports"] != list(REQUIRED_GUARD_EXPORTS): + _fail("model guard export whitelist rejected") + return root + + +def _validate_active_compatibility_manifest(value: Any) -> Mapping[str, Any]: + """Accept only the exact signed single-certificate schema.""" + if not isinstance(value, dict): + _fail("active compatibility manifest must be an object") + manifest = _validate_compatibility_manifest(value) + if _compatibility_id(manifest) != manifest["edge"]["compatibility_id"]: + _fail("edge/model-guard compatibility ID mismatch") + return manifest + + +def _exports_digest(exports: Sequence[str]) -> str: + return _sha256_bytes(("\n".join(exports) + "\n").encode("ascii")) + + +def _compatibility_id(manifest: Mapping[str, Any]) -> str: + guard = manifest["model_guard"] + fields = ( + f"edge_sha256={manifest['edge']['sha256']}", + f"guard_sha256={guard['sha256']}", + f"guard_exports_sha256={guard['exports_sha256']}", + f"guard_header_sha256={guard['header_sha256']}", + ) + domain = b"cosmo-edge-guard-compatibility-v3\x00" + return _sha256_bytes(domain + ("\n".join(fields) + "\n").encode("ascii")) + + +def _check_release_layout(release_root: Path) -> None: + regular_files = { + "bin/cosmo-engine", + RELEASE_BOOTSTRAP_PATH, + MODEL_PROVISION_PATH, + f"lib/{GUARD_REAL_FILENAME}", + "lib/libbmlib.so", + "lib/libbmlib.so.0", + "lib/libbmrt.so", + "lib/libbmrt.so.1.0", + "lib/libcrypto.so.3", + "lib/libssl.so.3", + GUARD_HEADER_PATH, + *REQUIRED_RELEASE_SCRIPTS, + } + for relative in regular_files: + candidate = release_root / relative + try: + info = os.stat(candidate) + except OSError as error: + raise ReleaseError(f"required release file is unavailable: {relative}") from error + if not stat.S_ISREG(info.st_mode): + _fail(f"required release path is not a file: {relative}") + for relative, target in ( + (f"lib/{GUARD_SONAME}", GUARD_REAL_FILENAME), + ("lib/libcosmo_model_guard.so", GUARD_SONAME), + ): + candidate = release_root / relative + try: + info = os.lstat(candidate) + except OSError as error: + raise ReleaseError(f"required release symlink is unavailable: {relative}") from error + if ( + not stat.S_ISLNK(info.st_mode) + or os.readlink(candidate) != target + ): + _fail(f"required release symlink rejected: {relative}") + for relative in FACADE_DIRECTORIES: + candidate = release_root / relative + try: + info = os.stat(candidate) + except OSError as error: + raise ReleaseError(f"required release directory is unavailable: {relative}") from error + if not stat.S_ISDIR(info.st_mode): + _fail(f"required release directory rejected: {relative}") + + +def _check_component_hashes( + release_root: Path, manifest: Mapping[str, Any] +) -> None: + for binding in (manifest["edge"], manifest["model_guard"]): + relative = str(binding["path"]) + if _sha256_file(release_root / relative) != binding["sha256"]: + _fail(f"signed component digest mismatch: {relative}") + + guard = manifest["model_guard"] + header = release_root / guard["header_path"] + if _sha256_file(header) != guard["header_sha256"]: + _fail("model guard header digest mismatch") + _validate_model_guard_header(_read_exact(header, MAX_GUARD_HEADER_BYTES)) + if _compatibility_id(manifest) != manifest["edge"]["compatibility_id"]: + _fail("edge/model-guard compatibility ID mismatch") + + +class _CemV2FormatError(ValueError): + """An unauthenticated CEM v2 core failed strict structural validation.""" + + +@dataclasses.dataclass(frozen=True) +class _CemV2ManifestFacts: + artifact_id: bytes + cohort_id: bytes + generation: int + model_id: str + model_version: str + model_identity_sha256: bytes + record_bytes: int + source_format: str + target_platform: str + + +@dataclasses.dataclass(frozen=True) +class _CemV2CoreSnapshot: + artifact_id: str + cohort_id: str + core_preamble_sha256: str + core_sha256: str + core_size: int + created_at: int + generation: int + manifest_sha256: str + model_id: str + model_identity_sha256: str + model_version: str + source_format: str + target_platform: str + + +class _CemV2CborReader: + """The bounded canonical-CBOR subset used by the Guard CEM v2 manifest.""" + + def __init__(self, data: bytes) -> None: + self._data = data + self._offset = 0 + + def _take(self, size: int) -> bytes: + if size < 0 or size > len(self._data) - self._offset: + raise _CemV2FormatError("truncated manifest") + start = self._offset + self._offset += size + return self._data[start : start + size] + + def _read_value(self, expected_major: int) -> int: + initial = self._take(1)[0] + major = initial >> 5 + additional = initial & 0x1F + if major != expected_major: + raise _CemV2FormatError("manifest has an invalid CBOR type") + if additional < 24: + return additional + widths = {24: 1, 25: 2, 26: 4, 27: 8} + width = widths.get(additional) + if width is None: + raise _CemV2FormatError("manifest uses indefinite or reserved CBOR") + value = int.from_bytes(self._take(width), "big") + if ( + (width == 1 and value < 24) + or (width == 2 and value <= 0xFF) + or (width == 4 and value <= 0xFFFF) + or (width == 8 and value <= 0xFFFFFFFF) + ): + raise _CemV2FormatError("manifest uses a non-minimal CBOR integer") + return value + + def read_unsigned(self) -> int: + return self._read_value(0) + + def read_bytes(self, expected_size: int) -> bytes: + size = self._read_value(2) + if size != expected_size: + raise _CemV2FormatError("manifest byte string has an invalid size") + return self._take(size) + + def read_text(self, maximum_size: int) -> str: + size = self._read_value(3) + if size == 0 or size > maximum_size: + raise _CemV2FormatError("manifest text has an invalid size") + try: + return self._take(size).decode("ascii", "strict") + except UnicodeError as error: + raise _CemV2FormatError("manifest text is not canonical ASCII") from error + + def read_array_size(self) -> int: + return self._read_value(4) + + def read_map_size(self) -> int: + return self._read_value(5) + + def require_key(self, expected: int) -> None: + if self.read_unsigned() != expected: + raise _CemV2FormatError("manifest keys are not exactly canonical") + + def require_end(self) -> None: + if self._offset != len(self._data): + raise _CemV2FormatError("manifest has trailing CBOR data") + + +def _cem_v2_nonzero(value: bytes, description: str) -> None: + if not any(value): + raise _CemV2FormatError(f"{description} must be nonzero") + + +def _parse_canonical_cem_v2_manifest( + manifest_bytes: bytes, +) -> _CemV2ManifestFacts: + """Parse the public CEM v2 wire contract and return its layout bindings. + + This independently validates canonical metadata and record layout only; + without a content key it cannot authenticate any AES-GCM record. + """ + + if not manifest_bytes or len(manifest_bytes) > CEM_V2_MAX_MANIFEST_BYTES: + raise _CemV2FormatError("manifest has an invalid size") + reader = _CemV2CborReader(manifest_bytes) + if reader.read_map_size() != 15: + raise _CemV2FormatError("manifest map must contain exactly 15 entries") + + reader.require_key(1) + schema_version = reader.read_unsigned() + reader.require_key(2) + model_id = reader.read_text(64) + reader.require_key(3) + model_version = reader.read_text(32) + reader.require_key(4) + model_identity_sha256 = reader.read_bytes(32) + reader.require_key(5) + artifact_id = reader.read_bytes(16) + reader.require_key(6) + cohort_id = reader.read_bytes(16) + reader.require_key(7) + generation = reader.read_unsigned() + reader.require_key(8) + source_format = reader.read_unsigned() + reader.require_key(9) + target_platform = reader.read_unsigned() + reader.require_key(10) + segment_count = reader.read_unsigned() + reader.require_key(11) + chunk_count = reader.read_unsigned() + reader.require_key(12) + nominal_chunk_plain_len = reader.read_unsigned() + reader.require_key(13) + if reader.read_array_size() != 3: + raise _CemV2FormatError("minimum Guard version must have three components") + min_guard_version = tuple(reader.read_unsigned() for _ in range(3)) + + if segment_count > CEM_V2_MAX_SEGMENTS: + raise _CemV2FormatError("manifest has too many segments") + reader.require_key(14) + encoded_segment_count = reader.read_array_size() + if encoded_segment_count != segment_count or encoded_segment_count == 0: + raise _CemV2FormatError("manifest segment count is inconsistent") + segments: list[tuple[int, int, int, int]] = [] + for _ in range(encoded_segment_count): + if reader.read_array_size() != 4: + raise _CemV2FormatError("segment descriptor has an invalid size") + segment_index = reader.read_unsigned() + plain_len = reader.read_unsigned() + first_global_chunk_index = reader.read_unsigned() + segment_chunk_count = reader.read_unsigned() + if ( + segment_index > 0xFFFFFFFF + or first_global_chunk_index > 0xFFFFFFFF + or segment_chunk_count > 0xFFFFFFFF + ): + raise _CemV2FormatError("segment descriptor exceeds its integer width") + segments.append( + ( + segment_index, + plain_len, + first_global_chunk_index, + segment_chunk_count, + ) + ) + + if chunk_count > CEM_V2_MAX_CHUNKS: + raise _CemV2FormatError("manifest has too many chunks") + reader.require_key(15) + encoded_chunk_count = reader.read_array_size() + if encoded_chunk_count != chunk_count or encoded_chunk_count == 0: + raise _CemV2FormatError("manifest chunk count is inconsistent") + chunks: list[tuple[int, int, int, int, int]] = [] + for _ in range(encoded_chunk_count): + if reader.read_array_size() != 5: + raise _CemV2FormatError("chunk descriptor has an invalid size") + segment_index = reader.read_unsigned() + global_chunk_index = reader.read_unsigned() + plain_len = reader.read_unsigned() + record_offset = reader.read_unsigned() + record_len = reader.read_unsigned() + if ( + segment_index > 0xFFFFFFFF + or global_chunk_index > 0xFFFFFFFF + or plain_len > 0xFFFFFFFF + or record_len > 0xFFFFFFFF + ): + raise _CemV2FormatError("chunk descriptor exceeds its integer width") + chunks.append( + ( + segment_index, + global_chunk_index, + plain_len, + record_offset, + record_len, + ) + ) + reader.require_end() + + if schema_version != 1 or source_format not in {1, 2} or target_platform != 1: + raise _CemV2FormatError("manifest uses an unsupported protocol value") + _cem_v2_nonzero(model_identity_sha256, "model identity") + _cem_v2_nonzero(artifact_id, "artifact ID") + _cem_v2_nonzero(cohort_id, "cohort ID") + if generation == 0: + raise _CemV2FormatError("generation must be nonzero") + if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_-]{0,63}", model_id): + raise _CemV2FormatError("model ID is not canonical") + if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._-]{0,31}", model_version): + raise _CemV2FormatError("model version is not canonical") + expected_identity = hashlib.sha256( + MODEL_IDENTITY_DOMAIN + + len(model_id).to_bytes(2, "big") + + model_id.encode("ascii") + + len(model_version).to_bytes(2, "big") + + model_version.encode("ascii") + ).digest() + if model_identity_sha256 != expected_identity: + raise _CemV2FormatError("model identity digest mismatch") + if not ( + CEM_V2_MIN_NOMINAL_CHUNK_BYTES + <= nominal_chunk_plain_len + <= CEM_V2_MAX_CHUNK_PLAIN_BYTES + ): + raise _CemV2FormatError("nominal chunk size is outside protocol limits") + if any(component > 0xFFFF for component in min_guard_version): + raise _CemV2FormatError("minimum Guard version is outside protocol limits") + if source_format == 2 and segment_count != 1: + raise _CemV2FormatError("raw bmodel must contain exactly one segment") + + expected_record_offset = 0 + expected_global_chunk = 0 + for expected_segment_index, segment in enumerate(segments): + ( + segment_index, + segment_plain_len, + first_global_chunk_index, + segment_chunk_count, + ) = segment + if segment_plain_len > CEM_V2_MAX_SEGMENT_PLAIN_BYTES: + raise _CemV2FormatError("segment plaintext size exceeds the limit") + if ( + segment_index != expected_segment_index + or segment_plain_len == 0 + or first_global_chunk_index != expected_global_chunk + or segment_chunk_count == 0 + or expected_global_chunk > chunk_count + or segment_chunk_count > chunk_count - expected_global_chunk + ): + raise _CemV2FormatError("segment layout is inconsistent") + + accumulated_plain_len = 0 + for local_index in range(segment_chunk_count): + ( + chunk_segment_index, + global_chunk_index, + chunk_plain_len, + record_offset, + record_len, + ) = chunks[expected_global_chunk] + if ( + chunk_segment_index != expected_segment_index + or global_chunk_index != expected_global_chunk + or chunk_plain_len == 0 + or chunk_plain_len > nominal_chunk_plain_len + or ( + local_index + 1 != segment_chunk_count + and chunk_plain_len != nominal_chunk_plain_len + ) + or record_offset != expected_record_offset + or record_len != chunk_plain_len + CEM_V2_GCM_TAG_BYTES + ): + raise _CemV2FormatError("chunk layout is inconsistent") + accumulated_plain_len += chunk_plain_len + expected_record_offset += record_len + if accumulated_plain_len > CEM_V2_MAX_SEGMENT_PLAIN_BYTES: + raise _CemV2FormatError("segment plaintext size exceeds the limit") + expected_global_chunk += 1 + if accumulated_plain_len != segment_plain_len: + raise _CemV2FormatError("segment plaintext size is inconsistent") + if expected_global_chunk != chunk_count: + raise _CemV2FormatError("manifest leaves unassigned chunks") + return _CemV2ManifestFacts( + artifact_id=artifact_id, + cohort_id=cohort_id, + generation=generation, + model_id=model_id, + model_version=model_version, + model_identity_sha256=model_identity_sha256, + record_bytes=expected_record_offset, + source_format=CEM_V2_SOURCE_FORMATS[source_format], + target_platform=CEM_V2_TARGET_PLATFORM, + ) + + +def _pread_exact(fd: int, offset: int, size: int, limit: int) -> bytes: + if offset < 0 or size < 0 or offset > limit or size > limit - offset: + raise _CemV2FormatError("file range is outside the CEM v2 core") + output = bytearray() + while len(output) < size: + block = os.pread(fd, size - len(output), offset + len(output)) + if not block: + raise _CemV2FormatError("CEM v2 core is truncated") + output.extend(block) + return bytes(output) + + +def _same_file_snapshot(left: os.stat_result, right: os.stat_result) -> bool: + return ( + left.st_dev, + left.st_ino, + left.st_size, + left.st_mtime_ns, + ) == ( + right.st_dev, + right.st_ino, + right.st_size, + right.st_mtime_ns, + ) + + +def _validate_cem_v2_core_fd( + fd: int, initial: os.stat_result, relative: str +) -> _CemV2CoreSnapshot: + """Validate one CEM v2 core from one already-open, non-following fd.""" + + try: + file_size = initial.st_size + if ( + file_size < CEM_V2_CORE_PREAMBLE_SIZE + or file_size > CEM_V2_MAX_CORE_BYTES + ): + raise _CemV2FormatError("core size is outside protocol limits") + preamble = _pread_exact( + fd, 0, CEM_V2_CORE_PREAMBLE_SIZE, file_size + ) + ( + magic, + format_version, + preamble_len, + suite_id, + flags, + artifact_id, + cohort_id, + generation, + created_at, + manifest_len, + manifest_sha256, + nonce_prefix, + reserved, + ) = struct.unpack(">4sHHHH16s16sQQI32s8s8s", preamble) + if magic != b"CEMC": + raise _CemV2FormatError("core magic mismatch") + if format_version != 2 or suite_id != 1 or flags != 0x0001: + raise _CemV2FormatError("core uses an unsupported protocol value") + if preamble_len != CEM_V2_CORE_PREAMBLE_SIZE: + raise _CemV2FormatError("core preamble length is not canonical") + _cem_v2_nonzero(artifact_id, "preamble artifact ID") + _cem_v2_nonzero(cohort_id, "preamble cohort ID") + _cem_v2_nonzero(manifest_sha256, "preamble manifest digest") + _cem_v2_nonzero(nonce_prefix, "preamble nonce prefix") + if generation == 0 or created_at == 0: + raise _CemV2FormatError("preamble generation and time must be nonzero") + if created_at > (1 << 63) - 1: + raise _CemV2FormatError("preamble time is outside protocol limits") + if manifest_len == 0 or manifest_len > CEM_V2_MAX_MANIFEST_BYTES: + raise _CemV2FormatError("manifest size is outside protocol limits") + if any(reserved): + raise _CemV2FormatError("preamble reserved bytes must be zero") + + payload_offset = CEM_V2_CORE_PREAMBLE_SIZE + manifest_len + if payload_offset >= file_size: + raise _CemV2FormatError("core has no chunk-record payload") + manifest = _pread_exact( + fd, CEM_V2_CORE_PREAMBLE_SIZE, manifest_len, file_size + ) + if hashlib.sha256(manifest).digest() != manifest_sha256: + raise _CemV2FormatError("manifest digest mismatch") + manifest_facts = _parse_canonical_cem_v2_manifest(manifest) + if ( + manifest_facts.artifact_id != artifact_id + or manifest_facts.cohort_id != cohort_id + or manifest_facts.generation != generation + ): + raise _CemV2FormatError("preamble and manifest bindings differ") + if payload_offset + manifest_facts.record_bytes != file_size: + raise _CemV2FormatError("chunk-record layout does not cover the core") + + core_sha256 = _sha256_fd(fd, file_size) + return _CemV2CoreSnapshot( + artifact_id=artifact_id.hex(), + cohort_id=cohort_id.hex(), + core_preamble_sha256=_sha256_bytes(preamble), + core_sha256=core_sha256, + core_size=file_size, + created_at=created_at, + generation=generation, + manifest_sha256=manifest_sha256.hex(), + model_id=manifest_facts.model_id, + model_identity_sha256=manifest_facts.model_identity_sha256.hex(), + model_version=manifest_facts.model_version, + source_format=manifest_facts.source_format, + target_platform=manifest_facts.target_platform, + ) + except (OSError, struct.error, _CemV2FormatError) as error: + raise ReleaseError( + f"invalid CEM v2 preset blocks upgrade: {relative}: {error}" + ) from error + + +def _sha256_fd(fd: int, file_size: int) -> str: + digest = hashlib.sha256() + offset = 0 + while offset < file_size: + block = os.pread(fd, min(1024 * 1024, file_size - offset), offset) + if not block: + _fail("preset model changed or became unreadable during validation") + digest.update(block) + offset += len(block) + return digest.hexdigest() + + +def _scan_preset_models( + release_root: Path, +) -> tuple[_CemV2CoreSnapshot, ...]: + """Validate every preset and require one nonzero package-wide cohort ID.""" + resource = release_root / "resource" + models = resource / "models" + try: + models_info = os.lstat(models) + except FileNotFoundError: + return () + if not stat.S_ISDIR(models_info.st_mode): + _fail("preset model root rejected") + snapshots: list[_CemV2CoreSnapshot] = [] + for current_root, directories, files in os.walk(models, followlinks=False): + directories.sort() + files.sort() + for directory in directories: + info = os.lstat(Path(current_root) / directory) + if not stat.S_ISDIR(info.st_mode): + _fail("preset model directory replacement rejected") + if directory == "model.nn": + _fail("preset model path must be a regular file") + for filename in files: + if filename != "model.nn": + continue + candidate = Path(current_root) / filename + relative = candidate.relative_to(models).as_posix() + try: + fd = os.open( + candidate, + os.O_RDONLY + | os.O_CLOEXEC + | getattr(os, "O_NONBLOCK", 0), + ) + except OSError as error: + raise ReleaseError(f"preset model open rejected: {relative}") from error + try: + info = os.fstat(fd) + if not stat.S_ISREG(info.st_mode): + _fail("preset model must be a regular file") + header = os.pread(fd, 8, 0) + if header[:4] != b"CEMC": + _fail( + f"plaintext or unknown preset model blocks upgrade: {relative}" + ) + snapshots.append( + _validate_cem_v2_core_fd(fd, info, relative) + ) + except OSError as error: + raise ReleaseError( + f"preset model validation failed: {relative}" + ) from error + finally: + os.close(fd) + if snapshots: + cohort_ids = {snapshot.cohort_id for snapshot in snapshots} + if "0" * 32 in cohort_ids: + _fail("preset model cohort ID must be nonzero") + if len(cohort_ids) != 1: + _fail("preset models use mixed cohort IDs") + return tuple(snapshots) + + +def _state_tree_fingerprint(root: Path) -> str: + digest = hashlib.sha256(b"cosmo-model-guard-persistent-state-v2\x00") + certificate = root / "device-certificate.bin" + try: + certificate_bytes = certificate.read_bytes() + except FileNotFoundError: + return digest.hexdigest() + except OSError as error: + raise ReleaseError("model-guard device certificate read failed") from error + + relative = b"device-certificate.bin" + digest.update(b"F") + digest.update(len(relative).to_bytes(4, "big")) + digest.update(relative) + digest.update(len(certificate_bytes).to_bytes(8, "big")) + digest.update(hashlib.sha256(certificate_bytes).digest()) + return digest.hexdigest() + + +@dataclasses.dataclass(frozen=True) +class ArchiveInspection: + manifest_bytes: bytes + manifest: Mapping[str, Any] + signature: bytes + payload_bytes: bytes + payload_entries: tuple[Mapping[str, Any], ...] + members: tuple[tarfile.TarInfo, ...] + + +@dataclasses.dataclass(frozen=True) +class BootstrapHealthPlan: + run_script: Path + health_script: Path + stop_script: Path + log_path: Path + + +class ReleaseUpdater: + def __init__( + self, + paths: ReleasePaths, + *, + failpoint: str | None = None, + failure_callback: Callable[[str], None] | None = None, + lifecycle_callback: Callable[[str], None] | None = None, + ) -> None: + self.paths = paths + self.failpoint = failpoint + self.failure_callback = failure_callback + self.lifecycle_callback = lifecycle_callback + + def _interrupt(self, point: str) -> None: + if self.failure_callback is not None: + self.failure_callback(point) + if self.failpoint == point: + raise InjectedInterruption(point) + + def _lifecycle(self, event: str) -> None: + if self.lifecycle_callback is not None: + self.lifecycle_callback(event) + + def _initialize_directories(self) -> None: + self.paths.install_root.mkdir(mode=0o755, parents=True, exist_ok=True) + _directory(self.paths.install_root) + self.paths.state_dir.mkdir(mode=0o700, exist_ok=True) + self.paths.releases.mkdir(mode=0o700, exist_ok=True) + self.paths.transactions.mkdir(mode=0o700, exist_ok=True) + _directory(self.paths.state_dir) + _directory(self.paths.releases) + _directory(self.paths.transactions) + + @contextlib.contextmanager + def _lock(self) -> Iterator[None]: + self._initialize_directories() + fd = os.open( + self.paths.lock_file, + os.O_RDWR | os.O_CREAT | os.O_CLOEXEC | os.O_NOFOLLOW, + 0o600, + ) + try: + info = os.fstat(fd) + if not stat.S_ISREG(info.st_mode): + _fail("release update lock rejected") + fcntl.flock(fd, fcntl.LOCK_EX) + yield + finally: + os.close(fd) + + def _load_state(self) -> Mapping[str, Any]: + data = _read_exact(self.paths.state_file, MAX_MANIFEST_BYTES) + value = _strict_json(data, MAX_MANIFEST_BYTES, "release compatibility state") + state = _require_object( + value, + { + "current_release_id", + "format", + "manifest_sha256", + "release_generation", + "release_key_id", + "release_public_key_sha256", + }, + "release compatibility state", + ) + if state["format"] != STATE_FORMAT: + _fail("release state format rejected") + _validate_release_id(state["current_release_id"]) + _require_uint(state["release_generation"], "state release generation", 1) + _require_hex(state["manifest_sha256"], 64, "state manifest sha256", nonzero=True) + _require_hex(state["release_key_id"], 32, "state release key id", nonzero=True) + _require_hex(state["release_public_key_sha256"], 64, "state public key sha256", nonzero=True) + return state + + def _current_release_path(self, state: Mapping[str, Any]) -> Path: + info = os.lstat(self.paths.current) + if not stat.S_ISLNK(info.st_mode): + _fail("active release pointer rejected") + target = os.readlink(self.paths.current) + expected = f".releases/{state['current_release_id']}" + if target != expected: + _fail("active release pointer does not match compatibility state") + release = self.paths.releases / state["current_release_id"] + _directory(release) + return release + + def _validate_facades(self, release: Path) -> None: + for name in FACADE_DIRECTORIES: + facade = self.paths.install_root / name + info = os.lstat(facade) + if ( + not stat.S_ISLNK(info.st_mode) + or os.readlink(facade) != f"current/{name}" + ): + _fail(f"release facade rejected: {name}") + target = release / name + target_info = os.stat(target) + if not stat.S_ISDIR(target_info.st_mode): + _fail(f"active release facade target rejected: {name}") + + def _validate_current_trust(self) -> tuple[Mapping[str, Any], Path, Mapping[str, Any]]: + state = self._load_state() + release = self._current_release_path(state) + self._validate_facades(release) + manifest = self._validate_release_trust_against_state(state, release) + return state, release, manifest + + def _validate_release_trust_against_state( + self, state: Mapping[str, Any], release: Path + ) -> Mapping[str, Any]: + if ( + release.parent != self.paths.releases + or release.name != state["current_release_id"] + ): + _fail("trusted release path does not match durable state") + _directory(release) + meta = release / "meta" + _directory(meta) + public_key = meta / "release-public-key.pem" + key_bytes = _read_exact(public_key, 16 * 1024) + if _sha256_bytes(key_bytes) != state["release_public_key_sha256"]: + _fail("active release trust anchor digest mismatch") + manifest_path = meta / "compatibility.manifest.json" + signature_path = meta / "compatibility.manifest.sig" + manifest_bytes = _read_exact(manifest_path, MAX_MANIFEST_BYTES) + signature = _read_exact(signature_path, 64) + if len(signature) != 64: + _fail("active release signature size rejected") + _verify_ed25519(self.paths.openssl, public_key, manifest_path, signature_path) + manifest = _validate_active_compatibility_manifest( + _strict_json(manifest_bytes, MAX_MANIFEST_BYTES, "active compatibility manifest") + ) + if ( + _sha256_bytes(manifest_bytes) != state["manifest_sha256"] + or manifest["release_id"] != state["current_release_id"] + or manifest["release_generation"] != state["release_generation"] + or manifest["release_key"]["id"] != state["release_key_id"] + or manifest["release_key"]["public_key_sha256"] != state["release_public_key_sha256"] + ): + _fail("active signed compatibility set does not match durable state") + return manifest + + def _load_journal(self) -> Mapping[str, Any] | None: + if not self.paths.journal_file.exists(): + return None + data = _read_exact(self.paths.journal_file, MAX_MANIFEST_BYTES) + value = _strict_json(data, MAX_MANIFEST_BYTES, "release transaction journal") + journal = _require_object( + value, + { + "archive_sha256", + "format", + "incoming_manifest_sha256", + "incoming_release_id", + "persistent_state_fingerprint", + "phase", + "previous_release_id", + "publication_marker_sha256", + "transaction_id", + }, + "release transaction journal", + ) + if journal["format"] != JOURNAL_FORMAT: + _fail("release journal format rejected") + _require_string(journal["transaction_id"], "transaction ID", r"[0-9a-f]{32}") + _validate_release_id(journal["previous_release_id"]) + _validate_release_id(journal["incoming_release_id"]) + _require_hex(journal["archive_sha256"], 64, "journal archive sha256", nonzero=True) + _require_hex(journal["incoming_manifest_sha256"], 64, "journal manifest sha256", nonzero=True) + _require_hex(journal["persistent_state_fingerprint"], 64, "journal persistent-state fingerprint") + _require_hex( + journal["publication_marker_sha256"], + 64, + "journal publication marker sha256", + nonzero=True, + ) + if journal["phase"] not in ("preparing", "publishing", "staged", "switched"): + _fail("release journal phase rejected") + return journal + + def _write_journal(self, journal: Mapping[str, Any]) -> None: + _atomic_write( + self.paths.journal_file, + _canonical_json(dict(journal)), + 0o600, + ) + + def _validate_transaction_owned_release( + self, journal: Mapping[str, Any], release: Path + ) -> None: + """Prove that a published tree was created by this exact transaction.""" + if ( + release.parent != self.paths.releases + or release.name != journal["incoming_release_id"] + ): + _fail("published release path does not match the transaction") + _directory(release) + meta = release / "meta" + _directory(meta) + + marker = _read_exact( + release / PUBLICATION_MARKER_PATH, + MAX_MANIFEST_BYTES, + ) + expected_marker = _publication_marker_bytes( + str(journal["transaction_id"]), + str(journal["incoming_release_id"]), + str(journal["incoming_manifest_sha256"]), + str(journal["archive_sha256"]), + ) + if ( + marker != expected_marker + or _sha256_bytes(marker) != journal["publication_marker_sha256"] + ): + _fail("published release is not owned by the pending transaction") + + manifest_bytes = _read_exact( + meta / "compatibility.manifest.json", + MAX_MANIFEST_BYTES, + ) + if _sha256_bytes(manifest_bytes) != journal["incoming_manifest_sha256"]: + _fail("transaction-owned release manifest changed") + + def _transaction_release_for_cleanup( + self, journal: Mapping[str, Any] + ) -> Path | None: + """Resolve only a tree whose publication is proven by this journal.""" + release = self.paths.releases / journal["incoming_release_id"] + present = release.exists() or release.is_symlink() + phase = journal["phase"] + if phase == "preparing": + if present: + _fail("preparing transaction encountered a non-transaction release tree") + return None + if not present: + if phase == "publishing": + return None + _fail("published incoming release tree is missing") + self._validate_transaction_owned_release(journal, release) + return release + + def _switch_current(self, release_id: str) -> None: + _validate_release_id(release_id) + destination = self.paths.releases / release_id + _directory(destination) + temporary = self.paths.install_root / f".current-{uuid.uuid4().hex}" + os.symlink(f".releases/{release_id}", temporary) + try: + info = os.lstat(temporary) + if not stat.S_ISLNK(info.st_mode): + _fail("temporary active release pointer rejected") + os.replace(temporary, self.paths.current) + _fsync_directory(self.paths.install_root) + except BaseException: + with contextlib.suppress(OSError): + os.unlink(temporary) + raise + + def _copy_input_archive(self, archive: Path, destination: Path) -> str: + """Copy one validated input inode into the private transaction tree. + + The caller-supplied path is opened exactly once. All later parsing and + extraction operates on the returned private inode, so replacing or + modifying the caller's path cannot change the authenticated input. + """ + try: + path_info = os.stat(archive) + except OSError as error: + raise ReleaseError("release archive is unavailable") from error + if ( + not stat.S_ISREG(path_info.st_mode) + or path_info.st_size <= 0 + or path_info.st_size > MAX_ARCHIVE_BYTES + ): + _fail("release archive type or size rejected") + + try: + source_fd = os.open(archive, os.O_RDONLY | os.O_CLOEXEC) + except OSError as error: + raise ReleaseError("release archive cannot be opened securely") from error + output_fd = -1 + try: + opened_info = os.fstat(source_fd) + if not _same_file_snapshot(path_info, opened_info): + _fail("release archive changed while opening") + + output_fd = os.open( + destination, + os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_CLOEXEC | os.O_NOFOLLOW, + 0o600, + ) + digest = hashlib.sha256() + copied = 0 + while True: + block = os.read(source_fd, 1024 * 1024) + if not block: + break + copied += len(block) + if copied > opened_info.st_size: + _fail("release archive grew while copying") + digest.update(block) + offset = 0 + while offset < len(block): + offset += os.write(output_fd, block[offset:]) + if copied != opened_info.st_size: + _fail("release archive size changed while copying") + os.fdatasync(output_fd) + os.fchmod(output_fd, 0o600) + private_info = os.fstat(output_fd) + if ( + not stat.S_ISREG(private_info.st_mode) + or private_info.st_size != copied + ): + _fail("private release archive inode rejected") + + current_info = os.fstat(source_fd) + if not _same_file_snapshot(path_info, current_info): + _fail("release archive inode changed while copying") + _fsync_directory(destination.parent) + return digest.hexdigest() + except BaseException: + with contextlib.suppress(OSError): + os.unlink(destination) + raise + finally: + if output_fd >= 0: + os.close(output_fd) + os.close(source_fd) + + def _archive_members(self, archive: Path) -> tuple[tuple[tarfile.TarInfo, ...], dict[str, bytes]]: + info = os.lstat(archive) + if ( + not stat.S_ISREG(info.st_mode) + or info.st_size <= 0 + or info.st_size > MAX_ARCHIVE_BYTES + ): + _fail("release archive type or size rejected") + fd = os.open(archive, os.O_RDONLY | os.O_CLOEXEC | os.O_NOFOLLOW) + metadata: dict[str, bytes] = {} + members: list[tarfile.TarInfo] = [] + names: set[str] = set() + total = 0 + try: + with os.fdopen(fd, "rb", closefd=False) as stream, tarfile.open(fileobj=stream, mode="r:gz") as bundle: + for member in bundle: + if len(members) >= MAX_ARCHIVE_ENTRIES: + _fail("release archive has too many entries") + name = _canonical_relative_path(member.name, "archive member path") + if not ( + name in ("payload", "meta") + or name.startswith("payload/") + or name.startswith("meta/") + ): + _fail("release archive contains an unexpected top-level path") + if name in names: + _fail("release archive contains duplicate member names") + names.add(name) + if member.islnk() or member.ischr() or member.isblk() or member.isfifo() or member.isdev(): + _fail("release archive contains a forbidden object") + if not (member.isfile() or member.isdir() or member.issym()): + _fail("release archive member type rejected") + if member.sparse is not None: + _fail("sparse release archive members are forbidden") + total += member.size + if total > MAX_ARCHIVE_BYTES: + _fail("release archive expanded size exceeds the limit") + if member.issym(): + _safe_symlink_target(name, member.linkname) + if name.startswith("meta/"): + if name not in { + "meta/compatibility.manifest.json", + "meta/compatibility.manifest.sig", + "meta/payload.files.json", + } or not member.isfile(): + _fail("release archive metadata set rejected") + maximum = 64 if name.endswith(".sig") else ( + MAX_PAYLOAD_MANIFEST_BYTES if name.endswith("payload.files.json") else MAX_MANIFEST_BYTES + ) + if member.size > maximum: + _fail("release archive metadata is too large") + extracted = bundle.extractfile(member) + if extracted is None: + _fail("cannot read release archive metadata") + data = extracted.read(maximum + 1) + if len(data) != member.size or len(data) > maximum: + _fail("release archive metadata length mismatch") + metadata[name] = data + members.append(member) + except (tarfile.TarError, OSError) as error: + if isinstance(error, ReleaseError): + raise + raise ReleaseError("release archive parsing failed") from error + finally: + os.close(fd) + if set(metadata) != { + "meta/compatibility.manifest.json", + "meta/compatibility.manifest.sig", + "meta/payload.files.json", + }: + _fail("release archive metadata is incomplete") + return tuple(members), metadata + + def _inspect_archive( + self, + archive: Path, + state: Mapping[str, Any], + current_release: Path, + current_manifest: Mapping[str, Any], + ) -> ArchiveInspection: + members, metadata = self._archive_members(archive) + manifest_bytes = metadata["meta/compatibility.manifest.json"] + signature = metadata["meta/compatibility.manifest.sig"] + payload_bytes = metadata["meta/payload.files.json"] + if len(signature) != 64: + _fail("incoming release signature size rejected") + manifest = _validate_compatibility_manifest( + _strict_json(manifest_bytes, MAX_MANIFEST_BYTES, "incoming compatibility manifest") + ) + payload_entries = _validate_payload_manifest( + _strict_json(payload_bytes, MAX_PAYLOAD_MANIFEST_BYTES, "incoming payload manifest") + ) + if _sha256_bytes(payload_bytes) != manifest["payload_manifest_sha256"]: + _fail("incoming payload manifest digest mismatch") + if ( + manifest["release_key"]["id"] != state["release_key_id"] + or manifest["release_key"]["public_key_sha256"] != state["release_public_key_sha256"] + ): + _fail("incoming package attempted to select a different release trust anchor") + if manifest["release_generation"] <= state["release_generation"]: + _fail("incoming release is not a strict generation upgrade") + if manifest["release_id"] == current_manifest["release_id"]: + _fail("incoming release ID is already active") + + with tempfile.TemporaryDirectory(prefix="cosmo-release-signature-", dir=self.paths.state_dir) as temporary: + temporary_path = Path(temporary) + message_path = temporary_path / "manifest" + signature_path = temporary_path / "signature" + message_path.write_bytes(manifest_bytes) + signature_path.write_bytes(signature) + os.chmod(message_path, 0o600) + os.chmod(signature_path, 0o600) + _verify_ed25519( + self.paths.openssl, + current_release / "meta/release-public-key.pem", + message_path, + signature_path, + ) + + expected: dict[str, tuple[str, Mapping[str, Any] | None]] = { + "meta": ("directory", None), + "meta/compatibility.manifest.json": ("file", None), + "meta/compatibility.manifest.sig": ("file", None), + "meta/payload.files.json": ("file", None), + "payload": ("directory", None), + } + for entry in payload_entries: + expected[f"payload/{entry['path']}"] = (str(entry["type"]), entry) + actual = {member.name: member for member in members} + if set(actual) != set(expected): + _fail("release archive does not exactly match its payload manifest") + for name, (expected_type, entry) in expected.items(): + member = actual[name] + if name in ("meta", "payload"): + if not member.isdir(): + _fail("release archive top-level directory type rejected") + continue + if name.startswith("meta/"): + continue + assert entry is not None + actual_type = "file" if member.isfile() else "directory" if member.isdir() else "symlink" + if actual_type != expected_type: + _fail(f"archive type differs from payload manifest: {name}") + if expected_type == "file" and member.size != entry["size"]: + _fail(f"archive file size differs from payload manifest: {name}") + if expected_type == "symlink" and member.linkname != entry["target"]: + _fail(f"archive symlink differs from payload manifest: {name}") + return ArchiveInspection( + manifest_bytes=manifest_bytes, + manifest=manifest, + signature=signature, + payload_bytes=payload_bytes, + payload_entries=payload_entries, + members=members, + ) + + def _extract_archive(self, archive: Path, inspection: ArchiveInspection, destination: Path) -> Path: + destination.mkdir(mode=0o700) + extracted_root = destination / "extracted" + extracted_root.mkdir(mode=0o700) + entries = {f"payload/{entry['path']}": entry for entry in inspection.payload_entries} + expected: dict[str, tuple[str, Mapping[str, Any] | None]] = { + "meta": ("directory", None), + "meta/compatibility.manifest.json": ("file", None), + "meta/compatibility.manifest.sig": ("file", None), + "meta/payload.files.json": ("file", None), + "payload": ("directory", None), + } + for entry in inspection.payload_entries: + expected[f"payload/{entry['path']}"] = (str(entry["type"]), entry) + metadata_digests = { + "meta/compatibility.manifest.json": _sha256_bytes(inspection.manifest_bytes), + "meta/compatibility.manifest.sig": _sha256_bytes(inspection.signature), + "meta/payload.files.json": _sha256_bytes(inspection.payload_bytes), + } + fd = os.open(archive, os.O_RDONLY | os.O_CLOEXEC | os.O_NOFOLLOW) + try: + with os.fdopen(fd, "rb", closefd=False) as stream, tarfile.open(fileobj=stream, mode="r:gz") as bundle: + members: dict[str, tarfile.TarInfo] = {} + expanded_size = 0 + for member in bundle: + if len(members) >= MAX_ARCHIVE_ENTRIES: + _fail("release archive has too many entries during extraction") + name = _canonical_relative_path(member.name, "archive member path during extraction") + if name in members: + _fail("release archive contains duplicate members during extraction") + if not ( + name in ("payload", "meta") + or name.startswith("payload/") + or name.startswith("meta/") + ): + _fail("release archive path set changed during extraction") + if member.islnk() or member.ischr() or member.isblk() or member.isfifo() or member.isdev(): + _fail("release archive contains a forbidden object during extraction") + if not (member.isfile() or member.isdir() or member.issym()): + _fail("release archive member type changed during extraction") + if member.sparse is not None: + _fail("sparse release archive member appeared during extraction") + expanded_size += member.size + if expanded_size > MAX_ARCHIVE_BYTES: + _fail("release archive expanded size exceeds the limit during extraction") + if member.issym(): + _safe_symlink_target(name, member.linkname) + members[name] = member + + if set(members) != set(expected): + _fail("release archive member set changed during extraction") + for name, (expected_type, entry) in expected.items(): + member = members[name] + actual_type = ( + "file" if member.isfile() else "directory" if member.isdir() else "symlink" + ) + if actual_type != expected_type: + _fail(f"release archive member type changed during extraction: {name}") + if name not in ("meta", "payload") and not name.startswith("meta/"): + assert entry is not None + if expected_type == "file" and member.size != entry["size"]: + _fail(f"archive file size changed during extraction: {name}") + if expected_type == "symlink" and member.linkname != entry["target"]: + _fail(f"archive symlink changed during extraction: {name}") + + for member in sorted(members.values(), key=lambda item: (item.name.count("/"), item.name.encode("utf-8"))): + target = extracted_root / member.name + parent = target.parent + parent.mkdir(mode=0o700, parents=True, exist_ok=True) + if member.isdir(): + target.mkdir(mode=0o700, exist_ok=False) + continue + if member.issym(): + continue + source = bundle.extractfile(member) + if source is None: + _fail("cannot extract signed release file") + output_fd = os.open( + target, + os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_CLOEXEC | os.O_NOFOLLOW, + 0o600, + ) + digest = hashlib.sha256() + count = 0 + try: + while True: + block = source.read(1024 * 1024) + if not block: + break + count += len(block) + if count > member.size: + _fail("archive file grew while extracting") + digest.update(block) + offset = 0 + while offset < len(block): + offset += os.write(output_fd, block[offset:]) + if count != member.size: + _fail("archive file length mismatch") + os.fdatasync(output_fd) + finally: + os.close(output_fd) + entry = entries.get(member.name) + if entry is not None: + if digest.hexdigest() != entry["sha256"]: + _fail(f"payload file digest mismatch: {entry['path']}") + os.chmod(target, entry["mode"], follow_symlinks=False) + else: + if digest.hexdigest() != metadata_digests[member.name]: + _fail(f"release archive metadata changed during extraction: {member.name}") + os.chmod(target, 0o600, follow_symlinks=False) + + for entry in inspection.payload_entries: + if entry["type"] != "symlink": + continue + target = extracted_root / "payload" / entry["path"] + os.symlink(entry["target"], target) + + payload_root = extracted_root / "payload" + meta_root = extracted_root / "meta" + for entry in inspection.payload_entries: + candidate = payload_root / entry["path"] + info = os.lstat(candidate) + if entry["type"] == "file" and ( + not stat.S_ISREG(info.st_mode) + or info.st_size != entry["size"] + ): + _fail(f"extracted file validation failed: {entry['path']}") + if entry["type"] == "directory" and not stat.S_ISDIR(info.st_mode): + _fail(f"extracted directory validation failed: {entry['path']}") + if entry["type"] == "symlink" and ( + not stat.S_ISLNK(info.st_mode) or os.readlink(candidate) != entry["target"] + ): + _fail(f"extracted symlink validation failed: {entry['path']}") + for entry in sorted( + (item for item in inspection.payload_entries if item["type"] == "directory"), + key=lambda item: str(item["path"]).count("/"), + reverse=True, + ): + os.chmod(payload_root / entry["path"], entry["mode"]) + os.chmod(payload_root, 0o755) + os.chmod(meta_root, 0o700) + _fsync_directory(meta_root) + _fsync_directory(payload_root) + _fsync_directory(extracted_root) + return extracted_root + finally: + os.close(fd) + + def _cleanup_unjournaled_transactions(self) -> None: + """Remove only recognizable private transactions when no journal owns them.""" + for candidate in sorted(self.paths.transactions.iterdir(), key=lambda item: item.name): + if re.fullmatch(r"[0-9a-f]{32}", candidate.name) is None: + _fail("unexpected unjournaled object in the transaction directory") + info = os.lstat(candidate) + if not stat.S_ISDIR(info.st_mode): + _fail("unjournaled transaction path is not a directory") + _remove_private_tree(candidate, self.paths.transactions) + + def prepare(self, archive: Path) -> Mapping[str, Any]: + archive = Path(os.path.abspath(os.fspath(archive))) + with self._lock(): + if self._load_journal() is not None: + _fail("another release transaction is pending; recover it first") + if self._load_bootstrap_journal() is not None: + _fail("a release bootstrap transaction is pending; recover it first") + self._cleanup_unjournaled_transactions() + state, current_release, current_manifest = self._validate_current_trust() + transaction_id = uuid.uuid4().hex + transaction_root = self.paths.transactions / transaction_id + transaction_root.mkdir(mode=0o700) + controlled_archive = transaction_root / "signed-release.tar.gz" + try: + archive_sha256 = self._copy_input_archive(archive, controlled_archive) + inspection = self._inspect_archive( + controlled_archive, state, current_release, current_manifest + ) + release_id = inspection.manifest["release_id"] + staged = self.paths.releases / release_id + if staged.exists() or staged.is_symlink(): + _fail("incoming release ID already exists") + persistent_fingerprint = _state_tree_fingerprint( + self.paths.model_guard_state_root + ) + incoming_manifest_sha256 = _sha256_bytes(inspection.manifest_bytes) + publication_marker = _publication_marker_bytes( + transaction_id, + release_id, + incoming_manifest_sha256, + archive_sha256, + ) + journal: dict[str, Any] = { + "archive_sha256": archive_sha256, + "format": JOURNAL_FORMAT, + "incoming_manifest_sha256": incoming_manifest_sha256, + "incoming_release_id": release_id, + "persistent_state_fingerprint": persistent_fingerprint, + "phase": "preparing", + "previous_release_id": state["current_release_id"], + "publication_marker_sha256": _sha256_bytes(publication_marker), + "transaction_id": transaction_id, + } + self._write_journal(journal) + self._interrupt("after_journal") + extracted_root = self._extract_archive( + controlled_archive, inspection, transaction_root / "work" + ) + self._interrupt("after_extract") + + payload_root = extracted_root / "payload" + meta_root = extracted_root / "meta" + os.rename(meta_root, payload_root / "meta") + trusted_key = current_release / "meta/release-public-key.pem" + key_bytes = _read_exact(trusted_key, 16 * 1024) + key_path = payload_root / "meta/release-public-key.pem" + key_fd = os.open( + key_path, + os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_CLOEXEC | os.O_NOFOLLOW, + 0o600, + ) + try: + os.write(key_fd, key_bytes) + os.fdatasync(key_fd) + finally: + os.close(key_fd) + _fsync_directory(payload_root / "meta") + + _check_release_layout(payload_root) + _check_component_hashes(payload_root, inspection.manifest) + _scan_preset_models(payload_root) + if ( + _state_tree_fingerprint(self.paths.model_guard_state_root) + != persistent_fingerprint + ): + _fail("model-guard state changed during release preparation") + + _atomic_write( + payload_root / PUBLICATION_MARKER_PATH, + publication_marker, + 0o600, + ) + self._interrupt("after_publication_marker") + journal["phase"] = "publishing" + self._write_journal(journal) + self._interrupt("before_release_publish") + _rename_noreplace(payload_root, staged) + _fsync_directory(self.paths.releases) + self._interrupt("after_release_rename") + journal["phase"] = "staged" + self._write_journal(journal) + self._interrupt("after_release_publish") + _remove_private_tree(transaction_root, self.paths.transactions) + return inspection.manifest + except BaseException: + # Before a journal exists, nobody else can discover this private + # tree for recovery. Remove that exact orphan immediately. + if not self.paths.journal_file.exists() and transaction_root.exists(): + _remove_private_tree(transaction_root, self.paths.transactions) + raise + + def activate(self) -> Path: + with self._lock(): + journal = self._load_journal() + if journal is None or journal["phase"] != "staged": + _fail("no staged release is ready for activation") + state, _, _ = self._validate_current_trust() + if state["current_release_id"] != journal["previous_release_id"]: + _fail("active release changed after staging") + if ( + _state_tree_fingerprint(self.paths.model_guard_state_root) + != journal["persistent_state_fingerprint"] + ): + _fail("model-guard state changed before activation") + staged = self._transaction_release_for_cleanup(journal) + if staged is None: + _fail("staged release publication is incomplete") + self._switch_current(journal["incoming_release_id"]) + journal = dict(journal) + journal["phase"] = "switched" + self._write_journal(journal) + self._interrupt("after_switch") + return staged + + def commit_healthy(self) -> Mapping[str, Any]: + with self._lock(): + journal = self._load_journal() + if journal is None or journal["phase"] != "switched": + _fail("no activated release is awaiting health acceptance") + previous_state = self._load_state() + if previous_state["current_release_id"] != journal["previous_release_id"]: + _fail("durable release state changed during health validation") + current_info = os.lstat(self.paths.current) + if not stat.S_ISLNK(current_info.st_mode) or os.readlink(self.paths.current) != f".releases/{journal['incoming_release_id']}": + _fail("active pointer changed during health validation") + release = self.paths.releases / journal["incoming_release_id"] + self._validate_transaction_owned_release(journal, release) + meta = release / "meta" + manifest_bytes = _read_exact( + meta / "compatibility.manifest.json", + MAX_MANIFEST_BYTES, + ) + if _sha256_bytes(manifest_bytes) != journal["incoming_manifest_sha256"]: + _fail("activated release manifest changed before commit") + manifest = _validate_compatibility_manifest( + _strict_json(manifest_bytes, MAX_MANIFEST_BYTES, "activated compatibility manifest") + ) + if ( + _state_tree_fingerprint(self.paths.model_guard_state_root) + != journal["persistent_state_fingerprint"] + ): + _fail("model-guard state changed during startup validation") + state = { + "current_release_id": manifest["release_id"], + "format": STATE_FORMAT, + "manifest_sha256": _sha256_bytes(manifest_bytes), + "release_generation": manifest["release_generation"], + "release_key_id": manifest["release_key"]["id"], + "release_public_key_sha256": manifest["release_key"]["public_key_sha256"], + } + _atomic_write( + self.paths.state_file, + _canonical_json(state), + 0o600, + ) + self._interrupt("after_state_commit") + os.unlink(self.paths.journal_file) + _fsync_directory(self.paths.state_dir) + return manifest + + def _complete_committed_journal( + self, journal: Mapping[str, Any], state: Mapping[str, Any] + ) -> Path | None: + """Finish journal cleanup when the durable incoming state already won.""" + if state["current_release_id"] != journal["incoming_release_id"]: + return None + if ( + journal["phase"] != "switched" + or state["manifest_sha256"] != journal["incoming_manifest_sha256"] + ): + _fail("durable incoming release does not match the residual journal") + if ( + _state_tree_fingerprint(self.paths.model_guard_state_root) + != journal["persistent_state_fingerprint"] + ): + _fail("model-guard persistent state changed after release commit") + self._validate_transaction_owned_release( + journal, self.paths.releases / journal["incoming_release_id"] + ) + validated_state, release, _ = self._validate_current_trust() + if validated_state["current_release_id"] != journal["incoming_release_id"]: + _fail("committed release validation selected an unexpected release") + transaction_path = self.paths.transactions / journal["transaction_id"] + if transaction_path.exists(): + _remove_private_tree(transaction_path, self.paths.transactions) + os.unlink(self.paths.journal_file) + _fsync_directory(self.paths.state_dir) + return release + + def rollback(self) -> Path: + with self._lock(): + journal = self._load_journal() + if journal is None: + state, release, _ = self._validate_current_trust() + return release + previous = journal["previous_release_id"] + incoming = journal["incoming_release_id"] + state = self._load_state() + committed = self._complete_committed_journal(journal, state) + if committed is not None: + return committed + if state["current_release_id"] != previous: + _fail("cannot rollback: durable previous release state changed") + target = os.readlink(self.paths.current) if self.paths.current.is_symlink() else "" + incoming_target = f".releases/{incoming}" + previous_target = f".releases/{previous}" + if target not in (incoming_target, previous_target): + _fail("cannot rollback an unknown active release pointer") + incoming_path = self._transaction_release_for_cleanup(journal) + if target == incoming_target and incoming_path is None: + _fail("cannot stop an active incoming release whose tree is missing") + if incoming_path is not None: + self._stop_incoming_before_cleanup(journal, incoming_path) + if target == incoming_target: + self._switch_current(previous) + if incoming_path is not None: + _remove_private_tree(incoming_path, self.paths.releases) + transaction_path = self.paths.transactions / journal["transaction_id"] + if transaction_path.exists(): + _remove_private_tree(transaction_path, self.paths.transactions) + os.unlink(self.paths.journal_file) + _fsync_directory(self.paths.state_dir) + if ( + _state_tree_fingerprint(self.paths.model_guard_state_root) + != journal["persistent_state_fingerprint"] + ): + _fail("model-guard persistent state changed across rollback") + return self.paths.releases / previous + + def recover(self) -> Path: + with self._lock(): + journal = self._load_journal() + if journal is None: + _, release, _ = self._validate_current_trust() + return release + state = self._load_state() + committed = self._complete_committed_journal(journal, state) + if committed is not None: + return committed + if state["current_release_id"] != journal["previous_release_id"]: + _fail("interrupted transaction no longer matches durable state") + target = os.readlink(self.paths.current) if self.paths.current.is_symlink() else "" + previous_target = f".releases/{journal['previous_release_id']}" + incoming_target = f".releases/{journal['incoming_release_id']}" + if target not in (incoming_target, previous_target): + _fail("interrupted transaction left an unknown active pointer") + incoming_path = self._transaction_release_for_cleanup(journal) + if target == incoming_target and incoming_path is None: + _fail("cannot stop an active incoming release whose tree is missing") + if incoming_path is not None: + self._stop_incoming_before_cleanup(journal, incoming_path) + if target == incoming_target: + self._switch_current(journal["previous_release_id"]) + if incoming_path is not None: + _remove_private_tree(incoming_path, self.paths.releases) + transaction_path = self.paths.transactions / journal["transaction_id"] + if transaction_path.exists(): + _remove_private_tree(transaction_path, self.paths.transactions) + os.unlink(self.paths.journal_file) + _fsync_directory(self.paths.state_dir) + if ( + _state_tree_fingerprint(self.paths.model_guard_state_root) + != journal["persistent_state_fingerprint"] + ): + _fail("model-guard persistent state changed across recovery") + return self.paths.releases / journal["previous_release_id"] + + def active_path(self) -> Path: + with self._lock(): + _, release, _ = self._validate_current_trust() + return release + + def pending_path(self) -> Path: + with self._lock(): + return self._pending_release_for_health() + + def pending_health_script(self) -> Path: + """Return the trusted active-release health evaluator for the pending switch.""" + with self._lock(): + self._pending_release_for_health() + health_script, expected_sha256 = ( + self._trusted_pending_health_script() + ) + with self._pinned_health_script( + health_script, expected_sha256 + ): + return health_script + + def run_pending_health(self, runner_pid_value: str, expected_release_value: Path) -> None: + """Run the trusted active-release health gate for the switched candidate.""" + if re.fullmatch(r"[0-9]+", runner_pid_value) is None: + _fail("pending health runner PID rejected") + runner_pid = int(runner_pid_value) + if runner_pid < 1 or runner_pid > (1 << 31) - 1: + _fail("pending health runner PID rejected") + expected_text = os.fspath(expected_release_value) + if not expected_text or expected_text != os.path.abspath(expected_text): + _fail("pending health release path must be absolute and canonical") + expected_release = Path(expected_text) + + with self._lock(): + release = self._pending_release_for_health() + if release != expected_release: + _fail("pending health release path does not match the transaction") + health_script, expected_sha256 = ( + self._trusted_pending_health_script() + ) + + try: + with self._pinned_health_script( + health_script, expected_sha256 + ) as (health_fd_path, health_fd): + health = subprocess.Popen( + (str(health_fd_path), str(runner_pid), str(release)), + stdin=subprocess.DEVNULL, + close_fds=True, + pass_fds=(health_fd,), + env={"LC_ALL": "C", "PATH": "/usr/sbin:/usr/bin:/sbin:/bin"}, + start_new_session=True, + ) + try: + return_code = health.wait( + timeout=CANDIDATE_HEALTH_TIMEOUT_SECONDS + ) + except subprocess.TimeoutExpired as error: + try: + os.killpg(health.pid, signal.SIGKILL) + except ProcessLookupError: + pass + except OSError as stop_error: + raise ReleaseError( + "timed-out trusted health process group could not be stopped" + ) from stop_error + try: + health.wait(timeout=HEALTH_RUNNER_STOP_TIMEOUT_SECONDS) + except subprocess.TimeoutExpired as stop_error: + raise ReleaseError( + "timed-out trusted health process could not be reaped" + ) from stop_error + raise ReleaseError( + f"pending release health check exceeded the trusted " + f"{CANDIDATE_HEALTH_TIMEOUT_SECONDS}s timeout" + ) from error + except OSError as error: + raise ReleaseError("trusted pending health check could not start") from error + if return_code != 0: + _fail("pending candidate did not pass the trusted active-release health check") + + # The trusted evaluator ran outside the updater lock. Re-check the exact + # switched transaction before allowing the trusted caller to commit it. + with self._lock(): + current_release = self._pending_release_for_health() + if current_release != release: + _fail("pending release changed during health validation") + current_health, current_sha256 = self._trusted_pending_health_script() + if ( + current_health != health_script + or current_sha256 != expected_sha256 + ): + _fail("trusted health evaluator changed during validation") + with self._pinned_health_script( + current_health, current_sha256 + ): + pass + + def _pending_release_for_health(self) -> Path: + journal = self._load_journal() + if journal is None or journal["phase"] != "switched": + _fail("no switched release is pending health validation") + state = self._load_state() + if state["current_release_id"] != journal["previous_release_id"]: + _fail("pending release no longer matches durable previous state") + try: + current_info = os.lstat(self.paths.current) + except OSError as error: + raise ReleaseError("pending release pointer is unavailable") from error + expected_target = f".releases/{journal['incoming_release_id']}" + if ( + not stat.S_ISLNK(current_info.st_mode) + or os.readlink(self.paths.current) != expected_target + ): + _fail("pending release pointer rejected") + release = self._transaction_release_for_cleanup(journal) + if release is None: + _fail("pending release publication is incomplete") + meta = release / "meta" + _directory(meta) + manifest_bytes = _read_exact( + meta / "compatibility.manifest.json", + MAX_MANIFEST_BYTES, + ) + if _sha256_bytes(manifest_bytes) != journal["incoming_manifest_sha256"]: + _fail("pending release manifest changed before health validation") + if ( + _state_tree_fingerprint(self.paths.model_guard_state_root) + != journal["persistent_state_fingerprint"] + ): + _fail("model-guard persistent state changed before health validation") + return release + + def _load_bootstrap_journal(self) -> Mapping[str, Any] | None: + if not self.paths.bootstrap_journal_file.exists(): + return None + data = _read_exact( + self.paths.bootstrap_journal_file, + MAX_MANIFEST_BYTES, + ) + value = _strict_json(data, MAX_MANIFEST_BYTES, "release bootstrap journal") + journal = _require_object( + value, + { + "format", + "incoming_manifest_sha256", + "incoming_release_id", + "persistent_state_fingerprint", + "phase", + "transaction_id", + }, + "release bootstrap journal", + ) + if journal["format"] != BOOTSTRAP_JOURNAL_FORMAT: + _fail("release bootstrap journal format rejected") + _require_string(journal["transaction_id"], "bootstrap transaction ID", r"[0-9a-f]{32}") + _validate_release_id(journal["incoming_release_id"]) + _require_hex( + journal["incoming_manifest_sha256"], + 64, + "bootstrap journal manifest sha256", + nonzero=True, + ) + _require_hex( + journal["persistent_state_fingerprint"], + 64, + "bootstrap journal persistent-state fingerprint", + ) + if journal["phase"] not in ( + "preparing", + "staged", + "stopped", + "migrating", + "switched", + "healthy", + ): + _fail("release bootstrap journal phase rejected") + return journal + + def _write_bootstrap_journal(self, journal: Mapping[str, Any]) -> None: + _atomic_write( + self.paths.bootstrap_journal_file, + _canonical_json(dict(journal)), + 0o600, + ) + + def _validate_legacy_facades(self) -> None: + for name in FACADE_DIRECTORIES: + candidate = self.paths.install_root / name + try: + info = os.lstat(candidate) + except OSError as error: + raise ReleaseError(f"legacy release facade is unavailable: {name}") from error + if not stat.S_ISDIR(info.st_mode): + _fail(f"legacy release facade is not a directory: {name}") + + def _preflight_facade_exchange(self) -> None: + """Prove renameat2(RENAME_EXCHANGE) before stopping the workload.""" + first = self.paths.state_dir / f".exchange-a-{uuid.uuid4().hex}" + second = self.paths.state_dir / f".exchange-b-{uuid.uuid4().hex}" + os.symlink("a", first) + try: + os.symlink("b", second) + _fsync_directory(self.paths.state_dir) + _rename_exchange(first, second) + if os.readlink(first) != "b" or os.readlink(second) != "a": + _fail("atomic facade exchange preflight returned an invalid result") + finally: + with contextlib.suppress(OSError): + os.unlink(first) + with contextlib.suppress(OSError): + os.unlink(second) + _fsync_directory(self.paths.state_dir) + + def _validate_signed_candidate_script(self, release: Path, name: str) -> Path: + if name not in SIGNED_CANDIDATE_SCRIPT_NAMES: + _fail("signed candidate script name rejected") + if release.parent != self.paths.releases: + _fail("signed candidate escaped the protected release directory") + _validate_release_id(release.name) + _directory(release) + scripts = release / "scripts" + _directory(scripts) + script = release / "scripts" / name + try: + _regular_file(script) + except ReleaseError as error: + raise ReleaseError( + f"signed candidate script metadata rejected: {name}" + ) from error + return script + + def _trusted_pending_health_script(self) -> tuple[Path, str]: + journal = self._load_journal() + if journal is None or journal["phase"] != "switched": + _fail("no switched release has a trusted health evaluator") + state = self._load_state() + if state["current_release_id"] != journal["previous_release_id"]: + _fail("trusted health evaluator no longer matches durable state") + trusted_release = self.paths.releases / journal["previous_release_id"] + manifest = self._validate_release_trust_against_state( + state, trusted_release + ) + payload_bytes = _read_exact( + trusted_release / "meta/payload.files.json", + MAX_PAYLOAD_MANIFEST_BYTES, + ) + if _sha256_bytes(payload_bytes) != manifest["payload_manifest_sha256"]: + _fail("trusted active-release payload manifest digest mismatch") + entries = _validate_payload_manifest( + _strict_json( + payload_bytes, + MAX_PAYLOAD_MANIFEST_BYTES, + "trusted active-release payload manifest", + ) + ) + matches = [ + entry + for entry in entries + if entry["path"] == "scripts/release_health_check.sh" + ] + if len(matches) != 1: + _fail("trusted active release has no unique health evaluator") + entry = matches[0] + if ( + entry["type"] != "file" + or entry["mode"] != 0o755 + or entry["size"] < 1 + or entry["size"] > MAX_HEALTH_SCRIPT_BYTES + ): + _fail("trusted active-release health evaluator metadata rejected") + health_script = self._validate_signed_candidate_script( + trusted_release, "release_health_check.sh" + ) + return health_script, str(entry["sha256"]) + + def _stable_bootstrap_health_script(self) -> Path: + stable_root = self.paths.install_root / ".release-bootstrap" + scripts = stable_root / "scripts" + _directory(stable_root) + _directory(scripts) + health_script = self.paths.stable_health_script + _regular_file(health_script, MAX_HEALTH_SCRIPT_BYTES) + return health_script + + @contextlib.contextmanager + def _pinned_health_script( + self, path: Path, expected_sha256: str | None + ) -> Iterator[tuple[Path, int]]: + initial = _regular_file(path, MAX_HEALTH_SCRIPT_BYTES) + if initial.st_size < 1: + _fail("trusted health evaluator is empty") + descriptor = os.open(path, os.O_RDONLY | os.O_CLOEXEC) + try: + opened = os.fstat(descriptor) + if not _same_file_snapshot(initial, opened): + _fail("trusted health evaluator changed while opening") + digest = hashlib.sha256() + offset = 0 + while offset < initial.st_size: + block = os.pread( + descriptor, + min(1024 * 1024, initial.st_size - offset), + offset, + ) + if not block: + _fail("trusted health evaluator became unreadable") + digest.update(block) + offset += len(block) + try: + path_after_read = os.stat(path) + except OSError as error: + raise ReleaseError( + "trusted health evaluator path changed while reading" + ) from error + if ( + not _same_file_snapshot(initial, os.fstat(descriptor)) + or not _same_file_snapshot(initial, path_after_read) + ): + _fail("trusted health evaluator changed while reading") + if ( + expected_sha256 is not None + and digest.hexdigest() != expected_sha256 + ): + _fail("trusted active-release health evaluator digest mismatch") + yield Path(f"/proc/self/fd/{descriptor}"), descriptor + try: + path_after_run = os.stat(path) + except OSError as error: + raise ReleaseError( + "trusted health evaluator path changed while running" + ) from error + if ( + not _same_file_snapshot(initial, os.fstat(descriptor)) + or not _same_file_snapshot(initial, path_after_run) + ): + _fail("trusted health evaluator changed while running") + finally: + os.close(descriptor) + + def _stop_incoming_before_cleanup( + self, journal: Mapping[str, Any], incoming: Path + ) -> None: + """Stop an authenticated published candidate before pointer/tree reversal.""" + if journal["phase"] != "switched": + # A preparing/publishing/staged transaction has not launched the + # candidate through the controlled start path. Running its global + # stop contract here could kill the still-active previous release. + return + if not incoming.exists() and not incoming.is_symlink(): + _fail("published incoming release tree is missing before stop") + self._run_signed_candidate_stop(incoming) + if ( + _state_tree_fingerprint(self.paths.model_guard_state_root) + != journal["persistent_state_fingerprint"] + ): + _fail("model-guard persistent state changed while stopping candidate") + + def _open_bootstrap_log(self, path: Path) -> int: + if path.parent != self.paths.state_dir: + _fail("bootstrap log path escaped the protected release state directory") + _directory(self.paths.state_dir) + descriptor = os.open( + path, + os.O_WRONLY | os.O_APPEND | os.O_CREAT | os.O_CLOEXEC | os.O_NOFOLLOW, + 0o600, + ) + info = os.fstat(descriptor) + if not stat.S_ISREG(info.st_mode): + os.close(descriptor) + _fail("bootstrap health log is not a regular file") + return descriptor + + def _preflight_bootstrap_health_gate(self, release: Path) -> BootstrapHealthPlan: + """Resolve every health-gate dependency before the signed stop boundary.""" + plan = BootstrapHealthPlan( + run_script=self._validate_signed_candidate_script(release, "run_start.sh"), + health_script=self._stable_bootstrap_health_script(), + stop_script=self._validate_signed_candidate_script(release, "stop.sh"), + log_path=self.paths.state_dir / "release-bootstrap.log", + ) + descriptor = self._open_bootstrap_log(plan.log_path) + os.close(descriptor) + return plan + + def _run_signed_candidate_stop(self, release: Path) -> None: + stop_script = self._validate_signed_candidate_script(release, "stop.sh") + try: + result = subprocess.run( + (str(stop_script),), + cwd=release / "scripts", + stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + check=False, + close_fds=True, + env={ + "COSMO_STOP_TIMEOUT_SECONDS": "15", + "INSTALLPATH": str(release), + "LC_ALL": "C", + "PATH": "/usr/sbin:/usr/bin:/sbin:/bin", + }, + timeout=30, + ) + except (OSError, subprocess.TimeoutExpired) as error: + raise ReleaseError("signed candidate stop operation failed") from error + if result.returncode != 0: + _fail("signed candidate stop script did not complete successfully") + remaining = self._managed_processes_remaining() + if remaining: + _fail( + "managed processes remain after signed candidate stop: " + + ", ".join(remaining) + ) + + @staticmethod + def _managed_processes_remaining() -> list[str]: + remaining: set[str] = set() + try: + process_entries = Path("/proc").iterdir() + for entry in process_entries: + if not entry.name.isdecimal(): + continue + try: + name = (entry / "comm").read_text(encoding="ascii").strip() + process_stat = (entry / "stat").read_text(encoding="ascii") + except (OSError, UnicodeError): + continue + closing_parenthesis = process_stat.rfind(")") + state_offset = closing_parenthesis + 2 + if ( + name in MANAGED_PROCESS_NAMES + and closing_parenthesis >= 0 + and state_offset < len(process_stat) + and process_stat[state_offset] != "Z" + ): + remaining.add(name) + except OSError as error: + raise ReleaseError("cannot inspect managed processes after stop") from error + return sorted(remaining) + + @staticmethod + def _process_owns_tcp_listener(process_id: int, port: int) -> bool: + socket_inodes: set[str] = set() + try: + descriptors = Path(f"/proc/{process_id}/fd").iterdir() + for descriptor in descriptors: + with contextlib.suppress(OSError): + target = os.readlink(descriptor) + match = re.fullmatch(r"socket:\[(\d+)\]", target) + if match is not None: + socket_inodes.add(match.group(1)) + except OSError: + return False + if not socket_inodes: + return False + expected_port = f"{port:04X}" + for table_path in (Path("/proc/net/tcp"), Path("/proc/net/tcp6")): + try: + lines = table_path.read_text(encoding="ascii").splitlines()[1:] + except (OSError, UnicodeError): + continue + for line in lines: + fields = line.split() + if ( + len(fields) >= 10 + and fields[1].upper().endswith(f":{expected_port}") + and fields[3] == "0A" + and fields[9] in socket_inodes + ): + return True + return False + + def _legacy_restart_ready(self, runner: subprocess.Popen[bytes], expected_engine: Path) -> bool: + if runner.poll() is not None: + return False + expected = os.path.realpath(expected_engine) + matching: list[int] = [] + try: + process_entries = Path("/proc").iterdir() + for entry in process_entries: + if not entry.name.isdecimal(): + continue + with contextlib.suppress(OSError): + if os.path.realpath(entry / "exe") == expected: + matching.append(int(entry.name)) + except OSError: + return False + return len(matching) == 1 and self._process_owns_tcp_listener(matching[0], 8000) + + def _stop_signed_runner( + self, release: Path, runner: subprocess.Popen[bytes], description: str + ) -> None: + stop_error: BaseException | None = None + try: + self._run_signed_candidate_stop(release) + except BaseException as error: + stop_error = error + if runner.poll() is None: + with contextlib.suppress(subprocess.TimeoutExpired): + runner.wait(timeout=HEALTH_RUNNER_STOP_TIMEOUT_SECONDS) + if runner.poll() is None: + with contextlib.suppress(ProcessLookupError): + os.killpg(runner.pid, signal.SIGTERM) + with contextlib.suppress(subprocess.TimeoutExpired): + runner.wait(timeout=HEALTH_RUNNER_STOP_TIMEOUT_SECONDS) + if runner.poll() is None: + with contextlib.suppress(ProcessLookupError): + os.killpg(runner.pid, signal.SIGKILL) + with contextlib.suppress(subprocess.TimeoutExpired): + runner.wait(timeout=HEALTH_RUNNER_STOP_TIMEOUT_SECONDS) + if stop_error is not None or runner.poll() is None: + raise ReleaseError( + f"signed candidate could not stop the {description} cleanly" + ) from stop_error + + def _run_legacy_restart_process(self, release: Path) -> None: + run_script = self._validate_signed_candidate_script(release, "run_start.sh") + stop_script = self._validate_signed_candidate_script(release, "stop.sh") + expected_engine = self.paths.install_root / "bin/cosmo-engine" + engine_info = os.lstat(expected_engine) + if ( + not stat.S_ISREG(engine_info.st_mode) + or not stat.S_IMODE(engine_info.st_mode) & 0o111 + ): + _fail("restored legacy engine is not executable") + log_path = self.paths.state_dir / "release-bootstrap.log" + log_descriptor = self._open_bootstrap_log(log_path) + runner: subprocess.Popen[bytes] | None = None + try: + runner = subprocess.Popen( + (str(run_script), "start", str(log_path)), + cwd=release / "scripts", + stdin=subprocess.DEVNULL, + stdout=log_descriptor, + stderr=log_descriptor, + close_fds=True, + env={ + "COSMO_TRUSTED_STOP_SCRIPT": str(stop_script), + "INSTALLPATH": str(self.paths.install_root), + "LC_ALL": "C", + "PATH": "/usr/sbin:/usr/bin:/sbin:/bin", + }, + start_new_session=True, + ) + except OSError as error: + raise ReleaseError("signed candidate could not start the restored legacy workload") from error + finally: + os.close(log_descriptor) + assert runner is not None + deadline = time.monotonic() + LEGACY_RESTART_TIMEOUT_SECONDS + while time.monotonic() < deadline: + if self._legacy_restart_ready(runner, expected_engine): + return + if runner.poll() is not None: + break + time.sleep(1) + self._stop_signed_runner(release, runner, "restored legacy workload") + _fail("restored legacy workload did not pass the fixed restart gate") + + def _restart_legacy_workload(self, release: Path) -> None: + self._lifecycle("bootstrap_legacy_restart_attempted") + self._run_legacy_restart_process(release) + self._lifecycle("bootstrap_legacy_restart_succeeded") + + def _recover_bootstrap_locked(self, journal: Mapping[str, Any]) -> Path | None: + release_id = str(journal["incoming_release_id"]) + transaction_id = str(journal["transaction_id"]) + staged = self.paths.releases / release_id + if self.paths.state_file.exists(): + state, release, _ = self._validate_current_trust() + if ( + state["current_release_id"] != release_id + or state["manifest_sha256"] != journal["incoming_manifest_sha256"] + ): + _fail("bootstrap state and journal describe different releases") + transaction = self.paths.transactions / transaction_id + if transaction.exists(): + _remove_private_tree(transaction, self.paths.transactions) + os.unlink(self.paths.bootstrap_journal_file) + _fsync_directory(self.paths.state_dir) + return release + + if journal["phase"] in ("switched", "healthy"): + self._run_signed_candidate_stop(staged) + + for name in FACADE_DIRECTORIES: + facade = self.paths.install_root / name + backup = self.paths.legacy_backup / name + facade_present = facade.exists() or facade.is_symlink() + backup_present = backup.exists() or backup.is_symlink() + if backup_present: + backup_info = os.lstat(backup) + if stat.S_ISLNK(backup_info.st_mode): + if ( + os.readlink(backup) != f"current/{name}" + or not facade_present + ): + _fail(f"bootstrap pre-exchange facade marker rejected: {name}") + facade_info = os.lstat(facade) + if not stat.S_ISDIR(facade_info.st_mode): + _fail(f"bootstrap pre-exchange legacy facade rejected: {name}") + os.unlink(backup) + backup_present = False + if backup_present: + backup_info = os.lstat(backup) + facade_info = os.lstat(facade) + if ( + not stat.S_ISDIR(backup_info.st_mode) + or not stat.S_ISLNK(facade_info.st_mode) + or os.readlink(facade) != f"current/{name}" + ): + _fail(f"bootstrap legacy backup rejected: {name}") + # The exchange restores the legacy facade without a single + # pathname gap. If power fails after it, the marker branch + # above removes the signed symlink left in the backup slot. + _rename_exchange(facade, backup) + _fsync_directory(self.paths.install_root) + _fsync_directory(self.paths.legacy_backup) + os.unlink(backup) + _fsync_directory(self.paths.legacy_backup) + elif facade_present: + facade_info = os.lstat(facade) + if not stat.S_ISDIR(facade_info.st_mode): + _fail(f"bootstrap recovery legacy facade rejected: {name}") + else: + _fail(f"bootstrap recovery lost facade: {name}") + + current = self.paths.current + if current.exists() or current.is_symlink(): + info = os.lstat(current) + if ( + not stat.S_ISLNK(info.st_mode) + or os.readlink(current) != f".releases/{release_id}" + ): + _fail("cannot recover a replaced bootstrap current pointer") + os.unlink(current) + + _fsync_directory(self.paths.install_root) + if self.paths.legacy_backup.exists(): + _fsync_directory(self.paths.legacy_backup) + + # Every phase at or beyond staging may have entered the signed stop + # operation. Restart the restored legacy workload with candidate-signed + # orchestration before deleting the only authenticated scripts. + if journal["phase"] != "preparing": + if not staged.exists(): + _fail("bootstrap recovery lost the signed restart candidate") + self._restart_legacy_workload(staged) + + if staged.exists(): + _remove_private_tree(staged, self.paths.releases) + transaction = self.paths.transactions / transaction_id + if transaction.exists(): + _remove_private_tree(transaction, self.paths.transactions) + if self.paths.legacy_backup.exists(): + try: + os.rmdir(self.paths.legacy_backup) + except OSError as error: + raise ReleaseError("bootstrap legacy backup did not recover completely") from error + os.unlink(self.paths.bootstrap_journal_file) + _fsync_directory(self.paths.state_dir) + return None + + def _run_bootstrap_health_gate( + self, release: Path, plan: BootstrapHealthPlan + ) -> None: + log_fd = self._open_bootstrap_log(plan.log_path) + runner: subprocess.Popen[bytes] | None = None + health_error: BaseException | None = None + healthy = False + try: + environment = { + "INSTALLPATH": str(release), + "LC_ALL": "C", + "LD_LIBRARY_PATH": f"{release}/lib:/usr/lib", + "PATH": "/usr/sbin:/usr/bin:/sbin:/bin", + } + with self._pinned_health_script( + plan.health_script, None + ) as (health_fd_path, health_fd): + runner = subprocess.Popen( + (str(plan.run_script), "start", str(plan.log_path)), + cwd=release / "scripts", + stdin=subprocess.DEVNULL, + stdout=log_fd, + stderr=log_fd, + close_fds=True, + env=environment, + start_new_session=True, + ) + health = subprocess.run( + (str(health_fd_path), str(runner.pid), str(release)), + stdin=subprocess.DEVNULL, + stdout=log_fd, + stderr=log_fd, + check=False, + close_fds=True, + pass_fds=(health_fd,), + env={ + "LC_ALL": "C", + "PATH": "/usr/sbin:/usr/bin:/sbin:/bin", + }, + timeout=90, + ) + healthy = health.returncode == 0 and runner.poll() is None + except (OSError, ReleaseError, subprocess.TimeoutExpired) as error: + health_error = error + finally: + os.close(log_fd) + if runner is not None: + # A health probe is never the long-lived service instance. Stop it + # before committing state so a successful stable installer cannot + # leave an orphan outside systemd's lifecycle. + self._stop_signed_runner(release, runner, "bootstrap health probe") + if health_error is not None: + raise ReleaseError("bootstrap candidate health validation failed") from health_error + if not healthy: + _fail("bootstrap candidate did not pass the fixed health gate") + + def bootstrap_from_embedded_verifier( + self, + archive_fd: int, + authenticated_manifest: bytes, + authenticated_signature: bytes, + public_key: bytes, + expected_raw_key: bytes, + expected_key_id: bytes, + expected_pem_sha256: bytes, + ) -> Path: + """Install the first signed release after the C++ embedded-key check. + + This is a backend API, not a command-line operation. The production + backend supplies an already-open archive FD and authenticates its peer + as the fixed C++ verifier before invoking this method. + """ + if ( + len(authenticated_manifest) == 0 + or len(authenticated_manifest) > MAX_MANIFEST_BYTES + or len(authenticated_signature) != 64 + or len(expected_raw_key) != 32 + or len(expected_key_id) != 16 + or len(expected_pem_sha256) != 32 + or len(public_key) == 0 + or len(public_key) > 16 * 1024 + ): + _fail("embedded verifier bootstrap input size rejected") + if hashlib.sha256(public_key).digest() != expected_pem_sha256: + _fail("embedded verifier PEM digest mismatch") + derived_key_id = hashlib.sha256( + b"cosmo-release-key-id-v1" + (1).to_bytes(2, "big") + expected_raw_key + ).digest()[:16] + if derived_key_id != expected_key_id or not any(expected_key_id): + _fail("embedded verifier release key ID mismatch") + + archive_info = os.fstat(archive_fd) + if ( + not stat.S_ISREG(archive_info.st_mode) + or archive_info.st_size <= 0 + or archive_info.st_size > MAX_ARCHIVE_BYTES + ): + _fail("embedded verifier archive FD type or size rejected") + + with self._lock(): + recovered = self._load_bootstrap_journal() + if recovered is not None: + committed = self._recover_bootstrap_locked(recovered) + if committed is not None: + return committed + if ( + self.paths.state_file.exists() + or self.paths.current.exists() + or self.paths.current.is_symlink() + ): + _fail("release trust is already initialized; use the ordinary updater") + if self._load_journal() is not None: + _fail("an ordinary release transaction already exists") + if any(self.paths.releases.iterdir()) or any(self.paths.transactions.iterdir()): + _fail("bootstrap release storage is not empty") + if self.paths.legacy_backup.exists() or self.paths.legacy_backup.is_symlink(): + _fail("bootstrap legacy backup path already exists") + self._validate_legacy_facades() + + persistent_fingerprint = _state_tree_fingerprint( + self.paths.model_guard_state_root + ) + transaction_id = uuid.uuid4().hex + transaction_root = self.paths.transactions / transaction_id + journal: dict[str, Any] = { + "format": BOOTSTRAP_JOURNAL_FORMAT, + "incoming_manifest_sha256": _sha256_bytes(authenticated_manifest), + "incoming_release_id": "pending", + "persistent_state_fingerprint": persistent_fingerprint, + "phase": "preparing", + "transaction_id": transaction_id, + } + # The pending value is schema-valid and is replaced by the signed + # release ID before any compatibility facade is moved. + self._write_bootstrap_journal(journal) + self._interrupt("bootstrap_after_journal") + transaction_root.mkdir(mode=0o700) + controlled_archive = transaction_root / "signed-release.tar.gz" + destination_fd = os.open( + controlled_archive, + os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_CLOEXEC | os.O_NOFOLLOW, + 0o600, + ) + try: + os.lseek(archive_fd, 0, os.SEEK_SET) + copied = 0 + while True: + block = os.read(archive_fd, 1024 * 1024) + if not block: + break + copied += len(block) + if copied > archive_info.st_size: + _fail("bootstrap archive grew while copying") + offset = 0 + while offset < len(block): + offset += os.write(destination_fd, block[offset:]) + if copied != archive_info.st_size: + _fail("bootstrap archive size changed while copying") + os.fdatasync(destination_fd) + finally: + os.close(destination_fd) + current_archive_info = os.fstat(archive_fd) + if ( + current_archive_info.st_dev, + current_archive_info.st_ino, + current_archive_info.st_size, + current_archive_info.st_mtime_ns, + current_archive_info.st_ctime_ns, + ) != ( + archive_info.st_dev, + archive_info.st_ino, + archive_info.st_size, + archive_info.st_mtime_ns, + archive_info.st_ctime_ns, + ): + _fail("bootstrap archive inode changed during verification") + + trust_root = transaction_root / "embedded-trust/meta" + trust_root.mkdir(mode=0o700, parents=True) + trusted_key = trust_root / "release-public-key.pem" + key_fd = os.open( + trusted_key, + os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_CLOEXEC | os.O_NOFOLLOW, + 0o600, + ) + try: + offset = 0 + while offset < len(public_key): + offset += os.write(key_fd, public_key[offset:]) + os.fdatasync(key_fd) + finally: + os.close(key_fd) + _fsync_directory(trust_root) + + bootstrap_state = { + "release_generation": 0, + "release_key_id": expected_key_id.hex(), + "release_public_key_sha256": expected_pem_sha256.hex(), + } + inspection = self._inspect_archive( + controlled_archive, + bootstrap_state, + transaction_root / "embedded-trust", + {"release_id": "bootstrap-anchor"}, + ) + if ( + inspection.manifest_bytes != authenticated_manifest + or inspection.signature != authenticated_signature + ): + _fail("C++-authenticated metadata differs from the controlled archive") + if ( + inspection.manifest["release_key"]["id"] != expected_key_id.hex() + or inspection.manifest["release_key"]["public_key_sha256"] + != expected_pem_sha256.hex() + ): + _fail("signed manifest does not select the embedded release key") + + release_id = str(inspection.manifest["release_id"]) + journal["incoming_release_id"] = release_id + self._write_bootstrap_journal(journal) + work_root = transaction_root / "work" + extracted_root = self._extract_archive(controlled_archive, inspection, work_root) + payload_root = extracted_root / "payload" + meta_root = extracted_root / "meta" + os.rename(meta_root, payload_root / "meta") + installed_key = payload_root / "meta/release-public-key.pem" + installed_key_fd = os.open( + installed_key, + os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_CLOEXEC | os.O_NOFOLLOW, + 0o600, + ) + try: + offset = 0 + while offset < len(public_key): + offset += os.write(installed_key_fd, public_key[offset:]) + os.fdatasync(installed_key_fd) + finally: + os.close(installed_key_fd) + _fsync_directory(payload_root / "meta") + + _check_release_layout(payload_root) + _check_component_hashes(payload_root, inspection.manifest) + _scan_preset_models(payload_root) + if ( + _state_tree_fingerprint(self.paths.model_guard_state_root) + != persistent_fingerprint + ): + _fail("model-guard state changed during bootstrap preparation") + + staged = self.paths.releases / release_id + if staged.exists() or staged.is_symlink(): + _fail("bootstrap release ID already exists") + _rename_noreplace(payload_root, staged) + _fsync_directory(self.paths.releases) + journal["phase"] = "staged" + self._write_bootstrap_journal(journal) + self._interrupt("bootstrap_after_staged") + + # Resolve all authenticated scripts, the protected log inode, and + # the required kernel primitive while the legacy workload and all + # of its paths are still intact. + health_plan = self._preflight_bootstrap_health_gate(staged) + self._preflight_facade_exchange() + + # Stop the legacy workload with code from the authenticated, + # extracted candidate before moving even one legacy facade. No + # unsigned legacy script participates in this trust transition. + self._run_signed_candidate_stop(staged) + journal["phase"] = "stopped" + self._write_bootstrap_journal(journal) + self._interrupt("bootstrap_after_stop") + + self.paths.legacy_backup.mkdir(mode=0o700) + journal["phase"] = "migrating" + self._write_bootstrap_journal(journal) + self._switch_current(release_id) + for name in FACADE_DIRECTORIES: + facade = self.paths.install_root / name + backup = self.paths.legacy_backup / name + os.symlink(f"current/{name}", backup) + _fsync_directory(self.paths.legacy_backup) + _rename_exchange(backup, facade) + facade_info = os.lstat(facade) + backup_info = os.lstat(backup) + if ( + not stat.S_ISLNK(facade_info.st_mode) + or os.readlink(facade) != f"current/{name}" + or not stat.S_ISDIR(backup_info.st_mode) + ): + _fail(f"per-facade atomic bootstrap migration rejected: {name}") + _fsync_directory(self.paths.legacy_backup) + _fsync_directory(self.paths.install_root) + self._interrupt(f"bootstrap_after_facade_{name}") + journal["phase"] = "switched" + self._write_bootstrap_journal(journal) + self._interrupt("bootstrap_after_switch") + + try: + self._run_bootstrap_health_gate(staged, health_plan) + if ( + _state_tree_fingerprint(self.paths.model_guard_state_root) + != persistent_fingerprint + ): + _fail("model-guard state changed during bootstrap health validation") + journal["phase"] = "healthy" + self._write_bootstrap_journal(journal) + self._interrupt("bootstrap_after_health") + state = { + "current_release_id": release_id, + "format": STATE_FORMAT, + "manifest_sha256": _sha256_bytes(inspection.manifest_bytes), + "release_generation": inspection.manifest["release_generation"], + "release_key_id": inspection.manifest["release_key"]["id"], + "release_public_key_sha256": inspection.manifest["release_key"][ + "public_key_sha256" + ], + } + _atomic_write( + self.paths.state_file, + _canonical_json(state), + 0o600, + ) + self._interrupt("bootstrap_after_state") + _remove_private_tree(transaction_root, self.paths.transactions) + os.unlink(self.paths.bootstrap_journal_file) + _fsync_directory(self.paths.state_dir) + return staged + except BaseException: + current_journal = self._load_bootstrap_journal() + if current_journal is not None: + committed = self._recover_bootstrap_locked(current_journal) + if committed is not None: + return committed + raise + + def recover_failed_bootstrap(self) -> Path | None: + """Recover the one fixed-root bootstrap journal after a rejected run.""" + with self._lock(): + journal = self._load_bootstrap_journal() + if journal is None: + return None + return self._recover_bootstrap_locked(journal) + +def _production_main(argv: Sequence[str]) -> int: + parser = argparse.ArgumentParser(description="Apply a signed Cosmo compatibility release") + subparsers = parser.add_subparsers(dest="command", required=True) + prepare = subparsers.add_parser("prepare") + prepare.add_argument("archive") + subparsers.add_parser("activate") + subparsers.add_parser("commit-healthy") + subparsers.add_parser("rollback") + subparsers.add_parser("recover") + subparsers.add_parser("active-path") + subparsers.add_parser("pending-path") + subparsers.add_parser("pending-health-script") + run_pending_health = subparsers.add_parser("run-pending-health") + run_pending_health.add_argument("runner_pid") + run_pending_health.add_argument("expected_release") + arguments = parser.parse_args(argv) + updater = ReleaseUpdater(PRODUCTION_PATHS) + try: + if arguments.command == "prepare": + manifest = updater.prepare(Path(arguments.archive)) + print(manifest["release_id"]) + elif arguments.command == "activate": + print(updater.activate()) + elif arguments.command == "commit-healthy": + manifest = updater.commit_healthy() + print(manifest["release_id"]) + elif arguments.command == "rollback": + print(updater.rollback()) + elif arguments.command == "recover": + print(updater.recover()) + elif arguments.command == "active-path": + print(updater.active_path()) + elif arguments.command == "pending-path": + print(updater.pending_path()) + elif arguments.command == "pending-health-script": + print(updater.pending_health_script()) + elif arguments.command == "run-pending-health": + updater.run_pending_health( + arguments.runner_pid, Path(arguments.expected_release) + ) + else: + _fail("unsupported release updater command") + except ReleaseError as error: + print(f"release updater rejected operation: {error}", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + if sys.flags.isolated != 1: + print( + "release updater must be launched with /usr/bin/python3 -I -B", + file=sys.stderr, + ) + raise SystemExit(1) + raise SystemExit(_production_main(sys.argv[1:])) diff --git a/scripts/release_updater.sh b/scripts/release_updater.sh new file mode 100755 index 000000000..118e8129d --- /dev/null +++ b/scripts/release_updater.sh @@ -0,0 +1,15 @@ +#!/bin/bash +set -eu +IFS=$' \t\n' +PATH='/usr/sbin:/usr/bin:/sbin:/bin' +export IFS PATH + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)" +implementation="${SCRIPT_DIR}/release_updater.py" + +if [ ! -f "$implementation" ]; then + echo "Signed release updater implementation is unavailable" >&2 + exit 1 +fi + +exec /usr/bin/python3 -I -B "$implementation" "$@" diff --git a/scripts/run_start.sh b/scripts/run_start.sh index a30e2156a..175ae70eb 100644 --- a/scripts/run_start.sh +++ b/scripts/run_start.sh @@ -56,7 +56,12 @@ NGINX_CONF="${NGINX_PREFIX}/conf/nginx.conf" # Stop all running processes before starting (including nginx) cosmo_log "$logTag" "Stopping all running processes before start..." "$logFile" -"${INSTALLPATH}/scripts/stop.sh" +TRUSTED_STOP_SCRIPT="${COSMO_TRUSTED_STOP_SCRIPT:-${INSTALLPATH}/scripts/stop.sh}" +if [ ! -f "${TRUSTED_STOP_SCRIPT}" ] || [ ! -x "${TRUSTED_STOP_SCRIPT}" ]; then + cosmo_log "$logTag" "Stop script is unavailable" "$logFile" + exit 1 +fi +"${TRUSTED_STOP_SCRIPT}" # Add iptables rule (idempotent - skips if already exists) if hash iptables 2>/dev/null; then diff --git a/scripts/source_health_check.sh b/scripts/source_health_check.sh new file mode 100755 index 000000000..855e84519 --- /dev/null +++ b/scripts/source_health_check.sh @@ -0,0 +1,45 @@ +#!/bin/bash +set -eu + +IFS=$' \t\n' +PATH='/usr/sbin:/usr/bin:/sbin:/bin' +export IFS PATH + +readonly RUNTIME_ROOT='/appfs/cosmo_wander/cwai_data' +readonly HTTP_PORT=8000 + +fail() { + echo "[SOURCE-HEALTH] ERROR: $*" >&2 + exit 1 +} + +[ "$#" -eq 0 ] || fail "this health check accepts no arguments" +[ -x "${RUNTIME_ROOT}/bin/cosmo-engine" ] || + fail "cosmo-engine is missing" +[ -f "${RUNTIME_ROOT}/lib/libcosmo_model_guard.so.2.0.0" ] || + fail "Model Guard library is missing" +pidof cosmo-engine >/dev/null 2>&1 || + fail "cosmo-engine is not running" + +[ -x /usr/bin/python3 ] || fail "python3 is unavailable" +/usr/bin/python3 -I -B -c ' +import socket + +for family, address in ( + (socket.AF_INET, ("127.0.0.1", 8000)), + (socket.AF_INET6, ("::1", 8000, 0, 0)), +): + try: + with socket.socket(family, socket.SOCK_STREAM) as connection: + connection.settimeout(1.0) + connection.connect(address) + connection.sendall( + b"HEAD / HTTP/1.0\r\nHost: localhost\r\nConnection: close\r\n\r\n" + ) + if connection.recv(64).startswith(b"HTTP/"): + raise SystemExit(0) + except OSError: + pass +raise SystemExit(1) +' /dev/null 2>&1 || + fail "HTTP endpoint on port 8000 did not respond" diff --git a/scripts/source_run_start.sh b/scripts/source_run_start.sh new file mode 100755 index 000000000..78f4d0bd9 --- /dev/null +++ b/scripts/source_run_start.sh @@ -0,0 +1,24 @@ +#!/bin/bash +set -euo pipefail + +IFS=$' \t\n' +PATH='/usr/sbin:/usr/bin:/sbin:/bin' +export IFS PATH + +# SOURCE packages deliberately bypass `start.sh`, `current`, and the signed +# release updater. They run only the application tree selected by the local +# root operator through install-device.sh. +readonly SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)" +readonly INSTALL_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd -P)" + +# shellcheck source=common.sh +. "${SCRIPT_DIR}/common.sh" + +ensure_runtime_dirs +mkdir -p "$COSMO_LOG_DIR" +readonly LOG_FILE="${COSMO_LOG_DIR}/SOURCE_RUN.log" + +cosmo_log "SOURCE_BOOT" "Starting directly from ${INSTALL_ROOT}" "$LOG_FILE" +INSTALLPATH="$INSTALL_ROOT" \ +COSMO_TRUSTED_STOP_SCRIPT="${SCRIPT_DIR}/stop.sh" \ + exec "${SCRIPT_DIR}/run_start.sh" start "$LOG_FILE" diff --git a/scripts/start.sh b/scripts/start.sh old mode 100644 new mode 100755 index e7e21873a..96bf738bc --- a/scripts/start.sh +++ b/scripts/start.sh @@ -1,214 +1,196 @@ #!/bin/bash -set -e +set -eu +IFS=$' \t\n' +PATH='/usr/sbin:/usr/bin:/sbin:/bin' +export IFS PATH -# Upgrade orchestrator - log rotation, OTA upgrade detection, MD5 verify, install, start. -# Called by inte_run_start.sh at system boot. +# Boot/start orchestrator for signed, versioned releases. Upgrade archives are +# parsed and verified only by the updater from the currently trusted release. -SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)" # shellcheck source=common.sh . "${SCRIPT_DIR}/common.sh" -if [ -z "${INSTALLPATH}" ]; then - INSTALLPATH="$(cd "${SCRIPT_DIR}/../" && pwd)" - echo "INSTALLPATH=${INSTALLPATH}" -fi - -# Ensure all runtime directories exist +legacy_install_path="${COSMO_INSTALL_DIR}" ensure_runtime_dirs -# Rotate nginx logs mv -f "${COSMO_LOG_DIR}/nginx_access.log" "${COSMO_LOG_DIR}/nginx_access_last.log" 2>/dev/null || true mv -f "${COSMO_LOG_DIR}/nginx_error.log" "${COSMO_LOG_DIR}/nginx_error_last.log" 2>/dev/null || true -# ── Log rotation ── -# Maintains up to 10 rotated log files: INTE_RUN.1 .. INTE_RUN.10 -# The current active log is always named INTE_RUN_now. -nowLogFileDefault="${COSMO_LOG_DIR}/INTE_RUN_now.1" -nowLogFile="$nowLogFileDefault" - -getNowFile() { - local latest="" - for f in "${COSMO_LOG_DIR}"/INTE_RUN_now.*; do - [ -f "$f" ] && latest="$f" - done - if [ -n "$latest" ]; then - nowLogFile="$latest" - return 0 +now_log_file="${COSMO_LOG_DIR}/INTE_RUN_now.1" +for candidate_log in "${COSMO_LOG_DIR}"/INTE_RUN_now.*; do + if [ -f "$candidate_log" ]; then + now_log_file="$candidate_log" fi - return 1 -} - -getNowFile || true -nowFileIndex="${nowLogFile##*.}" -nextFileIndex=$((nowFileIndex + 1)) - -if [ "$nextFileIndex" -gt 10 ]; then - nextFileIndex=1 +done +now_index="${now_log_file##*.}" +case "$now_index" in + ''|*[!0-9]*) now_index=1 ;; +esac +next_index=$((now_index + 1)) +if [ "$next_index" -gt 10 ]; then + next_index=1 fi - -if [ -f "${nowLogFile}" ]; then - mv -f "$nowLogFile" "${COSMO_LOG_DIR}/INTE_RUN.$nowFileIndex" - nowLogFile="${COSMO_LOG_DIR}/INTE_RUN_now.$nextFileIndex" +if [ -f "$now_log_file" ]; then + mv -f "$now_log_file" "${COSMO_LOG_DIR}/INTE_RUN.${now_index}" + now_log_file="${COSMO_LOG_DIR}/INTE_RUN_now.${next_index}" fi -echo "Log file: $nowLogFile" +log_tag="INTE_RUN" +log_file="$now_log_file" +action="${1:-}" +cosmo_log "$log_tag" "Start action=${action}" "$log_file" -logTag="INTE_RUN" -logFile="$nowLogFile" - -action="$1" - -cosmo_log "$logTag" "In start.sh, action=${action}" "$logFile" - -# Clean previous upgrade sign, convert HW upgrade sign if present rm -f "$COSMO_UPGRADE_SIGN" -if [ -f "${COSMO_HW_UPGRADE_SIGN}" ]; then - # Convert HW upgrade marker to upgrade-success marker for MQTT reporting +if [ -f "$COSMO_HW_UPGRADE_SIGN" ]; then mv -f "$COSMO_HW_UPGRADE_SIGN" "$COSMO_UPGRADE_SIGN" - cosmo_log "$logTag" "HW upgrade detected, marker converted." "$logFile" + cosmo_log "$log_tag" "Hardware upgrade marker converted" "$log_file" fi -# Handle stop action if [ "$action" = "stop" ]; then - "${INSTALLPATH}/scripts/stop.sh" - cosmo_log "$logTag" "Stop action completed." "$logFile" + "${SCRIPT_DIR}/stop.sh" exit 0 fi +if [ "$action" != "start" ]; then + cosmo_log "$log_tag" "Unsupported action: ${action}" "$log_file" + exit 2 +fi -# ── OTA upgrade detection ── -TARGZ_SUFFIX="tar.gz" -INSTALL_TYPE="" -EXIST_IF="unexists" -DIRECTORY_STATIC="${COSMO_UPGRADE_DIR}" -DIRECTORY_SHELL="${INSTALLPATH}/scripts/" -START_SHELL_PATH="${INSTALLPATH}/scripts/run_start.sh" - -# Regex pattern for full package name -# Example: cosmo-V1.1.0-52d08574819464a735d4b0a90f26c924.tar.gz -TARGZ_PATTERN='^cosmo-[Vv][0-9]{1,}\.[0-9]{1,}\.[0-9]{1,}-[0-9a-fA-F]{32}\.tar\.gz$' - -# Execute run_start.sh and exit -RUN() { - cosmo_log "$logTag" "[RUN] Before starting run_start.sh" "$logFile" - rm -rf "${DIRECTORY_STATIC:?}"/* - if [ "$action" = "start" ]; then - cd "$DIRECTORY_SHELL" || exit 1 - cosmo_log "$logTag" "[RUN] Executing $START_SHELL_PATH" "$logFile" - sh "$START_SHELL_PATH" start "$logFile" - fi - cosmo_log "$logTag" "Script ended." "$logFile" - exit 0 +run_foreground() { + release_root="$1" + cosmo_log "$log_tag" "Starting active release $(basename "$release_root")" "$log_file" + cd "${release_root}/scripts" + INSTALLPATH="$release_root" exec "${release_root}/scripts/run_start.sh" start "$log_file" } -# Check the legality of package name -# $1: filename string, $2: regex pattern -checkFileName() { - cosmo_log "$logTag" "Checking filename legality: $1" "$logFile" - local regex_ret - regex_ret=$(echo "$1" | grep -E "$2") || true - if [ -n "${regex_ret}" ]; then - cosmo_log "$logTag" "Valid filename." "$logFile" - return 0 +run_candidate_with_health_gate() { + release_root="$1" + archive="$2" + if ! "${SCRIPT_DIR}/install.sh" pending-health-script >/dev/null 2>> "$log_file"; then + cosmo_log "$log_tag" "Candidate health script validation failed; rolling back" "$log_file" + previous_release="$("${SCRIPT_DIR}/install.sh" rollback 2>> "$log_file")" + cosmo_log "$log_tag" "Rollback restored $(basename "$previous_release")" "$log_file" + run_foreground "$previous_release" + fi + cosmo_log "$log_tag" "Starting candidate release $(basename "$release_root")" "$log_file" + ( + cd "${release_root}/scripts" + INSTALLPATH="$release_root" exec "${release_root}/scripts/run_start.sh" start "$log_file" + ) & + runner_pid=$! + + if "${SCRIPT_DIR}/install.sh" run-pending-health \ + "$runner_pid" "$release_root" >> "$log_file" 2>&1; then + if "${SCRIPT_DIR}/install.sh" commit-healthy >> "$log_file" 2>&1; then + mkdir -p "$(dirname "$COSMO_UPGRADE_SIGN")" + : > "$COSMO_UPGRADE_SIGN" + sync + rm -f -- "$archive" + cosmo_log "$log_tag" "Candidate startup accepted and release committed" "$log_file" + wait "$runner_pid" + return $? + fi + cosmo_log "$log_tag" "Durable release commit failed; rolling back" "$log_file" else - cosmo_log "$logTag" "$1 file format error!" "$logFile" - return 1 + cosmo_log "$log_tag" "Candidate startup health failed; rolling back" "$log_file" fi + + # The currently trusted updater validates and executes the signed candidate + # stop script before it reverses the pointer or removes the incoming tree. + previous_release="$("${SCRIPT_DIR}/install.sh" rollback 2>> "$log_file")" + cosmo_log "$log_tag" "Rollback restored $(basename "$previous_release")" "$log_file" + run_foreground "$previous_release" } -# Validate that extracted directory has the expected package layout -hasUpgradePackageLayout() { - local root="$1" - for dir in bin files font scripts web; do - if [ ! -d "$root/$dir" ]; then - cosmo_log "$logTag" "Missing required package directory: $root/$dir" "$logFile" - return 1 - fi +find_signed_release_archive() { + signed_archive_count=0 + signed_archive="" + for candidate in "${COSMO_UPGRADE_DIR}"/*.tar.gz; do + [ -f "$candidate" ] || continue + signed_archive_count=$((signed_archive_count + 1)) + signed_archive="$candidate" done - return 0 } -if [ ! -d "$DIRECTORY_STATIC" ]; then - RUN - # NOTE: RUN() calls exit, code below is unreachable -fi - -# Scan for upgrade package -cosmo_log "$logTag" "Checking for upgrade package..." "$logFile" -for FILENAME_WHOLE in "$DIRECTORY_STATIC"/*; do - FILE_NAME_WITHOUT_PATH=$(basename "${FILENAME_WHOLE}") - if [ "${FILE_NAME_WITHOUT_PATH}" != "*" ] && echo "$FILE_NAME_WITHOUT_PATH" | grep -q "\.${TARGZ_SUFFIX}$"; then - if checkFileName "$FILE_NAME_WITHOUT_PATH" "$TARGZ_PATTERN"; then - INSTALL_TYPE="install" - EXIST_IF="exists" - break +release_state="${COSMO_RELEASE_STATE_DIR}/compatibility.state.json" +if [ ! -f "$release_state" ]; then + # First-release recovery and install always enter through the stable, + # embedded-key verifier outside the movable facades. An interrupted + # migration is reconciled before inspecting a new archive. + factory_bootstrap="${COSMO_INSTALL_DIR}/.release-bootstrap/bin/cosmo-release-bootstrap" + bootstrap_journal="${COSMO_RELEASE_STATE_DIR}/bootstrap-transaction.json" + if [ -f "$bootstrap_journal" ]; then + if [ ! -x "$factory_bootstrap" ]; then + cosmo_log "$log_tag" "Factory recovery journal exists but stable verifier is unavailable" "$log_file" + exit 1 + fi + cosmo_log "$log_tag" "Recovering interrupted factory release migration" "$log_file" + if ! "$factory_bootstrap" recover >> "$log_file" 2>&1; then + cosmo_log "$log_tag" "Factory release recovery failed closed" "$log_file" + exit 1 + fi + if [ -f "$release_state" ]; then + exec "${COSMO_RELEASE_CURRENT}/scripts/start.sh" start fi fi -done -cosmo_log "$logTag" "INSTALL_TYPE: ${INSTALL_TYPE}" "$logFile" + find_signed_release_archive -if [ "$EXIST_IF" = "exists" ]; then - cosmo_log "$logTag" "Upgrade package found: $FILENAME_WHOLE" "$logFile" -else - cosmo_log "$logTag" "No upgrade package found, starting normally." "$logFile" - RUN + if [ "$signed_archive_count" -gt 1 ]; then + cosmo_log "$log_tag" "Ambiguous first-release archive set; retaining legacy release" "$log_file" + run_foreground "$legacy_install_path" + fi + if [ "$signed_archive_count" -eq 1 ]; then + if [ ! -x "$factory_bootstrap" ]; then + cosmo_log "$log_tag" "Signed release found but stable embedded-key verifier is absent" "$log_file" + run_foreground "$legacy_install_path" + fi + cosmo_log "$log_tag" "Installing first signed compatibility release" "$log_file" + if "$factory_bootstrap" install "$signed_archive" >> "$log_file" 2>&1; then + rm -f -- "$signed_archive" + sync + if [ ! -f "$release_state" ] || [ ! -x "${COSMO_RELEASE_CURRENT}/scripts/start.sh" ]; then + cosmo_log "$log_tag" "Factory verifier returned without a complete active release" "$log_file" + exit 1 + fi + exec "${COSMO_RELEASE_CURRENT}/scripts/start.sh" start + fi + cosmo_log "$log_tag" "First signed release rejected; recovering legacy release" "$log_file" + if [ -f "$bootstrap_journal" ] && ! "$factory_bootstrap" recover >> "$log_file" 2>&1; then + cosmo_log "$log_tag" "Factory rollback failed closed" "$log_file" + exit 1 + fi + fi + run_foreground "$legacy_install_path" fi -# ── MD5 verification ── -cosmo_log "$logTag" "Verifying MD5 checksum..." "$logFile" -MD5_VALUE_IN_FILENAME="${FILENAME_WHOLE%.tar.gz}" -MD5_VALUE_IN_FILENAME="${MD5_VALUE_IN_FILENAME##*-}" -MD5_VALUE_IN_FILENAME=$(echo "$MD5_VALUE_IN_FILENAME" | tr 'A-F' 'a-f') -cosmo_log "$logTag" "MD5 from filename: $MD5_VALUE_IN_FILENAME" "$logFile" - -REAL_MD5_VALUE=$(/usr/bin/md5sum "${FILENAME_WHOLE}") -REAL_MD5_VALUE="${REAL_MD5_VALUE:0:${#MD5_VALUE_IN_FILENAME}}" -cosmo_log "$logTag" "MD5 computed: $REAL_MD5_VALUE" "$logFile" - -if [ "$MD5_VALUE_IN_FILENAME" = "$REAL_MD5_VALUE" ]; then - cosmo_log "$logTag" "MD5 verified. Proceeding with upgrade..." "$logFile" -else - cosmo_log "$logTag" "MD5 mismatch! Discarding package." "$logFile" - RUN +if ! active_release="$("${SCRIPT_DIR}/install.sh" recover 2>> "$log_file")"; then + cosmo_log "$log_tag" "Release recovery failed closed" "$log_file" + exit 1 fi -# Stop all processes before upgrade -cosmo_log "$logTag" "Stopping processes for upgrade..." "$logFile" -"${INSTALLPATH}/scripts/stop.sh" - -# Extract upgrade package -cosmo_log "$logTag" "Extracting upgrade package..." "$logFile" -tar -zxf "$FILENAME_WHOLE" -C "$DIRECTORY_STATIC" - -# Detect package layout (flat or nested directory) -if hasUpgradePackageLayout "$DIRECTORY_STATIC"; then - PACKAGE_ROOT="$DIRECTORY_STATIC" -else - # Find the top-level directory extracted by tar - UNZIP_DIRNAME="" - for d in "$DIRECTORY_STATIC"/*/; do - if [ -d "$d" ]; then - UNZIP_DIRNAME="$(basename "$d")" - break - fi - done - PACKAGE_ROOT="$DIRECTORY_STATIC/$UNZIP_DIRNAME" - if ! hasUpgradePackageLayout "$PACKAGE_ROOT"; then - cosmo_log "$logTag" "Upgrade package layout error, discarding." "$logFile" - RUN - fi +find_signed_release_archive + +if [ "$signed_archive_count" -eq 0 ]; then + run_foreground "$active_release" +fi +if [ "$signed_archive_count" -ne 1 ]; then + cosmo_log "$log_tag" "Ambiguous release archive set; exactly one signed archive is required" "$log_file" + run_foreground "$active_release" fi -cosmo_log "$logTag" "PACKAGE_ROOT: $PACKAGE_ROOT" "$logFile" -cd "$PACKAGE_ROOT" || exit 1 -cosmo_log "$logTag" "Extraction complete." "$logFile" +cosmo_log "$log_tag" "Preflighting signed release archive" "$log_file" +if ! "${SCRIPT_DIR}/install.sh" prepare "$signed_archive" "$log_file" >> "$log_file" 2>&1; then + cosmo_log "$log_tag" "Release archive rejected; retaining active release" "$log_file" + run_foreground "$active_release" +fi -# Run install script from the upgrade package -cosmo_log "$logTag" "Running install.sh from upgrade package..." "$logFile" -cd "$PACKAGE_ROOT/scripts/" || exit 1 -sh "$PACKAGE_ROOT/scripts/install.sh" "$logFile" -cosmo_log "$logTag" "install.sh completed." "$logFile" +"${active_release}/scripts/stop.sh" +if ! candidate_release="$("${SCRIPT_DIR}/install.sh" activate 2>> "$log_file")"; then + cosmo_log "$log_tag" "Release activation failed; recovering previous release" "$log_file" + active_release="$("${SCRIPT_DIR}/install.sh" recover)" + run_foreground "$active_release" +fi -# Start services -RUN +run_candidate_with_health_gate "$candidate_release" "$signed_archive" diff --git a/scripts/stop.sh b/scripts/stop.sh index a8fea4fc3..527809f26 100644 --- a/scripts/stop.sh +++ b/scripts/stop.sh @@ -1,4 +1,8 @@ #!/bin/bash +set -eu +IFS=$' \t\n' +PATH='/usr/sbin:/usr/bin:/sbin:/bin' +export IFS PATH # Stop managed processes gracefully (SIGTERM first, then SIGKILL) PROC_LIST="cosmo-engine srs nginx" @@ -17,6 +21,9 @@ graceful_timeout="${COSMO_STOP_TIMEOUT_SECONDS:-15}" case "$graceful_timeout" in ''|*[!0-9]*) graceful_timeout=15 ;; esac +if [ "$graceful_timeout" -lt 1 ] || [ "$graceful_timeout" -gt 60 ]; then + graceful_timeout=15 +fi elapsed=0 while [ "$elapsed" -lt "$graceful_timeout" ]; do @@ -42,3 +49,25 @@ for proc in $PROC_LIST; do kill -9 $pids 2>/dev/null || true fi done + +# A successful stop result is a migration security boundary. Do not let the +# release transaction move any facade while a managed process can still be +# executing through the legacy tree. +force_elapsed=0 +while [ "$force_elapsed" -lt 5 ]; do + any_running=0 + for proc in $PROC_LIST; do + if pidof "$proc" >/dev/null 2>&1; then + any_running=1 + break + fi + done + if [ "$any_running" -eq 0 ]; then + exit 0 + fi + sleep 1 + force_elapsed=$((force_elapsed + 1)) +done + +echo "Managed processes remain after forced shutdown; refusing release migration" >&2 +exit 1 diff --git a/scripts/verify_model_guard_v2_sdk.py b/scripts/verify_model_guard_v2_sdk.py new file mode 100644 index 000000000..21c7cd2b2 --- /dev/null +++ b/scripts/verify_model_guard_v2_sdk.py @@ -0,0 +1,218 @@ +#!/usr/bin/env python3 +"""Check that the Model Guard SDK contains the interface CosmoEdge uses.""" + +from __future__ import annotations + +import argparse +import hashlib +import os +import pathlib +import re +import subprocess +import sys +from typing import NoReturn + + +EXPECTED_V2_EXPORTS = { + "CmgV2CloseArtifact@@CMG_2.0", + "CmgV2GetArtifactInfo@@CMG_2.0", + "CmgV2LoadSophonSegment@@CMG_2.0", + "CmgV2OpenArtifact@@CMG_2.0", +} +REQUIRED_HEADER_LINES = { + "#define CMG_V2_ABI_MAJOR UINT32_C(2)", + "#define CMG_V2_ARTIFACT_INFO_SIZE UINT32_C(72)", + "#define CMG_V2_SOPHON_LOAD_OPTIONS_SIZE UINT32_C(16)", +} +REQUIRED_HEADER_FUNCTIONS = ( + "CmgV2OpenArtifact", + "CmgV2GetArtifactInfo", + "CmgV2LoadSophonSegment", + "CmgV2CloseArtifact", +) +ADMISSION_PUBLIC_RUNTIME = "public-runtime" +ADMISSION_PRODUCTION_RELEASE = "production-release" +ADMISSION_TEST_FIXTURE = "test-fixture" +ADMISSION_PROFILES = ( + ADMISSION_PUBLIC_RUNTIME, + ADMISSION_PRODUCTION_RELEASE, + ADMISSION_TEST_FIXTURE, +) +TEST_FIXTURE_MARKER_NAME = "TEST_FIXTURE_DO_NOT_DEPLOY" +TEST_FIXTURE_MARKER_CONTENT = b"COSMO_MODEL_GUARD_V2_TEST_FIXTURE_DO_NOT_DEPLOY\n" + + +def fail(message: str) -> NoReturn: + raise RuntimeError(message) + + +def checked_directory(path: pathlib.Path) -> None: + if not path.is_dir(): + fail(f"SDK directory is missing: {path}") + + +def checked_file( + path: pathlib.Path, + maximum_size: int, + *, + allow_empty: bool = False, +) -> bytes: + try: + data = path.read_bytes() + except OSError as error: + fail(f"cannot read SDK file {path}: {error}") + if (not allow_empty and not data) or len(data) > maximum_size: + fail(f"SDK file size is invalid: {path}") + return data + + +def checked_symlink(path: pathlib.Path, expected_target: str) -> None: + if not path.is_symlink() or os.readlink(path) != expected_target: + fail(f"SDK linker alias is invalid: {path}") + + +def run_tool(tool: pathlib.Path, arguments: list[str]) -> str: + if not tool.is_absolute() or not tool.is_file(): + fail(f"inspection tool is missing: {tool}") + environment = dict(os.environ) + environment["LC_ALL"] = "C" + completed = subprocess.run( + [str(tool), *arguments], + check=False, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + env=environment, + ) + if completed.returncode != 0: + fail(f"inspection tool failed: {tool.name}") + return completed.stdout + + +def verify_header(header: bytes) -> None: + if not header or len(header) > 128 * 1024 or b"\x00" in header: + fail("Model Guard v2 header size/content rejected") + try: + text = header.decode("utf-8", "strict") + except UnicodeError as error: + raise RuntimeError("Model Guard v2 header is not UTF-8") from error + if not REQUIRED_HEADER_LINES.issubset(set(text.splitlines())): + fail("Model Guard v2 header constants are incompatible") + for function in REQUIRED_HEADER_FUNCTIONS: + if len(re.findall(rf"\b{re.escape(function)}\s*\(", text)) != 1: + fail(f"Model Guard v2 header declaration rejected: {function}") + + +def verify_elf( + library: pathlib.Path, + readelf: pathlib.Path, + nm: pathlib.Path, +) -> None: + header = run_tool(readelf, ["-h", str(library)]) + if re.search(r"^\s*Type:\s+DYN\b", header, re.MULTILINE) is None: + fail("model guard SDK library is not a shared ELF image") + + dynamic = run_tool(readelf, ["-d", str(library)]) + runpaths = re.findall(r"\(RUNPATH\).*\[([^]]+)\]", dynamic) + if runpaths != ["$ORIGIN"]: + fail("model guard SDK RUNPATH must be exactly $ORIGIN") + + symbols = run_tool(nm, ["-D", "--defined-only", str(library)]) + exports = { + fields[2] + for line in symbols.splitlines() + if len(fields := line.split()) == 3 and fields[1] in {"T", "W"} + } + if exports != EXPECTED_V2_EXPORTS: + fail(f"model guard SDK exports are incompatible: {sorted(exports)}") + + +def verify_provision_tool(tool: pathlib.Path, readelf: pathlib.Path) -> None: + header = run_tool(readelf, ["-h", str(tool)]) + if re.search(r"^\s*Type:\s+(?:DYN|EXEC)\b", header, re.MULTILINE) is None: + fail("cosmo-model-provision is not an ELF executable") + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument( + "--admission-profile", choices=ADMISSION_PROFILES, required=True + ) + parser.add_argument("--sdk-root", required=True) + parser.add_argument("--readelf", required=True) + parser.add_argument("--nm", required=True) + arguments = parser.parse_args() + public_runtime = arguments.admission_profile == ADMISSION_PUBLIC_RUNTIME + test_fixture = arguments.admission_profile == ADMISSION_TEST_FIXTURE + + root = pathlib.Path(arguments.sdk_root) + if not root.is_absolute(): + fail("SDK root must be absolute") + include_directory = root / "include" + library_directory = root / "lib" + share_directory = root / "share/cosmo-model-guard" + for directory in (root, include_directory, library_directory): + checked_directory(directory) + + header_path = include_directory / "cosmo_model_guard_v2.h" + library_path = library_directory / "libcosmo_model_guard.so.2.0.0" + header = checked_file(header_path, 128 * 1024) + library = checked_file(library_path, 32 * 1024 * 1024) + checked_symlink( + library_directory / "libcosmo_model_guard.so.2", library_path.name + ) + checked_symlink( + library_directory / "libcosmo_model_guard.so", + "libcosmo_model_guard.so.2", + ) + + marker_path = share_directory / TEST_FIXTURE_MARKER_NAME + marker: bytes | None = None + if marker_path.exists(): + marker = checked_file(marker_path, len(TEST_FIXTURE_MARKER_CONTENT)) + if marker != TEST_FIXTURE_MARKER_CONTENT: + fail("Model Guard SDK test-fixture marker content is invalid") + if test_fixture and marker is None: + fail("test-fixture admission requires the exact non-production SDK marker") + if not test_fixture and marker is not None: + fail( + "Model Guard test fixtures are forbidden for public-runtime and " + "production-release admission" + ) + + provision_path: pathlib.Path | None = None + provision: bytes | None = None + if not public_runtime: + provision_path = root / "bin/cosmo-model-provision" + provision = checked_file(provision_path, 32 * 1024 * 1024) + + verify_header(header) + verify_elf( + library_path, + pathlib.Path(arguments.readelf), + pathlib.Path(arguments.nm), + ) + if provision_path is not None: + verify_provision_tool(provision_path, pathlib.Path(arguments.readelf)) + + print(f"admission_profile={arguments.admission_profile}") + print(f"verified_sdk_root={root}") + print(f"header_sha256={hashlib.sha256(header).hexdigest()}") + print(f"library_sha256={hashlib.sha256(library).hexdigest()}") + if provision is not None: + print(f"provision_tool_sha256={hashlib.sha256(provision).hexdigest()}") + if public_runtime: + print("sdk_profile=public-runtime") + elif test_fixture: + print("sdk_profile=TEST-FIXTURE-DO-NOT-DEPLOY") + else: + print("sdk_profile=production") + return 0 + + +if __name__ == "__main__": + try: + raise SystemExit(main()) + except (OSError, RuntimeError) as error: + print(f"model guard v2 SDK verification failed: {error}", file=sys.stderr) + raise SystemExit(1) diff --git a/scripts/verify_package_contents.py b/scripts/verify_package_contents.py new file mode 100644 index 000000000..f6ef0278f --- /dev/null +++ b/scripts/verify_package_contents.py @@ -0,0 +1,581 @@ +#!/usr/bin/python3 +"""Validate SOURCE and controlled production CPack inventories.""" + +from __future__ import annotations + +import argparse +import hashlib +import importlib.machinery +import importlib.util +import os +import pathlib +import re +import sys +import tarfile +import tempfile +from dataclasses import dataclass +from typing import Mapping + + +SCRIPT_DIRECTORY = pathlib.Path(__file__).resolve().parent +RELEASE_UPDATER_SOURCE = SCRIPT_DIRECTORY / "release_updater.py" +release_loader = importlib.machinery.SourceFileLoader( + "cosmo_source_package_release_schema", str(RELEASE_UPDATER_SOURCE) +) +release_spec = importlib.util.spec_from_loader( + release_loader.name, release_loader +) +if release_spec is None: + raise RuntimeError("cannot load the shared CEM v2 package scanner") +release_schema = importlib.util.module_from_spec(release_spec) +sys.modules[release_loader.name] = release_schema +release_loader.exec_module(release_schema) + + +PROFILE_SOURCE = "public-runtime" +PROFILE_PRODUCTION = "production-release" +PROFILES = (PROFILE_SOURCE, PROFILE_PRODUCTION) + +COMMON_DIRECTORIES = { + "bin", + "files", + "font", + "lib", + "resource", + "scripts", + "share/cosmo-model-guard", + "web", +} +COMMON_FILES = { + "lib/libcosmo_model_guard.so.2.0.0", + "share/cosmo-model-guard/cosmo_model_guard_v2.h", +} +COMMON_SYMLINKS = { + "lib/libcosmo_model_guard.so": "libcosmo_model_guard.so.2", + "lib/libcosmo_model_guard.so.2": "libcosmo_model_guard.so.2.0.0", +} + +SOURCE_EXECUTABLES = { + "bin/cosmo-engine", + "install-device.sh", + "scripts/run_start.sh", + "scripts/source_health_check.sh", + "scripts/source_run_start.sh", + "scripts/stop.sh", +} +SOURCE_FILES = { + "bin/version.txt", + "scripts/common.sh", + "share/cosmo-source/build-identity.env", + "share/cosmo-source/cosmo.service", +} +SOURCE_BUILD_IDENTITY = "share/cosmo-source/build-identity.env" +SOURCE_BUILD_IDENTITY_FORMAT = "cosmo-source-build-identity-v2" +SOURCE_BUILD_IDENTITY_KEYS = ( + "format", + "edge_commit", + "version", + "engine_sha256", + "build_identity", +) +SOURCE_ENGINE = "bin/cosmo-engine" +SOURCE_VERSION = "bin/version.txt" +INSPECTED_FILE_LIMITS = { + SOURCE_BUILD_IDENTITY: 4096, + SOURCE_ENGINE: 512 * 1024 * 1024, + SOURCE_VERSION: 1024, + "share/cosmo-model-guard/cosmo_model_guard_v2.h": 128 * 1024, + "lib/libcosmo_model_guard.so.2.0.0": 32 * 1024 * 1024, +} +PRIVATE_KEY_PEM_MARKERS = ( + b"-----BEGIN PRIVATE KEY-----", + b"-----BEGIN ENCRYPTED PRIVATE KEY-----", + b"-----BEGIN RSA PRIVATE KEY-----", + b"-----BEGIN EC PRIVATE KEY-----", + b"-----BEGIN DSA PRIVATE KEY-----", + b"-----BEGIN OPENSSH PRIVATE KEY-----", +) +PRIVATE_KEY_SCAN_OVERLAP = max(map(len, PRIVATE_KEY_PEM_MARKERS)) - 1 + +PRODUCTION_EXECUTABLES = { + ".release-bootstrap/bin/cosmo-release-bootstrap", + ".release-bootstrap/scripts/release_health_check.sh", + "bin/cosmo-model-provision", + "bin/cosmo-release-bootstrap", + "scripts/install.sh", + "scripts/inte_run_start.sh", + "scripts/release_health_check.sh", + "scripts/release_updater.sh", + "scripts/start.sh", +} +PRODUCTION_FILES = { + ".release-bootstrap/lib/libcrypto.so.3", + ".release-bootstrap/scripts/release_bootstrap_backend.py", + ".release-bootstrap/scripts/release_updater.py", + "scripts/release_bootstrap_backend.py", + "scripts/release_updater.py", + "share/cosmo-factory/cosmo.service", +} + +SOURCE_FORBIDDEN_PREFIXES = ( + ".release-bootstrap/", + "bin/cosmo-model-provision", + "bin/cosmo-release-bootstrap", + "scripts/build_release_", + "scripts/release_", + "scripts/verify_release_", + "share/cosmo-factory/", + "share/cosmo-model-guard/release-", +) +SOURCE_FORBIDDEN_FILES = PRODUCTION_EXECUTABLES | PRODUCTION_FILES | { + "scripts/build_release_bundle.py", + "scripts/build_release_public_key_object.py", + "scripts/verify_release_public_key_object.py", + "share/cosmo-model-guard/release-public-key.o", +} +SOURCE_FORBIDDEN_BASENAMES = { + "commissioning-ed25519.seed", + "cosmo-model-provision", + "cosmo-release-bootstrap", + "device-certificate.bin", + "product-model-key-v1.bin", + "product-pepper-v1.bin", + "product-pepper-v1.o", + "release-private-key.o", + "release-public-key.o", +} +SOURCE_PRIVATE_SUFFIXES = ( + ".key", + ".p8", + ".p12", + ".pfx", + ".pk8", + ".pkcs12", + ".jks", + ".keystore", + ".private.pem", +) +SOURCE_PRIVATE_NAMED_SUFFIXES = (".der", ".key", ".p8", ".pem", ".pk8") +PRODUCTION_FORBIDDEN_FILES = { + "install-device.sh", + "scripts/source_health_check.sh", + "scripts/source_run_start.sh", + "share/cosmo-source/build-identity.env", + "share/cosmo-source/cosmo.service", +} +PRODUCTION_FORBIDDEN_MODEL_GUARD_BASENAMES = { + "commissioning-ed25519.seed", + "device-certificate.bin", + "product-model-key-v1.bin", + "product-pepper-v1.bin", +} + + +class PackageAuditError(RuntimeError): + """Raised when a package does not match its declared profile.""" + + +@dataclass(frozen=True) +class ArchiveEntry: + kind: str + mode: int + linkname: str | None = None + content: bytes | None = None + sha256: str | None = None + preset_cohort_id: str | None = None + + +def _relative_member(name: str, root: str | None) -> tuple[str, str]: + path = pathlib.PurePosixPath(name) + parts = path.parts + if ( + path.is_absolute() + or not parts + or any(part in ("", ".", "..") for part in parts) + ): + raise PackageAuditError(f"archive member path is unsafe: {name}") + package_root = parts[0] if root is None else root + if parts[0] != package_root: + raise PackageAuditError("archive must contain exactly one package root") + relative = pathlib.PurePosixPath(*parts[1:]).as_posix() + return package_root, "" if relative == "." else relative + + +def read_inventory(archive: pathlib.Path) -> dict[str, ArchiveEntry]: + if not archive.is_absolute(): + raise PackageAuditError("package archive path must be absolute") + if archive.is_symlink() or not archive.is_file(): + raise PackageAuditError("package archive must be one regular file") + + inventory: dict[str, ArchiveEntry] = {} + package_root: str | None = None + try: + with tarfile.open(archive, "r:gz") as package: + for member in package: + package_root, relative = _relative_member( + member.name, package_root + ) + if not relative: + if not member.isdir(): + raise PackageAuditError( + "package root must be a directory" + ) + continue + if relative in inventory: + raise PackageAuditError( + f"duplicate archive member: {relative}" + ) + if member.isdir(): + kind = "directory" + elif member.isreg(): + kind = "file" + elif member.issym(): + kind = "symlink" + else: + raise PackageAuditError( + f"unsupported archive member type: {relative}" + ) + content: bytes | None = None + digest: str | None = None + preset_cohort_id: str | None = None + if kind == "file": + inspected = relative in INSPECTED_FILE_LIMITS + preset_model = ( + release_schema._is_preset_model_payload_path(relative) + ) + if inspected: + maximum_size = INSPECTED_FILE_LIMITS[relative] + if member.size < 0 or member.size > maximum_size: + raise PackageAuditError( + f"package member size is invalid: {relative}" + ) + source = package.extractfile(member) + if source is None: + raise PackageAuditError( + f"cannot read package member: {relative}" + ) + checksum = hashlib.sha256() if inspected else None + captured = bytearray() + private_key_tail = b"" + spool = ( + tempfile.TemporaryFile( + prefix="cosmo-source-preset-", + ) + if preset_model + else None + ) + try: + while True: + block = source.read(1024 * 1024) + if not block: + break + scan_block = private_key_tail + block + if any( + marker in scan_block + for marker in PRIVATE_KEY_PEM_MARKERS + ): + raise PackageAuditError( + "package contains a PEM private key marker: " + f"{relative}" + ) + private_key_tail = scan_block[ + -PRIVATE_KEY_SCAN_OVERLAP: + ] + if checksum is not None: + checksum.update(block) + if relative != SOURCE_ENGINE: + captured.extend(block) + if spool is not None: + spool.write(block) + if spool is not None: + spool.flush() + os.fchmod(spool.fileno(), 0o400) + snapshot = release_schema._validate_cem_v2_core_fd( + spool.fileno(), + os.fstat(spool.fileno()), + relative, + ) + preset_cohort_id = snapshot.cohort_id + except release_schema.ReleaseError as error: + raise PackageAuditError( + f"SOURCE preset model rejected: {relative}: {error}" + ) from error + finally: + source.close() + if spool is not None: + spool.close() + if checksum is not None: + digest = checksum.hexdigest() + if relative != SOURCE_ENGINE: + content = bytes(captured) + inventory[relative] = ArchiveEntry( + kind=kind, + mode=member.mode & 0o7777, + linkname=member.linkname if member.issym() else None, + content=content, + sha256=digest, + preset_cohort_id=preset_cohort_id, + ) + except (OSError, tarfile.TarError) as error: + raise PackageAuditError(f"cannot read package archive: {error}") from error + if package_root is None: + raise PackageAuditError("package archive is empty") + return inventory + + +def _require_kind( + inventory: Mapping[str, ArchiveEntry], paths: set[str], kind: str +) -> None: + for path in sorted(paths): + entry = inventory.get(path) + if entry is None: + raise PackageAuditError(f"package is missing {path}") + if entry.kind != kind: + raise PackageAuditError(f"package member has wrong type: {path}") + + +def _require_executable( + inventory: Mapping[str, ArchiveEntry], paths: set[str] +) -> None: + _require_kind(inventory, paths, "file") + for path in sorted(paths): + if inventory[path].mode & 0o111 == 0: + raise PackageAuditError( + f"package executable has no execute bit: {path}" + ) + + +def _source_path_is_forbidden(path: str) -> bool: + lower_path = path.lower() + basename = pathlib.PurePosixPath(lower_path).name + if lower_path in SOURCE_FORBIDDEN_FILES or lower_path.startswith( + SOURCE_FORBIDDEN_PREFIXES + ): + return True + if basename in SOURCE_FORBIDDEN_BASENAMES: + return True + if basename.endswith(SOURCE_PRIVATE_SUFFIXES): + return True + if "private" in basename and basename.endswith( + SOURCE_PRIVATE_NAMED_SUFFIXES + ): + return True + if "product" in basename and "pepper" in basename: + return True + if "model-provision" in basename or "provisioner" in basename: + return True + if "release" in basename and "bootstrap" in basename: + return True + if "private-key" in basename or "private_key" in basename: + return True + return False + + +def _production_path_is_forbidden(path: str) -> bool: + lower_path = path.lower() + basename = pathlib.PurePosixPath(lower_path).name + if lower_path in PRODUCTION_FORBIDDEN_FILES: + return True + return basename in PRODUCTION_FORBIDDEN_MODEL_GUARD_BASENAMES + + +def source_build_identity( + inventory: Mapping[str, ArchiveEntry], +) -> dict[str, str]: + identity_entry = inventory.get(SOURCE_BUILD_IDENTITY) + engine_entry = inventory.get(SOURCE_ENGINE) + version_entry = inventory.get(SOURCE_VERSION) + if ( + identity_entry is None + or identity_entry.content is None + or engine_entry is None + or engine_entry.sha256 is None + or version_entry is None + or version_entry.content is None + ): + raise PackageAuditError( + "SOURCE package identity inputs were not captured" + ) + + content = identity_entry.content + if ( + not content + or not content.endswith(b"\n") + or b"\r" in content + or b"\x00" in content + ): + raise PackageAuditError("SOURCE build identity encoding is invalid") + try: + lines = content.decode("ascii").removesuffix("\n").split("\n") + except UnicodeDecodeError as error: + raise PackageAuditError( + "SOURCE build identity must be ASCII" + ) from error + values: dict[str, str] = {} + keys: list[str] = [] + for line in lines: + if line.count("=") != 1: + raise PackageAuditError("SOURCE build identity line is invalid") + key, value = line.split("=", 1) + if not key or not value or key in values: + raise PackageAuditError("SOURCE build identity line is invalid") + keys.append(key) + values[key] = value + if tuple(keys) != SOURCE_BUILD_IDENTITY_KEYS: + raise PackageAuditError( + "SOURCE build identity keys or canonical order are invalid" + ) + if values["format"] != SOURCE_BUILD_IDENTITY_FORMAT: + raise PackageAuditError("SOURCE build identity format is incompatible") + if re.fullmatch(r"[0-9a-f]{40}", values["edge_commit"]) is None: + raise PackageAuditError("SOURCE Edge commit is malformed") + if re.fullmatch(r"V[0-9]+(?:\.[0-9]+){2}", values["version"]) is None: + raise PackageAuditError("SOURCE package version is malformed") + for key in ("engine_sha256", "build_identity"): + if re.fullmatch(r"[0-9a-f]{64}", values[key]) is None: + raise PackageAuditError(f"SOURCE {key} is malformed") + if values["engine_sha256"] != engine_entry.sha256: + raise PackageAuditError("SOURCE engine SHA-256 differs from identity") + if version_entry.content != f"{values['version']}\n".encode("ascii"): + raise PackageAuditError("SOURCE version.txt differs from identity") + hash_input = ( + f"{SOURCE_BUILD_IDENTITY_FORMAT}\n" + f"edge_commit={values['edge_commit']}\n" + f"version={values['version']}\n" + f"engine_sha256={values['engine_sha256']}\n" + ).encode("ascii") + if hashlib.sha256(hash_input).hexdigest() != values["build_identity"]: + raise PackageAuditError("SOURCE build identity digest is invalid") + return values + + +def file_sha256(path: pathlib.Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as source: + while True: + block = source.read(4 * 1024 * 1024) + if not block: + break + digest.update(block) + return digest.hexdigest() + + +def verify_source_archive_name( + archive: pathlib.Path, identity: Mapping[str, str], archive_sha256: str +) -> None: + suffix = ( + f"-SOURCE-{identity['edge_commit']}-" + f"{identity['build_identity']}-{archive_sha256}.tar.gz" + ) + if not archive.name.endswith(suffix): + raise PackageAuditError( + "SOURCE archive name does not bind commit, build identity, " + "and archive SHA-256" + ) + + +def verify_source_preset_cohort( + inventory: Mapping[str, ArchiveEntry], +) -> None: + cohorts: set[str] = set() + for path, entry in sorted(inventory.items()): + if not release_schema._is_preset_model_payload_path(path): + continue + if entry.kind != "file": + raise PackageAuditError( + f"SOURCE preset model must be a regular file: {path}" + ) + cohort = entry.preset_cohort_id + if cohort is None or re.fullmatch(r"[0-9a-f]{32}", cohort) is None: + raise PackageAuditError( + f"SOURCE preset model lacks strict CEM v2 validation: {path}" + ) + if cohort == "0" * 32: + raise PackageAuditError( + f"SOURCE preset model cohort ID must be nonzero: {path}" + ) + cohorts.add(cohort) + if len(cohorts) > 1: + raise PackageAuditError("SOURCE preset models use mixed cohort IDs") + + +def verify_inventory( + inventory: Mapping[str, ArchiveEntry], + build_profile: str, + legacy_migration: bool = False, +) -> str: + if build_profile not in PROFILES: + raise PackageAuditError(f"unsupported build profile: {build_profile}") + + _require_kind(inventory, COMMON_DIRECTORIES, "directory") + _require_kind(inventory, COMMON_FILES, "file") + for path, target in COMMON_SYMLINKS.items(): + entry = inventory.get(path) + if ( + entry is None + or entry.kind != "symlink" + or entry.linkname != target + ): + raise PackageAuditError( + f"package symlink is missing or invalid: {path}" + ) + + if build_profile == PROFILE_SOURCE: + _require_executable(inventory, SOURCE_EXECUTABLES) + _require_kind(inventory, SOURCE_FILES, "file") + source_build_identity(inventory) + verify_source_preset_cohort(inventory) + for path in sorted(inventory): + if legacy_migration and path in { + "scripts/install.sh", + "scripts/start.sh", + "scripts/inte_run_start.sh", + }: + continue + if _source_path_is_forbidden(path): + raise PackageAuditError( + f"SOURCE package contains controlled release material: {path}" + ) + return "SOURCE" + + _require_executable(inventory, PRODUCTION_EXECUTABLES) + _require_kind(inventory, PRODUCTION_FILES, "file") + for path in sorted(inventory): + if _production_path_is_forbidden(path): + raise PackageAuditError( + "production package contains SOURCE-only or device-specific " + f"material: {path}" + ) + return PROFILE_PRODUCTION + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--archive", required=True) + parser.add_argument("--build-profile", choices=PROFILES, required=True) + parser.add_argument("--legacy-migration", action="store_true") + arguments = parser.parse_args() + try: + archive = pathlib.Path(arguments.archive) + inventory = read_inventory(archive) + variant = verify_inventory( + inventory, arguments.build_profile, arguments.legacy_migration + ) + archive_sha256 = file_sha256(archive) + if arguments.build_profile == PROFILE_SOURCE: + identity = source_build_identity(inventory) + if not arguments.legacy_migration: + verify_source_archive_name(archive, identity, archive_sha256) + except PackageAuditError as error: + print(f"ERROR: {error}", file=sys.stderr) + return 1 + print(f"Verified package content profile: {variant}") + if arguments.build_profile == PROFILE_SOURCE: + print(f"Edge commit: {identity['edge_commit']}") + print(f"Build identity: {identity['build_identity']}") + print(f"Archive SHA-256: {archive_sha256}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/verify_release_public_key_object.py b/scripts/verify_release_public_key_object.py new file mode 100644 index 000000000..a861a1d04 --- /dev/null +++ b/scripts/verify_release_public_key_object.py @@ -0,0 +1,182 @@ +#!/usr/bin/python3 +"""Strictly verify a production AArch64 release trust-anchor object.""" + +from __future__ import annotations + +import argparse +import hashlib +import os +import re +import stat +import subprocess +import sys +from pathlib import Path +from typing import Sequence + + +EXPECTED_SYMBOLS = { + "cosmo_release_public_key_raw_v1": (32, 0), + "cosmo_release_public_key_id_v1": (16, 32), + "cosmo_release_public_key_pem_sha256_v1": (32, 48), +} + + +class VerificationError(RuntimeError): + pass + + +def _require_isolated_entrypoint() -> None: + if sys.flags.isolated != 1: + raise VerificationError( + "release public-key object verification must be launched with " + "/usr/bin/python3 -I -B" + ) + + +def _run(arguments: Sequence[str]) -> str: + try: + result = subprocess.run( + list(arguments), + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + close_fds=True, + env={"LC_ALL": "C", "PATH": "/usr/bin:/bin"}, + timeout=30, + ) + except (OSError, subprocess.TimeoutExpired) as error: + raise VerificationError("required target inspection tool failed") from error + if result.returncode != 0: + raise VerificationError("target inspection tool rejected the release key object") + return result.stdout.decode("utf-8", "strict") + + +def _validate_tool(path: Path) -> None: + info = os.stat(path) + if not path.is_absolute() or not stat.S_ISREG(info.st_mode): + raise VerificationError(f"inspection tool is not a regular file: {path}") + + +def verify(path: Path, readelf: Path, nm: Path) -> None: + if not path.is_absolute() or not readelf.is_absolute() or not nm.is_absolute(): + raise VerificationError("release key object and inspection tools must use absolute paths") + _validate_tool(readelf) + _validate_tool(nm) + info = os.stat(path) + if ( + not stat.S_ISREG(info.st_mode) + or info.st_size <= 0 + ): + raise VerificationError("release key object type or size rejected") + header = _run((str(readelf), "-hW", str(path))) + if ( + re.search(r"^\s*Type:\s+REL \(Relocatable file\)\s*$", header, re.MULTILINE) is None + or re.search(r"^\s*Machine:\s+AArch64\s*$", header, re.MULTILINE) is None + ): + raise VerificationError("release key object must be an AArch64 relocatable ELF") + sections = _run((str(readelf), "-SW", str(path))) + matching_sections = [line for line in sections.splitlines() if ".rodata.cosmo_release_key" in line] + section = ( + re.search( + r"^\s*\[\s*(\d+)\]\s+\.rodata\.cosmo_release_key\s+PROGBITS\s+" + r"([0-9a-fA-F]+)\s+[0-9a-fA-F]+\s+([0-9a-fA-F]+)\s+" + r"[0-9a-fA-F]+\s+([A-Z]+)\s+\d+\s+\d+\s+\d+\s*$", + matching_sections[0], + ) + if len(matching_sections) == 1 + else None + ) + if ( + section is None + or int(section.group(2), 16) != 0 + or int(section.group(3), 16) != 80 + or section.group(4) != "A" + ): + raise VerificationError("release key object trust section size/flags rejected") + trust_section_index = int(section.group(1)) + for line in sections.splitlines(): + executable = re.search( + r"^\s*\[\s*\d+\]\s+(\S+)\s+\S+\s+[0-9a-fA-F]+\s+" + r"[0-9a-fA-F]+\s+([0-9a-fA-F]+)\s+[0-9a-fA-F]+\s+([A-Z]+)", + line, + ) + if ( + executable is not None + and int(executable.group(2), 16) != 0 + and "X" in executable.group(3) + ): + raise VerificationError("release key object contains executable content") + if ( + executable is not None + and int(executable.group(2), 16) != 0 + and "A" in executable.group(3) + and executable.group(1) != ".rodata.cosmo_release_key" + ): + raise VerificationError("release key object contains unexpected allocated content") + + symbols = _run((str(readelf), "-sW", str(path))) + found: dict[str, tuple[int, int]] = {} + global_defined: set[str] = set() + pattern = re.compile( + r"^\s*\d+:\s+([0-9a-fA-F]+)\s+(\d+)\s+(\S+)\s+" + r"(GLOBAL|WEAK)\s+(\S+)\s+(\S+)\s+(\S+)\s*$" + ) + for line in symbols.splitlines(): + match = pattern.match(line) + if match is None: + continue + name = match.group(7) + section_index = match.group(6) + if section_index == "UND": + continue + global_defined.add(name) + if ( + name not in EXPECTED_SYMBOLS + or match.group(3) != "OBJECT" + or match.group(4) != "GLOBAL" + or match.group(5) != "HIDDEN" + or section_index != str(trust_section_index) + ): + raise VerificationError("release key object contains an unexpected defined symbol") + if name in found: + raise VerificationError("release key object contains a duplicate trust symbol") + found[name] = (int(match.group(2)), int(match.group(1), 16)) + if found != EXPECTED_SYMBOLS or global_defined != set(EXPECTED_SYMBOLS): + raise VerificationError("release key object symbol whitelist, size, or offset rejected") + + undefined = _run((str(nm), "-u", str(path))).strip() + if undefined: + raise VerificationError("release key object must not contain undefined symbols") + relocations = _run((str(readelf), "-rW", str(path))) + if "There are no relocations in this file." not in relocations: + raise VerificationError("release key object must not contain relocations") + + +def main(argv: Sequence[str]) -> int: + try: + _require_isolated_entrypoint() + except VerificationError as error: + print(f"release public-key object verification failed: {error}", file=sys.stderr) + return 1 + parser = argparse.ArgumentParser(description="Verify the production release key object") + parser.add_argument("--object", required=True) + parser.add_argument("--readelf", required=True) + parser.add_argument("--nm", required=True) + arguments = parser.parse_args(argv) + try: + verify( + Path(arguments.object), + Path(arguments.readelf), + Path(arguments.nm), + ) + except (OSError, UnicodeError, VerificationError) as error: + print(f"release public-key object verification failed: {error}", file=sys.stderr) + return 1 + digest = hashlib.sha256(Path(arguments.object).read_bytes()).hexdigest() + print("release_public_key_object=aarch64-relocatable-v1") + print(f"release_public_key_object_sha256={digest}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:])) diff --git a/src/api/ApiRouter.cc b/src/api/ApiRouter.cc index a3ff15601..269dac2a9 100644 --- a/src/api/ApiRouter.cc +++ b/src/api/ApiRouter.cc @@ -26,6 +26,7 @@ #include "service/media/ILiveStreamService.h" #include "service/media/IVideoFrameCodec.h" #include "service/model/IModelService.h" +#include "service/modelguard/IModelAuthorizationService.h" #include "service/network/IAuthService.h" #include "service/network/INetworkService.h" #include "service/onboarding/IOnboardingService.h" @@ -79,7 +80,8 @@ ApiRouter::ApiRouter(MessageFromType from) service::ServiceRegistry::Instance().Get(), service::ServiceRegistry::Instance().Get(), service::ServiceRegistry::Instance().Get(), - service::ServiceRegistry::Instance().Get())), + service::ServiceRegistry::Instance().Get(), + service::ServiceRegistry::Instance().Get())), live_stream_handler_(std::make_unique( service::ServiceRegistry::Instance().Get())), lib_handler_(std::make_unique( diff --git a/src/api/ApiRouterRoutes.cc b/src/api/ApiRouterRoutes.cc index 60feb717f..3c4c6e6d0 100644 --- a/src/api/ApiRouterRoutes.cc +++ b/src/api/ApiRouterRoutes.cc @@ -198,6 +198,9 @@ void ApiRouter::RegisterSystemRoutes() { ROUTE("/gtw/cwai/System/", kAuth, system_handler_, System, ResetSystem); ROUTE("/gtw/cwai/System/", kAuth, system_handler_, System, ExportFile); ROUTE_CONTEXT("/gtw/cwai/System/", kAuth, system_handler_, System, Upgrade); + ROUTE("/gtw/cwai/System/", kAuth, system_handler_, System, QueryModelAuthorization); + ROUTE("/gtw/cwai/System/", kAuth, system_handler_, System, DownloadModelAuthorizationRequest); + ROUTE_CONTEXT("/gtw/cwai/System/", kAuth, system_handler_, System, InstallModelAuthorization); ROUTE("/gtw/cwai/System/", kAuth, system_handler_, System, QuerySystemLogo); ROUTE("/gtw/cwai/System/", kAuth, system_handler_, System, SetSystemLogo); ROUTE("/gtw/cwai/System/", kAuth, system_handler_, System, QueryDeviceStatus); diff --git a/src/api/MessageSystemHandler.cc b/src/api/MessageSystemHandler.cc index 69d77cd92..8c5e8e504 100644 --- a/src/api/MessageSystemHandler.cc +++ b/src/api/MessageSystemHandler.cc @@ -7,6 +7,7 @@ #include "api/HttpUploadClaim.h" #include "media/PreviewPipelineMetrics.h" #include "service/detail/ServiceRegistry.h" +#include "service/modelguard/IModelAuthorizationService.h" #include "service/path/IUploadStagingService.h" #include "service/system/IConfigReadService.h" #include "service/system/IConfigWriteService.h" @@ -36,6 +37,24 @@ namespace { constexpr const char* kRebootMsg = "Rebooting, please do not power off"; constexpr const char* kResetMsg = "Resetting, please do not power off"; constexpr int kRebootWaitSec = 40; + + class UnsupportedModelAuthorizationService final : public service::IModelAuthorizationService { + public: + service::ModelAuthorizationStatus Status() override { + return {}; + } + util::ErrorEnum CreateDeviceRequest(std::string&, std::string&) override { + return util::ErrorEnum::OperationNotSupport; + } + util::ErrorEnum InstallCertificate(const std::string&) override { + return util::ErrorEnum::OperationNotSupport; + } + }; + + service::IModelAuthorizationService& UnsupportedModelAuthorization() { + static UnsupportedModelAuthorizationService service; + return service; + } } // namespace MessageSystemHandler::MessageSystemHandler(service::IConfigReadService& config_read, @@ -44,12 +63,23 @@ MessageSystemHandler::MessageSystemHandler(service::IConfigReadService& config_r service::IDeviceInfoService& device_info, service::ISystemOperationService& system_op, service::ITimeService& time_service) + : MessageSystemHandler(config_read, config_write, config_network, device_info, system_op, time_service, + UnsupportedModelAuthorization()) {} + +MessageSystemHandler::MessageSystemHandler(service::IConfigReadService& config_read, + service::IConfigWriteService& config_write, + service::IConfigNetworkService& config_network, + service::IDeviceInfoService& device_info, + service::ISystemOperationService& system_op, + service::ITimeService& time_service, + service::IModelAuthorizationService& model_authorization) : config_read_(config_read), config_write_(config_write), config_network_(config_network), device_info_(device_info), system_op_(system_op), - time_service_(time_service) {} + time_service_(time_service), + model_authorization_(model_authorization) {} // Device information System::MsgQueryDeviceInfoSend MessageSystemHandler::Handle(System::MsgQueryDeviceInfoRecv&& /*data*/, @@ -334,6 +364,48 @@ System::MsgUpgradeSend MessageSystemHandler::Handle(System::MsgUpgradeRecv&& dat return retData; } +System::MsgQueryModelAuthorizationSend MessageSystemHandler::Handle( + System::MsgQueryModelAuthorizationRecv&& /*data*/, std::error_condition& errc) { + System::MsgQueryModelAuthorizationSend result{}; + const auto status = model_authorization_.Status(); + result.resData.supported = status.supported; + result.resData.authorized = status.authorized; + result.resData.state = status.state; + errc = util::ErrorEnum::Success; + return result; +} + +System::MsgDownloadModelAuthorizationRequestSend MessageSystemHandler::Handle( + System::MsgDownloadModelAuthorizationRequestRecv&& /*data*/, std::error_condition& errc) { + System::MsgDownloadModelAuthorizationRequestSend result{}; + errc = model_authorization_.CreateDeviceRequest(result.filePath, result.fileName); + return result; +} + +System::MsgInstallModelAuthorizationSend MessageSystemHandler::Handle( + System::MsgInstallModelAuthorizationRecv&& data, const RequestDispatchContext& context, + std::error_condition& errc) { + System::MsgInstallModelAuthorizationSend result{}; + if (context.transport != RequestTransport::kHttp || context.principal.empty()) { + errc = util::ErrorEnum::InvalidParam; + return result; + } + service::StagedFileLease lease; + if (!data.uploadId.empty()) { + errc = service::ServiceRegistry::Instance().Get().Consume( + context.principal, data.uploadId, service::UploadPurpose::kModelAuthorizationCertificate, lease); + } else { + errc = detail::ClaimHttpUpload(context, data.filePath, + service::UploadPurpose::kModelAuthorizationCertificate, lease); + } + if (!errc && lease.Revalidate()) { + errc = model_authorization_.InstallCertificate(lease.Path()); + } else if (!errc) { + errc = util::ErrorEnum::FileAnalysisFailed; + } + return result; +} + System::MsgUpgradeSend MessageSystemHandler::Handle(System::MsgUpgradeRecv&& data, const RequestDispatchContext& context, std::error_condition& errc) { diff --git a/src/api/MessageSystemHandler.h b/src/api/MessageSystemHandler.h index fc21d1cb9..324043fbc 100644 --- a/src/api/MessageSystemHandler.h +++ b/src/api/MessageSystemHandler.h @@ -22,6 +22,7 @@ class IConfigNetworkService; class IDeviceInfoService; class ISystemOperationService; class ITimeService; +class IModelAuthorizationService; } // namespace cosmo::service namespace cosmo { @@ -33,6 +34,11 @@ class MessageSystemHandler { service::IConfigNetworkService& config_network, service::IDeviceInfoService& device_info, service::ISystemOperationService& system_op, service::ITimeService& time_service); + MessageSystemHandler(service::IConfigReadService& config_read, service::IConfigWriteService& config_write, + service::IConfigNetworkService& config_network, + service::IDeviceInfoService& device_info, + service::ISystemOperationService& system_op, service::ITimeService& time_service, + service::IModelAuthorizationService& model_authorization); System::MsgQueryDeviceInfoSend Handle(System::MsgQueryDeviceInfoRecv&& data, std::error_condition& errc); // @@ -71,6 +77,13 @@ class MessageSystemHandler { System::MsgUpgradeSend Handle(System::MsgUpgradeRecv&& data, std::error_condition& errc); // System::MsgUpgradeSend Handle(System::MsgUpgradeRecv&& data, const RequestDispatchContext& context, std::error_condition& errc); + System::MsgQueryModelAuthorizationSend Handle(System::MsgQueryModelAuthorizationRecv&& data, + std::error_condition& errc); + System::MsgDownloadModelAuthorizationRequestSend Handle( + System::MsgDownloadModelAuthorizationRequestRecv&& data, std::error_condition& errc); + System::MsgInstallModelAuthorizationSend Handle(System::MsgInstallModelAuthorizationRecv&& data, + const RequestDispatchContext& context, + std::error_condition& errc); System::MsgQuerySystemLogoSend Handle(System::MsgQuerySystemLogoRecv&& data, std::error_condition& errc); // @@ -137,6 +150,7 @@ class MessageSystemHandler { service::IDeviceInfoService& device_info_; service::ISystemOperationService& system_op_; service::ITimeService& time_service_; + service::IModelAuthorizationService& model_authorization_; }; } // namespace cosmo diff --git a/src/app/app_init.cc b/src/app/app_init.cc index 360c51c9a..4f03d3d28 100644 --- a/src/app/app_init.cc +++ b/src/app/app_init.cc @@ -67,6 +67,8 @@ #include "service/model/IModelQuery.h" #include "service/model/IModelService.h" #include "service/model/impl/ModelServiceImpl.h" +#include "service/modelguard/IModelAuthorizationService.h" +#include "service/modelguard/impl/ModelAuthorizationServiceImpl.h" #include "service/network/INetworkService.h" #include "service/network/impl/AuthServiceImpl.h" #include "service/network/impl/ClientMessageServiceImpl.h" @@ -222,6 +224,8 @@ static void RegisterBusinessServices() { registry.Register( std::make_unique()); registry.Register(std::make_unique()); + registry.Register( + std::make_unique()); auto& modelImpl = registry.Get(); registry.Set(static_cast(&modelImpl)); registry.Set( diff --git a/src/bootstrap/ReleaseBootstrap.cc b/src/bootstrap/ReleaseBootstrap.cc new file mode 100644 index 000000000..f72c93bd6 --- /dev/null +++ b/src/bootstrap/ReleaseBootstrap.cc @@ -0,0 +1,415 @@ +#include "bootstrap/ReleaseBootstrap.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +#include "bootstrap/ReleaseBootstrapVerifier.h" + +namespace cosmo::bootstrap { +namespace { + + constexpr char kPythonPath[] = "/usr/bin/python3"; + constexpr char kBackendPath[] = + "/appfs/cosmo_wander/cwai_data/.release-bootstrap/scripts/release_bootstrap_backend.py"; + constexpr char kUpdaterPath[] = + "/appfs/cosmo_wander/cwai_data/.release-bootstrap/scripts/release_updater.py"; + constexpr char kBackendFdPath[] = "/proc/self/fd/5"; + constexpr char kInstallArgument[] = "--install"; + constexpr char kRecoverArgument[] = "--recover"; + constexpr std::size_t kMaximumManifestSize = 128 * 1024; + constexpr std::size_t kMaximumPemSize = 16 * 1024; + constexpr std::uint64_t kMaximumArchiveSize = 128ULL * 1024 * 1024 * 1024; + constexpr std::uint64_t kMaximumTrustedScriptSize = 8ULL * 1024 * 1024; + constexpr std::uint64_t kMaximumPythonSize = 512ULL * 1024 * 1024; + constexpr std::uint32_t kRequestMagic = 0x43425231; // CBR1 + constexpr std::uint32_t kApprovalMagic = 0x43424131; // CBA1 + constexpr int kArchiveDescriptor = 3; + constexpr int kCommunicationDescriptor = 4; + constexpr int kBackendDescriptor = 5; + constexpr int kUpdaterDescriptor = 6; + constexpr int kPythonDescriptor = 7; + constexpr int kFirstTemporaryDescriptor = 32; + + enum class BackendOperation { + kInstall, + kRecover, + }; + + struct RequestHeader { + std::uint32_t magic; + std::uint32_t manifest_size; + std::uint32_t signature_size; + }; + + struct ApprovalHeader { + std::uint32_t magic; + std::uint32_t pem_size; + }; + + class OwnedDescriptor { + public: + explicit OwnedDescriptor(int descriptor = -1) : descriptor_(descriptor) {} + ~OwnedDescriptor() { + Reset(); + } + + OwnedDescriptor(const OwnedDescriptor&) = delete; + OwnedDescriptor& operator=(const OwnedDescriptor&) = delete; + + OwnedDescriptor(OwnedDescriptor&& other) noexcept : descriptor_(other.Release()) {} + + OwnedDescriptor& operator=(OwnedDescriptor&& other) noexcept { + if (this != &other) { + Reset(other.Release()); + } + return *this; + } + + int Get() const { + return descriptor_; + } + + explicit operator bool() const { + return descriptor_ >= 0; + } + + int Release() { + const int descriptor = descriptor_; + descriptor_ = -1; + return descriptor; + } + + void Reset(int descriptor = -1) { + if (descriptor_ >= 0) { + close(descriptor_); + } + descriptor_ = descriptor; + } + + private: + int descriptor_; + }; + + int OpenRegularFile(const char* path, bool executable, std::uint64_t maximum_size) { + if (path == nullptr) { + return -1; + } + OwnedDescriptor descriptor(open(path, O_RDONLY | O_CLOEXEC)); + struct stat information {}; + if (!descriptor || fstat(descriptor.Get(), &information) != 0 || !S_ISREG(information.st_mode) || + information.st_size <= 0 || static_cast(information.st_size) > maximum_size || + (executable && (information.st_mode & (S_IXUSR | S_IXGRP | S_IXOTH)) == 0)) { + return -1; + } + return descriptor.Release(); + } + + bool WriteAll(int descriptor, const void* input, std::size_t size) { + const auto* bytes = static_cast(input); + while (size != 0) { + const ssize_t count = send(descriptor, bytes, size, MSG_NOSIGNAL); + if (count > 0) { + bytes += count; + size -= static_cast(count); + continue; + } + if (count < 0 && errno == EINTR) { + continue; + } + return false; + } + return true; + } + + bool ReadAll(int descriptor, void* output, std::size_t size) { + auto* bytes = static_cast(output); + while (size != 0) { + const ssize_t count = recv(descriptor, bytes, size, 0); + if (count > 0) { + bytes += count; + size -= static_cast(count); + continue; + } + if (count < 0 && errno == EINTR) { + continue; + } + return false; + } + return true; + } + + bool IsRegularArchive(int descriptor) { + struct stat information {}; + return fstat(descriptor, &information) == 0 && S_ISREG(information.st_mode) && + information.st_size > 0 && + static_cast(information.st_size) <= kMaximumArchiveSize; + } + + void CloseUnneededDescriptors() { +#ifdef SYS_close_range + if (syscall(SYS_close_range, static_cast(kPythonDescriptor + 1), ~0U, 0U) == 0) { + return; + } +#endif + struct rlimit limit {}; + unsigned long maximum = 65536; + if (getrlimit(RLIMIT_NOFILE, &limit) == 0 && limit.rlim_cur != RLIM_INFINITY) { + maximum = static_cast(limit.rlim_cur); + } + for (unsigned long descriptor = static_cast(kPythonDescriptor + 1); + descriptor < maximum; ++descriptor) { + close(static_cast(descriptor)); + } + } + + int DuplicateHigh(int descriptor) { + return descriptor < 0 ? -1 : fcntl(descriptor, F_DUPFD_CLOEXEC, kFirstTemporaryDescriptor); + } + + bool InstallChildDescriptor(int source, int destination, bool close_on_exec) { + if (dup2(source, destination) < 0) { + return false; + } + return fcntl(destination, F_SETFD, close_on_exec ? FD_CLOEXEC : 0) == 0; + } + + pid_t StartBackend(BackendOperation operation, int archive_descriptor, int communication_descriptor, + int python_descriptor, int backend_descriptor, int updater_descriptor) { + OwnedDescriptor archive_copy( + operation == BackendOperation::kInstall ? DuplicateHigh(archive_descriptor) : -1); + OwnedDescriptor communication_copy(DuplicateHigh(communication_descriptor)); + OwnedDescriptor python_copy(DuplicateHigh(python_descriptor)); + OwnedDescriptor backend_copy(DuplicateHigh(backend_descriptor)); + OwnedDescriptor updater_copy(DuplicateHigh(updater_descriptor)); + if ((operation == BackendOperation::kInstall && !archive_copy) || !communication_copy || + !python_copy || !backend_copy || !updater_copy) { + return -1; + } + + const pid_t child = fork(); + if (child != 0) { + return child; + } + + for (int descriptor = kArchiveDescriptor; descriptor <= kPythonDescriptor; ++descriptor) { + close(descriptor); + } + const bool descriptors_ready = + (operation != BackendOperation::kInstall || + InstallChildDescriptor(archive_copy.Get(), kArchiveDescriptor, false)) && + InstallChildDescriptor(communication_copy.Get(), kCommunicationDescriptor, false) && + InstallChildDescriptor(backend_copy.Get(), kBackendDescriptor, false) && + InstallChildDescriptor(updater_copy.Get(), kUpdaterDescriptor, false) && + InstallChildDescriptor(python_copy.Get(), kPythonDescriptor, true); + if (!descriptors_ready) { + _exit(126); + } + CloseUnneededDescriptors(); + + char* arguments[] = { + const_cast(kPythonPath), + const_cast("-I"), + const_cast("-B"), + const_cast(kBackendFdPath), + const_cast(operation == BackendOperation::kInstall ? kInstallArgument : kRecoverArgument), + nullptr}; + char* environment[] = {nullptr}; +#ifdef SYS_execveat + syscall(SYS_execveat, kPythonDescriptor, "", arguments, environment, AT_EMPTY_PATH); +#endif + fexecve(kPythonDescriptor, arguments, environment); + _exit(126); + } + + bool ConfigureProcessSafety() { + struct sigaction action {}; + action.sa_handler = SIG_IGN; + if (sigemptyset(&action.sa_mask) != 0 || sigaction(SIGPIPE, &action, nullptr) != 0) { + return false; + } + struct rlimit no_core {}; + no_core.rlim_cur = 0; + no_core.rlim_max = 0; + return setrlimit(RLIMIT_CORE, &no_core) == 0; + } + + bool IsRootServiceIdentity() { + return getuid() == 0 && geteuid() == 0 && getgid() == 0 && getegid() == 0; + } + + bool WaitForSuccessfulBackend(pid_t child) { + int status = 0; + pid_t waited = -1; + do { + waited = waitpid(child, &status, 0); + } while (waited < 0 && errno == EINTR); + return waited == child && WIFEXITED(status) && WEXITSTATUS(status) == 0; + } + + bool OpenStableBackendComponents(OwnedDescriptor& python, OwnedDescriptor& backend, + OwnedDescriptor& updater) { + python.Reset(OpenRegularFile(kPythonPath, true, kMaximumPythonSize)); + backend.Reset(OpenRegularFile(kBackendPath, false, kMaximumTrustedScriptSize)); + updater.Reset(OpenRegularFile(kUpdaterPath, false, kMaximumTrustedScriptSize)); + return python && backend && updater; + } + +} // namespace + +int BootstrapSignedRelease(const char* archive_path) { + if (!IsRootServiceIdentity()) { + std::cerr << "release bootstrap requires the root service identity\n"; + return 1; + } + if (!ConfigureProcessSafety()) { + std::cerr << "release bootstrap cannot establish process safety controls\n"; + return 1; + } + if (archive_path == nullptr || archive_path[0] == '\0') { + std::cerr << "release bootstrap requires an archive path\n"; + return 1; + } + + OwnedDescriptor archive(open(archive_path, O_RDONLY | O_CLOEXEC)); + if (!archive || !IsRegularArchive(archive.Get())) { + std::cerr << "release bootstrap rejected archive type or size\n"; + return 1; + } + + OwnedDescriptor python; + OwnedDescriptor backend; + OwnedDescriptor updater; + if (!OpenStableBackendComponents(python, backend, updater)) { + std::cerr << "release bootstrap trusted backend is unavailable\n"; + return 1; + } + + EmbeddedReleaseKey key{}; + std::vector canonical_pem; + std::string error; + if (!LoadAndValidateEmbeddedReleaseKey(key, canonical_pem, error) || canonical_pem.empty() || + canonical_pem.size() > kMaximumPemSize) { + std::cerr << "release bootstrap trust-anchor validation failed"; + if (!error.empty()) { + std::cerr << ": " << error; + } + std::cerr << '\n'; + return 1; + } + + int channel_descriptors[2] = {-1, -1}; + if (socketpair(AF_UNIX, SOCK_STREAM | SOCK_CLOEXEC, 0, channel_descriptors) != 0) { + std::cerr << "release bootstrap cannot create its verifier channel\n"; + return 1; + } + OwnedDescriptor parent_channel(channel_descriptors[0]); + OwnedDescriptor child_channel(channel_descriptors[1]); + const pid_t child = StartBackend(BackendOperation::kInstall, archive.Get(), child_channel.Get(), + python.Get(), backend.Get(), updater.Get()); + archive.Reset(); + child_channel.Reset(); + if (child < 0) { + std::cerr << "release bootstrap cannot start the trusted backend\n"; + return 1; + } + + RequestHeader request{}; + bool approved = ReadAll(parent_channel.Get(), &request, sizeof(request)); + const std::size_t manifest_size = approved ? ntohl(request.manifest_size) : 0; + const std::size_t signature_size = approved ? ntohl(request.signature_size) : 0; + approved = approved && ntohl(request.magic) == kRequestMagic && manifest_size > 0 && + manifest_size <= kMaximumManifestSize && signature_size == 64; + + std::vector manifest; + std::array signature{}; + if (approved) { + try { + manifest.resize(manifest_size); + } catch (const std::bad_alloc&) { + approved = false; + error = "cannot allocate the bounded manifest buffer"; + } + } + if (approved) { + approved = ReadAll(parent_channel.Get(), manifest.data(), manifest.size()) && + ReadAll(parent_channel.Get(), signature.data(), signature.size()) && + VerifyEd25519Manifest(key, manifest.data(), manifest.size(), signature.data(), + signature.size(), error); + } + if (approved) { + const ApprovalHeader response{htonl(kApprovalMagic), + htonl(static_cast(canonical_pem.size()))}; + approved = WriteAll(parent_channel.Get(), &response, sizeof(response)) && + WriteAll(parent_channel.Get(), key.raw, sizeof(key.raw)) && + WriteAll(parent_channel.Get(), key.key_id, sizeof(key.key_id)) && + WriteAll(parent_channel.Get(), key.pem_sha256, sizeof(key.pem_sha256)) && + WriteAll(parent_channel.Get(), canonical_pem.data(), canonical_pem.size()); + } + parent_channel.Reset(); + + const bool backend_succeeded = WaitForSuccessfulBackend(child); + if (!approved) { + std::cerr << "release bootstrap embedded-key verification failed"; + if (!error.empty()) { + std::cerr << ": " << error; + } + std::cerr << '\n'; + return 1; + } + return backend_succeeded ? 0 : 1; +} + +int RecoverFactoryBootstrap() { + if (!IsRootServiceIdentity()) { + std::cerr << "release bootstrap recovery requires the root service identity\n"; + return 1; + } + if (!ConfigureProcessSafety()) { + std::cerr << "release bootstrap recovery cannot establish process safety controls\n"; + return 1; + } + + OwnedDescriptor python; + OwnedDescriptor backend; + OwnedDescriptor updater; + if (!OpenStableBackendComponents(python, backend, updater)) { + std::cerr << "release bootstrap stable recovery backend is unavailable\n"; + return 1; + } + + int channel_descriptors[2] = {-1, -1}; + if (socketpair(AF_UNIX, SOCK_STREAM | SOCK_CLOEXEC, 0, channel_descriptors) != 0) { + std::cerr << "release bootstrap recovery cannot create its private channel\n"; + return 1; + } + OwnedDescriptor parent_channel(channel_descriptors[0]); + OwnedDescriptor child_channel(channel_descriptors[1]); + const pid_t child = StartBackend(BackendOperation::kRecover, -1, child_channel.Get(), python.Get(), + backend.Get(), updater.Get()); + child_channel.Reset(); + if (child < 0) { + std::cerr << "release bootstrap recovery cannot start the trusted backend\n"; + return 1; + } + const bool backend_succeeded = WaitForSuccessfulBackend(child); + parent_channel.Reset(); + return backend_succeeded ? 0 : 1; +} + +} // namespace cosmo::bootstrap diff --git a/src/bootstrap/ReleaseBootstrap.h b/src/bootstrap/ReleaseBootstrap.h new file mode 100644 index 000000000..181f5a1f0 --- /dev/null +++ b/src/bootstrap/ReleaseBootstrap.h @@ -0,0 +1,14 @@ +#pragma once + +namespace cosmo::bootstrap { + +// Production entry point. The archive is the only variable input; roots, +// validation tools, backend script, and trust anchor are all fixed. +int BootstrapSignedRelease(const char* archive_path); + +// Stable factory-recovery entry point. This deliberately has no variable +// path or trust input and can therefore still run while the release facades +// are absent or being rolled back. +int RecoverFactoryBootstrap(); + +} // namespace cosmo::bootstrap diff --git a/src/bootstrap/ReleaseBootstrapVerifier.cc b/src/bootstrap/ReleaseBootstrapVerifier.cc new file mode 100644 index 000000000..559c4af4d --- /dev/null +++ b/src/bootstrap/ReleaseBootstrapVerifier.cc @@ -0,0 +1,127 @@ +#include "bootstrap/ReleaseBootstrapVerifier.h" + +#include +#include +#include +#include +#include + +#include +#include +#include +#include + +extern "C" { +extern const std::uint8_t cosmo_release_public_key_raw_v1[32]; +extern const std::uint8_t cosmo_release_public_key_id_v1[16]; +extern const std::uint8_t cosmo_release_public_key_pem_sha256_v1[32]; +} + +namespace cosmo::bootstrap { +namespace { + + constexpr std::array kReleaseKeyIdDomain = {'c', 'o', 's', 'm', 'o', '-', 'r', 'e', + 'l', 'e', 'a', 's', 'e', '-', 'k', 'e', + 'y', '-', 'i', 'd', '-', 'v', '1'}; + + using BioPtr = std::unique_ptr; + using KeyPtr = std::unique_ptr; + using KeyContextPtr = std::unique_ptr; + + bool IsAllZero(const std::uint8_t* data, std::size_t size) { + std::uint8_t aggregate = 0; + for (std::size_t index = 0; index < size; ++index) { + aggregate = static_cast(aggregate | data[index]); + } + return aggregate == 0; + } + + bool BuildCanonicalPem(const std::uint8_t* raw, std::vector& pem, std::string& error) { + KeyPtr key(EVP_PKEY_new_raw_public_key(EVP_PKEY_ED25519, nullptr, raw, 32), &EVP_PKEY_free); + if (!key) { + error = "cannot construct the embedded Ed25519 public key"; + return false; + } + BioPtr output(BIO_new(BIO_s_mem()), &BIO_free); + if (!output || PEM_write_bio_PUBKEY(output.get(), key.get()) != 1) { + error = "cannot encode the embedded Ed25519 public key"; + return false; + } + const char* bytes = nullptr; + const long length = BIO_get_mem_data(output.get(), &bytes); + if (length <= 0 || length > 16 * 1024 || bytes == nullptr) { + error = "embedded public-key PEM encoding has an invalid size"; + return false; + } + pem.assign(bytes, bytes + length); + return true; + } + +} // namespace + +bool LoadAndValidateEmbeddedReleaseKey(EmbeddedReleaseKey& key, std::vector& canonical_pem, + std::string& error) { + std::copy_n(cosmo_release_public_key_raw_v1, sizeof(key.raw), key.raw); + std::copy_n(cosmo_release_public_key_id_v1, sizeof(key.key_id), key.key_id); + std::copy_n(cosmo_release_public_key_pem_sha256_v1, sizeof(key.pem_sha256), key.pem_sha256); + if (IsAllZero(key.raw, sizeof(key.raw)) || IsAllZero(key.key_id, sizeof(key.key_id)) || + IsAllZero(key.pem_sha256, sizeof(key.pem_sha256))) { + error = "embedded release trust anchor contains a zero identity"; + return false; + } + + std::array key_id_digest{}; + SHA256_CTX key_id_context; + if (SHA256_Init(&key_id_context) != 1 || + SHA256_Update(&key_id_context, kReleaseKeyIdDomain.data(), kReleaseKeyIdDomain.size()) != 1) { + error = "cannot derive the embedded release key ID"; + return false; + } + const std::array version = {0, 1}; + if (SHA256_Update(&key_id_context, version.data(), version.size()) != 1 || + SHA256_Update(&key_id_context, key.raw, sizeof(key.raw)) != 1 || + SHA256_Final(key_id_digest.data(), &key_id_context) != 1) { + error = "cannot derive the embedded release key ID"; + return false; + } + if (CRYPTO_memcmp(key.key_id, key_id_digest.data(), sizeof(key.key_id)) != 0) { + error = "embedded release key ID does not match the raw public key"; + return false; + } + + if (!BuildCanonicalPem(key.raw, canonical_pem, error)) { + return false; + } + std::array pem_digest{}; + if (SHA256(canonical_pem.data(), canonical_pem.size(), pem_digest.data()) == nullptr || + CRYPTO_memcmp(key.pem_sha256, pem_digest.data(), sizeof(key.pem_sha256)) != 0) { + error = "embedded release PEM digest does not match the raw public key"; + return false; + } + return true; +} + +bool VerifyEd25519Manifest(const EmbeddedReleaseKey& key, const std::uint8_t* manifest, + std::size_t manifest_size, const std::uint8_t* signature, + std::size_t signature_size, std::string& error) { + if (manifest == nullptr || manifest_size == 0 || manifest_size > 128 * 1024 || signature == nullptr || + signature_size != 64) { + error = "bootstrap manifest or signature size is invalid"; + return false; + } + KeyPtr public_key(EVP_PKEY_new_raw_public_key(EVP_PKEY_ED25519, nullptr, key.raw, sizeof(key.raw)), + &EVP_PKEY_free); + KeyContextPtr context(EVP_MD_CTX_new(), &EVP_MD_CTX_free); + if (!public_key || !context || + EVP_DigestVerifyInit(context.get(), nullptr, nullptr, nullptr, public_key.get()) != 1) { + error = "cannot initialize embedded-key signature verification"; + return false; + } + if (EVP_DigestVerify(context.get(), signature, signature_size, manifest, manifest_size) != 1) { + error = "release manifest signature does not match the embedded key"; + return false; + } + return true; +} + +} // namespace cosmo::bootstrap diff --git a/src/bootstrap/ReleaseBootstrapVerifier.h b/src/bootstrap/ReleaseBootstrapVerifier.h new file mode 100644 index 000000000..0bed71f33 --- /dev/null +++ b/src/bootstrap/ReleaseBootstrapVerifier.h @@ -0,0 +1,25 @@ +#pragma once + +#include +#include +#include +#include + +namespace cosmo::bootstrap { + +struct EmbeddedReleaseKey { + std::uint8_t raw[32]; + std::uint8_t key_id[16]; + std::uint8_t pem_sha256[32]; +}; + +// Reads the three frozen symbols from the linked trust-anchor object, derives +// their expected identities, and emits the canonical Ed25519 public-key PEM. +bool LoadAndValidateEmbeddedReleaseKey(EmbeddedReleaseKey& key, std::vector& canonical_pem, + std::string& error); + +bool VerifyEd25519Manifest(const EmbeddedReleaseKey& key, const std::uint8_t* manifest, + std::size_t manifest_size, const std::uint8_t* signature, + std::size_t signature_size, std::string& error); + +} // namespace cosmo::bootstrap diff --git a/src/bootstrap/main.cc b/src/bootstrap/main.cc new file mode 100644 index 000000000..bd970e492 --- /dev/null +++ b/src/bootstrap/main.cc @@ -0,0 +1,16 @@ +#include +#include + +#include "bootstrap/ReleaseBootstrap.h" + +int main(int argc, char** argv) { + if (argc == 3 && std::string_view(argv[1]) == "install") { + return cosmo::bootstrap::BootstrapSignedRelease(argv[2]); + } + if (argc == 2 && std::string_view(argv[1]) == "recover") { + return cosmo::bootstrap::RecoverFactoryBootstrap(); + } + std::cerr << "Usage: cosmo-release-bootstrap install \n" + " cosmo-release-bootstrap recover\n"; + return 2; +} diff --git a/src/infer/AiClassifierUnify.cc b/src/infer/AiClassifierUnify.cc index 490e9dc6e..3e2ddf660 100644 --- a/src/infer/AiClassifierUnify.cc +++ b/src/infer/AiClassifierUnify.cc @@ -23,8 +23,10 @@ util::ErrorEnum AiClassifierUnify::Init() { return util::ErrorEnum::Created; } + cosmo::nn::DefaultComponent::Options options; + options.profiler = &profiler_; classifier_ = - std::make_unique(cfg_path_, model_path_, GetDeviceType(), &profiler_); + std::make_unique(options, cfg_path_, model_path_, GetDeviceType()); LOG_DEBUG("DEBUG: Classifier {} Init", model_path_); max_batch_size_ = static_cast(classifier_->GetMaxBatchSize()); diff --git a/src/infer/AiDetectorUnify.cc b/src/infer/AiDetectorUnify.cc index e6be8b28b..78597caa4 100644 --- a/src/infer/AiDetectorUnify.cc +++ b/src/infer/AiDetectorUnify.cc @@ -25,8 +25,10 @@ util::ErrorEnum AiDetectorUnify::Init() { } try { - detector_ = std::make_unique(cfg_path_, model_path_, GetDeviceType(), - &profiler_); + cosmo::nn::DefaultComponent::Options options; + options.profiler = &profiler_; + detector_ = + std::make_unique(options, cfg_path_, model_path_, GetDeviceType()); } catch (const std::exception& e) { LOG_ERRO("Init SDK Detector Failed. CfgPath:{} ModelPath:{}, {}", cfg_path_, model_path_, e.what()); detector_.reset(); diff --git a/src/infer/AiLandmarkerUnify.cc b/src/infer/AiLandmarkerUnify.cc index 50d944857..e0bb77bce 100644 --- a/src/infer/AiLandmarkerUnify.cc +++ b/src/infer/AiLandmarkerUnify.cc @@ -10,6 +10,10 @@ namespace cosmo { AiLandmarkerUnify::AiLandmarkerUnify(const std::string& jsonPath, const std::string& modelPath) : cfg_path_(jsonPath), model_path_(modelPath) {} +AiLandmarkerUnify::AiLandmarkerUnify(const std::string&, const std::string& jsonPath, + const std::string& modelPath) + : cfg_path_(jsonPath), model_path_(modelPath) {} + AiLandmarkerUnify::~AiLandmarkerUnify() { LOG_INFO("{}", "AiLandmarkerUnify Delete"); } @@ -20,8 +24,10 @@ util::ErrorEnum AiLandmarkerUnify::Init() { return util::ErrorEnum::Created; } + cosmo::nn::DefaultComponent::Options options; + options.profiler = &profiler_; landmarker_ = - std::make_unique(cfg_path_, model_path_, GetDeviceType(), &profiler_); + std::make_unique(options, cfg_path_, model_path_, GetDeviceType()); LOG_DEBUG("Landmarker {} Init", model_path_); max_batch_size_ = static_cast(landmarker_->GetMaxBatchSize()); @@ -242,4 +248,4 @@ util::ErrorEnum AiLandmarkerUnify::GetMaxBatchSize(size_t& value) { value = static_cast(landmarker_->GetMaxBatchSize()); return util::ErrorEnum::Success; } -} // namespace cosmo \ No newline at end of file +} // namespace cosmo diff --git a/src/infer/AiLandmarkerUnify.h b/src/infer/AiLandmarkerUnify.h index f57f10452..0804f2d0f 100644 --- a/src/infer/AiLandmarkerUnify.h +++ b/src/infer/AiLandmarkerUnify.h @@ -17,10 +17,8 @@ class AiLandmarkerUnify { AiLandmarkerUnify(const std::string& jsonPath, const std::string& modelPath); /// 3-arg constructor for InstancePool template compatibility. - /// The atomicCode is unused — pool-level keying is handled by InferPoolServiceImpl. - AiLandmarkerUnify(const std::string& /*atomicCode*/, const std::string& jsonPath, - const std::string& modelPath) - : AiLandmarkerUnify(jsonPath, modelPath) {} + AiLandmarkerUnify(const std::string& atomicCode, const std::string& jsonPath, + const std::string& modelPath); ~AiLandmarkerUnify(); util::ErrorEnum Init(); diff --git a/src/infer/AiOcrWordClassifierUnify.cc b/src/infer/AiOcrWordClassifierUnify.cc index d2fe7a1b1..52d053852 100644 --- a/src/infer/AiOcrWordClassifierUnify.cc +++ b/src/infer/AiOcrWordClassifierUnify.cc @@ -31,8 +31,11 @@ util::ErrorEnum AiOcrWordClassifierUnify::Init() { return util::ErrorEnum::Failed; } try { - classifier_ = std::make_unique(json_path_, model_path_, GetDeviceType(), - &profiler_, "", word_dict_path_); + cosmo::nn::DefaultComponent::Options options; + options.profiler = &profiler_; + options.word_table_path = word_dict_path_; + classifier_ = + std::make_unique(options, json_path_, model_path_, GetDeviceType()); } catch (const std::exception& e) { LOG_ERRO("Init OCR classifier failed. AlgCode:{} Error:{}", atomic_code_, e.what()); classifier_.reset(); diff --git a/src/infer/AiRecognizerUnify.cc b/src/infer/AiRecognizerUnify.cc index 61e503c3c..6c0316da7 100644 --- a/src/infer/AiRecognizerUnify.cc +++ b/src/infer/AiRecognizerUnify.cc @@ -8,6 +8,10 @@ namespace cosmo { AiRecognizerUnify::AiRecognizerUnify(const std::string& json_path, const std::string& model_path) : cfg_path_(json_path), model_path_(model_path) {} +AiRecognizerUnify::AiRecognizerUnify(const std::string&, const std::string& json_path, + const std::string& model_path) + : cfg_path_(json_path), model_path_(model_path) {} + AiRecognizerUnify::~AiRecognizerUnify() { LOG_INFO("{}", "AiRecognizerUnify Delete"); } @@ -19,8 +23,10 @@ util::ErrorEnum AiRecognizerUnify::Init() { } try { - recognizer_ = std::make_unique(cfg_path_, model_path_, GetDeviceType(), - &profiler_); + cosmo::nn::DefaultComponent::Options options; + options.profiler = &profiler_; + recognizer_ = + std::make_unique(options, cfg_path_, model_path_, GetDeviceType()); } catch (const std::exception& e) { LOG_ERRO("Init SDK Recognizer Failed. CfgPath:{} ModelPath:{}, {}", cfg_path_, model_path_, e.what()); recognizer_.reset(); @@ -333,4 +339,4 @@ util::ErrorEnum AiRecognizerUnify::GetMaxBatchSize(size_t& value) { value = static_cast(recognizer_->GetMaxBatchSize()); return util::ErrorEnum::Success; } -} // namespace cosmo \ No newline at end of file +} // namespace cosmo diff --git a/src/infer/AiRecognizerUnify.h b/src/infer/AiRecognizerUnify.h index 8cc5791ff..6bb27816d 100644 --- a/src/infer/AiRecognizerUnify.h +++ b/src/infer/AiRecognizerUnify.h @@ -15,10 +15,8 @@ class AiRecognizerUnify { AiRecognizerUnify(const std::string& json_path, const std::string& model_path); /// 3-arg constructor for InstancePool template compatibility. - /// The atomicCode is unused — pool-level keying is handled by InferPoolServiceImpl. - AiRecognizerUnify(const std::string& /*atomic_code*/, const std::string& json_path, - const std::string& model_path) - : AiRecognizerUnify(json_path, model_path) {} + AiRecognizerUnify(const std::string& atomic_code, const std::string& json_path, + const std::string& model_path); ~AiRecognizerUnify(); util::ErrorEnum Init(); diff --git a/src/infer/DinoDetectorUnify.cc b/src/infer/DinoDetectorUnify.cc index 0d6dc9a14..9bf2fb11b 100644 --- a/src/infer/DinoDetectorUnify.cc +++ b/src/infer/DinoDetectorUnify.cc @@ -31,9 +31,11 @@ util::ErrorEnum DinoDetectorUnify::Init() { } try { - // Pass vocabPath as the tokenizer_path parameter - detector_ = std::make_unique(cfg_path_, model_path_, GetDeviceType(), - &profiler_, vocab_path_); + cosmo::nn::DefaultComponent::Options options; + options.profiler = &profiler_; + options.tokenizer_path = vocab_path_; + detector_ = + std::make_unique(options, cfg_path_, model_path_, GetDeviceType()); } catch (const std::exception& e) { LOG_ERRO("Init SDK Dino Detector Failed. CfgPath:{} ModelPath:{} VocabPath:{}, {}", cfg_path_, model_path_, vocab_path_, e.what()); diff --git a/src/infer/Qwen3VLUnify.cc b/src/infer/Qwen3VLUnify.cc index e5cc694ea..f1a2e4cbe 100644 --- a/src/infer/Qwen3VLUnify.cc +++ b/src/infer/Qwen3VLUnify.cc @@ -10,12 +10,9 @@ #include "util/UuidUtil.h" namespace cosmo { -Qwen3VLUnify::Qwen3VLUnify(const std::string& atomic_code, const std::string& json_path, - const std::string& model_path, const std::string& tokenizer_path) - : atomic_code_(atomic_code), - cfg_path_(json_path), - model_path_(model_path), - tokenizer_path_(tokenizer_path) {} +Qwen3VLUnify::Qwen3VLUnify(const std::string&, const std::string& json_path, const std::string& model_path, + const std::string& tokenizer_path) + : cfg_path_(json_path), model_path_(model_path), tokenizer_path_(tokenizer_path) {} Qwen3VLUnify::~Qwen3VLUnify() { LOG_INFO("{}", "Qwen3VLUnify Delete"); @@ -28,9 +25,11 @@ util::ErrorEnum Qwen3VLUnify::Init() { } try { - // Pass tokenizer_path as the tokenizer_path parameter - generator_ = std::make_unique(cfg_path_, model_path_, GetDeviceType(), - &profiler_, tokenizer_path_); + cosmo::nn::DefaultComponent::Options options; + options.profiler = &profiler_; + options.tokenizer_path = tokenizer_path_; + generator_ = + std::make_unique(options, cfg_path_, model_path_, GetDeviceType()); } catch (const std::exception& e) { LOG_ERRO("Init SDK Qwen3VL Failed. CfgPath:{} ModelPath:{} TokenizerPath:{}, {}", cfg_path_, model_path_, tokenizer_path_, e.what()); diff --git a/src/infer/Qwen3VLUnify.h b/src/infer/Qwen3VLUnify.h index da70998cf..7a2b3a011 100644 --- a/src/infer/Qwen3VLUnify.h +++ b/src/infer/Qwen3VLUnify.h @@ -46,7 +46,6 @@ class Qwen3VLUnify { private: size_t max_batch_size_{1}; - std::string atomic_code_; std::string cfg_path_; std::string model_path_; std::string tokenizer_path_; diff --git a/src/infer/Sam2SegmenterUnify.cc b/src/infer/Sam2SegmenterUnify.cc index 5c42bad45..be38d0cfc 100644 --- a/src/infer/Sam2SegmenterUnify.cc +++ b/src/infer/Sam2SegmenterUnify.cc @@ -9,9 +9,9 @@ #include "util/UuidUtil.h" namespace cosmo { -Sam2SegmenterUnify::Sam2SegmenterUnify(const std::string &atomicCode, const std::string &jsonPath, +Sam2SegmenterUnify::Sam2SegmenterUnify(const std::string &, const std::string &jsonPath, const std::string &modelPath) - : atomic_code_(atomicCode), cfg_path_(jsonPath), model_path_(modelPath) {} + : cfg_path_(jsonPath), model_path_(modelPath) {} Sam2SegmenterUnify::~Sam2SegmenterUnify() { LOG_INFO("{}", "Sam2SegmenterUnify Delete"); @@ -25,8 +25,10 @@ util::ErrorEnum Sam2SegmenterUnify::Init() { try { // SAM2 contains encoder+decoder; current SDK does not support chaining, use single model for now - segmenter_ = std::make_unique(cfg_path_, model_path_, GetDeviceType(), - &profiler_); + cosmo::nn::DefaultComponent::Options options; + options.profiler = &profiler_; + segmenter_ = + std::make_unique(options, cfg_path_, model_path_, GetDeviceType()); } catch (const std::exception &e) { LOG_ERRO("Init SDK SAM2 Segmenter Failed. CfgPath:{} ModelPath:{}, {}", cfg_path_, model_path_, e.what()); diff --git a/src/infer/Sam2SegmenterUnify.h b/src/infer/Sam2SegmenterUnify.h index 52a591d74..980972962 100644 --- a/src/infer/Sam2SegmenterUnify.h +++ b/src/infer/Sam2SegmenterUnify.h @@ -40,7 +40,6 @@ class Sam2SegmenterUnify { private: size_t max_batch_size_ = 1; - std::string atomic_code_; std::string cfg_path_; std::string model_path_; std::unique_ptr segmenter_; diff --git a/src/nn/CMakeLists.txt b/src/nn/CMakeLists.txt index 087202c7d..4655d8db1 100644 --- a/src/nn/CMakeLists.txt +++ b/src/nn/CMakeLists.txt @@ -5,12 +5,13 @@ set(NN_DIR ${CMAKE_CURRENT_SOURCE_DIR}) # ── Sophon SDK path (used only for Sophon backend) ── if(COSMO_NN_USE_SOPHON_BACKEND) - set(SOPHON_ROOT_DIR ${CMAKE_SOURCE_DIR}/3rd/libsophon-0.4.11) + set(SOPHON_ROOT_DIR ${DEVICE_ROOT_DIR}) endif() # ── Collect NN source files ── file(GLOB_RECURSE NN_ALL_SRC "${NN_DIR}/core/*.h" "${NN_DIR}/core/*.cc" + "${NN_DIR}/guard/*.h" "${NN_DIR}/guard/*.cc" "${NN_DIR}/node/*.h" "${NN_DIR}/node/*.cc" "${NN_DIR}/pipeline/*.h" "${NN_DIR}/pipeline/*.cc" "${NN_DIR}/utils/*.h" "${NN_DIR}/utils/*.cc" @@ -57,6 +58,7 @@ endif() if(COSMO_MODEL_GUARD) target_compile_definitions(cosmo_nn PRIVATE COSMO_HAS_MODEL_GUARD=1) + target_link_libraries(cosmo_nn PRIVATE cosmo_model_guard_v2) endif() if(COSMO_NN_USE_CPU_BACKEND) diff --git a/src/nn/core/graph.cc b/src/nn/core/graph.cc index 077bc1b78..91e1309ef 100644 --- a/src/nn/core/graph.cc +++ b/src/nn/core/graph.cc @@ -27,9 +27,10 @@ #include "nn/utils/string_format.h" #include "util/DurationLogger.h" -#if defined(COSMO_NN_USE_SOPHON_BACKEND) && defined(COSMO_HAS_MODEL_GUARD) +#ifdef COSMO_NN_USE_SOPHON_BACKEND #include "nn/device/sophon/sophon_net_node.h" -#include "nn/guard/cosmo_model_guard.h" +#include "nn/guard/CemV2SophonLoader.h" +#include "nn/guard/ModelLoadPolicy.h" #endif namespace cosmo::nn { @@ -622,79 +623,94 @@ Status Graph::LoadWeight(const std::string& model_path) { int net_node_num = NodeTypeUtils::TypedNodeCount(nodes, NODE_NET); - std::ifstream stream(model_path, std::ios::in | std::ios::binary); -#ifdef COSMO_NN_USE_ONNX_BACKEND - if (stream.fail() && !std::filesystem::is_directory(model_path)) - return Status(COSMO_NN_ERR_LOAD_MODEL, "open model file failed"); -#else - if (stream.fail()) - return Status(COSMO_NN_ERR_LOAD_MODEL, "open model file failed"); -#endif + std::string authorized_model_path; + +#ifdef COSMO_NN_USE_SOPHON_BACKEND + // CEMC authorization belongs exclusively to Guard. Edge only routes by + // file format and does not derive a second model identity. + const ModelLoadDecision load_decision = + ModelLoadPolicy::Production().Evaluate(model_path, ModelLoadIntent::kCosmoNn); + if (!load_decision.IsAllowed()) { + return Status(COSMO_NN_ERR_LOAD_MODEL, "model format is not supported"); + } + authorized_model_path = load_decision.model_path; -#if defined(COSMO_NN_USE_SOPHON_BACKEND) && defined(COSMO_HAS_MODEL_GUARD) - // Peek first 4 bytes to detect encrypted model. - uint32_t magic = 0; - stream.read(reinterpret_cast(&magic), sizeof(magic)); - stream.seekg(0); - - if (cosmo::guard::IsEncryptedModel(magic)) { - // File-based guard API: the .so reads segments on demand from disk. - // No need to load the entire encrypted file into memory. - stream.close(); - - // Validate segment count matches graph structure. - int seg_count = cosmo::guard::GetEncryptedSegmentCountFromFile(model_path.c_str()); - if (seg_count < 0) - return Status(COSMO_NN_ERR_LOAD_MODEL, "Failed to read encrypted segment count (error: " + - std::to_string(seg_count) + ")"); - if (seg_count != net_node_num) - return Status(COSMO_NN_ERR_LOAD_MODEL, "Encrypted segment count (" + std::to_string(seg_count) + - ") does not match graph net nodes (" + - std::to_string(net_node_num) + ")"); - - // Decrypt and load each segment into its own independent bmrt, - // mirroring the plaintext path where each NetNode has its own bmrt. - for (int i = 0; i < net_node_num; i++) { - auto net_node = GetNodeByName("net_" + std::to_string(i)); - if (!net_node) - return Status(COSMO_NN_ERR_LOAD_MODEL, "Can not find net node"); - - // The .so handles everything internally per segment: - // open file → seek to segment → read ciphertext - // → SN validation → key derivation → decrypt → bmrt_create → bmrt_load → wipe - void* bmrt = nullptr; - int ret = 0; - { - cosmo::util::DurationLogger logger("DecryptAndLoadSegmentFromFile net_" + std::to_string(i)); - ret = cosmo::guard::DecryptAndLoadSegmentFromFile( - model_path.c_str(), shared_resource->m_handle, static_cast(i), &bmrt); + if (load_decision.action == ModelLoadAction::kGuardV2) { + if (net_node_num <= 0 || shared_resource == nullptr || shared_resource->m_handle == nullptr) { + return Status(COSMO_NN_ERR_LOAD_MODEL, "protected model graph shape is invalid"); + } + + // Validate every ownership destination before opening the artifact. The + // guard then authenticates once and loads every segment through the + // same immutable artifact handle. + std::vector target_nodes; + try { + target_nodes.reserve(static_cast(net_node_num)); + } catch (const std::bad_alloc&) { + return Status(COSMO_NN_ERR_OUT_OF_MEMORY, "protected model graph allocation failed"); + } + for (int index = 0; index < net_node_num; ++index) { + Node* node = GetNodeByName("net_" + std::to_string(index)); + auto* sophon_node = dynamic_cast(node); + if (sophon_node == nullptr) { + return Status(COSMO_NN_ERR_LOAD_MODEL, "protected model requires Sophon network nodes"); } - if (ret != 0 || !bmrt) - return Status(COSMO_NN_ERR_LOAD_MODEL, - "Encrypted segment " + std::to_string(i) + - " load failed (guard error: " + std::to_string(ret) + ")"); - - // AttachBmrt is on SophonNetNode only (ISP: not on NetNode base class). - // Within this #ifdef block, the node is guaranteed to be SophonNetNode. - auto* sophon_node = dynamic_cast(net_node); - if (!sophon_node) { - bmrt_destroy(bmrt); - return Status(COSMO_NN_ERR_LOAD_MODEL, "Expected SophonNetNode for encrypted model"); + target_nodes.push_back(sophon_node); + } + + SophonModelLoadResult loaded; + { + cosmo::util::DurationLogger logger("Load protected CEM v2 artifact"); + loaded = + LoadCemV2SophonArtifact(FrozenCemV2Api(), NativeSophonRuntimeApi(), authorized_model_path, + CMG_V2_SOURCE_COSMO_NN_V1, shared_resource->m_handle, 0); + } + if (!loaded.IsSuccess() || loaded.runtimes.size() != target_nodes.size()) { + if (loaded.IsOutOfMemory()) { + return Status(COSMO_NN_ERR_OUT_OF_MEMORY, "protected model load ran out of memory"); + } + return Status(COSMO_NN_ERR_LOAD_MODEL, "protected model load failed"); + } + + for (size_t index = 0; index < target_nodes.size(); ++index) { + OwnedBmrt runtime(loaded.runtimes[index].release()); + Status attach_status = target_nodes[index]->AttachOwnedBmrt(std::move(runtime)); + if (!bool(attach_status)) { + // Some earlier runtimes may already have transferred to their + // nodes. Destroy the whole failed graph immediately so later + // segments and attached runtimes cannot survive partial init. + nodes.clear(); + return attach_status; } - RETURN_ON_FAIL(sophon_node->AttachBmrt(bmrt)); } return COSMO_NN_OK; } + + // A protected CEMC decision is handled above and every failure returns. + if (load_decision.action != ModelLoadAction::kNativeCenn) { + return Status(COSMO_NN_ERR_LOAD_MODEL, "model loader action is not available"); + } +#else + authorized_model_path = model_path; +#endif + + std::ifstream stream(authorized_model_path, std::ios::in | std::ios::binary); +#ifdef COSMO_NN_USE_ONNX_BACKEND + if (stream.fail() && !std::filesystem::is_directory(authorized_model_path)) + return Status(COSMO_NN_ERR_LOAD_MODEL, "open model file failed"); +#else + if (stream.fail()) + return Status(COSMO_NN_ERR_LOAD_MODEL, "open model file failed"); #endif #ifdef COSMO_NN_USE_ONNX_BACKEND stream.close(); namespace fs = std::filesystem; - fs::path base_path(model_path); + fs::path base_path(authorized_model_path); if (net_node_num == 1 && fs::is_regular_file(base_path)) { - std::ifstream model_stream(model_path, std::ios::in | std::ios::binary); + std::ifstream model_stream(authorized_model_path, std::ios::in | std::ios::binary); if (model_stream.fail()) return Status(COSMO_NN_ERR_LOAD_MODEL, "open model file failed"); diff --git a/src/nn/device/sophon/qwen3_5/qwen3_5_model.cc b/src/nn/device/sophon/qwen3_5/qwen3_5_model.cc index e1939d383..f149aefbb 100644 --- a/src/nn/device/sophon/qwen3_5/qwen3_5_model.cc +++ b/src/nn/device/sophon/qwen3_5/qwen3_5_model.cc @@ -250,16 +250,12 @@ namespace qwen3_5 { if (bm_handle_ != nullptr || p_bmrt_ != nullptr || model_path.empty()) { throw safety::RuntimeError("initialize model", "already initialized or empty model path"); } + + const RawBmodelLoadPlan load_plan = safety::AuthorizeRawBmodel(model_path); + try { safety::CheckStatus(bm_dev_request(&bm_handle_, dev_id), "request device"); - p_bmrt_ = bmrt_create(bm_handle_); - if (p_bmrt_ == nullptr) { - throw safety::RuntimeError("create runtime"); - } - bmrt_set_flags(p_bmrt_, BM_RUNTIME_SHARE_MEM); - if (!bmrt_load_bmodel(p_bmrt_, model_path.c_str())) { - throw safety::RuntimeError("load model", model_path); - } + p_bmrt_ = safety::LoadSingleAuthorizedRawBmodel(load_plan, bm_handle_); safety::CheckStatus(bm_thread_sync(bm_handle_), "synchronize model load"); init_by_names(); const size_t token_capacity = static_cast(std::max(SEQLEN, MAX_INPUT_LENGTH)) + 1U; diff --git a/src/nn/device/sophon/qwen3_5/qwen3_5_model.h b/src/nn/device/sophon/qwen3_5/qwen3_5_model.h index 288df7163..34c514e90 100644 --- a/src/nn/device/sophon/qwen3_5/qwen3_5_model.h +++ b/src/nn/device/sophon/qwen3_5/qwen3_5_model.h @@ -29,7 +29,7 @@ namespace qwen3_5 { ~Qwen3_5Model(); void init(int dev_id, const std::string& model_path, bool do_sample, - const std::string& model_config_json_path = ""); + const std::string& model_config_json_path); void deinit(); void forward_embed(const ArrayInt& tokens); void forward_vit(const float* pixel_values, const ArrayInt& position_ids, const ArrayInt& pos_idx, diff --git a/src/nn/device/sophon/qwen3vl/qwen3vl_model.cc b/src/nn/device/sophon/qwen3vl/qwen3vl_model.cc index 0269f06af..e0605239d 100644 --- a/src/nn/device/sophon/qwen3vl/qwen3vl_model.cc +++ b/src/nn/device/sophon/qwen3vl/qwen3vl_model.cc @@ -338,19 +338,12 @@ namespace qwen3vl { if (bm_handle_ != nullptr || p_bmrt_ != nullptr || model_path.empty()) { throw safety::RuntimeError("initialize model", "already initialized or empty model path"); } + + const RawBmodelLoadPlan load_plan = safety::AuthorizeRawBmodel(model_path); + try { safety::CheckStatus(bm_dev_request(&bm_handle_, dev_id), "request device"); - p_bmrt_ = bmrt_create(bm_handle_); - if (p_bmrt_ == nullptr) { - throw safety::RuntimeError("create runtime"); - } - bmrt_set_flags(p_bmrt_, BM_RUNTIME_SHARE_MEM); - /* Qwen3VL model file: uses .nn extension, content is raw bmodel without extra header, loaded - * directly as bmodel to save memory and storage - */ - if (!bmrt_load_bmodel(p_bmrt_, model_path.c_str())) { - throw safety::RuntimeError("load model", model_path); - } + p_bmrt_ = safety::LoadSingleAuthorizedRawBmodel(load_plan, bm_handle_); safety::CheckStatus(bm_thread_sync(bm_handle_), "synchronize model load"); init_by_names(); const size_t token_capacity = static_cast(std::max(SEQLEN, MAX_INPUT_LENGTH)) + 1U; diff --git a/src/nn/device/sophon/qwen3vl/qwen3vl_model.h b/src/nn/device/sophon/qwen3vl/qwen3vl_model.h index 572366f33..89b46d9f9 100644 --- a/src/nn/device/sophon/qwen3vl/qwen3vl_model.h +++ b/src/nn/device/sophon/qwen3vl/qwen3vl_model.h @@ -6,7 +6,6 @@ #include "bmlib_runtime.h" #include "bmruntime_interface.h" - namespace cosmo::nn { namespace qwen3vl { @@ -35,7 +34,7 @@ namespace qwen3vl { * reads from config.generation for generation params; tokenizer etc. loaded by runner from * config_dir, no directory passed here */ void init(int dev_id, const std::string& model_path, bool do_sample, - const std::string& model_config_json_path = ""); + const std::string& model_config_json_path); void deinit(); void forward_embed(const ArrayInt& tokens); void forward_vit(const float* pixel_values, const ArrayInt& position_ids, const ArrayInt& pos_idx, diff --git a/src/nn/device/sophon/qwen_runtime_safety.h b/src/nn/device/sophon/qwen_runtime_safety.h index 5634e6f6b..7d02702b2 100644 --- a/src/nn/device/sophon/qwen_runtime_safety.h +++ b/src/nn/device/sophon/qwen_runtime_safety.h @@ -2,11 +2,13 @@ #include #include +#include #include #include #include "bmlib_runtime.h" #include "bmruntime_interface.h" +#include "nn/guard/CemV2SophonLoader.h" namespace cosmo::nn::qwen_runtime_safety { @@ -144,4 +146,41 @@ inline void FreeHandleNoThrow(bm_handle_t* handle) noexcept { *handle = nullptr; } +inline RawBmodelLoadPlan RequireAuthorizedRawBmodel(RawBmodelLoadPlan plan) { + switch (plan.error) { + case RawBmodelAuthorizationError::kNone: + return plan; + case RawBmodelAuthorizationError::kPolicyRejected: + default: + throw RuntimeError("authorize model", "format rejected"); + } +} + +inline RawBmodelLoadPlan AuthorizeRawBmodel(const std::string& model_path) { + return RequireAuthorizedRawBmodel(PrepareRawBmodelLoad(ModelLoadPolicy::Production(), model_path)); +} + +inline void* LoadSingleAuthorizedRawBmodel(const RawBmodelLoadPlan& plan, const CemV2Api& guard_api, + const SophonRuntimeApi& runtime_api, bm_handle_t bm_handle) { + SophonModelLoadResult loaded = LoadRawBmodelByPlan(plan, guard_api, runtime_api, bm_handle); + if (!loaded.IsSuccess() || loaded.runtimes.size() != 1) { + if (loaded.IsOutOfMemory()) { + throw std::bad_alloc(); + } + if (loaded.error == SophonModelLoadError::kNativeRuntimeCreateFailed) { + throw RuntimeError("create runtime"); + } + if (loaded.error == SophonModelLoadError::kNativeLoadFailed && + plan.decision.action == ModelLoadAction::kNativeRawBmodel) { + throw RuntimeError("load model", plan.decision.model_path); + } + throw RuntimeError("load authorized model"); + } + return loaded.runtimes.front().release(); +} + +inline void* LoadSingleAuthorizedRawBmodel(const RawBmodelLoadPlan& plan, bm_handle_t bm_handle) { + return LoadSingleAuthorizedRawBmodel(plan, FrozenCemV2Api(), NativeSophonRuntimeApi(), bm_handle); +} + } // namespace cosmo::nn::qwen_runtime_safety diff --git a/src/nn/device/sophon/sophon_net_node.cc b/src/nn/device/sophon/sophon_net_node.cc index e2ae65c52..4d0e1a3d7 100644 --- a/src/nn/device/sophon/sophon_net_node.cc +++ b/src/nn/device/sophon/sophon_net_node.cc @@ -181,6 +181,10 @@ namespace { } } // namespace +void BmrtDeleter::operator()(void* runtime) const noexcept { + DestroyBmRuntime(&runtime); +} + std::mutex SophonNetNode::sophon_net_mutex; SophonNetNode::SophonNetNode() : NetNode() {} @@ -436,9 +440,9 @@ Status SophonNetNode::LoadWeight(const char* data, size_t size) { } } -Status SophonNetNode::AttachBmrt(void* bmrt_handle) { +Status SophonNetNode::AttachOwnedBmrt(OwnedBmrt bmrt_handle) { if (!bmrt_handle) - return Status(COSMO_NN_ERR_LOAD_MODEL, "AttachBmrt: bmrt handle is null"); + return Status(COSMO_NN_ERR_LOAD_MODEL, "AttachOwnedBmrt: bmrt handle is null"); if (shared_resource == nullptr) { return Status(COSMO_NN_ERR_GRAPH_NOT_INIT, "Sophon shared resource is null"); @@ -450,12 +454,10 @@ Status SophonNetNode::AttachBmrt(void* bmrt_handle) { std::unique_lock lck(sophon_net_mutex); if (m_bmrt != nullptr) { - return Status(COSMO_NN_ERR_LOAD_MODEL, "AttachBmrt: runtime is already attached"); + return Status(COSMO_NN_ERR_LOAD_MODEL, "AttachOwnedBmrt: runtime is already attached"); } - // Take ownership of the externally-created bmrt. - // The .so has already completed bmrt_create + bmrt_load_bmodel_data. - m_bmrt = bmrt_handle; + m_bmrt = bmrt_handle.release(); try { Status status = SetupNetworkAfterBmrt(); @@ -472,6 +474,10 @@ Status SophonNetNode::AttachBmrt(void* bmrt_handle) { DestroyBmRuntime(&m_bmrt); m_netinfo = nullptr; return Status(COSMO_NN_ERR_LOAD_MODEL, error.what()); + } catch (...) { + DestroyBmRuntime(&m_bmrt); + m_netinfo = nullptr; + return Status(COSMO_NN_ERR_LOAD_MODEL, "Sophon model setup failed with an unknown error"); } } diff --git a/src/nn/device/sophon/sophon_net_node.h b/src/nn/device/sophon/sophon_net_node.h index 0339225b8..bd8bfe4da 100644 --- a/src/nn/device/sophon/sophon_net_node.h +++ b/src/nn/device/sophon/sophon_net_node.h @@ -1,5 +1,6 @@ #pragma once +#include #include #include @@ -11,6 +12,12 @@ namespace cosmo::nn { +struct BmrtDeleter { + void operator()(void* runtime) const noexcept; +}; + +using OwnedBmrt = std::unique_ptr; + typedef struct _netInfos { std::vector> inPuts; std::vector> outPuts; @@ -30,11 +37,9 @@ class SophonNetNode : public NetNode { virtual Status LoadWeight(const char* data, size_t size) override; - /// Attach an externally-created bmrt handle (from model guard .so). - /// The bmrt must already have bmodel data loaded via bmrt_load_bmodel_data. - /// Skips bmrt_create + bmrt_load_bmodel_data, but performs all subsequent - /// setup: network info, input/output tensors, device memory allocation. - Status AttachBmrt(void* bmrt_handle); + /// Consume an externally-created bmrt handle whose model data is already loaded. + /// The handle is destroyed on every failure path or retained by the node on success. + Status AttachOwnedBmrt(OwnedBmrt bmrt_handle); virtual size_t GetBottomCount() override; diff --git a/src/nn/guard/CemV2SophonLoader.cc b/src/nn/guard/CemV2SophonLoader.cc new file mode 100644 index 000000000..0b9f0c8d4 --- /dev/null +++ b/src/nn/guard/CemV2SophonLoader.cc @@ -0,0 +1,296 @@ +#include "nn/guard/CemV2SophonLoader.h" + +#ifdef COSMO_NN_USE_SOPHON_BACKEND + +#include +#include +#include + +#include "bmruntime_interface.h" + +namespace cosmo::nn { +namespace { + + class ArtifactOwner final { + public: + ArtifactOwner(CmgV2Artifact* artifact, const CemV2Api& api) noexcept + : artifact_(artifact), api_(api) {} + + ~ArtifactOwner() { + Reset(); + } + + ArtifactOwner(const ArtifactOwner&) = delete; + ArtifactOwner& operator=(const ArtifactOwner&) = delete; + + ArtifactOwner(ArtifactOwner&& other) noexcept + : artifact_(std::exchange(other.artifact_, nullptr)), api_(other.api_) {} + + ArtifactOwner& operator=(ArtifactOwner&& other) noexcept { + if (this != &other) { + Reset(); + artifact_ = std::exchange(other.artifact_, nullptr); + api_ = other.api_; + } + return *this; + } + + [[nodiscard]] CmgV2Artifact* Get() const noexcept { + return artifact_; + } + + private: + void Reset() noexcept { + if (artifact_ == nullptr) { + return; + } + api_.close_artifact(api_.context, artifact_); + artifact_ = nullptr; + } + + CmgV2Artifact* artifact_ = nullptr; + CemV2Api api_{}; + }; + + bool IsGuardApiAvailable(const CemV2Api& api) noexcept { + return api.abi_major == CMG_V2_ABI_MAJOR && api.open_artifact != nullptr && + api.get_artifact_info != nullptr && api.load_sophon_segment != nullptr && + api.close_artifact != nullptr; + } + + bool IsRuntimeDestroyAvailable(const SophonRuntimeApi& api) noexcept { + return api.destroy != nullptr; + } + + bool IsValidArtifactInfo(const CmgV2ArtifactInfo& info, + CmgV2SourceFormat expected_source_format) noexcept { + return info.struct_size == CMG_V2_ARTIFACT_INFO_SIZE && info.reserved == 0 && + info.source_format == expected_source_format && info.segment_count > 0; + } + + SophonModelLoadResult Failure(SophonModelLoadError error, CmgV2Status guard_status) { + SophonModelLoadResult result; + result.error = error; + result.guard_status = guard_status; + return result; + } + +#ifdef COSMO_HAS_MODEL_GUARD + CmgV2Status OpenFrozenArtifact(void*, const char* path, CmgV2SourceFormat source_format, + CmgV2Artifact** out_artifact) { + return CmgV2OpenArtifact(path, source_format, out_artifact); + } + + CmgV2Status GetFrozenArtifactInfo(void*, const CmgV2Artifact* artifact, CmgV2ArtifactInfo* out_info) { + return CmgV2GetArtifactInfo(artifact, out_info); + } + + CmgV2Status LoadFrozenSophonSegment(void*, CmgV2Artifact* artifact, bm_handle_t bm_handle, + std::uint32_t segment_index, const CmgV2SophonLoadOptions* options, + void** out_bmrt) { + return CmgV2LoadSophonSegment(artifact, bm_handle, segment_index, options, out_bmrt); + } + + void CloseFrozenArtifact(void*, CmgV2Artifact* artifact) noexcept { + CmgV2CloseArtifact(artifact); + } +#endif + + void* CreateNativeRuntime(void*, bm_handle_t bm_handle) { + return bmrt_create(bm_handle); + } + + void SetNativeRuntimeFlags(void*, void* runtime, std::uint32_t flags) { + bmrt_set_flags(runtime, flags); + } + + bool LoadNativeRuntimeFile(void*, void* runtime, const char* model_path) { + return bmrt_load_bmodel(runtime, model_path); + } + + void DestroyNativeRuntime(void*, void* runtime) noexcept { + if (runtime == nullptr) { + return; + } + try { + bmrt_destroy(runtime); + } catch (...) { + std::fputs("[ModelLoader] bmrt_destroy failed during cleanup\n", stderr); + } + } + + bool IsProtectedRawDecision(const ModelLoadDecision& decision) noexcept { + return decision.action == ModelLoadAction::kGuardV2 && decision.magic == ModelMagic::kCemc && + !decision.model_path.empty(); + } + + bool IsNativeRawDecision(const ModelLoadDecision& decision) noexcept { + return decision.action == ModelLoadAction::kNativeRawBmodel && + decision.magic == ModelMagic::kUnknown && !decision.model_path.empty(); + } + +} // namespace + +void SophonRuntimeDeleter::operator()(void* runtime) const noexcept { + if (runtime != nullptr && destroy != nullptr) { + destroy(context, runtime); + } +} + +const CemV2Api& FrozenCemV2Api() noexcept { +#ifdef COSMO_HAS_MODEL_GUARD + static const CemV2Api api{CMG_V2_ABI_MAJOR, nullptr, + OpenFrozenArtifact, GetFrozenArtifactInfo, + LoadFrozenSophonSegment, CloseFrozenArtifact}; +#else + static const CemV2Api api{CMG_V2_ABI_MAJOR, nullptr, nullptr, nullptr, nullptr, nullptr}; +#endif + return api; +} + +const SophonRuntimeApi& NativeSophonRuntimeApi() noexcept { + static const SophonRuntimeApi api{nullptr, CreateNativeRuntime, SetNativeRuntimeFlags, + LoadNativeRuntimeFile, DestroyNativeRuntime}; + return api; +} + +RawBmodelLoadPlan PrepareRawBmodelLoad(const ModelLoadPolicy& policy, const std::string& model_path) { + RawBmodelLoadPlan plan; + plan.decision = policy.Evaluate(model_path, ModelLoadIntent::kRawBmodel); + if (!plan.decision.IsAllowed() || (plan.decision.action != ModelLoadAction::kGuardV2 && + plan.decision.action != ModelLoadAction::kNativeRawBmodel)) { + return plan; + } + + plan.error = RawBmodelAuthorizationError::kNone; + return plan; +} + +SophonModelLoadResult LoadCemV2SophonArtifact(const CemV2Api& guard_api, const SophonRuntimeApi& runtime_api, + const std::string& model_path, + CmgV2SourceFormat expected_source_format, bm_handle_t bm_handle, + CmgV2SophonLoadFlags flags) { + if (model_path.empty() || bm_handle == nullptr || (flags & ~CMG_V2_SOPHON_SHARE_MEM) != 0) { + return Failure(SophonModelLoadError::kInvalidArgument, CMG_V2_RESOURCE_INVALID_ARGUMENT); + } + if (expected_source_format != CMG_V2_SOURCE_COSMO_NN_V1 && + expected_source_format != CMG_V2_SOURCE_RAW_BMODEL) { + return Failure(SophonModelLoadError::kInvalidArgument, CMG_V2_RESOURCE_INVALID_ARGUMENT); + } + if (!IsGuardApiAvailable(guard_api)) { + return Failure(SophonModelLoadError::kAbiUnavailable, CMG_V2_RESOURCE_ABI_MISMATCH); + } + if (!IsRuntimeDestroyAvailable(runtime_api)) { + return Failure(SophonModelLoadError::kNativeRuntimeUnavailable, CMG_V2_RESOURCE_INVALID_STATE); + } + + CmgV2Artifact* raw_artifact = nullptr; + const CmgV2Status open_status = + guard_api.open_artifact(guard_api.context, model_path.c_str(), expected_source_format, &raw_artifact); + if (open_status != CMG_V2_OK) { + if (raw_artifact != nullptr) { + ArtifactOwner invalid_artifact(raw_artifact, guard_api); + return Failure(SophonModelLoadError::kAbiContractViolation, CMG_V2_RESOURCE_INTERNAL); + } + return Failure(SophonModelLoadError::kArtifactOpenFailed, open_status); + } + if (raw_artifact == nullptr) { + return Failure(SophonModelLoadError::kAbiContractViolation, CMG_V2_RESOURCE_INTERNAL); + } + ArtifactOwner artifact(raw_artifact, guard_api); + + CmgV2ArtifactInfo info{}; + info.struct_size = CMG_V2_ARTIFACT_INFO_SIZE; + const CmgV2Status info_status = guard_api.get_artifact_info(guard_api.context, artifact.Get(), &info); + if (info_status != CMG_V2_OK) { + return Failure(SophonModelLoadError::kArtifactInfoFailed, info_status); + } + if (!IsValidArtifactInfo(info, expected_source_format)) { + return Failure(SophonModelLoadError::kArtifactInfoInvalid, CMG_V2_RESOURCE_INTERNAL); + } + SophonModelLoadResult result; + try { + result.runtimes.reserve(info.segment_count); + } catch (const std::bad_alloc&) { + return Failure(SophonModelLoadError::kNoMemory, CMG_V2_RESOURCE_NO_MEMORY); + } + + CmgV2SophonLoadOptions options{}; + options.struct_size = CMG_V2_SOPHON_LOAD_OPTIONS_SIZE; + options.flags = flags; + + for (std::uint32_t index = 0; index < info.segment_count; ++index) { + void* raw_runtime = nullptr; + const CmgV2Status load_status = guard_api.load_sophon_segment( + guard_api.context, artifact.Get(), bm_handle, index, &options, &raw_runtime); + GuardOwnedBmrt owned_runtime(raw_runtime, {runtime_api.context, runtime_api.destroy}); + if (load_status != CMG_V2_OK) { + if (raw_runtime != nullptr) { + return Failure(SophonModelLoadError::kAbiContractViolation, CMG_V2_RESOURCE_INTERNAL); + } + return Failure(SophonModelLoadError::kSegmentLoadFailed, load_status); + } + if (raw_runtime == nullptr) { + return Failure(SophonModelLoadError::kAbiContractViolation, CMG_V2_RESOURCE_INTERNAL); + } + try { + result.runtimes.push_back(std::move(owned_runtime)); + } catch (const std::bad_alloc&) { + return Failure(SophonModelLoadError::kNoMemory, CMG_V2_RESOURCE_NO_MEMORY); + } + } + + return result; +} + +SophonModelLoadResult LoadRawBmodelByPolicy(const ModelLoadDecision& decision, const CemV2Api& guard_api, + const SophonRuntimeApi& runtime_api, bm_handle_t bm_handle) { + if (bm_handle == nullptr) { + return Failure(SophonModelLoadError::kInvalidArgument, CMG_V2_RESOURCE_INVALID_ARGUMENT); + } + if (IsProtectedRawDecision(decision)) { + return LoadCemV2SophonArtifact(guard_api, runtime_api, decision.model_path, CMG_V2_SOURCE_RAW_BMODEL, + bm_handle, CMG_V2_SOPHON_SHARE_MEM); + } + if (!IsNativeRawDecision(decision)) { + return Failure(SophonModelLoadError::kPolicyRejected, CMG_V2_FORMAT_SOURCE_MISMATCH); + } + if (runtime_api.create == nullptr || runtime_api.set_flags == nullptr || + runtime_api.load_file == nullptr || runtime_api.destroy == nullptr) { + return Failure(SophonModelLoadError::kNativeRuntimeUnavailable, CMG_V2_RESOURCE_INVALID_STATE); + } + + void* raw_runtime = runtime_api.create(runtime_api.context, bm_handle); + GuardOwnedBmrt owned_runtime(raw_runtime, {runtime_api.context, runtime_api.destroy}); + if (raw_runtime == nullptr) { + return Failure(SophonModelLoadError::kNativeRuntimeCreateFailed, CMG_V2_BACKEND_FAILED); + } + try { + runtime_api.set_flags(runtime_api.context, raw_runtime, BM_RUNTIME_SHARE_MEM); + if (!runtime_api.load_file(runtime_api.context, raw_runtime, decision.model_path.c_str())) { + return Failure(SophonModelLoadError::kNativeLoadFailed, CMG_V2_BACKEND_FAILED); + } + } catch (...) { + return Failure(SophonModelLoadError::kNativeLoadFailed, CMG_V2_BACKEND_FAILED); + } + + SophonModelLoadResult result; + try { + result.runtimes.push_back(std::move(owned_runtime)); + } catch (const std::bad_alloc&) { + return Failure(SophonModelLoadError::kNoMemory, CMG_V2_RESOURCE_NO_MEMORY); + } + return result; +} + +SophonModelLoadResult LoadRawBmodelByPlan(const RawBmodelLoadPlan& plan, const CemV2Api& guard_api, + const SophonRuntimeApi& runtime_api, bm_handle_t bm_handle) { + if (!plan.IsAuthorized()) { + return Failure(SophonModelLoadError::kPolicyRejected, CMG_V2_FORMAT_SOURCE_MISMATCH); + } + return LoadRawBmodelByPolicy(plan.decision, guard_api, runtime_api, bm_handle); +} + +} // namespace cosmo::nn + +#endif // COSMO_NN_USE_SOPHON_BACKEND diff --git a/src/nn/guard/CemV2SophonLoader.h b/src/nn/guard/CemV2SophonLoader.h new file mode 100644 index 000000000..310a86e28 --- /dev/null +++ b/src/nn/guard/CemV2SophonLoader.h @@ -0,0 +1,144 @@ +#pragma once + +#ifdef COSMO_NN_USE_SOPHON_BACKEND + +#include + +#include +#include +#include +#include + +#include "nn/guard/ModelLoadPolicy.h" + +namespace cosmo::nn { + +static_assert(CMG_V2_ABI_MAJOR == UINT32_C(2), "CosmoEdge requires model-guard ABI major 2"); + +/// Adapter for the four frozen model-guard v2 entry points. The context is +/// opaque so focused tests can provide deterministic C-ABI mocks without +/// changing the production ABI. +struct CemV2Api { + using OpenArtifact = CmgV2Status (*)(void* context, const char* installed_model_path, + CmgV2SourceFormat expected_source_format, + CmgV2Artifact** out_artifact); + using GetArtifactInfo = CmgV2Status (*)(void* context, const CmgV2Artifact* artifact, + CmgV2ArtifactInfo* out_info); + using LoadSophonSegment = CmgV2Status (*)(void* context, CmgV2Artifact* artifact, bm_handle_t bm_handle, + std::uint32_t segment_index, + const CmgV2SophonLoadOptions* options, void** out_bmrt); + using CloseArtifact = void (*)(void* context, CmgV2Artifact* artifact) noexcept; + + std::uint32_t abi_major = 0; + void* context = nullptr; + OpenArtifact open_artifact{}; + GetArtifactInfo get_artifact_info{}; + LoadSophonSegment load_sophon_segment{}; + CloseArtifact close_artifact{}; +}; + +/// Minimal Sophon runtime surface needed for native user-model loading and +/// bmrt ownership cleanup. Protected CEMC loading never calls create/load_file. +struct SophonRuntimeApi { + using Create = void* (*)(void* context, bm_handle_t bm_handle); + using SetFlags = void (*)(void* context, void* bmrt, std::uint32_t flags); + using LoadFile = bool (*)(void* context, void* bmrt, const char* model_path); + using Destroy = void (*)(void* context, void* bmrt) noexcept; + + void* context = nullptr; + Create create{}; + SetFlags set_flags{}; + LoadFile load_file{}; + Destroy destroy{}; +}; + +struct SophonRuntimeDeleter { + void* context = nullptr; + SophonRuntimeApi::Destroy destroy = nullptr; + + void operator()(void* runtime) const noexcept; +}; + +using GuardOwnedBmrt = std::unique_ptr; + +enum class SophonModelLoadError { + kNone, + kInvalidArgument, + kPolicyRejected, + kAbiUnavailable, + kArtifactOpenFailed, + kArtifactInfoFailed, + kArtifactInfoInvalid, + kSegmentLoadFailed, + kAbiContractViolation, + kNativeRuntimeUnavailable, + kNativeRuntimeCreateFailed, + kNativeLoadFailed, + kNoMemory, +}; + +struct SophonModelLoadResult { + SophonModelLoadError error = SophonModelLoadError::kNone; + CmgV2Status guard_status = CMG_V2_OK; + std::vector runtimes; + + [[nodiscard]] bool IsSuccess() const noexcept { + return error == SophonModelLoadError::kNone; + } + + [[nodiscard]] bool IsOutOfMemory() const noexcept { + return error == SophonModelLoadError::kNoMemory || guard_status == CMG_V2_RESOURCE_NO_MEMORY; + } +}; + +enum class RawBmodelAuthorizationError { + kNone, + kPolicyRejected, +}; + +/// Authorization state resolved before a Sophon device is requested. +struct RawBmodelLoadPlan { + RawBmodelAuthorizationError error = RawBmodelAuthorizationError::kPolicyRejected; + ModelLoadDecision decision; + + [[nodiscard]] bool IsAuthorized() const noexcept { + return error == RawBmodelAuthorizationError::kNone; + } +}; + +/// Production adapters. The guard adapter binds directly to the four frozen +/// CmgV2* symbols when model-guard is enabled; no dynamic lookup or fallback is +/// used. The build compatibility gate is responsible for supplying ABI major 2. +[[nodiscard]] const CemV2Api& FrozenCemV2Api() noexcept; +[[nodiscard]] const SophonRuntimeApi& NativeSophonRuntimeApi() noexcept; + +/// Resolve raw-bmodel format routing. Call this before acquiring device +/// resources. +[[nodiscard]] RawBmodelLoadPlan PrepareRawBmodelLoad(const ModelLoadPolicy& policy, + const std::string& model_path); + +/// Open one authenticated artifact, validate its immutable info, load every +/// segment through the same handle, then close it exactly once. Partial failure +/// destroys every bmrt already returned by the guard. +[[nodiscard]] SophonModelLoadResult LoadCemV2SophonArtifact( + const CemV2Api& guard_api, const SophonRuntimeApi& runtime_api, const std::string& model_path, + CmgV2SourceFormat expected_source_format, bm_handle_t bm_handle, CmgV2SophonLoadFlags flags); + +/// Dispatch a raw-bmodel decision already produced by ModelLoadPolicy. CEMC +/// decisions can only use the guard path; any guard failure is final. Native +/// loading is available only for an explicit kNativeRawBmodel decision. +[[nodiscard]] SophonModelLoadResult LoadRawBmodelByPolicy(const ModelLoadDecision& decision, + const CemV2Api& guard_api, + const SophonRuntimeApi& runtime_api, + bm_handle_t bm_handle); + +/// Execute a previously authorized plan. An unauthorized plan is rejected +/// without touching either the guard ABI or the native BMRuntime API. +[[nodiscard]] SophonModelLoadResult LoadRawBmodelByPlan(const RawBmodelLoadPlan& plan, + const CemV2Api& guard_api, + const SophonRuntimeApi& runtime_api, + bm_handle_t bm_handle); + +} // namespace cosmo::nn + +#endif // COSMO_NN_USE_SOPHON_BACKEND diff --git a/src/nn/guard/ModelLoadPolicy.cc b/src/nn/guard/ModelLoadPolicy.cc new file mode 100644 index 000000000..bd07b40c0 --- /dev/null +++ b/src/nn/guard/ModelLoadPolicy.cc @@ -0,0 +1,88 @@ +#include "nn/guard/ModelLoadPolicy.h" + +#include +#include +#include +#include + +namespace cosmo::nn { +namespace { + + namespace fs = std::filesystem; + + constexpr std::array kCemcMagic = {'C', 'E', 'M', 'C'}; + constexpr std::array kCennMagic = {'C', 'E', 'N', 'N'}; + constexpr std::array kLegacyEncryptedMagic = {0x01, 0x00, 0x01, 0xec}; + + bool ReadModelMagic(const fs::path& model_path, ModelMagic& magic) { + std::ifstream input(model_path, std::ios::binary); + std::array bytes{}; + if (!input.read(reinterpret_cast(bytes.data()), static_cast(bytes.size()))) { + return false; + } + magic = DetectModelMagic(bytes); + return true; + } + + ModelLoadDecision Reject(ModelLoadDecision decision, ModelPolicyError error) { + decision.action = ModelLoadAction::kReject; + decision.error = error; + return decision; + } + +} // namespace + +ModelMagic DetectModelMagic(const std::array& bytes) noexcept { + if (bytes == kCemcMagic) { + return ModelMagic::kCemc; + } + if (bytes == kCennMagic) { + return ModelMagic::kCenn; + } + if (bytes == kLegacyEncryptedMagic) { + return ModelMagic::kLegacyEncrypted; + } + return ModelMagic::kUnknown; +} + +ModelLoadPolicy ModelLoadPolicy::Production() { + return ModelLoadPolicy(); +} + +ModelLoadDecision ModelLoadPolicy::Evaluate(const std::string& model_path, ModelLoadIntent intent) const { + ModelLoadDecision decision; + if (model_path.empty()) { + return Reject(std::move(decision), ModelPolicyError::kPathNotRegularFile); + } + + std::error_code error; + const fs::path input_path(model_path); + if (!fs::is_regular_file(fs::status(input_path, error)) || error) { + return Reject(std::move(decision), ModelPolicyError::kPathNotRegularFile); + } + + const fs::path absolute_path = fs::absolute(input_path, error).lexically_normal(); + if (error || absolute_path.empty()) { + return Reject(std::move(decision), ModelPolicyError::kPathNotRegularFile); + } + decision.model_path = absolute_path.string(); + if (!ReadModelMagic(absolute_path, decision.magic)) { + return Reject(std::move(decision), ModelPolicyError::kHeaderReadFailed); + } + + if (decision.magic == ModelMagic::kCemc) { + decision.action = ModelLoadAction::kGuardV2; + return decision; + } + if (decision.magic == ModelMagic::kCenn && intent == ModelLoadIntent::kCosmoNn) { + decision.action = ModelLoadAction::kNativeCenn; + return decision; + } + if (decision.magic == ModelMagic::kUnknown && intent == ModelLoadIntent::kRawBmodel) { + decision.action = ModelLoadAction::kNativeRawBmodel; + return decision; + } + return Reject(std::move(decision), ModelPolicyError::kFormatRejected); +} + +} // namespace cosmo::nn diff --git a/src/nn/guard/ModelLoadPolicy.h b/src/nn/guard/ModelLoadPolicy.h new file mode 100644 index 000000000..c6fbf880c --- /dev/null +++ b/src/nn/guard/ModelLoadPolicy.h @@ -0,0 +1,66 @@ +#pragma once + +#include +#include +#include + +namespace cosmo::nn { + +/// Format selected solely from the first four bytes of a model file. +enum class ModelMagic { + kUnknown, + kCemc, + kCenn, + kLegacyEncrypted, +}; + +/// Native loader selected by the model consumer. +enum class ModelLoadIntent { + kCosmoNn, + kRawBmodel, +}; + +/// Loader action selected from the file format and consumer intent. +enum class ModelLoadAction { + kReject, + kGuardV2, + kNativeCenn, + kNativeRawBmodel, +}; + +/// Stable reason for a rejected decision. +enum class ModelPolicyError { + kNone, + kPathNotRegularFile, + kHeaderReadFailed, + kFormatRejected, +}; + +/// Result of inspecting one model file. +struct ModelLoadDecision { + ModelMagic magic = ModelMagic::kUnknown; + ModelLoadAction action = ModelLoadAction::kReject; + ModelPolicyError error = ModelPolicyError::kNone; + std::string model_path; + + [[nodiscard]] bool IsAllowed() const { + return action != ModelLoadAction::kReject; + } +}; + +/// Detect reserved model formats without host-endian integer conversion. +[[nodiscard]] ModelMagic DetectModelMagic(const std::array& bytes) noexcept; + +/// Minimal format router. CEMC always goes through Guard, while native CENN and +/// raw bmodel loading follow the caller's already selected model type. +class ModelLoadPolicy final { +public: + [[nodiscard]] static ModelLoadPolicy Production(); + + [[nodiscard]] ModelLoadDecision Evaluate(const std::string& model_path, ModelLoadIntent intent) const; + +private: + ModelLoadPolicy() = default; +}; + +} // namespace cosmo::nn diff --git a/src/nn/guard/cosmo_model_guard.h b/src/nn/guard/cosmo_model_guard.h deleted file mode 100644 index c1e829afd..000000000 --- a/src/nn/guard/cosmo_model_guard.h +++ /dev/null @@ -1,58 +0,0 @@ -/// @file cosmo_model_guard.h -/// @brief Public interface for the model guard library. - -#pragma once -#include -#include - -#ifdef COSMO_NN_USE_SOPHON_BACKEND -#include "bmruntime_interface.h" -#endif - -#ifndef GUARD_EXPORT -#define GUARD_EXPORT __attribute__((visibility("default"))) -#endif - -namespace cosmo::guard { - -/// Magic number identifying an encrypted model file. -static constexpr uint32_t kEncryptedMagic = 0xEC010001; - -/// Check whether the file-header magic indicates an encrypted model. -inline bool IsEncryptedModel(uint32_t magic) { - return magic == kEncryptedMagic; -} - -#ifdef COSMO_NN_USE_SOPHON_BACKEND - -/// Error codes returned by guard functions. -enum class GuardError : int { - kSuccess = 0, - kInvalidMagic = -1, - kDecryptFailed = -2, - kLoadFailed = -3, - kHandleNull = -4, - kSegmentOutOfRange = -5, - kFileOpenFailed = -6, - kGuardNotAvailable = -10, -}; - -/// Decrypt and load a single segment from an encrypted model file. -/// -/// @param enc_file_path Path to the encrypted model file. -/// @param bm_handle Sophon device handle. -/// @param segment_index 0-based segment index. -/// @param out_bmrt [out] Created bmrt handle; caller manages lifetime via bmrt_destroy. -/// @return 0 on success, negative GuardError code on failure. -GUARD_EXPORT int DecryptAndLoadSegmentFromFile(const char* enc_file_path, bm_handle_t bm_handle, - uint32_t segment_index, void** out_bmrt); - -/// Query the number of encrypted segments in a model file. -/// -/// @param enc_file_path Path to the encrypted model file. -/// @return Segment count (>0) on success, negative GuardError code on failure. -GUARD_EXPORT int GetEncryptedSegmentCountFromFile(const char* enc_file_path); - -#endif // COSMO_NN_USE_SOPHON_BACKEND - -} // namespace cosmo::guard diff --git a/src/nn/utils/default_component.cc b/src/nn/utils/default_component.cc index c0aeaaa5e..8ffffbdce 100644 --- a/src/nn/utils/default_component.cc +++ b/src/nn/utils/default_component.cc @@ -3,11 +3,26 @@ #include #include #include +#include #include "nn/pipeline/model_pipeline.h" #include "nn/pipeline/pipeline_utils.h" namespace cosmo::nn { +namespace { + + DefaultComponent::Options MakeOptions(IProfiler* profiler, std::string tokenizer_path, + std::string word_table_path, int device_id, bool use_skip) { + DefaultComponent::Options options; + options.profiler = profiler; + options.tokenizer_path = std::move(tokenizer_path); + options.word_table_path = std::move(word_table_path); + options.device_id = device_id; + options.use_skip = use_skip; + return options; + } + +} // namespace std::mutex DefaultComponent::mutex; @@ -15,7 +30,13 @@ std::mutex DefaultComponent::mutex; DefaultComponent::DefaultComponent(std::string json_path, std::string model_path, DeviceType device_type, IProfiler* profiler, std::string tokenizer_path, - std::string word_table_path, int device_id, bool use_skip) { + std::string word_table_path, int device_id, bool use_skip) + : DefaultComponent( + MakeOptions(profiler, std::move(tokenizer_path), std::move(word_table_path), device_id, use_skip), + std::move(json_path), std::move(model_path), device_type) {} + +DefaultComponent::DefaultComponent(const Options& options, std::string json_path, std::string model_path, + DeviceType device_type) { std::unique_lock lock(mutex); std::string json_content; @@ -44,8 +65,8 @@ DefaultComponent::DefaultComponent(std::string json_path, std::string model_path if (!pipeline_) throw std::runtime_error("Failed to create pipeline for: " + config.model_type); - status = pipeline_->Init(config, model_path, device_type, device_id, profiler, tokenizer_path, - word_table_path, use_skip); + status = pipeline_->Init(config, model_path, device_type, options.device_id, options.profiler, + options.tokenizer_path, options.word_table_path, options.use_skip); if (!bool(status)) throw std::runtime_error("Pipeline init failed: " + std::string(status.description())); } diff --git a/src/nn/utils/default_component.h b/src/nn/utils/default_component.h index d46ebef8d..1ceb54baf 100644 --- a/src/nn/utils/default_component.h +++ b/src/nn/utils/default_component.h @@ -15,11 +15,24 @@ class ModelPipeline; class PUBLIC DefaultComponent { public: + struct Options { + IProfiler* profiler = nullptr; + std::string tokenizer_path; + std::string word_table_path; + int device_id = 0; + bool use_skip = false; + }; + DefaultComponent(std::string json_path, std::string model_path, DeviceType device_type, IProfiler* profiler = nullptr, std::string tokenizer_path = std::string(), std::string word_table_path = std::string(), int device_id = 0, bool use_skip = false) noexcept(false); + /// Options-first form remains unambiguous with the legacy constructor's + /// optional profiler argument. + DefaultComponent(const Options& options, std::string json_path, std::string model_path, + DeviceType device_type) noexcept(false); + ~DefaultComponent(); DefaultComponent(DefaultComponent const& other) = delete; diff --git a/src/service/modelguard/IModelAuthorizationService.h b/src/service/modelguard/IModelAuthorizationService.h new file mode 100644 index 000000000..b32f1cef9 --- /dev/null +++ b/src/service/modelguard/IModelAuthorizationService.h @@ -0,0 +1,23 @@ +#pragma once + +#include + +#include "util/ErrorCode.h" + +namespace cosmo::service { + +struct ModelAuthorizationStatus { + bool supported{false}; + bool authorized{false}; + std::string state{"unsupported"}; +}; + +class IModelAuthorizationService { +public: + virtual ~IModelAuthorizationService() = default; + virtual ModelAuthorizationStatus Status() = 0; + virtual util::ErrorEnum CreateDeviceRequest(std::string& file_path, std::string& file_name) = 0; + virtual util::ErrorEnum InstallCertificate(const std::string& file_path) = 0; +}; + +} // namespace cosmo::service diff --git a/src/service/modelguard/impl/ModelAuthorizationServiceImpl.cc b/src/service/modelguard/impl/ModelAuthorizationServiceImpl.cc new file mode 100644 index 000000000..add473c97 --- /dev/null +++ b/src/service/modelguard/impl/ModelAuthorizationServiceImpl.cc @@ -0,0 +1,83 @@ +#include "service/modelguard/impl/ModelAuthorizationServiceImpl.h" + +#include +#include + +#include "util/Exec.h" +#include "util/PathUtil.h" +#include "util/UuidUtil.h" + +namespace cosmo::service { +namespace { +constexpr std::uintmax_t kDeviceRequestSize = 48; +constexpr std::uintmax_t kCertificateSize = 236; +} // namespace + +ModelAuthorizationServiceImpl::ModelAuthorizationServiceImpl(std::string provision_tool) + : provision_tool_(std::move(provision_tool)) {} + +bool ModelAuthorizationServiceImpl::ToolAvailable() const { + std::error_code ec; + const auto status = std::filesystem::status(provision_tool_, ec); + return !ec && std::filesystem::is_regular_file(status) && + (status.permissions() & std::filesystem::perms::owner_exec) != std::filesystem::perms::none; +} + +ModelAuthorizationStatus ModelAuthorizationServiceImpl::Status() { + if (!ToolAvailable()) { + return {}; + } + std::string output; + const int result = util::Exec({provision_tool_, "status"}, output); + if (result == 0 && output.rfind("valid ", 0) == 0) { + return {true, true, "valid"}; + } + for (const auto* state : {"certificate_unavailable", "certificate_rejected", "device_mismatch", + "identity_rejected", "resource_failure"}) { + if (output.find(state) != std::string::npos) { + return {true, false, state}; + } + } + return {true, false, "unknown"}; +} + +util::ErrorEnum ModelAuthorizationServiceImpl::CreateDeviceRequest(std::string& file_path, + std::string& file_name) { + file_path.clear(); + file_name.clear(); + if (!ToolAvailable()) { + return util::ErrorEnum::OperationNotSupport; + } + const auto candidate = std::filesystem::path(path::GetTemporaryDirPath()) / + ("model-authorization-request-" + util::GenerateUUID() + ".cmpr"); + std::string output; + if (util::Exec({provision_tool_, "request", "--output", candidate.string()}, output) != 0) { + return util::ErrorEnum::Failed; + } + std::error_code ec; + if (!std::filesystem::is_regular_file(candidate, ec) || ec || + std::filesystem::file_size(candidate, ec) != kDeviceRequestSize || ec) { + std::filesystem::remove(candidate, ec); + return util::ErrorEnum::FileAnalysisFailed; + } + file_path = candidate.string(); + file_name = "device-request.cmpr"; + return util::ErrorEnum::Success; +} + +util::ErrorEnum ModelAuthorizationServiceImpl::InstallCertificate(const std::string& file_path) { + if (!ToolAvailable()) { + return util::ErrorEnum::OperationNotSupport; + } + std::error_code ec; + if (!std::filesystem::is_regular_file(file_path, ec) || ec || + std::filesystem::file_size(file_path, ec) != kCertificateSize || ec) { + return util::ErrorEnum::FileAnalysisFailed; + } + std::string output; + return util::Exec({provision_tool_, "install", "--certificate", file_path}, output) == 0 + ? util::ErrorEnum::Success + : util::ErrorEnum::Failed; +} + +} // namespace cosmo::service diff --git a/src/service/modelguard/impl/ModelAuthorizationServiceImpl.h b/src/service/modelguard/impl/ModelAuthorizationServiceImpl.h new file mode 100644 index 000000000..00480dc8b --- /dev/null +++ b/src/service/modelguard/impl/ModelAuthorizationServiceImpl.h @@ -0,0 +1,23 @@ +#pragma once + +#include + +#include "service/modelguard/IModelAuthorizationService.h" + +namespace cosmo::service { + +class ModelAuthorizationServiceImpl final : public IModelAuthorizationService { +public: + explicit ModelAuthorizationServiceImpl( + std::string provision_tool = "/appfs/cosmo_wander/cwai_data/bin/cosmo-model-provision"); + + ModelAuthorizationStatus Status() override; + util::ErrorEnum CreateDeviceRequest(std::string& file_path, std::string& file_name) override; + util::ErrorEnum InstallCertificate(const std::string& file_path) override; + +private: + bool ToolAvailable() const; + std::string provision_tool_; +}; + +} // namespace cosmo::service diff --git a/src/service/path/IUploadStagingService.h b/src/service/path/IUploadStagingService.h index 60c022036..bc598cf8a 100644 --- a/src/service/path/IUploadStagingService.h +++ b/src/service/path/IUploadStagingService.h @@ -20,6 +20,7 @@ enum class UploadPurpose { kAlgorithm, kUpgrade, kImage, + kModelAuthorizationCertificate, }; [[nodiscard]] std::string_view UploadPurposeName(UploadPurpose purpose); diff --git a/src/service/path/impl/UploadStagingServiceImpl.cc b/src/service/path/impl/UploadStagingServiceImpl.cc index c7980d2a4..e20834a6b 100644 --- a/src/service/path/impl/UploadStagingServiceImpl.cc +++ b/src/service/path/impl/UploadStagingServiceImpl.cc @@ -304,14 +304,17 @@ std::string_view UploadPurposeName(UploadPurpose purpose) { return "upgrade"; case UploadPurpose::kImage: return "image"; + case UploadPurpose::kModelAuthorizationCertificate: + return "model-authorization-certificate"; } return {}; } bool ParseUploadPurpose(std::string_view value, UploadPurpose& purpose) { - for (auto candidate : {UploadPurpose::kModelComponent, UploadPurpose::kModelArchive, - UploadPurpose::kVideo, UploadPurpose::kFaceImport, UploadPurpose::kAudio, - UploadPurpose::kAlgorithm, UploadPurpose::kUpgrade, UploadPurpose::kImage}) { + for (auto candidate : + {UploadPurpose::kModelComponent, UploadPurpose::kModelArchive, UploadPurpose::kVideo, + UploadPurpose::kFaceImport, UploadPurpose::kAudio, UploadPurpose::kAlgorithm, + UploadPurpose::kUpgrade, UploadPurpose::kImage, UploadPurpose::kModelAuthorizationCertificate}) { if (value == UploadPurposeName(candidate)) { purpose = candidate; return true; @@ -1086,6 +1089,7 @@ bool UploadStagingServiceImpl::IsPurposeValid(UploadPurpose purpose) { case UploadPurpose::kAlgorithm: case UploadPurpose::kUpgrade: case UploadPurpose::kImage: + case UploadPurpose::kModelAuthorizationCertificate: return true; } return false; diff --git a/src/service/system/dto/SystemMaintainDto.cc b/src/service/system/dto/SystemMaintainDto.cc index 0432c3c6c..6089d08c0 100644 --- a/src/service/system/dto/SystemMaintainDto.cc +++ b/src/service/system/dto/SystemMaintainDto.cc @@ -67,6 +67,46 @@ void from_json(const nlohmann::json& j, MsgUpgradeRecv& v) { JSON_OPT(j, v, fileUrl); } +void to_json(nlohmann::json& j, const MsgQueryModelAuthorizationSend& v) { + to_json(j, static_cast(v)); + j["resData"] = v.resData; +} +void from_json(const nlohmann::json& j, MsgQueryModelAuthorizationSend& v) { + from_json(j, static_cast(v)); + JSON_OPT(j, v, resData); +} +void to_json(nlohmann::json& j, const MsgQueryModelAuthorizationSend::ResData& v) { + j = nlohmann::json{{"supported", v.supported}, {"authorized", v.authorized}, {"state", v.state}}; +} +void from_json(const nlohmann::json& j, MsgQueryModelAuthorizationSend::ResData& v) { + if (j.contains("supported")) + j.at("supported").get_to(v.supported); + if (j.contains("authorized")) + j.at("authorized").get_to(v.authorized); + if (j.contains("state")) + j.at("state").get_to(v.state); +} +void to_json(nlohmann::json& j, const MsgDownloadModelAuthorizationRequestSend& v) { + to_json(j, static_cast(v)); + j["filePath"] = v.filePath; + j["fileName"] = v.fileName; +} +void from_json(const nlohmann::json& j, MsgDownloadModelAuthorizationRequestSend& v) { + from_json(j, static_cast(v)); + JSON_OPT(j, v, filePath); + JSON_OPT(j, v, fileName); +} +void to_json(nlohmann::json& j, const MsgInstallModelAuthorizationRecv& v) { + to_json(j, static_cast(v)); + j["uploadId"] = v.uploadId; + j["filePath"] = v.filePath; +} +void from_json(const nlohmann::json& j, MsgInstallModelAuthorizationRecv& v) { + from_json(j, static_cast(v)); + JSON_OPT(j, v, uploadId); + JSON_OPT(j, v, filePath); +} + void to_json(nlohmann::json& j, const MsgQueryDocumentUrlRecv& v) { to_json(j, static_cast(v)); j["type"] = v.type; diff --git a/src/service/system/dto/SystemMaintainDto.h b/src/service/system/dto/SystemMaintainDto.h index 77a8d85e9..045b535e3 100644 --- a/src/service/system/dto/SystemMaintainDto.h +++ b/src/service/system/dto/SystemMaintainDto.h @@ -62,6 +62,35 @@ void from_json(const nlohmann::json& j, MsgUpgradeRecv& v); // struct MsgUpgradeSend : public MsgSendHead {}; +struct MsgQueryModelAuthorizationRecv : public MsgRecvHead {}; +struct MsgQueryModelAuthorizationSend : public MsgSendHead { + struct ResData { + bool supported{false}; + bool authorized{false}; + std::string state; + friend void to_json(nlohmann::json& j, const ResData& v); + friend void from_json(const nlohmann::json& j, ResData& v); + } resData; +}; +void to_json(nlohmann::json& j, const MsgQueryModelAuthorizationSend& v); +void from_json(const nlohmann::json& j, MsgQueryModelAuthorizationSend& v); + +struct MsgDownloadModelAuthorizationRequestRecv : public MsgRecvHead {}; +struct MsgDownloadModelAuthorizationRequestSend : public MsgSendHead { + std::string filePath; + std::string fileName; +}; +void to_json(nlohmann::json& j, const MsgDownloadModelAuthorizationRequestSend& v); +void from_json(const nlohmann::json& j, MsgDownloadModelAuthorizationRequestSend& v); + +struct MsgInstallModelAuthorizationRecv : public MsgRecvHead { + std::string uploadId; + std::string filePath; +}; +void to_json(nlohmann::json& j, const MsgInstallModelAuthorizationRecv& v); +void from_json(const nlohmann::json& j, MsgInstallModelAuthorizationRecv& v); +struct MsgInstallModelAuthorizationSend : public MsgSendHead {}; + // Document download address request struct MsgQueryDocumentUrlRecv : public MsgRecvHead { int type{0}; diff --git a/src/service/system/impl/PacketUpgrade.cc b/src/service/system/impl/PacketUpgrade.cc index b2ee64dc3..6d92f9f49 100644 --- a/src/service/system/impl/PacketUpgrade.cc +++ b/src/service/system/impl/PacketUpgrade.cc @@ -34,8 +34,9 @@ namespace cosmo { namespace { - constexpr size_t kMaxUpgradeArchiveEntries = 20000; - constexpr size_t kMaxUpgradeListingBytes = kMaxUpgradeArchiveEntries * 1024 + 16 * 1024; + constexpr size_t kMaxUpgradeArchiveEntries = 20000; + constexpr size_t kMaxUpgradeListingBytes = kMaxUpgradeArchiveEntries * 1024 + 16 * 1024; + constexpr std::uintmax_t kMaxSignedReleaseBytes = 128ULL * 1024 * 1024 * 1024; struct UpgradeArchiveInspection { size_t entry_count{0}; @@ -297,6 +298,31 @@ util::ErrorEnum UpgradeFileNameCheck(std::string file_name, std::string& md5sum) return util::ErrorEnum::Success; } +util::ErrorEnum SignedReleaseFileNameCheck(std::string_view file_name) { + constexpr std::string_view prefix = "cosmo-release-"; + constexpr std::string_view suffix = ".tar.gz"; + if (file_name.size() <= prefix.size() + suffix.size() || + file_name.compare(0, prefix.size(), prefix) != 0 || + file_name.compare(file_name.size() - suffix.size(), suffix.size(), suffix) != 0) { + return util::ErrorEnum::UpgradeFileVerifyFailed; + } + + const std::string_view release_id = + file_name.substr(prefix.size(), file_name.size() - prefix.size() - suffix.size()); + if (release_id.empty() || release_id.size() > 64 || + !((release_id.front() >= 'a' && release_id.front() <= 'z') || + (release_id.front() >= '0' && release_id.front() <= '9'))) { + return util::ErrorEnum::UpgradeFileVerifyFailed; + } + for (const char value : release_id) { + if (!((value >= 'a' && value <= 'z') || (value >= '0' && value <= '9') || value == '.' || + value == '_' || value == '-')) { + return util::ErrorEnum::UpgradeFileVerifyFailed; + } + } + return util::ErrorEnum::Success; +} + util::ErrorEnum PacketUpgrade(const fs::path& filePath) { std::error_code ec; const auto absolute_file = fs::absolute(filePath, ec); @@ -304,11 +330,11 @@ util::ErrorEnum PacketUpgrade(const fs::path& filePath) { !cosmo::path::IsSafePathComponent(absolute_file.filename().string(), 255)) { return util::ErrorEnum::UpgradeFileVerifyFailed; } - std::string file_name = absolute_file.filename().string(); + std::string file_name = absolute_file.filename().string(); + const bool signed_release = SignedReleaseFileNameCheck(file_name) == util::ErrorEnum::Success; std::string file_name_md5sum; - auto ret = UpgradeFileNameCheck(file_name, file_name_md5sum); - if (ret != util::ErrorEnum::Success) { - return ret; + if (!signed_release && UpgradeFileNameCheck(file_name, file_name_md5sum) != util::ErrorEnum::Success) { + return util::ErrorEnum::UpgradeFileVerifyFailed; } std::string resolved_file; @@ -320,35 +346,38 @@ util::ErrorEnum PacketUpgrade(const fs::path& filePath) { } const auto archive_size = fs::file_size(resolved_file, ec); - if (ec || archive_size == 0 || !IsGzipFile(resolved_file)) { + if (ec || archive_size == 0 || (signed_release && archive_size > kMaxSignedReleaseBytes) || + !IsGzipFile(resolved_file)) { LOG_ERRO("upgrade package has invalid size or format: {}", resolved_file); return util::ErrorEnum::UpgradeFileVerifyFailed; } - const auto md5Str = Md5SumFile(resolved_file); - if (md5Str.empty() || (util::ToLower(md5Str) != file_name_md5sum)) { - LOG_ERRO("upgrade package md5 error, expected={}, actual={}", file_name_md5sum, md5Str); - return util::ErrorEnum::UpgradeFileNotMatch; - } UpgradeArchiveInspection inspection; - if (!ValidateUpgradeArchiveListing(resolved_file, inspection)) { - return util::ErrorEnum::UpgradeFileVerifyFailed; - } - fs::path upgradeFileDir(cosmo::path::GetUpgradePath()); - const auto budget = util::InspectStorageResourceBudget(upgradeFileDir.string()); - if (!budget.valid) { - throw util::ErrorMessage(util::ErrorEnum::SysErr, "Cannot inspect upgrade extraction storage"); - } - // A valid package replaces the previous prepared upgrade tree. Include - // those allocated blocks in the admission budget, while keeping the tree - // intact until all package validation above has succeeded. - const auto usable_bytes = - util::UsableStorageBytesAfterReclaim(budget, AllocatedTreeBytes(upgradeFileDir)); - if (inspection.total_bytes > usable_bytes) { - throw util::ResourceLimitError("Insufficient safe disk space to extract the upgrade package", - "archive-extraction", "upgrade", inspection.total_bytes, usable_bytes, - budget.reserve_bytes); + if (!signed_release) { + const auto md5Str = Md5SumFile(resolved_file); + if (md5Str.empty() || (util::ToLower(md5Str) != file_name_md5sum)) { + LOG_ERRO("upgrade package md5 error, expected={}, actual={}", file_name_md5sum, md5Str); + return util::ErrorEnum::UpgradeFileNotMatch; + } + if (!ValidateUpgradeArchiveListing(resolved_file, inspection)) { + return util::ErrorEnum::UpgradeFileVerifyFailed; + } + + const auto budget = util::InspectStorageResourceBudget(upgradeFileDir.string()); + if (!budget.valid) { + throw util::ErrorMessage(util::ErrorEnum::SysErr, "Cannot inspect upgrade extraction storage"); + } + // A valid package replaces the previous prepared upgrade tree. Include + // those allocated blocks in the admission budget, while keeping the tree + // intact until all package validation above has succeeded. + const auto usable_bytes = + util::UsableStorageBytesAfterReclaim(budget, AllocatedTreeBytes(upgradeFileDir)); + if (inspection.total_bytes > usable_bytes) { + throw util::ResourceLimitError("Insufficient safe disk space to extract the upgrade package", + "archive-extraction", "upgrade", inspection.total_bytes, + usable_bytes, budget.reserve_bytes); + } } std::string resolved_upgrade_dir; if (!cosmo::path::ResolveExistingPathWithinRoot(cosmo::path::GetBaseDir(), upgradeFileDir.string(), @@ -385,6 +414,16 @@ util::ErrorEnum PacketUpgrade(const fs::path& filePath) { return util::ErrorEnum::FileMoveFailed; } + // A signed compatibility release must remain byte-for-byte opaque here. + // The embedded-key bootstrap or the currently trusted updater pins this + // exact archive inode and performs the canonical tar, signature, payload, + // ELF, ABI, model-policy, and rollback checks after reboot. Extracting it + // through the legacy MD5 path would destroy that authenticated container. + if (signed_release) { + LOG_INFO("signed compatibility release staged without extraction: {}", upgrade_file_name.string()); + return util::ErrorEnum::Success; + } + // Extract tar.gz std::string output; const int exit_code = diff --git a/src/service/system/impl/PacketUpgrade.h b/src/service/system/impl/PacketUpgrade.h index 214ec28ac..f43f5875e 100644 --- a/src/service/system/impl/PacketUpgrade.h +++ b/src/service/system/impl/PacketUpgrade.h @@ -3,11 +3,13 @@ #include #include #include +#include #include "util/ErrorCode.h" namespace cosmo { namespace fs = std::filesystem; util::ErrorEnum UpgradeFileNameCheck(std::string fileName, std::string& md5sum); +util::ErrorEnum SignedReleaseFileNameCheck(std::string_view fileName); util::ErrorEnum PacketUpgrade(const fs::path& filePath); } // namespace cosmo diff --git a/src/service/system/impl/SystemOperationServiceImpl.cc b/src/service/system/impl/SystemOperationServiceImpl.cc index 1a55a2de2..2b78a62e0 100644 --- a/src/service/system/impl/SystemOperationServiceImpl.cc +++ b/src/service/system/impl/SystemOperationServiceImpl.cc @@ -2,7 +2,9 @@ #include "service/system/impl/SystemOperationServiceImpl.h" +#include #include +#include #include "service/detail/ServiceRegistry.h" #include "service/system/impl/PacketUpgrade.h" @@ -17,6 +19,17 @@ namespace cosmo::service { namespace fs = std::filesystem; +namespace { + + constexpr std::string_view kSourceRuntimeEnvironment = "COSMO_SOURCE_RUNTIME"; + + bool IsSourceRuntime() { + const char* value = std::getenv(kSourceRuntimeEnvironment.data()); + return value != nullptr && std::string_view(value) == "1"; + } + +} // namespace + void SystemOperationServiceImpl::RebootDevice(const std::string& reason) { reboot_mgr_.Reboot(reason); } @@ -69,6 +82,10 @@ cosmo::util::ErrorEnum SystemOperationServiceImpl::ExportLogs(std::string& fileN } cosmo::util::ErrorEnum SystemOperationServiceImpl::Upgrade(const std::string& filePath) { + if (IsSourceRuntime()) { + LOG_WARN("{}", "Software upgrade is disabled while the SOURCE runtime is active"); + return cosmo::util::ErrorEnum::OperationNotSupport; + } auto result = cosmo::PacketUpgrade(filePath); if (result == cosmo::util::ErrorEnum::Success) { reboot_mgr_.Reboot("upgrade Reboot"); diff --git a/src/web/package.json b/src/web/package.json index 05fb48b69..59cd7cd9f 100644 --- a/src/web/package.json +++ b/src/web/package.json @@ -11,7 +11,8 @@ "i18n:check-dialogs": "node scripts/i18n_check_dialog_actions.mjs", "i18n:check-used-keys": "node scripts/i18n_check_used_keys.mjs", "i18n:check": "npm run i18n:check-scopes && npm run i18n:check-glossary && npm run i18n:check-locales && npm run i18n:check-dialogs && npm run i18n:check-used-keys", - "prebuild": "npm run i18n:check", + "upgrade-package:check": "node scripts/upgrade_package_pattern_check.mjs", + "prebuild": "npm run upgrade-package:check && npm run i18n:check", "build": "vite build", "preview": "vite preview", "resource-i18n:check": "node scripts/resource_i18n_sync.mjs --check", diff --git a/src/web/scripts/upgrade_package_pattern_check.mjs b/src/web/scripts/upgrade_package_pattern_check.mjs new file mode 100644 index 000000000..a339c1fb5 --- /dev/null +++ b/src/web/scripts/upgrade_package_pattern_check.mjs @@ -0,0 +1,28 @@ +import assert from 'node:assert/strict' + +import { isSupportedUpgradePackageName } from '../src/utils/upgradePackage.js' + +const accepted = [ + 'cosmo-V1.0.0-0123456789abcdef0123456789abcdef.tar.gz', + 'cosmo-v1.0.0-0123456789ABCDEF0123456789ABCDEF.tar.gz', + 'cosmo-release-a.tar.gz', + 'cosmo-release-ab_cd.12-3.tar.gz', + `cosmo-release-${'a'.repeat(64)}.tar.gz` +] + +const rejected = [ + '', + 'cosmo-V1.0.0.tar.gz', + 'cosmo-V1.0.0-0123456789abcdef0123456789abcde.tar.gz', + 'cosmo-release-.tar.gz', + 'cosmo-release-Ab.tar.gz', + 'cosmo-release-../escape.tar.gz', + `cosmo-release-${'a'.repeat(65)}.tar.gz` +] + +for (const name of accepted) { + assert.equal(isSupportedUpgradePackageName(name), true, `expected accepted: ${name}`) +} +for (const name of rejected) { + assert.equal(isSupportedUpgradePackageName(name), false, `expected rejected: ${name}`) +} diff --git a/src/web/src/api/box.js b/src/web/src/api/box.js index 500a2013a..c82661c7f 100644 --- a/src/web/src/api/box.js +++ b/src/web/src/api/box.js @@ -215,6 +215,12 @@ const system = { method: 'post', }) }, + queryModelAuthorization() { + return request({ url: '/gtw/cwai/System/QueryModelAuthorization', method: 'post', data: {} }) + }, + installModelAuthorization(data) { + return request({ url: '/gtw/cwai/System/InstallModelAuthorization', method: 'post', data }) + }, // 网络设置 // 网卡查询 diff --git a/src/web/src/i18n/locales/en-US.js b/src/web/src/i18n/locales/en-US.js index 6f32f2858..524a3f53d 100644 --- a/src/web/src/i18n/locales/en-US.js +++ b/src/web/src/i18n/locales/en-US.js @@ -1137,6 +1137,16 @@ export default { clear: 'Clear' }, systemManage: { + modelAuthorization: 'Model Authorization', + authorizationStatus: 'Authorization Status', + authorized: 'Authorized', + notAuthorized: 'Not Authorized', + downloadAuthorizationRequest: 'Download Device Request', + uploadAuthorizationFile: 'Upload Authorization File', + authorizationRequestFailed: 'Failed to create the device request', + invalidAuthorizationFile: 'Invalid authorization file', + authorizationInstalled: 'Authorization installed', + authorizationInstallFailed: 'Authorization installation failed', // Network Port Settings networkPortSettings: 'Network Port Settings', dnsSettings: 'DNS Settings', @@ -1262,11 +1272,11 @@ export default { browse: 'Browse', upgrade: 'Upgrade', upgradeTip1: '1. The device restarts during upgrade; keep power and network connected.', - upgradeTip2: '2. The package name must match cosmo-Vversion-md5.tar.gz.', + upgradeTip2: '2. The package name must match cosmo-Vversion-md5.tar.gz or cosmo-release-release-id.tar.gz.', upgradeConfirm: 'Upgrade the device with {fileName}? The device will restart and this page will disconnect temporarily.', restoreFactory: 'Restore Factory Settings', downloadDeviceLog: 'Download Device Log', - invalidUpgradeFile: 'Only cosmo-Vversion-md5.tar.gz format files are supported!', + invalidUpgradeFile: 'Only cosmo-Vversion-md5.tar.gz or cosmo-release-release-id.tar.gz files are supported!', fileTransferring: 'File transferring', fileTransferringProgress: 'File transferring {percent}%', upgradePreparing: 'File uploaded; validating the package and preparing to restart', diff --git a/src/web/src/i18n/locales/zh-CN.js b/src/web/src/i18n/locales/zh-CN.js index b92c2a152..ce882b86f 100644 --- a/src/web/src/i18n/locales/zh-CN.js +++ b/src/web/src/i18n/locales/zh-CN.js @@ -1137,6 +1137,16 @@ export default { clear: '清空' }, systemManage: { + modelAuthorization: '模型授权', + authorizationStatus: '授权状态', + authorized: '已授权', + notAuthorized: '未授权', + downloadAuthorizationRequest: '下载设备请求文件', + uploadAuthorizationFile: '上传授权文件', + authorizationRequestFailed: '生成设备请求文件失败', + invalidAuthorizationFile: '授权文件无效', + authorizationInstalled: '授权安装成功', + authorizationInstallFailed: '授权安装失败', // 网口设置 networkPortSettings: '网口设置', dnsSettings: 'DNS设置', @@ -1262,11 +1272,11 @@ export default { browse: '浏览', upgrade: '升级', upgradeTip1: '1.升级期间设备会自动重启,请保持供电和网络连接。', - upgradeTip2: '2.仅支持文件名为cosmo-V版本号-md5.tar.gz的安装包。', + upgradeTip2: '2.仅支持文件名为cosmo-V版本号-md5.tar.gz或cosmo-release-发布标识.tar.gz的安装包。', upgradeConfirm: '将使用 {fileName} 升级设备。设备会自动重启,管理页面将暂时断开,是否继续?', restoreFactory: '恢复出厂设置', downloadDeviceLog: '下载设备日志', - invalidUpgradeFile: '只能上传cosmo-V版本号-md5.tar.gz格式的文件!', + invalidUpgradeFile: '只能上传cosmo-V版本号-md5.tar.gz或cosmo-release-发布标识.tar.gz格式的文件!', fileTransferring: '文件传输中', fileTransferringProgress: '文件传输中 {percent}%', upgradePreparing: '文件已上传,正在校验并准备重启', diff --git a/src/web/src/utils/chunkUpload.js b/src/web/src/utils/chunkUpload.js index 837b69541..e6727eb9b 100644 --- a/src/web/src/utils/chunkUpload.js +++ b/src/web/src/utils/chunkUpload.js @@ -12,7 +12,8 @@ export const UploadPurpose = Object.freeze({ AUDIO: 'audio', ALGORITHM: 'algorithm', UPGRADE: 'upgrade', - IMAGE: 'image' + IMAGE: 'image', + MODEL_AUTHORIZATION_CERTIFICATE: 'model-authorization-certificate' }) const validPurposes = new Set(Object.values(UploadPurpose)) diff --git a/src/web/src/utils/upgradePackage.js b/src/web/src/utils/upgradePackage.js new file mode 100644 index 000000000..0ae7a9ad5 --- /dev/null +++ b/src/web/src/utils/upgradePackage.js @@ -0,0 +1,9 @@ +const legacyUpgradePackagePattern = + /^cosmo-[Vv]\d+\.\d+\.\d+-[0-9a-fA-F]{32}\.tar\.gz$/ +const signedReleasePackagePattern = + /^cosmo-release-[a-z0-9][a-z0-9._-]{0,63}\.tar\.gz$/ + +export const isSupportedUpgradePackageName = name => + typeof name === 'string' && + (legacyUpgradePackagePattern.test(name) || + signedReleasePackagePattern.test(name)) diff --git a/src/web/src/views/box/systemManagement/systemMaintain/index.vue b/src/web/src/views/box/systemManagement/systemMaintain/index.vue index 9b88d9208..5cc24d2f7 100644 --- a/src/web/src/views/box/systemManagement/systemMaintain/index.vue +++ b/src/web/src/views/box/systemManagement/systemMaintain/index.vue @@ -32,6 +32,23 @@ {{ t('systemManage.downloadDeviceLog') }}
+ +
+ + + + {{ authorization.authorized ? t('systemManage.authorized') : t('systemManage.notAuthorized') }} + + + +
+ {{ t('systemManage.downloadAuthorizationRequest') }} + + {{ t('systemManage.uploadAuthorizationFile') }} + +
+
+
@@ -40,11 +57,12 @@