Files
attest/src/subject.ts
Brian DeHamer 68a047fd01 bugfix for glob exclude patterns (#100)
Signed-off-by: Brian DeHamer <bdehamer@github.com>
2024-07-09 07:55:19 -07:00

143 lines
4.0 KiB
TypeScript

import * as glob from '@actions/glob'
import crypto from 'crypto'
import { parse } from 'csv-parse/sync'
import fs from 'fs'
import path from 'path'
import type { Subject } from '@actions/attest'
const MAX_SUBJECT_COUNT = 2500
const DIGEST_ALGORITHM = 'sha256'
export type SubjectInputs = {
subjectPath: string
subjectName: string
subjectDigest: string
downcaseName?: boolean
}
// Returns the subject specified by the action's inputs. The subject may be
// specified as a path to a file or as a digest. If a path is provided, the
// file's digest is calculated and returned along with the subject's name. If a
// digest is provided, the name must also be provided.
export const subjectFromInputs = async (
inputs: SubjectInputs
): Promise<Subject[]> => {
const { subjectPath, subjectDigest, subjectName, downcaseName } = inputs
if (!subjectPath && !subjectDigest) {
throw new Error('One of subject-path or subject-digest must be provided')
}
if (subjectPath && subjectDigest) {
throw new Error(
'Only one of subject-path or subject-digest may be provided'
)
}
if (subjectDigest && !subjectName) {
throw new Error('subject-name must be provided when using subject-digest')
}
// If push-to-registry is enabled, ensure the subject name is lowercase
// to conform to OCI image naming conventions
const name = downcaseName ? subjectName.toLowerCase() : subjectName
if (subjectPath) {
return await getSubjectFromPath(subjectPath, name)
} else {
return [getSubjectFromDigest(subjectDigest, name)]
}
}
// Returns the subject specified by the path to a file. The file's digest is
// calculated and returned along with the subject's name.
const getSubjectFromPath = async (
subjectPath: string,
subjectName?: string
): Promise<Subject[]> => {
const digestedSubjects: Subject[] = []
// Parse the list of subject paths
const subjectPaths = parseList(subjectPath).join('\n')
// Expand the globbed paths to a list of files
/* eslint-disable-next-line github/no-then */
const files = await glob.create(subjectPaths).then(async g => g.glob())
if (files.length > MAX_SUBJECT_COUNT) {
throw new Error(
`Too many subjects specified. The maximum number of subjects is ${MAX_SUBJECT_COUNT}.`
)
}
for (const file of files) {
// Skip anything that is NOT a file
if (!fs.statSync(file).isFile()) {
continue
}
const name = subjectName || path.parse(file).base
const digest = await digestFile(DIGEST_ALGORITHM, file)
digestedSubjects.push({ name, digest: { [DIGEST_ALGORITHM]: digest } })
}
if (digestedSubjects.length === 0) {
throw new Error(`Could not find subject at path ${subjectPath}`)
}
return digestedSubjects
}
// Returns the subject specified by the digest of a file. The digest is returned
// along with the subject's name.
const getSubjectFromDigest = (
subjectDigest: string,
subjectName: string
): Subject => {
if (!subjectDigest.match(/^sha256:[A-Za-z0-9]{64}$/)) {
throw new Error(
'subject-digest must be in the format "sha256:<hex-digest>"'
)
}
const [alg, digest] = subjectDigest.split(':')
return {
name: subjectName,
digest: { [alg]: digest }
}
}
// Calculates the digest of a file using the specified algorithm. The file is
// streamed into the digest function to avoid loading the entire file into
// memory. The returned digest is a hex string.
const digestFile = async (
algorithm: string,
filePath: string
): Promise<string> => {
return new Promise((resolve, reject) => {
const hash = crypto.createHash(algorithm).setEncoding('hex')
fs.createReadStream(filePath)
.once('error', reject)
.pipe(hash)
.once('finish', () => resolve(hash.read()))
})
}
const parseList = (input: string): string[] => {
const res: string[] = []
const records: string[][] = parse(input, {
columns: false,
relaxQuotes: true,
relaxColumnCount: true,
skipEmptyLines: true
})
for (const record of records) {
res.push(...record)
}
return res.filter(item => item).map(pat => pat.trim())
}