File size: 5,122 Bytes
7a1ad33 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 | /**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import * as crypto from 'node:crypto';
import * as fs from 'node:fs/promises';
import * as path from 'node:path';
import { Storage } from '../config/storage.js';
import { readPolicyFiles } from './toml-loader.js';
import { debugLogger } from '../utils/debugLogger.js';
import { isNodeError } from '../utils/errors.js';
export enum IntegrityStatus {
MATCH = 'MATCH',
MISMATCH = 'MISMATCH',
NEW = 'NEW',
}
export interface IntegrityResult {
status: IntegrityStatus;
hash: string;
fileCount: number;
}
interface StoredIntegrityData {
[key: string]: string; // key = scope:identifier, value = hash
}
export class PolicyIntegrityManager {
/**
* Checks the integrity of policies in a given directory against the stored hash.
*
* @param scope The scope of the policy (e.g., 'project', 'user').
* @param identifier A unique identifier for the policy scope (e.g., project path).
* @param policyDir The directory containing the policy files.
* @returns IntegrityResult indicating if the current policies match the stored hash.
*/
async checkIntegrity(
scope: string,
identifier: string,
policyDir: string,
): Promise<IntegrityResult> {
const { hash: currentHash, fileCount } =
await PolicyIntegrityManager.calculateIntegrityHash(policyDir);
const storedData = await this.loadIntegrityData();
const key = this.getIntegrityKey(scope, identifier);
const storedHash = storedData[key];
if (!storedHash) {
return { status: IntegrityStatus.NEW, hash: currentHash, fileCount };
}
if (storedHash === currentHash) {
return { status: IntegrityStatus.MATCH, hash: currentHash, fileCount };
}
return { status: IntegrityStatus.MISMATCH, hash: currentHash, fileCount };
}
/**
* Accepts and persists the current integrity hash for a given policy scope.
*
* @param scope The scope of the policy.
* @param identifier A unique identifier for the policy scope (e.g., project path).
* @param hash The hash to persist.
*/
async acceptIntegrity(
scope: string,
identifier: string,
hash: string,
): Promise<void> {
const storedData = await this.loadIntegrityData();
const key = this.getIntegrityKey(scope, identifier);
storedData[key] = hash;
await this.saveIntegrityData(storedData);
}
/**
* Calculates a SHA-256 hash of all policy files in the directory.
* The hash includes the relative file path and content to detect renames and modifications.
*
* @param policyDir The directory containing the policy files.
* @returns The calculated hash and file count
*/
private static async calculateIntegrityHash(
policyDir: string,
): Promise<{ hash: string; fileCount: number }> {
try {
const files = await readPolicyFiles(policyDir);
// Sort files by path to ensure deterministic hashing
files.sort((a, b) => a.path.localeCompare(b.path));
const hash = crypto.createHash('sha256');
for (const file of files) {
const relativePath = path.relative(policyDir, file.path);
// Include relative path and content in the hash
hash.update(relativePath);
hash.update('\0'); // Separator
hash.update(file.content);
hash.update('\0'); // Separator
}
return { hash: hash.digest('hex'), fileCount: files.length };
} catch (error) {
debugLogger.error('Failed to calculate policy integrity hash', error);
// Return a unique hash (random) to force a mismatch if calculation fails?
// Or throw? Throwing is better so we don't accidentally accept/deny corrupted state.
throw error;
}
}
private getIntegrityKey(scope: string, identifier: string): string {
return `${scope}:${identifier}`;
}
private async loadIntegrityData(): Promise<StoredIntegrityData> {
const storagePath = Storage.getPolicyIntegrityStoragePath();
try {
const content = await fs.readFile(storagePath, 'utf-8');
const parsed: unknown = JSON.parse(content);
if (
typeof parsed === 'object' &&
parsed !== null &&
Object.values(parsed).every((v) => typeof v === 'string')
) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
return parsed as StoredIntegrityData;
}
debugLogger.warn('Invalid policy integrity data format');
return {};
} catch (error) {
if (isNodeError(error) && error.code === 'ENOENT') {
return {};
}
debugLogger.error('Failed to load policy integrity data', error);
return {};
}
}
private async saveIntegrityData(data: StoredIntegrityData): Promise<void> {
const storagePath = Storage.getPolicyIntegrityStoragePath();
try {
await fs.mkdir(path.dirname(storagePath), { recursive: true });
await fs.writeFile(storagePath, JSON.stringify(data, null, 2), 'utf-8');
} catch (error) {
debugLogger.error('Failed to save policy integrity data', error);
throw error;
}
}
}
|