refactor: adapter 구현중..
This commit is contained in:
+304
-29
@@ -8,7 +8,9 @@ import {
|
||||
lstat,
|
||||
mkdir,
|
||||
open,
|
||||
readdir,
|
||||
rm,
|
||||
rmdir,
|
||||
stat,
|
||||
} from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
@@ -19,6 +21,7 @@ import {
|
||||
} from "../contracts/promotion-artifacts.ts";
|
||||
import {
|
||||
evaluatePromotionEvidence,
|
||||
assertDistinctProviderTrust,
|
||||
providerPublicKeyFingerprint,
|
||||
providerVerificationArtifactSchema,
|
||||
PROMOTION_VERIFIER_ID,
|
||||
@@ -28,6 +31,7 @@ import {
|
||||
vulnerabilityProviderReportSchema,
|
||||
type ProviderTrust,
|
||||
} from "./provider-evidence.ts";
|
||||
import { verifyExactPromotionBundle } from "./exact-promotion-bundle.ts";
|
||||
import {
|
||||
captureCiCandidateArchive,
|
||||
withVerifiedCapturedCandidate,
|
||||
@@ -35,7 +39,8 @@ import {
|
||||
import { verifyArchivedLocalEvidence } from "./local-release-evidence.ts";
|
||||
import { readBoundedRegularFile } from "./ci-artifact-validator.ts";
|
||||
|
||||
type StagedFile = Readonly<{
|
||||
|
||||
export type StagedFile = Readonly<{
|
||||
name: PromotedFileName;
|
||||
bytes: Buffer;
|
||||
sha256: string;
|
||||
@@ -45,6 +50,7 @@ export type FinalizedPromotion = Readonly<{
|
||||
stagingRoot: string;
|
||||
cleanupToken: string;
|
||||
runnerTempIdentity: Readonly<{ dev: number; ino: number }>;
|
||||
stagingIdentity: Readonly<{ dev: number; ino: number }>;
|
||||
files: readonly Readonly<{ name: PromotedFileName; sha256: string }>[];
|
||||
}>;
|
||||
|
||||
@@ -69,6 +75,9 @@ export async function finalizeVerifiedPromotion(input: Readonly<{
|
||||
afterCapture?: () => Promise<void>;
|
||||
beforePublish?: () => Promise<void>;
|
||||
afterStagingWrite?: () => Promise<void>;
|
||||
afterFileWrite?: (name: PromotedFileName) => Promise<void>;
|
||||
beforeSeal?: () => Promise<void>;
|
||||
afterMkdirBeforeOpen?: (stagingRoot: string) => Promise<void>;
|
||||
}> = {}): Promise<FinalizedPromotion> {
|
||||
const root = path.resolve(input.repositoryRoot);
|
||||
const capturedArchive = await (dependencies.captureArchive ?? captureCiCandidateArchive)({
|
||||
@@ -92,14 +101,14 @@ export async function finalizeVerifiedPromotion(input: Readonly<{
|
||||
input.provenanceKeyId,
|
||||
provenanceKeyBytes,
|
||||
);
|
||||
assertDistinctProviderTrust({ vulnerabilityTrust, provenanceTrust });
|
||||
const vulnerabilityReport = vulnerabilityProviderReportSchema.parse(
|
||||
parseJson(vulnerabilityBytes),
|
||||
);
|
||||
const provenanceAttestation = provenanceProviderAttestationSchema.parse(
|
||||
parseJson(provenanceBytes),
|
||||
);
|
||||
const now = (dependencies.nowEpochMs ?? Date.now)();
|
||||
const verifiedAt = new Date(now).toISOString();
|
||||
const nowEpochMs = dependencies.nowEpochMs ?? Date.now;
|
||||
|
||||
const generated = await withVerifiedCapturedCandidate({
|
||||
captured: capturedArchive,
|
||||
@@ -128,6 +137,14 @@ export async function finalizeVerifiedPromotion(input: Readonly<{
|
||||
distSha256: manifest.distSha256,
|
||||
lockfileSha256: manifest.lockfileSha256,
|
||||
},
|
||||
secretScanAttestation: {
|
||||
status: "PASS" as const,
|
||||
localEvidenceAssessmentSha256: local.identity.assessmentSha256,
|
||||
sourceSetSha256: local.identity.sourceSetSha256,
|
||||
policySha256: local.identity.secretScan.policySha256,
|
||||
sarifSha256: local.identity.secretScan.sarifSha256,
|
||||
scanInputSha256: local.identity.secretScan.scanInputSha256,
|
||||
},
|
||||
vulnerabilityInvocationNonce: input.vulnerabilityInvocationNonce,
|
||||
provenanceInvocationNonce: input.provenanceInvocationNonce,
|
||||
} as const;
|
||||
@@ -138,7 +155,7 @@ export async function finalizeVerifiedPromotion(input: Readonly<{
|
||||
provenanceAttestation,
|
||||
vulnerabilityTrust,
|
||||
provenanceTrust,
|
||||
nowEpochMs: () => now,
|
||||
nowEpochMs,
|
||||
});
|
||||
if (reevaluated.status !== "PASS") {
|
||||
throw new Error(
|
||||
@@ -154,8 +171,10 @@ export async function finalizeVerifiedPromotion(input: Readonly<{
|
||||
vulnerabilityKeyFingerprint: vulnerabilityTrust.publicKeyFingerprint,
|
||||
provenanceKeyId: provenanceTrust.keyId,
|
||||
provenanceKeyFingerprint: provenanceTrust.publicKeyFingerprint,
|
||||
secretScanAttestation: expected.secretScanAttestation,
|
||||
} as const;
|
||||
const trustDigest = trustPolicySha256({ vulnerabilityTrust, provenanceTrust });
|
||||
const verifiedAt = new Date(nowEpochMs()).toISOString();
|
||||
const common = {
|
||||
schemaVersion: 3 as const,
|
||||
verifiedAt,
|
||||
@@ -188,6 +207,15 @@ export async function finalizeVerifiedPromotion(input: Readonly<{
|
||||
return Object.freeze({
|
||||
providerRecordBytes,
|
||||
promotionRecordBytes: canonicalJsonBytes(promotionRecord),
|
||||
exactExpected: Object.freeze({
|
||||
run: expected.run,
|
||||
sourceRevision: expected.source.revision,
|
||||
sourceSetSha256: expected.source.sourceSetSha256,
|
||||
archiveSha256: expected.candidate.archiveSha256,
|
||||
bundleSha256: expected.candidate.bundleSha256,
|
||||
distSha256: expected.candidate.distSha256,
|
||||
lockfileSha256: expected.candidate.lockfileSha256,
|
||||
}),
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -206,12 +234,35 @@ export async function finalizeVerifiedPromotion(input: Readonly<{
|
||||
throw new Error("promotion exact-five canonical file order drift");
|
||||
}
|
||||
await dependencies.beforePublish?.();
|
||||
return publishPrivateStaging(
|
||||
await verifyExactPromotionBundle(
|
||||
Object.fromEntries(stagedFiles.map(({ name, bytes }) => [name, bytes])),
|
||||
{
|
||||
vulnerabilityTrust,
|
||||
provenanceTrust,
|
||||
expected: generated.exactExpected,
|
||||
nowEpochMs,
|
||||
},
|
||||
);
|
||||
return publishPrivatePromotionStaging(
|
||||
input.runnerTempRoot,
|
||||
input.expectedRun,
|
||||
stagedFiles,
|
||||
dependencies.randomBytes ?? cryptoRandomBytes,
|
||||
dependencies.afterStagingWrite,
|
||||
dependencies.afterFileWrite,
|
||||
async (capturedFiles) => {
|
||||
await verifyExactPromotionBundle(
|
||||
capturedFiles,
|
||||
{
|
||||
vulnerabilityTrust,
|
||||
provenanceTrust,
|
||||
expected: generated.exactExpected,
|
||||
nowEpochMs,
|
||||
},
|
||||
);
|
||||
},
|
||||
dependencies.afterMkdirBeforeOpen,
|
||||
dependencies.beforeSeal,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -222,6 +273,7 @@ export async function cleanupFinalizedPromotion(input: Readonly<{
|
||||
stagingRoot: string;
|
||||
cleanupToken: string;
|
||||
runnerTempIdentity: Readonly<{ dev: number; ino: number }>;
|
||||
stagingIdentity: Readonly<{ dev: number; ino: number }>;
|
||||
}>, dependencies: Readonly<{
|
||||
beforeRemove?: () => Promise<void>;
|
||||
}> = {}): Promise<void> {
|
||||
@@ -234,6 +286,10 @@ export async function cleanupFinalizedPromotion(input: Readonly<{
|
||||
input.runnerTempIdentity.dev <= 0 ||
|
||||
!Number.isSafeInteger(input.runnerTempIdentity.ino) ||
|
||||
input.runnerTempIdentity.ino <= 0
|
||||
|| !Number.isSafeInteger(input.stagingIdentity.dev)
|
||||
|| input.stagingIdentity.dev <= 0
|
||||
|| !Number.isSafeInteger(input.stagingIdentity.ino)
|
||||
|| input.stagingIdentity.ino <= 0
|
||||
) {
|
||||
throw new TypeError("promotion cleanup root/token mismatch");
|
||||
}
|
||||
@@ -260,10 +316,33 @@ export async function cleanupFinalizedPromotion(input: Readonly<{
|
||||
if (metadata.isSymbolicLink() || !metadata.isDirectory()) {
|
||||
throw new TypeError("promotion cleanup leaf is unsafe");
|
||||
}
|
||||
await dependencies.beforeRemove?.();
|
||||
const visibleParent = await lstat(parent);
|
||||
assertRunnerTempIdentity(visibleParent, input.runnerTempIdentity);
|
||||
await rm(descriptorExpected, { recursive: true, force: true });
|
||||
assertStagingIdentity(metadata, input.stagingIdentity);
|
||||
const stagingHandle = await open(
|
||||
descriptorExpected,
|
||||
constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW,
|
||||
);
|
||||
try {
|
||||
const stagingDescriptorRoot = `/proc/self/fd/${stagingHandle.fd}`;
|
||||
assertStagingIdentity(await stat(stagingDescriptorRoot), input.stagingIdentity);
|
||||
const names = (await readdir(stagingDescriptorRoot)).sort(asciiCompare);
|
||||
if (
|
||||
JSON.stringify(names) !==
|
||||
JSON.stringify([...PROMOTED_FILE_NAMES].sort(asciiCompare))
|
||||
) {
|
||||
throw new Error("promotion cleanup leaf does not contain the exact five files");
|
||||
}
|
||||
await dependencies.beforeRemove?.();
|
||||
const visibleParent = await lstat(parent);
|
||||
assertRunnerTempIdentity(visibleParent, input.runnerTempIdentity);
|
||||
for (const name of PROMOTED_FILE_NAMES) {
|
||||
await rm(path.join(stagingDescriptorRoot, name), { force: false });
|
||||
}
|
||||
assertStagingIdentity(await stat(stagingDescriptorRoot), input.stagingIdentity);
|
||||
assertStagingIdentity(await lstat(descriptorExpected), input.stagingIdentity);
|
||||
await rmdir(descriptorExpected);
|
||||
} finally {
|
||||
await stagingHandle.close();
|
||||
}
|
||||
const afterParent = await lstat(parent);
|
||||
assertRunnerTempIdentity(afterParent, input.runnerTempIdentity);
|
||||
} finally {
|
||||
@@ -271,13 +350,29 @@ export async function cleanupFinalizedPromotion(input: Readonly<{
|
||||
}
|
||||
}
|
||||
|
||||
async function publishPrivateStaging(
|
||||
export async function publishPrivatePromotionStaging(
|
||||
runnerTempRoot: string,
|
||||
run: Readonly<{ id: string; attempt: number }>,
|
||||
files: readonly StagedFile[],
|
||||
randomBytes: (bytes: number) => Buffer,
|
||||
afterStagingWrite?: () => Promise<void>,
|
||||
afterFileWrite?: (name: PromotedFileName) => Promise<void>,
|
||||
sealStagedFiles?: (files: Readonly<Record<PromotedFileName, Buffer>>) => Promise<void>,
|
||||
afterMkdirBeforeOpen?: (stagingRoot: string) => Promise<void>,
|
||||
beforeSeal?: () => Promise<void>,
|
||||
): Promise<FinalizedPromotion> {
|
||||
if (
|
||||
JSON.stringify(files.map(({ name }) => name)) !==
|
||||
JSON.stringify(PROMOTED_FILE_NAMES) ||
|
||||
files.some(
|
||||
({ bytes, sha256: digest }) =>
|
||||
!Buffer.isBuffer(bytes) ||
|
||||
!/^[a-f0-9]{64}$/u.test(digest) ||
|
||||
sha256(bytes) !== digest,
|
||||
)
|
||||
) {
|
||||
throw new TypeError("private promotion staging requires the canonical exact-five bytes");
|
||||
}
|
||||
const parentPath = path.resolve(runnerTempRoot);
|
||||
const before = await lstat(parentPath);
|
||||
if (!before.isDirectory() || before.isSymbolicLink()) {
|
||||
@@ -298,14 +393,119 @@ async function publishPrivateStaging(
|
||||
const descriptorStaging = path.join(descriptorRoot, cleanupToken);
|
||||
const visibleStaging = path.join(parentPath, cleanupToken);
|
||||
let ownsStaging = false;
|
||||
let stagingHandle: Awaited<ReturnType<typeof open>> | undefined;
|
||||
let createdStagingIdentity: Readonly<{ dev: number; ino: number }> | undefined;
|
||||
let stagingIdentity: Readonly<{ dev: number; ino: number }> | undefined;
|
||||
let openedIdentityVerified = false;
|
||||
const cleanup = async (primaryFailure?: unknown): Promise<void> => {
|
||||
const cleanupFailures: unknown[] = [];
|
||||
const attemptCleanup = async (operation: () => Promise<void>): Promise<void> => {
|
||||
try {
|
||||
await operation();
|
||||
} catch (error) {
|
||||
cleanupFailures.push(error);
|
||||
}
|
||||
};
|
||||
if (ownsStaging && openedIdentityVerified && stagingHandle && stagingIdentity) {
|
||||
const ownedIdentity = stagingIdentity;
|
||||
const stagingDescriptorRoot = `/proc/self/fd/${stagingHandle.fd}`;
|
||||
const removals = await Promise.allSettled(
|
||||
files.map(({ name }) => rm(path.join(stagingDescriptorRoot, name), { force: true })),
|
||||
);
|
||||
cleanupFailures.push(
|
||||
...removals.flatMap((result) =>
|
||||
result.status === "rejected" ? [result.reason] : [],
|
||||
),
|
||||
);
|
||||
await attemptCleanup(async () => {
|
||||
let visible;
|
||||
try {
|
||||
visible = await lstat(descriptorStaging);
|
||||
} catch (error) {
|
||||
if (hasErrorCode(error, "ENOENT")) return;
|
||||
throw error;
|
||||
}
|
||||
if (
|
||||
visible.isDirectory() &&
|
||||
!visible.isSymbolicLink() &&
|
||||
visible.dev === ownedIdentity.dev &&
|
||||
visible.ino === ownedIdentity.ino
|
||||
) {
|
||||
await rmdir(descriptorStaging);
|
||||
}
|
||||
});
|
||||
}
|
||||
if (stagingHandle) {
|
||||
const ownedHandle = stagingHandle;
|
||||
await attemptCleanup(async () => ownedHandle.close());
|
||||
}
|
||||
await attemptCleanup(async () => parentHandle.close());
|
||||
if (cleanupFailures.length > 0) {
|
||||
throw new AggregateError(
|
||||
primaryFailure === undefined
|
||||
? cleanupFailures
|
||||
: [primaryFailure, ...cleanupFailures],
|
||||
primaryFailure instanceof Error
|
||||
? `${primaryFailure.message}; promotion staging cleanup also failed`
|
||||
: "promotion staging cleanup failed",
|
||||
{ cause: cleanupFailures.at(-1) },
|
||||
);
|
||||
}
|
||||
};
|
||||
let finalizedPromotion: FinalizedPromotion;
|
||||
try {
|
||||
const procMetadata = await stat(descriptorRoot);
|
||||
if (!procMetadata.isDirectory()) throw new Error("descriptor-relative staging is unavailable");
|
||||
await mkdir(descriptorStaging, { mode: 0o700 });
|
||||
ownsStaging = true;
|
||||
const createdStaging = await lstat(descriptorStaging);
|
||||
if (!createdStaging.isDirectory() || createdStaging.isSymbolicLink()) {
|
||||
throw new Error("created promotion staging leaf is unsafe");
|
||||
}
|
||||
createdStagingIdentity = Object.freeze({
|
||||
dev: createdStaging.dev,
|
||||
ino: createdStaging.ino,
|
||||
});
|
||||
await afterMkdirBeforeOpen?.(visibleStaging);
|
||||
const openedHandle = await open(
|
||||
descriptorStaging,
|
||||
constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW,
|
||||
);
|
||||
let openedStaging;
|
||||
try {
|
||||
openedStaging = await openedHandle.stat();
|
||||
if (!openedStaging.isDirectory()) {
|
||||
throw new Error("promotion staging descriptor is not a directory");
|
||||
}
|
||||
if (
|
||||
openedStaging.dev !== createdStagingIdentity.dev ||
|
||||
openedStaging.ino !== createdStagingIdentity.ino
|
||||
) {
|
||||
throw new Error("promotion staging leaf identity changed between mkdir and open");
|
||||
}
|
||||
openedIdentityVerified = true;
|
||||
} catch (error) {
|
||||
try {
|
||||
await openedHandle.close();
|
||||
} catch (closeError) {
|
||||
throw new AggregateError(
|
||||
[error, closeError],
|
||||
error instanceof Error
|
||||
? `${error.message}; rejected staging descriptor close also failed`
|
||||
: "rejected staging descriptor and close both failed",
|
||||
{ cause: closeError },
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
stagingHandle = openedHandle;
|
||||
await stagingHandle.chmod(0o700);
|
||||
stagingIdentity = Object.freeze({ dev: openedStaging.dev, ino: openedStaging.ino });
|
||||
const stagingDescriptorRoot = `/proc/self/fd/${stagingHandle.fd}`;
|
||||
assertStagingIdentity(await stat(stagingDescriptorRoot), stagingIdentity);
|
||||
for (const file of files) {
|
||||
const handle = await open(
|
||||
path.join(descriptorStaging, file.name),
|
||||
path.join(stagingDescriptorRoot, file.name),
|
||||
constants.O_WRONLY |
|
||||
constants.O_CREAT |
|
||||
constants.O_EXCL |
|
||||
@@ -313,15 +513,20 @@ async function publishPrivateStaging(
|
||||
0o400,
|
||||
);
|
||||
try {
|
||||
await handle.chmod(0o400);
|
||||
await handle.writeFile(file.bytes);
|
||||
await handle.sync();
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
await afterFileWrite?.(file.name);
|
||||
}
|
||||
await syncDirectory(descriptorStaging);
|
||||
await syncHandle(stagingHandle);
|
||||
await syncHandle(parentHandle);
|
||||
await afterStagingWrite?.();
|
||||
await beforeSeal?.();
|
||||
const capturedFiles = await captureStagedFiles(stagingHandle, files);
|
||||
await sealStagedFiles?.(capturedFiles);
|
||||
const after = await lstat(parentPath);
|
||||
if (
|
||||
after.dev !== before.dev ||
|
||||
@@ -335,20 +540,102 @@ async function publishPrivateStaging(
|
||||
if (!visible.isDirectory() || visible.isSymbolicLink()) {
|
||||
throw new Error("promotion staging visibility identity mismatch");
|
||||
}
|
||||
assertStagingIdentity(visible, stagingIdentity);
|
||||
ownsStaging = false;
|
||||
return Object.freeze({
|
||||
finalizedPromotion = Object.freeze({
|
||||
stagingRoot: visibleStaging,
|
||||
cleanupToken,
|
||||
runnerTempIdentity: Object.freeze({ dev: before.dev, ino: before.ino }),
|
||||
stagingIdentity,
|
||||
files: Object.freeze(
|
||||
files.map(({ name, sha256: digest }) => Object.freeze({ name, sha256: digest })),
|
||||
),
|
||||
});
|
||||
} finally {
|
||||
if (ownsStaging) {
|
||||
await rm(descriptorStaging, { recursive: true, force: true }).catch(() => undefined);
|
||||
} catch (error) {
|
||||
await cleanup(error);
|
||||
throw error;
|
||||
}
|
||||
await cleanup();
|
||||
return finalizedPromotion;
|
||||
}
|
||||
|
||||
async function captureStagedFiles(
|
||||
stagingHandle: Awaited<ReturnType<typeof open>>,
|
||||
declaredFiles: readonly StagedFile[],
|
||||
): Promise<Readonly<Record<PromotedFileName, Buffer>>> {
|
||||
const descriptorRoot = `/proc/self/fd/${stagingHandle.fd}`;
|
||||
const names = (await readdir(descriptorRoot)).sort(asciiCompare);
|
||||
if (
|
||||
JSON.stringify(names) !==
|
||||
JSON.stringify([...PROMOTED_FILE_NAMES].sort(asciiCompare))
|
||||
) {
|
||||
throw new Error("staged promotion seal requires exactly the canonical five files");
|
||||
}
|
||||
const declared = new Map(declaredFiles.map((file) => [file.name, file] as const));
|
||||
const captured = {} as Record<PromotedFileName, Buffer>;
|
||||
for (const name of PROMOTED_FILE_NAMES) {
|
||||
const expected = declared.get(name)!;
|
||||
const handle = await open(
|
||||
path.join(descriptorRoot, name),
|
||||
constants.O_RDONLY | constants.O_NOFOLLOW,
|
||||
);
|
||||
try {
|
||||
const before = await handle.stat();
|
||||
const maxBytes = name === "release-candidate.tar.gz" ? 268_435_456 : 16_777_216;
|
||||
if (
|
||||
!before.isFile() ||
|
||||
before.nlink !== 1 ||
|
||||
(before.mode & 0o777) !== 0o400 ||
|
||||
before.size <= 0 ||
|
||||
before.size > maxBytes
|
||||
) {
|
||||
throw new Error(
|
||||
`staged promotion file must be regular, single-link, bounded, and mode 0400: ${name}`,
|
||||
);
|
||||
}
|
||||
const bytes = await handle.readFile();
|
||||
const after = await handle.stat();
|
||||
if (
|
||||
after.dev !== before.dev ||
|
||||
after.ino !== before.ino ||
|
||||
after.size !== before.size ||
|
||||
after.nlink !== 1 ||
|
||||
(after.mode & 0o777) !== 0o400 ||
|
||||
bytes.byteLength !== before.size
|
||||
) {
|
||||
throw new Error(`staged promotion file inode or size changed during seal: ${name}`);
|
||||
}
|
||||
if (sha256(bytes) !== expected.sha256) {
|
||||
throw new Error(`staged promotion file digest mismatch during seal: ${name}`);
|
||||
}
|
||||
captured[name] = bytes;
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
await parentHandle.close();
|
||||
}
|
||||
return Object.freeze(captured);
|
||||
}
|
||||
|
||||
function asciiCompare(left: string, right: string): number {
|
||||
return left < right ? -1 : left > right ? 1 : 0;
|
||||
}
|
||||
|
||||
function assertStagingIdentity(
|
||||
metadata: Readonly<{
|
||||
dev: number;
|
||||
ino: number;
|
||||
isDirectory: () => boolean;
|
||||
isSymbolicLink?: () => boolean;
|
||||
}>,
|
||||
expected: Readonly<{ dev: number; ino: number }>,
|
||||
): void {
|
||||
if (
|
||||
metadata.dev !== expected.dev ||
|
||||
metadata.ino !== expected.ino ||
|
||||
!metadata.isDirectory() ||
|
||||
metadata.isSymbolicLink?.()
|
||||
) {
|
||||
throw new Error("promotion staging leaf identity changed");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -409,18 +696,6 @@ function sha256(bytes: Buffer): string {
|
||||
return createHash("sha256").update(bytes).digest("hex");
|
||||
}
|
||||
|
||||
async function syncDirectory(directory: string): Promise<void> {
|
||||
const handle = await open(
|
||||
directory,
|
||||
constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW,
|
||||
);
|
||||
try {
|
||||
await syncHandle(handle);
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function syncHandle(handle: Awaited<ReturnType<typeof open>>): Promise<void> {
|
||||
try {
|
||||
await handle.sync();
|
||||
|
||||
Reference in New Issue
Block a user