fix(mosaic): fail closed on unsafe brain cleanup and config
This commit is contained in:
@@ -1,7 +1,7 @@
|
|||||||
import { homedir } from 'node:os';
|
import { homedir } from 'node:os';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { readRegularFileSecure } from '../fleet/secure-file.js';
|
import { readBrainConfigSecure } from './brain-secure-config.js';
|
||||||
import { resolveBrainOwnerPolicy } from './brain-owner-resolver.js';
|
import { resolveBrainOwnerPolicy } from './brain-owner-resolver.js';
|
||||||
import { deriveBrainTarget } from './brain-store.js';
|
import { deriveBrainTarget } from './brain-store.js';
|
||||||
import {
|
import {
|
||||||
@@ -11,7 +11,6 @@ import {
|
|||||||
type DoctorRuntimeReport,
|
type DoctorRuntimeReport,
|
||||||
} from './brain-store-runtime.js';
|
} from './brain-store-runtime.js';
|
||||||
|
|
||||||
const MAX_CONFIG_BYTES = 256 * 1024;
|
|
||||||
const IDENTITY = /^[A-Za-z0-9][A-Za-z0-9_.-]*$/;
|
const IDENTITY = /^[A-Za-z0-9][A-Za-z0-9_.-]*$/;
|
||||||
const manifestSchema = z
|
const manifestSchema = z
|
||||||
.object({
|
.object({
|
||||||
@@ -40,15 +39,6 @@ function configurationError(code: string, reasonCode = code): InstalledDoctorRes
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function readUtf8(path: string, root: string): string {
|
|
||||||
const snapshot = readRegularFileSecure(path, { root, maxBytes: MAX_CONFIG_BYTES });
|
|
||||||
try {
|
|
||||||
return new TextDecoder('utf-8', { fatal: true }).decode(snapshot.content);
|
|
||||||
} catch {
|
|
||||||
throw new Error('config-not-utf8');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderReport(report: DoctorRuntimeReport): InstalledDoctorResult {
|
function renderReport(report: DoctorRuntimeReport): InstalledDoctorResult {
|
||||||
const findings = report.findings.map(
|
const findings = report.findings.map(
|
||||||
(finding): InstalledDoctorFinding => ({
|
(finding): InstalledDoctorFinding => ({
|
||||||
@@ -95,13 +85,13 @@ export function runInstalledBrainDoctorCheck(
|
|||||||
const ownerPolicyPath = join(options.mosaicHome, 'brain', 'owners.json');
|
const ownerPolicyPath = join(options.mosaicHome, 'brain', 'owners.json');
|
||||||
let registrySource: string;
|
let registrySource: string;
|
||||||
try {
|
try {
|
||||||
registrySource = readUtf8(registryPath, options.mosaicHome);
|
registrySource = readBrainConfigSecure(registryPath, options.mosaicHome);
|
||||||
} catch {
|
} catch {
|
||||||
return configurationError('brain-estate-registry-unavailable');
|
return configurationError('brain-estate-registry-unavailable');
|
||||||
}
|
}
|
||||||
let manifestSource: string;
|
let manifestSource: string;
|
||||||
try {
|
try {
|
||||||
manifestSource = readUtf8(manifestPath, options.mosaicHome);
|
manifestSource = readBrainConfigSecure(manifestPath, options.mosaicHome);
|
||||||
} catch {
|
} catch {
|
||||||
return configurationError('brain-install-manifest-unavailable');
|
return configurationError('brain-install-manifest-unavailable');
|
||||||
}
|
}
|
||||||
@@ -115,7 +105,7 @@ export function runInstalledBrainDoctorCheck(
|
|||||||
if (!manifest.success) return configurationError('brain-install-manifest-invalid');
|
if (!manifest.success) return configurationError('brain-install-manifest-invalid');
|
||||||
let ownerPolicySource: string;
|
let ownerPolicySource: string;
|
||||||
try {
|
try {
|
||||||
ownerPolicySource = readUtf8(ownerPolicyPath, options.mosaicHome);
|
ownerPolicySource = readBrainConfigSecure(ownerPolicyPath, options.mosaicHome);
|
||||||
} catch {
|
} catch {
|
||||||
return configurationError('brain-owner-policy-unavailable');
|
return configurationError('brain-owner-policy-unavailable');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,12 +2,11 @@ import { randomUUID } from 'node:crypto';
|
|||||||
import { homedir } from 'node:os';
|
import { homedir } from 'node:os';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import type { Command } from 'commander';
|
import type { Command } from 'commander';
|
||||||
import { readRegularFileSecure } from '../fleet/secure-file.js';
|
import { readBrainConfigSecure } from './brain-secure-config.js';
|
||||||
import { provisionBrain, type ProvisionResult } from './brain-provision.js';
|
import { provisionBrain, type ProvisionResult } from './brain-provision.js';
|
||||||
import { systemCommandRunner, type CommandRunner } from './brain-store-runtime.js';
|
import { systemCommandRunner, type CommandRunner } from './brain-store-runtime.js';
|
||||||
import type { OwnerFetch } from './brain-owner-resolver.js';
|
import type { OwnerFetch } from './brain-owner-resolver.js';
|
||||||
|
|
||||||
const MAX_POLICY_BYTES = 256 * 1024;
|
|
||||||
export const BRAIN_PROVISION_COMMAND = '__brain-provision';
|
export const BRAIN_PROVISION_COMMAND = '__brain-provision';
|
||||||
|
|
||||||
interface BrainProvisionCommandOptions {
|
interface BrainProvisionCommandOptions {
|
||||||
@@ -40,11 +39,6 @@ function configFailure(reasonCode: string): ProvisionResult {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function readConfig(path: string, root: string): string {
|
|
||||||
const content = readRegularFileSecure(path, { root, maxBytes: MAX_POLICY_BYTES }).content;
|
|
||||||
return new TextDecoder('utf-8', { fatal: true }).decode(content);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function executeBrainProvisionCommand(
|
export async function executeBrainProvisionCommand(
|
||||||
options: BrainProvisionCommandOptions,
|
options: BrainProvisionCommandOptions,
|
||||||
dependencies: BrainProvisionCommandDependencies,
|
dependencies: BrainProvisionCommandDependencies,
|
||||||
@@ -53,13 +47,13 @@ export async function executeBrainProvisionCommand(
|
|||||||
const ownerPolicy = options.ownerPolicy ?? join(options.mosaicHome, 'brain', 'owners.json');
|
const ownerPolicy = options.ownerPolicy ?? join(options.mosaicHome, 'brain', 'owners.json');
|
||||||
let estateRegistrySource: string;
|
let estateRegistrySource: string;
|
||||||
try {
|
try {
|
||||||
estateRegistrySource = readConfig(registry, options.mosaicHome);
|
estateRegistrySource = readBrainConfigSecure(registry, options.mosaicHome);
|
||||||
} catch {
|
} catch {
|
||||||
return configFailure('estate-registry-unavailable');
|
return configFailure('estate-registry-unavailable');
|
||||||
}
|
}
|
||||||
let ownerPolicySource: string;
|
let ownerPolicySource: string;
|
||||||
try {
|
try {
|
||||||
ownerPolicySource = readConfig(ownerPolicy, options.mosaicHome);
|
ownerPolicySource = readBrainConfigSecure(ownerPolicy, options.mosaicHome);
|
||||||
} catch {
|
} catch {
|
||||||
return configFailure('owner-policy-unavailable');
|
return configFailure('owner-policy-unavailable');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -365,11 +365,15 @@ describe('P7 brain provisioning orchestration', (): void => {
|
|||||||
status: 'provisioned',
|
status: 'provisioned',
|
||||||
reasonCode: 'brain-provisioned',
|
reasonCode: 'brain-provisioned',
|
||||||
owner: { verdict: 'resolved', reasonCode: 'owner-verified' },
|
owner: { verdict: 'resolved', reasonCode: 'owner-verified' },
|
||||||
migration: { status: 'migrated' },
|
migration: { status: 'reported' },
|
||||||
});
|
});
|
||||||
expect(existsSync(source)).toBe(false);
|
expect(existsSync(source)).toBe(true);
|
||||||
const imported = result.migration?.reported ?? [];
|
const imported = result.migration?.reported ?? [];
|
||||||
expect(imported).toEqual([]);
|
expect(imported).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
expect.objectContaining({ path: source, reason: expect.stringMatching(/retained/i) }),
|
||||||
|
]),
|
||||||
|
);
|
||||||
const laneImports = join(input.root, 'lanes', 'lane-a', 'findings', 'imports');
|
const laneImports = join(input.root, 'lanes', 'lane-a', 'findings', 'imports');
|
||||||
const archiveImports = join(input.root, 'archives', 'imports', 'lane');
|
const archiveImports = join(input.root, 'archives', 'imports', 'lane');
|
||||||
expect(existsSync(laneImports)).toBe(true);
|
expect(existsSync(laneImports)).toBe(true);
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { afterEach, describe, expect, it } from 'vitest';
|
||||||
|
import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
|
||||||
|
interface SecureConfigModule {
|
||||||
|
readBrainConfigSecure(path: string, root: string): string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const roots: string[] = [];
|
||||||
|
|
||||||
|
async function loadSecureConfig(): Promise<SecureConfigModule> {
|
||||||
|
try {
|
||||||
|
return (await import('./brain-secure-config.js')) as SecureConfigModule;
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const detail = error instanceof Error ? error.message : String(error);
|
||||||
|
throw new Error(`MB-REQ-06 secure brain config reader is absent (${detail})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function fixture(): { readonly root: string; readonly directory: string; readonly file: string } {
|
||||||
|
const outer = mkdtempSync(join(tmpdir(), 'mosaic-brain-secure-config-'));
|
||||||
|
roots.push(outer);
|
||||||
|
const root = join(outer, 'mosaic');
|
||||||
|
const directory = join(root, 'brain');
|
||||||
|
const file = join(directory, 'owners.json');
|
||||||
|
mkdirSync(directory, { recursive: true, mode: 0o700 });
|
||||||
|
writeFileSync(file, '{"version":1}\n', { mode: 0o600 });
|
||||||
|
return { root, directory, file };
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach((): void => {
|
||||||
|
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('security-critical brain configuration reads', (): void => {
|
||||||
|
it('reads a principal-owned non-writable regular file through the secure descriptor path', async (): Promise<void> => {
|
||||||
|
const secure = await loadSecureConfig();
|
||||||
|
const config = fixture();
|
||||||
|
|
||||||
|
expect(secure.readBrainConfigSecure(config.file, config.root)).toBe('{"version":1}\n');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a group/world-writable policy file', async (): Promise<void> => {
|
||||||
|
const secure = await loadSecureConfig();
|
||||||
|
const config = fixture();
|
||||||
|
chmodSync(config.file, 0o666);
|
||||||
|
|
||||||
|
expect(() => secure.readBrainConfigSecure(config.file, config.root)).toThrow(
|
||||||
|
/config-file-permissions-unsafe/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a group/world-writable managed ancestor', async (): Promise<void> => {
|
||||||
|
const secure = await loadSecureConfig();
|
||||||
|
const config = fixture();
|
||||||
|
chmodSync(config.directory, 0o777);
|
||||||
|
|
||||||
|
expect(() => secure.readBrainConfigSecure(config.file, config.root)).toThrow(
|
||||||
|
/config-ancestor-permissions-unsafe/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { lstatSync } from 'node:fs';
|
||||||
|
import { dirname, relative, resolve, sep } from 'node:path';
|
||||||
|
import { assertCanonicalContainment, readRegularFileSecure } from '../fleet/secure-file.js';
|
||||||
|
|
||||||
|
const MAX_CONFIG_BYTES = 256 * 1024;
|
||||||
|
const GROUP_OR_OTHER_WRITE = 0o022;
|
||||||
|
|
||||||
|
function currentUid(): number {
|
||||||
|
if (typeof process.getuid !== 'function') {
|
||||||
|
throw new Error('config-owner-check-unsupported');
|
||||||
|
}
|
||||||
|
return process.getuid();
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertOwnedNonWritableDirectory(path: string, uid: number): void {
|
||||||
|
const status = lstatSync(path);
|
||||||
|
if (!status.isDirectory() || status.isSymbolicLink() || status.uid !== uid) {
|
||||||
|
throw new Error('config-ancestor-owner-unsafe');
|
||||||
|
}
|
||||||
|
if ((status.mode & GROUP_OR_OTHER_WRITE) !== 0) {
|
||||||
|
throw new Error('config-ancestor-permissions-unsafe');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readBrainConfigSecure(path: string, root: string): string {
|
||||||
|
const canonicalRoot = resolve(root);
|
||||||
|
const canonicalPath = resolve(path);
|
||||||
|
assertCanonicalContainment(canonicalRoot, canonicalPath);
|
||||||
|
const uid = currentUid();
|
||||||
|
assertOwnedNonWritableDirectory(canonicalRoot, uid);
|
||||||
|
let cursor = canonicalRoot;
|
||||||
|
for (const component of relative(canonicalRoot, dirname(canonicalPath))
|
||||||
|
.split(sep)
|
||||||
|
.filter(Boolean)) {
|
||||||
|
cursor = resolve(cursor, component);
|
||||||
|
assertOwnedNonWritableDirectory(cursor, uid);
|
||||||
|
}
|
||||||
|
|
||||||
|
const snapshot = readRegularFileSecure(canonicalPath, {
|
||||||
|
root: canonicalRoot,
|
||||||
|
maxBytes: MAX_CONFIG_BYTES,
|
||||||
|
});
|
||||||
|
if (snapshot.uid !== uid) throw new Error('config-file-owner-unsafe');
|
||||||
|
if ((snapshot.mode & GROUP_OR_OTHER_WRITE) !== 0) {
|
||||||
|
throw new Error('config-file-permissions-unsafe');
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return new TextDecoder('utf-8', { fatal: true }).decode(snapshot.content);
|
||||||
|
} catch {
|
||||||
|
throw new Error('config-file-not-utf8');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -138,7 +138,10 @@ interface BrainStoreModule {
|
|||||||
plan: MigrationPlan,
|
plan: MigrationPlan,
|
||||||
publish: (brainRoot: string, paths: readonly string[]) => MigrationPublishEvidence,
|
publish: (brainRoot: string, paths: readonly string[]) => MigrationPublishEvidence,
|
||||||
brainRoot: string,
|
brainRoot: string,
|
||||||
hooks?: { readonly beforeDestinationWrite?: (destination: string) => void },
|
hooks?: {
|
||||||
|
readonly beforeDestinationWrite?: (destination: string) => void;
|
||||||
|
readonly beforeSourceCleanup?: (source: string) => void;
|
||||||
|
},
|
||||||
): MigrationResult;
|
): MigrationResult;
|
||||||
evaluateBrainDoctor(
|
evaluateBrainDoctor(
|
||||||
observation: BrainDoctorObservation,
|
observation: BrainDoctorObservation,
|
||||||
@@ -683,7 +686,7 @@ describe('R7 — migration is non-destructive, append-only, and explicit', (): v
|
|||||||
expect(existsSync(brainRoot)).toBe(false);
|
expect(existsSync(brainRoot)).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('publishes collision-safe append-only copies before archiving sources and never overwrites a finding', async (): Promise<void> => {
|
it('publishes collision-safe append-only copies, retains the source explicitly, and never overwrites a finding', async (): Promise<void> => {
|
||||||
const sut = await loadSut('MB-REQ-07 append-only publish-before-archive migration');
|
const sut = await loadSut('MB-REQ-07 append-only publish-before-archive migration');
|
||||||
const root = tempRoot();
|
const root = tempRoot();
|
||||||
const sourceRoot = join(root, 'local-memory');
|
const sourceRoot = join(root, 'local-memory');
|
||||||
@@ -718,11 +721,19 @@ describe('R7 — migration is non-destructive, append-only, and explicit', (): v
|
|||||||
brainRoot,
|
brainRoot,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(result.status).toBe('migrated');
|
expect(result.status).toBe('reported');
|
||||||
expect(readFileSync(preexisting, 'utf8')).toBe('older independent finding\n');
|
expect(readFileSync(preexisting, 'utf8')).toBe('older independent finding\n');
|
||||||
expect(readFileSync(candidate.destination, 'utf8')).toBe('new finding\n');
|
expect(readFileSync(candidate.destination, 'utf8')).toBe('new finding\n');
|
||||||
expect(readFileSync(candidate.archive, 'utf8')).toBe('new finding\n');
|
expect(readFileSync(candidate.archive, 'utf8')).toBe('new finding\n');
|
||||||
expect(existsSync(candidate.source)).toBe(false);
|
expect(existsSync(candidate.source)).toBe(true);
|
||||||
|
expect(result.reported).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
expect.objectContaining({
|
||||||
|
path: candidate.source,
|
||||||
|
reason: expect.stringMatching(/retained/i),
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
);
|
||||||
expect(publishedPaths).toContain(candidate.destination);
|
expect(publishedPaths).toContain(candidate.destination);
|
||||||
expect(publishedPaths).toContain(candidate.archive);
|
expect(publishedPaths).toContain(candidate.archive);
|
||||||
});
|
});
|
||||||
@@ -759,7 +770,7 @@ describe('R7 — migration is non-destructive, append-only, and explicit', (): v
|
|||||||
expect.arrayContaining([
|
expect.arrayContaining([
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
path: source,
|
path: source,
|
||||||
reason: expect.stringMatching(/changed|identity/i),
|
reason: expect.stringMatching(/retained|cleanup/i),
|
||||||
}),
|
}),
|
||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
@@ -840,6 +851,47 @@ describe('R7 — migration is non-destructive, append-only, and explicit', (): v
|
|||||||
expect(publishCalls).toBe(0);
|
expect(publishCalls).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('never path-unlinks a source when identity-aware unlink is unavailable', async (): Promise<void> => {
|
||||||
|
const sut = await loadSut('MB-REQ-07 no path-based source unlink');
|
||||||
|
const root = tempRoot();
|
||||||
|
const sourceRoot = join(root, 'local-memory');
|
||||||
|
const brainRoot = join(root, 'brain');
|
||||||
|
mkdirSync(join(sourceRoot, 'lanes', 'lane-a'), { recursive: true });
|
||||||
|
const source = join(sourceRoot, 'lanes', 'lane-a', 'finding.md');
|
||||||
|
writeFileSync(source, 'published snapshot\n');
|
||||||
|
const plan = sut.discoverBrainMigration(
|
||||||
|
{ sourceRoot, brainRoot, seat: 'seat-a', lane: 'lane-a', laneActive: true },
|
||||||
|
activeLaneOwner,
|
||||||
|
);
|
||||||
|
let cleanupHookCalled = false;
|
||||||
|
|
||||||
|
const result = sut.migrateBrainState(
|
||||||
|
plan,
|
||||||
|
(): MigrationPublishEvidence => ({
|
||||||
|
commit: 'a'.repeat(40),
|
||||||
|
remoteHead: 'a'.repeat(40),
|
||||||
|
reachable: true,
|
||||||
|
}),
|
||||||
|
brainRoot,
|
||||||
|
{
|
||||||
|
beforeSourceCleanup: (): void => {
|
||||||
|
cleanupHookCalled = true;
|
||||||
|
rmSync(source);
|
||||||
|
writeFileSync(source, 'replacement must survive\n');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(cleanupHookCalled).toBe(true);
|
||||||
|
expect(result.status).toBe('reported');
|
||||||
|
expect(readFileSync(source, 'utf8')).toBe('replacement must survive\n');
|
||||||
|
expect(result.reported).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
expect.objectContaining({ path: source, reason: expect.stringMatching(/retained/i) }),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('retains every source and reports failure when remote reachability is not established', async (): Promise<void> => {
|
it('retains every source and reports failure when remote reachability is not established', async (): Promise<void> => {
|
||||||
const sut = await loadSut('MB-REQ-07 failed-publish source preservation');
|
const sut = await loadSut('MB-REQ-07 failed-publish source preservation');
|
||||||
const root = tempRoot();
|
const root = tempRoot();
|
||||||
|
|||||||
@@ -157,6 +157,7 @@ export interface MigrationPublishEvidence {
|
|||||||
|
|
||||||
export interface MigrationHooks {
|
export interface MigrationHooks {
|
||||||
readonly beforeDestinationWrite?: (destination: string) => void;
|
readonly beforeDestinationWrite?: (destination: string) => void;
|
||||||
|
readonly beforeSourceCleanup?: (source: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MigrationResult {
|
export interface MigrationResult {
|
||||||
@@ -962,39 +963,18 @@ export function migrateBrainState(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const migrated: MigrationCandidate[] = [];
|
const retentionReports = plan.candidates.map((candidate: MigrationCandidate): MigrationReport => {
|
||||||
const removalReports: MigrationReport[] = [];
|
hooks.beforeSourceCleanup?.(candidate.source);
|
||||||
for (const candidate of plan.candidates) {
|
return {
|
||||||
try {
|
path: candidate.source,
|
||||||
const current = stableSourceSnapshot(candidate.source);
|
reason:
|
||||||
const beforeUnlink = lstatSync(candidate.source);
|
'Published snapshot is remotely reachable; automatic path-based cleanup is unsafe, so Mosaic retained and reported the source.',
|
||||||
if (
|
};
|
||||||
!sourceSnapshotMatches(current, candidate.sourceIdentity) ||
|
});
|
||||||
!beforeUnlink.isFile() ||
|
|
||||||
beforeUnlink.isSymbolicLink() ||
|
|
||||||
beforeUnlink.dev !== current.dev ||
|
|
||||||
beforeUnlink.ino !== current.ino
|
|
||||||
) {
|
|
||||||
removalReports.push({
|
|
||||||
path: candidate.source,
|
|
||||||
reason: 'Source identity or content changed after publication; retained and reported.',
|
|
||||||
});
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
unlinkSync(candidate.source);
|
|
||||||
migrated.push(candidate);
|
|
||||||
} catch {
|
|
||||||
removalReports.push({
|
|
||||||
path: candidate.source,
|
|
||||||
reason:
|
|
||||||
'Published migration is reachable but source cleanup failed; retained and reported.',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return {
|
return {
|
||||||
status: removalReports.length === 0 ? 'migrated' : 'reported',
|
status: 'reported',
|
||||||
migrated,
|
migrated: [],
|
||||||
reported: [...plan.reported, ...removalReports],
|
reported: [...plan.reported, ...retentionReports],
|
||||||
publish: evidence,
|
publish: evidence,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ export interface SecureFileSnapshot {
|
|||||||
mode: number;
|
mode: number;
|
||||||
dev: number | bigint;
|
dev: number | bigint;
|
||||||
ino: number | bigint;
|
ino: number | bigint;
|
||||||
|
uid: number;
|
||||||
|
gid: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
function sameIdentity(
|
function sameIdentity(
|
||||||
@@ -235,6 +237,8 @@ export function readRegularFileSecure(
|
|||||||
mode: Number(opened.mode),
|
mode: Number(opened.mode),
|
||||||
dev: opened.dev,
|
dev: opened.dev,
|
||||||
ino: opened.ino,
|
ino: opened.ino,
|
||||||
|
uid: opened.uid,
|
||||||
|
gid: opened.gid,
|
||||||
};
|
};
|
||||||
} finally {
|
} finally {
|
||||||
closeDescriptors(openedFile.descriptors);
|
closeDescriptors(openedFile.descriptors);
|
||||||
|
|||||||
Reference in New Issue
Block a user