From 601d666f0d08d5bc4c6c29be825b4cee1cd9f5a6 Mon Sep 17 00:00:00 2001 From: Gautam Korlam Date: Fri, 18 Sep 2026 01:24:39 -0700 Subject: [PATCH] [Tools] Run Prisma 4.8 on verified Node 24 --- .github/actionlint.yaml | 4 + .github/workflows/ci.yaml | 7 +- .github/workflows/compatibility.yaml | 94 ++++-- Cargo.lock | 49 ++- cli/Cargo.toml | 2 + cli/src/binaries/mod.rs | 61 +--- cli/src/binaries/platform.rs | 29 -- cli/src/binaries/runner.json | 139 ++++++++ cli/src/binaries/runner.rs | 462 +++++++++++++++++++++++++++ cli/src/prisma_cli.rs | 10 +- tests/compatibility/README.md | 41 +++ tests/compatibility/baseline.py | 23 ++ tests/compatibility/runner.py | 116 +++++++ 13 files changed, 926 insertions(+), 111 deletions(-) create mode 100644 .github/actionlint.yaml create mode 100644 cli/src/binaries/runner.json create mode 100644 cli/src/binaries/runner.rs create mode 100644 tests/compatibility/README.md create mode 100644 tests/compatibility/baseline.py create mode 100644 tests/compatibility/runner.py diff --git a/.github/actionlint.yaml b/.github/actionlint.yaml new file mode 100644 index 00000000..6db24d67 --- /dev/null +++ b/.github/actionlint.yaml @@ -0,0 +1,4 @@ +self-hosted-runner: + labels: + - namespace-profile-gitar + - namespace-profile-gitar-macos diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index e375a0ec..e68e0201 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -1,6 +1,7 @@ name: CI on: pull_request: + branches: [main] push: branches: - main @@ -39,11 +40,11 @@ jobs: - name: Rename existing rust toolchain (Windows) if: matrix.os == 'windows-latest' run: Rename-Item C:\Users\runneradmin\.rustup\toolchains\stable-x86_64-pc-windows-msvc C:\Users\runneradmin\.rustup\toolchains\stable-x86_64-pc-windows-msvc.old - + - name: Instal libssl (Ubuntu) if: matrix.os == 'ubuntu-latest' run: sudo apt-get install libssl-dev - + - name: Install Rust toolchain uses: actions-rs/toolchain@v1 with: @@ -54,7 +55,7 @@ jobs: - name: Cache Dependencies uses: Swatinem/rust-cache@ce325b60658c1b38465c06cc965b79baf32c1e72 - + - name: Generate client working-directory: integration-tests run: cargo prisma generate diff --git a/.github/workflows/compatibility.yaml b/.github/workflows/compatibility.yaml index c739b7cc..2f4c4c40 100644 --- a/.github/workflows/compatibility.yaml +++ b/.github/workflows/compatibility.yaml @@ -2,6 +2,8 @@ name: Compatibility binaries on: push: branches: [gitar-0.6.11, gitar-rustls-engines] + pull_request: + branches: [gitar-0.6.11, gitar-rustls-engines] workflow_dispatch: permissions: contents: read @@ -15,12 +17,15 @@ jobs: - target: aarch64-apple-darwin runner: namespace-profile-gitar-macos features: postgresql,mocking + platform: darwin - target: x86_64-unknown-linux-gnu runner: namespace-profile-gitar features: postgresql,mocking + platform: linux/amd64 - target: aarch64-unknown-linux-gnu runner: namespace-profile-gitar features: postgresql,mocking + platform: linux/arm64 runs-on: ${{ matrix.runner }} steps: - uses: namespacelabs/nscloud-checkout-action@v8 @@ -60,27 +65,62 @@ jobs: zip "prisma-cli-$TARGET.zip" "prisma-cli-$TARGET" source-sha.txt - name: Test archive verification if: matrix.target == 'aarch64-apple-darwin' - run: cargo test --locked -p prisma-client-rust-cli --no-default-features --features postgresql,mocking --lib binaries::tests - - name: Test x86 binary on Debian 12 with an empty engine cache - if: matrix.target == 'x86_64-unknown-linux-gnu' + run: cargo test --locked -p prisma-client-rust-cli --no-default-features --features postgresql,mocking --lib binaries + - name: Check runner formatting and lints + if: matrix.target == 'aarch64-apple-darwin' + run: | + cargo fmt --check -p prisma-client-rust-cli + cargo clippy --locked -p prisma-client-rust-cli -p prisma-cli \ + --no-default-features --features postgresql,mocking --all-targets + - name: Download the previous generator baseline + env: + TARGET: ${{ matrix.target }} + run: | + python3 tests/compatibility/baseline.py "$TARGET" + - name: Test macOS runner with an empty cache + if: matrix.target == 'aarch64-apple-darwin' run: | - cp artifacts/prisma-cli-x86_64-unknown-linux-gnu tests/compatibility/prisma - docker run --rm -v "$PWD/tests/compatibility:/work" debian:bookworm-slim sh -ec ' - apt-get update -qq - apt-get install -y -qq ca-certificates rustfmt - # Fail if the wrapper regresses to invoking OpenSSL for engine selection. - printf "#!/bin/sh\nexit 99\n" > /work/openssl - chmod +x /work/openssl - export PATH=/work:$PATH - export DATABASE_URL=postgresql://postgres@localhost:5432/postgres - prisma generate --schema /work/schema.prisma - test -s /work/prisma.rs - prisma --version - if prisma generate --schema /work/does-not-exist.prisma; then - echo "Invalid Prisma commands must fail" - exit 1 - fi - ' + python3 tests/compatibility/runner.py target/${{ matrix.target }}/release/prisma \ + --baseline baseline/prisma --work-dir runner-results + - uses: docker/setup-qemu-action@v3 + if: matrix.target == 'aarch64-unknown-linux-gnu' + with: + platforms: arm64 + - name: Test Linux runner with disposable PostgreSQL + if: matrix.target != 'aarch64-apple-darwin' + env: + TARGET: ${{ matrix.target }} + PLATFORM: ${{ matrix.platform }} + run: | + docker network create runner-test + trap 'docker rm -f runner-db; docker network rm runner-test' EXIT + docker run -d --name runner-db --network runner-test --network-alias fixture-db \ + -e POSTGRES_PASSWORD=fixture -e POSTGRES_DB=runner_fixture postgres:17 + for attempt in $(seq 1 30); do + if docker exec runner-db pg_isready -U postgres; then break; fi + sleep 1 + done + docker run --rm --platform "$PLATFORM" --network runner-test \ + -v "$PWD:/work" -e TARGET="$TARGET" -w /work debian:bookworm-slim sh -ec ' + apt-get update -qq + apt-get install -y -qq ca-certificates openssl rustfmt python3 + python3 tests/compatibility/runner.py "target/$TARGET/release/prisma" \ + --baseline baseline/prisma --work-dir runner-results \ + --database-url postgresql://postgres:fixture@fixture-db:5432/runner_fixture + ' + - uses: actions/upload-artifact@v4 + if: always() + with: + name: runner-results-${{ matrix.target }} + path: | + runner-results/results.json + runner-results/*.stdout + runner-results/*.stderr + runner-results/schema-*/*.stdout + runner-results/schema-*/*.stderr + runner-results/database/*.stdout + runner-results/database/*.stderr + retention-days: 7 - uses: actions/upload-artifact@v4 with: name: prisma-cli-${{ matrix.target }}-${{ github.sha }} @@ -142,3 +182,17 @@ jobs: if grep -E '^(native-tls|openssl|openssl-sys|postgres-native-tls) v' /tmp/quaint-dependencies.txt; then exit 1 fi + ci-success: + if: always() + needs: [build, driver-tests, quaint-tests] + runs-on: ubuntu-latest + steps: + - name: Require every compatibility job + env: + JOB_RESULTS: ${{ toJson(needs) }} + run: | + python3 - <<'PYTHON' + import json, os + results = json.loads(os.environ["JOB_RESULTS"]) + assert all(job["result"] == "success" for job in results.values()), results + PYTHON diff --git a/Cargo.lock b/Cargo.lock index 1088872f..efca7d54 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -583,7 +583,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "117725a109d387c937a1533ce01b450cbde6b88abceea8473c4d7a85853cda3c" dependencies = [ "lazy_static", - "windows-sys 0.48.0", + "windows-sys 0.52.0", ] [[package]] @@ -1221,7 +1221,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -1264,6 +1264,16 @@ version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" +[[package]] +name = "filetime" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" +dependencies = [ + "cfg-if", + "libc", +] + [[package]] name = "find-msvc-tools" version = "0.1.12" @@ -3503,6 +3513,8 @@ dependencies = [ "serde_path_to_error", "sha2 0.10.9", "syn 1.0.109", + "tar", + "tempfile", "thiserror 1.0.69", ] @@ -3813,7 +3825,7 @@ dependencies = [ "once_cell", "socket2 0.6.5", "tracing", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -4295,7 +4307,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -5099,6 +5111,17 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" +[[package]] +name = "tar" +version = "0.4.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" +dependencies = [ + "filetime", + "libc", + "xattr", +] + [[package]] name = "tempdir" version = "0.3.7" @@ -5116,10 +5139,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.3.4", + "getrandom 0.4.3", "once_cell", "rustix", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -5650,7 +5673,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "97fee6b57c6a41524a810daee9286c02d7752c4253064d0b05472833a438f675" dependencies = [ "cfg-if", - "rand 0.4.6", + "rand 0.8.8", "static_assertions", ] @@ -6003,7 +6026,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.48.0", + "windows-sys 0.61.2", ] [[package]] @@ -6313,6 +6336,16 @@ dependencies = [ "time", ] +[[package]] +name = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix", +] + [[package]] name = "yoke" version = "0.8.3" diff --git a/cli/Cargo.toml b/cli/Cargo.toml index 35b11ef2..215f404d 100644 --- a/cli/Cargo.toml +++ b/cli/Cargo.toml @@ -22,6 +22,8 @@ mongodb = ["prisma-client-rust-sdk/mongodb"] [dependencies] sha2 = "0.10" +tar = "0.4" +tempfile = "3" serde_json.workspace = true serde.workspace = true serde_path_to_error = "0.1.7" diff --git a/cli/src/binaries/mod.rs b/cli/src/binaries/mod.rs index b99da56b..2a8f5d32 100644 --- a/cli/src/binaries/mod.rs +++ b/cli/src/binaries/mod.rs @@ -1,11 +1,12 @@ pub mod platform; +mod runner; use directories::BaseDirs; use flate2::read::GzDecoder; use reqwest::blocking as reqwest; use sha2::{Digest, Sha256}; use std::collections::BTreeMap; -use std::fs::{create_dir_all, metadata, File}; +use std::fs::{create_dir_all, File}; use std::io; use std::io::prelude::*; use std::path::{Path, PathBuf}; @@ -38,29 +39,29 @@ pub const ENGINES: [Engine; 4] = [ }, ]; -pub fn prisma_cli_name() -> String { - let variation = platform::name(); - let arch = platform::arch(); - - format!("prisma-cli-{variation}-{arch}") -} - -pub fn global_cache_dir() -> PathBuf { - let base_dirs = BaseDirs::new().unwrap(); +pub fn global_cache_dir() -> Result { + if let Some(path) = std::env::var_os("PRISMA_CLI_CACHE_DIR") { + let path = PathBuf::from(path); + if !path.is_absolute() { + return Err("PRISMA_CLI_CACHE_DIR must be absolute".into()); + } + return Ok(path); + } + let base_dirs = BaseDirs::new().ok_or("Cannot resolve the Prisma cache directory")?; let cache_dir = base_dirs.cache_dir(); - cache_dir + Ok(cache_dir .join(BASE_DIR_NAME) .join("cli") - .join(PRISMA_CLI_VERSION) + .join(PRISMA_CLI_VERSION)) } -pub fn fetch_native(to_dir: &PathBuf) -> Result<(), String> { +pub fn fetch_native(to_dir: &Path) -> Result { if !to_dir.is_absolute() { Err("to_dir must be absolute".to_string())?; } - download_cli(to_dir)?; + let runner = runner::install(to_dir)?; for e in &ENGINES { if std::env::var_os(e.env).is_none() { @@ -68,37 +69,7 @@ pub fn fetch_native(to_dir: &PathBuf) -> Result<(), String> { } } - Ok(()) -} - -pub fn download_cli(to_dir: &PathBuf) -> Result<(), String> { - let cli = prisma_cli_name(); - - let to = platform::check_for_extension(&platform::name(), &to_dir.join(cli).to_str().unwrap()); - - let url = platform::check_for_extension( - &platform::name(), - &format!( - "https://prisma-photongo.s3-eu-west-1.amazonaws.com/{}-{}-{}-{}.gz", - "prisma-cli", - PRISMA_CLI_VERSION, - platform::name(), - platform::arch() - ), - ); - - match metadata(&to) { - Err(_) => (), - Ok(_) => { - return Ok(()); - } - }; - - println!("Downloading {} to {}", url, to); - - download(&url, Path::new(&to), None)?; - - Ok(()) + Ok(runner) } #[derive(serde::Deserialize)] diff --git a/cli/src/binaries/platform.rs b/cli/src/binaries/platform.rs index 1d90eb81..fce5585b 100644 --- a/cli/src/binaries/platform.rs +++ b/cli/src/binaries/platform.rs @@ -1,5 +1,4 @@ use std::env; -use std::ops::Add; /// Engine assets depend on the Rust target, not the host OpenSSL installation. pub fn binary_platform_name() -> Result<&'static str, String> { @@ -12,31 +11,3 @@ pub fn binary_platform_name() -> Result<&'static str, String> { )), } } - -pub fn arch() -> String { - match env::consts::ARCH { - "x86_64" => "x64".to_string(), - "aarch64" => "arm64".to_string(), - arch => panic!("Architecture {arch} is not yet supported"), - } -} - -pub fn name() -> String { - match env::consts::OS { - "macos" => "darwin".to_string(), - os => os.to_string(), - } -} - -pub fn check_for_extension(platform: &str, path: &str) -> String { - let path = path.to_string(); - - if platform == "windows" { - if path.contains(".gz") { - return path.replace(".gz", ".exe.gz"); - } - return path.add(".exe"); - } - - path -} diff --git a/cli/src/binaries/runner.json b/cli/src/binaries/runner.json new file mode 100644 index 00000000..2ca1467e --- /dev/null +++ b/cli/src/binaries/runner.json @@ -0,0 +1,139 @@ +{ + "node_version": "24.21.0", + "prisma_version": "4.8.0", + "nodes": { + "aarch64-apple-darwin": { + "url": "https://nodejs.org/dist/v24.21.0/node-v24.21.0-darwin-arm64.tar.gz", + "sha256": "bed7eea5325e1108f32ce5228ddd6a5f0f08a499ee42aa7442aea583702f6057", + "prefix": "node-v24.21.0-darwin-arm64", + "files": { + "bin/node": "e4b5a3af0e05c75de2eae013904145f40fe7fc2a6e6f17510128bf45cca4e79b", + "LICENSE": "5888dbb9a1d2b18f2c3e6c5f6af1b39de658372b402a0577b002777f14c62ace" + } + }, + "aarch64-unknown-linux-gnu": { + "url": "https://nodejs.org/dist/v24.21.0/node-v24.21.0-linux-arm64.tar.gz", + "sha256": "724282c3b43aec998aa9527380465b45d229e021b58035f5f4f63095eabfe5d5", + "prefix": "node-v24.21.0-linux-arm64", + "files": { + "bin/node": "0f8949d1028f6d61506b2d5bc57e7e6fe893d7b1997509b7847294fc9c616584", + "LICENSE": "5888dbb9a1d2b18f2c3e6c5f6af1b39de658372b402a0577b002777f14c62ace" + } + }, + "x86_64-unknown-linux-gnu": { + "url": "https://nodejs.org/dist/v24.21.0/node-v24.21.0-linux-x64.tar.gz", + "sha256": "6e1db87ef58b8819e5d5402eff1536491b18edd8eb7bee5ef7897876e88dc5ff", + "prefix": "node-v24.21.0-linux-x64", + "files": { + "bin/node": "7fde7b8afa198da66257f42ee2001d874c7355631e6d1579a5fb5ef1f246df4c", + "LICENSE": "5888dbb9a1d2b18f2c3e6c5f6af1b39de658372b402a0577b002777f14c62ace" + } + } + }, + "packages": [ + { + "name": "prisma", + "files": { + "LICENSE": "c71d239df91726fc519c6eb72d318ec65820627232b2f796219e87dcf35d0ab4", + "README.md": "8c84cd87ee1dc2b137e3e1101b324e2480a7e631e874849a2cc1fb4e43a1cdde", + "build/child.js": "17f5dd85527c122c345025e6a28e03d72c5bea1b6a0d459490f348e42b0f0dad", + "build/index.js": "a48fc7bb8a2a2ef9177001c683cc5b2b29816b77d443a274d5a06bf029bd6c5c", + "build/prisma_fmt_build_bg.wasm": "7e7dbc11267035199250a66803d676a8c8a03e11ba19910f8671833ce6bf1e13", + "build/public/assets/alert.60ea9f84.svg": "60ea9f846dff4acc207fb1b502e927b9902dba938f1c6638a113350560f0dd52", + "build/public/assets/array.1a36c222.svg": "1a36c2228a55c61b87d7ca35a0e7e58053c806d6b1dc48e79b105b93dff2eb4c", + "build/public/assets/boolean.9188b434.svg": "9188b4345a12ee28b7587d34192fc22d9b9c0f25dcc2065fbf813242e85d5a93", + "build/public/assets/chevron-down.24f76e3c.svg": "24f76e3c6cf393bd3badf71faa16d1990efd98296ee2396d7173f235bcf28a6e", + "build/public/assets/cross.c2610cf5.svg": "c2610cf52f598ad8e0a7e038c0b4d71f59edfd6ed78d4d9471c19b956156a40c", + "build/public/assets/datetime.a3bf710a.svg": "a3bf710a4309655b437e72f1b75ab55f8204e73247e604b649392b25e4c57cfe", + "build/public/assets/download.8d34b65a.svg": "8d34b65a85bb6f62e0a50724c9075a94527e6a0d1af92d7951b7276887ec6a89", + "build/public/assets/ellipsis.a8c5a34a.svg": "a8c5a34af5be4e2426aecf460b05155d6c78974be12cf714fea51d2c505c11b2", + "build/public/assets/enum.7ec0b64c.svg": "7ec0b64c7be2d733081521aa23ee51cca580ddb6deea5b45a83b2df8c78395a8", + "build/public/assets/folder.d77b8eaf.svg": "d77b8eafd83cbee7b51bafe9dd389eec64801e5d8b40a08eb1256a84157f491b", + "build/public/assets/hamburger.5fdadeac.svg": "5fdadeacc36339d74f7487f769a46728a240896add9d99c8a5d027a2b54e60b7", + "build/public/assets/index.js": "bfcb50068715bd3e07ee74aed14f77e5ecda6439aad11bbd74d72ef8ada3dedf", + "build/public/assets/inter-all-400-normal.4c1f8a0d.woff": "4c1f8a0d5ef1e04d7e14d194a4ef624345e7c7c1cdb4ff61d4552e32a60ac1d0", + "build/public/assets/inter-all-600-normal.d0a7c8a9.woff": "d0a7c8a993b9310ebb74a893fa564133bc129f6af41126eb98d207c813b96093", + "build/public/assets/inter-cyrillic-400-normal.ac97a49e.woff2": "ac97a49eca5ce5218b7dc5da931a69182aca421e1a66a3e03c0d7c84e1197541", + "build/public/assets/inter-cyrillic-600-normal.2c917f10.woff2": "2c917f10b6896b55f3793d1956066271ab909e52c8bffce225c4cd9bfad936fe", + "build/public/assets/inter-cyrillic-ext-400-normal.f21a6a97.woff2": "f21a6a97f08901ceed020bc58cf50ed44bf1fc62fd2f920177a3112e3f579cb0", + "build/public/assets/inter-cyrillic-ext-600-normal.bb31f197.woff2": "bb31f197d44718fce59f2936615168edbad9ca3e386b28ddc68c982b3675a0db", + "build/public/assets/inter-greek-400-normal.e9163df8.woff2": "e9163df802bb09d4a7642248fe44c1e234b1b803abab3e870e26673980dfb02a", + "build/public/assets/inter-greek-600-normal.e644d70f.woff2": "e644d70f8f22dbed6f177e58a6bb72ad81ab11b4568c70ea3d7d0a9ba90bbe7c", + "build/public/assets/inter-greek-ext-400-normal.43addcc8.woff2": "43addcc8f0505fd25ed1bee5f1df8182759c31e871643c2cf4b14d8a269e1655", + "build/public/assets/inter-greek-ext-600-normal.7f437016.woff2": "7f4370165124400d053bd09c08a0c0e8523f03433eb9cbdf2883ffa424896ffa", + "build/public/assets/inter-latin-400-normal.27ae72da.woff2": "27ae72daf88c7431896929273087c99910d019ae82dc0af7d86505c0f5ef5dbf", + "build/public/assets/inter-latin-600-normal.87d718a2.woff2": "87d718a282da60f8ef79c2c85e2999bd0fe7a6ef3fc77ccb3ad8a5ff8474b1ef", + "build/public/assets/inter-latin-ext-400-normal.5b02c69a.woff2": "5b02c69a8b7169da95612efd3003a29290b5b5dc573efe993ae066db23733714", + "build/public/assets/inter-latin-ext-600-normal.88feb9e4.woff2": "88feb9e4406cd478925f7ecf204cd31bfeb53b10694d4e16fd018187a82dc16d", + "build/public/assets/inter-vietnamese-600-normal.8185dacd.woff2": "8185dacd90a13f274cb37ffab50c79fa3e6191be2d14e9e804de5f74e6b385f7", + "build/public/assets/jetbrains-mono-all-400-normal.f86807b7.woff": "f86807b7a7aea856964b2010ab53d7b2cbacb0a5c2ce4c7b81103bf8455997e2", + "build/public/assets/jetbrains-mono-cyrillic-400-normal.1ae57fe2.woff2": "1ae57fe249bb4916830623f9824e037e375a72e5626eb946aac2a1531a146f75", + "build/public/assets/jetbrains-mono-latin-400-normal.80a5dc9e.woff2": "80a5dc9ee5c98aa278db4d618d253a1a9de8cfd5ff9d43a7a9cf41cbc4b6ce78", + "build/public/assets/jetbrains-mono-latin-ext-400-normal.6315c53c.woff2": "6315c53c76183393d75f9ead6d42aeea593d7c9b9bfa953bf4d6d3881f6fb35d", + "build/public/assets/logotype.a960b169.svg": "a960b169116bbed704932b6808a5efc51ce24c03d0dafed50d2bc0458ad1a8a1", + "build/public/assets/number.85ddf96b.svg": "85ddf96b9120707e6cb423fa7b74129f6df936545f8dee7feac8a5b5b116eb35", + "build/public/assets/object.0ba944a6.svg": "0ba944a6f22f291fb83c3a95bb1f4382ab7bb8da684c9410fbe3a9f20e700164", + "build/public/assets/play.8811691e.svg": "8811691edd5bdf7ff927519c7ece8dbcf350fa55553ad5a32dcf95aa6e0ddc6d", + "build/public/assets/plus.8fbf7ad3.svg": "8fbf7ad3218d46777f84ba237497927107bd98b54628e1ce1df8a5ee959b46ea", + "build/public/assets/refresh.d5448ccc.svg": "d5448ccc360a081541eb180883fcd3781cee6b583d5e2226fa5cefd84d15061f", + "build/public/assets/search.2ed766ce.svg": "2ed766ce032784cfeb1f73389bbffa264a0155b3b40368534a01aa1dcab2152a", + "build/public/assets/settings.5ad25af2.svg": "5ad25af29ec3d743fe977a3176ca3ee6bc277c18935bdf2eedf09f471d768d6f", + "build/public/assets/string.ea615a24.svg": "ea615a247275383aed0b5d6d63361f85dd6d42851d48e9db0ea0c9b0e7d946e8", + "build/public/assets/tick-indeterminate.aec8a44d.svg": "aec8a44d733bf35307eaa91b3ce3f8aabc0f4aea1f67e101104700a7cb719ffc", + "build/public/assets/tick.8cbb6a93.svg": "8cbb6a93e053ac6d4bbe5bb1983ec17e3457119e4ec038ce218515b64e55fa6d", + "build/public/assets/vendor.js": "4b43d1ddd63e2bbcb01e786c9fbeb51f7a3b3660758f276d845afe10e74a4a93", + "build/public/favicon.svg": "a08eaa01f28936b4dcfa2384ca6e7e71251d957135a3d9a377f17ec759f48f45", + "build/public/http/databrowser.js": "95bc49f2006d73e3495ed0637d7107156c49746e95a89e4e38ddaace028c6100", + "build/public/http/splash.js": "88042d6cd806bdebcef81a0a35d5d3850a89f5fbecf067f71b8ed8ecf9ff0d2d", + "build/public/icon-1024.png": "11bbab4a98472b4c6e56cedf6b8b0b96611af4104a19156d4296036db25659c0", + "build/public/index.css": "0e4426559166b7309dcf50cf6429981c564882ee99002951e801c89d0b0447ed", + "build/public/pages/http/databrowser.html": "ddc600ecb42508fb819132bc4f85ff5722c17f58b70cca3e15f4bc3c98cc7773", + "build/public/pages/http/splash.html": "72a5ee0c5ad1eeadf59d1f916ae3897eae792afda48093d6cdaa00dba87f6311", + "build/xdg-open": "4ef38acc21ba40c78ef9d3808fddc14fce0efeab3df27c6caa4a82d7bdac7492", + "install/index.js": "e9cdb6af88e928525743f30fa64ffcdc145dcfcbe1ae72fd24cc06ce6b2d2c8f", + "package.json": "2c8ecc8c2910ae6abece2701aad0f50bf570925467676f5a56b54c4c2ee0eb5d", + "preinstall/index.js": "1fef5d05363a6bbb823737bb1736581160d089f6ec7bff7ad51f76377c72fa27", + "prisma-client/README.md": "db376c808c671a8dfe0674275cbf8d5da765881517f00d9de831ca5dbb6ab47e", + "prisma-client/edge.d.ts": "022d0e5b18aa33e48e53e2fc7a558ee448a29cba6fe163a36cd5ece60cd1f7ff", + "prisma-client/edge.js": "2cfa752ce627ee625252ea60d1e9d16c181619459e6a3503e8d31a6685ab8ffc", + "prisma-client/generator-build/index.js": "7be1e8db76ed55fb3aae093291ab5a0cc0598955e8a49d7557dfd995ba77ae34", + "prisma-client/index-browser.js": "3d3124ae428b3514dbeb60a4270b904a063451cb82de1d96f0b16d69fd1860c6", + "prisma-client/index.d.ts": "022d0e5b18aa33e48e53e2fc7a558ee448a29cba6fe163a36cd5ece60cd1f7ff", + "prisma-client/index.js": "8b13a9c9749f7aa8087da8f50cbb385a8433cd51e912a7f17837cd5592d0c572", + "prisma-client/runtime/edge-esm.js": "bf7766a6459bc3e4b0d3d369c6790866171a42edfa17d9bfcbda9bcbf9c72752", + "prisma-client/runtime/edge.js": "4afe8470c93a63a7d0c52681b3fefb60084de983d84bce1a07f28f4bd8785d94", + "prisma-client/runtime/index-browser.d.ts": "bab2cb8c14c8af8c5ec66de6ed38d511a424a1fae94f6bb9d5c6ca98da90446a", + "prisma-client/runtime/index-browser.js": "d4e1e72d6d101e5ae00dbc3d20e6612b04382d763f20a3b4092b6f2ad2e936c1", + "prisma-client/runtime/index.d.ts": "ceb0ee3dd6f63ab530e4a364fc7f673c474128ccd24c308a4909dce063b11950", + "prisma-client/runtime/index.js": "1169f87ee13c7ec740768173364edfe51264b062f9c401ed24f11f658177e875", + "prisma-client/scripts/colors.js": "08eb6a703f1f8acb8c3cdd69f5c15ba958c3df657a35c7d4e30436a799847a9f", + "prisma-client/scripts/default-deno-edge.ts": "e33a4dc35013a5faf1a7f9f0bd4ee914b0070de60378b5a319a89f8ba54621ad", + "prisma-client/scripts/default-edge.js": "dc1fc74e4941929a8d56cc8a52b8bf1fd9d1d12cc7c38167731e288abfc4d794", + "prisma-client/scripts/default-index-browser.js": "3b96b42e115b324db7fdb6c9254218f5255fef44c0bfdd45e696c88401fb839c", + "prisma-client/scripts/default-index.d.ts": "6295a323d66e88c3f832df94d497952f1b982f55ce7c955e69c166efcbf73dd3", + "prisma-client/scripts/default-index.js": "e24a4388311861c39e4602666d3ea1647a1eb1042b217f16cd8ebabff633c501", + "prisma-client/scripts/get-packed-client.js": "43dd2083853c48515053198edca46618eff57676d4391286276f74f9a785cb26", + "prisma-client/scripts/mock-fs.js": "311301d563ee197dd63e1541cf235952a91dbb5bebe258675bd0a53224e4306f", + "prisma-client/scripts/postinstall.d.ts": "3bf65a90eb02f4bdf76d64569983b3770d6d54c27428682a38d836c487bc7bfd", + "prisma-client/scripts/postinstall.js": "7c227a99542f3d16a7c97abe49fe24013f79684182fb2c25fc5b2c4a445510c4", + "scripts/install-entry.js": "77499c7020e0f3ed14076591b6e6304d43e39460b21ba0fe8a56400e89478b09", + "scripts/preinstall-entry.js": "331e2afe45a311223b6f33439a58cbcc816ff26ec03f3e306482f5d7443fa73e" + } + }, + { + "name": "@prisma/engines", + "files": { + "LICENSE": "c71d239df91726fc519c6eb72d318ec65820627232b2f796219e87dcf35d0ab4", + "README.md": "8e16cab835fe30cd295d57fe019b813b66c68ac8f2ff61f5cfd4fea7852e85dc", + "dist/index.d.ts": "1d984b13b43a592ee3d19a7600e513e34e06a3bfc4fe3ae9a73ad69f313e3ad3", + "dist/index.js": "291f2d51660e2880f1e848a059b5eecced36f6dc40301015067b9a09c0c565d8", + "dist/scripts/localinstall.d.ts": "8e609bb71c20b858c77f0e9f90bb1319db8477b13f9f965f1a1e18524bf50881", + "dist/scripts/localinstall.js": "d026b14ce177b2983a45c4406290a6bdeca6083630fff2b6791f74e53f96f480", + "dist/scripts/postinstall.d.ts": "8e609bb71c20b858c77f0e9f90bb1319db8477b13f9f965f1a1e18524bf50881", + "dist/scripts/postinstall.js": "9dfdb0f626c569ceb0c80591fa34cece6ceb8e8d25788332948f4a83f6ed1a83", + "package.json": "b6830cb081b0fa5b7972211d7bb6066d8e9b746d4e72595b289a4c2aad65e6ba", + "scripts/postinstall.js": "63513eaac15578950530ea95a2e5ad0b4dd061f264d1db2dcc82a33661164f61" + } + } + ] +} diff --git a/cli/src/binaries/runner.rs b/cli/src/binaries/runner.rs new file mode 100644 index 00000000..935be78e --- /dev/null +++ b/cli/src/binaries/runner.rs @@ -0,0 +1,462 @@ +//! Verified Node and Prisma JavaScript installations. Never run npm install scripts. + +use std::{ + collections::BTreeMap, + fs, + io::Read, + path::{Component, Path, PathBuf}, + time::Duration, +}; + +use flate2::read::GzDecoder; +use serde::Deserialize; +use sha2::{Digest, Sha256}; + +use super::{platform, PRISMA_CLI_VERSION}; + +const MANIFEST: &str = include_str!("runner.json"); +const MAX_DOWNLOAD_BYTES: u64 = 128 * 1024 * 1024; + +type Files = BTreeMap; + +#[derive(Deserialize)] +struct Manifest { + node_version: String, + prisma_version: String, + nodes: BTreeMap, + packages: Vec, +} + +#[derive(Deserialize)] +struct NodeArchive { + url: String, + sha256: String, + prefix: String, + files: Files, +} + +#[derive(Deserialize)] +struct Package { + name: String, + files: Files, +} + +pub struct Runner { + pub node: PathBuf, + pub script: PathBuf, +} + +pub fn install(cache: &Path) -> Result { + let manifest: Manifest = serde_json::from_str(MANIFEST) + .map_err(|error| format!("Invalid runner manifest: {error}"))?; + if manifest.prisma_version != PRISMA_CLI_VERSION { + return Err("Prisma CLI and runner manifest versions disagree".into()); + } + let client = reqwest::blocking::Client::builder() + .connect_timeout(Duration::from_secs(15)) + .timeout(Duration::from_secs(180)) + .build() + .map_err(|error| format!("Cannot build runner download client: {error}"))?; + let root = cache.join("runner"); + install_with(&root, &manifest, platform::binary_platform_name()?, |url| { + let response = client + .get(url) + .send() + .and_then(|response| response.error_for_status()) + .map_err(|error| format!("Cannot download {url}: {error}"))?; + let mut bytes = Vec::new(); + response + .take(MAX_DOWNLOAD_BYTES + 1) + .read_to_end(&mut bytes) + .map_err(|error| format!("Cannot read {url}: {error}"))?; + if bytes.len() as u64 > MAX_DOWNLOAD_BYTES { + return Err(format!("Runner download exceeds the size limit: {url}")); + } + Ok(bytes) + }) +} + +fn digest(bytes: &[u8]) -> String { + format!("{:x}", Sha256::digest(bytes)) +} + +fn check_digest(bytes: &[u8], expected: &str) -> Result<(), String> { + if digest(bytes) != expected { + return Err("Runner checksum mismatch".into()); + } + Ok(()) +} + +fn relative_path(path: &str) -> Result<&Path, String> { + let path = Path::new(path); + if path.as_os_str().is_empty() + || !path + .components() + .all(|part| matches!(part, Component::Normal(_))) + { + return Err(format!("Invalid runner manifest path: {}", path.display())); + } + Ok(path) +} + +fn verify_installation(directory: &Path, files: &Files) -> Result<(), String> { + for (name, expected) in files { + let path = directory.join(relative_path(name)?); + let bytes = fs::read(&path).map_err(|error| { + format!( + "Cannot read cached runner {}: {error}. Remove {} and retry", + path.display(), + directory.display() + ) + })?; + check_digest(&bytes, expected).map_err(|error| { + format!( + "{error}: {}. Remove {} and retry", + path.display(), + directory.display() + ) + })?; + } + Ok(()) +} + +/// Publish a whole verified directory. A concurrent install may win the rename. +fn install_directory( + directory: &Path, + files: &Files, + populate: impl FnOnce(&Path) -> Result<(), String>, +) -> Result<(), String> { + if directory.exists() { + return verify_installation(directory, files); + } + let parent = directory.parent().ok_or("Runner cache has no parent")?; + fs::create_dir_all(parent).map_err(|error| error.to_string())?; + let temporary = tempfile::Builder::new() + .prefix(".runner-") + .tempdir_in(parent) + .map_err(|error| error.to_string())?; + populate(temporary.path())?; + verify_installation(temporary.path(), files)?; + match fs::rename(temporary.path(), directory) { + Ok(()) => Ok(()), + Err(_) if directory.exists() => verify_installation(directory, files), + Err(error) => Err(format!("Cannot install {}: {error}", directory.display())), + } +} + +fn write_verified( + directory: &Path, + name: &str, + bytes: &[u8], + checksum: &str, +) -> Result<(), String> { + check_digest(bytes, checksum)?; + let path = directory.join(relative_path(name)?); + fs::create_dir_all(path.parent().ok_or("Runner file has no parent")?) + .map_err(|error| error.to_string())?; + fs::write(&path, bytes).map_err(|error| error.to_string())?; + #[cfg(unix)] + if matches!(path.file_name(), Some(name) if name == "xdg-open") { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(&path, fs::Permissions::from_mode(0o755)) + .map_err(|error| error.to_string())?; + } + Ok(()) +} + +fn unpack_node(directory: &Path, archive: &NodeArchive, bytes: &[u8]) -> Result<(), String> { + // Authenticate the compressed bytes before parsing any archive entries. + check_digest(bytes, &archive.sha256)?; + let mut tar = tar::Archive::new(GzDecoder::new(bytes)); + for entry in tar.entries().map_err(|error| error.to_string())? { + let mut entry = entry.map_err(|error| error.to_string())?; + let path = entry + .path() + .map_err(|error| error.to_string())? + .into_owned(); + let name = match path + .strip_prefix(&archive.prefix) + .ok() + .and_then(Path::to_str) + { + Some(name) if archive.files.contains_key(name) => name, + _ => continue, + }; + if !entry.header().entry_type().is_file() { + return Err(format!("Node archive entry is not a file: {name}")); + } + let expected = archive + .files + .get(name) + .ok_or("Missing Node file checksum")?; + let mut data = Vec::new(); + entry + .read_to_end(&mut data) + .map_err(|error| error.to_string())?; + write_verified(directory, name, &data, expected)?; + } + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions( + directory.join("bin/node"), + fs::Permissions::from_mode(0o755), + ) + .map_err(|error| error.to_string())?; + } + Ok(()) +} + +fn install_with( + cache: &Path, + manifest: &Manifest, + target: &str, + mut fetch: impl FnMut(&str) -> Result, String>, +) -> Result { + let node = manifest + .nodes + .get(target) + .ok_or("No Node runtime for this target")?; + let node_dir = cache.join(format!("node-{}-{}", manifest.node_version, node.sha256)); + install_directory(&node_dir, &node.files, |directory| { + unpack_node(directory, node, &fetch(&node.url)?) + })?; + + let mut files = Files::new(); + let mut downloads = BTreeMap::new(); + for package in &manifest.packages { + relative_path(&package.name)?; + let prefix = if package.name == "prisma" { + "prisma".to_string() + } else { + format!("prisma/node_modules/{}", package.name) + }; + for (name, checksum) in &package.files { + relative_path(name)?; + let path = format!("{prefix}/{name}"); + files.insert(path.clone(), checksum.clone()); + downloads.insert( + path, + format!( + "https://unpkg.com/{}@{}/{name}", + package.name, manifest.prisma_version + ), + ); + } + } + let fingerprint = digest(&serde_json::to_vec(&files).map_err(|error| error.to_string())?); + let js_dir = cache.join(format!("prisma-{}-{fingerprint}", manifest.prisma_version)); + install_directory(&js_dir, &files, |directory| { + for (path, url) in &downloads { + let checksum = files.get(path).ok_or("Missing JavaScript checksum")?; + write_verified(directory, path, &fetch(url)?, checksum)?; + } + Ok(()) + })?; + Ok(Runner { + node: node_dir.join("bin/node"), + script: js_dir.join("prisma/build/index.js"), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use flate2::{write::GzEncoder, Compression}; + use std::sync::{Arc, Barrier}; + + type Downloads = BTreeMap>; + + fn fixture() -> Result<(Manifest, Downloads), Box> { + let mut tar = tar::Builder::new(GzEncoder::new(Vec::new(), Compression::default())); + for (name, data) in [ + ("bin/node", b"node fixture".as_slice()), + ("LICENSE", b"license".as_slice()), + ] { + let mut header = tar::Header::new_gnu(); + header.set_size(data.len() as u64); + header.set_mode(0o644); + header.set_cksum(); + tar.append_data(&mut header, format!("node-fixture/{name}"), data)?; + } + let archive = tar.into_inner()?.finish()?; + let script = b"JavaScript fixture".to_vec(); + let manifest = Manifest { + node_version: "fixture".into(), + prisma_version: "fixture".into(), + nodes: BTreeMap::from([( + "target".into(), + NodeArchive { + url: "https://fixture/node.tar.gz".into(), + sha256: digest(&archive), + prefix: "node-fixture".into(), + files: BTreeMap::from([ + ("bin/node".into(), digest(b"node fixture")), + ("LICENSE".into(), digest(b"license")), + ]), + }, + )]), + packages: vec![Package { + name: "prisma".into(), + files: BTreeMap::from([("build/index.js".into(), digest(&script))]), + }], + }; + Ok(( + manifest, + BTreeMap::from([ + ("https://fixture/node.tar.gz".into(), archive), + ( + "https://unpkg.com/prisma@fixture/build/index.js".into(), + script, + ), + ]), + )) + } + + #[test] + fn manifest_covers_release_targets_and_entrypoints() -> Result<(), Box> { + let manifest: Manifest = serde_json::from_str(MANIFEST)?; + assert_eq!(manifest.prisma_version, PRISMA_CLI_VERSION); + for target in [ + "aarch64-apple-darwin", + "aarch64-unknown-linux-gnu", + "x86_64-unknown-linux-gnu", + ] { + let node = manifest.nodes.get(target).ok_or("missing target")?; + assert!(node.files.contains_key("bin/node")); + assert!(node.files.contains_key("LICENSE")); + assert!(node.url.starts_with("https://nodejs.org/dist/")); + assert_eq!(node.sha256.len(), 64); + } + for name in ["prisma", "@prisma/engines"] { + let package = manifest + .packages + .iter() + .find(|p| p.name == name) + .ok_or("missing package")?; + assert!(package.files.contains_key("package.json")); + assert!(package.files.contains_key("LICENSE")); + for (path, checksum) in &package.files { + relative_path(path)?; + assert_eq!(checksum.len(), 64); + assert!(checksum.bytes().all(|byte| byte.is_ascii_hexdigit())); + } + } + Ok(()) + } + + #[test] + fn installs_verified_files_and_reuses_cache_offline() -> Result<(), Box> + { + let dir = tempfile::tempdir()?; + let (manifest, downloads) = fixture()?; + let runner = install_with(dir.path(), &manifest, "target", |url| { + downloads.get(url).cloned().ok_or("unknown URL".into()) + })?; + assert_eq!(fs::read(&runner.node)?, b"node fixture"); + assert_eq!(fs::read(&runner.script)?, b"JavaScript fixture"); + let cached = install_with(dir.path(), &manifest, "target", |_| { + Err("network must not be used".into()) + })?; + assert_eq!(cached.node, runner.node); + assert_eq!(cached.script, runner.script); + Ok(()) + } + + #[test] + fn failed_download_never_publishes_partial_javascript() -> Result<(), Box> + { + let dir = tempfile::tempdir()?; + let (manifest, downloads) = fixture()?; + let result = install_with(dir.path(), &manifest, "target", |url| { + if url.ends_with("index.js") { + return Err("interrupted download".into()); + } + downloads.get(url).cloned().ok_or("unknown URL".into()) + }); + assert!(result.is_err()); + for entry in fs::read_dir(dir.path())? { + assert!(entry?.file_name().to_string_lossy().starts_with("node-")); + } + install_with(dir.path(), &manifest, "target", |url| { + assert!( + url.ends_with("index.js"), + "verified Node cache should be reused" + ); + downloads.get(url).cloned().ok_or("unknown URL".into()) + })?; + Ok(()) + } + + #[test] + fn rejects_corrupt_download_before_extraction() -> Result<(), Box> { + let dir = tempfile::tempdir()?; + let (manifest, _) = fixture()?; + let result = install_with(dir.path(), &manifest, "target", |_| { + Ok(b"not an archive".to_vec()) + }); + assert!(matches!(result, Err(error) if error.contains("checksum mismatch"))); + assert_eq!(fs::read_dir(dir.path())?.count(), 0); + Ok(()) + } + + #[test] + fn rejects_corrupt_cached_node_and_javascript() -> Result<(), Box> { + for corrupt_node in [false, true] { + let dir = tempfile::tempdir()?; + let (manifest, downloads) = fixture()?; + let runner = install_with(dir.path(), &manifest, "target", |url| { + downloads.get(url).cloned().ok_or("unknown URL".into()) + })?; + fs::write( + if corrupt_node { + runner.node + } else { + runner.script + }, + b"tampered", + )?; + let result = install_with(dir.path(), &manifest, "target", |_| { + Err("network must not be used".into()) + }); + assert!(matches!(result, Err(error) if error.contains("checksum mismatch"))); + } + Ok(()) + } + + #[test] + fn concurrent_installers_publish_one_complete_directory( + ) -> Result<(), Box> { + let dir = tempfile::tempdir()?; + let destination = dir.path().join("installation"); + let barrier = Arc::new(Barrier::new(2)); + let mut handles = Vec::new(); + for _ in 0..2 { + let destination = destination.clone(); + let barrier = Arc::clone(&barrier); + handles.push(std::thread::spawn(move || { + let files = BTreeMap::from([("entry.js".into(), digest(b"complete"))]); + install_directory(&destination, &files, |temporary| { + fs::write(temporary.join("entry.js"), b"complete") + .map_err(|error| error.to_string())?; + barrier.wait(); + Ok(()) + }) + })); + } + for handle in handles { + handle.join().map_err(|_| "installer panicked")??; + } + assert_eq!(fs::read(destination.join("entry.js"))?, b"complete"); + assert_eq!(fs::read_dir(dir.path())?.count(), 1); + Ok(()) + } + + #[test] + fn rejects_non_relative_paths() { + for path in ["", "/absolute", "../escape", "package/../../escape"] { + assert!(relative_path(path).is_err()); + } + assert!(relative_path("@prisma/engines/dist/index.js").is_ok()); + } +} diff --git a/cli/src/prisma_cli.rs b/cli/src/prisma_cli.rs index 92d05a70..16593580 100644 --- a/cli/src/prisma_cli.rs +++ b/cli/src/prisma_cli.rs @@ -3,13 +3,11 @@ use std::env; use std::process::Command; pub fn main(args: &[String]) -> Result { - let dir = binaries::global_cache_dir(); + let dir = binaries::global_cache_dir()?; - binaries::fetch_native(&dir)?; - - let prisma = binaries::prisma_cli_name(); - - let mut cmd = Command::new(dir.join(prisma)); + let runner = binaries::fetch_native(&dir)?; + let mut cmd = Command::new(runner.node); + cmd.arg(runner.script); let binary_name = platform::binary_platform_name()?; cmd.args(args); diff --git a/tests/compatibility/README.md b/tests/compatibility/README.md new file mode 100644 index 00000000..19b4c9c8 --- /dev/null +++ b/tests/compatibility/README.md @@ -0,0 +1,41 @@ +# Prisma runner compatibility + +The CLI uses checksum-pinned Node 24 with Prisma 4.8 JavaScript. It retains the +Rust generator and the PostgreSQL engines from `v4.8.0-gitar.1`. + +`cli/src/binaries/runner.json` pins the Node archives and every file from the +`prisma` and `@prisma/engines` packages. JavaScript files come from versioned UNPKG +URLs. No npm lifecycle scripts run. The Node license and package licenses remain +in the installed directories. + +Downloads install into temporary directories and become visible after checksum +verification and an atomic rename. Cache keys include content hashes. Each launch +verifies cached files, and a corrupt cache fails with the directory to remove. +Parallel installers reuse the completed installation from the first successful rename. + +The cache defaults to the platform cache directory under `prisma/binaries/cli/4.8.0`. +`PRISMA_CLI_CACHE_DIR` accepts an absolute override, including an empty directory +for release tests. The old photongo executable is never selected. + +## Run the checks + +Build `prisma-cli` with the PostgreSQL features, then run: + +```sh +cargo test --locked -p prisma-client-rust-cli --no-default-features --features postgresql,mocking --lib binaries +cargo build --locked -p prisma-cli +python3 tests/compatibility/runner.py target/debug/prisma --baseline /path/to/v0.6.11.7/prisma +``` + +Pass `--schema` repeatedly to compare additional Gitar schemas. Each schema must +use the `prisma` provider and write `prisma.rs`. `--work-dir` retains command logs +and the result manifest. The baseline and replacement run at the same schema path +so generated Rust can be compared byte-for-byte. + +`--database-url` enables migration deployment and introspection on a disposable +PostgreSQL database. Only localhost or the Docker hostname `fixture-db` is accepted. +The runner does not read an ambient database URL for these checks. + +Linux ARM64 still needs the system `openssl` command for Prisma 4.8 platform +detection. Retain maintained certificate packages and OpenSSL. This runtime change +removes the obsolete embedded Node/OpenSSL executable, not that platform probe. diff --git a/tests/compatibility/baseline.py b/tests/compatibility/baseline.py new file mode 100644 index 00000000..ff4decbc --- /dev/null +++ b/tests/compatibility/baseline.py @@ -0,0 +1,23 @@ +#!/usr/bin/env python3 +"""Fetch the checksum-pinned previous release for generator comparisons.""" +import hashlib +import io +from pathlib import Path +import sys +import urllib.request +import zipfile + +CHECKSUMS = { + "aarch64-apple-darwin": "f8b1ffd0932f2025da6fac36c410e05eef036778ce858465e9af37359ad4de7b", + "aarch64-unknown-linux-gnu": "be77d9469fbe0de81c11f95d3c9b92790ad5eefa29ba11da8eda1feb47f63c1e", + "x86_64-unknown-linux-gnu": "a7f8a122cbe5d1a671fa1dc684a37c266f4508560f011bdf4c847ccf4138bc05", +} +target = sys.argv[1] +url = f"https://github.com/gitarcode/prisma-client-rust/releases/download/v0.6.11.7/prisma-cli-{target}.zip" +data = urllib.request.urlopen(url, timeout=120).read() +assert hashlib.sha256(data).hexdigest() == CHECKSUMS[target], "baseline checksum mismatch" +with zipfile.ZipFile(io.BytesIO(data)) as archive: + binary = archive.read(f"prisma-cli-{target}") +Path("baseline").mkdir(exist_ok=True) +Path("baseline/prisma").write_bytes(binary) +Path("baseline/prisma").chmod(0o755) diff --git a/tests/compatibility/runner.py b/tests/compatibility/runner.py new file mode 100644 index 00000000..406023b5 --- /dev/null +++ b/tests/compatibility/runner.py @@ -0,0 +1,116 @@ +#!/usr/bin/env python3 +"""Check a runner release without production credentials or database migrations.""" + +import argparse +import hashlib +import json +import os +from pathlib import Path +import shutil +import subprocess +import tempfile +from urllib.parse import urlparse + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("cli", type=Path) + parser.add_argument("--baseline", type=Path) + parser.add_argument("--schema", type=Path, action="append") + parser.add_argument("--database-url", help="Explicit disposable local Postgres database") + parser.add_argument("--work-dir", type=Path) + args = parser.parse_args() + cli = args.cli.resolve() + baseline = args.baseline.resolve() if args.baseline else None + schemas = args.schema or [Path(__file__).with_name("schema.prisma")] + if args.database_url: + parsed = urlparse(args.database_url) + if parsed.hostname not in {"localhost", "127.0.0.1", "fixture-db"}: + parser.error("database checks only accept local disposable Postgres") + with tempfile.TemporaryDirectory(prefix="prisma-runner-") as temporary: + work = args.work_dir.resolve() if args.work_dir else Path(temporary) + work.mkdir(parents=True, exist_ok=True) + cache = work / "cache" + bindir = work / "bin" + bindir.mkdir(exist_ok=True) + provider = bindir / "prisma" + env = dict(os.environ) + env.update( + PATH=str(bindir) + os.pathsep + env["PATH"], + PRISMA_CLI_CACHE_DIR=str(cache), + PRISMA_HIDE_UPDATE_MESSAGE="true", + CHECKPOINT_DISABLE="1", + DATABASE_URL="postgresql://postgres:fixture@localhost:5432/unused", + ) + for variable in ["PRISMA_GENERATOR_INVOCATION", "PRISMA_QUERY_ENGINE_BINARY", + "PRISMA_MIGRATION_ENGINE_BINARY", "PRISMA_INTROSPECTION_ENGINE_BINARY", + "PRISMA_FMT_BINARY"]: + env.pop(variable, None) + + def run(executable, directory, name, arguments, expected_success=True): + provider.unlink(missing_ok=True) + provider.symlink_to(executable) + result = subprocess.run([str(executable), *arguments], cwd=directory, env=env, + capture_output=True, text=True) + (directory / f"{name}.stdout").write_text(result.stdout) + (directory / f"{name}.stderr").write_text(result.stderr) + if expected_success != (result.returncode == 0): + raise RuntimeError(f"{name} exited {result.returncode}: {result.stderr}") + return result.stdout + + version = run(cli, work, "version", ["--version"]) + assert "prisma : 4.8.0" in version + nodes = list((cache / "runner").glob("node-*/bin/node")) + assert len(nodes) == 1, "expected exactly one pinned Node runtime" + runtime = json.loads(subprocess.check_output( + [str(nodes[0]), "-p", "JSON.stringify(process.versions)"], text=True)) + assert runtime["node"].startswith("24.") + assert not runtime["openssl"].startswith("1.1.") + results = {"node": runtime["node"], "openssl": runtime["openssl"], "schemas": []} + for index, schema in enumerate(schemas): + directory = work / f"schema-{index}" + directory.mkdir(exist_ok=True) + shutil.copy2(schema, directory / "schema.prisma") + old = None + if baseline: + run(baseline, directory, "baseline", ["generate", "--schema", "schema.prisma"]) + old = (directory / "prisma.rs").read_bytes() + run(cli, directory, "generate", ["generate", "--schema", "schema.prisma"]) + generated = (directory / "prisma.rs").read_bytes() + if old is not None: + assert old == generated, f"generated Rust changed for {schema}" + results["schemas"].append({"schema": str(schema), "bytes": len(generated), + "sha256": hashlib.sha256(generated).hexdigest(), "baseline_identical": old == generated if old is not None else None}) + run(cli, work, "missing-schema", ["generate", "--schema", "missing.prisma"], False) + # Existing verified caches remain usable without any network path. + env.update(HTTPS_PROXY="http://127.0.0.1:1", HTTP_PROXY="http://127.0.0.1:1", ALL_PROXY="http://127.0.0.1:1", NO_PROXY="") + run(cli, work, "offline", ["--version"]) + for key in ["HTTPS_PROXY", "HTTP_PROXY", "ALL_PROXY", "NO_PROXY"]: + env.pop(key, None) + if args.database_url: + directory = work / "database" + directory.mkdir(exist_ok=True) + shutil.copy2(Path(__file__).with_name("schema.prisma"), directory / "schema.prisma") + env["DATABASE_URL"] = args.database_url + run(cli, directory, "validate", ["validate", "--schema", "schema.prisma"]) + run(cli, directory, "format", ["format", "--schema", "schema.prisma"]) + sql = run(cli, directory, "diff", ["migrate", "diff", "--from-empty", "--to-schema-datamodel", "schema.prisma", "--script"]) + assert 'CREATE TABLE "Item"' in sql + migration = directory / "migrations/20260918000000_fixture" + migration.mkdir(parents=True, exist_ok=True) + (migration / "migration.sql").write_text(sql) + (directory / "migrations/migration_lock.toml").write_text('provider = "postgresql"\n') + run(cli, directory, "deploy", ["migrate", "deploy", "--schema", "schema.prisma"]) + run(cli, directory, "status", ["migrate", "status", "--schema", "schema.prisma"]) + run(cli, directory, "deploy-again", ["migrate", "deploy", "--schema", "schema.prisma"]) + introspected = run(cli, directory, "introspection", ["db", "pull", "--schema", "schema.prisma", "--print"]) + assert "model Item" in introspected and "enum State" in introspected + diff = run(cli, directory, "db-diff", ["migrate", "diff", "--from-url", args.database_url, "--to-schema-datamodel", "schema.prisma", "--script"]) + assert not any(statement in diff for statement in ["CREATE TABLE", "ALTER TABLE", "DROP TABLE"]) + results["database_checks"] = "passed" + (work / "results.json").write_text(json.dumps(results, indent=2) + "\n") + print(json.dumps(results, indent=2)) + + +if __name__ == "__main__": + main()