mirror of
https://github.com/actions/toolkit.git
synced 2026-08-17 00:00:22 +02:00
@actions/glob: extend hashFiles options (#2357)
* @actions/glob: extend hashFiles options * improve hashFiles symlink handling * Improve error handling and messaging in hashFiles function * apply relative exclude patterns across all roots and use named minimatch import * format error message
This commit is contained in:
@@ -37,5 +37,5 @@ export async function hashFiles(
|
||||
followSymbolicLinks = options.followSymbolicLinks
|
||||
}
|
||||
const globber = await create(patterns, {followSymbolicLinks})
|
||||
return _hashFiles(globber, currentWorkspace, verbose)
|
||||
return _hashFiles(globber, currentWorkspace, options, verbose)
|
||||
}
|
||||
|
||||
@@ -9,4 +9,27 @@ export interface HashFileOptions {
|
||||
* @default true
|
||||
*/
|
||||
followSymbolicLinks?: boolean
|
||||
|
||||
/**
|
||||
* Array of allowed root directories. Only files that resolve under one of
|
||||
* these roots will be included in the hash.
|
||||
*
|
||||
* @default [GITHUB_WORKSPACE]
|
||||
*/
|
||||
roots?: string[]
|
||||
|
||||
/**
|
||||
* Indicates whether files outside the allowed roots should be included.
|
||||
* If false, outside-root files are skipped with a warning.
|
||||
*
|
||||
* @default false
|
||||
*/
|
||||
allowFilesOutsideWorkspace?: boolean
|
||||
|
||||
/**
|
||||
* Array of glob patterns for files to exclude from hashing.
|
||||
*
|
||||
* @default []
|
||||
*/
|
||||
exclude?: string[]
|
||||
}
|
||||
|
||||
@@ -4,41 +4,224 @@ import * as fs from 'fs'
|
||||
import * as stream from 'stream'
|
||||
import * as util from 'util'
|
||||
import * as path from 'path'
|
||||
import {Minimatch, type MinimatchOptions} from 'minimatch'
|
||||
import {Globber} from './glob.js'
|
||||
import {HashFileOptions} from './internal-hash-file-options.js'
|
||||
|
||||
const IS_WINDOWS = process.platform === 'win32'
|
||||
const MAX_WARNED_FILES = 10
|
||||
|
||||
const MINIMATCH_OPTIONS: MinimatchOptions = {
|
||||
dot: true,
|
||||
nobrace: true,
|
||||
nocase: IS_WINDOWS,
|
||||
nocomment: true,
|
||||
noext: true,
|
||||
nonegate: true
|
||||
}
|
||||
|
||||
type ExcludeMatcher = {
|
||||
absolutePathMatcher: Minimatch
|
||||
relativePathMatcher: Minimatch
|
||||
}
|
||||
|
||||
type OutsideRootFile = {
|
||||
matched: string
|
||||
resolved: string
|
||||
}
|
||||
|
||||
// Checks if resolvedFile is inside any of resolvedRoots.
|
||||
function isInResolvedRoots(
|
||||
resolvedFile: string,
|
||||
resolvedRoots: string[]
|
||||
): boolean {
|
||||
const normalizedFile = IS_WINDOWS ? resolvedFile.toLowerCase() : resolvedFile
|
||||
return resolvedRoots.some(root => {
|
||||
const normalizedRoot = IS_WINDOWS ? root.toLowerCase() : root
|
||||
if (normalizedFile === normalizedRoot) return true
|
||||
const rel = path.relative(normalizedRoot, normalizedFile)
|
||||
return (
|
||||
!path.isAbsolute(rel) && rel !== '..' && !rel.startsWith(`..${path.sep}`)
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
function normalizeForMatch(p: string): string {
|
||||
return p.split(path.sep).join('/')
|
||||
}
|
||||
|
||||
function buildExcludeMatchers(excludePatterns: string[]): ExcludeMatcher[] {
|
||||
return excludePatterns.map(pattern => {
|
||||
const normalizedPattern = normalizeForMatch(pattern)
|
||||
// basename-only pattern (no "/") uses matchBase so "*.log" matches anywhere
|
||||
const isBasenamePattern = !normalizedPattern.includes('/')
|
||||
return {
|
||||
absolutePathMatcher: new Minimatch(normalizedPattern, {
|
||||
...MINIMATCH_OPTIONS,
|
||||
matchBase: false
|
||||
}),
|
||||
relativePathMatcher: new Minimatch(normalizedPattern, {
|
||||
...MINIMATCH_OPTIONS,
|
||||
matchBase: isBasenamePattern
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function isExcluded(
|
||||
resolvedFile: string,
|
||||
excludeMatchers: ExcludeMatcher[],
|
||||
rootsForRelativeMatch: string[]
|
||||
): boolean {
|
||||
if (excludeMatchers.length === 0) return false
|
||||
const absolutePath = path.resolve(resolvedFile)
|
||||
const absolutePathForMatch = normalizeForMatch(absolutePath)
|
||||
// Match relative patterns against every allowed root (and the workspace).
|
||||
const relativePathsForMatch = rootsForRelativeMatch.map(root =>
|
||||
normalizeForMatch(path.relative(root, absolutePath))
|
||||
)
|
||||
return excludeMatchers.some(
|
||||
m =>
|
||||
m.absolutePathMatcher.match(absolutePathForMatch) ||
|
||||
relativePathsForMatch.some(rel => m.relativePathMatcher.match(rel))
|
||||
)
|
||||
}
|
||||
|
||||
export async function hashFiles(
|
||||
globber: Globber,
|
||||
currentWorkspace: string,
|
||||
options?: HashFileOptions,
|
||||
verbose: Boolean = false
|
||||
): Promise<string> {
|
||||
const writeDelegate = verbose ? core.info : core.debug
|
||||
let hasMatch = false
|
||||
const githubWorkspace = currentWorkspace
|
||||
? currentWorkspace
|
||||
: (process.env['GITHUB_WORKSPACE'] ?? process.cwd())
|
||||
|
||||
// Resolve the workspace so workspace-relative exclude matching is consistent.
|
||||
// This avoids mismatches when resolvedFile is a realpath but the workspace path contains symlinks.
|
||||
let resolvedWorkspace = githubWorkspace
|
||||
try {
|
||||
resolvedWorkspace = fs.realpathSync(githubWorkspace)
|
||||
} catch (err) {
|
||||
writeDelegate(
|
||||
`Could not resolve workspace '${githubWorkspace}', falling back to original path. Details: ${err.message}`
|
||||
)
|
||||
}
|
||||
|
||||
const allowOutside = options?.allowFilesOutsideWorkspace ?? false
|
||||
const excludeMatchers = buildExcludeMatchers(options?.exclude ?? [])
|
||||
|
||||
// Resolve roots up front; warn and skip any that fail to resolve.
|
||||
// If allowFilesOutsideWorkspace is not enabled, roots are restricted to the resolved workspace.
|
||||
const resolvedRootsSet = new Set<string>()
|
||||
const roots = options?.roots ?? [resolvedWorkspace]
|
||||
|
||||
for (const root of roots) {
|
||||
try {
|
||||
const resolvedRoot =
|
||||
root === resolvedWorkspace ? root : fs.realpathSync(root)
|
||||
|
||||
if (
|
||||
!allowOutside &&
|
||||
!isInResolvedRoots(resolvedRoot, [resolvedWorkspace])
|
||||
) {
|
||||
writeDelegate(`Skipping root outside workspace: ${resolvedRoot}`)
|
||||
continue
|
||||
}
|
||||
|
||||
resolvedRootsSet.add(resolvedRoot)
|
||||
} catch (err) {
|
||||
writeDelegate(
|
||||
`Skipping unresolved root '${root}'. Details: ${err.message}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const resolvedRoots = Array.from(resolvedRootsSet)
|
||||
if (resolvedRoots.length === 0) {
|
||||
core.warning(
|
||||
`Could not resolve any allowed root(s); no files will be considered for hashing.`
|
||||
)
|
||||
return ''
|
||||
}
|
||||
|
||||
// Workspace + every allowed root, used to evaluate relative exclude patterns.
|
||||
const rootsForRelativeMatch = Array.from(
|
||||
new Set([resolvedWorkspace, ...resolvedRoots])
|
||||
)
|
||||
|
||||
const outsideRootFiles: OutsideRootFile[] = []
|
||||
const result = crypto.createHash('sha256')
|
||||
const pipeline = util.promisify(stream.pipeline)
|
||||
let hasMatch = false
|
||||
let count = 0
|
||||
|
||||
for await (const file of globber.globGenerator()) {
|
||||
writeDelegate(file)
|
||||
if (!file.startsWith(`${githubWorkspace}${path.sep}`)) {
|
||||
writeDelegate(`Ignore '${file}' since it is not under GITHUB_WORKSPACE.`)
|
||||
|
||||
// Resolve real path of the file for symlink-safe exclude + root checking
|
||||
let resolvedFile: string
|
||||
try {
|
||||
resolvedFile = fs.realpathSync(file)
|
||||
} catch (err) {
|
||||
core.warning(
|
||||
`Could not read "${file}". Please check symlinks and file access. Details: ${err.message}`
|
||||
)
|
||||
continue
|
||||
}
|
||||
if (fs.statSync(file).isDirectory()) {
|
||||
|
||||
// Exclude matching patterns (apply to resolved path for symlink-safety)
|
||||
if (isExcluded(resolvedFile, excludeMatchers, rootsForRelativeMatch)) {
|
||||
writeDelegate(`Exclude '${file}' (exclude pattern match).`)
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if in resolved roots
|
||||
if (!isInResolvedRoots(resolvedFile, resolvedRoots)) {
|
||||
outsideRootFiles.push({matched: file, resolved: resolvedFile})
|
||||
if (allowOutside) {
|
||||
writeDelegate(
|
||||
`Including '${file}' since it is outside the allowed root(s) and 'allowFilesOutsideWorkspace' is enabled.`
|
||||
)
|
||||
} else {
|
||||
writeDelegate(`Skip '${file}' since it is not under allowed root(s).`)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if (fs.statSync(resolvedFile).isDirectory()) {
|
||||
writeDelegate(`Skip directory '${file}'.`)
|
||||
continue
|
||||
}
|
||||
|
||||
const hash = crypto.createHash('sha256')
|
||||
const pipeline = util.promisify(stream.pipeline)
|
||||
await pipeline(fs.createReadStream(file), hash)
|
||||
await pipeline(fs.createReadStream(resolvedFile), hash)
|
||||
result.write(hash.digest())
|
||||
count++
|
||||
if (!hasMatch) {
|
||||
hasMatch = true
|
||||
}
|
||||
hasMatch = true
|
||||
}
|
||||
result.end()
|
||||
|
||||
// Warn if any files outside root were found without opt-in.
|
||||
if (!allowOutside && outsideRootFiles.length > 0) {
|
||||
const shown = outsideRootFiles.slice(0, MAX_WARNED_FILES)
|
||||
const remaining = outsideRootFiles.length - shown.length
|
||||
const fileList = shown
|
||||
.map(f => `- ${f.matched} -> ${f.resolved}`)
|
||||
.join('\n')
|
||||
|
||||
const suffix =
|
||||
remaining > 0
|
||||
? `\n ...and ${remaining} more file(s). Enable debug logging to see all.`
|
||||
: ''
|
||||
|
||||
core.warning(
|
||||
`Some matched files are outside the allowed root(s) and were skipped:\n${fileList}${suffix}\n` +
|
||||
`To include them, set 'allowFilesOutsideWorkspace: true' in your options.`
|
||||
)
|
||||
}
|
||||
|
||||
if (hasMatch) {
|
||||
writeDelegate(`Found ${count} files to hash.`)
|
||||
return result.digest('hex')
|
||||
|
||||
Reference in New Issue
Block a user