Files
cypress/packages/server/lib/util/fs.ts
T
Yashodhan e8a7c3f5f8 fix: prevent overwriting of video files (#30673)
* fix: use getPath to prevent video file overwrite

Signed-off-by: Yashodhan Joshi <yjdoc2@gmail.com>

* chore: update changelog

Signed-off-by: Yashodhan Joshi <yjdoc2@gmail.com>

* test: add system e2e for the retain videos fix

Signed-off-by: Yashodhan Joshi <yjdoc2@gmail.com>

* Update cli/CHANGELOG.md

Co-authored-by: Mike McCready <66998419+MikeMcC399@users.noreply.github.com>

* Update types for screenshots to be in util/fs

* Fix changelog entry placement

* fix extension type

* more types fixes

* fix: add required field in getPath call to satisfy ts

Signed-off-by: Yashodhan Joshi <yjdoc2@gmail.com>

* fix: sync Data interface from develop branch

Signed-off-by: Yashodhan Joshi <yjdoc2@gmail.com>

* fix: update SavedDetails type to better definition

Signed-off-by: Yashodhan Joshi <yjdoc2@gmail.com>

* update changelog

* break out type import into unique line to allow mksnapshot to work

* fix: minor comment fixes

Signed-off-by: Yashodhan Joshi <yjdoc2@gmail.com>

* fix: change videoPath fn signature as per comment

Signed-off-by: Yashodhan Joshi <yjdoc2@gmail.com>

* fix: convert the test to async/await

Signed-off-by: Yashodhan Joshi <yjdoc2@gmail.com>

* Update CHANGELOG.md

---------

Signed-off-by: Yashodhan Joshi <yjdoc2@gmail.com>
Co-authored-by: Jennifer Shehane <jennifer@cypress.io>
Co-authored-by: Jennifer Shehane <shehane.jennifer@gmail.com>
Co-authored-by: Mike McCready <66998419+MikeMcC399@users.noreply.github.com>
Co-authored-by: AtofStryker <bglesias@gmail.com>
2025-04-29 10:01:48 -04:00

143 lines
4.3 KiB
TypeScript

/* eslint-disable no-console */
import Bluebird from 'bluebird'
import fsExtra from 'fs-extra'
import sanitize from 'sanitize-filename'
import path from 'path'
import _ from 'lodash'
const RUNNABLE_SEPARATOR = ' -- '
const pathSeparatorRe = /[\\\/]/g
// many filesystems limit filename length to 255 bytes/characters, so truncate the filename to
// the smallest common denominator of safe filenames, which is 255 bytes. when ENAMETOOLONG
// errors are encountered, `maxSafeBytes` will be decremented to at most `MIN_PREFIX_BYTES`, at
// which point the latest ENAMETOOLONG error will be emitted.
// @see https://en.wikipedia.org/wiki/Comparison_of_file_systems#Limits
let maxSafeBytes = Number(process.env.CYPRESS_MAX_SAFE_FILENAME_BYTES) || 254
const MIN_PREFIX_BYTES = 64
type Promisified<T extends (...args: any) => any>
= (...params: Parameters<T>) => Bluebird<ReturnType<T>>
interface PromisifiedFsExtra {
statAsync: (path: string | Buffer) => Bluebird<ReturnType<typeof fsExtra.statSync>>
removeAsync: Promisified<typeof fsExtra.removeSync>
readFileAsync: Promisified<typeof fsExtra.readFileSync>
writeFileAsync: Promisified<typeof fsExtra.writeFileSync>
pathExistsAsync: Promisified<typeof fsExtra.pathExistsSync>
outputFileAsync: Promisified<typeof fsExtra.outputFileSync>
}
interface Clip {
x: number
y: number
width: number
height: number
}
export type ScreenshotsFolder = string | false | undefined
// TODO: This is likely not representative of the entire Type and should be updated
export interface Data {
specName: string
name: string
startTime: Date
viewport: {
width: number
height: number
}
titles?: string[]
testFailure?: boolean
overwrite?: boolean
simple?: boolean
current?: number
total?: number
testAttemptIndex?: number
appOnly?: boolean
hideRunnerUi?: boolean
clip?: Clip
userClip?: Clip
}
export const fs = Bluebird.promisifyAll(fsExtra) as PromisifiedFsExtra & typeof fsExtra
const ensureSafePath = async function (withoutExt: string, extension: string | null, overwrite: boolean | undefined, num: number = 0): Promise<string> {
const suffix = `${(num && !overwrite) ? ` (${num})` : ''}.${extension}`
const maxSafePrefixBytes = maxSafeBytes - suffix.length
const filenameBuf = Buffer.from(path.basename(withoutExt))
if (filenameBuf.byteLength > maxSafePrefixBytes) {
const truncated = filenameBuf.slice(0, maxSafePrefixBytes).toString()
withoutExt = path.join(path.dirname(withoutExt), truncated)
}
const fullPath = [withoutExt, suffix].join('')
return fs.pathExists(fullPath)
.then((found) => {
if (found && !overwrite) {
return ensureSafePath(withoutExt, extension, overwrite, num + 1)
}
// path does not exist, attempt to create it to check for an ENAMETOOLONG error
return fs.outputFileAsync(fullPath, '')
.then(() => fullPath)
.catch((err) => {
if (err.code === 'ENAMETOOLONG' && maxSafePrefixBytes >= MIN_PREFIX_BYTES) {
maxSafeBytes -= 1
return ensureSafePath(withoutExt, extension, overwrite, num)
}
throw err
})
})
}
const sanitizeToString = (title: any, idx: number, arr: Array<string>) => {
// test titles may be values which aren't strings like
// null or undefined - so convert before trying to sanitize
return sanitize(_.toString(title))
}
export const getPath = async function (data: Data, ext: string | null, screenshotsFolder: ScreenshotsFolder, overwrite: boolean | undefined): Promise<string> {
let names
const specNames = (data.specName || '')
.split(pathSeparatorRe)
if (data.name) {
names = data.name.split(pathSeparatorRe).map(sanitizeToString)
} else {
// we put this in array so to match with type of the if branch above
names = [_
.chain(data.titles)
.map(sanitizeToString)
.join(RUNNABLE_SEPARATOR)
.value()]
}
const index = names.length - 1
// append '(failed)' to the last name
if (data.testFailure) {
names[index] = `${names[index]} (failed)`
}
if (data.testAttemptIndex && data.testAttemptIndex > 0) {
names[index] = `${names[index]} (attempt ${data.testAttemptIndex + 1})`
}
let withoutExt
if (screenshotsFolder) {
withoutExt = path.join(screenshotsFolder, ...specNames, ...names)
} else {
withoutExt = path.join(...specNames, ...names)
}
return await ensureSafePath(withoutExt, ext, overwrite)
}