Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions packages/glob/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,36 @@ for await (const file of globber.globGenerator()) {
}
```

## Hashing files (`hashFiles`)

`hashFiles` computes a deterministic hash of files matched by glob patterns.
Comment thread
priyagupta108 marked this conversation as resolved.
Outdated

By default, only files under the workspace (`GITHUB_WORKSPACE`) are eligible to be hashed.

To improve security, file eligibility is evaluated using each file's resolved (real) path to prevent symbolic link traversal outside the allowed root path(s).

### Options

- `roots?: string[]` — Allowlist of root paths. Only files that resolve under (or equal) one of these roots are hashed. Defaults to `[GITHUB_WORKSPACE]` (or `currentWorkspace` if provided).
- `allowFilesOutsideWorkspace?: boolean` — Explicit opt-in to include files outside the specified root path(s). Defaults to `false`.
- `exclude?: string[]` — Glob patterns to exclude from hashing. Defaults to `[]`.

If files match your patterns but are outside the allowed roots and `allowFilesOutsideWorkspace` is not enabled, those files are skipped and a warning is emitted. If no eligible files remain after filtering, `hashFiles` returns an empty string (`''`).

### Example

```js
const glob = require('@actions/glob')

const hash = await glob.hashFiles('**/*.json', process.env.GITHUB_WORKSPACE || '', {
roots: [process.env.GITHUB_WORKSPACE, process.env.GITHUB_ACTION_PATH].filter(Boolean),
allowFilesOutsideWorkspace: true,
exclude: ['**/node_modules/**']
})

console.log(hash)
```

## Patterns

### Glob behavior
Expand Down
108 changes: 108 additions & 0 deletions packages/glob/__tests__/hash-files.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,114 @@ describe('globber', () => {
'4e911ea5824830b6a3ec096c7833d5af8381c189ffaa825c3503a5333a73eadc'
)
})

it('hashes files in allowed roots only', async () => {
const root = path.join(getTestTemp(), 'roots-hashfiles')
const dir1 = path.join(root, 'dir1')
const dir2 = path.join(root, 'dir2')
await fs.mkdir(dir1, {recursive: true})
await fs.mkdir(dir2, {recursive: true})
await fs.writeFile(path.join(dir1, 'file1.txt'), 'test 1 file content')
await fs.writeFile(path.join(dir2, 'file2.txt'), 'test 2 file content')

const broadPattern = `${root}/**`

const hashDir1Only = await hashFiles(broadPattern, '', {roots: [dir1]})
expect(hashDir1Only).not.toEqual('')

const hashDir2Only = await hashFiles(broadPattern, '', {roots: [dir2]})
expect(hashDir2Only).not.toEqual('')

expect(hashDir1Only).not.toEqual(hashDir2Only)

const hashBoth = await hashFiles(broadPattern, '', {roots: [dir1, dir2]})
expect(hashBoth).not.toEqual(hashDir1Only)
expect(hashBoth).not.toEqual(hashDir2Only)

const hashDir1Again = await hashFiles(broadPattern, '', {roots: [dir1]})
expect(hashDir1Again).toEqual(hashDir1Only)
})

it('skips outside-root matches by default (hash unchanged)', async () => {
const root = path.join(getTestTemp(), 'default-skip-outside-roots')
const dir1 = path.join(root, 'dir1')
const outsideDir = path.join(root, 'outsideDir')

await fs.mkdir(dir1, {recursive: true})
await fs.mkdir(outsideDir, {recursive: true})

await fs.writeFile(path.join(dir1, 'file1.txt'), 'test 1 file content')
await fs.writeFile(
path.join(outsideDir, 'fileOut.txt'),
'test outside file content'
)

const insideOnly = await hashFiles(`${dir1}/*`, '', {roots: [dir1]})
expect(insideOnly).not.toEqual('')

const patterns = `${dir1}/*\n${outsideDir}/*`
const defaultSkip = await hashFiles(patterns, '', {roots: [dir1]})

expect(defaultSkip).toEqual(insideOnly)
})

it('allows files outside roots if opted-in (hash changes + deterministic)', async () => {
const root = path.join(getTestTemp(), 'allow-outside-roots')
const dir1 = path.join(root, 'dir1')
const outsideDir = path.join(root, 'outsideDir')
await fs.mkdir(dir1, {recursive: true})
await fs.mkdir(outsideDir, {recursive: true})
await fs.writeFile(path.join(dir1, 'file1.txt'), 'test 1 file content')
await fs.writeFile(
path.join(outsideDir, 'fileOut.txt'),
'test outside file content'
)

const insideOnly = await hashFiles(`${dir1}/*`, '', {roots: [dir1]})
expect(insideOnly).not.toEqual('')

const patterns = `${dir1}/*\n${outsideDir}/*`
const withOptIn1 = await hashFiles(patterns, '', {
roots: [dir1],
allowFilesOutsideWorkspace: true
})
expect(withOptIn1).not.toEqual('')
expect(withOptIn1).not.toEqual(insideOnly)

const withOptIn2 = await hashFiles(patterns, '', {
roots: [dir1],
allowFilesOutsideWorkspace: true
})
expect(withOptIn2).toEqual(withOptIn1)
})

it('excludes files matching exclude patterns', async () => {
const root = path.join(getTestTemp(), 'exclude-hashfiles')
await fs.mkdir(root, {recursive: true})
await fs.writeFile(path.join(root, 'file1.txt'), 'test 1 file content')
await fs.writeFile(path.join(root, 'file2.log'), 'test 2 file content')

const all = await hashFiles(`${root}/*`, '', {roots: [root]})
expect(all).not.toEqual('')

// Exclude by exact filename and extension
const excluded = await hashFiles(`${root}/*`, '', {
roots: [root],
exclude: ['file2.log', '*.log']
})
expect(excluded).not.toEqual('')

const justIncluded = await hashFiles(
`${path.join(root, 'file1.txt')}`,
'',
{
roots: [root]
}
)

expect(excluded).toEqual(justIncluded)
expect(excluded).not.toEqual(all)
})
})

function getTestTemp(): string {
Expand Down
2 changes: 1 addition & 1 deletion packages/glob/src/glob.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
23 changes: 23 additions & 0 deletions packages/glob/src/internal-hash-file-options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[]
}
160 changes: 151 additions & 9 deletions packages/glob/src/internal-hash-files.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,41 +4,183 @@ import * as fs from 'fs'
import * as stream from 'stream'
import * as util from 'util'
import * as path from 'path'
import minimatch from 'minimatch'
import {Globber} from './glob.js'
import {HashFileOptions} from './internal-hash-file-options.js'

type IMinimatch = minimatch.IMinimatch
type IMinimatchOptions = minimatch.IOptions
const {Minimatch} = minimatch

const IS_WINDOWS = process.platform === 'win32'
const MAX_WARNED_FILES = 10

const MINIMATCH_OPTIONS: IMinimatchOptions = {
dot: true,
nobrace: true,
nocase: IS_WINDOWS,
nocomment: true,
noext: true,
nonegate: true
}

type ExcludeMatcher = {
absolutePathMatcher: IMinimatch
workspaceRelativeMatcher: IMinimatch
}

// 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
} as IMinimatchOptions),
workspaceRelativeMatcher: new Minimatch(normalizedPattern, {
...MINIMATCH_OPTIONS,
matchBase: isBasenamePattern
} as IMinimatchOptions)
}
})
}

function isExcluded(
resolvedFile: string,
excludeMatchers: ExcludeMatcher[],
githubWorkspace: string
): boolean {
if (excludeMatchers.length === 0) return false
const absolutePath = path.resolve(resolvedFile)
const absolutePathForMatch = normalizeForMatch(absolutePath)
const workspaceRelativePathForMatch = normalizeForMatch(
path.relative(githubWorkspace, absolutePath)
Comment thread
priyagupta108 marked this conversation as resolved.
Outdated
)
return excludeMatchers.some(
m =>
m.absolutePathMatcher.match(absolutePathForMatch) ||
m.workspaceRelativeMatcher.match(workspaceRelativePathForMatch)
)
}

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())
const allowOutside = options?.allowFilesOutsideWorkspace ?? false
const excludeMatchers = buildExcludeMatchers(options?.exclude ?? [])

// Resolve roots up front; warn and skip any that fail to resolve
const resolvedRoots: string[] = []
for (const root of options?.roots ?? [githubWorkspace]) {
try {
resolvedRoots.push(fs.realpathSync(root))
} catch (err) {
core.warning(`Could not resolve root '${root}': ${err}`)
}
}
if (resolvedRoots.length === 0) {
core.warning(
`Could not resolve any allowed root(s); no files will be considered for hashing.`
)
return ''
}

const outsideRootFiles: string[] = []
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}`
)
continue
}
if (fs.statSync(file).isDirectory()) {

// Exclude matching patterns (apply to resolved path for symlink-safety)
if (isExcluded(resolvedFile, excludeMatchers, githubWorkspace)) {
writeDelegate(`Exclude '${file}' (exclude pattern match).`)
continue
}

// Check if in resolved roots
if (!isInResolvedRoots(resolvedFile, resolvedRoots)) {
outsideRootFiles.push(file)
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}`).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.`
)
Comment thread
priyagupta108 marked this conversation as resolved.
}

if (hasMatch) {
writeDelegate(`Found ${count} files to hash.`)
return result.digest('hex')
Expand Down
Loading