fix(desktop): harden dormant update safety boundary
Signed-off-by: Haozhe Wu <haozhe_wu@connect.hku.hk>
This commit is contained in:
1 parent
d8723223e9
commit
3012fe98b2
7 files changed
+317
-70
No files matched your search
@@ -29,7 +29,9 @@ download an artifact, launch an installer, or enable updates. See
|
|||||||
- Requests authenticated graceful shutdown before asking the watchdog to
|
- Requests authenticated graceful shutdown before asking the watchdog to
|
||||||
terminate the owned Windows process tree as a fallback.
|
terminate the owned Windows process tree as a fallback.
|
||||||
- Exposes a strict update-handoff shutdown call that succeeds only after the
|
- Exposes a strict update-handoff shutdown call that succeeds only after the
|
||||||
owned backend PID, watchdog PID, and loopback listener are all gone.
|
owned backend PID, watchdog PID, and loopback listener are all gone. A failed
|
||||||
|
handoff retains those identifiers for retry, and a TCP probe keeps a
|
||||||
|
listening-but-unresponsive port fail-closed.
|
||||||
- Terminates the Python process tree if the Electron main process is killed
|
- Terminates the Python process tree if the Electron main process is killed
|
||||||
without running JavaScript shutdown handlers.
|
without running JavaScript shutdown handlers.
|
||||||
- Localizes desktop-owned loading, status, error, dialog, and menu text in
|
- Localizes desktop-owned loading, status, error, dialog, and menu text in
|
||||||
@@ -71,7 +73,8 @@ the parent-death path by force-terminating only the Electron main PID before
|
|||||||
checking that the Python process and loopback listener are gone. Both graceful
|
checking that the Python process and loopback listener are gone. Both graceful
|
||||||
and parent-death tests keep a separate Python sentinel alive throughout the
|
and parent-death tests keep a separate Python sentinel alive throughout the
|
||||||
owned-process cleanup. The update-safety test exercises the rejection matrix
|
owned-process cleanup. The update-safety test exercises the rejection matrix
|
||||||
and every journal recovery phase without enabling an updater.
|
and every journal recovery phase without enabling an updater, including
|
||||||
|
artifact mutation, concurrent journal creation, and failed-shutdown retry.
|
||||||
|
|
||||||
Backend resolution is intentionally narrow. It checks an explicit
|
Backend resolution is intentionally narrow. It checks an explicit
|
||||||
`VIBE_TRADING_EXECUTABLE` override first, then exact application/resource
|
`VIBE_TRADING_EXECUTABLE` override first, then exact application/resource
|
||||||
|
|||||||
@@ -121,7 +121,9 @@ therefore security-significant.
|
|||||||
then independently verifies that both the Python PID and loopback listener
|
then independently verifies that both the Python PID and loopback listener
|
||||||
are gone.
|
are gone.
|
||||||
- Graceful update handoff returns structured evidence for the exact owned
|
- Graceful update handoff returns structured evidence for the exact owned
|
||||||
backend PID, watchdog PID, and listener. It fails closed if any remains.
|
backend PID, watchdog PID, and listener. The listener check probes TCP rather
|
||||||
|
than HTTP responsiveness. It fails closed if any remains and retains the
|
||||||
|
ownership state for a later cleanup retry.
|
||||||
- Graceful and parent-death tests each start an unrelated Python sentinel and
|
- Graceful and parent-death tests each start an unrelated Python sentinel and
|
||||||
verify that it remains alive; termination commands are always PID-scoped and
|
verify that it remains alive; termination commands are always PID-scoped and
|
||||||
never use an image name such as `python.exe`.
|
never use an image name such as `python.exe`.
|
||||||
@@ -132,22 +134,31 @@ therefore security-significant.
|
|||||||
and the candidate must be strictly newer.
|
and the candidate must be strictly newer.
|
||||||
- The artifact SHA-256 must match caller-supplied release metadata before its
|
- The artifact SHA-256 must match caller-supplied release metadata before its
|
||||||
signature is considered.
|
signature is considered.
|
||||||
|
- The production Authenticode adapter holds a read-only Windows file lock that
|
||||||
|
denies writers and deletion while it hashes and inspects the same artifact.
|
||||||
|
Its locked digest and a post-inspection digest must both match release
|
||||||
|
metadata.
|
||||||
- Windows Authenticode status must be `Valid`; unsigned and invalid signatures
|
- Windows Authenticode status must be `Valid`; unsigned and invalid signatures
|
||||||
are rejected with stable codes.
|
are rejected with stable codes.
|
||||||
- Publisher identity uses an explicit allowlist of SHA-256 hashes of signer
|
- Publisher identity uses an explicit allowlist of SHA-256 hashes of signer
|
||||||
certificate raw bytes. Subject display names alone are not trusted.
|
certificate raw bytes. Subject display names alone are not trusted.
|
||||||
- No publisher is currently configured. An empty allowlist is itself a policy
|
- No publisher is currently configured. An empty allowlist is itself a policy
|
||||||
error, so these primitives cannot silently trust the first certificate seen.
|
error, so these primitives cannot silently trust the first certificate seen.
|
||||||
- A pending-attempt journal is written through a same-directory temporary file
|
- A pending-attempt journal is fsynced to a same-directory temporary file. Its
|
||||||
and rename. It records only a simple staged `.exe` file name, not an
|
first publication uses an atomic hard-link create-if-absent operation, so two
|
||||||
arbitrary deletion path.
|
concurrent begins cannot replace one another; later phase changes use the
|
||||||
|
existing same-directory rename path. It records only a simple staged `.exe`
|
||||||
|
file name, not an arbitrary deletion path.
|
||||||
- On the next launch, an attempt that still reports the old version discards
|
- On the next launch, an attempt that still reports the old version discards
|
||||||
the staged candidate and requires a fresh download and verification. A
|
the staged candidate and requires a fresh download and verification. A
|
||||||
successful target version clears the journal. Any third version or malformed
|
successful target version clears the journal. Any third version or malformed
|
||||||
journal is left untouched for explicit operator intervention.
|
journal is left untouched for explicit operator intervention.
|
||||||
- The allowed phase order is `verified` -> `backend-stopped` ->
|
- The allowed phase order is `verified` -> `backend-stopped` ->
|
||||||
`installer-launched`. A future updater must call the strict backend shutdown
|
`installer-launched`. A future updater must call the strict backend shutdown
|
||||||
path before recording `backend-stopped`.
|
path before recording `backend-stopped`, and must re-hash the verified
|
||||||
|
artifact immediately before installer launch.
|
||||||
|
- PowerShell host discovery and Authenticode inspection have hard timeouts; a
|
||||||
|
hung host rejects the candidate rather than blocking Electron indefinitely.
|
||||||
|
|
||||||
### Credential storage
|
### Credential storage
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,10 @@ This code makes two parts of that future review executable now:
|
|||||||
|
|
||||||
`BackendManager.stopForUpdate()` is the required handoff boundary. It returns
|
`BackendManager.stopForUpdate()` is the required handoff boundary. It returns
|
||||||
only after the exact owned backend PID, watchdog PID, and loopback listener are
|
only after the exact owned backend PID, watchdog PID, and loopback listener are
|
||||||
gone. It never terminates `python.exe` by image name.
|
gone. Listener evidence uses a TCP connection probe rather than HTTP health, so
|
||||||
|
a process that accepts connections but stops answering HTTP remains open. A
|
||||||
|
failed handoff retains the exact identifiers and log stream for a later cleanup
|
||||||
|
retry. It never terminates `python.exe` by image name.
|
||||||
|
|
||||||
## Verification order
|
## Verification order
|
||||||
|
|
||||||
@@ -29,8 +32,18 @@ Checks run in this order:
|
|||||||
1. policy and release metadata are well formed;
|
1. policy and release metadata are well formed;
|
||||||
2. the candidate semantic version is strictly newer;
|
2. the candidate semantic version is strictly newer;
|
||||||
3. the local file digest matches the expected SHA-256;
|
3. the local file digest matches the expected SHA-256;
|
||||||
4. Windows reports a `Valid` Authenticode signature;
|
4. PowerShell opens the artifact with a lock that permits readers but denies
|
||||||
5. the SHA-256 hash of the signer certificate's raw bytes is allowlisted.
|
writers and deletion, hashes those locked bytes, and inspects Authenticode;
|
||||||
|
5. the locked digest matches the initial and expected digests;
|
||||||
|
6. Windows reports a `Valid` Authenticode signature;
|
||||||
|
7. the SHA-256 hash of the signer certificate's raw bytes is allowlisted;
|
||||||
|
8. the file digest is confirmed again after the locked inspection.
|
||||||
|
|
||||||
|
The future launch path must call
|
||||||
|
`assertVerifiedUpdateArtifactUnchanged()` immediately before starting the
|
||||||
|
installer. Verification and PowerShell discovery are both timeout-bounded. The
|
||||||
|
pre-launch assertion is mandatory even though the artifact was already checked:
|
||||||
|
no result object makes a mutable path permanently trustworthy.
|
||||||
|
|
||||||
The expected digest and publisher allowlist must eventually come from a trusted
|
The expected digest and publisher allowlist must eventually come from a trusted
|
||||||
release configuration owned by the maintainers. That source is deliberately
|
release configuration owned by the maintainers. That source is deliberately
|
||||||
@@ -48,6 +61,7 @@ not invented here.
|
|||||||
| Missing publisher policy | Certificate allowlist is empty | `invalid-verification-policy` | Keep the updater disabled |
|
| Missing publisher policy | Certificate allowlist is empty | `invalid-verification-policy` | Keep the updater disabled |
|
||||||
| Malformed version or digest | Release metadata is not valid | `invalid-release-metadata` | Reject before signature inspection |
|
| Malformed version or digest | Release metadata is not valid | `invalid-release-metadata` | Reject before signature inspection |
|
||||||
| File or Authenticode inspection failure | Artifact cannot be read or Windows inspection fails | `artifact-inspection-failed` | Reject without launching |
|
| File or Authenticode inspection failure | Artifact cannot be read or Windows inspection fails | `artifact-inspection-failed` | Reject without launching |
|
||||||
|
| Artifact replaced during inspection | Locked Authenticode digest or post-inspection digest differs | `artifact-changed-during-verification` | Reject and require a fresh staged artifact |
|
||||||
|
|
||||||
The unsigned automated test uses injected Authenticode observations so every
|
The unsigned automated test uses injected Authenticode observations so every
|
||||||
branch is deterministic. The later signed end-to-end run must repeat the first
|
branch is deterministic. The later signed end-to-end run must repeat the first
|
||||||
@@ -78,8 +92,11 @@ application version:
|
|||||||
| Malformed journal | Unknown | Leave it untouched; require manual intervention |
|
| Malformed journal | Unknown | Leave it untouched; require manual intervention |
|
||||||
|
|
||||||
The journal stores a simple `.exe` file name inside its dedicated staging
|
The journal stores a simple `.exe` file name inside its dedicated staging
|
||||||
directory. Malformed or traversal paths are never followed for deletion. Writes
|
directory. Malformed or traversal paths are never followed for deletion. A
|
||||||
use a temporary file and same-directory rename.
|
begin operation fsyncs a complete same-directory temporary file and publishes
|
||||||
|
it with an atomic hard-link create-if-absent operation. Concurrent begins can
|
||||||
|
therefore never overwrite one another. Later phase writes retain the
|
||||||
|
same-directory rename path.
|
||||||
|
|
||||||
This recovery path restores the application to a clean retry state when the
|
This recovery path restores the application to a clean retry state when the
|
||||||
old or new version can launch. It does **not** claim rollback of a partially
|
old or new version can launch. It does **not** claim rollback of a partially
|
||||||
@@ -99,7 +116,11 @@ npm run smoke:parent-death
|
|||||||
The lifecycle tests start an unrelated Python sentinel before stopping the
|
The lifecycle tests start an unrelated Python sentinel before stopping the
|
||||||
desktop-owned backend. The sentinel must still be alive after graceful and
|
desktop-owned backend. The sentinel must still be alive after graceful and
|
||||||
forced-parent-death cleanup, while the owned Python PID and listener must be
|
forced-parent-death cleanup, while the owned Python PID and listener must be
|
||||||
gone.
|
gone. The update-safety test also holds open a TCP listener that never answers
|
||||||
|
HTTP, proves shutdown fails closed while retaining the exact identifiers, then
|
||||||
|
closes the listener and proves a second cleanup attempt succeeds. It races two
|
||||||
|
journal begins and requires exactly one durable winner, and mutates an artifact
|
||||||
|
during and after verification to exercise both integrity gates.
|
||||||
|
|
||||||
## Remaining enablement gates
|
## Remaining enablement gates
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,16 @@
|
|||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import { createHash } from "node:crypto";
|
import { createHash } from "node:crypto";
|
||||||
|
import { writeFileSync } from "node:fs";
|
||||||
import { access, mkdtemp, readFile, writeFile } from "node:fs/promises";
|
import { access, mkdtemp, readFile, writeFile } from "node:fs/promises";
|
||||||
|
import { createServer } from "node:net";
|
||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
|
||||||
|
import { BackendManager } from "../dist/backend-manager.js";
|
||||||
|
import { getDesktopMessages } from "../dist/locales.js";
|
||||||
import { UpdateRecoveryJournal } from "../dist/update-recovery.js";
|
import { UpdateRecoveryJournal } from "../dist/update-recovery.js";
|
||||||
import {
|
import {
|
||||||
|
assertVerifiedUpdateArtifactUnchanged,
|
||||||
compareSemanticVersions,
|
compareSemanticVersions,
|
||||||
inspectAuthenticodeSignature,
|
inspectAuthenticodeSignature,
|
||||||
verifyWindowsUpdateCandidate,
|
verifyWindowsUpdateCandidate,
|
||||||
@@ -26,12 +31,14 @@ const policy = {
|
|||||||
};
|
};
|
||||||
const validSignature = () => ({
|
const validSignature = () => ({
|
||||||
status: "Valid",
|
status: "Valid",
|
||||||
|
artifactSha256: fixtureHash,
|
||||||
signerSubject: "CN=Vibe-Trading Test Publisher",
|
signerSubject: "CN=Vibe-Trading Test Publisher",
|
||||||
certificateSha256: publisher,
|
certificateSha256: publisher,
|
||||||
});
|
});
|
||||||
|
|
||||||
const accepted = await verifyWindowsUpdateCandidate(candidate, policy, validSignature);
|
const accepted = await verifyWindowsUpdateCandidate(candidate, policy, validSignature);
|
||||||
assert.equal(accepted.accepted, true);
|
assert.equal(accepted.accepted, true);
|
||||||
|
await assertVerifiedUpdateArtifactUnchanged(artifactPath, accepted.sha256);
|
||||||
|
|
||||||
await expectRejection(
|
await expectRejection(
|
||||||
{ ...candidate, expectedSha256: "00".repeat(32) },
|
{ ...candidate, expectedSha256: "00".repeat(32) },
|
||||||
@@ -76,6 +83,31 @@ await expectRejection(
|
|||||||
validSignature,
|
validSignature,
|
||||||
"artifact-inspection-failed",
|
"artifact-inspection-failed",
|
||||||
);
|
);
|
||||||
|
await expectRejection(
|
||||||
|
candidate,
|
||||||
|
policy,
|
||||||
|
() => ({ ...validSignature(), artifactSha256: otherPublisher }),
|
||||||
|
"artifact-changed-during-verification",
|
||||||
|
);
|
||||||
|
|
||||||
|
const mutationArtifactPath = path.join(testRoot, "mutation.exe");
|
||||||
|
await writeFile(mutationArtifactPath, fixture);
|
||||||
|
await expectRejection(
|
||||||
|
{ ...candidate, artifactPath: mutationArtifactPath },
|
||||||
|
policy,
|
||||||
|
(inspectedPath) => {
|
||||||
|
writeFileSync(inspectedPath, Buffer.from("changed-during-signature-inspection", "utf8"));
|
||||||
|
return validSignature();
|
||||||
|
},
|
||||||
|
"artifact-changed-during-verification",
|
||||||
|
);
|
||||||
|
|
||||||
|
await writeFile(artifactPath, Buffer.from("changed-after-verification", "utf8"));
|
||||||
|
await assert.rejects(
|
||||||
|
() => assertVerifiedUpdateArtifactUnchanged(artifactPath, accepted.sha256),
|
||||||
|
/changed before installer launch/u,
|
||||||
|
);
|
||||||
|
await writeFile(artifactPath, fixture);
|
||||||
|
|
||||||
assert.equal(compareSemanticVersions("0.3.1", "0.3.0"), 1);
|
assert.equal(compareSemanticVersions("0.3.1", "0.3.0"), 1);
|
||||||
assert.equal(compareSemanticVersions("0.3.1-beta.2", "0.3.1-beta.1"), 1);
|
assert.equal(compareSemanticVersions("0.3.1-beta.2", "0.3.1-beta.1"), 1);
|
||||||
@@ -140,6 +172,21 @@ assert.equal(
|
|||||||
assert.equal(await exists(mismatchArtifact), true);
|
assert.equal(await exists(mismatchArtifact), true);
|
||||||
assert.equal(await exists(mismatchJournal.journalPath), true);
|
assert.equal(await exists(mismatchJournal.journalPath), true);
|
||||||
|
|
||||||
|
const concurrentDirectory = await mkdtemp(path.join(testRoot, "concurrent-"));
|
||||||
|
const concurrentJournal = new UpdateRecoveryJournal(concurrentDirectory);
|
||||||
|
const concurrentResults = await Promise.allSettled([
|
||||||
|
concurrentJournal.begin(newAttempt("first.exe")),
|
||||||
|
concurrentJournal.begin(newAttempt("second.exe")),
|
||||||
|
]);
|
||||||
|
const concurrentWinners = concurrentResults.filter((result) => result.status === "fulfilled");
|
||||||
|
const concurrentLosers = concurrentResults.filter((result) => result.status === "rejected");
|
||||||
|
assert.equal(concurrentWinners.length, 1);
|
||||||
|
assert.equal(concurrentLosers.length, 1);
|
||||||
|
assert.match(String(concurrentLosers[0].reason), /already exists/u);
|
||||||
|
const concurrentRecord = JSON.parse(await readFile(concurrentJournal.journalPath, "utf8"));
|
||||||
|
assert.equal(concurrentRecord.attemptId, concurrentWinners[0].value.attemptId);
|
||||||
|
assert.equal(concurrentRecord.artifactFileName, concurrentWinners[0].value.artifactFileName);
|
||||||
|
|
||||||
const traversalDirectory = await mkdtemp(path.join(testRoot, "traversal-"));
|
const traversalDirectory = await mkdtemp(path.join(testRoot, "traversal-"));
|
||||||
const traversalJournal = new UpdateRecoveryJournal(traversalDirectory);
|
const traversalJournal = new UpdateRecoveryJournal(traversalDirectory);
|
||||||
const protectedFile = path.join(testRoot, "protected.exe");
|
const protectedFile = path.join(testRoot, "protected.exe");
|
||||||
@@ -158,9 +205,56 @@ assert.equal(
|
|||||||
);
|
);
|
||||||
assert.equal((await readFile(protectedFile)).equals(fixture), true);
|
assert.equal((await readFile(protectedFile)).equals(fixture), true);
|
||||||
|
|
||||||
|
const hungListener = createServer(() => {
|
||||||
|
// Accept TCP and deliberately never speak HTTP. Shutdown evidence must still
|
||||||
|
// classify this port as open.
|
||||||
|
});
|
||||||
|
await listen(hungListener);
|
||||||
|
const hungAddress = hungListener.address();
|
||||||
|
if (!hungAddress || typeof hungAddress === "string") throw new Error("Expected a TCP listener address");
|
||||||
|
const hungBaseUrl = `http://127.0.0.1:${hungAddress.port}/`;
|
||||||
|
const retainedBackendPid = 2_147_483_646;
|
||||||
|
const retainedWatchdogPid = 2_147_483_647;
|
||||||
|
const retainedManager = new BackendManager({
|
||||||
|
appPath: testRoot,
|
||||||
|
resourcesPath: testRoot,
|
||||||
|
allowSourceDiscovery: false,
|
||||||
|
logDirectory: testRoot,
|
||||||
|
apiAuthKey: "test-auth-key",
|
||||||
|
messages: getDesktopMessages("en"),
|
||||||
|
shutdownEvidenceTimeoutMilliseconds: 25,
|
||||||
|
onStatus: () => {},
|
||||||
|
onUnexpectedExit: () => {},
|
||||||
|
});
|
||||||
|
// TypeScript `private` fields compile to ordinary properties. This test seam
|
||||||
|
// models an already-exited watchdog whose exact identifiers must be retained
|
||||||
|
// while a stubborn listener survives.
|
||||||
|
retainedManager.watchdog = {
|
||||||
|
pid: retainedWatchdogPid,
|
||||||
|
exitCode: 0,
|
||||||
|
signalCode: null,
|
||||||
|
};
|
||||||
|
retainedManager.backendPid = retainedBackendPid;
|
||||||
|
retainedManager.baseUrl = hungBaseUrl;
|
||||||
|
await assert.rejects(() => retainedManager.stopForUpdate(), /could not be verified/u);
|
||||||
|
assert.equal(retainedManager.url, hungBaseUrl);
|
||||||
|
assert.equal(retainedManager.processId, retainedBackendPid);
|
||||||
|
await close(hungListener);
|
||||||
|
const retriedEvidence = await retainedManager.stopForUpdate();
|
||||||
|
assert.deepEqual(retriedEvidence, {
|
||||||
|
backendPid: retainedBackendPid,
|
||||||
|
watchdogPid: retainedWatchdogPid,
|
||||||
|
backendExited: true,
|
||||||
|
watchdogExited: true,
|
||||||
|
listenerClosed: true,
|
||||||
|
});
|
||||||
|
assert.equal(retainedManager.url, undefined);
|
||||||
|
assert.equal(retainedManager.processId, undefined);
|
||||||
|
|
||||||
console.log(JSON.stringify({
|
console.log(JSON.stringify({
|
||||||
verificationCases: ["accepted", "tampered", "unsigned", "invalid-signature", "wrong-publisher", "downgraded"],
|
verificationCases: ["accepted", "tampered", "unsigned", "invalid-signature", "wrong-publisher", "downgraded", "mutation-during-verification", "mutation-before-launch"],
|
||||||
recoveryPhases: ["verified", "backend-stopped", "installer-launched", "completed", "version-mismatch", "path-traversal"],
|
recoveryPhases: ["verified", "backend-stopped", "installer-launched", "completed", "version-mismatch", "path-traversal", "concurrent-begin"],
|
||||||
|
shutdownFailureRetryVerified: true,
|
||||||
authenticodeAdapterVerified,
|
authenticodeAdapterVerified,
|
||||||
updaterEnabled: false,
|
updaterEnabled: false,
|
||||||
}, null, 2));
|
}, null, 2));
|
||||||
@@ -189,3 +283,19 @@ async function exists(file) {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function listen(server) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
server.once("error", reject);
|
||||||
|
server.listen(0, "127.0.0.1", () => {
|
||||||
|
server.off("error", reject);
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function close(server) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
server.close((error) => error ? reject(error) : resolve());
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
readFileSync,
|
readFileSync,
|
||||||
WriteStream,
|
WriteStream,
|
||||||
} from "node:fs";
|
} from "node:fs";
|
||||||
import { createServer } from "node:net";
|
import { connect, createServer } from "node:net";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import {
|
import {
|
||||||
DesktopMessages,
|
DesktopMessages,
|
||||||
@@ -21,6 +21,7 @@ type BackendManagerOptions = {
|
|||||||
apiAuthKey: string;
|
apiAuthKey: string;
|
||||||
messages: DesktopMessages;
|
messages: DesktopMessages;
|
||||||
credentialEnvironment?: NodeJS.ProcessEnv;
|
credentialEnvironment?: NodeJS.ProcessEnv;
|
||||||
|
shutdownEvidenceTimeoutMilliseconds?: number;
|
||||||
onStatus: (message: string) => void;
|
onStatus: (message: string) => void;
|
||||||
onUnexpectedExit: (message: string) => void;
|
onUnexpectedExit: (message: string) => void;
|
||||||
};
|
};
|
||||||
@@ -39,6 +40,11 @@ export type BackendShutdownEvidence = {
|
|||||||
listenerClosed: boolean;
|
listenerClosed: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type BackendShutdownTarget = Pick<
|
||||||
|
BackendShutdownEvidence,
|
||||||
|
"backendPid" | "watchdogPid"
|
||||||
|
> & { baseUrl?: string };
|
||||||
|
|
||||||
export type BackendResolutionOptions = {
|
export type BackendResolutionOptions = {
|
||||||
appPath: string;
|
appPath: string;
|
||||||
resourcesPath: string;
|
resourcesPath: string;
|
||||||
@@ -62,6 +68,7 @@ export class BackendManager {
|
|||||||
private watchdogError: Error | undefined;
|
private watchdogError: Error | undefined;
|
||||||
private backendPid: number | undefined;
|
private backendPid: number | undefined;
|
||||||
private baseUrl: string | undefined;
|
private baseUrl: string | undefined;
|
||||||
|
private shutdownTarget: BackendShutdownTarget | undefined;
|
||||||
private stopping = false;
|
private stopping = false;
|
||||||
private logStream: WriteStream | undefined;
|
private logStream: WriteStream | undefined;
|
||||||
private readonly recentOutput: string[] = [];
|
private readonly recentOutput: string[] = [];
|
||||||
@@ -138,7 +145,6 @@ export class BackendManager {
|
|||||||
});
|
});
|
||||||
watchdog.once("exit", (code, signal) => {
|
watchdog.once("exit", (code, signal) => {
|
||||||
this.writeLog(`[WATCHDOG] exited code=${String(code)} signal=${String(signal)}`);
|
this.writeLog(`[WATCHDOG] exited code=${String(code)} signal=${String(signal)}`);
|
||||||
this.backendPid = undefined;
|
|
||||||
if (!this.stopping) {
|
if (!this.stopping) {
|
||||||
this.options.onUnexpectedExit(formatDesktopMessage(
|
this.options.onUnexpectedExit(formatDesktopMessage(
|
||||||
this.options.messages.backendUnexpectedExit,
|
this.options.messages.backendUnexpectedExit,
|
||||||
@@ -155,16 +161,16 @@ export class BackendManager {
|
|||||||
|
|
||||||
async stop(): Promise<BackendShutdownEvidence> {
|
async stop(): Promise<BackendShutdownEvidence> {
|
||||||
const watchdog = this.watchdog;
|
const watchdog = this.watchdog;
|
||||||
const backendPid = this.backendPid;
|
const target = this.shutdownTarget ?? {
|
||||||
const watchdogPid = watchdog?.pid;
|
backendPid: this.backendPid,
|
||||||
const baseUrl = this.baseUrl;
|
watchdogPid: watchdog?.pid,
|
||||||
if (!watchdog) {
|
baseUrl: this.baseUrl,
|
||||||
return shutdownEvidence(backendPid, watchdogPid, baseUrl);
|
};
|
||||||
}
|
this.shutdownTarget = target;
|
||||||
this.stopping = true;
|
this.stopping = true;
|
||||||
if (isRunning(watchdog) && this.baseUrl) {
|
if (watchdog && isRunning(watchdog) && target.baseUrl) {
|
||||||
try {
|
try {
|
||||||
await fetch(new URL("system/shutdown", this.baseUrl), {
|
await fetch(new URL("system/shutdown", target.baseUrl), {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { Authorization: `Bearer ${this.options.apiAuthKey}` },
|
headers: { Authorization: `Bearer ${this.options.apiAuthKey}` },
|
||||||
signal: AbortSignal.timeout(2_000),
|
signal: AbortSignal.timeout(2_000),
|
||||||
@@ -174,11 +180,11 @@ export class BackendManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isRunning(watchdog)) {
|
if (watchdog && isRunning(watchdog)) {
|
||||||
await Promise.race([waitForExit(watchdog), delay(3_000)]);
|
await Promise.race([waitForExit(watchdog), delay(3_000)]);
|
||||||
}
|
}
|
||||||
if (isRunning(watchdog)) {
|
if (watchdog && isRunning(watchdog)) {
|
||||||
this.writeLog(`[SHUTDOWN] asking watchdog to terminate backend pid=${String(this.backendPid)}`);
|
this.writeLog(`[SHUTDOWN] asking watchdog to terminate backend pid=${String(target.backendPid)}`);
|
||||||
try {
|
try {
|
||||||
watchdog.send({ type: "terminate-backend" });
|
watchdog.send({ type: "terminate-backend" });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -186,21 +192,23 @@ export class BackendManager {
|
|||||||
}
|
}
|
||||||
await Promise.race([waitForExit(watchdog), delay(5_000)]);
|
await Promise.race([waitForExit(watchdog), delay(5_000)]);
|
||||||
}
|
}
|
||||||
if (isRunning(watchdog) && watchdog.pid) {
|
if (watchdog && isRunning(watchdog) && target.watchdogPid) {
|
||||||
this.writeLog(`[SHUTDOWN] terminating watchdog tree pid=${watchdog.pid}`);
|
this.writeLog(`[SHUTDOWN] terminating watchdog tree pid=${target.watchdogPid}`);
|
||||||
await runTaskkill(watchdog.pid);
|
await runTaskkill(target.watchdogPid);
|
||||||
}
|
}
|
||||||
|
|
||||||
const evidence = await waitForShutdownEvidence(
|
const evidence = await waitForShutdownEvidence(
|
||||||
backendPid,
|
target.backendPid,
|
||||||
watchdogPid,
|
target.watchdogPid,
|
||||||
baseUrl,
|
target.baseUrl,
|
||||||
5_000,
|
this.options.shutdownEvidenceTimeoutMilliseconds ?? 5_000,
|
||||||
);
|
);
|
||||||
if (!evidence.backendExited || !evidence.watchdogExited || !evidence.listenerClosed) {
|
if (!isCompleteShutdownEvidence(evidence)) {
|
||||||
this.writeLog(`[SHUTDOWN] incomplete evidence ${JSON.stringify(evidence)}`);
|
this.writeLog(`[SHUTDOWN] incomplete evidence ${JSON.stringify(evidence)}`);
|
||||||
|
return evidence;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.shutdownTarget = undefined;
|
||||||
this.watchdog = undefined;
|
this.watchdog = undefined;
|
||||||
this.watchdogError = undefined;
|
this.watchdogError = undefined;
|
||||||
this.backendPid = undefined;
|
this.backendPid = undefined;
|
||||||
@@ -212,7 +220,7 @@ export class BackendManager {
|
|||||||
|
|
||||||
async stopForUpdate(): Promise<BackendShutdownEvidence> {
|
async stopForUpdate(): Promise<BackendShutdownEvidence> {
|
||||||
const evidence = await this.stop();
|
const evidence = await this.stop();
|
||||||
if (!evidence.backendExited || !evidence.watchdogExited || !evidence.listenerClosed) {
|
if (!isCompleteShutdownEvidence(evidence)) {
|
||||||
throw new Error(`Owned backend shutdown could not be verified: ${JSON.stringify(evidence)}`);
|
throw new Error(`Owned backend shutdown could not be verified: ${JSON.stringify(evidence)}`);
|
||||||
}
|
}
|
||||||
return evidence;
|
return evidence;
|
||||||
@@ -443,7 +451,7 @@ async function waitForShutdownEvidence(
|
|||||||
let evidence = await shutdownEvidence(backendPid, watchdogPid, baseUrl);
|
let evidence = await shutdownEvidence(backendPid, watchdogPid, baseUrl);
|
||||||
while (
|
while (
|
||||||
Date.now() < deadline &&
|
Date.now() < deadline &&
|
||||||
(!evidence.backendExited || !evidence.watchdogExited || !evidence.listenerClosed)
|
!isCompleteShutdownEvidence(evidence)
|
||||||
) {
|
) {
|
||||||
await delay(100);
|
await delay(100);
|
||||||
evidence = await shutdownEvidence(backendPid, watchdogPid, baseUrl);
|
evidence = await shutdownEvidence(backendPid, watchdogPid, baseUrl);
|
||||||
@@ -461,10 +469,14 @@ async function shutdownEvidence(
|
|||||||
watchdogPid,
|
watchdogPid,
|
||||||
backendExited: !backendPid || !isProcessAlive(backendPid),
|
backendExited: !backendPid || !isProcessAlive(backendPid),
|
||||||
watchdogExited: !watchdogPid || !isProcessAlive(watchdogPid),
|
watchdogExited: !watchdogPid || !isProcessAlive(watchdogPid),
|
||||||
listenerClosed: !baseUrl || !(await listenerAcceptsConnections(baseUrl)),
|
listenerClosed: !baseUrl || await listenerIsClosed(baseUrl),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isCompleteShutdownEvidence(evidence: BackendShutdownEvidence): boolean {
|
||||||
|
return evidence.backendExited && evidence.watchdogExited && evidence.listenerClosed;
|
||||||
|
}
|
||||||
|
|
||||||
function isProcessAlive(pid: number): boolean {
|
function isProcessAlive(pid: number): boolean {
|
||||||
try {
|
try {
|
||||||
process.kill(pid, 0);
|
process.kill(pid, 0);
|
||||||
@@ -474,15 +486,32 @@ function isProcessAlive(pid: number): boolean {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function listenerAcceptsConnections(baseUrl: string): Promise<boolean> {
|
async function listenerIsClosed(baseUrl: string): Promise<boolean> {
|
||||||
|
let endpoint: URL;
|
||||||
try {
|
try {
|
||||||
await fetch(new URL("health", baseUrl), {
|
endpoint = new URL(baseUrl);
|
||||||
signal: AbortSignal.timeout(500),
|
|
||||||
});
|
|
||||||
return true;
|
|
||||||
} catch {
|
} catch {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
const port = Number(endpoint.port);
|
||||||
|
if (!Number.isSafeInteger(port) || port <= 0 || port > 65_535) return false;
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const socket = connect({ host: endpoint.hostname, port });
|
||||||
|
let settled = false;
|
||||||
|
const finish = (closed: boolean): void => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
socket.destroy();
|
||||||
|
resolve(closed);
|
||||||
|
};
|
||||||
|
socket.setTimeout(500, () => finish(false));
|
||||||
|
socket.once("connect", () => finish(false));
|
||||||
|
socket.once("error", (error: NodeJS.ErrnoException) => {
|
||||||
|
// Only an explicit loopback refusal proves that no listener accepted the
|
||||||
|
// connection. Timeouts and other socket failures stay fail-closed.
|
||||||
|
finish(error.code === "ECONNREFUSED");
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function runTaskkill(pid: number): Promise<void> {
|
function runTaskkill(pid: number): Promise<void> {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { randomBytes } from "node:crypto";
|
import { randomBytes } from "node:crypto";
|
||||||
import { mkdir, readFile, rename, rm, unlink, writeFile } from "node:fs/promises";
|
import { link, mkdir, open, readFile, rename, rm, unlink } from "node:fs/promises";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { compareSemanticVersions } from "./update-verification";
|
import { compareSemanticVersions } from "./update-verification";
|
||||||
|
|
||||||
@@ -53,12 +53,6 @@ export class UpdateRecoveryJournal {
|
|||||||
throw new Error("An update attempt must target a newer semantic version");
|
throw new Error("An update attempt must target a newer semantic version");
|
||||||
}
|
}
|
||||||
await mkdir(this.stagingDirectory, { recursive: true });
|
await mkdir(this.stagingDirectory, { recursive: true });
|
||||||
try {
|
|
||||||
await readFile(this.journalPath, "utf8");
|
|
||||||
throw new Error("A pending update attempt already exists");
|
|
||||||
} catch (error) {
|
|
||||||
if (errorCode(error) !== "ENOENT") throw error;
|
|
||||||
}
|
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
const record: UpdateAttempt = {
|
const record: UpdateAttempt = {
|
||||||
...attempt,
|
...attempt,
|
||||||
@@ -68,7 +62,7 @@ export class UpdateRecoveryJournal {
|
|||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
};
|
};
|
||||||
await this.write(record);
|
await this.write(record, true);
|
||||||
return record;
|
return record;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -135,17 +129,34 @@ export class UpdateRecoveryJournal {
|
|||||||
return parsed;
|
return parsed;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async write(attempt: UpdateAttempt): Promise<void> {
|
private async write(attempt: UpdateAttempt, exclusive = false): Promise<void> {
|
||||||
const temporaryPath = path.join(
|
const temporaryPath = path.join(
|
||||||
this.stagingDirectory,
|
this.stagingDirectory,
|
||||||
`${journalFileName}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`,
|
`${journalFileName}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`,
|
||||||
);
|
);
|
||||||
try {
|
try {
|
||||||
await writeFile(temporaryPath, `${JSON.stringify(attempt, null, 2)}\n`, {
|
const handle = await open(temporaryPath, "wx");
|
||||||
encoding: "utf8",
|
try {
|
||||||
flag: "wx",
|
await handle.writeFile(`${JSON.stringify(attempt, null, 2)}\n`, "utf8");
|
||||||
});
|
await handle.sync();
|
||||||
await rename(temporaryPath, this.journalPath);
|
} finally {
|
||||||
|
await handle.close();
|
||||||
|
}
|
||||||
|
if (exclusive) {
|
||||||
|
try {
|
||||||
|
// Linking a complete same-directory temp file is an atomic
|
||||||
|
// create-if-absent operation on Windows and POSIX. Unlike rename, it
|
||||||
|
// can never replace a concurrent pending attempt.
|
||||||
|
await link(temporaryPath, this.journalPath);
|
||||||
|
} catch (error) {
|
||||||
|
if (errorCode(error) === "EEXIST") {
|
||||||
|
throw new Error("A pending update attempt already exists");
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
await rename(temporaryPath, this.journalPath);
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
await rm(temporaryPath, { force: true });
|
await rm(temporaryPath, { force: true });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import path from "node:path";
|
|||||||
|
|
||||||
export type AuthenticodeInspection = {
|
export type AuthenticodeInspection = {
|
||||||
status: string;
|
status: string;
|
||||||
|
artifactSha256?: string;
|
||||||
signerSubject?: string;
|
signerSubject?: string;
|
||||||
certificateSha256?: string;
|
certificateSha256?: string;
|
||||||
};
|
};
|
||||||
@@ -26,6 +27,7 @@ export type UpdateRejectionCode =
|
|||||||
| "version-not-newer"
|
| "version-not-newer"
|
||||||
| "artifact-inspection-failed"
|
| "artifact-inspection-failed"
|
||||||
| "artifact-hash-mismatch"
|
| "artifact-hash-mismatch"
|
||||||
|
| "artifact-changed-during-verification"
|
||||||
| "artifact-unsigned"
|
| "artifact-unsigned"
|
||||||
| "artifact-signature-invalid"
|
| "artifact-signature-invalid"
|
||||||
| "publisher-not-allowed";
|
| "publisher-not-allowed";
|
||||||
@@ -113,15 +115,56 @@ export async function verifyWindowsUpdateCandidate(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const inspectedArtifactSha256 = normalizeSha256(signature.artifactSha256);
|
||||||
|
if (!inspectedArtifactSha256 || inspectedArtifactSha256 !== actualHash) {
|
||||||
|
return reject(
|
||||||
|
"artifact-changed-during-verification",
|
||||||
|
"Authenticode inspection did not cover the same artifact bytes as the release digest.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let confirmedHash: string;
|
||||||
|
try {
|
||||||
|
confirmedHash = await sha256(candidate.artifactPath);
|
||||||
|
} catch (error) {
|
||||||
|
return reject(
|
||||||
|
"artifact-inspection-failed",
|
||||||
|
`The artifact could not be confirmed after signature inspection: ${errorText(error)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (confirmedHash !== actualHash) {
|
||||||
|
return reject(
|
||||||
|
"artifact-changed-during-verification",
|
||||||
|
"The update artifact changed while its signature was being verified.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
accepted: true,
|
accepted: true,
|
||||||
version: candidate.version,
|
version: candidate.version,
|
||||||
sha256: actualHash,
|
sha256: confirmedHash,
|
||||||
signerSubject: signature.signerSubject,
|
signerSubject: signature.signerSubject,
|
||||||
certificateSha256,
|
certificateSha256,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function assertVerifiedUpdateArtifactUnchanged(
|
||||||
|
artifactPath: string,
|
||||||
|
expectedSha256: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const expectedHash = normalizeSha256(expectedSha256);
|
||||||
|
if (!expectedHash) throw new Error("The verified artifact SHA-256 is malformed");
|
||||||
|
let actualHash: string;
|
||||||
|
try {
|
||||||
|
actualHash = await sha256(artifactPath);
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(`The verified update artifact could not be read: ${errorText(error)}`);
|
||||||
|
}
|
||||||
|
if (actualHash !== expectedHash) {
|
||||||
|
throw new Error("The verified update artifact changed before installer launch");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function inspectAuthenticodeSignature(artifactPath: string): AuthenticodeInspection {
|
export function inspectAuthenticodeSignature(artifactPath: string): AuthenticodeInspection {
|
||||||
if (process.platform !== "win32") {
|
if (process.platform !== "win32") {
|
||||||
return { status: "UnsupportedPlatform" };
|
return { status: "UnsupportedPlatform" };
|
||||||
@@ -133,22 +176,33 @@ export function inspectAuthenticodeSignature(artifactPath: string): Authenticode
|
|||||||
"-NonInteractive",
|
"-NonInteractive",
|
||||||
"-Command",
|
"-Command",
|
||||||
[
|
[
|
||||||
"$signature = Get-AuthenticodeSignature -LiteralPath $env:VIBE_UPDATE_ARTIFACT_PATH",
|
"$artifactPath = [System.IO.Path]::GetFullPath($env:VIBE_UPDATE_ARTIFACT_PATH)",
|
||||||
"$fingerprint = $null",
|
"$stream = [System.IO.File]::Open($artifactPath, [System.IO.FileMode]::Open, [System.IO.FileAccess]::Read, [System.IO.FileShare]::Read)",
|
||||||
"if ($signature.SignerCertificate) {",
|
"try {",
|
||||||
" $sha256 = [System.Security.Cryptography.SHA256]::Create()",
|
" $artifactHasher = [System.Security.Cryptography.SHA256]::Create()",
|
||||||
" try {",
|
" try {",
|
||||||
" $bytes = $sha256.ComputeHash($signature.SignerCertificate.RawData)",
|
" $artifactBytes = $artifactHasher.ComputeHash($stream)",
|
||||||
" $fingerprint = ([System.BitConverter]::ToString($bytes)).Replace('-', '').ToLowerInvariant()",
|
" $artifactFingerprint = ([System.BitConverter]::ToString($artifactBytes)).Replace('-', '').ToLowerInvariant()",
|
||||||
" } finally { $sha256.Dispose() }",
|
" } finally { $artifactHasher.Dispose() }",
|
||||||
"}",
|
" $signature = Get-AuthenticodeSignature -LiteralPath $artifactPath",
|
||||||
"@{ status = $signature.Status.ToString(); signerSubject = $signature.SignerCertificate.Subject; certificateSha256 = $fingerprint } | ConvertTo-Json -Compress",
|
" $certificateFingerprint = $null",
|
||||||
].join("; "),
|
" if ($signature.SignerCertificate) {",
|
||||||
|
" $certificateHasher = [System.Security.Cryptography.SHA256]::Create()",
|
||||||
|
" try {",
|
||||||
|
" $certificateBytes = $certificateHasher.ComputeHash($signature.SignerCertificate.RawData)",
|
||||||
|
" $certificateFingerprint = ([System.BitConverter]::ToString($certificateBytes)).Replace('-', '').ToLowerInvariant()",
|
||||||
|
" } finally { $certificateHasher.Dispose() }",
|
||||||
|
" }",
|
||||||
|
" @{ status = $signature.Status.ToString(); artifactSha256 = $artifactFingerprint; signerSubject = $signature.SignerCertificate.Subject; certificateSha256 = $certificateFingerprint } | ConvertTo-Json -Compress",
|
||||||
|
"} finally { $stream.Dispose() }",
|
||||||
|
].join("\n"),
|
||||||
],
|
],
|
||||||
{
|
{
|
||||||
env: { ...process.env, VIBE_UPDATE_ARTIFACT_PATH: path.resolve(artifactPath) },
|
env: { ...process.env, VIBE_UPDATE_ARTIFACT_PATH: path.resolve(artifactPath) },
|
||||||
encoding: "utf8",
|
encoding: "utf8",
|
||||||
windowsHide: true,
|
windowsHide: true,
|
||||||
|
timeout: 15_000,
|
||||||
|
maxBuffer: 1_048_576,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
if (result.error) throw result.error;
|
if (result.error) throw result.error;
|
||||||
@@ -162,6 +216,9 @@ export function inspectAuthenticodeSignature(artifactPath: string): Authenticode
|
|||||||
const payload = parsed as Record<string, unknown>;
|
const payload = parsed as Record<string, unknown>;
|
||||||
return {
|
return {
|
||||||
status: payload.status as string,
|
status: payload.status as string,
|
||||||
|
artifactSha256: typeof payload.artifactSha256 === "string"
|
||||||
|
? payload.artifactSha256
|
||||||
|
: undefined,
|
||||||
signerSubject: typeof payload.signerSubject === "string" ? payload.signerSubject : undefined,
|
signerSubject: typeof payload.signerSubject === "string" ? payload.signerSubject : undefined,
|
||||||
certificateSha256: typeof payload.certificateSha256 === "string"
|
certificateSha256: typeof payload.certificateSha256 === "string"
|
||||||
? payload.certificateSha256
|
? payload.certificateSha256
|
||||||
@@ -231,7 +288,12 @@ function resolveSignaturePowerShell(): string {
|
|||||||
const probe = spawnSync(
|
const probe = spawnSync(
|
||||||
candidate,
|
candidate,
|
||||||
["-NoProfile", "-NonInteractive", "-Command", "Get-Command Get-AuthenticodeSignature -ErrorAction Stop | Out-Null"],
|
["-NoProfile", "-NonInteractive", "-Command", "Get-Command Get-AuthenticodeSignature -ErrorAction Stop | Out-Null"],
|
||||||
{ encoding: "utf8", windowsHide: true },
|
{
|
||||||
|
encoding: "utf8",
|
||||||
|
windowsHide: true,
|
||||||
|
timeout: 5_000,
|
||||||
|
maxBuffer: 262_144,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
if (!probe.error && probe.status === 0) return candidate;
|
if (!probe.error && probe.status === 0) return candidate;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user