mirror of
https://github.com/github/codeql-action.git
synced 2026-10-30 17:39:50 +00:00
393 lines
13 KiB
TypeScript
393 lines
13 KiB
TypeScript
import * as fs from "fs";
|
|
import path from "path";
|
|
|
|
import type { ActionState } from "./action-common";
|
|
import { getTemporaryDirectory } from "./actions-util";
|
|
import type { Config } from "./config-utils";
|
|
import { Language } from "./languages";
|
|
import { getActionsLogger, type Logger } from "./logging";
|
|
import {
|
|
getCodeQLDatabasePath,
|
|
getErrorMessage,
|
|
isErrnoException,
|
|
} from "./util";
|
|
|
|
/**
|
|
* Known tags for diagnostics. There is currently only "internal-error",
|
|
* but others may be added in the future.
|
|
*/
|
|
export type DiagnosticTag = "internal-error";
|
|
|
|
/** Optional information about the origin of a diagnostic. */
|
|
export type DiagnosticSourceOptions = {
|
|
/**
|
|
* Name of the CodeQL extractor. This is used to identify which tool component the reporting
|
|
* descriptor object should be nested under in SARIF.
|
|
*/
|
|
extractorName?: string;
|
|
/** An array of tags for the diagnostic. */
|
|
tags?: DiagnosticTag[];
|
|
};
|
|
|
|
/** Represents information about the origin of a diagnostic. */
|
|
export type DiagnosticSource = {
|
|
/**
|
|
* An identifier under which it makes sense to group this diagnostic message.
|
|
* This is used to build the SARIF reporting descriptor object.
|
|
*/
|
|
id: string;
|
|
/** Display name for the ID. This is used to build the SARIF reporting descriptor object. */
|
|
name: string;
|
|
} & DiagnosticSourceOptions;
|
|
|
|
/**
|
|
* Represents a diagnostic message for the tool status page, etc.
|
|
*
|
|
* Unlike {@link DiagnosticMessage}, properties which can automatically
|
|
* be populated are optional in this type.
|
|
*/
|
|
export type DiagnosticMessageOptions = {
|
|
/** ISO 8601 timestamp */
|
|
timestamp?: string;
|
|
/** Information about the origin of the diagnostic. */
|
|
source?: DiagnosticSourceOptions;
|
|
/** GitHub flavored Markdown formatted message. Should include inline links to any help pages. */
|
|
markdownMessage?: string;
|
|
/** Plain text message. Used by components where the string processing needed to support Markdown is cumbersome. */
|
|
plaintextMessage?: string;
|
|
/** List of help links intended to supplement the `plaintextMessage`. */
|
|
helpLinks?: string[];
|
|
/** SARIF severity */
|
|
severity?: "error" | "warning" | "note";
|
|
visibility?: {
|
|
/** True if the message should be displayed on the status page (defaults to false) */
|
|
statusPage?: boolean;
|
|
/**
|
|
* True if the message should be counted in the diagnostics summary table printed by `codeql database analyze`
|
|
* (defaults to false)
|
|
*/
|
|
cliSummaryTable?: boolean;
|
|
/** True if the message should be sent to telemetry (defaults to false) */
|
|
telemetry?: boolean;
|
|
};
|
|
location?: {
|
|
/** Path to the affected file if appropriate, relative to the source root */
|
|
file?: string;
|
|
startLine?: number;
|
|
startColumn?: number;
|
|
endLine?: number;
|
|
endColumn?: number;
|
|
};
|
|
/** Structured metadata about the diagnostic message */
|
|
attributes?: { [key: string]: any };
|
|
};
|
|
|
|
/** Represents a diagnostic message for the tool status page, etc. */
|
|
export type DiagnosticMessage = DiagnosticMessageOptions & {
|
|
/** ISO 8601 timestamp */
|
|
timestamp: string;
|
|
/** Information about the origin of the diagnostic. */
|
|
source: DiagnosticSource;
|
|
};
|
|
|
|
/** Represents a diagnostic message that has not yet been saved to the database. */
|
|
interface TemporaryDiagnostic {
|
|
/** The path to which the diagnostic has temporarily been written to. */
|
|
path: string;
|
|
/** The language the diagnostic is for, if any. */
|
|
language?: Language;
|
|
}
|
|
|
|
/** A list of diagnostics which have not yet been saved to the database. */
|
|
let temporaryDiagnostics: TemporaryDiagnostic[] = [];
|
|
|
|
/**
|
|
* Counter used to generate a unique suffix for each diagnostic filename, so that
|
|
* two diagnostics produced within the same millisecond do not overwrite each
|
|
* other on disk.
|
|
*/
|
|
let diagnosticCounter = 0;
|
|
|
|
/**
|
|
* Constructs a new diagnostic message with the specified id and name, as well as optional additional data.
|
|
*
|
|
* @param id An identifier under which it makes sense to group this diagnostic message.
|
|
* @param name Display name for the ID.
|
|
* @param data Optional additional data to initialize the diagnostic with.
|
|
* @returns Returns the new diagnostic message.
|
|
*/
|
|
export function makeDiagnostic(
|
|
id: string,
|
|
name: string,
|
|
data: DiagnosticMessageOptions | undefined = undefined,
|
|
): DiagnosticMessage {
|
|
return {
|
|
...data,
|
|
timestamp: data?.timestamp ?? new Date().toISOString(),
|
|
source: { ...data?.source, id, name },
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Gets a path relative to {@link tmpDir} where diagnostics for {@link language}
|
|
* can temporarily be stored at before the database is initialised. If {@link language}
|
|
* is `undefined`, then the common base path for all languages relative to {@link tmpDir}
|
|
* is returned.
|
|
*/
|
|
export function getTempDiagnosticPath(tmpDir: string, language?: Language) {
|
|
const root = path.join(tmpDir, "codeql-action-temp-diagnostics");
|
|
return getDiagnosticsPath(root, language);
|
|
}
|
|
|
|
/**
|
|
* Gets a path relative to {@link basePath} where diagnostics for {@link language}
|
|
* should be stored at. If {@link language} is `undefined`, then {@link basePath}
|
|
* is returned instead.
|
|
*/
|
|
export function getDiagnosticsPath(basePath: string, language?: Language) {
|
|
if (language !== undefined) {
|
|
return path.resolve(basePath, language, "diagnostic", "codeql-action");
|
|
}
|
|
return basePath;
|
|
}
|
|
|
|
/**
|
|
* Adds the given diagnostic to the database. If the database does not yet exist,
|
|
* the diagnostic will be written to it once it has been created.
|
|
*
|
|
* @param config The configuration that tells us where to store the diagnostic.
|
|
* @param language The language which the diagnostic is for.
|
|
* @param diagnostic The diagnostic message to add to the database.
|
|
*/
|
|
export function addDiagnostic(
|
|
config: Config,
|
|
language: Language,
|
|
diagnostic: DiagnosticMessage,
|
|
logger: Logger = getActionsLogger(),
|
|
) {
|
|
const databasePath = language
|
|
? getCodeQLDatabasePath(config, language)
|
|
: config.dbLocation;
|
|
|
|
// Check that the database exists before writing to it. If the database does not yet exist,
|
|
// store the diagnostic in memory and write it later.
|
|
if (fs.existsSync(databasePath)) {
|
|
writeDiagnostic({ logger }, config, language, diagnostic);
|
|
} else {
|
|
logger.debug(
|
|
`Writing a diagnostic for ${language}, but the database at ${databasePath} does not exist yet.`,
|
|
);
|
|
|
|
// Write the diagnostic to a temporary location.
|
|
writeTemporaryDiagnosticFile(logger, config.tempDir, language, diagnostic);
|
|
}
|
|
}
|
|
|
|
/** Adds a diagnostic that is not specific to any language. */
|
|
export function addNoLanguageDiagnostic(
|
|
config: Config | undefined,
|
|
diagnostic: DiagnosticMessage,
|
|
logger: Logger = getActionsLogger(),
|
|
) {
|
|
if (config !== undefined) {
|
|
addDiagnostic(
|
|
config,
|
|
// Arbitrarily choose the first language. We could also choose all languages, but that
|
|
// increases the risk of misinterpreting the data.
|
|
config.languages[0],
|
|
diagnostic,
|
|
);
|
|
} else {
|
|
logger.debug(
|
|
`Writing a diagnostic not associated with any language, but we have no configuration to tell us where the database is yet.`,
|
|
);
|
|
|
|
// Write the diagnostic to a temporary location.
|
|
writeTemporaryDiagnosticFile(
|
|
logger,
|
|
getTemporaryDirectory(),
|
|
undefined,
|
|
diagnostic,
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Writes {@link diagnostic} for an optional {@link language} to a subdirectory of {@link tempDir}
|
|
* and tracks it as a diagnostic that was written to a temporary location.
|
|
*/
|
|
function writeTemporaryDiagnosticFile(
|
|
logger: Logger,
|
|
tempDir: string,
|
|
language: Language | undefined,
|
|
diagnostic: DiagnosticMessage,
|
|
) {
|
|
const tempDiagnosticsPath = getTempDiagnosticPath(tempDir, language);
|
|
|
|
try {
|
|
// Write the diagnostic to a temporary location.
|
|
const diagnosticPath = writeDiagnosticFile(tempDiagnosticsPath, diagnostic);
|
|
|
|
// Track the temporary file.
|
|
temporaryDiagnostics.push({ path: diagnosticPath, language });
|
|
} catch (err) {
|
|
logger.warning(
|
|
`Unable to write temporary diagnostic to '${tempDiagnosticsPath}': ${getErrorMessage(err)}\n\n${JSON.stringify(diagnostic)}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Writes {@link diagnostic} to a file in {@link diagnosticsPath}.
|
|
*/
|
|
function writeDiagnosticFile(
|
|
diagnosticsPath: string,
|
|
diagnostic: DiagnosticMessage,
|
|
): string {
|
|
// Create the directory if it doesn't exist yet.
|
|
fs.mkdirSync(diagnosticsPath, { recursive: true });
|
|
|
|
// Include a monotonically increasing suffix to avoid filename collisions
|
|
// between diagnostics produced within the same millisecond.
|
|
const uniqueSuffix = (diagnosticCounter++).toString();
|
|
// We should only need to remove colons, but to be defensive, only allow a restricted set of
|
|
// characters.
|
|
const sanitizedTimestamp = diagnostic.timestamp.replace(
|
|
/[^a-zA-Z0-9.-]/g,
|
|
"",
|
|
);
|
|
const jsonPath = path.resolve(
|
|
diagnosticsPath,
|
|
`codeql-action-${sanitizedTimestamp}-${uniqueSuffix}.json`,
|
|
);
|
|
|
|
fs.writeFileSync(jsonPath, JSON.stringify(diagnostic));
|
|
|
|
return jsonPath;
|
|
}
|
|
|
|
/** Gets the path where diagnostics for {@link language} should be stored in the database. */
|
|
export function getDatabaseDiagnosticsPath(
|
|
config: Config,
|
|
language: Language | undefined,
|
|
) {
|
|
return getDiagnosticsPath(config.dbLocation, language);
|
|
}
|
|
|
|
/**
|
|
* Writes the given diagnostic to the database.
|
|
*
|
|
* @param config The configuration that tells us where to store the diagnostic.
|
|
* @param language The language which the diagnostic is for.
|
|
* @param diagnostic The diagnostic message to add to the database.
|
|
*/
|
|
function writeDiagnostic(
|
|
action: ActionState<["Logger"]>,
|
|
config: Config,
|
|
language: Language | undefined,
|
|
diagnostic: DiagnosticMessage,
|
|
) {
|
|
const diagnosticsPath = getDatabaseDiagnosticsPath(config, language);
|
|
|
|
try {
|
|
writeDiagnosticFile(diagnosticsPath, diagnostic);
|
|
} catch (err) {
|
|
action.logger.warning(
|
|
`Unable to write diagnostic message to database: ${err}`,
|
|
);
|
|
action.logger.debug(JSON.stringify(diagnostic));
|
|
}
|
|
}
|
|
|
|
/** Report if there are temporary diagnostics and write them to the log. */
|
|
export function logTemporaryDiagnostics(action: ActionState<["Logger"]>) {
|
|
const num = temporaryDiagnostics.length;
|
|
|
|
if (num > 0) {
|
|
action.logger.warning(
|
|
`${num} diagnostic(s) could not be written to the database and will not appear on the Tool Status Page.`,
|
|
);
|
|
|
|
for (const temporary of temporaryDiagnostics) {
|
|
action.logger.debug(fs.readFileSync(temporary.path, "utf-8"));
|
|
}
|
|
}
|
|
}
|
|
|
|
/** Relocates all temporary diagnostics to the respective databases. */
|
|
export function flushDiagnostics(
|
|
action: ActionState<["Logger"]>,
|
|
config: Config,
|
|
) {
|
|
const diagnosticsCount = temporaryDiagnostics.length;
|
|
action.logger.debug(
|
|
`Moving ${diagnosticsCount} diagnostic(s) to their respective databases.`,
|
|
);
|
|
|
|
const failedFlushDiagnostics: TemporaryDiagnostic[] = [];
|
|
for (const temporary of temporaryDiagnostics) {
|
|
// If `temporary.language` is `undefined`, then we didn't have a `config` at the time that
|
|
// `addNoLanguageDiagnostic` was called. In that case, we arbitrarily choose the first
|
|
// configured language here like we would have done in `addNoLanguageDiagnostic`.
|
|
const directory = getDatabaseDiagnosticsPath(
|
|
config,
|
|
temporary.language ?? config.languages[0],
|
|
);
|
|
const filename = path.basename(temporary.path);
|
|
const destination = path.join(directory, filename);
|
|
|
|
try {
|
|
fs.mkdirSync(directory, { recursive: true });
|
|
try {
|
|
fs.renameSync(temporary.path, destination);
|
|
} catch (renameErr: unknown) {
|
|
// Rethrow the error if it isn't an EXDEV one.
|
|
if (!isErrnoException(renameErr) || renameErr.code !== "EXDEV") {
|
|
throw renameErr;
|
|
}
|
|
|
|
// The error was an EXDEV one: try to copy the file and delete the temporary one.
|
|
fs.copyFileSync(temporary.path, destination);
|
|
fs.unlinkSync(temporary.path);
|
|
}
|
|
} catch (err) {
|
|
action.logger.warning(
|
|
`Failed to relocate '${temporary.path}' to '${destination}': ${getErrorMessage(err)}`,
|
|
);
|
|
|
|
failedFlushDiagnostics.push(temporary);
|
|
}
|
|
}
|
|
|
|
// Reset the temporary diagnostics arrays.
|
|
temporaryDiagnostics = failedFlushDiagnostics;
|
|
}
|
|
|
|
/**
|
|
* Creates a telemetry-only diagnostic message. This is a convenience function
|
|
* for creating diagnostics that should only be sent to telemetry and not
|
|
* displayed on the status page or CLI summary table.
|
|
*
|
|
* @param id An identifier under which it makes sense to group this diagnostic message
|
|
* @param name Display name
|
|
* @param attributes Structured metadata
|
|
*/
|
|
export function makeTelemetryDiagnostic(
|
|
id: string,
|
|
name: string,
|
|
attributes: { [key: string]: any },
|
|
tags?: DiagnosticTag[],
|
|
): DiagnosticMessage {
|
|
return makeDiagnostic(id, name, {
|
|
attributes,
|
|
visibility: {
|
|
cliSummaryTable: false,
|
|
statusPage: false,
|
|
telemetry: true,
|
|
},
|
|
source: {
|
|
tags,
|
|
},
|
|
});
|
|
}
|