From 810cdec7300eebc8074cbeca69727fd21a0799fb Mon Sep 17 00:00:00 2001 From: MK Date: Fri, 9 Oct 2026 00:10:17 +0800 Subject: [PATCH 1/3] fix: isolate Azure auth environment mappings --- README.md | 4 ++ azure/setup-vp.yml | 8 ++- dist/azure/index.mjs | 2 +- src/azure/auth-env.test.ts | 141 +++++++++++++++++++++++++++++++++++++ src/azure/auth-env.ts | 55 +++++++++++++++ src/azure/index.ts | 2 + src/azure/parity.test.ts | 43 ++++++++++- src/azure/template.test.ts | 109 +++++++++++++++++++++++++++- 8 files changed, 358 insertions(+), 6 deletions(-) create mode 100644 src/azure/auth-env.test.ts create mode 100644 src/azure/auth-env.ts diff --git a/README.md b/README.md index f0a2235..943ce78 100644 --- a/README.md +++ b/README.md @@ -690,6 +690,10 @@ Finalize tasks also expose `version` and `cacheHit` as named outputs. Use `$(set Without `registryUrl`, the runtime preserves existing project `.npmrc` auth entries but does not generate token entries for its registry URLs. Set `registryUrl` to generate auth configuration for a registry. Referenced custom token variables are propagated as secret pipeline variables, not public outputs. Pass custom secret mappings through `authEnv`, for example `authEnv: { CUSTOM_TOKEN: "$(CUSTOM_TOKEN)" }`. Azure does not automatically put secret pipeline variables in task environments. +`authEnv` accepts `TOKEN`, `PASSWORD`, `SECRET`, `KEY`, or names ending in `_TOKEN`, `_PASSWORD`, `_SECRET`, or `_KEY`. Names can contain only ASCII letters, digits, and underscores, cannot start with a digit, and must be unique regardless of case. Runtime and CI namespaces, such as `NODE_*` and `SETUP_VP_*`, are reserved. Supported credential exceptions include `NODE_AUTH_TOKEN`, `GITHUB_TOKEN`, `CI_JOB_TOKEN`, `SYSTEM_ACCESSTOKEN`, `YARN_NPM_AUTH_TOKEN`, and `YARN_NPM_AUTH_IDENT`. Other names are rejected before installation. + +For a secret with a different name, use an alias such as `authEnv: { CUSTOM_TOKEN: "$(MY_CREDENTIAL)" }` and reference `${CUSTOM_TOKEN}` in `.npmrc`. Custom credentials reach the install process after validation; unresolved secret macros are treated as missing credentials. + ### Azure Notes - The template supports Microsoft-hosted Linux, macOS, and Windows agents. diff --git a/azure/setup-vp.yml b/azure/setup-vp.yml index 92274be..a6eecbe 100644 --- a/azure/setup-vp.yml +++ b/azure/setup-vp.yml @@ -153,7 +153,9 @@ steps: SETUP_VP_REGISTRY_URL: ${{ parameters.registryUrl }} SETUP_VP_SCOPE: ${{ parameters.scope }} NODE_AUTH_TOKEN: $(NODE_AUTH_TOKEN) - ${{ insert }}: ${{ parameters.authEnv }} + # Keep credential mappings inert until the runtime validates their names. + ${{ each pair in parameters.authEnv }}: + ${{ format('SETUP_VP_AUTH_ENV_{0}', pair.key) }}: ${{ pair.value }} SETUP_VP_CACHE: ${{ iif(eq(parameters.cache, true), 'true', 'false') }} SETUP_VP_CACHE_DEPENDENCY_PATH: ${{ parameters.cacheDependencyPath }} @@ -171,6 +173,8 @@ steps: SETUP_VP_REGISTRY_URL: ${{ parameters.registryUrl }} SETUP_VP_SCOPE: ${{ parameters.scope }} NODE_AUTH_TOKEN: $(NODE_AUTH_TOKEN) - ${{ insert }}: ${{ parameters.authEnv }} + # Keep credential mappings inert until the runtime validates their names. + ${{ each pair in parameters.authEnv }}: + ${{ format('SETUP_VP_AUTH_ENV_{0}', pair.key) }}: ${{ pair.value }} SETUP_VP_CACHE: ${{ iif(eq(parameters.cache, true), 'true', 'false') }} SETUP_VP_CACHE_DEPENDENCY_PATH: ${{ parameters.cacheDependencyPath }} diff --git a/dist/azure/index.mjs b/dist/azure/index.mjs index f2053fa..e1563cc 100644 --- a/dist/azure/index.mjs +++ b/dist/azure/index.mjs @@ -189,4 +189,4 @@ fi set +u set -o pipefail ${r} | bash -`.trim()]}}const dt=2e3;function getVitePlusHome(e=process.platform,t=process.env){let n=isWindows(e)?t.USERPROFILE||S():t.HOME||S();return g(n,`.vite-plus`)}function runInstallCommand(e,t,n=process.platform){let{command:r,args:i}=getInstallScriptCommand(e,n,t.VP_VPDIRS_AWARE===`1`),a={env:{...process.env,...t},stdio:`inherit`},o=x(r,i,a);if(isWindows(n)&&o.error?.code===`ENOENT`&&(o=x(`powershell.exe`,i,a)),o.error)throw o.error;return o.status??1}async function installVitePlus(e,t={}){let n=t.platform??process.platform,r=t.env??process.env,i=t.prependPath,a=t.sleep??re,o=t.runInstall??runInstallCommand,s=t.logWarningFn??console.warn,c={...Object.fromEntries(Object.entries(r).filter(e=>e[1]!==void 0)),VP_VERSION:e,VITE_PLUS_VERSION:e},l=supportsVitePlusDirs(e)?createVitePlusDirsFile():void 0;l?(c.VP_VPDIRS_AWARE=`1`,c[$]=l):(delete c.VP_VPDIRS_AWARE,delete c[$]);let u=pkgPrNewCommitSha(e);u&&(c.VP_PR_VERSION=u);let{pinned:d,latest:f}=getInstallScriptUrls(e,n),p=d.length+f.length,m=2*p,h=``,_=0;async function tryUrls(e){for(let t=0;t<2;t+=1)for(let t of e){_+=1;try{let e=o(t,c,n);if(e===0)return!0;h=`exit code ${e}`}catch(e){h=e instanceof Error?e.message:String(e)}_0){if(await tryUrls(d)){ensureBinInPath();return}s(`setup-vp: could not fetch the install script pinned to Vite+ ${e}. Falling back to the latest install script. The latest script may not be compatible with ${e}.`)}if(await tryUrls(f)){ensureBinInPath();return}throw Error(`Failed to install Vite+ after ${m} attempts across ${p} URL(s): ${h}`)}finally{l&&removeVitePlusDirsFile(l)}}function parseBoolean(e){return e?.toLowerCase()===`true`}function parseAzureInputs(e){return{version:e.SETUP_VP_VERSION||``,versionFile:e.SETUP_VP_VERSION_FILE||void 0,nodeVersion:e.SETUP_VP_NODE_VERSION||void 0,nodeVersionFile:e.SETUP_VP_NODE_VERSION_FILE||void 0,workingDirectory:e.SETUP_VP_WORKING_DIRECTORY||`.`,runInstall:e.SETUP_VP_RUN_INSTALL??`true`,sfw:parseBoolean(e.SETUP_VP_SFW),nodeManager:parseNodeManager(e.SETUP_VP_NODE_MANAGER),packageManager:parsePackageManager(e.SETUP_VP_PACKAGE_MANAGER),registryUrl:e.SETUP_VP_REGISTRY_URL||``,scope:e.SETUP_VP_SCOPE||``,cache:parseBoolean(e.SETUP_VP_CACHE),cacheDependencyPath:e.SETUP_VP_CACHE_DEPENDENCY_PATH||``,workspaceRoot:e.SYSTEM_DEFAULTWORKINGDIRECTORY||process.cwd()}}function resolveProjectDirFromInputs(e){return resolveProjectDirectory({workingDirectory:e.workingDirectory,workspaceRoot:e.workspaceRoot})}const ft={installVitePlus,prepareCacheMetadata,configureAuth,setupSfw,parseRunInstall,runInstall,getCommandOutput,run,parseInstalledVpVersion,prependPath,setVariable,logWarning,logInfo};function fail(e){console.error(`setup-vp: ${e}`),process.exit(1)}async function runPrepare(e=process.env,t=ft){let n=parseAzureInputs(e),r=resolveProjectDirFromInputs(n);if(n.nodeManager===!1&&(n.nodeVersion||n.nodeVersionFile))throw Error(`node-version and node-version-file cannot be used with node-manager: false`);let i={...resolutionContext(n.workspaceRoot),info:t.logInfo,warning:t.logWarning},o=createVersionResolver(i).resolveVitePlusVersion(n,r),s=n.nodeVersion||(n.nodeVersionFile?createNodeVersionResolver(i).resolveNodeVersionFile(n.nodeVersionFile,r):void 0);t.setVariable(`SETUP_VP_CACHE_HIT`,`false`),t.setVariable(`SETUP_VP_CACHE_READY`,`false`),t.setVariable(`SETUP_VP_SFW_READY`,`false`),t.setVariable(`SETUP_VP_BOOTSTRAP_NODE`,process.execPath),await t.installVitePlus(o,{env:e,prependPath:e=>t.prependPath(e),exportPath:e=>t.setVariable(`PATH`,e),logWarningFn:t.logWarning}),t.setVariable(`SETUP_VP_RESOLVED_VERSION`,o);let c=(n.nodeManager===!1||n.packageManager!==void 0)&&t.getCommandOutput(`vp`,[`--version`])||``,l=packageManagerArgs(n.packageManager,c);n.nodeManager===!1?t.run(`vp`,nodeManagerOffArgs(c)):s&&t.run(`vp`,[`env`,`use`,s],{cwd:r});for(let e of l)t.run(`vp`,e);let u=f.resolve(process.argv[1]||``);if(t.setVariable(`SETUP_VP_RUNTIME_PATH`,u),n.sfw&&!pkgPrNewCommitSha(o)&&t.parseRunInstall(n.runInstall).length>0)try{let r=getSfwAssetName(process.platform,process.arch,isMuslLinux()),i=f.join(e.PIPELINE_WORKSPACE||n.workspaceRoot,`.setup-vp-sfw`);a(i,{recursive:!0}),t.setVariable(`SETUP_VP_SFW_CACHE_DIR`,i),t.setVariable(`SETUP_VP_SFW_CACHE_KEY`,`${Q}-${r}`),t.setVariable(`SETUP_VP_SFW_READY`,`true`)}catch(e){t.logWarning(String(e))}if(!n.cache)return;let d=t.prepareCacheMetadata({projectDir:r,cacheDependencyPath:n.cacheDependencyPath||void 0,logWarning:t.logWarning});if(!d.ready){t.setVariable(`SETUP_VP_CACHE_READY`,`false`);return}t.setVariable(`SETUP_VP_CACHE_READY`,`true`),d.cachePath&&t.setVariable(`SETUP_VP_CACHE_PATH`,d.cachePath),d.lockFile&&t.setVariable(`SETUP_VP_LOCK_FILE`,d.lockFile),d.lockType&&t.setVariable(`SETUP_VP_LOCK_TYPE`,d.lockType)}async function runFinalize(e=process.env,t=ft){let n=parseAzureInputs(e),r=resolveProjectDirFromInputs(n),i=analyzeProjectNpmrc(readNpmrc(f.join(r,`.npmrc`))).envVarRefs;i.add(`NODE_AUTH_TOKEN`);for(let t of i)!isReservedAuthVariable(t)&&/^\$\([^)]+\)$/.test(e[t]||``)&&delete e[t];t.configureAuth(n.registryUrl,n.scope,e,(e,n)=>{e!==`NODE_AUTH_TOKEN`&&n!==void 0&&t.setVariable(e,n,{isSecret:e!==`NPM_CONFIG_USERCONFIG`&&e!==`PNPM_CONFIG_USERCONFIG`})},r);let a=t.parseRunInstall(n.runInstall),o=await t.setupSfw(a,{env:e,sfwEnabled:n.sfw,vitePlusVersion:e.SETUP_VP_RESOLVED_VERSION||n.version,logWarning:t.logWarning,exportVariable:(e,n)=>{n!==void 0&&(e===`PATH`?t.prependPath(n.split(f.delimiter)[0]):t.setVariable(e,n))}});a.length>0&&await t.runInstall(a,r,o,e);let s=t.getCommandOutput(`vp`,[`--version`],{cwd:r});if(!s)throw Error(`Failed to verify Vite+ installation: vp --version failed or returned no output.`);t.logInfo(s);let c=t.parseInstalledVpVersion(s);t.setVariable(`SETUP_VP_INSTALLED_VERSION`,c),t.setVariable(`version`,c,{isOutput:!0}),t.setVariable(`cacheHit`,String(e.SETUP_VP_CACHE_HIT===`true`),{isOutput:!0})}async function main(e){if(e===`prepare`){await runPrepare();return}if(e===`finalize`){await runFinalize();return}fail(`invalid phase "${String(e)}"; expected "prepare" or "finalize"`)}function isEntrypoint(e=process.argv[1],t=import.meta.url){return!!(e&&t===y(f.resolve(e)).href)}if(isEntrypoint()){let e=process.argv[2];e||fail(`missing phase argument; expected "prepare" or "finalize"`);try{await main(e)}catch(e){fail(e instanceof Error?e.message:String(e))}}export{isEntrypoint,main,runFinalize,runPrepare}; \ No newline at end of file +`.trim()]}}const dt=2e3;function getVitePlusHome(e=process.platform,t=process.env){let n=isWindows(e)?t.USERPROFILE||S():t.HOME||S();return g(n,`.vite-plus`)}function runInstallCommand(e,t,n=process.platform){let{command:r,args:i}=getInstallScriptCommand(e,n,t.VP_VPDIRS_AWARE===`1`),a={env:{...process.env,...t},stdio:`inherit`},o=x(r,i,a);if(isWindows(n)&&o.error?.code===`ENOENT`&&(o=x(`powershell.exe`,i,a)),o.error)throw o.error;return o.status??1}async function installVitePlus(e,t={}){let n=t.platform??process.platform,r=t.env??process.env,i=t.prependPath,a=t.sleep??re,o=t.runInstall??runInstallCommand,s=t.logWarningFn??console.warn,c={...Object.fromEntries(Object.entries(r).filter(e=>e[1]!==void 0)),VP_VERSION:e,VITE_PLUS_VERSION:e},l=supportsVitePlusDirs(e)?createVitePlusDirsFile():void 0;l?(c.VP_VPDIRS_AWARE=`1`,c[$]=l):(delete c.VP_VPDIRS_AWARE,delete c[$]);let u=pkgPrNewCommitSha(e);u&&(c.VP_PR_VERSION=u);let{pinned:d,latest:f}=getInstallScriptUrls(e,n),p=d.length+f.length,m=2*p,h=``,_=0;async function tryUrls(e){for(let t=0;t<2;t+=1)for(let t of e){_+=1;try{let e=o(t,c,n);if(e===0)return!0;h=`exit code ${e}`}catch(e){h=e instanceof Error?e.message:String(e)}_0){if(await tryUrls(d)){ensureBinInPath();return}s(`setup-vp: could not fetch the install script pinned to Vite+ ${e}. Falling back to the latest install script. The latest script may not be compatible with ${e}.`)}if(await tryUrls(f)){ensureBinInPath();return}throw Error(`Failed to install Vite+ after ${m} attempts across ${p} URL(s): ${h}`)}finally{l&&removeVitePlusDirsFile(l)}}function parseBoolean(e){return e?.toLowerCase()===`true`}function parseAzureInputs(e){return{version:e.SETUP_VP_VERSION||``,versionFile:e.SETUP_VP_VERSION_FILE||void 0,nodeVersion:e.SETUP_VP_NODE_VERSION||void 0,nodeVersionFile:e.SETUP_VP_NODE_VERSION_FILE||void 0,workingDirectory:e.SETUP_VP_WORKING_DIRECTORY||`.`,runInstall:e.SETUP_VP_RUN_INSTALL??`true`,sfw:parseBoolean(e.SETUP_VP_SFW),nodeManager:parseNodeManager(e.SETUP_VP_NODE_MANAGER),packageManager:parsePackageManager(e.SETUP_VP_PACKAGE_MANAGER),registryUrl:e.SETUP_VP_REGISTRY_URL||``,scope:e.SETUP_VP_SCOPE||``,cache:parseBoolean(e.SETUP_VP_CACHE),cacheDependencyPath:e.SETUP_VP_CACHE_DEPENDENCY_PATH||``,workspaceRoot:e.SYSTEM_DEFAULTWORKINGDIRECTORY||process.cwd()}}function resolveProjectDirFromInputs(e){return resolveProjectDirectory({workingDirectory:e.workingDirectory,workspaceRoot:e.workspaceRoot})}function isCredentialName(e){return[`NODE_AUTH_TOKEN`,`SYSTEM_ACCESSTOKEN`,`YARN_NPM_AUTH_TOKEN`,`YARN_NPM_AUTH_IDENT`].includes(e)?!0:/(?:^|_)(?:TOKEN|PASSWORD|SECRET|KEY)$/.test(e)&&!isReservedAuthVariable(e)&&!/^(?:NODE_|BASH|LD_|DYLD_|OPENSSL_|SSL_|NPM_CONFIG_|PNPM_|YARN_|BUN_|COREPACK_|VP_|VITE_|SFW_|SOCKET_|XDG_|DOTNET_|COMPLUS_|COR_|POWERSHELL_|PSMODULE|INPUT_|ENDPOINT_|VSTS_|AZP_|TASK_|PIPELINE_|AZURE_)/.test(e)}function applyAuthEnv(e){let t=Object.entries(e).filter(([e])=>e.toUpperCase().startsWith(`SETUP_VP_AUTH_ENV_`)).map(([e,t])=>({key:e,name:e.slice(18),value:t})),n=/* @__PURE__ */ new Set;for(let{name:e}of t){let t=e.toUpperCase();if(!/^[A-Za-z_][A-Za-z0-9_]*$/.test(e)||!isCredentialName(t))throw Error(`authEnv variable ${JSON.stringify(e)} is not a supported credential name`);if(n.has(t))throw Error(`authEnv contains duplicate credential name ${JSON.stringify(e)}`);n.add(t)}for(let{key:n,name:r,value:i}of t)delete e[n],i===void 0||/^\$\([^)]+\)$/.test(i)?delete e[r]:e[r]=i}const ft={installVitePlus,prepareCacheMetadata,configureAuth,setupSfw,parseRunInstall,runInstall,getCommandOutput,run,parseInstalledVpVersion,prependPath,setVariable,logWarning,logInfo};function fail(e){console.error(`setup-vp: ${e}`),process.exit(1)}async function runPrepare(e=process.env,t=ft){let n=parseAzureInputs(e),r=resolveProjectDirFromInputs(n);if(n.nodeManager===!1&&(n.nodeVersion||n.nodeVersionFile))throw Error(`node-version and node-version-file cannot be used with node-manager: false`);let i={...resolutionContext(n.workspaceRoot),info:t.logInfo,warning:t.logWarning},o=createVersionResolver(i).resolveVitePlusVersion(n,r),s=n.nodeVersion||(n.nodeVersionFile?createNodeVersionResolver(i).resolveNodeVersionFile(n.nodeVersionFile,r):void 0);t.setVariable(`SETUP_VP_CACHE_HIT`,`false`),t.setVariable(`SETUP_VP_CACHE_READY`,`false`),t.setVariable(`SETUP_VP_SFW_READY`,`false`),t.setVariable(`SETUP_VP_BOOTSTRAP_NODE`,process.execPath),await t.installVitePlus(o,{env:e,prependPath:e=>t.prependPath(e),exportPath:e=>t.setVariable(`PATH`,e),logWarningFn:t.logWarning}),t.setVariable(`SETUP_VP_RESOLVED_VERSION`,o);let c=(n.nodeManager===!1||n.packageManager!==void 0)&&t.getCommandOutput(`vp`,[`--version`])||``,l=packageManagerArgs(n.packageManager,c);n.nodeManager===!1?t.run(`vp`,nodeManagerOffArgs(c)):s&&t.run(`vp`,[`env`,`use`,s],{cwd:r});for(let e of l)t.run(`vp`,e);let u=f.resolve(process.argv[1]||``);if(t.setVariable(`SETUP_VP_RUNTIME_PATH`,u),n.sfw&&!pkgPrNewCommitSha(o)&&t.parseRunInstall(n.runInstall).length>0)try{let r=getSfwAssetName(process.platform,process.arch,isMuslLinux()),i=f.join(e.PIPELINE_WORKSPACE||n.workspaceRoot,`.setup-vp-sfw`);a(i,{recursive:!0}),t.setVariable(`SETUP_VP_SFW_CACHE_DIR`,i),t.setVariable(`SETUP_VP_SFW_CACHE_KEY`,`${Q}-${r}`),t.setVariable(`SETUP_VP_SFW_READY`,`true`)}catch(e){t.logWarning(String(e))}if(!n.cache)return;let d=t.prepareCacheMetadata({projectDir:r,cacheDependencyPath:n.cacheDependencyPath||void 0,logWarning:t.logWarning});if(!d.ready){t.setVariable(`SETUP_VP_CACHE_READY`,`false`);return}t.setVariable(`SETUP_VP_CACHE_READY`,`true`),d.cachePath&&t.setVariable(`SETUP_VP_CACHE_PATH`,d.cachePath),d.lockFile&&t.setVariable(`SETUP_VP_LOCK_FILE`,d.lockFile),d.lockType&&t.setVariable(`SETUP_VP_LOCK_TYPE`,d.lockType)}async function runFinalize(e=process.env,t=ft){applyAuthEnv(e);let n=parseAzureInputs(e),r=resolveProjectDirFromInputs(n),i=analyzeProjectNpmrc(readNpmrc(f.join(r,`.npmrc`))).envVarRefs;i.add(`NODE_AUTH_TOKEN`);for(let t of i)!isReservedAuthVariable(t)&&/^\$\([^)]+\)$/.test(e[t]||``)&&delete e[t];t.configureAuth(n.registryUrl,n.scope,e,(e,n)=>{e!==`NODE_AUTH_TOKEN`&&n!==void 0&&t.setVariable(e,n,{isSecret:e!==`NPM_CONFIG_USERCONFIG`&&e!==`PNPM_CONFIG_USERCONFIG`})},r);let a=t.parseRunInstall(n.runInstall),o=await t.setupSfw(a,{env:e,sfwEnabled:n.sfw,vitePlusVersion:e.SETUP_VP_RESOLVED_VERSION||n.version,logWarning:t.logWarning,exportVariable:(e,n)=>{n!==void 0&&(e===`PATH`?t.prependPath(n.split(f.delimiter)[0]):t.setVariable(e,n))}});a.length>0&&await t.runInstall(a,r,o,e);let s=t.getCommandOutput(`vp`,[`--version`],{cwd:r});if(!s)throw Error(`Failed to verify Vite+ installation: vp --version failed or returned no output.`);t.logInfo(s);let c=t.parseInstalledVpVersion(s);t.setVariable(`SETUP_VP_INSTALLED_VERSION`,c),t.setVariable(`version`,c,{isOutput:!0}),t.setVariable(`cacheHit`,String(e.SETUP_VP_CACHE_HIT===`true`),{isOutput:!0})}async function main(e){if(e===`prepare`){await runPrepare();return}if(e===`finalize`){await runFinalize();return}fail(`invalid phase "${String(e)}"; expected "prepare" or "finalize"`)}function isEntrypoint(e=process.argv[1],t=import.meta.url){return!!(e&&t===y(f.resolve(e)).href)}if(isEntrypoint()){let e=process.argv[2];e||fail(`missing phase argument; expected "prepare" or "finalize"`);try{await main(e)}catch(e){fail(e instanceof Error?e.message:String(e))}}export{isEntrypoint,main,runFinalize,runPrepare}; \ No newline at end of file diff --git a/src/azure/auth-env.test.ts b/src/azure/auth-env.test.ts new file mode 100644 index 0000000..8019c8b --- /dev/null +++ b/src/azure/auth-env.test.ts @@ -0,0 +1,141 @@ +import { describe, expect, it } from "vite-plus/test"; +import { applyAuthEnv } from "./auth-env.js"; + +describe("Azure authEnv", () => { + it("preserves credential values and removes their transport variables", () => { + const secret = "quotes \" ' \\ and\nnewlines $(literal) `literal`"; + const env: NodeJS.ProcessEnv = { + SETUP_VP_AUTH_ENV_CUSTOM_TOKEN: secret, + SETUP_VP_AUTH_ENV_NODE_AUTH_TOKEN: "registry-token", + SETUP_VP_AUTH_ENV_NPM_TOKEN: "npm-token", + SETUP_VP_AUTH_ENV_YARN_NPM_AUTH_TOKEN: "yarn-token", + SETUP_VP_AUTH_ENV_REGISTRY_PASSWORD: "password", + SETUP_VP_AUTH_ENV_REGISTRY_SECRET: "secret", + SETUP_VP_AUTH_ENV_REGISTRY_KEY: "key", + SETUP_VP_AUTH_ENV_GITHUB_TOKEN: "github-token", + SETUP_VP_AUTH_ENV_CI_JOB_TOKEN: "gitlab-token", + SETUP_VP_AUTH_ENV_SYSTEM_ACCESSTOKEN: "azure-token", + CUSTOM_TOKEN: "previous-token", + NODE_OPTIONS: "--max-old-space-size=4096", + UNRELATED: "$(leave-me)", + }; + + applyAuthEnv(env); + + expect(env).toEqual({ + CUSTOM_TOKEN: secret, + NODE_AUTH_TOKEN: "registry-token", + NPM_TOKEN: "npm-token", + YARN_NPM_AUTH_TOKEN: "yarn-token", + REGISTRY_PASSWORD: "password", + REGISTRY_SECRET: "secret", + REGISTRY_KEY: "key", + GITHUB_TOKEN: "github-token", + CI_JOB_TOKEN: "gitlab-token", + SYSTEM_ACCESSTOKEN: "azure-token", + NODE_OPTIONS: "--max-old-space-size=4096", + UNRELATED: "$(leave-me)", + }); + }); + + it.each(["$(MISSING)", "$(OTHER_SECRET)", undefined])( + "treats an unresolved mapping as missing (%s)", + (value) => { + const env = { SETUP_VP_AUTH_ENV_CUSTOM_TOKEN: value, CUSTOM_TOKEN: "previous-token" }; + applyAuthEnv(env); + expect(env).toEqual({}); + }, + ); + + it("preserves empty values and the case of custom credential names", () => { + const env = { setup_vp_auth_env_Custom_Token: "", SETUP_VP_AUTH_ENV__TOKEN: "private" }; + applyAuthEnv(env); + expect(env).toEqual({ Custom_Token: "", _TOKEN: "private" }); + }); + + it.each([ + "NODE_OPTIONS", + "node_options", + "Node_Options", + "NODE_PATH", + "NODE_EXTRA_CA_CERTS", + "NODE_TLS_REJECT_UNAUTHORIZED", + "BASH_ENV", + "BASHOPTS", + "ENV", + "SHELLOPTS", + "SHELL", + "ZDOTDIR", + "LD_PRELOAD", + "LD_LIBRARY_PATH", + "DYLD_INSERT_LIBRARIES", + "OPENSSL_CONF", + "SSL_CERT_FILE", + "PATH", + "Path", + "PATHEXT", + "COMSPEC", + "PSModulePath", + "DOTNET_STARTUP_HOOKS", + "HOME", + "USERPROFILE", + "TMPDIR", + "TEMP", + "TMP", + "SYSTEMROOT", + "NPM_CONFIG_USERCONFIG", + "npm_config_script_shell", + "NPM_EXECPATH", + "PNPM_HOME", + "YARN_RC_FILENAME", + "BUN_OPTIONS", + "COREPACK_HOME", + "VP_HOME", + "SFW_BIN", + "XDG_CONFIG_HOME", + "HTTPS_PROXY", + "SETUP_VP_RUN_INSTALL", + "SETUP_VP_AUTH_ENV_NODE_OPTIONS", + "AGENT_TEMPDIRECTORY", + "SYSTEM_DEFAULTWORKINGDIRECTORY", + "BUILD_SOURCESDIRECTORY", + "PIPELINE_WORKSPACE", + "CI", + "CI_PROJECT_DIR", + "GITHUB_ENV", + "RUNNER_TEMP", + "INPUT_SCRIPT", + "VSTS_TASKVARIABLE_SECRET", + "SETUP_VP_TOKEN", + "NODE_SECRET", + "LD_SECRET", + "AGENT_KEY", + "PYTHONPATH", + "PERL5OPT", + "RUBYOPT", + "GIT_SSH_COMMAND", + "ARBITRARY_SETTING", + "", + "1TOKEN", + "TOKEN-NAME", + "TOKEN=NAME", + "TOKEN\nNAME", + "ſECRET", + ])("rejects %j before applying any mappings", (name) => { + const env = { + SETUP_VP_AUTH_ENV_CUSTOM_TOKEN: "secret", + [`SETUP_VP_AUTH_ENV_${name}`]: "sensitive-value", + }; + const original = { ...env }; + + expect(() => applyAuthEnv(env)).toThrow("is not a supported credential name"); + expect(() => applyAuthEnv(env)).not.toThrow("sensitive-value"); + expect(env).toEqual(original); + }); + + it("rejects case-insensitive duplicate names on every platform", () => { + const env = { SETUP_VP_AUTH_ENV_TOKEN: "one", SETUP_VP_AUTH_ENV_Token: "two" }; + expect(() => applyAuthEnv(env)).toThrow("duplicate credential name"); + expect(env).toEqual({ SETUP_VP_AUTH_ENV_TOKEN: "one", SETUP_VP_AUTH_ENV_Token: "two" }); + }); +}); diff --git a/src/azure/auth-env.ts b/src/azure/auth-env.ts new file mode 100644 index 0000000..25426f7 --- /dev/null +++ b/src/azure/auth-env.ts @@ -0,0 +1,55 @@ +import { isReservedAuthVariable } from "../ci/auth.js"; +import type { RuntimeEnv } from "../ci/types.js"; + +const AUTH_ENV_PREFIX = "SETUP_VP_AUTH_ENV_"; + +function isCredentialName(name: string): boolean { + // These credential names belong to otherwise reserved namespaces. + if ( + [ + "NODE_AUTH_TOKEN", + "SYSTEM_ACCESSTOKEN", + "YARN_NPM_AUTH_TOKEN", + "YARN_NPM_AUTH_IDENT", + ].includes(name) + ) + return true; + // Accept credential names, rather than trying to enumerate every environment + // variable that can control a shell, interpreter, or package manager. + return ( + /(?:^|_)(?:TOKEN|PASSWORD|SECRET|KEY)$/.test(name) && + !isReservedAuthVariable(name) && + !/^(?:NODE_|BASH|LD_|DYLD_|OPENSSL_|SSL_|NPM_CONFIG_|PNPM_|YARN_|BUN_|COREPACK_|VP_|VITE_|SFW_|SOCKET_|XDG_|DOTNET_|COMPLUS_|COR_|POWERSHELL_|PSMODULE|INPUT_|ENDPOINT_|VSTS_|AZP_|TASK_|PIPELINE_|AZURE_)/.test( + name, + ) + ); +} + +/** Decode auth mappings only after the task's shell and Node.js have started. */ +export function applyAuthEnv(env: RuntimeEnv): void { + const entries = Object.entries(env) + .filter(([key]) => key.toUpperCase().startsWith(AUTH_ENV_PREFIX)) + .map(([key, value]) => ({ key, name: key.slice(AUTH_ENV_PREFIX.length), value })); + const names = new Set(); + + // Validate every name before exposing any values to subprocesses or later steps. + for (const { name } of entries) { + const normalized = name.toUpperCase(); + if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name) || !isCredentialName(normalized)) { + throw new Error( + `authEnv variable ${JSON.stringify(name)} is not a supported credential name`, + ); + } + if (names.has(normalized)) { + throw new Error(`authEnv contains duplicate credential name ${JSON.stringify(name)}`); + } + names.add(normalized); + } + + for (const { key, name, value } of entries) { + delete env[key]; + // Azure leaves missing secret macros unchanged. Do not pass them as tokens. + if (value === undefined || /^\$\([^)]+\)$/.test(value)) delete env[name]; + else env[name] = value; + } +} diff --git a/src/azure/index.ts b/src/azure/index.ts index 9f55edf..7fdf586 100644 --- a/src/azure/index.ts +++ b/src/azure/index.ts @@ -17,6 +17,7 @@ import { parseInstalledVpVersion } from "../ci/version.js"; import { logInfo, logWarning, prependPath, setVariable } from "./commands.js"; import { installVitePlus } from "./install-viteplus.js"; import { parseAzureInputs, resolveProjectDirFromInputs } from "./inputs.js"; +import { applyAuthEnv } from "./auth-env.js"; export type AzurePhase = "prepare" | "finalize"; @@ -163,6 +164,7 @@ export async function runFinalize( env: NodeJS.ProcessEnv = process.env, ports: AzurePorts = defaultPorts, ): Promise { + applyAuthEnv(env); const inputs = parseAzureInputs(env); const projectDir = resolveProjectDirFromInputs(inputs); // Azure leaves undefined macros unexpanded, including aliases supplied via diff --git a/src/azure/parity.test.ts b/src/azure/parity.test.ts index 1631f9d..8e47531 100644 --- a/src/azure/parity.test.ts +++ b/src/azure/parity.test.ts @@ -1,4 +1,5 @@ import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs"; +import { spawnSync } from "node:child_process"; import { tmpdir } from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vite-plus/test"; @@ -38,6 +39,42 @@ function fixture() { } describe("Azure parity", () => { + it("passes custom auth mappings to install subprocesses and exports referenced secrets", async () => { + const { project, env, ports } = fixture(); + const secret = 'token with "quotes", \\ and\nnewlines'; + writeFileSync(path.join(project, ".npmrc"), "//registry.example/:_authToken=${CUSTOM_TOKEN}\n"); + const target: NodeJS.ProcessEnv = { ...env, SETUP_VP_AUTH_ENV_CUSTOM_TOKEN: secret }; + ports.runInstall.mockImplementation((_entries, _project, _command, installEnv) => { + const result = spawnSync( + process.execPath, + [ + "-e", + "process.stdout.write(JSON.stringify({token:process.env.CUSTOM_TOKEN,transport:process.env.SETUP_VP_AUTH_ENV_CUSTOM_TOKEN}))", + ], + { env: installEnv, encoding: "utf8" }, + ); + expect(result.status, result.stderr).toBe(0); + expect(JSON.parse(result.stdout)).toEqual({ token: secret }); + }); + + await runFinalize(target, ports); + + expect(ports.runInstall).toHaveBeenCalledOnce(); + expect(ports.setVariable).toHaveBeenCalledWith("CUSTOM_TOKEN", secret, { isSecret: true }); + }); + + it("rejects unsafe auth mappings before auth, install, or version commands", async () => { + const { env, ports } = fixture(); + await expect( + runFinalize({ ...env, SETUP_VP_AUTH_ENV_NODE_OPTIONS: "--require=./payload.cjs" }, ports), + ).rejects.toThrow("is not a supported credential name"); + expect(ports.configureAuth).not.toHaveBeenCalled(); + expect(ports.setupSfw).not.toHaveBeenCalled(); + expect(ports.runInstall).not.toHaveBeenCalled(); + expect(ports.getCommandOutput).not.toHaveBeenCalled(); + expect(ports.setVariable).not.toHaveBeenCalled(); + }); + it("exports the full installer PATH for subsequent Azure tasks", async () => { const { root, env, ports } = fixture(); const bin = path.join(root, "bin"); @@ -221,7 +258,11 @@ describe("Azure parity", () => { path.join(project, ".npmrc"), "//registry.example/:_authToken=${CUSTOM_TOKEN}\n", ); - const target = { ...env, CUSTOM_TOKEN: value, UNRELATED_VALUE: "$(keep-me)" }; + const target: NodeJS.ProcessEnv = { + ...env, + SETUP_VP_AUTH_ENV_CUSTOM_TOKEN: value, + UNRELATED_VALUE: "$(keep-me)", + }; await runFinalize(target, ports); expect(target.CUSTOM_TOKEN).toBeUndefined(); expect(target.UNRELATED_VALUE).toBe("$(keep-me)"); diff --git a/src/azure/template.test.ts b/src/azure/template.test.ts index 1cde2db..7b27285 100644 --- a/src/azure/template.test.ts +++ b/src/azure/template.test.ts @@ -1,5 +1,14 @@ import { spawnSync } from "node:child_process"; -import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { + copyFileSync, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + realpathSync, + rmSync, + writeFileSync, +} from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { fileURLToPath } from "node:url"; @@ -17,6 +26,9 @@ type Shell = "bash" | "powershell"; const prepareSteps = docs.steps.filter((step: { displayName?: string }) => step.displayName?.startsWith("setup-vp prepare"), ) as Array<{ bash?: string; powershell?: string; env: Record }>; +const finalizeSteps = docs.steps.filter((step: { displayName?: string }) => + step.displayName?.startsWith("setup-vp finalize"), +) as Array<{ bash?: string; powershell?: string; env: Record }>; function prepareStep(shell: Shell) { return prepareSteps.find((step) => step[shell])!; @@ -80,6 +92,79 @@ function Invoke-WebRequest { } } +// Expand only the authEnv insertion from the real template. Support the old +// direct insertion too, so the execution test detects a return to that behavior. +function renderAuthEnv(shell: Shell, authEnv: Record): Record { + const step = finalizeSteps.find((step) => step[shell])!; + if (step.env["${{ insert }}"] === "${{ parameters.authEnv }}") return authEnv; + const mapping = step.env["${{ each pair in parameters.authEnv }}"] as Record; + const entries = Object.entries(mapping); + expect(entries).toHaveLength(1); + const [key, value] = entries[0]!; + expect(value).toBe("${{ pair.value }}"); + const format = key.match(/^\$\{\{ format\('([^']*)', pair\.key\) \}\}$/)?.[1]; + expect(format).toBeDefined(); + return Object.fromEntries( + Object.entries(authEnv).map(([name, token]) => [format!.replaceAll("{0}", name), token]), + ); +} + +function runFinalizeWithAuthEnv(shell: Shell, name: "NODE_OPTIONS" | "BASH_ENV") { + const directory = realpathSync(mkdtempSync(join(tmpdir(), "setup-vp-azure-auth-env-"))); + const marker = join(directory, "injected"); + const hook = join(directory, "hook.sh"); + try { + const runtimeDir = join(directory, "setup-vp-azure", "dist", "azure"); + mkdirSync(runtimeDir, { recursive: true }); + copyFileSync( + new URL("../../dist/azure/index.mjs", import.meta.url), + join(runtimeDir, "index.mjs"), + ); + writeFileSync(hook, 'printf injected > "$SETUP_VP_TEST_MARKER"\n'); + const preload = + "import{writeFileSync}from'node:fs';writeFileSync(process.env.SETUP_VP_TEST_MARKER,'injected');"; + const authEnv = { + [name]: + name === "BASH_ENV" + ? hook + : `--import=data:text/javascript;base64,${Buffer.from(preload).toString("base64")}`, + }; + const step = finalizeSteps.find((step) => step[shell])!; + const script = step[shell]!.replaceAll( + "$(SETUP_VP_BOOTSTRAP_NODE)", + () => process.execPath, + ).replaceAll("$(Agent.TempDirectory)", () => directory); + const scriptPath = join(directory, shell === "bash" ? "finalize.sh" : "finalize.ps1"); + // PowerShell@2's wrapper propagates the last native command's exit code. + writeFileSync(scriptPath, script + (shell === "powershell" ? "\nexit $LASTEXITCODE\n" : "")); + const result = spawnSync( + shell === "bash" ? "bash" : "powershell.exe", + shell === "bash" + ? [scriptPath] + : ["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-File", scriptPath], + { + encoding: "utf8", + timeout: 10_000, + cwd: directory, + env: { + ...process.env, + SETUP_VP_RUN_INSTALL: "false", + SETUP_VP_TEST_MARKER: marker, + ...renderAuthEnv(shell, authEnv), + }, + }, + ); + expect(result.error).toBeUndefined(); + expect(existsSync(marker)).toBe(false); + expect(result.status).not.toBe(0); + expect(result.stderr).toContain( + `authEnv variable "${name}" is not a supported credential name`, + ); + } finally { + rmSync(directory, { recursive: true, force: true }); + } +} + describe("azure/setup-vp.yml", () => { it("declares the documented parameters with defaults", () => { const parameters = docs.parameters as Array<{ @@ -137,9 +222,20 @@ describe("azure/setup-vp.yml", () => { expect(template).toContain("name: ${{ parameters.stepName }}Windows"); expect(template).toContain("name: ${{ parameters.stepName }}Unix"); expect(template.match(/\$\(SETUP_VP_BOOTSTRAP_NODE\)/g)).toHaveLength(2); - expect(template.split("${{ insert }}: ${{ parameters.authEnv }}")).toHaveLength(3); + expect(template).not.toContain("${{ insert }}: ${{ parameters.authEnv }}"); }); + it.each(["bash", "powershell"] as const)( + "keeps authEnv names outside the %s launcher environment", + (shell) => { + const authEnv = { NODE_OPTIONS: "--require=./hook.cjs", CUSTOM_TOKEN: "$(CUSTOM_TOKEN)" }; + expect(renderAuthEnv(shell, authEnv)).toEqual({ + SETUP_VP_AUTH_ENV_NODE_OPTIONS: "--require=./hook.cjs", + SETUP_VP_AUTH_ENV_CUSTOM_TOKEN: "$(CUSTOM_TOKEN)", + }); + }, + ); + it("selects the agent shell at runtime", () => { expect(template).not.toContain("${{ if eq(variables['Agent.OS'], 'Windows_NT') }}"); expect(template).not.toContain("${{ if ne(variables['Agent.OS'], 'Windows_NT') }}"); @@ -161,6 +257,15 @@ describe("azure/setup-vp.yml", () => { ); for (const shell of ["bash", "powershell"] as const) { + describe.skipIf((shell === "powershell") !== (process.platform === "win32"))( + `${shell} finalize`, + () => { + it.each(["NODE_OPTIONS", "BASH_ENV"] as const)( + "rejects %s without executing startup code", + (name) => runFinalizeWithAuthEnv(shell, name), + ); + }, + ); describe.skipIf((shell === "powershell") !== (process.platform === "win32"))( `${shell} prepare`, () => { From e80f165332a1baec9fdebff6ff51b0573174cf90 Mon Sep 17 00:00:00 2001 From: MK Date: Fri, 9 Oct 2026 14:08:55 +0800 Subject: [PATCH 2/3] refactor: simplify Azure auth validation and tests --- dist/azure/index.mjs | 6 +- src/azure/auth-env.test.ts | 5 +- src/azure/auth-env.ts | 60 +++++++++++++------ src/azure/index.ts | 2 +- src/azure/parity.test.ts | 20 ++++--- src/azure/template.test.ts | 117 ++++++++++++++++++++----------------- 6 files changed, 126 insertions(+), 84 deletions(-) diff --git a/dist/azure/index.mjs b/dist/azure/index.mjs index e1563cc..4639353 100644 --- a/dist/azure/index.mjs +++ b/dist/azure/index.mjs @@ -142,8 +142,8 @@ n?this.stack.push(n):(yield*this.pop(),yield*this.step())}else{let t=this.peek(2 `)+` `,n,r);return n.NODE_AUTH_TOKEN=n.NODE_AUTH_TOKEN||`XXXXX-XXXXX-XXXXX-XXXXX`,r?.(`NODE_AUTH_TOKEN`,n.NODE_AUTH_TOKEN),u}function resolveSfwEnabled(e,t,n){return e?!pkgPrNewCommitSha(t)||(n(`sfw was requested but is automatically disabled for Vite+ preview build ${t}; Socket Firewall Free will not be used.`),!1):!1}const Q=`v1.15.4`,lt=`https://github.com/SocketDev/sfw-free/releases/download/${Q}`;function isMuslLinux(){if(process.platform!==`linux`)return!1;try{let e=process.report?.getReport();if(e?.header&&!e.header.glibcVersionRuntime)return!0}catch{}return i(`/etc/alpine-release`)}function getSfwAssetName(e,t,n){if(e===`darwin`){if(t===`arm64`)return`sfw-free-macos-arm64`;if(t===`x64`)return`sfw-free-macos-x86_64`}else if(e===`linux`){if(t===`arm64`)return n?`sfw-free-musl-linux-arm64`:`sfw-free-linux-arm64`;if(t===`x64`)return n?`sfw-free-musl-linux-x86_64`:`sfw-free-linux-x86_64`}else if(isWindows(e)){if(t===`arm64`)return`sfw-free-windows-arm64.exe`;if(t===`x64`)return`sfw-free-windows-x86_64.exe`}throw Error(`Unsupported platform/arch for sfw: ${e}/${t}${e===`linux`?` (${n?`musl`:`glibc`})`:``}`)}function downloadFileWithShell(e,t,n){let r=Math.max(1,Math.ceil(n/1e3)),i=commandPath(`curl`);if(i){let n=x(i,[`-fsSL`,`--connect-timeout`,`5`,`--max-time`,String(r),e,`-o`,t],{stdio:`ignore`});if(n.status===0)return;throw n.error||/* @__PURE__ */ Error(`curl failed while downloading ${e}`)}let a=commandPath(`wget`);if(a){let n=x(a,[`-q`,`-T`,String(r),`-t`,`2`,`-O`,t,e],{stdio:`ignore`});if(n.status===0)return;throw n.error||/* @__PURE__ */ Error(`wget failed while downloading ${e}`)}throw Error(`curl or wget is required to download sfw`)}function downloadFile(e,t,n=0,i=6e4,a){return n>5?Promise.reject(/* @__PURE__ */ Error(`too many redirects while downloading ${e}`)):a?new Promise((o,s)=>{let c=!1,finish=e=>{c||(c=!0,clearTimeout(u),e?s(e):o())},l=a(e,o=>{let s=o.statusCode??0,c=o.headers.location;if(s>=300&&s<400&&c){o.resume(),downloadFile(new URL(c,e).toString(),t,n+1,i,a).then(()=>finish(),finish);return}if(s!==200){o.resume(),finish(/* @__PURE__ */ Error(`download failed with HTTP ${s}: ${e}`));return}let l=r(t);o.pipe(l),l.on(`finish`,()=>l.close(()=>finish())),l.on(`error`,finish)}),u=setTimeout(()=>{l.destroy(/* @__PURE__ */ Error(`download timed out after ${i}ms: ${e}`))},i);l.on(`error`,finish)}):(downloadFileWithShell(e,t,i),Promise.resolve())}async function setupSfw(e,t={}){let n=t.env??process.env,r=t.sfwEnabled??n.SETUP_VP_SFW===`true`,i=t.platform??process.platform,a=t.arch??process.arch,o=t.isMusl??isMuslLinux(),s=t.download??downloadFile;if(!resolveSfwEnabled(r,t.vitePlusVersion??``,t.logWarning??console.warn))return{executable:`vp`,sfw:!1};if(e.length===0)return console.log(`setup-vp: sfw was requested but run-install is disabled; sfw will not be invoked.`),{executable:`vp`,sfw:!1};let c=commandPath(`sfw`,n);if(c)return console.log(`setup-vp: using existing sfw on PATH: ${c}`),{executable:c,sfw:!0};let l;try{l=getSfwAssetName(i,a,o)}catch{return console.error(`setup-vp: sfw has no published binary for this runner's platform/architecture (process.platform=${i}, process.arch=${a}, musl=${o}) and none was found on PATH; falling back to plain vp install.`),{executable:`vp`,sfw:!1}}let u=t.cacheDirectory||n.SETUP_VP_SFW_CACHE_DIR,d=u?f.resolve(u,Q,l):await E(f.resolve(C(),`setup-vp-sfw-`));await T(d,{recursive:!0});let p=f.join(d,isWindows(i)?`sfw.exe`:`sfw`),m=`${lt}/${l}`;function activate(){let e=isWindows(i)?`;`:`:`;return n.PATH=`${d}${e}${n.PATH||``}`,t.exportVariable?.(`PATH`,n.PATH),{executable:p,sfw:!0}}if(await ne(p).then(e=>e.isFile()&&e.size>0,()=>!1))return console.log(`setup-vp: using cached sfw ${Q}: ${p}`),activate();for(let e=1;e<=2;e+=1){let t=await E(f.join(d,`.download-`)),n=f.join(t,`sfw`);try{if(console.log(`setup-vp: installing sfw ${Q} from ${m}`),await s(m,n),(await ne(n)).size===0)throw Error(`Downloaded sfw binary is empty`);return await w(n,493),await ee(n,p),activate()}catch(t){if(e===2)throw t;await new Promise(e=>setTimeout(e,2e3))}finally{await te(t,{recursive:!0,force:!0})}}throw Error(`failed to install sfw after retrying`)}function isRecord(e){return typeof e==`object`&&!!e&&!Array.isArray(e)}function validateRunInstallEntry(e){if(!isRecord(e))throw Error(`run-install entries must be objects`);for(let t of Object.keys(e))if(t!==`cwd`&&t!==`args`)throw Error(`unsupported run-install key: ${t}`);let t={};if(e.cwd!==void 0){if(typeof e.cwd!=`string`)throw Error(`run-install.cwd must be a string`);t.cwd=e.cwd}if(e.args!==void 0){if(!Array.isArray(e.args)||e.args.some(e=>typeof e!=`string`))throw Error(`run-install.args must be an array of strings`);t.args=e.args}return t}function validateRunInstallInput(e){return e===null||typeof e==`boolean`?e:Array.isArray(e)?e.map(validateRunInstallEntry):validateRunInstallEntry(e)}function parseRunInstall(e){let t=validateRunInstallInput((0,J.parse)(e)??null);return t?t===!0?[{}]:Array.isArray(t)?t:[t]:[]}function isSfwVpNotFoundFlake(e,t){return/Command 'vp' not found in PATH/.test(e+` `+t)}async function runInstall(e,t,n,r=process.env,i={}){let a={...r};delete a.SETUP_VP_ENV_FILE;let o=i.execute??runWithOutput,{executable:s,sfw:c}=n,l=[];for(let n of e){let e=n.cwd?f.resolve(t,n.cwd):t,r=[`install`,...n.args||[]],u=c?[`vp`,...r]:r,d=`${s} ${u.join(` `)} (cwd: ${e})`;console.log(`setup-vp: running ${d}`);try{let t=await o(s,u,{cwd:e,env:a});if(t.exitCode!==0&&c&&isSfwVpNotFoundFlake(t.stdout,t.stderr)){let n=isWindows(i.platform);if(console.warn(n?`setup-vp: sfw could not resolve vp; warming the PowerShell command cache and retrying once.`:`setup-vp: sfw could not resolve vp; retrying once.`),n)try{await o(`powershell.exe`,[`-NoProfile`,`-Command`,`Get-Command vp`],{cwd:e,env:a})}catch{}t=await o(s,u,{cwd:e,env:a})}t.exitCode!==0&&l.push(`${d} exited with code ${t.exitCode}\n${t.stderr.trim()||t.stdout.trim()}`)}catch(e){l.push(`${d}: ${String(e)}`)}}if(l.length)throw Error(l.join(` -`))}function escapeLoggingCommandData(e){return e.replaceAll(`%`,`%AZP25`).replaceAll(`\r`,`%0D`).replaceAll(` -`,`%0A`).replaceAll(`;`,`%3B`).replaceAll(`]`,`%5D`)}function prependPath(e){process.stdout.write(`##vso[task.prependpath]${escapeLoggingCommandData(e)}\n`)}function setVariable(e,t,n={}){let r=[`variable=${e}`];n.isOutput&&r.push(`isOutput=true`),n.isReadonly&&r.push(`isReadonly=true`),n.isSecret&&r.push(`issecret=true`),process.stdout.write(`##vso[task.setvariable ${r.join(`;`)}]${escapeLoggingCommandData(t)}\n`)}function logWarning(e){process.stdout.write(`##vso[task.logissue type=warning]${escapeLoggingCommandData(e)}\n`)}function logInfo(e){console.log(e)}const $=`SETUP_VP_DIRS_FILE`;function getVitePlusBinDirs(e){let t=g(e.data,`fallback-bin`);return u(t,{throwIfNoEntry:!1})?.isDirectory()?{bin:e.bin,fallbackBin:t}:{bin:e.bin}}function appendFallbackBinToPath(e,t,n=process.platform){let r=isWindows(n)?`;`:`:`;return[...e?e.split(r).filter(e=>e!==t):[],t].join(r)}const ut=/^v?(\d+)\.(\d+)\.\d+(?:-[0-9A-Za-z.-]+)?$/;function supportsVitePlusDirs(e){if(pkgPrNewCommitSha(e))return!0;let t=e.match(ut);if(!t)return!0;let n=Number(t[1]),r=Number(t[2]);return n>0||r>=3}function createVitePlusDirsFile(){return g(C(),`setup-vp-dirs-${ie()}.txt`)}function removeVitePlusDirsFile(e){l(e,{force:!0})}function readVitePlusProbe(e){try{return s(e,`utf8`)}catch(e){if(e.code===`ENOENT`)return;throw e}}function resolveVitePlusBinDirs(e,t,n){if(!t){if(!supportsVitePlusDirs(e))return{bin:n};throw Error(`Vite+ was installed successfully, but setup-vp could not resolve its VpDirs.`)}let r=readVitePlusProbe(t),i=r===void 0?void 0:parseVitePlusDirs(r);if(i)return getVitePlusBinDirs(i);if(!(pkgPrNewCommitSha(e)!==void 0||ut.test(e))&&r!==void 0){let e=parseInstalledVpVersion(r);if(e!==`unknown`&&!supportsVitePlusDirs(e))return{bin:n}}throw Error(`Vite+ was installed successfully, but setup-vp could not resolve its VpDirs.`)}function parseVitePlusDirs(e){let t=/* @__PURE__ */ new Map;for(let n of e.split(/\r?\n/)){let e=n.indexOf(` `);if(e<1)continue;let r=n.slice(0,e).replace(/^\uFEFF/,``),i=n.slice(e+1).trim();i&&t.set(r,i)}let n=t.get(`data`),r=t.get(`bin`),i=t.get(`cache`),a=t.get(`config`),o=t.get(`state`);if(n&&r&&i&&a&&o)return{data:n,bin:r,cache:i,config:a,state:o}}function getInstallScriptCommand(e,t=process.platform,n=!0){if(isWindows(t))return n?{command:`pwsh`,args:[`-Command`,` +`))}const ut=/* @__PURE__ */ new Set([`NODE_AUTH_TOKEN`,`SYSTEM_ACCESSTOKEN`,`YARN_NPM_AUTH_TOKEN`,`YARN_NPM_AUTH_IDENT`]),dt=/* @__PURE__ */ `NODE_.BASH.LD_.DYLD_.OPENSSL_.SSL_.NPM_CONFIG_.PNPM_.YARN_.BUN_.COREPACK_.VP_.VITE_.SFW_.SOCKET_.XDG_.DOTNET_.COMPLUS_.COR_.POWERSHELL_.PSMODULE.INPUT_.ENDPOINT_.VSTS_.AZP_.TASK_.PIPELINE_.AZURE_`.split(`.`);function isCredentialName(e){return ut.has(e)?!0:/(?:^|_)(?:TOKEN|PASSWORD|SECRET|KEY)$/.test(e)&&!isReservedAuthVariable(e)&&!dt.some(t=>e.startsWith(t))}function applyAuthEnv(e){let t=Object.entries(e).filter(([e])=>e.toUpperCase().startsWith(`SETUP_VP_AUTH_ENV_`)).map(([e,t])=>({key:e,name:e.slice(18),value:t})),n=/* @__PURE__ */ new Set;for(let{name:e}of t){let t=e.toUpperCase();if(!/^[A-Za-z_][A-Za-z0-9_]*$/.test(e)||!isCredentialName(t))throw Error(`authEnv variable ${JSON.stringify(e)} is not a supported credential name`);if(n.has(t))throw Error(`authEnv contains duplicate credential name ${JSON.stringify(e)}`);n.add(t)}for(let{key:n,name:r,value:i}of t)delete e[n],i===void 0||/^\$\([^)]+\)$/.test(i)?delete e[r]:e[r]=i}function escapeLoggingCommandData(e){return e.replaceAll(`%`,`%AZP25`).replaceAll(`\r`,`%0D`).replaceAll(` +`,`%0A`).replaceAll(`;`,`%3B`).replaceAll(`]`,`%5D`)}function prependPath(e){process.stdout.write(`##vso[task.prependpath]${escapeLoggingCommandData(e)}\n`)}function setVariable(e,t,n={}){let r=[`variable=${e}`];n.isOutput&&r.push(`isOutput=true`),n.isReadonly&&r.push(`isReadonly=true`),n.isSecret&&r.push(`issecret=true`),process.stdout.write(`##vso[task.setvariable ${r.join(`;`)}]${escapeLoggingCommandData(t)}\n`)}function logWarning(e){process.stdout.write(`##vso[task.logissue type=warning]${escapeLoggingCommandData(e)}\n`)}function logInfo(e){console.log(e)}const $=`SETUP_VP_DIRS_FILE`;function getVitePlusBinDirs(e){let t=g(e.data,`fallback-bin`);return u(t,{throwIfNoEntry:!1})?.isDirectory()?{bin:e.bin,fallbackBin:t}:{bin:e.bin}}function appendFallbackBinToPath(e,t,n=process.platform){let r=isWindows(n)?`;`:`:`;return[...e?e.split(r).filter(e=>e!==t):[],t].join(r)}const ft=/^v?(\d+)\.(\d+)\.\d+(?:-[0-9A-Za-z.-]+)?$/;function supportsVitePlusDirs(e){if(pkgPrNewCommitSha(e))return!0;let t=e.match(ft);if(!t)return!0;let n=Number(t[1]),r=Number(t[2]);return n>0||r>=3}function createVitePlusDirsFile(){return g(C(),`setup-vp-dirs-${ie()}.txt`)}function removeVitePlusDirsFile(e){l(e,{force:!0})}function readVitePlusProbe(e){try{return s(e,`utf8`)}catch(e){if(e.code===`ENOENT`)return;throw e}}function resolveVitePlusBinDirs(e,t,n){if(!t){if(!supportsVitePlusDirs(e))return{bin:n};throw Error(`Vite+ was installed successfully, but setup-vp could not resolve its VpDirs.`)}let r=readVitePlusProbe(t),i=r===void 0?void 0:parseVitePlusDirs(r);if(i)return getVitePlusBinDirs(i);if(!(pkgPrNewCommitSha(e)!==void 0||ft.test(e))&&r!==void 0){let e=parseInstalledVpVersion(r);if(e!==`unknown`&&!supportsVitePlusDirs(e))return{bin:n}}throw Error(`Vite+ was installed successfully, but setup-vp could not resolve its VpDirs.`)}function parseVitePlusDirs(e){let t=/* @__PURE__ */ new Map;for(let n of e.split(/\r?\n/)){let e=n.indexOf(` `);if(e<1)continue;let r=n.slice(0,e).replace(/^\uFEFF/,``),i=n.slice(e+1).trim();i&&t.set(r,i)}let n=t.get(`data`),r=t.get(`bin`),i=t.get(`cache`),a=t.get(`config`),o=t.get(`state`);if(n&&r&&i&&a&&o)return{data:n,bin:r,cache:i,config:a,state:o}}function getInstallScriptCommand(e,t=process.platform,n=!0){if(isWindows(t))return n?{command:`pwsh`,args:[`-Command`,` $dirsFile = $env:${$} Set-Content -LiteralPath $dirsFile -Value '' -NoNewline -Encoding UTF8 . ([scriptblock]::Create((irm -TimeoutSec 15 ${e}))) @@ -189,4 +189,4 @@ fi set +u set -o pipefail ${r} | bash -`.trim()]}}const dt=2e3;function getVitePlusHome(e=process.platform,t=process.env){let n=isWindows(e)?t.USERPROFILE||S():t.HOME||S();return g(n,`.vite-plus`)}function runInstallCommand(e,t,n=process.platform){let{command:r,args:i}=getInstallScriptCommand(e,n,t.VP_VPDIRS_AWARE===`1`),a={env:{...process.env,...t},stdio:`inherit`},o=x(r,i,a);if(isWindows(n)&&o.error?.code===`ENOENT`&&(o=x(`powershell.exe`,i,a)),o.error)throw o.error;return o.status??1}async function installVitePlus(e,t={}){let n=t.platform??process.platform,r=t.env??process.env,i=t.prependPath,a=t.sleep??re,o=t.runInstall??runInstallCommand,s=t.logWarningFn??console.warn,c={...Object.fromEntries(Object.entries(r).filter(e=>e[1]!==void 0)),VP_VERSION:e,VITE_PLUS_VERSION:e},l=supportsVitePlusDirs(e)?createVitePlusDirsFile():void 0;l?(c.VP_VPDIRS_AWARE=`1`,c[$]=l):(delete c.VP_VPDIRS_AWARE,delete c[$]);let u=pkgPrNewCommitSha(e);u&&(c.VP_PR_VERSION=u);let{pinned:d,latest:f}=getInstallScriptUrls(e,n),p=d.length+f.length,m=2*p,h=``,_=0;async function tryUrls(e){for(let t=0;t<2;t+=1)for(let t of e){_+=1;try{let e=o(t,c,n);if(e===0)return!0;h=`exit code ${e}`}catch(e){h=e instanceof Error?e.message:String(e)}_0){if(await tryUrls(d)){ensureBinInPath();return}s(`setup-vp: could not fetch the install script pinned to Vite+ ${e}. Falling back to the latest install script. The latest script may not be compatible with ${e}.`)}if(await tryUrls(f)){ensureBinInPath();return}throw Error(`Failed to install Vite+ after ${m} attempts across ${p} URL(s): ${h}`)}finally{l&&removeVitePlusDirsFile(l)}}function parseBoolean(e){return e?.toLowerCase()===`true`}function parseAzureInputs(e){return{version:e.SETUP_VP_VERSION||``,versionFile:e.SETUP_VP_VERSION_FILE||void 0,nodeVersion:e.SETUP_VP_NODE_VERSION||void 0,nodeVersionFile:e.SETUP_VP_NODE_VERSION_FILE||void 0,workingDirectory:e.SETUP_VP_WORKING_DIRECTORY||`.`,runInstall:e.SETUP_VP_RUN_INSTALL??`true`,sfw:parseBoolean(e.SETUP_VP_SFW),nodeManager:parseNodeManager(e.SETUP_VP_NODE_MANAGER),packageManager:parsePackageManager(e.SETUP_VP_PACKAGE_MANAGER),registryUrl:e.SETUP_VP_REGISTRY_URL||``,scope:e.SETUP_VP_SCOPE||``,cache:parseBoolean(e.SETUP_VP_CACHE),cacheDependencyPath:e.SETUP_VP_CACHE_DEPENDENCY_PATH||``,workspaceRoot:e.SYSTEM_DEFAULTWORKINGDIRECTORY||process.cwd()}}function resolveProjectDirFromInputs(e){return resolveProjectDirectory({workingDirectory:e.workingDirectory,workspaceRoot:e.workspaceRoot})}function isCredentialName(e){return[`NODE_AUTH_TOKEN`,`SYSTEM_ACCESSTOKEN`,`YARN_NPM_AUTH_TOKEN`,`YARN_NPM_AUTH_IDENT`].includes(e)?!0:/(?:^|_)(?:TOKEN|PASSWORD|SECRET|KEY)$/.test(e)&&!isReservedAuthVariable(e)&&!/^(?:NODE_|BASH|LD_|DYLD_|OPENSSL_|SSL_|NPM_CONFIG_|PNPM_|YARN_|BUN_|COREPACK_|VP_|VITE_|SFW_|SOCKET_|XDG_|DOTNET_|COMPLUS_|COR_|POWERSHELL_|PSMODULE|INPUT_|ENDPOINT_|VSTS_|AZP_|TASK_|PIPELINE_|AZURE_)/.test(e)}function applyAuthEnv(e){let t=Object.entries(e).filter(([e])=>e.toUpperCase().startsWith(`SETUP_VP_AUTH_ENV_`)).map(([e,t])=>({key:e,name:e.slice(18),value:t})),n=/* @__PURE__ */ new Set;for(let{name:e}of t){let t=e.toUpperCase();if(!/^[A-Za-z_][A-Za-z0-9_]*$/.test(e)||!isCredentialName(t))throw Error(`authEnv variable ${JSON.stringify(e)} is not a supported credential name`);if(n.has(t))throw Error(`authEnv contains duplicate credential name ${JSON.stringify(e)}`);n.add(t)}for(let{key:n,name:r,value:i}of t)delete e[n],i===void 0||/^\$\([^)]+\)$/.test(i)?delete e[r]:e[r]=i}const ft={installVitePlus,prepareCacheMetadata,configureAuth,setupSfw,parseRunInstall,runInstall,getCommandOutput,run,parseInstalledVpVersion,prependPath,setVariable,logWarning,logInfo};function fail(e){console.error(`setup-vp: ${e}`),process.exit(1)}async function runPrepare(e=process.env,t=ft){let n=parseAzureInputs(e),r=resolveProjectDirFromInputs(n);if(n.nodeManager===!1&&(n.nodeVersion||n.nodeVersionFile))throw Error(`node-version and node-version-file cannot be used with node-manager: false`);let i={...resolutionContext(n.workspaceRoot),info:t.logInfo,warning:t.logWarning},o=createVersionResolver(i).resolveVitePlusVersion(n,r),s=n.nodeVersion||(n.nodeVersionFile?createNodeVersionResolver(i).resolveNodeVersionFile(n.nodeVersionFile,r):void 0);t.setVariable(`SETUP_VP_CACHE_HIT`,`false`),t.setVariable(`SETUP_VP_CACHE_READY`,`false`),t.setVariable(`SETUP_VP_SFW_READY`,`false`),t.setVariable(`SETUP_VP_BOOTSTRAP_NODE`,process.execPath),await t.installVitePlus(o,{env:e,prependPath:e=>t.prependPath(e),exportPath:e=>t.setVariable(`PATH`,e),logWarningFn:t.logWarning}),t.setVariable(`SETUP_VP_RESOLVED_VERSION`,o);let c=(n.nodeManager===!1||n.packageManager!==void 0)&&t.getCommandOutput(`vp`,[`--version`])||``,l=packageManagerArgs(n.packageManager,c);n.nodeManager===!1?t.run(`vp`,nodeManagerOffArgs(c)):s&&t.run(`vp`,[`env`,`use`,s],{cwd:r});for(let e of l)t.run(`vp`,e);let u=f.resolve(process.argv[1]||``);if(t.setVariable(`SETUP_VP_RUNTIME_PATH`,u),n.sfw&&!pkgPrNewCommitSha(o)&&t.parseRunInstall(n.runInstall).length>0)try{let r=getSfwAssetName(process.platform,process.arch,isMuslLinux()),i=f.join(e.PIPELINE_WORKSPACE||n.workspaceRoot,`.setup-vp-sfw`);a(i,{recursive:!0}),t.setVariable(`SETUP_VP_SFW_CACHE_DIR`,i),t.setVariable(`SETUP_VP_SFW_CACHE_KEY`,`${Q}-${r}`),t.setVariable(`SETUP_VP_SFW_READY`,`true`)}catch(e){t.logWarning(String(e))}if(!n.cache)return;let d=t.prepareCacheMetadata({projectDir:r,cacheDependencyPath:n.cacheDependencyPath||void 0,logWarning:t.logWarning});if(!d.ready){t.setVariable(`SETUP_VP_CACHE_READY`,`false`);return}t.setVariable(`SETUP_VP_CACHE_READY`,`true`),d.cachePath&&t.setVariable(`SETUP_VP_CACHE_PATH`,d.cachePath),d.lockFile&&t.setVariable(`SETUP_VP_LOCK_FILE`,d.lockFile),d.lockType&&t.setVariable(`SETUP_VP_LOCK_TYPE`,d.lockType)}async function runFinalize(e=process.env,t=ft){applyAuthEnv(e);let n=parseAzureInputs(e),r=resolveProjectDirFromInputs(n),i=analyzeProjectNpmrc(readNpmrc(f.join(r,`.npmrc`))).envVarRefs;i.add(`NODE_AUTH_TOKEN`);for(let t of i)!isReservedAuthVariable(t)&&/^\$\([^)]+\)$/.test(e[t]||``)&&delete e[t];t.configureAuth(n.registryUrl,n.scope,e,(e,n)=>{e!==`NODE_AUTH_TOKEN`&&n!==void 0&&t.setVariable(e,n,{isSecret:e!==`NPM_CONFIG_USERCONFIG`&&e!==`PNPM_CONFIG_USERCONFIG`})},r);let a=t.parseRunInstall(n.runInstall),o=await t.setupSfw(a,{env:e,sfwEnabled:n.sfw,vitePlusVersion:e.SETUP_VP_RESOLVED_VERSION||n.version,logWarning:t.logWarning,exportVariable:(e,n)=>{n!==void 0&&(e===`PATH`?t.prependPath(n.split(f.delimiter)[0]):t.setVariable(e,n))}});a.length>0&&await t.runInstall(a,r,o,e);let s=t.getCommandOutput(`vp`,[`--version`],{cwd:r});if(!s)throw Error(`Failed to verify Vite+ installation: vp --version failed or returned no output.`);t.logInfo(s);let c=t.parseInstalledVpVersion(s);t.setVariable(`SETUP_VP_INSTALLED_VERSION`,c),t.setVariable(`version`,c,{isOutput:!0}),t.setVariable(`cacheHit`,String(e.SETUP_VP_CACHE_HIT===`true`),{isOutput:!0})}async function main(e){if(e===`prepare`){await runPrepare();return}if(e===`finalize`){await runFinalize();return}fail(`invalid phase "${String(e)}"; expected "prepare" or "finalize"`)}function isEntrypoint(e=process.argv[1],t=import.meta.url){return!!(e&&t===y(f.resolve(e)).href)}if(isEntrypoint()){let e=process.argv[2];e||fail(`missing phase argument; expected "prepare" or "finalize"`);try{await main(e)}catch(e){fail(e instanceof Error?e.message:String(e))}}export{isEntrypoint,main,runFinalize,runPrepare}; \ No newline at end of file +`.trim()]}}const pt=2e3;function getVitePlusHome(e=process.platform,t=process.env){let n=isWindows(e)?t.USERPROFILE||S():t.HOME||S();return g(n,`.vite-plus`)}function runInstallCommand(e,t,n=process.platform){let{command:r,args:i}=getInstallScriptCommand(e,n,t.VP_VPDIRS_AWARE===`1`),a={env:{...process.env,...t},stdio:`inherit`},o=x(r,i,a);if(isWindows(n)&&o.error?.code===`ENOENT`&&(o=x(`powershell.exe`,i,a)),o.error)throw o.error;return o.status??1}async function installVitePlus(e,t={}){let n=t.platform??process.platform,r=t.env??process.env,i=t.prependPath,a=t.sleep??re,o=t.runInstall??runInstallCommand,s=t.logWarningFn??console.warn,c={...Object.fromEntries(Object.entries(r).filter(e=>e[1]!==void 0)),VP_VERSION:e,VITE_PLUS_VERSION:e},l=supportsVitePlusDirs(e)?createVitePlusDirsFile():void 0;l?(c.VP_VPDIRS_AWARE=`1`,c[$]=l):(delete c.VP_VPDIRS_AWARE,delete c[$]);let u=pkgPrNewCommitSha(e);u&&(c.VP_PR_VERSION=u);let{pinned:d,latest:f}=getInstallScriptUrls(e,n),p=d.length+f.length,m=2*p,h=``,_=0;async function tryUrls(e){for(let t=0;t<2;t+=1)for(let t of e){_+=1;try{let e=o(t,c,n);if(e===0)return!0;h=`exit code ${e}`}catch(e){h=e instanceof Error?e.message:String(e)}_0){if(await tryUrls(d)){ensureBinInPath();return}s(`setup-vp: could not fetch the install script pinned to Vite+ ${e}. Falling back to the latest install script. The latest script may not be compatible with ${e}.`)}if(await tryUrls(f)){ensureBinInPath();return}throw Error(`Failed to install Vite+ after ${m} attempts across ${p} URL(s): ${h}`)}finally{l&&removeVitePlusDirsFile(l)}}function parseBoolean(e){return e?.toLowerCase()===`true`}function parseAzureInputs(e){return{version:e.SETUP_VP_VERSION||``,versionFile:e.SETUP_VP_VERSION_FILE||void 0,nodeVersion:e.SETUP_VP_NODE_VERSION||void 0,nodeVersionFile:e.SETUP_VP_NODE_VERSION_FILE||void 0,workingDirectory:e.SETUP_VP_WORKING_DIRECTORY||`.`,runInstall:e.SETUP_VP_RUN_INSTALL??`true`,sfw:parseBoolean(e.SETUP_VP_SFW),nodeManager:parseNodeManager(e.SETUP_VP_NODE_MANAGER),packageManager:parsePackageManager(e.SETUP_VP_PACKAGE_MANAGER),registryUrl:e.SETUP_VP_REGISTRY_URL||``,scope:e.SETUP_VP_SCOPE||``,cache:parseBoolean(e.SETUP_VP_CACHE),cacheDependencyPath:e.SETUP_VP_CACHE_DEPENDENCY_PATH||``,workspaceRoot:e.SYSTEM_DEFAULTWORKINGDIRECTORY||process.cwd()}}function resolveProjectDirFromInputs(e){return resolveProjectDirectory({workingDirectory:e.workingDirectory,workspaceRoot:e.workspaceRoot})}const mt={installVitePlus,prepareCacheMetadata,configureAuth,setupSfw,parseRunInstall,runInstall,getCommandOutput,run,parseInstalledVpVersion,prependPath,setVariable,logWarning,logInfo};function fail(e){console.error(`setup-vp: ${e}`),process.exit(1)}async function runPrepare(e=process.env,t=mt){let n=parseAzureInputs(e),r=resolveProjectDirFromInputs(n);if(n.nodeManager===!1&&(n.nodeVersion||n.nodeVersionFile))throw Error(`node-version and node-version-file cannot be used with node-manager: false`);let i={...resolutionContext(n.workspaceRoot),info:t.logInfo,warning:t.logWarning},o=createVersionResolver(i).resolveVitePlusVersion(n,r),s=n.nodeVersion||(n.nodeVersionFile?createNodeVersionResolver(i).resolveNodeVersionFile(n.nodeVersionFile,r):void 0);t.setVariable(`SETUP_VP_CACHE_HIT`,`false`),t.setVariable(`SETUP_VP_CACHE_READY`,`false`),t.setVariable(`SETUP_VP_SFW_READY`,`false`),t.setVariable(`SETUP_VP_BOOTSTRAP_NODE`,process.execPath),await t.installVitePlus(o,{env:e,prependPath:e=>t.prependPath(e),exportPath:e=>t.setVariable(`PATH`,e),logWarningFn:t.logWarning}),t.setVariable(`SETUP_VP_RESOLVED_VERSION`,o);let c=(n.nodeManager===!1||n.packageManager!==void 0)&&t.getCommandOutput(`vp`,[`--version`])||``,l=packageManagerArgs(n.packageManager,c);n.nodeManager===!1?t.run(`vp`,nodeManagerOffArgs(c)):s&&t.run(`vp`,[`env`,`use`,s],{cwd:r});for(let e of l)t.run(`vp`,e);let u=f.resolve(process.argv[1]||``);if(t.setVariable(`SETUP_VP_RUNTIME_PATH`,u),n.sfw&&!pkgPrNewCommitSha(o)&&t.parseRunInstall(n.runInstall).length>0)try{let r=getSfwAssetName(process.platform,process.arch,isMuslLinux()),i=f.join(e.PIPELINE_WORKSPACE||n.workspaceRoot,`.setup-vp-sfw`);a(i,{recursive:!0}),t.setVariable(`SETUP_VP_SFW_CACHE_DIR`,i),t.setVariable(`SETUP_VP_SFW_CACHE_KEY`,`${Q}-${r}`),t.setVariable(`SETUP_VP_SFW_READY`,`true`)}catch(e){t.logWarning(String(e))}if(!n.cache)return;let d=t.prepareCacheMetadata({projectDir:r,cacheDependencyPath:n.cacheDependencyPath||void 0,logWarning:t.logWarning});if(!d.ready){t.setVariable(`SETUP_VP_CACHE_READY`,`false`);return}t.setVariable(`SETUP_VP_CACHE_READY`,`true`),d.cachePath&&t.setVariable(`SETUP_VP_CACHE_PATH`,d.cachePath),d.lockFile&&t.setVariable(`SETUP_VP_LOCK_FILE`,d.lockFile),d.lockType&&t.setVariable(`SETUP_VP_LOCK_TYPE`,d.lockType)}async function runFinalize(e=process.env,t=mt){applyAuthEnv(e);let n=parseAzureInputs(e),r=resolveProjectDirFromInputs(n),i=analyzeProjectNpmrc(readNpmrc(f.join(r,`.npmrc`))).envVarRefs;i.add(`NODE_AUTH_TOKEN`);for(let t of i)!isReservedAuthVariable(t)&&/^\$\([^)]+\)$/.test(e[t]||``)&&delete e[t];t.configureAuth(n.registryUrl,n.scope,e,(e,n)=>{e!==`NODE_AUTH_TOKEN`&&n!==void 0&&t.setVariable(e,n,{isSecret:e!==`NPM_CONFIG_USERCONFIG`&&e!==`PNPM_CONFIG_USERCONFIG`})},r);let a=t.parseRunInstall(n.runInstall),o=await t.setupSfw(a,{env:e,sfwEnabled:n.sfw,vitePlusVersion:e.SETUP_VP_RESOLVED_VERSION||n.version,logWarning:t.logWarning,exportVariable:(e,n)=>{n!==void 0&&(e===`PATH`?t.prependPath(n.split(f.delimiter)[0]):t.setVariable(e,n))}});a.length>0&&await t.runInstall(a,r,o,e);let s=t.getCommandOutput(`vp`,[`--version`],{cwd:r});if(!s)throw Error(`Failed to verify Vite+ installation: vp --version failed or returned no output.`);t.logInfo(s);let c=t.parseInstalledVpVersion(s);t.setVariable(`SETUP_VP_INSTALLED_VERSION`,c),t.setVariable(`version`,c,{isOutput:!0}),t.setVariable(`cacheHit`,String(e.SETUP_VP_CACHE_HIT===`true`),{isOutput:!0})}async function main(e){if(e===`prepare`){await runPrepare();return}if(e===`finalize`){await runFinalize();return}fail(`invalid phase "${String(e)}"; expected "prepare" or "finalize"`)}function isEntrypoint(e=process.argv[1],t=import.meta.url){return!!(e&&t===y(f.resolve(e)).href)}if(isEntrypoint()){let e=process.argv[2];e||fail(`missing phase argument; expected "prepare" or "finalize"`);try{await main(e)}catch(e){fail(e instanceof Error?e.message:String(e))}}export{isEntrypoint,main,runFinalize,runPrepare}; \ No newline at end of file diff --git a/src/azure/auth-env.test.ts b/src/azure/auth-env.test.ts index 8019c8b..526e22d 100644 --- a/src/azure/auth-env.test.ts +++ b/src/azure/auth-env.test.ts @@ -128,8 +128,9 @@ describe("Azure authEnv", () => { }; const original = { ...env }; - expect(() => applyAuthEnv(env)).toThrow("is not a supported credential name"); - expect(() => applyAuthEnv(env)).not.toThrow("sensitive-value"); + expect(() => applyAuthEnv(env)).toThrow( + new Error(`authEnv variable ${JSON.stringify(name)} is not a supported credential name`), + ); expect(env).toEqual(original); }); diff --git a/src/azure/auth-env.ts b/src/azure/auth-env.ts index 25426f7..5bc0d87 100644 --- a/src/azure/auth-env.ts +++ b/src/azure/auth-env.ts @@ -2,26 +2,52 @@ import { isReservedAuthVariable } from "../ci/auth.js"; import type { RuntimeEnv } from "../ci/types.js"; const AUTH_ENV_PREFIX = "SETUP_VP_AUTH_ENV_"; +const CREDENTIAL_EXCEPTIONS = new Set([ + "NODE_AUTH_TOKEN", + "SYSTEM_ACCESSTOKEN", + "YARN_NPM_AUTH_TOKEN", + "YARN_NPM_AUTH_IDENT", +]); +const RESERVED_AUTH_PREFIXES = [ + "NODE_", + "BASH", + "LD_", + "DYLD_", + "OPENSSL_", + "SSL_", + "NPM_CONFIG_", + "PNPM_", + "YARN_", + "BUN_", + "COREPACK_", + "VP_", + "VITE_", + "SFW_", + "SOCKET_", + "XDG_", + "DOTNET_", + "COMPLUS_", + "COR_", + "POWERSHELL_", + "PSMODULE", + "INPUT_", + "ENDPOINT_", + "VSTS_", + "AZP_", + "TASK_", + "PIPELINE_", + "AZURE_", +]; function isCredentialName(name: string): boolean { // These credential names belong to otherwise reserved namespaces. - if ( - [ - "NODE_AUTH_TOKEN", - "SYSTEM_ACCESSTOKEN", - "YARN_NPM_AUTH_TOKEN", - "YARN_NPM_AUTH_IDENT", - ].includes(name) - ) - return true; + if (CREDENTIAL_EXCEPTIONS.has(name)) return true; // Accept credential names, rather than trying to enumerate every environment // variable that can control a shell, interpreter, or package manager. return ( /(?:^|_)(?:TOKEN|PASSWORD|SECRET|KEY)$/.test(name) && !isReservedAuthVariable(name) && - !/^(?:NODE_|BASH|LD_|DYLD_|OPENSSL_|SSL_|NPM_CONFIG_|PNPM_|YARN_|BUN_|COREPACK_|VP_|VITE_|SFW_|SOCKET_|XDG_|DOTNET_|COMPLUS_|COR_|POWERSHELL_|PSMODULE|INPUT_|ENDPOINT_|VSTS_|AZP_|TASK_|PIPELINE_|AZURE_)/.test( - name, - ) + !RESERVED_AUTH_PREFIXES.some((prefix) => name.startsWith(prefix)) ); } @@ -30,20 +56,20 @@ export function applyAuthEnv(env: RuntimeEnv): void { const entries = Object.entries(env) .filter(([key]) => key.toUpperCase().startsWith(AUTH_ENV_PREFIX)) .map(([key, value]) => ({ key, name: key.slice(AUTH_ENV_PREFIX.length), value })); - const names = new Set(); + const seenNames = new Set(); // Validate every name before exposing any values to subprocesses or later steps. for (const { name } of entries) { - const normalized = name.toUpperCase(); - if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name) || !isCredentialName(normalized)) { + const normalizedName = name.toUpperCase(); + if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name) || !isCredentialName(normalizedName)) { throw new Error( `authEnv variable ${JSON.stringify(name)} is not a supported credential name`, ); } - if (names.has(normalized)) { + if (seenNames.has(normalizedName)) { throw new Error(`authEnv contains duplicate credential name ${JSON.stringify(name)}`); } - names.add(normalized); + seenNames.add(normalizedName); } for (const { key, name, value } of entries) { diff --git a/src/azure/index.ts b/src/azure/index.ts index 7fdf586..c14081a 100644 --- a/src/azure/index.ts +++ b/src/azure/index.ts @@ -14,10 +14,10 @@ import { getSfwAssetName, isMuslLinux, setupSfw, SFW_VERSION } from "../ci/insta import { getCommandOutput, run } from "../ci/process.js"; import { parseRunInstall, runInstall } from "../ci/run-install.js"; import { parseInstalledVpVersion } from "../ci/version.js"; +import { applyAuthEnv } from "./auth-env.js"; import { logInfo, logWarning, prependPath, setVariable } from "./commands.js"; import { installVitePlus } from "./install-viteplus.js"; import { parseAzureInputs, resolveProjectDirFromInputs } from "./inputs.js"; -import { applyAuthEnv } from "./auth-env.js"; export type AzurePhase = "prepare" | "finalize"; diff --git a/src/azure/parity.test.ts b/src/azure/parity.test.ts index 8e47531..86f9afa 100644 --- a/src/azure/parity.test.ts +++ b/src/azure/parity.test.ts @@ -1,5 +1,5 @@ -import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs"; import { spawnSync } from "node:child_process"; +import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vite-plus/test"; @@ -45,14 +45,16 @@ describe("Azure parity", () => { writeFileSync(path.join(project, ".npmrc"), "//registry.example/:_authToken=${CUSTOM_TOKEN}\n"); const target: NodeJS.ProcessEnv = { ...env, SETUP_VP_AUTH_ENV_CUSTOM_TOKEN: secret }; ports.runInstall.mockImplementation((_entries, _project, _command, installEnv) => { - const result = spawnSync( - process.execPath, - [ - "-e", - "process.stdout.write(JSON.stringify({token:process.env.CUSTOM_TOKEN,transport:process.env.SETUP_VP_AUTH_ENV_CUSTOM_TOKEN}))", - ], - { env: installEnv, encoding: "utf8" }, - ); + const script = ` + process.stdout.write(JSON.stringify({ + token: process.env.CUSTOM_TOKEN, + transport: process.env.SETUP_VP_AUTH_ENV_CUSTOM_TOKEN, + })); + `; + const result = spawnSync(process.execPath, ["-e", script], { + env: installEnv, + encoding: "utf8", + }); expect(result.status, result.stderr).toBe(0); expect(JSON.parse(result.stdout)).toEqual({ token: secret }); }); diff --git a/src/azure/template.test.ts b/src/azure/template.test.ts index 7b27285..e5f6613 100644 --- a/src/azure/template.test.ts +++ b/src/azure/template.test.ts @@ -1,4 +1,4 @@ -import { spawnSync } from "node:child_process"; +import { spawnSync, type SpawnSyncReturns } from "node:child_process"; import { copyFileSync, existsSync, @@ -23,25 +23,52 @@ const { version } = JSON.parse( ) as { version: string }; type Shell = "bash" | "powershell"; -const prepareSteps = docs.steps.filter((step: { displayName?: string }) => - step.displayName?.startsWith("setup-vp prepare"), -) as Array<{ bash?: string; powershell?: string; env: Record }>; -const finalizeSteps = docs.steps.filter((step: { displayName?: string }) => - step.displayName?.startsWith("setup-vp finalize"), -) as Array<{ bash?: string; powershell?: string; env: Record }>; +interface TemplateStep { + displayName?: string; + bash?: string; + powershell?: string; + env: Record; +} + +interface PrepareResult extends SpawnSyncReturns { + download: string | undefined; + bootstrap: string | undefined; + injected: boolean; +} + +function templateStep(phase: "prepare" | "finalize", shell: Shell): TemplateStep { + return (docs.steps as TemplateStep[]).find( + (step) => step.displayName?.startsWith(`setup-vp ${phase}`) && step[shell], + )!; +} -function prepareStep(shell: Shell) { - return prepareSteps.find((step) => step[shell])!; +function runShellScript( + shell: Shell, + scriptPath: string, + env: NodeJS.ProcessEnv, + cwd?: string, +): SpawnSyncReturns { + const executable = shell === "bash" ? "bash" : "powershell.exe"; + const args = + shell === "bash" + ? [scriptPath] + : ["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-File", scriptPath]; + return spawnSync(executable, args, { + encoding: "utf8", + timeout: 10_000, + cwd, + env: { ...process.env, ...env }, + }); } -function runPrepare(shell: Shell, setupRef: string) { +function runPrepare(shell: Shell, setupRef: string): PrepareResult { const directory = mkdtempSync(join(tmpdir(), "setup-vp-azure-template-")); const download = join(directory, "download"); const bootstrap = join(directory, "bootstrap"); const marker = join(directory, "injected"); // Render the real template source as Azure would, so reintroducing inline // parameter interpolation makes the injection payloads executable again. - const script = prepareStep(shell) + const script = templateStep("prepare", shell) [shell]!.replaceAll("${{ parameters.setupRef }}", () => setupRef) .replaceAll("$(Agent.TempDirectory)", () => directory); const prelude = @@ -62,24 +89,13 @@ function Invoke-WebRequest { const scriptPath = join(directory, shell === "bash" ? "prepare.sh" : "prepare.ps1"); writeFileSync(scriptPath, prelude + script); try { - const result = spawnSync( - shell === "bash" ? "bash" : "powershell.exe", - shell === "bash" - ? [scriptPath] - : ["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-File", scriptPath], - { - encoding: "utf8", - timeout: 10_000, - env: { - ...process.env, - AGENT_TEMPDIRECTORY: directory, - SETUP_VP_SETUP_REF: setupRef, - SETUP_VP_TEST_DOWNLOAD: download, - SETUP_VP_TEST_BOOTSTRAP: bootstrap, - SETUP_VP_TEST_MARKER: marker, - }, - }, - ); + const result = runShellScript(shell, scriptPath, { + AGENT_TEMPDIRECTORY: directory, + SETUP_VP_SETUP_REF: setupRef, + SETUP_VP_TEST_DOWNLOAD: download, + SETUP_VP_TEST_BOOTSTRAP: bootstrap, + SETUP_VP_TEST_MARKER: marker, + }); expect(result.error).toBeUndefined(); return { ...result, @@ -94,8 +110,10 @@ function Invoke-WebRequest { // Expand only the authEnv insertion from the real template. Support the old // direct insertion too, so the execution test detects a return to that behavior. -function renderAuthEnv(shell: Shell, authEnv: Record): Record { - const step = finalizeSteps.find((step) => step[shell])!; +function renderAuthEnv( + step: TemplateStep, + authEnv: Record, +): Record { if (step.env["${{ insert }}"] === "${{ parameters.authEnv }}") return authEnv; const mapping = step.env["${{ each pair in parameters.authEnv }}"] as Record; const entries = Object.entries(mapping); @@ -109,7 +127,7 @@ function renderAuthEnv(shell: Shell, authEnv: Record): Record "$SETUP_VP_TEST_MARKER"\n'); - const preload = - "import{writeFileSync}from'node:fs';writeFileSync(process.env.SETUP_VP_TEST_MARKER,'injected');"; + const preload = ` + import { writeFileSync } from "node:fs"; + writeFileSync(process.env.SETUP_VP_TEST_MARKER, "injected"); + `; const authEnv = { [name]: name === "BASH_ENV" ? hook : `--import=data:text/javascript;base64,${Buffer.from(preload).toString("base64")}`, }; - const step = finalizeSteps.find((step) => step[shell])!; + const step = templateStep("finalize", shell); const script = step[shell]!.replaceAll( "$(SETUP_VP_BOOTSTRAP_NODE)", () => process.execPath, @@ -137,22 +157,15 @@ function runFinalizeWithAuthEnv(shell: Shell, name: "NODE_OPTIONS" | "BASH_ENV") const scriptPath = join(directory, shell === "bash" ? "finalize.sh" : "finalize.ps1"); // PowerShell@2's wrapper propagates the last native command's exit code. writeFileSync(scriptPath, script + (shell === "powershell" ? "\nexit $LASTEXITCODE\n" : "")); - const result = spawnSync( - shell === "bash" ? "bash" : "powershell.exe", - shell === "bash" - ? [scriptPath] - : ["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-File", scriptPath], + const result = runShellScript( + shell, + scriptPath, { - encoding: "utf8", - timeout: 10_000, - cwd: directory, - env: { - ...process.env, - SETUP_VP_RUN_INSTALL: "false", - SETUP_VP_TEST_MARKER: marker, - ...renderAuthEnv(shell, authEnv), - }, + SETUP_VP_RUN_INSTALL: "false", + SETUP_VP_TEST_MARKER: marker, + ...renderAuthEnv(step, authEnv), }, + directory, ); expect(result.error).toBeUndefined(); expect(existsSync(marker)).toBe(false); @@ -229,7 +242,7 @@ describe("azure/setup-vp.yml", () => { "keeps authEnv names outside the %s launcher environment", (shell) => { const authEnv = { NODE_OPTIONS: "--require=./hook.cjs", CUSTOM_TOKEN: "$(CUSTOM_TOKEN)" }; - expect(renderAuthEnv(shell, authEnv)).toEqual({ + expect(renderAuthEnv(templateStep("finalize", shell), authEnv)).toEqual({ SETUP_VP_AUTH_ENV_NODE_OPTIONS: "--require=./hook.cjs", SETUP_VP_AUTH_ENV_CUSTOM_TOKEN: "$(CUSTOM_TOKEN)", }); @@ -250,7 +263,7 @@ describe("azure/setup-vp.yml", () => { it.each(["bash", "powershell"] as const)( "passes setupRef only through %s's environment", (shell) => { - const step = prepareStep(shell); + const step = templateStep("prepare", shell); expect(step.env.SETUP_VP_SETUP_REF).toBe("${{ parameters.setupRef }}"); expect(step[shell]).not.toContain("${{"); }, @@ -262,7 +275,7 @@ describe("azure/setup-vp.yml", () => { () => { it.each(["NODE_OPTIONS", "BASH_ENV"] as const)( "rejects %s without executing startup code", - (name) => runFinalizeWithAuthEnv(shell, name), + (name) => assertFinalizeRejectsAuthEnv(shell, name), ); }, ); From f5b432fdb2c9aefb0b6aa5094e09c485815e0047 Mon Sep 17 00:00:00 2001 From: MK Date: Sat, 10 Oct 2026 23:36:17 +0800 Subject: [PATCH 3/3] fix: preserve Azure auth names before Windows environment folding --- azure/setup-vp.yml | 6 +++ dist/azure/index.mjs | 2 +- src/azure/auth-env.test.ts | 38 +++++++++++++++ src/azure/auth-env.ts | 39 ++++++++++++--- src/azure/parity.test.ts | 7 ++- src/azure/template.test.ts | 99 ++++++++++++++++++++++++++++---------- 6 files changed, 156 insertions(+), 35 deletions(-) diff --git a/azure/setup-vp.yml b/azure/setup-vp.yml index a6eecbe..3712beb 100644 --- a/azure/setup-vp.yml +++ b/azure/setup-vp.yml @@ -154,6 +154,9 @@ steps: SETUP_VP_SCOPE: ${{ parameters.scope }} NODE_AUTH_TOKEN: $(NODE_AUTH_TOKEN) # Keep credential mappings inert until the runtime validates their names. + # Preserve original names when Windows folds environment keys. Escape + # dollars so Azure cannot expand secret macros inside this JSON metadata. + SETUP_VP_AUTH_ENV: ${{ replace(convertToJson(parameters.authEnv), '$', '\u0024') }} ${{ each pair in parameters.authEnv }}: ${{ format('SETUP_VP_AUTH_ENV_{0}', pair.key) }}: ${{ pair.value }} SETUP_VP_CACHE: ${{ iif(eq(parameters.cache, true), 'true', 'false') }} @@ -174,6 +177,9 @@ steps: SETUP_VP_SCOPE: ${{ parameters.scope }} NODE_AUTH_TOKEN: $(NODE_AUTH_TOKEN) # Keep credential mappings inert until the runtime validates their names. + # Preserve original names when Windows folds environment keys. Escape + # dollars so Azure cannot expand secret macros inside this JSON metadata. + SETUP_VP_AUTH_ENV: ${{ replace(convertToJson(parameters.authEnv), '$', '\u0024') }} ${{ each pair in parameters.authEnv }}: ${{ format('SETUP_VP_AUTH_ENV_{0}', pair.key) }}: ${{ pair.value }} SETUP_VP_CACHE: ${{ iif(eq(parameters.cache, true), 'true', 'false') }} diff --git a/dist/azure/index.mjs b/dist/azure/index.mjs index 4639353..6dd9073 100644 --- a/dist/azure/index.mjs +++ b/dist/azure/index.mjs @@ -142,7 +142,7 @@ n?this.stack.push(n):(yield*this.pop(),yield*this.step())}else{let t=this.peek(2 `)+` `,n,r);return n.NODE_AUTH_TOKEN=n.NODE_AUTH_TOKEN||`XXXXX-XXXXX-XXXXX-XXXXX`,r?.(`NODE_AUTH_TOKEN`,n.NODE_AUTH_TOKEN),u}function resolveSfwEnabled(e,t,n){return e?!pkgPrNewCommitSha(t)||(n(`sfw was requested but is automatically disabled for Vite+ preview build ${t}; Socket Firewall Free will not be used.`),!1):!1}const Q=`v1.15.4`,lt=`https://github.com/SocketDev/sfw-free/releases/download/${Q}`;function isMuslLinux(){if(process.platform!==`linux`)return!1;try{let e=process.report?.getReport();if(e?.header&&!e.header.glibcVersionRuntime)return!0}catch{}return i(`/etc/alpine-release`)}function getSfwAssetName(e,t,n){if(e===`darwin`){if(t===`arm64`)return`sfw-free-macos-arm64`;if(t===`x64`)return`sfw-free-macos-x86_64`}else if(e===`linux`){if(t===`arm64`)return n?`sfw-free-musl-linux-arm64`:`sfw-free-linux-arm64`;if(t===`x64`)return n?`sfw-free-musl-linux-x86_64`:`sfw-free-linux-x86_64`}else if(isWindows(e)){if(t===`arm64`)return`sfw-free-windows-arm64.exe`;if(t===`x64`)return`sfw-free-windows-x86_64.exe`}throw Error(`Unsupported platform/arch for sfw: ${e}/${t}${e===`linux`?` (${n?`musl`:`glibc`})`:``}`)}function downloadFileWithShell(e,t,n){let r=Math.max(1,Math.ceil(n/1e3)),i=commandPath(`curl`);if(i){let n=x(i,[`-fsSL`,`--connect-timeout`,`5`,`--max-time`,String(r),e,`-o`,t],{stdio:`ignore`});if(n.status===0)return;throw n.error||/* @__PURE__ */ Error(`curl failed while downloading ${e}`)}let a=commandPath(`wget`);if(a){let n=x(a,[`-q`,`-T`,String(r),`-t`,`2`,`-O`,t,e],{stdio:`ignore`});if(n.status===0)return;throw n.error||/* @__PURE__ */ Error(`wget failed while downloading ${e}`)}throw Error(`curl or wget is required to download sfw`)}function downloadFile(e,t,n=0,i=6e4,a){return n>5?Promise.reject(/* @__PURE__ */ Error(`too many redirects while downloading ${e}`)):a?new Promise((o,s)=>{let c=!1,finish=e=>{c||(c=!0,clearTimeout(u),e?s(e):o())},l=a(e,o=>{let s=o.statusCode??0,c=o.headers.location;if(s>=300&&s<400&&c){o.resume(),downloadFile(new URL(c,e).toString(),t,n+1,i,a).then(()=>finish(),finish);return}if(s!==200){o.resume(),finish(/* @__PURE__ */ Error(`download failed with HTTP ${s}: ${e}`));return}let l=r(t);o.pipe(l),l.on(`finish`,()=>l.close(()=>finish())),l.on(`error`,finish)}),u=setTimeout(()=>{l.destroy(/* @__PURE__ */ Error(`download timed out after ${i}ms: ${e}`))},i);l.on(`error`,finish)}):(downloadFileWithShell(e,t,i),Promise.resolve())}async function setupSfw(e,t={}){let n=t.env??process.env,r=t.sfwEnabled??n.SETUP_VP_SFW===`true`,i=t.platform??process.platform,a=t.arch??process.arch,o=t.isMusl??isMuslLinux(),s=t.download??downloadFile;if(!resolveSfwEnabled(r,t.vitePlusVersion??``,t.logWarning??console.warn))return{executable:`vp`,sfw:!1};if(e.length===0)return console.log(`setup-vp: sfw was requested but run-install is disabled; sfw will not be invoked.`),{executable:`vp`,sfw:!1};let c=commandPath(`sfw`,n);if(c)return console.log(`setup-vp: using existing sfw on PATH: ${c}`),{executable:c,sfw:!0};let l;try{l=getSfwAssetName(i,a,o)}catch{return console.error(`setup-vp: sfw has no published binary for this runner's platform/architecture (process.platform=${i}, process.arch=${a}, musl=${o}) and none was found on PATH; falling back to plain vp install.`),{executable:`vp`,sfw:!1}}let u=t.cacheDirectory||n.SETUP_VP_SFW_CACHE_DIR,d=u?f.resolve(u,Q,l):await E(f.resolve(C(),`setup-vp-sfw-`));await T(d,{recursive:!0});let p=f.join(d,isWindows(i)?`sfw.exe`:`sfw`),m=`${lt}/${l}`;function activate(){let e=isWindows(i)?`;`:`:`;return n.PATH=`${d}${e}${n.PATH||``}`,t.exportVariable?.(`PATH`,n.PATH),{executable:p,sfw:!0}}if(await ne(p).then(e=>e.isFile()&&e.size>0,()=>!1))return console.log(`setup-vp: using cached sfw ${Q}: ${p}`),activate();for(let e=1;e<=2;e+=1){let t=await E(f.join(d,`.download-`)),n=f.join(t,`sfw`);try{if(console.log(`setup-vp: installing sfw ${Q} from ${m}`),await s(m,n),(await ne(n)).size===0)throw Error(`Downloaded sfw binary is empty`);return await w(n,493),await ee(n,p),activate()}catch(t){if(e===2)throw t;await new Promise(e=>setTimeout(e,2e3))}finally{await te(t,{recursive:!0,force:!0})}}throw Error(`failed to install sfw after retrying`)}function isRecord(e){return typeof e==`object`&&!!e&&!Array.isArray(e)}function validateRunInstallEntry(e){if(!isRecord(e))throw Error(`run-install entries must be objects`);for(let t of Object.keys(e))if(t!==`cwd`&&t!==`args`)throw Error(`unsupported run-install key: ${t}`);let t={};if(e.cwd!==void 0){if(typeof e.cwd!=`string`)throw Error(`run-install.cwd must be a string`);t.cwd=e.cwd}if(e.args!==void 0){if(!Array.isArray(e.args)||e.args.some(e=>typeof e!=`string`))throw Error(`run-install.args must be an array of strings`);t.args=e.args}return t}function validateRunInstallInput(e){return e===null||typeof e==`boolean`?e:Array.isArray(e)?e.map(validateRunInstallEntry):validateRunInstallEntry(e)}function parseRunInstall(e){let t=validateRunInstallInput((0,J.parse)(e)??null);return t?t===!0?[{}]:Array.isArray(t)?t:[t]:[]}function isSfwVpNotFoundFlake(e,t){return/Command 'vp' not found in PATH/.test(e+` `+t)}async function runInstall(e,t,n,r=process.env,i={}){let a={...r};delete a.SETUP_VP_ENV_FILE;let o=i.execute??runWithOutput,{executable:s,sfw:c}=n,l=[];for(let n of e){let e=n.cwd?f.resolve(t,n.cwd):t,r=[`install`,...n.args||[]],u=c?[`vp`,...r]:r,d=`${s} ${u.join(` `)} (cwd: ${e})`;console.log(`setup-vp: running ${d}`);try{let t=await o(s,u,{cwd:e,env:a});if(t.exitCode!==0&&c&&isSfwVpNotFoundFlake(t.stdout,t.stderr)){let n=isWindows(i.platform);if(console.warn(n?`setup-vp: sfw could not resolve vp; warming the PowerShell command cache and retrying once.`:`setup-vp: sfw could not resolve vp; retrying once.`),n)try{await o(`powershell.exe`,[`-NoProfile`,`-Command`,`Get-Command vp`],{cwd:e,env:a})}catch{}t=await o(s,u,{cwd:e,env:a})}t.exitCode!==0&&l.push(`${d} exited with code ${t.exitCode}\n${t.stderr.trim()||t.stdout.trim()}`)}catch(e){l.push(`${d}: ${String(e)}`)}}if(l.length)throw Error(l.join(` -`))}const ut=/* @__PURE__ */ new Set([`NODE_AUTH_TOKEN`,`SYSTEM_ACCESSTOKEN`,`YARN_NPM_AUTH_TOKEN`,`YARN_NPM_AUTH_IDENT`]),dt=/* @__PURE__ */ `NODE_.BASH.LD_.DYLD_.OPENSSL_.SSL_.NPM_CONFIG_.PNPM_.YARN_.BUN_.COREPACK_.VP_.VITE_.SFW_.SOCKET_.XDG_.DOTNET_.COMPLUS_.COR_.POWERSHELL_.PSMODULE.INPUT_.ENDPOINT_.VSTS_.AZP_.TASK_.PIPELINE_.AZURE_`.split(`.`);function isCredentialName(e){return ut.has(e)?!0:/(?:^|_)(?:TOKEN|PASSWORD|SECRET|KEY)$/.test(e)&&!isReservedAuthVariable(e)&&!dt.some(t=>e.startsWith(t))}function applyAuthEnv(e){let t=Object.entries(e).filter(([e])=>e.toUpperCase().startsWith(`SETUP_VP_AUTH_ENV_`)).map(([e,t])=>({key:e,name:e.slice(18),value:t})),n=/* @__PURE__ */ new Set;for(let{name:e}of t){let t=e.toUpperCase();if(!/^[A-Za-z_][A-Za-z0-9_]*$/.test(e)||!isCredentialName(t))throw Error(`authEnv variable ${JSON.stringify(e)} is not a supported credential name`);if(n.has(t))throw Error(`authEnv contains duplicate credential name ${JSON.stringify(e)}`);n.add(t)}for(let{key:n,name:r,value:i}of t)delete e[n],i===void 0||/^\$\([^)]+\)$/.test(i)?delete e[r]:e[r]=i}function escapeLoggingCommandData(e){return e.replaceAll(`%`,`%AZP25`).replaceAll(`\r`,`%0D`).replaceAll(` +`))}const ut=/* @__PURE__ */ new Set([`NODE_AUTH_TOKEN`,`SYSTEM_ACCESSTOKEN`,`YARN_NPM_AUTH_TOKEN`,`YARN_NPM_AUTH_IDENT`]),dt=/* @__PURE__ */ `NODE_.BASH.LD_.DYLD_.OPENSSL_.SSL_.NPM_CONFIG_.PNPM_.YARN_.BUN_.COREPACK_.VP_.VITE_.SFW_.SOCKET_.XDG_.DOTNET_.COMPLUS_.COR_.POWERSHELL_.PSMODULE.INPUT_.ENDPOINT_.VSTS_.AZP_.TASK_.PIPELINE_.AZURE_`.split(`.`);function isCredentialName(e){return ut.has(e)?!0:/(?:^|_)(?:TOKEN|PASSWORD|SECRET|KEY)$/.test(e)&&!isReservedAuthVariable(e)&&!dt.some(t=>e.startsWith(t))}function declaredAuthNames(e){let t;try{t=JSON.parse(e)}catch{throw Error(`Invalid authEnv metadata: expected a JSON object`)}if(!t||typeof t!=`object`||Array.isArray(t))throw Error(`Invalid authEnv metadata: expected a JSON object`);return Object.keys(t)}function validateAuthNames(e){let t=/* @__PURE__ */ new Set;for(let n of e){let e=n.toUpperCase();if(!/^[A-Za-z_][A-Za-z0-9_]*$/.test(n)||!isCredentialName(e))throw Error(`authEnv variable ${JSON.stringify(n)} is not a supported credential name`);if(t.has(e))throw Error(`authEnv contains duplicate credential name ${JSON.stringify(n)}`);t.add(e)}}function applyAuthEnv(e){let t=Object.entries(e).filter(([e])=>e.toUpperCase().startsWith(`SETUP_VP_AUTH_ENV_`)).map(([e,t])=>({key:e,name:e.slice(18),value:t}));e.SETUP_VP_AUTH_ENV!==void 0&&validateAuthNames(declaredAuthNames(e.SETUP_VP_AUTH_ENV)),validateAuthNames(t.map(({name:e})=>e)),delete e.SETUP_VP_AUTH_ENV;for(let{key:n,name:r,value:i}of t)delete e[n],i===void 0||/^\$\([^)]+\)$/.test(i)?delete e[r]:e[r]=i}function escapeLoggingCommandData(e){return e.replaceAll(`%`,`%AZP25`).replaceAll(`\r`,`%0D`).replaceAll(` `,`%0A`).replaceAll(`;`,`%3B`).replaceAll(`]`,`%5D`)}function prependPath(e){process.stdout.write(`##vso[task.prependpath]${escapeLoggingCommandData(e)}\n`)}function setVariable(e,t,n={}){let r=[`variable=${e}`];n.isOutput&&r.push(`isOutput=true`),n.isReadonly&&r.push(`isReadonly=true`),n.isSecret&&r.push(`issecret=true`),process.stdout.write(`##vso[task.setvariable ${r.join(`;`)}]${escapeLoggingCommandData(t)}\n`)}function logWarning(e){process.stdout.write(`##vso[task.logissue type=warning]${escapeLoggingCommandData(e)}\n`)}function logInfo(e){console.log(e)}const $=`SETUP_VP_DIRS_FILE`;function getVitePlusBinDirs(e){let t=g(e.data,`fallback-bin`);return u(t,{throwIfNoEntry:!1})?.isDirectory()?{bin:e.bin,fallbackBin:t}:{bin:e.bin}}function appendFallbackBinToPath(e,t,n=process.platform){let r=isWindows(n)?`;`:`:`;return[...e?e.split(r).filter(e=>e!==t):[],t].join(r)}const ft=/^v?(\d+)\.(\d+)\.\d+(?:-[0-9A-Za-z.-]+)?$/;function supportsVitePlusDirs(e){if(pkgPrNewCommitSha(e))return!0;let t=e.match(ft);if(!t)return!0;let n=Number(t[1]),r=Number(t[2]);return n>0||r>=3}function createVitePlusDirsFile(){return g(C(),`setup-vp-dirs-${ie()}.txt`)}function removeVitePlusDirsFile(e){l(e,{force:!0})}function readVitePlusProbe(e){try{return s(e,`utf8`)}catch(e){if(e.code===`ENOENT`)return;throw e}}function resolveVitePlusBinDirs(e,t,n){if(!t){if(!supportsVitePlusDirs(e))return{bin:n};throw Error(`Vite+ was installed successfully, but setup-vp could not resolve its VpDirs.`)}let r=readVitePlusProbe(t),i=r===void 0?void 0:parseVitePlusDirs(r);if(i)return getVitePlusBinDirs(i);if(!(pkgPrNewCommitSha(e)!==void 0||ft.test(e))&&r!==void 0){let e=parseInstalledVpVersion(r);if(e!==`unknown`&&!supportsVitePlusDirs(e))return{bin:n}}throw Error(`Vite+ was installed successfully, but setup-vp could not resolve its VpDirs.`)}function parseVitePlusDirs(e){let t=/* @__PURE__ */ new Map;for(let n of e.split(/\r?\n/)){let e=n.indexOf(` `);if(e<1)continue;let r=n.slice(0,e).replace(/^\uFEFF/,``),i=n.slice(e+1).trim();i&&t.set(r,i)}let n=t.get(`data`),r=t.get(`bin`),i=t.get(`cache`),a=t.get(`config`),o=t.get(`state`);if(n&&r&&i&&a&&o)return{data:n,bin:r,cache:i,config:a,state:o}}function getInstallScriptCommand(e,t=process.platform,n=!0){if(isWindows(t))return n?{command:`pwsh`,args:[`-Command`,` $dirsFile = $env:${$} Set-Content -LiteralPath $dirsFile -Value '' -NoNewline -Encoding UTF8 diff --git a/src/azure/auth-env.test.ts b/src/azure/auth-env.test.ts index 526e22d..8925c38 100644 --- a/src/azure/auth-env.test.ts +++ b/src/azure/auth-env.test.ts @@ -139,4 +139,42 @@ describe("Azure authEnv", () => { expect(() => applyAuthEnv(env)).toThrow("duplicate credential name"); expect(env).toEqual({ SETUP_VP_AUTH_ENV_TOKEN: "one", SETUP_VP_AUTH_ENV_Token: "two" }); }); + + it.each(["TOKEN", "Token"])( + "rejects original duplicate names after Windows retains only %s", + (name) => { + const env = { + SETUP_VP_AUTH_ENV: JSON.stringify({ TOKEN: "$(FIRST)", Token: "$(SECOND)" }), + [`SETUP_VP_AUTH_ENV_${name}`]: "surviving-secret", + }; + const original = { ...env }; + + expect(() => applyAuthEnv(env)).toThrow('authEnv contains duplicate credential name "Token"'); + expect(env).toEqual(original); + }, + ); + + it("uses metadata only for names and removes it before passing credentials onward", () => { + const env = { + SETUP_VP_AUTH_ENV: JSON.stringify({ CUSTOM_TOKEN: "$(MY_SECRET)" }), + SETUP_VP_AUTH_ENV_CUSTOM_TOKEN: 'resolved "secret" with \\ and\nnewlines', + }; + + applyAuthEnv(env); + + expect(env).toEqual({ CUSTOM_TOKEN: 'resolved "secret" with \\ and\nnewlines' }); + }); + + it.each(["sensitive-value", "null", "[]", "42", '"sensitive-value"'])( + "rejects invalid metadata without exposing its contents (%s)", + (value) => { + const env = { SETUP_VP_AUTH_ENV: value, SETUP_VP_AUTH_ENV_CUSTOM_TOKEN: "secret" }; + const original = { ...env }; + + expect(() => applyAuthEnv(env)).toThrow( + new Error("Invalid authEnv metadata: expected a JSON object"), + ); + expect(env).toEqual(original); + }, + ); }); diff --git a/src/azure/auth-env.ts b/src/azure/auth-env.ts index 5bc0d87..3b68ce5 100644 --- a/src/azure/auth-env.ts +++ b/src/azure/auth-env.ts @@ -51,15 +51,23 @@ function isCredentialName(name: string): boolean { ); } -/** Decode auth mappings only after the task's shell and Node.js have started. */ -export function applyAuthEnv(env: RuntimeEnv): void { - const entries = Object.entries(env) - .filter(([key]) => key.toUpperCase().startsWith(AUTH_ENV_PREFIX)) - .map(([key, value]) => ({ key, name: key.slice(AUTH_ENV_PREFIX.length), value })); - const seenNames = new Set(); +function declaredAuthNames(value: string): string[] { + let declaration: unknown; + try { + declaration = JSON.parse(value); + } catch { + // JSON errors can contain secret values. Do not include parser diagnostics. + throw new Error("Invalid authEnv metadata: expected a JSON object"); + } + if (!declaration || typeof declaration !== "object" || Array.isArray(declaration)) { + throw new Error("Invalid authEnv metadata: expected a JSON object"); + } + return Object.keys(declaration); +} - // Validate every name before exposing any values to subprocesses or later steps. - for (const { name } of entries) { +function validateAuthNames(names: string[]): void { + const seenNames = new Set(); + for (const name of names) { const normalizedName = name.toUpperCase(); if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name) || !isCredentialName(normalizedName)) { throw new Error( @@ -71,7 +79,22 @@ export function applyAuthEnv(env: RuntimeEnv): void { } seenNames.add(normalizedName); } +} + +/** Decode auth mappings only after the task's shell and Node.js have started. */ +export function applyAuthEnv(env: RuntimeEnv): void { + const entries = Object.entries(env) + .filter(([key]) => key.toUpperCase().startsWith(AUTH_ENV_PREFIX)) + .map(([key, value]) => ({ key, name: key.slice(AUTH_ENV_PREFIX.length), value })); + + // Windows can collapse case variants before Node starts. Validate the original + // names as well as the surviving environment entries before changing anything. + if (env.SETUP_VP_AUTH_ENV !== undefined) { + validateAuthNames(declaredAuthNames(env.SETUP_VP_AUTH_ENV)); + } + validateAuthNames(entries.map(({ name }) => name)); + delete env.SETUP_VP_AUTH_ENV; for (const { key, name, value } of entries) { delete env[key]; // Azure leaves missing secret macros unchanged. Do not pass them as tokens. diff --git a/src/azure/parity.test.ts b/src/azure/parity.test.ts index 86f9afa..128dbd6 100644 --- a/src/azure/parity.test.ts +++ b/src/azure/parity.test.ts @@ -43,12 +43,17 @@ describe("Azure parity", () => { const { project, env, ports } = fixture(); const secret = 'token with "quotes", \\ and\nnewlines'; writeFileSync(path.join(project, ".npmrc"), "//registry.example/:_authToken=${CUSTOM_TOKEN}\n"); - const target: NodeJS.ProcessEnv = { ...env, SETUP_VP_AUTH_ENV_CUSTOM_TOKEN: secret }; + const target: NodeJS.ProcessEnv = { + ...env, + SETUP_VP_AUTH_ENV: JSON.stringify({ CUSTOM_TOKEN: "$(CUSTOM_TOKEN)" }), + SETUP_VP_AUTH_ENV_CUSTOM_TOKEN: secret, + }; ports.runInstall.mockImplementation((_entries, _project, _command, installEnv) => { const script = ` process.stdout.write(JSON.stringify({ token: process.env.CUSTOM_TOKEN, transport: process.env.SETUP_VP_AUTH_ENV_CUSTOM_TOKEN, + metadata: process.env.SETUP_VP_AUTH_ENV, })); `; const result = spawnSync(process.execPath, ["-e", script], { diff --git a/src/azure/template.test.ts b/src/azure/template.test.ts index e5f6613..53a7c1f 100644 --- a/src/azure/template.test.ts +++ b/src/azure/template.test.ts @@ -14,6 +14,7 @@ import { join } from "node:path"; import { fileURLToPath } from "node:url"; import { describe, expect, it } from "vite-plus/test"; import { parse as parseYaml } from "yaml"; +import { applyAuthEnv } from "./auth-env.js"; const templatePath = fileURLToPath(new URL("../../azure/setup-vp.yml", import.meta.url)); const template = readFileSync(templatePath, "utf8"); @@ -122,9 +123,47 @@ function renderAuthEnv( expect(value).toBe("${{ pair.value }}"); const format = key.match(/^\$\{\{ format\('([^']*)', pair\.key\) \}\}$/)?.[1]; expect(format).toBeDefined(); - return Object.fromEntries( + const rendered = Object.fromEntries( Object.entries(authEnv).map(([name, token]) => [format!.replaceAll("{0}", name), token]), ); + if (step.env.SETUP_VP_AUTH_ENV !== undefined) { + expect(step.env.SETUP_VP_AUTH_ENV).toBe( + "${{ replace(convertToJson(parameters.authEnv), '$', '\\u0024') }}", + ); + rendered.SETUP_VP_AUTH_ENV = JSON.stringify(authEnv).replaceAll("$", "\\u0024"); + } + return rendered; +} + +function runFinalize( + shell: Shell, + authEnv: Record, + directory: string, +): SpawnSyncReturns { + const runtimeDir = join(directory, "setup-vp-azure", "dist", "azure"); + mkdirSync(runtimeDir, { recursive: true }); + copyFileSync( + new URL("../../dist/azure/index.mjs", import.meta.url), + join(runtimeDir, "index.mjs"), + ); + const step = templateStep("finalize", shell); + const script = step[shell]!.replaceAll( + "$(SETUP_VP_BOOTSTRAP_NODE)", + () => process.execPath, + ).replaceAll("$(Agent.TempDirectory)", () => directory); + const scriptPath = join(directory, shell === "bash" ? "finalize.sh" : "finalize.ps1"); + // PowerShell@2's wrapper propagates the last native command's exit code. + writeFileSync(scriptPath, script + (shell === "powershell" ? "\nexit $LASTEXITCODE\n" : "")); + return runShellScript( + shell, + scriptPath, + { + SETUP_VP_RUN_INSTALL: "false", + SETUP_VP_TEST_MARKER: join(directory, "injected"), + ...renderAuthEnv(step, authEnv), + }, + directory, + ); } function assertFinalizeRejectsAuthEnv(shell: Shell, name: "NODE_OPTIONS" | "BASH_ENV"): void { @@ -132,12 +171,6 @@ function assertFinalizeRejectsAuthEnv(shell: Shell, name: "NODE_OPTIONS" | "BASH const marker = join(directory, "injected"); const hook = join(directory, "hook.sh"); try { - const runtimeDir = join(directory, "setup-vp-azure", "dist", "azure"); - mkdirSync(runtimeDir, { recursive: true }); - copyFileSync( - new URL("../../dist/azure/index.mjs", import.meta.url), - join(runtimeDir, "index.mjs"), - ); writeFileSync(hook, 'printf injected > "$SETUP_VP_TEST_MARKER"\n'); const preload = ` import { writeFileSync } from "node:fs"; @@ -149,24 +182,7 @@ function assertFinalizeRejectsAuthEnv(shell: Shell, name: "NODE_OPTIONS" | "BASH ? hook : `--import=data:text/javascript;base64,${Buffer.from(preload).toString("base64")}`, }; - const step = templateStep("finalize", shell); - const script = step[shell]!.replaceAll( - "$(SETUP_VP_BOOTSTRAP_NODE)", - () => process.execPath, - ).replaceAll("$(Agent.TempDirectory)", () => directory); - const scriptPath = join(directory, shell === "bash" ? "finalize.sh" : "finalize.ps1"); - // PowerShell@2's wrapper propagates the last native command's exit code. - writeFileSync(scriptPath, script + (shell === "powershell" ? "\nexit $LASTEXITCODE\n" : "")); - const result = runShellScript( - shell, - scriptPath, - { - SETUP_VP_RUN_INSTALL: "false", - SETUP_VP_TEST_MARKER: marker, - ...renderAuthEnv(step, authEnv), - }, - directory, - ); + const result = runFinalize(shell, authEnv, directory); expect(result.error).toBeUndefined(); expect(existsSync(marker)).toBe(false); expect(result.status).not.toBe(0); @@ -243,12 +259,34 @@ describe("azure/setup-vp.yml", () => { (shell) => { const authEnv = { NODE_OPTIONS: "--require=./hook.cjs", CUSTOM_TOKEN: "$(CUSTOM_TOKEN)" }; expect(renderAuthEnv(templateStep("finalize", shell), authEnv)).toEqual({ + SETUP_VP_AUTH_ENV: + '{"NODE_OPTIONS":"--require=./hook.cjs","CUSTOM_TOKEN":"\\u0024(CUSTOM_TOKEN)"}', SETUP_VP_AUTH_ENV_NODE_OPTIONS: "--require=./hook.cjs", SETUP_VP_AUTH_ENV_CUSTOM_TOKEN: "$(CUSTOM_TOKEN)", }); }, ); + it.each(["bash", "powershell"] as const)( + "keeps %s metadata valid when Azure expands a secret containing JSON syntax", + (shell) => { + const secret = 'quotes " and \\ and\nnewlines $(literal)'; + const taskEnv = renderAuthEnv(templateStep("finalize", shell), { + CUSTOM_TOKEN: "$(MY_SECRET)", + }); + const expanded = Object.fromEntries( + Object.entries(taskEnv).map(([key, value]) => [ + key, + value.replaceAll("$(MY_SECRET)", () => secret), + ]), + ); + + expect(JSON.parse(expanded.SETUP_VP_AUTH_ENV!)).toEqual({ CUSTOM_TOKEN: "$(MY_SECRET)" }); + applyAuthEnv(expanded); + expect(expanded).toEqual({ CUSTOM_TOKEN: secret }); + }, + ); + it("selects the agent shell at runtime", () => { expect(template).not.toContain("${{ if eq(variables['Agent.OS'], 'Windows_NT') }}"); expect(template).not.toContain("${{ if ne(variables['Agent.OS'], 'Windows_NT') }}"); @@ -273,6 +311,17 @@ describe("azure/setup-vp.yml", () => { describe.skipIf((shell === "powershell") !== (process.platform === "win32"))( `${shell} finalize`, () => { + it("rejects case-insensitive duplicate names through the native task environment", () => { + const directory = realpathSync(mkdtempSync(join(tmpdir(), "setup-vp-azure-auth-env-"))); + try { + const result = runFinalize(shell, { TOKEN: "one", Token: "two" }, directory); + expect(result.error).toBeUndefined(); + expect(result.status).not.toBe(0); + expect(result.stderr).toContain('authEnv contains duplicate credential name "Token"'); + } finally { + rmSync(directory, { recursive: true, force: true }); + } + }); it.each(["NODE_OPTIONS", "BASH_ENV"] as const)( "rejects %s without executing startup code", (name) => assertFinalizeRejectsAuthEnv(shell, name),