Files
fluxer/fluxer_desktop/src/main/ModuleStore.ts
T

1817 lines
60 KiB
TypeScript

// SPDX-License-Identifier: AGPL-3.0-or-later
import {Buffer} from 'node:buffer';
import {createHash, randomBytes} from 'node:crypto';
import {type Dirent, constants as fsConstants, type Stats} from 'node:fs';
import type {FileHandle} from 'node:fs/promises';
import fs from 'node:fs/promises';
import path from 'node:path';
import {ModuleInstallCoordinator} from '@electron/main/ModuleInstallCoordinator';
import {
DESKTOP_MODULE_FILE_LIST_NAME,
DESKTOP_MODULE_MANIFEST_MAX_BYTES,
type DesktopModuleManifest,
ModulePackageHashMismatchError,
parseDesktopModuleManifest,
verifyAndExtract,
} from '@electron/main/ModulePackage';
import {compareModuleVersions, parseModuleVersion} from '@electron/main/ModuleVersion';
import {isDesktopModuleName} from '@fluxer/desktop_ipc/src/ModuleContract';
const MODULE_STORE_DIRECTORY_NAME = 'modules';
export const MODULE_STATE_FILE_NAME = 'state.json';
export const MODULE_STATE_VERSION = 3;
const MODULE_DOWNLOAD_DIRECTORY_NAME = 'download';
const MODULE_DOWNLOAD_INCOMING_DIRECTORY_NAME = 'incoming';
const MODULE_STORE_TREE_DIRECTORY_NAME = 'store';
const MODULE_PACKAGE_FILE_EXTENSION = '.br';
const MODULE_PARTIAL_PACKAGE_FILE_EXTENSION = '.partial';
export const MODULE_INCOMING_STORE_PREFIX = '.incoming-';
const MODULE_REPLACED_STORE_PREFIX = '.replaced-';
export const MODULE_PACKAGE_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
export const MODULE_BOOT_ATTEMPT_ROLLBACK_THRESHOLD = 2;
const MODULE_STATE_MAX_BYTES = 4 * 1024 * 1024;
const MODULE_MANIFEST_HIGH_WATER_MAX_ENTRIES = 16;
const MODULE_FEED_COMPONENT_PATTERN = /^[a-z][a-z0-9_-]{0,63}$/u;
const SHA256_PATTERN = /^[0-9a-f]{64}$/u;
const DOWNLOAD_FILE_MODE = 0o644;
const STATE_FILE_MODE = 0o600;
const INSTALLED_FILE_HASH_CHUNK_BYTES = 1024 * 1024;
const INSTALLED_FILE_CHECK_CONCURRENCY = 32;
const TREE_FSYNC_CONCURRENCY = 4;
export const MODULE_FILE_STAMPS_NAME = '.installed-files.json';
const MODULE_FILE_STAMPS_VERSION = 1;
const MODULE_FILE_STAMPS_MODE = 0o600;
const MODULE_FILE_STAMPS_MAX_BYTES = 16 * 1024 * 1024;
const MISSING_INSTALLATION_ERROR_CODES: ReadonlySet<string> = new Set(['ENOENT', 'ENOTDIR', 'ELOOP']);
const MODULE_PACKAGE_DIGEST_MISMATCH_PREFIX = 'package sha256 mismatch:';
const MODULE_LAUNCH_ATTEMPT = Symbol('fluxer.desktop.moduleLaunchAttempt');
const WINDOWS_TRANSIENT_FS_ERROR_CODES: ReadonlySet<string> = new Set(['EPERM', 'EACCES', 'EBUSY']);
const WINDOWS_TRANSIENT_FS_RETRY_DELAYS_MS: ReadonlyArray<number> = [25, 50, 100, 200, 400, 800];
class ModuleStoreError extends Error {
public constructor(message: string, options?: ErrorOptions) {
super(message, options);
this.name = 'ModuleStoreError';
}
}
class ModuleStoreIOError extends ModuleStoreError {
public constructor(message: string, options?: ErrorOptions) {
super(message, options);
this.name = 'ModuleStoreIOError';
}
}
class ModuleStoreStateCorruptError extends ModuleStoreError {
public constructor(statePath: string, detail: string, options?: ErrorOptions) {
super(`module state at ${statePath} is corrupt: ${detail}`, options);
this.name = 'ModuleStoreStateCorruptError';
}
}
class ModuleStoreStateUnsupportedVersionError extends ModuleStoreError {
public constructor(statePath: string, version: unknown) {
super(`module state at ${statePath} has unsupported state_version ${String(version)}`);
this.name = 'ModuleStoreStateUnsupportedVersionError';
}
}
export class ModuleManifestRollbackError extends ModuleStoreError {
public constructor(feed: ModuleManifestFeedIdentity, candidate: number, retained: number) {
super(
`module manifest metadata_version ${candidate} for ${moduleManifestFeedKey(feed)} is below retained version ${retained}`,
);
this.name = 'ModuleManifestRollbackError';
}
}
export class ModuleManifestEquivocationError extends ModuleStoreError {
public constructor(feed: ModuleManifestFeedIdentity, metadataVersion: number) {
super(
`module manifest metadata_version ${metadataVersion} for ${moduleManifestFeedKey(feed)} has a different digest`,
);
this.name = 'ModuleManifestEquivocationError';
}
}
class ModuleManifestHighWaterCapacityError extends ModuleStoreError {
public constructor() {
super(`module manifest high-water state cannot exceed ${MODULE_MANIFEST_HIGH_WATER_MAX_ENTRIES} feeds`);
this.name = 'ModuleManifestHighWaterCapacityError';
}
}
export class ModuleStoreDownloadHashMismatchError extends ModuleStoreError {
public readonly module: string;
public readonly expectedSha256: string;
public readonly actualSha256: string;
public constructor(moduleName: string, expectedSha256: string, actualSha256: string) {
super(`module ${moduleName} package sha256 mismatch: expected ${expectedSha256}, got ${actualSha256}`);
this.name = 'ModuleStoreDownloadHashMismatchError';
this.module = moduleName;
this.expectedSha256 = expectedSha256;
this.actualSha256 = actualSha256;
}
}
export class ModuleStoreInstallationMissingError extends ModuleStoreError {
public readonly module: string;
public readonly sha256: string;
public readonly directory: string;
public constructor(moduleName: string, sha256: string, directory: string) {
super(`module ${moduleName} ${sha256} is not installed at ${directory}`);
this.name = 'ModuleStoreInstallationMissingError';
this.module = moduleName;
this.sha256 = sha256;
this.directory = directory;
}
}
export class ModuleStoreGarbageCollectionOrderError extends ModuleStoreError {
public constructor() {
super('module garbage collection ran before a successful launch');
this.name = 'ModuleStoreGarbageCollectionOrderError';
}
}
export interface LinuxModuleSecurityMinimum {
readonly version: string;
readonly requiredModules: ReadonlyArray<string>;
}
export interface ModuleManifestFeedIdentity {
readonly releaseChannel: string;
readonly platform: string;
readonly arch: string;
}
export interface ModuleManifestFeedObservation {
readonly feed: ModuleManifestFeedIdentity;
readonly metadataVersion: number;
readonly manifestSha256: string;
}
interface ModuleManifestHighWater extends ModuleManifestFeedIdentity {
readonly metadataVersion: number;
readonly manifestSha256: string;
}
interface ModuleStoreState {
readonly state_version: number;
readonly shell_version: string;
readonly release_channel: string;
readonly committed: Readonly<Record<string, string>>;
readonly previous: Readonly<Record<string, string>>;
readonly rejected: Readonly<Record<string, string>>;
readonly floor: Readonly<Record<string, string>>;
readonly linux_security_minimum: LinuxModuleSecurityMinimum | null;
readonly manifest_high_water: ReadonlyArray<ModuleManifestHighWater>;
readonly last_manifest_etag: string | null;
readonly last_manifest_fetch: string | null;
readonly boot_attempt: number;
}
const ModuleStoreStateFileStatus = Object.freeze({
MISSING: 'missing',
LOADED: 'loaded',
} as const);
type ModuleStoreStateFile =
| {readonly status: typeof ModuleStoreStateFileStatus.MISSING}
| {
readonly status: typeof ModuleStoreStateFileStatus.LOADED;
readonly state: ModuleStoreState;
};
interface ModuleInstallation {
readonly module: string;
readonly sha256: string;
readonly directory: string;
readonly manifest: DesktopModuleManifest;
}
interface InstalledModuleFile {
readonly relativePath: string;
readonly absolutePath: string;
}
interface ValidInstalledModule {
readonly manifest: DesktopModuleManifest;
readonly files: ReadonlyArray<InstalledModuleFile>;
}
interface ModuleBootAttempt {
readonly rolledBack: boolean;
readonly bootAttempt: number;
readonly committed: Readonly<Record<string, string>>;
}
export interface ModuleLaunchAttempt {
readonly [MODULE_LAUNCH_ATTEMPT]: true;
readonly committed: Readonly<Record<string, string>>;
}
interface ModuleGarbageCollectionResult {
readonly removedDirectories: ReadonlyArray<string>;
readonly removedPackages: ReadonlyArray<string>;
}
export interface ModulePackageDownload {
readonly offset: number;
readonly chunks: AsyncIterable<Uint8Array> | Iterable<Uint8Array>;
}
export type ModulePackageDownloader = (resumeFrom: number) => ModulePackageDownload | Promise<ModulePackageDownload>;
export function getModuleStoreRoot(userDataPath: string): string {
return path.join(userDataPath, MODULE_STORE_DIRECTORY_NAME);
}
export function getModuleStoreTreeRoot(userDataPath: string): string {
return path.join(getModuleStoreRoot(userDataPath), MODULE_STORE_TREE_DIRECTORY_NAME);
}
function assertModuleName(moduleName: string): void {
if (!isDesktopModuleName(moduleName)) {
throw new ModuleStoreError(`unsupported module name: ${moduleName}`);
}
}
function assertSha256(sha256: string): void {
if (!SHA256_PATTERN.test(sha256)) {
throw new ModuleStoreError(`not a sha256 digest: ${sha256}`);
}
}
function isPathInsideRoot(root: string, candidate: string): boolean {
const relative = path.relative(root, candidate);
return relative.length > 0 && !relative.startsWith('..') && !path.isAbsolute(relative);
}
async function createDirectory(directory: string): Promise<void> {
try {
await fs.mkdir(directory, {recursive: true});
} catch (error) {
throw new ModuleStoreIOError(`failed to create ${directory}`, {cause: error});
}
}
function isWindowsTransientFsError(error: unknown): boolean {
return (
process.platform === 'win32' &&
typeof error === 'object' &&
error !== null &&
'code' in error &&
typeof error.code === 'string' &&
WINDOWS_TRANSIENT_FS_ERROR_CODES.has(error.code)
);
}
async function renameWithRetry(source: string, target: string): Promise<void> {
for (let attempt = 0; ; attempt += 1) {
try {
await fs.rename(source, target);
return;
} catch (error) {
const delay = WINDOWS_TRANSIENT_FS_RETRY_DELAYS_MS[attempt];
if (delay === undefined || !isWindowsTransientFsError(error)) {
throw error;
}
await new Promise((resolve) => setTimeout(resolve, delay));
}
}
}
function removeOptions(): {recursive: true; force: true; maxRetries: number} {
return {
recursive: true,
force: true,
maxRetries: process.platform === 'win32' ? WINDOWS_TRANSIENT_FS_RETRY_DELAYS_MS.length : 0,
};
}
async function removeTree(target: string): Promise<void> {
try {
await fs.rm(target, removeOptions());
} catch (error) {
throw new ModuleStoreIOError(`failed to remove ${target}`, {cause: error});
}
}
async function renameAside(directory: string, aside: string): Promise<boolean> {
try {
await renameWithRetry(directory, aside);
return true;
} catch (error) {
if (typeof error === 'object' && error !== null && 'code' in error && error.code === 'ENOENT') {
return false;
}
throw new ModuleStoreIOError(`failed to set aside ${directory}`, {cause: error});
}
}
function isPackageDigestMismatch(error: unknown): boolean {
return (
error instanceof ModulePackageHashMismatchError && error.message.startsWith(MODULE_PACKAGE_DIGEST_MISMATCH_PREFIX)
);
}
async function readDirectoryEntries(directory: string): Promise<ReadonlyArray<Dirent>> {
try {
return await fs.readdir(directory, {withFileTypes: true});
} catch {
return [];
}
}
async function fsyncFile(filePath: string): Promise<void> {
let handle: FileHandle;
try {
handle = await fs.open(filePath, 'r+');
} catch (error) {
throw new ModuleStoreIOError(`failed to open ${filePath} for fsync`, {cause: error});
}
try {
await handle.sync();
} catch (error) {
throw new ModuleStoreIOError(`failed to fsync ${filePath}`, {cause: error});
} finally {
await handle.close();
}
}
async function fsyncDirectory(directory: string): Promise<void> {
if (process.platform === 'win32') {
return;
}
let handle: FileHandle;
try {
handle = await fs.open(directory, 'r');
} catch (error) {
throw new ModuleStoreIOError(`failed to open ${directory} for fsync`, {cause: error});
}
try {
await handle.sync();
} catch (error) {
throw new ModuleStoreIOError(`failed to fsync ${directory}`, {cause: error});
} finally {
await handle.close();
}
}
export async function writeFileAtomically(target: string, contents: Buffer | string, mode: number): Promise<void> {
const directory = path.dirname(target);
const temporary = path.join(directory, `${path.basename(target)}.${randomBytes(8).toString('hex')}.tmp`);
let handle: FileHandle;
try {
handle = await fs.open(temporary, 'wx', mode);
} catch (error) {
throw new ModuleStoreIOError(`failed to create ${temporary}`, {cause: error});
}
try {
await handle.writeFile(contents, 'utf8');
await handle.sync();
} catch (error) {
await handle.close();
await fs.rm(temporary, {force: true});
throw new ModuleStoreIOError(`failed to write ${temporary}`, {cause: error});
}
await handle.close();
try {
await renameWithRetry(temporary, target);
} catch (error) {
await fs.rm(temporary, {force: true});
throw new ModuleStoreIOError(`failed to publish ${target}`, {cause: error});
}
await fsyncDirectory(directory);
}
async function openPartialPackage(partial: string): Promise<FileHandle> {
try {
return await fs.open(partial, 'r+');
} catch (error) {
if (!isMissingStateFileError(error)) {
throw new ModuleStoreIOError(`failed to open ${partial}`, {cause: error});
}
}
try {
return await fs.open(partial, 'wx', DOWNLOAD_FILE_MODE);
} catch (error) {
throw new ModuleStoreIOError(`failed to create ${partial}`, {cause: error});
}
}
async function hashPartialPackage(handle: FileHandle, digest: ReturnType<typeof createHash>): Promise<number> {
const stats = await handle.stat();
const chunk = Buffer.allocUnsafe(INSTALLED_FILE_HASH_CHUNK_BYTES);
let offset = 0;
while (offset < stats.size) {
const {bytesRead} = await handle.read(chunk, 0, Math.min(chunk.byteLength, stats.size - offset), offset);
if (bytesRead === 0) {
break;
}
digest.update(chunk.subarray(0, bytesRead));
offset += bytesRead;
}
if (offset !== stats.size) {
await handle.truncate(offset);
}
return offset;
}
async function fsyncTree(directory: string): Promise<void> {
const files: Array<string> = [];
const directories: Array<string> = [];
const walk = async (current: string): Promise<void> => {
for (const entry of await readDirectoryEntries(current)) {
const child = path.join(current, entry.name);
if (entry.isDirectory()) {
await walk(child);
} else if (entry.isFile()) {
files.push(child);
}
}
directories.push(current);
};
await walk(directory);
await forEachConcurrently(files, fsyncFile, TREE_FSYNC_CONCURRENCY);
await forEachConcurrently(directories, fsyncDirectory, TREE_FSYNC_CONCURRENCY);
}
async function makeInstallationDurable(directory: string, stamps: InstalledFileStamps | null): Promise<void> {
await fsyncTree(directory);
if (stamps != null) {
await writeInstalledFileStamps(directory, stamps.manifestSha256, stamps.files);
}
}
async function modifiedAt(target: string): Promise<number | null> {
try {
const stats = await fs.stat(target);
return stats.mtimeMs;
} catch {
return null;
}
}
async function isRegularFile(target: string): Promise<boolean> {
try {
const stats = await fs.stat(target);
return stats.isFile();
} catch {
return false;
}
}
function isMissingInstallationError(error: unknown): boolean {
return (
typeof error === 'object' &&
error !== null &&
'code' in error &&
typeof error.code === 'string' &&
MISSING_INSTALLATION_ERROR_CODES.has(error.code)
);
}
function readOnlyNoFollowFlags(): number {
const noFollow = typeof fsConstants.O_NOFOLLOW === 'number' ? fsConstants.O_NOFOLLOW : 0;
return fsConstants.O_RDONLY | noFollow;
}
async function installationPathStats(target: string): Promise<Stats | null> {
try {
return await fs.lstat(target);
} catch (error) {
if (isMissingInstallationError(error)) {
return null;
}
throw new ModuleStoreIOError(`failed to inspect installed module path ${target}`, {cause: error});
}
}
async function readInstalledManifestBytes(target: string): Promise<Buffer | null> {
let handle: FileHandle;
try {
handle = await fs.open(target, readOnlyNoFollowFlags());
} catch (error) {
if (isMissingInstallationError(error)) {
return null;
}
throw new ModuleStoreIOError(`failed to open installed module file ${target}`, {cause: error});
}
try {
const stats = await handle.stat();
if (
!stats.isFile() ||
!Number.isSafeInteger(stats.size) ||
stats.size < 0 ||
stats.size > DESKTOP_MODULE_MANIFEST_MAX_BYTES
) {
return null;
}
const bytes = Buffer.alloc(stats.size);
let offset = 0;
while (offset < stats.size) {
const {bytesRead} = await handle.read(bytes, offset, stats.size - offset, offset);
if (bytesRead === 0) {
return null;
}
offset += bytesRead;
}
const trailing = Buffer.allocUnsafe(1);
const {bytesRead: trailingBytes} = await handle.read(trailing, 0, trailing.length, stats.size);
return trailingBytes === 0 ? bytes : null;
} catch (error) {
throw new ModuleStoreIOError(`failed to read installed module file ${target}`, {cause: error});
} finally {
await handle.close();
}
}
async function installedFileMatches(
target: string,
expectedBytes: number,
expectedSha256: string,
): Promise<Stats | null> {
let handle: FileHandle;
try {
handle = await fs.open(target, readOnlyNoFollowFlags());
} catch (error) {
if (isMissingInstallationError(error)) {
return null;
}
throw new ModuleStoreIOError(`failed to open installed module file ${target}`, {cause: error});
}
try {
const stats = await handle.stat();
if (!stats.isFile() || !Number.isSafeInteger(stats.size) || stats.size < 0 || stats.size !== expectedBytes) {
return null;
}
const digest = createHash('sha256');
const chunk = Buffer.allocUnsafe(Math.min(INSTALLED_FILE_HASH_CHUNK_BYTES, Math.max(1, expectedBytes)));
let offset = 0;
while (offset < expectedBytes) {
const length = Math.min(chunk.byteLength, expectedBytes - offset);
const {bytesRead} = await handle.read(chunk, 0, length, offset);
if (bytesRead === 0) {
return null;
}
digest.update(chunk.subarray(0, bytesRead));
offset += bytesRead;
}
const trailing = Buffer.allocUnsafe(1);
const {bytesRead: trailingBytes} = await handle.read(trailing, 0, trailing.byteLength, expectedBytes);
return trailingBytes === 0 && digest.digest('hex') === expectedSha256 ? stats : null;
} catch (error) {
throw new ModuleStoreIOError(`failed to verify installed module file ${target}`, {cause: error});
} finally {
await handle.close();
}
}
type InstalledFileStamp = readonly [size: number, mtimeMs: number, ctimeMs: number, ino: number];
interface InstalledFileStamps {
readonly manifestSha256: string;
readonly files: ReadonlyMap<string, InstalledFileStamp>;
}
function fileStampOf(stats: Stats): InstalledFileStamp {
return [stats.size, stats.mtimeMs, stats.ctimeMs, stats.ino];
}
function fileStampMatches(stamp: InstalledFileStamp | undefined, stats: Stats, expectedBytes: number): boolean {
return (
stamp !== undefined &&
stats.isFile() &&
stats.size === expectedBytes &&
stamp[0] === stats.size &&
stamp[1] === stats.mtimeMs &&
stamp[2] === stats.ctimeMs &&
stamp[3] === stats.ino
);
}
function manifestDeclaresStampFile(manifest: DesktopModuleManifest): boolean {
const reserved = MODULE_FILE_STAMPS_NAME.toLowerCase();
return manifest.files.some((file) => file.path.toLowerCase() === reserved);
}
function serializeInstalledFileStamps(manifestSha256: string, files: ReadonlyMap<string, InstalledFileStamp>): string {
return JSON.stringify({
version: MODULE_FILE_STAMPS_VERSION,
manifest_sha256: manifestSha256,
files: Object.fromEntries(files),
});
}
function isInstalledFileStamp(value: unknown): value is InstalledFileStamp {
return (
Array.isArray(value) &&
value.length === 4 &&
value.every((component) => typeof component === 'number' && Number.isFinite(component))
);
}
async function readInstalledFileStamps(directory: string): Promise<InstalledFileStamps | null> {
let raw: string;
try {
const target = path.join(directory, MODULE_FILE_STAMPS_NAME);
const stats = await fs.lstat(target);
if (!stats.isFile() || stats.size > MODULE_FILE_STAMPS_MAX_BYTES) {
return null;
}
raw = await fs.readFile(target, 'utf8');
} catch {
return null;
}
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
return null;
}
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
return null;
}
const record = parsed as Record<string, unknown>;
const manifestSha256 = record['manifest_sha256'];
const rawFiles = record['files'];
if (
record['version'] !== MODULE_FILE_STAMPS_VERSION ||
typeof manifestSha256 !== 'string' ||
!SHA256_PATTERN.test(manifestSha256) ||
typeof rawFiles !== 'object' ||
rawFiles === null ||
Array.isArray(rawFiles)
) {
return null;
}
const files = new Map<string, InstalledFileStamp>();
for (const [relativePath, stamp] of Object.entries(rawFiles as Record<string, unknown>)) {
if (isInstalledFileStamp(stamp)) {
files.set(relativePath, stamp);
}
}
return {manifestSha256, files};
}
async function writeInstalledFileStamps(
directory: string,
manifestSha256: string,
files: ReadonlyMap<string, InstalledFileStamp>,
): Promise<void> {
await writeFileAtomically(
path.join(directory, MODULE_FILE_STAMPS_NAME),
serializeInstalledFileStamps(manifestSha256, files),
MODULE_FILE_STAMPS_MODE,
);
}
async function stampExtractedModule(
directory: string,
manifest: DesktopModuleManifest,
): Promise<InstalledFileStamps | null> {
if (manifestDeclaresStampFile(manifest)) {
return null;
}
const manifestBytes = await readInstalledManifestBytes(path.join(directory, DESKTOP_MODULE_FILE_LIST_NAME));
if (manifestBytes == null) {
return null;
}
const files = new Map<string, InstalledFileStamp>();
await forEachConcurrently(manifest.files, async (file) => {
const stats = await fs.lstat(path.resolve(directory, file.path));
files.set(file.path, fileStampOf(stats));
});
return {manifestSha256: createHash('sha256').update(manifestBytes).digest('hex'), files};
}
async function forEachConcurrently<T>(
items: ReadonlyArray<T>,
operation: (item: T) => Promise<void>,
concurrency: number = INSTALLED_FILE_CHECK_CONCURRENCY,
): Promise<void> {
let next = 0;
const worker = async (): Promise<void> => {
while (next < items.length) {
const item = items[next];
next += 1;
await operation(item);
}
};
await Promise.all(Array.from({length: Math.min(concurrency, items.length)}, worker));
}
async function installedFileStats(target: string): Promise<Stats | null> {
try {
return await fs.lstat(target);
} catch (error) {
if (isMissingInstallationError(error)) {
return null;
}
throw new ModuleStoreIOError(`failed to inspect installed module file ${target}`, {cause: error});
}
}
async function readValidInstalledModule(
directory: string,
moduleName: string,
pendingStamps: InstalledFileStamps | null = null,
): Promise<ValidInstalledModule | null> {
const directoryStats = await installationPathStats(directory);
if (directoryStats == null || !directoryStats.isDirectory() || directoryStats.isSymbolicLink()) {
return null;
}
const bytes = await readInstalledManifestBytes(path.join(directory, DESKTOP_MODULE_FILE_LIST_NAME));
if (bytes == null) {
return null;
}
let manifest: DesktopModuleManifest;
try {
manifest = parseDesktopModuleManifest(bytes);
} catch {
return null;
}
if (manifest.module !== moduleName) {
return null;
}
const validatedDirectories = new Set<string>([directory]);
const files: Array<InstalledModuleFile> = [];
for (const file of manifest.files) {
const segments = file.path.split('/');
let current = directory;
for (let index = 0; index < segments.length - 1; index += 1) {
current = path.join(current, segments[index]);
if (validatedDirectories.has(current)) {
continue;
}
const parentStats = await installationPathStats(current);
if (parentStats == null || !parentStats.isDirectory() || parentStats.isSymbolicLink()) {
return null;
}
validatedDirectories.add(current);
}
const absolutePath = path.resolve(directory, file.path);
if (!isPathInsideRoot(directory, absolutePath)) {
return null;
}
files.push({relativePath: file.path, absolutePath});
}
const stampable = !manifestDeclaresStampFile(manifest);
const manifestSha256 = createHash('sha256').update(bytes).digest('hex');
const persisted = stampable ? (pendingStamps ?? (await readInstalledFileStamps(directory))) : null;
const trusted = persisted != null && persisted.manifestSha256 === manifestSha256 ? persisted.files : null;
const stamps = new Map<string, InstalledFileStamp>();
let restamped = trusted == null;
let intact = true;
await forEachConcurrently(manifest.files, async (file) => {
if (!intact) {
return;
}
const absolutePath = path.resolve(directory, file.path);
const stamp = trusted?.get(file.path);
const stats = await installedFileStats(absolutePath);
if (stats != null && fileStampMatches(stamp, stats, file.bytes)) {
stamps.set(file.path, stamp as InstalledFileStamp);
return;
}
const verified = await installedFileMatches(absolutePath, file.bytes, file.sha256);
if (verified == null) {
intact = false;
return;
}
restamped = true;
stamps.set(file.path, fileStampOf(verified));
});
if (!intact) {
return null;
}
if (stampable && restamped && pendingStamps == null) {
await writeInstalledFileStamps(directory, manifestSha256, stamps).catch(() => undefined);
}
return {manifest, files};
}
export function sameModuleMap(
left: Readonly<Record<string, string>>,
right: Readonly<Record<string, string>>,
): boolean {
const leftKeys = Object.keys(left);
if (leftKeys.length !== Object.keys(right).length) {
return false;
}
return leftKeys.every((key) => left[key] === right[key]);
}
function sortModuleMap(value: Readonly<Record<string, string>>): Record<string, string> {
const sorted: Record<string, string> = {};
for (const key of Object.keys(value).sort()) {
sorted[key] = value[key];
}
return sorted;
}
function moduleManifestFeedKey(feed: ModuleManifestFeedIdentity): string {
return `${feed.releaseChannel}/${feed.platform}/${feed.arch}`;
}
function assertManifestFeedIdentity(feed: ModuleManifestFeedIdentity): void {
for (const [name, value] of Object.entries(feed)) {
if (!MODULE_FEED_COMPONENT_PATTERN.test(value)) {
throw new ModuleStoreError(`invalid module manifest feed ${name}: ${value}`);
}
}
}
function sortManifestHighWater(
entries: ReadonlyArray<ModuleManifestHighWater>,
): ReadonlyArray<ModuleManifestHighWater> {
return Object.freeze(
[...entries]
.sort((left, right) => moduleManifestFeedKey(left).localeCompare(moduleManifestFeedKey(right)))
.map((entry) => Object.freeze({...entry})),
);
}
function parseManifestHighWater(value: unknown): ReadonlyArray<ModuleManifestHighWater> | null {
if (!Array.isArray(value) || value.length > MODULE_MANIFEST_HIGH_WATER_MAX_ENTRIES) {
return null;
}
const entries: Array<ModuleManifestHighWater> = [];
const feeds = new Set<string>();
for (const rawEntry of value) {
if (typeof rawEntry !== 'object' || rawEntry === null || Array.isArray(rawEntry)) {
return null;
}
const record = rawEntry as Record<string, unknown>;
const releaseChannel = record['release_channel'];
const platform = record['platform'];
const arch = record['arch'];
const metadataVersion = record['metadata_version'];
const manifestSha256 = record['manifest_sha256'];
if (
typeof releaseChannel !== 'string' ||
typeof platform !== 'string' ||
typeof arch !== 'string' ||
!MODULE_FEED_COMPONENT_PATTERN.test(releaseChannel) ||
!MODULE_FEED_COMPONENT_PATTERN.test(platform) ||
!MODULE_FEED_COMPONENT_PATTERN.test(arch) ||
typeof metadataVersion !== 'number' ||
!Number.isSafeInteger(metadataVersion) ||
metadataVersion < 0 ||
typeof manifestSha256 !== 'string' ||
!SHA256_PATTERN.test(manifestSha256)
) {
return null;
}
const entry = {releaseChannel, platform, arch, metadataVersion, manifestSha256};
const feedKey = moduleManifestFeedKey(entry);
if (feeds.has(feedKey)) {
return null;
}
feeds.add(feedKey);
entries.push(entry);
}
return sortManifestHighWater(entries);
}
function assertManifestObservation(observation: ModuleManifestFeedObservation): void {
assertManifestFeedIdentity(observation.feed);
if (!Number.isSafeInteger(observation.metadataVersion) || observation.metadataVersion < 0) {
throw new ModuleStoreError(`invalid module manifest metadata_version: ${observation.metadataVersion}`);
}
assertSha256(observation.manifestSha256);
}
function retainedManifestHighWater(
entries: ReadonlyArray<ModuleManifestHighWater>,
feed: ModuleManifestFeedIdentity,
): ModuleManifestHighWater | null {
const feedKey = moduleManifestFeedKey(feed);
return entries.find((entry) => moduleManifestFeedKey(entry) === feedKey) ?? null;
}
function requireManifestFresh(
entries: ReadonlyArray<ModuleManifestHighWater>,
observation: ModuleManifestFeedObservation,
): void {
assertManifestObservation(observation);
const retained = retainedManifestHighWater(entries, observation.feed);
if (retained == null) {
return;
}
if (observation.metadataVersion < retained.metadataVersion) {
throw new ModuleManifestRollbackError(observation.feed, observation.metadataVersion, retained.metadataVersion);
}
if (
observation.metadataVersion === retained.metadataVersion &&
observation.manifestSha256 !== retained.manifestSha256
) {
throw new ModuleManifestEquivocationError(observation.feed, observation.metadataVersion);
}
}
function advanceManifestHighWater(
entries: ReadonlyArray<ModuleManifestHighWater>,
observation: ModuleManifestFeedObservation,
): ReadonlyArray<ModuleManifestHighWater> {
requireManifestFresh(entries, observation);
const feedKey = moduleManifestFeedKey(observation.feed);
const retained = retainedManifestHighWater(entries, observation.feed);
if (
retained != null &&
retained.metadataVersion === observation.metadataVersion &&
retained.manifestSha256 === observation.manifestSha256
) {
return entries;
}
if (retained == null && entries.length >= MODULE_MANIFEST_HIGH_WATER_MAX_ENTRIES) {
throw new ModuleManifestHighWaterCapacityError();
}
return sortManifestHighWater([
...entries.filter((entry) => moduleManifestFeedKey(entry) !== feedKey),
{
...observation.feed,
metadataVersion: observation.metadataVersion,
manifestSha256: observation.manifestSha256,
},
]);
}
function parseModuleMap(value: unknown): Record<string, string> | null {
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
return null;
}
const parsed: Record<string, string> = {};
for (const [moduleName, sha256] of Object.entries(value as Record<string, unknown>)) {
if (!isDesktopModuleName(moduleName)) {
return null;
}
if (typeof sha256 !== 'string' || !SHA256_PATTERN.test(sha256)) {
return null;
}
parsed[moduleName] = sha256;
}
return sortModuleMap(parsed);
}
function retainRejected(
rejected: Readonly<Record<string, string>>,
current: Readonly<Record<string, string>>,
): Record<string, string> {
const retained: Record<string, string> = {};
for (const [moduleName, sha256] of Object.entries(rejected)) {
if (current[moduleName] === undefined || current[moduleName] === sha256) {
retained[moduleName] = sha256;
}
}
return retained;
}
function parseLinuxSecurityMinimum(value: unknown): LinuxModuleSecurityMinimum | null | undefined {
if (value === undefined || value === null) {
return null;
}
if (typeof value !== 'object' || Array.isArray(value)) {
return undefined;
}
const record = value as Record<string, unknown>;
const version = record['version'];
const rawRequiredModules = record['required_modules'];
if (typeof version !== 'string' || !Array.isArray(rawRequiredModules)) {
return undefined;
}
try {
parseModuleVersion(version, 'persisted Linux security minimum');
} catch {
return undefined;
}
const requiredModules: Array<string> = [];
const seen = new Set<string>();
for (const moduleName of rawRequiredModules) {
if (!isDesktopModuleName(moduleName) || seen.has(moduleName)) {
return undefined;
}
seen.add(moduleName);
requiredModules.push(moduleName);
}
return {version, requiredModules: Object.freeze(requiredModules.sort())};
}
function advanceLinuxSecurityMinimum(
current: LinuxModuleSecurityMinimum | null,
candidate: LinuxModuleSecurityMinimum,
): LinuxModuleSecurityMinimum {
const version = parseModuleVersion(candidate.version, 'Linux security minimum');
const requiredModules = Array.from(new Set(candidate.requiredModules)).sort();
if (requiredModules.length !== candidate.requiredModules.length) {
throw new ModuleStoreError('Linux security minimum contains duplicate required modules');
}
for (const moduleName of requiredModules) {
assertModuleName(moduleName);
}
if (current != null) {
const comparison = compareModuleVersions(
version,
parseModuleVersion(current.version, 'persisted Linux security minimum'),
);
if (comparison < 0) {
throw new ModuleStoreError(
`refusing to lower Linux security minimum from ${current.version} to ${candidate.version}`,
);
}
if (comparison === 0 && requiredModules.join('\0') !== current.requiredModules.join('\0')) {
throw new ModuleStoreError(`Linux security minimum ${candidate.version} changed its required modules`);
}
}
return {version: candidate.version, requiredModules: Object.freeze(requiredModules)};
}
function parseOptionalString(value: unknown): string | null | undefined {
if (value === null) {
return null;
}
return typeof value === 'string' ? value : undefined;
}
function isMissingStateFileError(error: unknown): boolean {
return (
typeof error === 'object' &&
error !== null &&
'code' in error &&
typeof error.code === 'string' &&
error.code === 'ENOENT'
);
}
function parseModuleStoreState(bytes: Buffer, statePath: string): ModuleStoreState {
let parsed: unknown;
try {
parsed = JSON.parse(bytes.toString('utf8'));
} catch (error) {
throw new ModuleStoreStateCorruptError(statePath, 'state is not valid JSON', {cause: error});
}
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
throw new ModuleStoreStateCorruptError(statePath, 'state root must be an object');
}
const record = parsed as Record<string, unknown>;
const stateVersion = record['state_version'];
if (stateVersion !== MODULE_STATE_VERSION) {
throw new ModuleStoreStateUnsupportedVersionError(statePath, stateVersion);
}
const shellVersion = record['shell_version'];
const releaseChannel = record['release_channel'];
if (
typeof shellVersion !== 'string' ||
shellVersion.length === 0 ||
typeof releaseChannel !== 'string' ||
releaseChannel.length === 0
) {
throw new ModuleStoreStateCorruptError(statePath, 'shell_version and release_channel are required');
}
const committed = parseModuleMap(record['committed']);
const previous = parseModuleMap(record['previous']);
const rejected = record['rejected'] === undefined ? {} : parseModuleMap(record['rejected']);
const floor = parseModuleMap(record['floor']);
const rawLinuxSecurityMinimum = record['linux_security_minimum'];
if (rawLinuxSecurityMinimum === undefined) {
throw new ModuleStoreStateCorruptError(statePath, 'linux_security_minimum is required');
}
const linuxSecurityMinimum = parseLinuxSecurityMinimum(rawLinuxSecurityMinimum);
if (
committed === null ||
previous === null ||
rejected === null ||
floor === null ||
linuxSecurityMinimum === undefined
) {
throw new ModuleStoreStateCorruptError(statePath, 'module maps or Linux security minimum are invalid');
}
const manifestHighWater = parseManifestHighWater(record['manifest_high_water']);
if (manifestHighWater === null) {
throw new ModuleStoreStateCorruptError(statePath, 'manifest_high_water is invalid');
}
const lastManifestEtag = parseOptionalString(record['last_manifest_etag']);
const lastManifestFetch = parseOptionalString(record['last_manifest_fetch']);
if (lastManifestEtag === undefined || lastManifestFetch === undefined) {
throw new ModuleStoreStateCorruptError(statePath, 'manifest fetch metadata is invalid');
}
const bootAttempt = record['boot_attempt'];
if (typeof bootAttempt !== 'number' || !Number.isSafeInteger(bootAttempt) || bootAttempt < 0) {
throw new ModuleStoreStateCorruptError(statePath, 'boot_attempt is invalid');
}
return {
state_version: MODULE_STATE_VERSION,
shell_version: shellVersion,
release_channel: releaseChannel,
committed,
previous,
rejected,
floor,
linux_security_minimum: linuxSecurityMinimum,
manifest_high_water: manifestHighWater,
last_manifest_etag: lastManifestEtag,
last_manifest_fetch: lastManifestFetch,
boot_attempt: bootAttempt,
};
}
function createInitialModuleStoreState(shellVersion: string, releaseChannel: string): ModuleStoreState {
return {
state_version: MODULE_STATE_VERSION,
shell_version: shellVersion,
release_channel: releaseChannel,
committed: {},
previous: {},
rejected: {},
floor: {},
linux_security_minimum: null,
manifest_high_water: [],
last_manifest_etag: null,
last_manifest_fetch: null,
boot_attempt: 0,
};
}
function serializeModuleStoreState(state: ModuleStoreState): string {
return `${JSON.stringify(
{
state_version: state.state_version,
shell_version: state.shell_version,
release_channel: state.release_channel,
committed: sortModuleMap(state.committed),
previous: sortModuleMap(state.previous),
rejected: sortModuleMap(state.rejected),
floor: sortModuleMap(state.floor),
linux_security_minimum:
state.linux_security_minimum == null
? null
: {
version: state.linux_security_minimum.version,
required_modules: state.linux_security_minimum.requiredModules,
},
manifest_high_water: state.manifest_high_water.map((entry) => ({
release_channel: entry.releaseChannel,
platform: entry.platform,
arch: entry.arch,
metadata_version: entry.metadataVersion,
manifest_sha256: entry.manifestSha256,
})),
last_manifest_etag: state.last_manifest_etag,
last_manifest_fetch: state.last_manifest_fetch,
boot_attempt: state.boot_attempt,
},
null,
'\t',
)}\n`;
}
export class ModuleStore {
public readonly root: string;
public readonly statePath: string;
public readonly downloadRoot: string;
public readonly incomingDownloadRoot: string;
public readonly storeRoot: string;
private state: ModuleStoreState;
private convergingShellVersion: string | null = null;
private launchSucceeded = false;
private activeLaunchAttempt: ModuleLaunchAttempt | null = null;
private readonly installCoordinator = new ModuleInstallCoordinator<ModuleInstallation>();
private readonly installationsAwaitingCommit = new Set<string>();
private readonly installationsAwaitingDurability = new Map<string, InstalledFileStamps | null>();
private durabilityWork: Promise<void> = Promise.resolve();
private readonly packagesInUse = new Set<string>();
private stateWrites: Promise<void> = Promise.resolve();
private storeTreeWrites: Promise<void> = Promise.resolve();
private constructor(root: string, state: ModuleStoreState) {
this.root = root;
this.statePath = path.join(root, MODULE_STATE_FILE_NAME);
this.downloadRoot = path.join(root, MODULE_DOWNLOAD_DIRECTORY_NAME);
this.incomingDownloadRoot = path.join(this.downloadRoot, MODULE_DOWNLOAD_INCOMING_DIRECTORY_NAME);
this.storeRoot = path.join(root, MODULE_STORE_TREE_DIRECTORY_NAME);
this.state = state;
}
public static async open({
root,
shellVersion,
releaseChannel,
}: {
readonly root: string;
readonly shellVersion: string;
readonly releaseChannel: string;
}): Promise<ModuleStore> {
const resolved = path.resolve(root);
await createDirectory(resolved);
await createDirectory(path.join(resolved, MODULE_DOWNLOAD_DIRECTORY_NAME, MODULE_DOWNLOAD_INCOMING_DIRECTORY_NAME));
await createDirectory(path.join(resolved, MODULE_STORE_TREE_DIRECTORY_NAME));
const stateFile = await ModuleStore.readState(path.join(resolved, MODULE_STATE_FILE_NAME));
if (stateFile.status === ModuleStoreStateFileStatus.MISSING) {
const store = new ModuleStore(resolved, createInitialModuleStoreState(shellVersion, releaseChannel));
await store.writeState(store.state);
return store;
}
const store = new ModuleStore(resolved, stateFile.state);
if (stateFile.state.shell_version !== shellVersion) {
store.convergingShellVersion = shellVersion;
}
if (stateFile.state.release_channel !== releaseChannel) {
await store.writeState({...store.state, release_channel: releaseChannel});
}
return store;
}
private static async readState(statePath: string): Promise<ModuleStoreStateFile> {
let handle: FileHandle;
try {
handle = await fs.open(statePath, readOnlyNoFollowFlags());
} catch (error) {
if (isMissingStateFileError(error)) {
return {status: ModuleStoreStateFileStatus.MISSING};
}
throw new ModuleStoreIOError(`failed to open module state at ${statePath}`, {cause: error});
}
try {
const stats = await handle.stat();
if (!stats.isFile()) {
throw new ModuleStoreStateCorruptError(statePath, 'state path is not a regular file');
}
if (!Number.isSafeInteger(stats.size) || stats.size < 0 || stats.size > MODULE_STATE_MAX_BYTES) {
throw new ModuleStoreStateCorruptError(statePath, `state exceeds ${MODULE_STATE_MAX_BYTES} bytes`);
}
const buffer = Buffer.allocUnsafe(MODULE_STATE_MAX_BYTES + 1);
let offset = 0;
while (offset < buffer.byteLength) {
const {bytesRead} = await handle.read(buffer, offset, buffer.byteLength - offset, offset);
if (bytesRead === 0) {
break;
}
offset += bytesRead;
}
if (offset > MODULE_STATE_MAX_BYTES) {
throw new ModuleStoreStateCorruptError(statePath, `state exceeds ${MODULE_STATE_MAX_BYTES} bytes`);
}
const state = parseModuleStoreState(buffer.subarray(0, offset), statePath);
return {status: ModuleStoreStateFileStatus.LOADED, state};
} catch (error) {
if (error instanceof ModuleStoreError) {
throw error;
}
throw new ModuleStoreIOError(`failed to read module state at ${statePath}`, {cause: error});
} finally {
await handle.close();
}
}
public get shellVersionChanged(): boolean {
return this.convergingShellVersion != null;
}
public async recordShellVersionConverged(): Promise<void> {
const shellVersion = this.convergingShellVersion;
if (shellVersion == null) {
return;
}
await this.withStateLock(async () => {
if (this.state.shell_version !== shellVersion) {
await this.writeState({...this.state, shell_version: shellVersion});
}
});
}
public getState(): ModuleStoreState {
return this.state;
}
public getCommitted(): Readonly<Record<string, string>> {
return this.state.committed;
}
public getPackagePath(sha256: string): string {
assertSha256(sha256);
return path.join(this.downloadRoot, `${sha256}${MODULE_PACKAGE_FILE_EXTENSION}`);
}
public getModuleDirectory(moduleName: string, sha256: string): string {
assertModuleName(moduleName);
assertSha256(sha256);
return path.join(this.storeRoot, moduleName, sha256);
}
public async isInstalled(moduleName: string, sha256: string): Promise<boolean> {
return (await this.getInstalledManifest(moduleName, sha256)) != null;
}
public async getInstalledManifest(moduleName: string, sha256: string): Promise<DesktopModuleManifest | null> {
assertModuleName(moduleName);
assertSha256(sha256);
return (
(await this.readValidInstallation(this.getModuleDirectory(moduleName, sha256), moduleName))?.manifest ?? null
);
}
public async installModule({
module: moduleName,
sha256,
download,
}: {
readonly module: string;
readonly sha256: string;
readonly download: ModulePackageDownloader;
}): Promise<ModuleInstallation> {
assertModuleName(moduleName);
assertSha256(sha256);
return this.installCoordinator.run(moduleName, sha256, () =>
this.installModuleExclusively(moduleName, sha256, download),
);
}
private async installModuleExclusively(
moduleName: string,
sha256: string,
download: ModulePackageDownloader,
): Promise<ModuleInstallation> {
const packagePath = this.getPackagePath(sha256);
this.packagesInUse.add(sha256);
try {
const cached = await isRegularFile(packagePath);
if (!cached) {
await this.downloadPackage(moduleName, sha256, packagePath, download);
}
return await this.withStoreTreeLock(() => this.publishInstallation(moduleName, sha256, packagePath, cached));
} finally {
this.packagesInUse.delete(sha256);
}
}
private async publishInstallation(
moduleName: string,
sha256: string,
packagePath: string,
cached: boolean,
): Promise<ModuleInstallation> {
const directory = this.getModuleDirectory(moduleName, sha256);
const installed = await this.readValidInstallation(directory, moduleName);
if (installed != null) {
this.installationsAwaitingCommit.add(`${moduleName}/${sha256}`);
return {module: moduleName, sha256, directory, manifest: installed.manifest};
}
const incoming = path.join(this.storeRoot, `${MODULE_INCOMING_STORE_PREFIX}${sha256}`);
await removeTree(incoming);
const aside = path.join(
this.storeRoot,
`${MODULE_REPLACED_STORE_PREFIX}${sha256}-${randomBytes(8).toString('hex')}`,
);
let replaced = false;
let manifest: DesktopModuleManifest;
let stamps: InstalledFileStamps | null = null;
try {
manifest = await verifyAndExtract({
packedPath: packagePath,
expectedSha256: sha256,
destinationDir: incoming,
});
if (manifest.module !== moduleName) {
throw new ModuleStoreError(`package ${sha256} declares module ${manifest.module}, expected ${moduleName}`);
}
stamps = await stampExtractedModule(incoming, manifest).catch(() => null);
await createDirectory(path.dirname(directory));
replaced = await renameAside(directory, aside);
try {
await renameWithRetry(incoming, directory);
} catch (error) {
if (replaced) {
replaced = false;
await renameWithRetry(aside, directory).catch(() => undefined);
}
throw new ModuleStoreIOError(`failed to publish ${directory}`, {cause: error});
}
} catch (error) {
await removeTree(incoming);
if (cached && isPackageDigestMismatch(error)) {
await fs.rm(packagePath, {force: true});
}
throw error;
}
await fsyncDirectory(path.dirname(directory));
this.installationsAwaitingCommit.add(`${moduleName}/${sha256}`);
this.installationsAwaitingDurability.set(directory, stamps);
if (this.launchSucceeded) {
void this.settleInstalledModules();
}
if (replaced) {
await removeTree(aside).catch(() => undefined);
}
return {module: moduleName, sha256, directory, manifest};
}
public settleInstalledModules(): Promise<void> {
const settle = async (): Promise<void> => {
for (const [directory, stamps] of [...this.installationsAwaitingDurability]) {
await makeInstallationDurable(directory, stamps).catch(() => undefined);
if (this.installationsAwaitingDurability.get(directory) === stamps) {
this.installationsAwaitingDurability.delete(directory);
}
}
};
this.durabilityWork = this.durabilityWork.then(settle, settle);
return this.durabilityWork;
}
private readValidInstallation(directory: string, moduleName: string): Promise<ValidInstalledModule | null> {
return readValidInstalledModule(directory, moduleName, this.installationsAwaitingDurability.get(directory) ?? null);
}
public getPartialPackagePath(sha256: string): string {
assertSha256(sha256);
return path.join(this.incomingDownloadRoot, `${sha256}${MODULE_PARTIAL_PACKAGE_FILE_EXTENSION}`);
}
private async downloadPackage(
moduleName: string,
sha256: string,
packagePath: string,
download: ModulePackageDownloader,
): Promise<void> {
await createDirectory(this.incomingDownloadRoot);
const partial = this.getPartialPackagePath(sha256);
const handle = await openPartialPackage(partial);
let digest = createHash('sha256');
let position: number;
try {
position = await hashPartialPackage(handle, digest);
} catch (error) {
await handle.close();
await fs.rm(partial, {force: true});
throw new ModuleStoreIOError(`failed to read ${partial}`, {cause: error});
}
try {
const stream = await download(position);
if (stream.offset !== position) {
if (stream.offset !== 0) {
throw new ModuleStoreError(
`package download for ${moduleName} resumed at byte ${stream.offset}, expected ${position}`,
);
}
await handle.truncate(0);
digest = createHash('sha256');
position = 0;
}
for await (const chunk of stream.chunks) {
digest.update(chunk);
let written = 0;
while (written < chunk.byteLength) {
const {bytesWritten} = await handle.write(chunk, written, chunk.byteLength - written, position);
written += bytesWritten;
position += bytesWritten;
}
}
await handle.sync();
} catch (error) {
await handle.close();
throw error instanceof ModuleStoreError
? error
: new ModuleStoreIOError(`failed to download module ${moduleName}`, {cause: error});
}
await handle.close();
const actual = digest.digest('hex');
if (actual !== sha256) {
await fs.rm(partial, {force: true});
throw new ModuleStoreDownloadHashMismatchError(moduleName, sha256, actual);
}
try {
await renameWithRetry(partial, packagePath);
} catch (error) {
throw new ModuleStoreIOError(`failed to publish ${packagePath}`, {cause: error});
}
await fsyncDirectory(this.downloadRoot);
}
public async commit(next: Readonly<Record<string, string>>): Promise<ModuleStoreState> {
return await this.withStateLock(async () => {
const committed = await this.resolveInstalled(next);
return await this.writeState({...this.state, committed, previous: this.state.committed});
});
}
public async mergeCommitted(entries: Readonly<Record<string, string>>): Promise<ModuleStoreState> {
return await this.withStateLock(async () => {
const added = await this.resolveInstalled(entries);
const committed = {...this.state.committed, ...added};
if (sameModuleMap(this.state.committed, committed)) {
return this.state;
}
return await this.writeState({...this.state, committed});
});
}
public async activateMergedForRendererReload(
installed: Readonly<Record<string, string>>,
dropped: ReadonlySet<string> = new Set(),
): Promise<ModuleLaunchAttempt | null> {
return await this.withStateLock(async () => {
const merged: Record<string, string> = {...this.state.committed, ...installed};
for (const moduleName of dropped) {
if (installed[moduleName] === undefined) {
delete merged[moduleName];
}
}
const committed = await this.resolveInstalled(merged);
if (sameModuleMap(this.state.committed, committed)) {
return null;
}
const previous = this.state.committed;
const activated = await this.writeState({
...this.state,
committed,
previous,
boot_attempt: 1,
});
return this.createLaunchAttempt(activated.committed);
});
}
private async resolveInstalled(next: Readonly<Record<string, string>>): Promise<Record<string, string>> {
const resolved: Record<string, string> = {};
for (const moduleName of Object.keys(next).sort()) {
const sha256 = next[moduleName];
assertModuleName(moduleName);
assertSha256(sha256);
const directory = this.getModuleDirectory(moduleName, sha256);
if ((await this.readValidInstallation(directory, moduleName)) == null) {
throw new ModuleStoreInstallationMissingError(moduleName, sha256, directory);
}
resolved[moduleName] = sha256;
}
return resolved;
}
public requireManifestFresh(observation: ModuleManifestFeedObservation): void {
requireManifestFresh(this.state.manifest_high_water, observation);
}
public async recordManifestFetch({
etag,
fetchedAt,
manifest,
floor,
linuxSecurityMinimum,
advertised,
}: {
readonly etag: string | null;
readonly fetchedAt: string;
readonly manifest: ModuleManifestFeedObservation;
readonly floor?: Readonly<Record<string, string>>;
readonly linuxSecurityMinimum?: LinuxModuleSecurityMinimum;
readonly advertised?: Readonly<Record<string, string>>;
}): Promise<ModuleStoreState> {
return await this.withStateLock(async () => {
const manifestHighWater = advanceManifestHighWater(this.state.manifest_high_water, manifest);
return await this.writeState({
...this.state,
rejected: advertised == null ? this.state.rejected : retainRejected(this.state.rejected, advertised),
last_manifest_etag: etag,
last_manifest_fetch: fetchedAt,
floor: floor == null ? this.state.floor : sortModuleMap(floor),
linux_security_minimum:
linuxSecurityMinimum == null
? this.state.linux_security_minimum
: advanceLinuxSecurityMinimum(this.state.linux_security_minimum, linuxSecurityMinimum),
manifest_high_water: manifestHighWater,
});
});
}
public async beginBootAttempt(): Promise<ModuleBootAttempt> {
return await this.withStateLock(async () => {
if (this.state.boot_attempt < MODULE_BOOT_ATTEMPT_ROLLBACK_THRESHOLD) {
return {rolledBack: false, bootAttempt: this.state.boot_attempt, committed: this.state.committed};
}
const revertedCommitted =
Object.keys(this.state.previous).length === 0 ? this.state.committed : sortModuleMap(this.state.previous);
const rolledBack = !sameModuleMap(this.state.committed, revertedCommitted);
const rejected: Record<string, string> = {...this.state.rejected};
if (rolledBack) {
for (const [moduleName, sha256] of Object.entries(this.state.committed)) {
if (revertedCommitted[moduleName] !== sha256) {
rejected[moduleName] = sha256;
}
}
}
const reverted = await this.writeState({
...this.state,
committed: revertedCommitted,
rejected,
boot_attempt: 0,
});
return {rolledBack, bootAttempt: reverted.boot_attempt, committed: reverted.committed};
});
}
public async recordLaunchAttempt(): Promise<ModuleLaunchAttempt> {
return await this.withStateLock(async () => {
const next = await this.writeState({...this.state, boot_attempt: this.state.boot_attempt + 1});
return this.createLaunchAttempt(next.committed);
});
}
public async markLaunchAttemptSucceeded(attempt: ModuleLaunchAttempt): Promise<boolean> {
return await this.withStateLock(async () => {
if (this.activeLaunchAttempt !== attempt) {
return false;
}
if (this.state.boot_attempt !== 0) {
await this.writeState({...this.state, boot_attempt: 0});
}
this.activeLaunchAttempt = null;
this.launchSucceeded = true;
void this.settleInstalledModules();
return true;
});
}
public isRejected(moduleName: string, sha256: string): boolean {
return this.state.rejected[moduleName] === sha256;
}
public async buildModuleIndex(
committed: Readonly<Record<string, string>> = this.state.committed,
): Promise<Map<string, string>> {
const index = new Map<string, string>();
const owners = new Map<string, string>();
for (const moduleName of Object.keys(committed).sort()) {
const sha256 = committed[moduleName];
const directory = this.getModuleDirectory(moduleName, sha256);
const installed = await this.readValidInstallation(directory, moduleName);
if (installed == null) {
throw new ModuleStoreInstallationMissingError(moduleName, sha256, directory);
}
for (const file of installed.files) {
const existingOwner = owners.get(file.relativePath);
if (existingOwner != null) {
throw new ModuleStoreError(
`modules ${existingOwner} and ${moduleName} both declare served path ${file.relativePath}`,
);
}
owners.set(file.relativePath, moduleName);
index.set(file.relativePath, file.absolutePath);
}
}
return index;
}
public async collectGarbage({
now = Date.now(),
}: {
readonly now?: number;
} = {}): Promise<ModuleGarbageCollectionResult> {
if (!this.launchSucceeded) {
throw new ModuleStoreGarbageCollectionOrderError();
}
return await this.withStoreTreeLock(() => this.collectGarbageExclusively(now));
}
private async collectGarbageExclusively(now: number): Promise<ModuleGarbageCollectionResult> {
const keep = new Set<string>(this.installationsAwaitingCommit);
for (const map of [this.state.committed, this.state.previous]) {
for (const [moduleName, sha256] of Object.entries(map)) {
keep.add(`${moduleName}/${sha256}`);
}
}
const removedDirectories = await this.removeUnkeptInstallations(keep);
const stalePackages = await this.removeStalePackages(now);
const staleIncoming = await this.removeStaleIncomingDownloads(now);
return {removedDirectories, removedPackages: [...stalePackages, ...staleIncoming]};
}
private async removeUnkeptInstallations(keep: ReadonlySet<string>): Promise<Array<string>> {
const removedDirectories: Array<string> = [];
for (const entry of await readDirectoryEntries(this.storeRoot)) {
const child = path.join(this.storeRoot, entry.name);
if (!entry.isDirectory() || !isDesktopModuleName(entry.name)) {
await removeTree(child);
removedDirectories.push(child);
continue;
}
let retained = 0;
for (const version of await readDirectoryEntries(child)) {
const versionPath = path.join(child, version.name);
if (version.isDirectory() && SHA256_PATTERN.test(version.name) && keep.has(`${entry.name}/${version.name}`)) {
retained += 1;
continue;
}
await removeTree(versionPath);
removedDirectories.push(versionPath);
}
if (retained === 0) {
await removeTree(child);
removedDirectories.push(child);
}
}
return removedDirectories;
}
private async removeStalePackages(now: number): Promise<Array<string>> {
const removedPackages: Array<string> = [];
for (const entry of await readDirectoryEntries(this.downloadRoot)) {
if (entry.name === MODULE_DOWNLOAD_INCOMING_DIRECTORY_NAME) {
continue;
}
const child = path.join(this.downloadRoot, entry.name);
const packageSha256 = entry.name.slice(0, -MODULE_PACKAGE_FILE_EXTENSION.length);
const named =
entry.isFile() && entry.name.endsWith(MODULE_PACKAGE_FILE_EXTENSION) && SHA256_PATTERN.test(packageSha256);
if (!named) {
await removeTree(child);
removedPackages.push(child);
continue;
}
if (this.packagesInUse.has(packageSha256)) {
continue;
}
const modified = await modifiedAt(child);
if (modified != null && now - modified > MODULE_PACKAGE_MAX_AGE_MS) {
await removeTree(child);
removedPackages.push(child);
}
}
return removedPackages;
}
private async removeStaleIncomingDownloads(now: number): Promise<Array<string>> {
const removedPackages: Array<string> = [];
for (const entry of await readDirectoryEntries(this.incomingDownloadRoot)) {
if (
entry.name.endsWith(MODULE_PARTIAL_PACKAGE_FILE_EXTENSION) &&
this.packagesInUse.has(entry.name.slice(0, -MODULE_PARTIAL_PACKAGE_FILE_EXTENSION.length))
) {
continue;
}
const child = path.join(this.incomingDownloadRoot, entry.name);
const modified = await modifiedAt(child);
if (modified != null && now - modified > MODULE_PACKAGE_MAX_AGE_MS) {
await removeTree(child);
removedPackages.push(child);
}
}
return removedPackages;
}
private withStateLock<T>(operation: () => Promise<T>): Promise<T> {
const started = this.stateWrites.then(operation, operation);
this.stateWrites = started.then(
() => undefined,
() => undefined,
);
return started;
}
private withStoreTreeLock<T>(operation: () => Promise<T>): Promise<T> {
const started = this.storeTreeWrites.then(operation, operation);
this.storeTreeWrites = started.then(
() => undefined,
() => undefined,
);
return started;
}
private createLaunchAttempt(committed: Readonly<Record<string, string>>): ModuleLaunchAttempt {
const launchAttempt = Object.freeze({
[MODULE_LAUNCH_ATTEMPT]: true as const,
committed: Object.freeze({...committed}),
});
this.activeLaunchAttempt = launchAttempt;
this.launchSucceeded = false;
return launchAttempt;
}
private async writeState(next: ModuleStoreState): Promise<ModuleStoreState> {
await writeFileAtomically(this.statePath, serializeModuleStoreState(next), STATE_FILE_MODE);
this.state = {
...next,
committed: sortModuleMap(next.committed),
previous: sortModuleMap(next.previous),
rejected: sortModuleMap(next.rejected),
floor: sortModuleMap(next.floor),
linux_security_minimum:
next.linux_security_minimum == null
? null
: {
version: next.linux_security_minimum.version,
requiredModules: Object.freeze([...next.linux_security_minimum.requiredModules]),
},
manifest_high_water: sortManifestHighWater(next.manifest_high_water),
};
for (const [moduleName, sha256] of Object.entries(this.state.committed)) {
this.installationsAwaitingCommit.delete(`${moduleName}/${sha256}`);
}
return this.state;
}
}