Repository navigation
fix(core): accept a host-supplied Maestro scope root #2404
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
862606e
bd5a3aa
494da75
2180cb5
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -2,6 +2,8 @@ import fs from 'fs'; | |
| import path from 'path'; | ||
| import { ServerError } from './server.js'; | ||
|
|
||
| const MAX_SEARCH_DEPTH = 15; | ||
|
|
||
| // Root under which BrowserStack App Automate hosts write Maestro session | ||
| // artifacts (screenshots + debug output). Historically /tmp; hosts that | ||
| // relocate it inject PERCY_APP_AUTOMATE_TMP_DIR with the new root, so the | ||
|
|
@@ -15,31 +17,42 @@ export function appAutomateTmpDir() { | |
| return path.isAbsolute(dir) ? dir : '/tmp'; | ||
| } | ||
|
|
||
| export function bsScopeRootOverride() { | ||
| let raw = process.env.PERCY_MAESTRO_BS_SCOPE_ROOT; | ||
| if (!raw) return null; | ||
| let dir = raw.replace(/[/\\]+$/, ''); | ||
| return path.isAbsolute(dir) ? dir : null; | ||
| } | ||
|
|
||
| /* istanbul ignore next — defensive manual directory walker invoked only when | ||
| fast-glob import fails (broken install / FS corruption). Unit tests | ||
| exercise the primary glob path; integration tests on BS hosts exercise | ||
| the walker against real session layouts. Path-traversal sinks inside this | ||
| function are suppressed at file level in .semgrepignore with the same | ||
| rationale (upstream SAFE_ID validation, depth cap, exact filename match). */ | ||
| async function manualScreenshotWalk(platform, sessionId, name) { | ||
| async function manualScreenshotWalk(platform, sessionId, name, scopeRoot) { | ||
| const files = []; | ||
| try { | ||
| if (platform === 'ios') { | ||
| const sessionDir = `${appAutomateTmpDir()}/${sessionId}`; | ||
| const walk = async (dir, depth) => { | ||
| if (depth > 15) return; // sanity cap | ||
| let entries; | ||
| try { entries = await fs.promises.readdir(dir, { withFileTypes: true }); } catch { return; } | ||
| for (const entry of entries) { | ||
| const full = path.join(dir, entry.name); | ||
| if (entry.isDirectory()) { | ||
| await walk(full, depth + 1); | ||
| } else if (entry.isFile() && entry.name === `${name}.png` && full.includes('_maestro_debug_')) { | ||
| files.push(full); | ||
| } | ||
| const walkFrom = async (root, accept) => { | ||
| const walk = async (dir, depth) => { | ||
| if (depth > MAX_SEARCH_DEPTH) return; | ||
| let entries; | ||
| try { entries = await fs.promises.readdir(dir, { withFileTypes: true }); } catch { return; } | ||
| for (const entry of entries) { | ||
| const full = path.join(dir, entry.name); | ||
| if (entry.isDirectory()) { | ||
| await walk(full, depth + 1); | ||
| } else if (entry.isFile() && entry.name === `${name}.png` && accept(full)) { | ||
| files.push(full); | ||
| } | ||
| }; | ||
| await walk(sessionDir, 0); | ||
| } | ||
| }; | ||
| await walk(root, 0); | ||
| }; | ||
| try { | ||
| if (scopeRoot) { | ||
| await walkFrom(scopeRoot, () => true); | ||
| } else if (platform === 'ios') { | ||
| await walkFrom(`${appAutomateTmpDir()}/${sessionId}`, full => full.includes('_maestro_debug_')); | ||
| } else { | ||
| const baseDir = `${appAutomateTmpDir()}/${sessionId}_test_suite/logs`; | ||
| const logDirs = await fs.promises.readdir(baseDir); | ||
|
|
@@ -66,7 +79,8 @@ async function manualScreenshotWalk(platform, sessionId, name) { | |
| // ServerError(404) when the file is missing or resolves outside scopeRoot. | ||
| // Callers pass `filePath` already shape-validated, plus the resolved `scopeRoot` | ||
| // and `selfHosted` flag. | ||
| export async function locateScreenshot({ platform, sessionId, name, filePath, scopeRoot, selfHosted }) { | ||
| export async function locateScreenshot({ platform, sessionId, name, filePath, scopeRoot, selfHosted, recursiveScope }) { | ||
| let scopedGlob = selfHosted || !!recursiveScope; | ||
| let chosenFile; | ||
| if (filePath) { | ||
| chosenFile = filePath; | ||
|
|
@@ -81,7 +95,7 @@ export async function locateScreenshot({ platform, sessionId, name, filePath, sc | |
| // (scopeRoot = PERCY_MAESTRO_SCREENSHOT_DIR). `name` is SAFE_ID-validated | ||
| // by the caller, so it cannot contain separators or traversal chars. | ||
| let searchPattern; | ||
| if (selfHosted) { | ||
| if (scopedGlob) { | ||
| // fast-glob requires forward-slashes in patterns on every platform; on | ||
| // Windows scopeRoot contains backslashes, so normalize before embedding. | ||
| // Production-code Windows portability — verified by the CI Windows runner. | ||
|
|
@@ -99,19 +113,17 @@ export async function locateScreenshot({ platform, sessionId, name, filePath, sc | |
| let files; | ||
| try { | ||
| let { default: glob } = await import('fast-glob'); | ||
| // Self-hosted needs `dot: true` because Maestro's default output dir is | ||
| // `.maestro/` — a dot-prefixed entry fast-glob hides by default. BS | ||
| // layouts have no dot-prefixed segments, so omitting it there keeps the | ||
| // byte-identical behavior. | ||
| files = await glob(searchPattern, selfHosted ? { dot: true } : undefined); | ||
| files = await glob(searchPattern, scopedGlob ? { dot: true, deep: MAX_SEARCH_DEPTH } : undefined); | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Low] Two small follow-ups on the new cap — the original "unbounded glob" finding is resolved by this line.
Suggestion: use Reviewer: stack-code-reviewer |
||
| } catch { | ||
| // Fast-glob import / glob call failed — fall back to manual walker (BS | ||
| // only; self-hosted has no fixed-layout convention, so empty → 404 with | ||
| // the actionable PERCY_MAESTRO_SCREENSHOT_DIR guidance from the caller). | ||
| // See manualScreenshotWalk() at file top + the file-level .semgrepignore. | ||
| /* istanbul ignore next — only fires when fast-glob import throws | ||
| (broken install / FS corruption); integration-test territory. */ | ||
| files = selfHosted ? [] : await manualScreenshotWalk(platform, sessionId, name); | ||
| files = selfHosted | ||
| ? [] | ||
| : await manualScreenshotWalk(platform, sessionId, name, recursiveScope ? scopeRoot : null); | ||
| } | ||
|
|
||
| if (!files || files.length === 0) { | ||
|
|
@@ -154,7 +166,10 @@ export async function locateScreenshot({ platform, sessionId, name, filePath, sc | |
| const realPathFwd = realPath.replace(/\\/g, '/'); | ||
| const realPrefixFwd = realPrefix.replace(/\\/g, '/'); | ||
| if (!realPathFwd.startsWith(`${realPrefixFwd}/`)) { | ||
| throw new ServerError(404, `Screenshot not found: ${name}.png (resolved outside ${selfHosted ? 'PERCY_MAESTRO_SCREENSHOT_DIR' : 'session dir'})`); | ||
| const scopeLabel = selfHosted | ||
| ? 'PERCY_MAESTRO_SCREENSHOT_DIR' | ||
| : recursiveScope ? 'PERCY_MAESTRO_BS_SCOPE_ROOT' : 'session dir'; | ||
| throw new ServerError(404, `Screenshot not found: ${name}.png (resolved outside ${scopeLabel})`); | ||
| } | ||
|
|
||
| return realPath; | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -4,7 +4,7 @@ import { normalize } from '@percy/config/utils'; | |
| import { ServerError } from './server.js'; | ||
| import { encodeURLSearchParams } from './utils.js'; | ||
| import { handleSyncJob } from './snapshot.js'; | ||
| import { locateScreenshot, appAutomateTmpDir } from './maestro-screenshot-file.js'; | ||
| import { locateScreenshot, appAutomateTmpDir, bsScopeRootOverride } from './maestro-screenshot-file.js'; | ||
| import { validateRegionInputs, resolveRegions } from './maestro-regions.js'; | ||
| import { deriveDeviceInsets } from './maestro-hierarchy.js'; | ||
|
|
||
|
|
@@ -98,6 +98,7 @@ export async function handleMaestroScreenshot(req, res, percy) { | |
| // realpath + prefix check inside locateScreenshot enforces the security | ||
| // invariant at whichever root applies; the boundary is relocated, not removed. | ||
| let scopeRoot; | ||
| let recursiveScope = false; | ||
| if (selfHosted) { | ||
| // Reject filePath outright in self-hosted mode. The SDK never emits it (it | ||
| // sends a relative SCREENSHOT_NAME); honoring an absolute filePath against | ||
|
|
@@ -124,10 +125,21 @@ export async function handleMaestroScreenshot(req, res, percy) { | |
| throw new ServerError(400, `PERCY_MAESTRO_SCREENSHOT_DIR is not an existing directory: ${dir}`); | ||
| } | ||
| scopeRoot = dir; | ||
| recursiveScope = true; | ||
| } else { | ||
| scopeRoot = platform === 'ios' | ||
| ? `${appAutomateTmpDir()}/${sessionId}` | ||
| : `${appAutomateTmpDir()}/${sessionId}_test_suite`; | ||
| let overrideRoot = bsScopeRootOverride(); | ||
| if (overrideRoot) { | ||
| recursiveScope = true; | ||
| let sessionScoped = `${overrideRoot}/${sessionId}`; | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Low] New This Functionally this is fine: the Suggestion: one line mirroring the existing comment at Reviewer: stack-code-reviewer |
||
| let sessionStat; | ||
| try { sessionStat = await fs.promises.stat(sessionScoped); } catch { sessionStat = null; } | ||
| scopeRoot = sessionStat?.isDirectory() ? sessionScoped : overrideRoot; | ||
| percy.log.debug(`maestro screenshot scope root: ${scopeRoot}`); | ||
| } else { | ||
| scopeRoot = platform === 'ios' | ||
| ? `${appAutomateTmpDir()}/${sessionId}` | ||
| : `${appAutomateTmpDir()}/${sessionId}_test_suite`; | ||
| } | ||
| } | ||
|
|
||
| // Validate regions input shape early (before file I/O and ADB work) so | ||
|
|
@@ -137,7 +149,7 @@ export async function handleMaestroScreenshot(req, res, percy) { | |
| // Locate the screenshot on disk (supplied filePath, BS session glob, or | ||
| // self-hosted PERCY_MAESTRO_SCREENSHOT_DIR recursive glob) and confirm it | ||
| // resolves under scopeRoot. Throws ServerError(404) when missing/out-of-root. | ||
| let realPath = await locateScreenshot({ platform, sessionId, name, filePath: suppliedFilePath, scopeRoot, selfHosted }); | ||
| let realPath = await locateScreenshot({ platform, sessionId, name, filePath: suppliedFilePath, scopeRoot, selfHosted, recursiveScope }); | ||
|
|
||
| // Read and base64-encode the screenshot | ||
| let fileContent = await fs.promises.readFile(realPath); | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.