diff --git a/.github/workflows/node-test.yml b/.github/workflows/node-test.yml index 80671fbc7e3..279fa7e8bbb 100644 --- a/.github/workflows/node-test.yml +++ b/.github/workflows/node-test.yml @@ -299,6 +299,13 @@ jobs: - node-version: "24.18" script: "npm run test:functions-discover" steps: + # Exclude workspace and npm caches from Windows Defender real-time scanning. + # Windows Defender locks files during rapid disk operations (npm ci, binary/emulator extraction), + # which causes intermittent EPERM errors and test runner deadlocks on Windows runners. + - name: Disable Windows Defender for workspace + run: Set-MpPreference -ExclusionPath @("${{ github.workspace }}", "$env:LocalAppData\npm-cache", "$env:AppData\npm") + shell: powershell + - name: Setup Java JDK uses: actions/setup-java@860f60056505705214d223b91ed7a30f173f6142 # ratchet:actions/setup-java@v3.3.0 with: @@ -324,12 +331,11 @@ jobs: - run: echo ${{ secrets.service_account_json_base64 }} > tmp.txt - run: certutil -decode tmp.txt scripts/service-account.json - - run: npm i -g npm@9.5 - - run: npm ci + - run: npm ci --prefer-offline --no-audit - run: ${{ matrix.script }} - name: Print debug logs if: failure() - run: dir "*.log" /s/b | type + run: Get-ChildItem -Path . -Filter "*debug.log" -Recurse | ForEach-Object { Get-Content $_.FullName } check-package-lock: runs-on: ubuntu-latest diff --git a/scripts/clean-install.sh b/scripts/clean-install.sh index ba107ace6eb..ed726b16c6f 100755 --- a/scripts/clean-install.sh +++ b/scripts/clean-install.sh @@ -18,8 +18,10 @@ npx --yes clean-publish@5.0.0 --without-publish --before-script ./scripts/clean- echo "Ran clean-publish@5.0.0 --without-publish." echo "Packaging cleaned firebase-tools..." cd $ROOT_DIR/clean -PACKED=$(npm pack --pack-destination ./ | tail -n 1) +npm pack --pack-destination ./ +PACKED=$(ls -1 *.tgz | head -n 1) echo "Packaged firebase-tools to $PACKED." echo "Installing clean-packaged firebase-tools..." -npm install -g $PACKED +npm install -g "./$PACKED" echo "Installed clean-packaged firebase-tools." +cd "$ROOT_DIR" diff --git a/scripts/client-integration-tests/run.sh b/scripts/client-integration-tests/run.sh index a46669d2bea..995de95191e 100755 --- a/scripts/client-integration-tests/run.sh +++ b/scripts/client-integration-tests/run.sh @@ -2,4 +2,4 @@ source scripts/set-default-credentials.sh -mocha scripts/client-integration-tests/tests.ts \ No newline at end of file +mocha --exit --timeout 30000 scripts/client-integration-tests/tests.ts \ No newline at end of file diff --git a/scripts/client-integration-tests/tests.ts b/scripts/client-integration-tests/tests.ts index d36c0d2fbde..f5306811616 100644 --- a/scripts/client-integration-tests/tests.ts +++ b/scripts/client-integration-tests/tests.ts @@ -41,7 +41,11 @@ describe("deployHosting", () => { }); after(() => { - unlinkSync(firebasercFile); + try { + unlinkSync(firebasercFile); + } catch { + // ignore + } }); it("should deploy hosting", async () => { @@ -53,19 +57,18 @@ describe("deployHosting", () => { }).timeout(20 * 1e3); // Deploying takes several steps. }); -describe("apps:list", () => { +describe("apps:list", function (this: Mocha.Suite) { + this.timeout(15 * 1000); + this.retries(2); + it("should be able to list apps with missing or undefined optional arguments", async () => { - const noArgsApps = await client.apps.list({ project: process.env.FBTOOLS_TARGET_PROJECT }); + const [noArgsApps, undefinedArgsApps, nullArgsApps] = await Promise.all([ + client.apps.list({ project: process.env.FBTOOLS_TARGET_PROJECT }), + client.apps.list(undefined, { project: process.env.FBTOOLS_TARGET_PROJECT }), + client.apps.list(null, { project: process.env.FBTOOLS_TARGET_PROJECT }), + ]); expect(noArgsApps).to.have.length.greaterThan(0); - - const undefinedArgsApps = await client.apps.list(undefined, { - project: process.env.FBTOOLS_TARGET_PROJECT, - }); expect(undefinedArgsApps).to.have.length.greaterThan(0); - - const nullArgsApps = await client.apps.list(null, { - project: process.env.FBTOOLS_TARGET_PROJECT, - }); expect(nullArgsApps).to.have.length.greaterThan(0); }); @@ -76,7 +79,10 @@ describe("apps:list", () => { }); }); -describe("apps:sdkconfig", () => { +describe("apps:sdkconfig", function (this: Mocha.Suite) { + this.timeout(15 * 1000); + this.retries(2); + it("should return the web app configuration", async () => { const opts = { project: process.env.FBTOOLS_TARGET_PROJECT }; const apps = await client.apps.list("web", opts); diff --git a/scripts/emulator-tests/fixtures.ts b/scripts/emulator-tests/fixtures.ts index 8beb968d263..cd5c6f2446c 100644 --- a/scripts/emulator-tests/fixtures.ts +++ b/scripts/emulator-tests/fixtures.ts @@ -1,7 +1,7 @@ import { findModuleRoot, FunctionsRuntimeBundle } from "../../src/emulator/functionsEmulatorShared"; -export const TIMEOUT_LONG = 10000; -export const TIMEOUT_MED = 5000; +export const TIMEOUT_LONG = process.platform === "win32" ? 30000 : 10000; +export const TIMEOUT_MED = process.platform === "win32" ? 15000 : 5000; export const MODULE_ROOT = findModuleRoot("firebase-tools", __dirname); export const FunctionRuntimeBundles: { [key: string]: FunctionsRuntimeBundle } = { diff --git a/scripts/emulator-tests/functionsEmulator.spec.ts b/scripts/emulator-tests/functionsEmulator.spec.ts index df5cb1ef92f..92bc500db57 100644 --- a/scripts/emulator-tests/functionsEmulator.spec.ts +++ b/scripts/emulator-tests/functionsEmulator.spec.ts @@ -20,7 +20,7 @@ import * as registry from "../../src/emulator/registry"; import * as secretManager from "../../src/gcp/secretManager"; if ((process.env.DEBUG || "").toLowerCase().includes("spec")) { - const dropLogLevels = (info: logform.TransformableInfo) => info.message; + const dropLogLevels = (info: logform.TransformableInfo): string => `${info.message}`; logger.add( new winston.transports.Console({ level: "debug", diff --git a/scripts/emulator-tests/run.sh b/scripts/emulator-tests/run.sh index 4833febc8d9..c6fd2dd4572 100755 --- a/scripts/emulator-tests/run.sh +++ b/scripts/emulator-tests/run.sh @@ -15,7 +15,7 @@ trap cleanup EXIT cp package.json dev/package.json # Install deps required to run test triggers. -(cd scripts/emulator-tests/functions && npm ci --legacy-peer-deps) +(cd scripts/emulator-tests/functions && npm ci --legacy-peer-deps --prefer-offline --no-audit) # Run the tests from the built dev directory. - mocha dev/scripts/emulator-tests/*.spec.* +mocha --exit dev/scripts/emulator-tests/*.spec.* diff --git a/scripts/emulator-tests/unzipEmulators.spec.ts b/scripts/emulator-tests/unzipEmulators.spec.ts index bdf3b5ece31..4d55f14e8db 100644 --- a/scripts/emulator-tests/unzipEmulators.spec.ts +++ b/scripts/emulator-tests/unzipEmulators.spec.ts @@ -40,7 +40,7 @@ describe("unzipEmulators", () => { const serverFiles = await fs.promises.readdir(path.join(tempDir, "ui", "server")); expect(serverFiles).to.include("server.mjs"); - }).timeout(60000); + }).timeout(process.platform === "win32" ? 120000 : 60000); it("should unzip a pubsub emulator zip file", async () => { const downloadDetails = getDownloadDetails(Emulators.PUBSUB); @@ -73,7 +73,7 @@ describe("unzipEmulators", () => { path.join(tempDir, "pubsub", "pubsub-emulator", "bin"), ); expect(binFiles).to.include("cloud-pubsub-emulator"); - }).timeout(60000); + }).timeout(process.platform === "win32" ? 120000 : 60000); }); async function downloadFile(url: string, targetPath: string): Promise { diff --git a/scripts/functions-discover-tests/fixtures/bundled/install.sh b/scripts/functions-discover-tests/fixtures/bundled/install.sh index de6890dcdf0..2ec684bcb0f 100755 --- a/scripts/functions-discover-tests/fixtures/bundled/install.sh +++ b/scripts/functions-discover-tests/fixtures/bundled/install.sh @@ -2,4 +2,4 @@ set -euxo pipefail # bash strict mode IFS=$'\n\t' -npm i +npm i --prefer-offline --no-audit diff --git a/scripts/functions-discover-tests/fixtures/codebases/install.sh b/scripts/functions-discover-tests/fixtures/codebases/install.sh index b15011a2b4d..ab742f838af 100755 --- a/scripts/functions-discover-tests/fixtures/codebases/install.sh +++ b/scripts/functions-discover-tests/fixtures/codebases/install.sh @@ -2,5 +2,5 @@ set -euxo pipefail # bash strict mode IFS=$'\n\t' -(cd v1 && npm i) -(cd v2 && npm i) +(cd v1 && npm i --prefer-offline --no-audit) +(cd v2 && npm i --prefer-offline --no-audit) diff --git a/scripts/functions-discover-tests/fixtures/esm/install.sh b/scripts/functions-discover-tests/fixtures/esm/install.sh index 21c35208cf2..d9f9505a9ad 100755 --- a/scripts/functions-discover-tests/fixtures/esm/install.sh +++ b/scripts/functions-discover-tests/fixtures/esm/install.sh @@ -2,4 +2,4 @@ set -euxo pipefail # bash strict mode IFS=$'\n\t' -cd functions && npm i +cd functions && npm i --prefer-offline --no-audit diff --git a/scripts/functions-discover-tests/fixtures/pnpm/install.sh b/scripts/functions-discover-tests/fixtures/pnpm/install.sh index a7cdf59588d..d4cb035385e 100755 --- a/scripts/functions-discover-tests/fixtures/pnpm/install.sh +++ b/scripts/functions-discover-tests/fixtures/pnpm/install.sh @@ -2,4 +2,4 @@ set -euxo pipefail # bash strict mode IFS=$'\n\t' -cd functions && pnpm install --ignore-scripts +cd functions && pnpm install --ignore-scripts --config.node-linker=hoisted diff --git a/scripts/functions-discover-tests/fixtures/simple/install.sh b/scripts/functions-discover-tests/fixtures/simple/install.sh index 21c35208cf2..d9f9505a9ad 100755 --- a/scripts/functions-discover-tests/fixtures/simple/install.sh +++ b/scripts/functions-discover-tests/fixtures/simple/install.sh @@ -2,4 +2,4 @@ set -euxo pipefail # bash strict mode IFS=$'\n\t' -cd functions && npm i +cd functions && npm i --prefer-offline --no-audit diff --git a/scripts/functions-discover-tests/fixtures/stress-test/install.sh b/scripts/functions-discover-tests/fixtures/stress-test/install.sh index b60d01c6103..b79069e35de 100755 --- a/scripts/functions-discover-tests/fixtures/stress-test/install.sh +++ b/scripts/functions-discover-tests/fixtures/stress-test/install.sh @@ -2,4 +2,4 @@ set -euxo pipefail # bash strict mode IFS=$'\n\t' -cd functions && npm i \ No newline at end of file +cd functions && npm i --prefer-offline --no-audit \ No newline at end of file diff --git a/scripts/functions-discover-tests/fixtures/yarn-workspaces/install.sh b/scripts/functions-discover-tests/fixtures/yarn-workspaces/install.sh index 9e1c5a2ab0d..a7e5cb012c9 100755 --- a/scripts/functions-discover-tests/fixtures/yarn-workspaces/install.sh +++ b/scripts/functions-discover-tests/fixtures/yarn-workspaces/install.sh @@ -2,4 +2,4 @@ set -euxo pipefail # bash strict mode IFS=$'\n\t' -yarn install \ No newline at end of file +yarn install --prefer-offline \ No newline at end of file diff --git a/scripts/functions-discover-tests/run.sh b/scripts/functions-discover-tests/run.sh index 92a12377fb3..49f1f4043af 100755 --- a/scripts/functions-discover-tests/run.sh +++ b/scripts/functions-discover-tests/run.sh @@ -9,13 +9,13 @@ IFS=$'\n\t' firebase experiments:enable internaltesting # Install yarn -npm i -g yarn +npm i -g yarn --prefer-offline --no-audit # Install pnpm -npm install -g pnpm --force # it's okay to reinstall pnpm +npm install -g pnpm --force --prefer-offline --no-audit # it's okay to reinstall pnpm for dir in ./scripts/functions-discover-tests/fixtures/*; do (cd $dir && ./install.sh) done -mocha scripts/functions-discover-tests/tests.ts \ No newline at end of file +mocha --exit scripts/functions-discover-tests/tests.ts \ No newline at end of file diff --git a/scripts/hosting-tests/run.sh b/scripts/hosting-tests/run.sh index 623723c0f2a..8acba54895a 100755 --- a/scripts/hosting-tests/run.sh +++ b/scripts/hosting-tests/run.sh @@ -4,7 +4,8 @@ CWD="$(pwd)" source scripts/set-default-credentials.sh -TARGET_FILE="${COMMIT_SHA}-${CI_JOB_ID}.txt" +RUN_SUFFIX="${GITHUB_RUN_NUMBER:-$RANDOM}-${RUNNER_OS:-linux}-${RANDOM}" +TARGET_FILE="${COMMIT_SHA}-${RUN_SUFFIX}.txt" echo "Running in ${CWD}" echo "Running with node: $(which node)" @@ -50,14 +51,35 @@ touch "public/${TARGET_FILE}" echo "${DATE}" > "public/${TARGET_FILE}" echo "Initialized temp directory." +function kill_port() { + local PORT_NUM="$1" + if command -v lsof &> /dev/null; then + local pids=$(lsof -t -sTCP:LISTEN -i:"$PORT_NUM" 2>/dev/null || true) + if [ -n "$pids" ]; then + kill -9 $pids 2>/dev/null || true + fi + fi + if command -v netstat &> /dev/null; then + local pids=$(netstat -ano | awk -v port=":$PORT_NUM" '$2 ~ port"$" && $4 == "LISTENING" {print $5}' | sort -u || true) + for p in $pids; do + if [ "$p" != "0" ] && [ -n "$p" ]; then + taskkill //pid "$p" //T //F 2>/dev/null || true + fi + done + fi +} + echo "Testing local serve..." firebase serve --only hosting --project "${FBTOOLS_TARGET_PROJECT}" --port "${PORT}" --debug & PID="$!" sleep 5 VALUE="$(curl localhost:${PORT}/${TARGET_FILE})" test "${DATE}" = "${VALUE}" || (echo "Expected ${VALUE} to equal ${DATE}." && false) -kill "$PID" -wait +kill "$PID" 2>/dev/null || true +if command -v taskkill &> /dev/null; then + taskkill //pid "$PID" //T //F 2>/dev/null || true +fi +kill_port "${PORT}" echo "Tested local serve." echo "Testing local hosting emulator..." @@ -75,12 +97,16 @@ INIT_JS_FALSE="$(curl localhost:${PORT}/__/firebase/init.js\?useEmulator=false)" INIT_JS_TRUE="$(curl localhost:${PORT}/__/firebase/init.js\?useEmulator=true)" [[ "${INIT_JS_TRUE}" =~ "firebaseEmulators = {" ]] || (echo "Expected firebaseEmulators to be defined" && false) -kill "$PID" -wait +kill "$PID" 2>/dev/null || true +if command -v taskkill &> /dev/null; then + taskkill //pid "$PID" //T //F 2>/dev/null || true +fi +kill_port "${PORT}" +kill_port "5000" echo "Tested local hosting emulator." echo "Testing hosting deployment..." -firebase hosting:channel:deploy --expires 1h --project "${FBTOOLS_TARGET_PROJECT}" --json "${GITHUB_RUN_NUMBER}" | tee channeldeploy.json +firebase hosting:channel:deploy --non-interactive --expires 1h --project "${FBTOOLS_TARGET_PROJECT}" --json "channel-${RUN_SUFFIX}" | tee channeldeploy.json URL=$(cat channeldeploy.json | jq -r ".result.\"${FBTOOLS_TARGET_PROJECT}\".url") sleep 12 VALUE="$(curl $URL/${TARGET_FILE})" @@ -123,8 +149,7 @@ mkdir "public" touch "public/${TARGET_FILE}" echo "${DATE}" > "public/${TARGET_FILE}" echo "Setting targets..." -firebase use --add "${FBTOOLS_TARGET_PROJECT}" -firebase target:apply hosting customtarget "${FBTOOLS_TARGET_PROJECT}" +firebase target:apply hosting customtarget "${FBTOOLS_TARGET_PROJECT}" --project "${FBTOOLS_TARGET_PROJECT}" echo "Set targets." echo "Initialized second temp directory." @@ -137,7 +162,7 @@ echo "Initialized second temp directory." # echo "Tested hosting deployment by target." echo "Testing hosting channel deployment by target..." -firebase hosting:channel:deploy mychannel --only customtarget --project "${FBTOOLS_TARGET_PROJECT}" --json | tee output.json +firebase hosting:channel:deploy "targetchannel-${RUN_SUFFIX}" --only customtarget --project "${FBTOOLS_TARGET_PROJECT}" --non-interactive --json | tee output.json CHANNEL_URL=$(cat output.json | jq -r ".result.customtarget.url") sleep 12 VALUE="$(curl ${CHANNEL_URL}/${TARGET_FILE})" diff --git a/scripts/integration-helpers/cli.ts b/scripts/integration-helpers/cli.ts index 0b44c58dcca..cc64ad189da 100644 --- a/scripts/integration-helpers/cli.ts +++ b/scripts/integration-helpers/cli.ts @@ -1,4 +1,4 @@ -import { ChildProcess } from "child_process"; +import { ChildProcess, execSync } from "child_process"; import * as spawn from "cross-spawn"; export class CLIProcess { @@ -77,6 +77,32 @@ export class CLIProcess { return Promise.resolve(); } + if (process.platform === "win32" && p.pid) { + const exitPromise = new Promise((resolve) => { + if (p.exitCode !== null || p.signalCode !== null) { + resolve(); + return; + } + p.once("exit", () => resolve()); + }); + + let timeoutId: NodeJS.Timeout; + const timeoutPromise = new Promise((resolve) => { + timeoutId = setTimeout(resolve, 2000); + }); + + try { + execSync(`taskkill /pid ${p.pid} /T /F`); + } catch { + // ignore if process already exited + } + + return Promise.race([exitPromise, timeoutPromise]).then(() => { + clearTimeout(timeoutId); + this.process = undefined; + }); + } + const stopped = new Promise((resolve) => { p.once("exit", (/* exitCode, signal */) => { this.process = undefined; diff --git a/scripts/storage-deploy-tests/run.sh b/scripts/storage-deploy-tests/run.sh index 65e5ec33f18..ae6e530b809 100755 --- a/scripts/storage-deploy-tests/run.sh +++ b/scripts/storage-deploy-tests/run.sh @@ -4,7 +4,8 @@ CWD="$(pwd)" source scripts/set-default-credentials.sh -TARGET_FILE="${COMMIT_SHA}-${CI_JOB_ID}.txt" +RUN_SUFFIX="${GITHUB_RUN_NUMBER:-$RANDOM}-${RUNNER_OS:-linux}-${RANDOM}" +TARGET_FILE="${COMMIT_SHA}-${RUN_SUFFIX}.txt" echo "Running in ${CWD}" echo "Running with node: $(which node)" @@ -48,7 +49,7 @@ EOM echo "Initialized temp directory." echo "Testing storage deployment..." -firebase deploy --force --only storage --project "${FBTOOLS_TARGET_PROJECT}" +firebase deploy --force --non-interactive --only storage --project "${FBTOOLS_TARGET_PROJECT}" RET_CODE="$?" test "${RET_CODE}" == "0" || (echo "Expected exit code ${RET_CODE} to equal 0." && false) echo "Tested storage deployment." @@ -64,20 +65,19 @@ cat > "firebase.json" <<- EOM ] } EOM -firebase use --add "${FBTOOLS_TARGET_PROJECT}" -firebase target:apply storage storage-target "${FBTOOLS_TARGET_PROJECT}.appspot.com" +firebase target:apply storage storage-target "${FBTOOLS_TARGET_PROJECT}.appspot.com" --project "${FBTOOLS_TARGET_PROJECT}" echo "Updated config for targets." echo "Testing storage deployment with invalid target..." set +e -firebase deploy --force --only storage:storage-invalid-target --project "${FBTOOLS_TARGET_PROJECT}" +firebase deploy --force --non-interactive --only storage:storage-invalid-target --project "${FBTOOLS_TARGET_PROJECT}" RET_CODE="$?" set -e -test "${RET_CODE}" == "1" || (echo "Expected exit code ${RET_CODE} to equal 1." && false) +test "${RET_CODE}" != "0" || (echo "Expected exit code ${RET_CODE} to not equal 0." && false) echo "Tested storage deployment with invalid target." echo "Testing storage deployment with target..." -firebase deploy --force --only storage:storage-target --project "${FBTOOLS_TARGET_PROJECT}" +firebase deploy --force --non-interactive --only storage:storage-target --project "${FBTOOLS_TARGET_PROJECT}" RET_CODE="$?" test "${RET_CODE}" == "0" || (echo "Expected exit code ${RET_CODE} to equal 0." && false) echo "Tested storage deployment with target." \ No newline at end of file diff --git a/scripts/triggers-end-to-end-tests/run.sh b/scripts/triggers-end-to-end-tests/run.sh index 89cab9dc587..a27c5180ebb 100755 --- a/scripts/triggers-end-to-end-tests/run.sh +++ b/scripts/triggers-end-to-end-tests/run.sh @@ -1,18 +1,19 @@ #!/bin/bash function cleanup() { - if ! command -v lsof &> /dev/null - then - echo "lsof could not be found" - exit - fi - # Kill all emulator processes - for PORT in 4000 9000 9001 9002 8085 9099 9199 - do - PID=$(lsof -t -i:$PORT || true) - if [ -n "$PID" ] - then - kill -9 $PID + for PORT in 4000 9000 9001 9002 8085 9099 9199; do + if command -v lsof &> /dev/null; then + PID=$(lsof -t -sTCP:LISTEN -i:$PORT 2>/dev/null || true) + if [ -n "$PID" ]; then + kill -9 $PID 2>/dev/null || true + fi + elif command -v netstat &> /dev/null; then + PIDS=$(netstat -ano | awk -v port=":$PORT" '$2 ~ port"$" && $4 == "LISTENING" {print $5}' | sort -u || true) + for P in $PIDS; do + if [ "$P" != "0" ] && [ -n "$P" ]; then + taskkill //pid "$P" //T //F 2>/dev/null || true + fi + done fi done } @@ -24,7 +25,7 @@ source scripts/set-default-credentials.sh for dir in triggers v1 v2; do ( cd scripts/triggers-end-to-end-tests/$dir - npm ci + npm ci --prefer-offline --no-audit ) done diff --git a/scripts/triggers-end-to-end-tests/tests.inspect.ts b/scripts/triggers-end-to-end-tests/tests.inspect.ts index a25fe795d9b..36162ad32eb 100755 --- a/scripts/triggers-end-to-end-tests/tests.inspect.ts +++ b/scripts/triggers-end-to-end-tests/tests.inspect.ts @@ -9,9 +9,9 @@ const FIREBASE_PROJECT = process.env.FBTOOLS_TARGET_PROJECT || ""; * Various delays that are needed because this test spawns * parallel emulator subprocesses. */ -const TEST_SETUP_TIMEOUT = 80000; -const EMULATORS_WRITE_DELAY_MS = 5000; -const EMULATORS_SHUTDOWN_DELAY_MS = 5000; +const TEST_SETUP_TIMEOUT = process.platform === "win32" ? 180000 : 80000; +const EMULATORS_WRITE_DELAY_MS = process.platform === "win32" ? 10000 : 5000; +const EMULATORS_SHUTDOWN_DELAY_MS = process.platform === "win32" ? 30000 : 5000; function readConfig(): FrameworkOptions { const filename = path.join(__dirname, "firebase.json"); diff --git a/src/fetchMOTD.ts b/src/fetchMOTD.ts index 335e309b101..062c1950524 100644 --- a/src/fetchMOTD.ts +++ b/src/fetchMOTD.ts @@ -15,6 +15,9 @@ const ONE_DAY_MS = 1000 * 60 * 60 * 24; * Fetches the message of the day. */ export function fetchMOTD(): void { + if (process.env.CI) { + return; + } let motd = configstore.get("motd"); const motdFetched = configstore.get("motd.fetched") || 0; diff --git a/src/unzip.spec.ts b/src/unzip.spec.ts index 9df66bed03d..a23ed5edbc6 100644 --- a/src/unzip.spec.ts +++ b/src/unzip.spec.ts @@ -2,9 +2,33 @@ import { expect } from "chai"; import * as fs from "fs"; import { tmpdir } from "os"; import * as path from "path"; -import { unzip } from "./unzip"; +import { unzip, isChildDir } from "./unzip"; import { ZIP_CASES } from "./test/fixtures/zip-files"; +describe("isChildDir", () => { + it("should return true for legitimate subdirectories and files", () => { + expect(isChildDir("/parent", "/parent/child")).to.be.true; + expect(isChildDir("/parent", "/parent/child/grandchild.txt")).to.be.true; + expect(isChildDir("/parent/", "/parent/child")).to.be.true; + }); + + it("should return false for the exact same path", () => { + expect(isChildDir("/parent", "/parent")).to.be.false; + expect(isChildDir("/parent/", "/parent/")).to.be.false; + }); + + it("should return false for sibling directories sharing a prefix (Zip Slip protection)", () => { + expect(isChildDir("/parent", "/parent-sibling")).to.be.false; + expect(isChildDir("/parent", "/parent_sibling/file.txt")).to.be.false; + expect(isChildDir("/tmp/app", "/tmp/app-secret/config.json")).to.be.false; + }); + + it("should return false for parent or ancestor traversal", () => { + expect(isChildDir("/parent/sub", "/parent")).to.be.false; + expect(isChildDir("/parent/sub", "/parent/other")).to.be.false; + }); +}); + describe("unzip", () => { let tempDir: string; @@ -24,7 +48,7 @@ describe("unzip", () => { const expectedSize = await calculateFolderSize(inflatedDir); expect(await calculateFolderSize(unzipPath)).to.eql(expectedSize); - }).timeout(2000); + }).timeout(10000); } else { it(`should throw "${wantErr}" when reading a zip file with ${name} case`, async () => { const unzipPath = path.join(tempDir, name); diff --git a/src/unzip.ts b/src/unzip.ts index 3edd9687382..76c8869c90a 100644 --- a/src/unzip.ts +++ b/src/unzip.ts @@ -97,7 +97,7 @@ const extractEntriesFromBuffer = async (data: Buffer, outputDir: string): Promis logger.debug(`[unzip] mkdir: ${outputFilePath}`); await fs.promises.mkdir(outputFilePath, { recursive: true }); } else { - const parentDir = outputFilePath.substring(0, outputFilePath.lastIndexOf(path.sep)); + const parentDir = path.dirname(outputFilePath); logger.debug(`[unzip] else mkdir: ${parentDir}`); await fs.promises.mkdir(parentDir, { recursive: true }); @@ -123,13 +123,31 @@ const extractEntriesFromBuffer = async (data: Buffer, outputDir: string): Promis } }; -function isChildDir(parentDir: string, potentialChild: string): boolean { +/** + * Validates whether potentialChild is a strict subdirectory or descendant file of parentDir. + * Protects against Zip Slip directory traversal vulnerabilities. + */ +export function isChildDir(parentDir: string, potentialChild: string): boolean { try { // 1. Resolve and normalize both paths to absolute paths const resolvedParent = path.resolve(parentDir); const resolvedChild = path.resolve(potentialChild); - // The child path must start with the parent path and not be the same path. - return resolvedChild.startsWith(resolvedParent) && resolvedChild !== resolvedParent; + // On Windows, file systems are case-insensitive (e.g. drive letters C: vs c:, + // or system paths like TEMP vs Temp). Comparing resolved paths directly with startsWith + // can fail when casing diverges between process.cwd() and archive entries, causing + // valid extraction paths to be falsely flagged as Zip Slip violations. + // Converting both paths to lowercase on win32 ensures robust prefix checking. + if (process.platform === "win32") { + const lowerParent = resolvedParent.toLowerCase(); + const lowerChild = resolvedChild.toLowerCase(); + const parentWithSep = lowerParent.endsWith(path.sep) ? lowerParent : lowerParent + path.sep; + return lowerChild.startsWith(parentWithSep) && lowerChild !== lowerParent; + } + // The child path must start with the parent path with separator and not be the same path. + const parentWithSep = resolvedParent.endsWith(path.sep) + ? resolvedParent + : resolvedParent + path.sep; + return resolvedChild.startsWith(parentWithSep) && resolvedChild !== resolvedParent; } catch (error) { // If either path does not exist, an error will be thrown. // In this case, the potential child cannot be a subdirectory.