cabloy 5.1.216 → 5.1.218
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.cabloy-version +1 -1
- package/.github/workflows/create-cabloy.yml +137 -0
- package/.github/workflows/playwright-e2e.yml +163 -13
- package/.github/workflows/vona-test-sqlite3.yml +77 -7
- package/CHANGELOG.md +22 -0
- package/CONTRIBUTING.md +4 -2
- package/package.json +1 -1
- package/repo-docs/frontend/scripts.md +2 -0
- package/repo-docs/reference/repo-scripts.md +27 -1
- package/repo-e2e/scripts/e2e.test.ts +1 -1
- package/repo-e2e/scripts/managedTest.test.ts +115 -3
- package/repo-e2e/scripts/managedTest.ts +69 -20
- package/repo-e2e/scripts/managedTestImports.test.ts +55 -5
- package/repo-e2e/scripts/runE2e.test.ts +109 -0
- package/repo-e2e/scripts/runE2e.ts +58 -39
- package/repo-e2e/scripts/runE2eArgs.test.ts +16 -1
- package/repo-e2e/scripts/runE2eArgs.ts +5 -0
- package/repo-e2e/specs/a-commerce.spec.ts +10 -2
- package/repo-e2e/specs/home-user-account.spec.ts +4 -0
- package/repo-e2e/specs/markdown.spec.ts +14 -3
- package/scripts/generateOpenapiIsolated.test.ts +5 -1
- package/scripts/runIsolatedTest.test.ts +403 -36
- package/scripts/runIsolatedTest.ts +139 -12
- package/scripts/testBrowserGuardianWindows.test.ts +327 -0
- package/scripts/testClaimCleanup.test.ts +25 -9
- package/scripts/testClaimCleanup.ts +14 -45
- package/scripts/testClaimProcesses.test.ts +81 -2
- package/scripts/testClaimProcesses.ts +46 -10
- package/scripts/testProcessGuardianWindows.test.ts +602 -0
- package/scripts/testProcessGuardianWindows.ts +155 -0
- package/vona/packages-cli/cli/package.json +1 -1
- package/vona/packages-cli/cli-set-api/package.json +1 -1
- package/vona/packages-cli/cli-set-api/src/lib/bean/cli.bin.build.ts +4 -1
- package/vona/packages-cli/cli-set-api/test/binBuildEntries.test.ts +159 -0
- package/vona/packages-vona/vona/package.json +1 -1
- package/vona/packages-vona/vona-core/package.json +1 -1
- package/vona/packages-vona/vona-core/src/lib/core/testClaim.test.ts +298 -17
- package/vona/packages-vona/vona-core/src/lib/core/testClaim.ts +205 -97
- package/vona/packages-vona/vona-core/src/lib/core/testClaimFilesystem.test.ts +247 -0
- package/vona/packages-vona/vona-core/src/lib/core/testClaimFilesystem.ts +224 -0
- package/vona/packages-vona/vona-core/src/lib/core/testClaimFilesystemWindows.cs +229 -0
- package/vona/packages-vona/vona-core/src/lib/core/testClaimFilesystemWindows.ps1 +20 -0
- package/vona/packages-vona/vona-core/src/lib/core/testClaimFilesystemWindows.ts +200 -0
- package/vona/packages-vona/vona-core/src/lib/core/testProcessWindowsNative.cs +336 -0
- package/vona/packages-vona/vona-core/src/lib/core/testProcessWindowsNative.ps1 +20 -0
- package/vona/packages-vona/vona-core/src/lib/core/testProcessWindowsNative.ts +531 -0
- package/vona/packages-vona/vona-core/src/lib/framework/bootstrap.test.ts +61 -0
- package/vona/packages-vona/vona-core/src/lib/framework/index.ts +1 -0
- package/vona/packages-vona/vona-core/src/lib/framework/processWorker.test.ts +59 -0
- package/vona/packages-vona/vona-core/src/lib/framework/processWorker.ts +8 -5
- package/vona/packages-vona/vona-core/src/lib/framework/useApp.test.ts +84 -0
- package/vona/packages-vona/vona-core/src/lib/framework/useApp.ts +2 -2
- package/vona/packages-vona/vona-core/src/lib/utils/util.ts +1 -1
- package/vona/packages-vona/vona-mock/package.json +1 -1
- package/vona/pnpm-lock.yaml +42 -181
- package/vona/src/suite-vendor/a-cabloy/modules/a-rbac/package.json +1 -1
- package/vona/src/suite-vendor/a-cabloy/package.json +2 -2
- package/vona/src/suite-vendor/a-vona/modules/a-core/package.json +1 -1
- package/vona/src/suite-vendor/a-vona/modules/a-executor/package.json +1 -1
- package/vona/src/suite-vendor/a-vona/modules/a-executor/src/bean/bean.executor.ts +2 -1
- package/vona/src/suite-vendor/a-vona/modules/a-logger/package.json +1 -1
- package/vona/src/suite-vendor/a-vona/modules/a-logger/src/bean/middlewareSystem.httpLog.ts +2 -2
- package/vona/src/suite-vendor/a-vona/modules/a-logger/src/lib/redactIsolatedReadyHeader.test.ts +16 -0
- package/vona/src/suite-vendor/a-vona/modules/a-logger/src/lib/redactIsolatedReadyHeader.ts +6 -3
- package/vona/src/suite-vendor/a-vona/modules/a-logger/test/middlewareSystem.httpLog.test.ts +80 -0
- package/vona/src/suite-vendor/a-vona/modules/a-metrics/package.json +1 -1
- package/vona/src/suite-vendor/a-vona/modules/a-metrics/src/bean/middlewareSystem.health.ts +73 -2
- package/vona/src/suite-vendor/a-vona/modules/a-metrics/test/middlewareSystem.health.test.ts +309 -0
- package/vona/src/suite-vendor/a-vona/modules/a-orm/package.json +1 -1
- package/vona/src/suite-vendor/a-vona/modules/a-orm/src/service/transactionAsyncLocalStorage_.ts +3 -3
- package/vona/src/suite-vendor/a-vona/modules/a-permission/package.json +1 -1
- package/vona/src/suite-vendor/a-vona/modules/a-static/package.json +1 -1
- package/vona/src/suite-vendor/a-vona/modules/a-static/src/bean/middlewareSystem.static.ts +2 -2
- package/vona/src/suite-vendor/a-vona/modules/a-static/test/middlewareSystem.static.test.ts +101 -0
- package/vona/src/suite-vendor/a-vona/package.json +1 -1
- package/zova/package.original.json +1 -1
- package/zova/packages-cli/cli/package.json +2 -2
- package/zova/packages-cli/cli-set-front/package.json +4 -4
- package/zova/packages-utils/zova-jsx/package.json +2 -2
- package/zova/packages-utils/zova-vite/package.json +2 -2
- package/zova/packages-utils/zova-vite/src/configUtils.ts +1 -1
- package/zova/packages-utils/zova-vite/test/configUtils.test.ts +55 -0
- package/zova/packages-zova/zova/package.json +3 -3
- package/zova/packages-zova/zova-core/package.json +2 -2
- package/zova/pnpm-lock.yaml +324 -63
- package/zova/src/suite/a-commerce/modules/commerce-trade/src/model/cart.ts +8 -4
- package/zova/src/suite/a-commerce/modules/commerce-trade/test/lib/cartModel.test.ts +71 -0
- package/zova/src/suite-vendor/a-zova/modules/a-zova/package.json +2 -2
- package/zova/src/suite-vendor/a-zova/package.json +2 -2
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto';
|
|
2
|
+
import { constants, lstatSync } from 'node:fs';
|
|
3
|
+
import { lstat, mkdir, open, rename, unlink } from 'node:fs/promises';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
windowsTestClaimFilesystem,
|
|
9
|
+
windowsTestClaimFilesystemSync,
|
|
10
|
+
} from './testClaimFilesystemWindows.ts';
|
|
11
|
+
|
|
12
|
+
export const TEST_CLAIM_FILE_LIMIT = 4 * 1024 * 1024;
|
|
13
|
+
|
|
14
|
+
export function assertTestClaimFilesystemSafePath(file: string, allowMissing = true) {
|
|
15
|
+
if (process.platform === 'win32') {
|
|
16
|
+
windowsTestClaimFilesystemSync({
|
|
17
|
+
command: 'safe-path',
|
|
18
|
+
path: path.resolve(file),
|
|
19
|
+
allowMissing,
|
|
20
|
+
});
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
let current = path.resolve(file);
|
|
24
|
+
while (true) {
|
|
25
|
+
try {
|
|
26
|
+
const stat = lstatSync(current);
|
|
27
|
+
if (stat.isSymbolicLink() || (current !== path.resolve(file) && !stat.isDirectory()))
|
|
28
|
+
throw new Error('Unsafe test claim path');
|
|
29
|
+
} catch (error) {
|
|
30
|
+
if (!allowMissing || (error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
|
|
31
|
+
}
|
|
32
|
+
const parent = path.dirname(current);
|
|
33
|
+
if (parent === current) break;
|
|
34
|
+
current = parent;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export async function assertTestClaimPrivateDirectory(
|
|
39
|
+
directory: string,
|
|
40
|
+
missingAllowed = true,
|
|
41
|
+
): Promise<boolean> {
|
|
42
|
+
if (process.platform === 'win32') {
|
|
43
|
+
const value = await windowsTestClaimFilesystem({
|
|
44
|
+
command: 'directory',
|
|
45
|
+
path: path.resolve(directory),
|
|
46
|
+
allowMissing: missingAllowed,
|
|
47
|
+
});
|
|
48
|
+
return value.exists === true;
|
|
49
|
+
}
|
|
50
|
+
assertTestClaimFilesystemSafePath(directory, missingAllowed);
|
|
51
|
+
let stat;
|
|
52
|
+
try {
|
|
53
|
+
stat = await lstat(directory);
|
|
54
|
+
} catch (error) {
|
|
55
|
+
if (missingAllowed && (error as NodeJS.ErrnoException).code === 'ENOENT') return false;
|
|
56
|
+
throw error;
|
|
57
|
+
}
|
|
58
|
+
if (
|
|
59
|
+
!stat.isDirectory() ||
|
|
60
|
+
(stat.mode & 0o077) !== 0 ||
|
|
61
|
+
(process.getuid && stat.uid !== process.getuid())
|
|
62
|
+
) {
|
|
63
|
+
throw new Error('Unsafe shared test claim directory');
|
|
64
|
+
}
|
|
65
|
+
return true;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export async function ensureTestClaimPrivateDirectory(directory: string) {
|
|
69
|
+
if (process.platform === 'win32') {
|
|
70
|
+
await windowsTestClaimFilesystem({
|
|
71
|
+
command: 'ensure-directory',
|
|
72
|
+
path: path.resolve(directory),
|
|
73
|
+
});
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
assertTestClaimFilesystemSafePath(directory);
|
|
77
|
+
await mkdir(directory, { mode: 0o700 }).catch(error => {
|
|
78
|
+
if (error.code !== 'EEXIST') throw error;
|
|
79
|
+
});
|
|
80
|
+
await assertTestClaimPrivateDirectory(directory, false);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function assertPrivateFile(file: string) {
|
|
84
|
+
assertTestClaimFilesystemSafePath(file, false);
|
|
85
|
+
const stat = lstatSync(file);
|
|
86
|
+
if (
|
|
87
|
+
!stat.isFile() ||
|
|
88
|
+
stat.nlink !== 1 ||
|
|
89
|
+
(stat.mode & 0o077) !== 0 ||
|
|
90
|
+
(process.getuid && stat.uid !== process.getuid())
|
|
91
|
+
) {
|
|
92
|
+
throw new Error('Unsafe test claim file');
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
async function syncDirectory(directory: string) {
|
|
96
|
+
const handle = await open(directory, constants.O_RDONLY);
|
|
97
|
+
try {
|
|
98
|
+
await handle.sync();
|
|
99
|
+
} finally {
|
|
100
|
+
await handle.close();
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
function checkContent(content: string) {
|
|
104
|
+
if (Buffer.byteLength(content) > TEST_CLAIM_FILE_LIMIT)
|
|
105
|
+
throw new Error('Invalid test claim file size');
|
|
106
|
+
}
|
|
107
|
+
export async function readTestClaimFile(file: string): Promise<string> {
|
|
108
|
+
if (process.platform === 'win32') {
|
|
109
|
+
const value = await windowsTestClaimFilesystem({ command: 'read', path: path.resolve(file) });
|
|
110
|
+
if (typeof value.content !== 'string') throw new Error('Invalid test claim file response');
|
|
111
|
+
return value.content;
|
|
112
|
+
}
|
|
113
|
+
assertPrivateFile(file);
|
|
114
|
+
const handle = await open(file, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
115
|
+
try {
|
|
116
|
+
const stat = await handle.stat();
|
|
117
|
+
if (
|
|
118
|
+
!stat.isFile() ||
|
|
119
|
+
stat.nlink !== 1 ||
|
|
120
|
+
stat.size > TEST_CLAIM_FILE_LIMIT ||
|
|
121
|
+
(stat.mode & 0o077) !== 0 ||
|
|
122
|
+
(process.getuid && stat.uid !== process.getuid())
|
|
123
|
+
) {
|
|
124
|
+
throw new Error('Invalid test claim file');
|
|
125
|
+
}
|
|
126
|
+
// Bound the allocation and bytes read even if the file grows after fstat.
|
|
127
|
+
const buffer = Buffer.alloc(TEST_CLAIM_FILE_LIMIT + 1);
|
|
128
|
+
let offset = 0;
|
|
129
|
+
while (offset < buffer.length) {
|
|
130
|
+
const { bytesRead } = await handle.read(buffer, offset, buffer.length - offset, null);
|
|
131
|
+
if (!bytesRead) break;
|
|
132
|
+
offset += bytesRead;
|
|
133
|
+
}
|
|
134
|
+
if (offset > TEST_CLAIM_FILE_LIMIT) throw new Error('Invalid test claim file size');
|
|
135
|
+
return buffer.subarray(0, offset).toString('utf8');
|
|
136
|
+
} finally {
|
|
137
|
+
await handle.close();
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
export async function createTestClaimFile(file: string, content: string) {
|
|
141
|
+
checkContent(content);
|
|
142
|
+
if (process.platform === 'win32') {
|
|
143
|
+
await windowsTestClaimFilesystem({ command: 'create', path: path.resolve(file), content });
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
assertTestClaimFilesystemSafePath(file);
|
|
147
|
+
const handle = await open(
|
|
148
|
+
file,
|
|
149
|
+
constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW,
|
|
150
|
+
0o600,
|
|
151
|
+
);
|
|
152
|
+
try {
|
|
153
|
+
await handle.writeFile(content);
|
|
154
|
+
await handle.sync();
|
|
155
|
+
} finally {
|
|
156
|
+
await handle.close();
|
|
157
|
+
}
|
|
158
|
+
await syncDirectory(path.dirname(file));
|
|
159
|
+
}
|
|
160
|
+
export async function replaceTestClaimFile(file: string, content: string) {
|
|
161
|
+
checkContent(content);
|
|
162
|
+
if (process.platform === 'win32') {
|
|
163
|
+
await windowsTestClaimFilesystem({ command: 'replace', path: path.resolve(file), content });
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
const temp = `${file}.${randomBytes(12).toString('hex')}.tmp`;
|
|
167
|
+
const handle = await open(
|
|
168
|
+
temp,
|
|
169
|
+
constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW,
|
|
170
|
+
0o600,
|
|
171
|
+
);
|
|
172
|
+
try {
|
|
173
|
+
try {
|
|
174
|
+
await handle.writeFile(content);
|
|
175
|
+
await handle.sync();
|
|
176
|
+
} finally {
|
|
177
|
+
await handle.close();
|
|
178
|
+
}
|
|
179
|
+
assertPrivateFile(file);
|
|
180
|
+
await rename(temp, file);
|
|
181
|
+
await syncDirectory(path.dirname(file));
|
|
182
|
+
} finally {
|
|
183
|
+
await unlink(temp).catch(error => {
|
|
184
|
+
if (error.code !== 'ENOENT') throw error;
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
export async function deleteTestClaimFile(file: string) {
|
|
189
|
+
if (process.platform === 'win32') {
|
|
190
|
+
await windowsTestClaimFilesystem({ command: 'delete', path: path.resolve(file) });
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
assertPrivateFile(file);
|
|
194
|
+
await unlink(file);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export interface TestClaimFilesystemLock {
|
|
198
|
+
release(): Promise<void>;
|
|
199
|
+
}
|
|
200
|
+
export async function acquireTestClaimCleanupLock(file: string): Promise<TestClaimFilesystemLock> {
|
|
201
|
+
const token = randomBytes(24).toString('hex');
|
|
202
|
+
const content = `${JSON.stringify({ pid: process.pid, hostname: os.hostname(), startTime: String(Date.now() - process.uptime() * 1000), token })}\n`;
|
|
203
|
+
// Existence, never age, is the ownership gate. A crash leaves the lock for inspection.
|
|
204
|
+
await createTestClaimFile(file, content);
|
|
205
|
+
let released = false;
|
|
206
|
+
return {
|
|
207
|
+
async release() {
|
|
208
|
+
if (released) return;
|
|
209
|
+
if (process.platform === 'win32') {
|
|
210
|
+
await windowsTestClaimFilesystem({
|
|
211
|
+
command: 'release-lock',
|
|
212
|
+
path: path.resolve(file),
|
|
213
|
+
expected: content,
|
|
214
|
+
});
|
|
215
|
+
} else {
|
|
216
|
+
// Exact serialized token-bearing identity; never remove a replacement lock.
|
|
217
|
+
if ((await readTestClaimFile(file)) !== content)
|
|
218
|
+
throw new Error('Test claim cleanup lock identity changed');
|
|
219
|
+
await unlink(file);
|
|
220
|
+
}
|
|
221
|
+
released = true;
|
|
222
|
+
},
|
|
223
|
+
};
|
|
224
|
+
}
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
using System;
|
|
2
|
+
using System.Collections.Generic;
|
|
3
|
+
using System.ComponentModel;
|
|
4
|
+
using System.IO;
|
|
5
|
+
using System.Runtime.InteropServices;
|
|
6
|
+
using System.Security.AccessControl;
|
|
7
|
+
using System.Security.Principal;
|
|
8
|
+
using System.Text;
|
|
9
|
+
using Microsoft.Win32.SafeHandles;
|
|
10
|
+
|
|
11
|
+
namespace Cabloy {
|
|
12
|
+
// Local NTFS only. FlushFileBuffers + same-directory rename provides committed
|
|
13
|
+
// process/crash-safe updates, not a promise of power-loss directory durability.
|
|
14
|
+
public static class TestClaimFilesystem {
|
|
15
|
+
const uint ReadControl = 0x20000, DeleteAccess = 0x10000, ReadAttributes = 0x80;
|
|
16
|
+
const uint GenericRead = 0x80000000, GenericWrite = 0x40000000;
|
|
17
|
+
const uint ShareRead = 1, ShareWrite = 2, OpenExisting = 3, CreateNew = 1;
|
|
18
|
+
const uint OpenReparsePoint = 0x200000, BackupSemantics = 0x2000000;
|
|
19
|
+
const uint ReparsePoint = 0x400, DirectoryAttribute = 0x10;
|
|
20
|
+
const int Limit = 4 * 1024 * 1024;
|
|
21
|
+
static readonly string UserSid = WindowsIdentity.GetCurrent().User.Value;
|
|
22
|
+
static readonly Encoding Utf8 = new UTF8Encoding(false, true);
|
|
23
|
+
[StructLayout(LayoutKind.Sequential)] struct SecurityAttributes {
|
|
24
|
+
public int Length; public IntPtr Descriptor;
|
|
25
|
+
[MarshalAs(UnmanagedType.Bool)] public bool Inherit;
|
|
26
|
+
}
|
|
27
|
+
[StructLayout(LayoutKind.Sequential)] struct FileInformation {
|
|
28
|
+
public uint Attributes; public System.Runtime.InteropServices.ComTypes.FILETIME Creation, Access, Write;
|
|
29
|
+
public uint Volume, SizeHigh, SizeLow, Links, IndexHigh, IndexLow;
|
|
30
|
+
}
|
|
31
|
+
[StructLayout(LayoutKind.Sequential)] struct Disposition {
|
|
32
|
+
[MarshalAs(UnmanagedType.U1)] public bool Delete;
|
|
33
|
+
}
|
|
34
|
+
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
|
|
35
|
+
static extern SafeFileHandle CreateFileW(string name, uint access, uint share, IntPtr security, uint creation, uint flags, IntPtr template);
|
|
36
|
+
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
|
|
37
|
+
static extern bool CreateDirectoryW(string name, ref SecurityAttributes security);
|
|
38
|
+
[DllImport("kernel32.dll", SetLastError = true)] static extern bool GetFileInformationByHandle(SafeFileHandle file, out FileInformation info);
|
|
39
|
+
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
|
|
40
|
+
static extern bool GetVolumeInformationByHandleW(SafeFileHandle file, StringBuilder volume, uint volumeSize, out uint serial, out uint maxLength, out uint flags, StringBuilder filesystem, uint filesystemSize);
|
|
41
|
+
[DllImport("kernel32.dll", CharSet = CharSet.Unicode)] static extern uint GetDriveTypeW(string root);
|
|
42
|
+
[DllImport("advapi32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
|
|
43
|
+
static extern bool ConvertStringSecurityDescriptorToSecurityDescriptorW(string sddl, uint revision, out IntPtr descriptor, out uint size);
|
|
44
|
+
[DllImport("advapi32.dll")] static extern uint GetSecurityInfo(SafeFileHandle file, int type, uint information, out IntPtr owner, out IntPtr group, out IntPtr dacl, out IntPtr sacl, out IntPtr descriptor);
|
|
45
|
+
[DllImport("advapi32.dll")] static extern uint GetSecurityDescriptorLength(IntPtr descriptor);
|
|
46
|
+
[DllImport("kernel32.dll")] static extern IntPtr LocalFree(IntPtr value);
|
|
47
|
+
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
|
|
48
|
+
static extern bool MoveFileExW(string existing, string replacement, uint flags);
|
|
49
|
+
[DllImport("kernel32.dll", SetLastError = true)] static extern bool FlushFileBuffers(SafeFileHandle file);
|
|
50
|
+
[DllImport("kernel32.dll", SetLastError = true)] static extern bool SetFileInformationByHandle(SafeFileHandle file, int type, ref Disposition info, uint size);
|
|
51
|
+
|
|
52
|
+
static void Check(bool success) { if (!success) throw new Win32Exception(Marshal.GetLastWin32Error()); }
|
|
53
|
+
static Exception Unsafe() { return new InvalidOperationException("Unsafe claim filesystem"); }
|
|
54
|
+
static string Canonical(string value) {
|
|
55
|
+
if (String.IsNullOrEmpty(value) || value.IndexOf('\0') >= 0 || value.StartsWith(@"\\")) throw Unsafe();
|
|
56
|
+
string full = Path.GetFullPath(value);
|
|
57
|
+
string root = Path.GetPathRoot(full);
|
|
58
|
+
if (root.Length != 3 || !Char.IsLetter(root[0]) || root[1] != ':' || GetDriveTypeW(root) != 3) throw Unsafe();
|
|
59
|
+
// Reject ADS, Win32 aliases, wildcard paths, and path normalization surprises.
|
|
60
|
+
foreach (string part in full.Substring(root.Length).Split('\\')) {
|
|
61
|
+
if (part.Length == 0) continue;
|
|
62
|
+
if (part.EndsWith(".") || part.EndsWith(" ") || part.IndexOfAny(new char[] { ':', '*', '?', '"', '<', '>', '|' }) >= 0) throw Unsafe();
|
|
63
|
+
string stem = part.Split('.')[0].ToUpperInvariant();
|
|
64
|
+
if (stem == "CON" || stem == "PRN" || stem == "AUX" || stem == "NUL" ||
|
|
65
|
+
(stem.Length == 4 && (stem.StartsWith("COM") || stem.StartsWith("LPT")) && stem[3] >= '0' && stem[3] <= '9')) throw Unsafe();
|
|
66
|
+
}
|
|
67
|
+
return full;
|
|
68
|
+
}
|
|
69
|
+
static FileInformation Info(SafeFileHandle handle, bool directory) {
|
|
70
|
+
FileInformation info;
|
|
71
|
+
Check(GetFileInformationByHandle(handle, out info));
|
|
72
|
+
if ((info.Attributes & ReparsePoint) != 0 || ((info.Attributes & DirectoryAttribute) != 0) != directory || (!directory && info.Links != 1)) throw Unsafe();
|
|
73
|
+
StringBuilder fs = new StringBuilder(32), volume = new StringBuilder(256);
|
|
74
|
+
uint serial, max, flags;
|
|
75
|
+
Check(GetVolumeInformationByHandleW(handle, volume, 256, out serial, out max, out flags, fs, 32));
|
|
76
|
+
if (fs.ToString() != "NTFS" || (flags & 8) == 0) throw Unsafe(); // persistent ACLs
|
|
77
|
+
return info;
|
|
78
|
+
}
|
|
79
|
+
static SafeFileHandle Open(string file, bool directory, uint access, uint share = ShareRead) {
|
|
80
|
+
SafeFileHandle handle = CreateFileW(file, access | ReadAttributes | ReadControl, share, IntPtr.Zero, OpenExisting, OpenReparsePoint | BackupSemantics, IntPtr.Zero);
|
|
81
|
+
if (handle.IsInvalid) { int error = Marshal.GetLastWin32Error(); handle.Dispose(); throw new Win32Exception(error); }
|
|
82
|
+
try { Info(handle, directory); return handle; } catch { handle.Dispose(); throw; }
|
|
83
|
+
}
|
|
84
|
+
static void Private(SafeFileHandle file) {
|
|
85
|
+
IntPtr owner, group, dacl, sacl, descriptor;
|
|
86
|
+
uint code = GetSecurityInfo(file, 1, 5, out owner, out group, out dacl, out sacl, out descriptor);
|
|
87
|
+
if (code != 0) throw new Win32Exception((int)code);
|
|
88
|
+
try {
|
|
89
|
+
byte[] bytes = new byte[GetSecurityDescriptorLength(descriptor)];
|
|
90
|
+
Marshal.Copy(descriptor, bytes, 0, bytes.Length);
|
|
91
|
+
RawSecurityDescriptor security = new RawSecurityDescriptor(bytes, 0);
|
|
92
|
+
if (security.Owner == null || security.Owner.Value != UserSid || security.DiscretionaryAcl == null ||
|
|
93
|
+
(security.ControlFlags & ControlFlags.DiscretionaryAclProtected) == 0 ||
|
|
94
|
+
(security.ControlFlags & ControlFlags.DiscretionaryAclPresent) == 0) throw Unsafe();
|
|
95
|
+
bool user = false;
|
|
96
|
+
foreach (GenericAce item in security.DiscretionaryAcl) {
|
|
97
|
+
CommonAce ace = item as CommonAce;
|
|
98
|
+
if (ace == null || ace.AceQualifier != AceQualifier.AccessAllowed || ace.IsCallback ||
|
|
99
|
+
(ace.AceFlags & (AceFlags.Inherited | AceFlags.InheritOnly)) != 0 ||
|
|
100
|
+
(ace.SecurityIdentifier.Value != UserSid && ace.SecurityIdentifier.Value != "S-1-5-18" && ace.SecurityIdentifier.Value != "S-1-5-32-544")) throw Unsafe();
|
|
101
|
+
if (ace.SecurityIdentifier.Value == UserSid && (ace.AccessMask & 0x1f01ff) == 0x1f01ff) user = true;
|
|
102
|
+
}
|
|
103
|
+
if (!user) throw Unsafe();
|
|
104
|
+
} finally { if (descriptor != IntPtr.Zero) LocalFree(descriptor); }
|
|
105
|
+
}
|
|
106
|
+
sealed class Chain : IDisposable {
|
|
107
|
+
readonly List<SafeFileHandle> handles = new List<SafeFileHandle>();
|
|
108
|
+
public Chain(string file, bool includeLeaf, bool missing) {
|
|
109
|
+
string root = Path.GetPathRoot(file), current = root;
|
|
110
|
+
try {
|
|
111
|
+
handles.Add(Open(root, true, 0, ShareRead | ShareWrite));
|
|
112
|
+
string[] parts = file.Substring(root.Length).Split(new char[] { '\\' }, StringSplitOptions.RemoveEmptyEntries);
|
|
113
|
+
for (int i = 0; i < parts.Length; i++) {
|
|
114
|
+
current = Path.Combine(current, parts[i]);
|
|
115
|
+
if (i == parts.Length - 1 && !includeLeaf) break;
|
|
116
|
+
try {
|
|
117
|
+
// Holding each ancestor without FILE_SHARE_DELETE pins the path
|
|
118
|
+
// throughout native operations instead of merely lstat-ing names.
|
|
119
|
+
bool leaf = i == parts.Length - 1;
|
|
120
|
+
if (leaf) {
|
|
121
|
+
SafeFileHandle h = CreateFileW(current, ReadAttributes | ReadControl, ShareRead | ShareWrite, IntPtr.Zero, OpenExisting, OpenReparsePoint | BackupSemantics, IntPtr.Zero);
|
|
122
|
+
if (h.IsInvalid) { int e = Marshal.GetLastWin32Error(); h.Dispose(); throw new Win32Exception(e); }
|
|
123
|
+
try { FileInformation info; Check(GetFileInformationByHandle(h, out info)); Info(h, (info.Attributes & DirectoryAttribute) != 0); handles.Add(h); } catch { h.Dispose(); throw; }
|
|
124
|
+
} else handles.Add(Open(current, true, 0, ShareRead | ShareWrite));
|
|
125
|
+
} catch (Win32Exception e) { if (missing && (e.NativeErrorCode == 2 || e.NativeErrorCode == 3)) break; throw; }
|
|
126
|
+
}
|
|
127
|
+
} catch { Dispose(); throw; }
|
|
128
|
+
}
|
|
129
|
+
public void Dispose() { for (int i = handles.Count - 1; i >= 0; i--) handles[i].Dispose(); handles.Clear(); }
|
|
130
|
+
}
|
|
131
|
+
static IntPtr Descriptor(bool directory) {
|
|
132
|
+
string inheritance = directory ? "OICI" : "";
|
|
133
|
+
string sddl = "O:" + UserSid + "D:P(A;" + inheritance + ";FA;;;" + UserSid + ")(A;" + inheritance + ";FA;;;SY)(A;" + inheritance + ";FA;;;BA)";
|
|
134
|
+
IntPtr descriptor; uint length;
|
|
135
|
+
Check(ConvertStringSecurityDescriptorToSecurityDescriptorW(sddl, 1, out descriptor, out length));
|
|
136
|
+
return descriptor;
|
|
137
|
+
}
|
|
138
|
+
static SafeFileHandle Create(string file) {
|
|
139
|
+
IntPtr descriptor = Descriptor(false), memory = IntPtr.Zero;
|
|
140
|
+
try {
|
|
141
|
+
SecurityAttributes security = new SecurityAttributes { Length = Marshal.SizeOf<SecurityAttributes>(), Descriptor = descriptor, Inherit = false };
|
|
142
|
+
memory = Marshal.AllocHGlobal(security.Length); Marshal.StructureToPtr(security, memory, false);
|
|
143
|
+
SafeFileHandle handle = CreateFileW(file, GenericRead | GenericWrite | ReadControl | DeleteAccess, 0, memory, CreateNew, OpenReparsePoint | BackupSemantics, IntPtr.Zero);
|
|
144
|
+
if (handle.IsInvalid) { int e = Marshal.GetLastWin32Error(); handle.Dispose(); throw new Win32Exception(e); }
|
|
145
|
+
try { Info(handle, false); Private(handle); return handle; } catch { handle.Dispose(); throw; }
|
|
146
|
+
} finally { if (memory != IntPtr.Zero) Marshal.FreeHGlobal(memory); LocalFree(descriptor); }
|
|
147
|
+
}
|
|
148
|
+
static void Write(string file, byte[] content) {
|
|
149
|
+
using (SafeFileHandle handle = Create(file))
|
|
150
|
+
using (FileStream stream = new FileStream(handle, FileAccess.ReadWrite)) {
|
|
151
|
+
stream.Write(content, 0, content.Length); stream.Flush(true); Check(FlushFileBuffers(handle));
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
static string Read(SafeFileHandle handle) {
|
|
155
|
+
FileInformation info = Info(handle, false); Private(handle);
|
|
156
|
+
if (info.SizeHigh != 0 || info.SizeLow > Limit) throw Unsafe();
|
|
157
|
+
using (FileStream stream = new FileStream(handle, FileAccess.Read)) {
|
|
158
|
+
byte[] data = new byte[Limit + 1]; int count = 0, n;
|
|
159
|
+
while (count < data.Length && (n = stream.Read(data, count, data.Length - count)) != 0) count += n;
|
|
160
|
+
if (count > Limit) throw Unsafe();
|
|
161
|
+
Info(handle, false); return Utf8.GetString(data, 0, count);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
static void Remove(SafeFileHandle handle) {
|
|
165
|
+
Disposition info = new Disposition { Delete = true };
|
|
166
|
+
Check(SetFileInformationByHandle(handle, 4, ref info, (uint)Marshal.SizeOf<Disposition>()));
|
|
167
|
+
}
|
|
168
|
+
static void ParentPrivate(string file) { using (SafeFileHandle parent = Open(Path.GetDirectoryName(file), true, 0, ShareRead | ShareWrite)) Private(parent); }
|
|
169
|
+
static Dictionary<string, object> Success() { return new Dictionary<string, object> { { "ok", true } }; }
|
|
170
|
+
public static Dictionary<string, object> Execute(string command, string file, bool allowMissing, string content, string expected) {
|
|
171
|
+
try {
|
|
172
|
+
file = Canonical(file);
|
|
173
|
+
Dictionary<string, object> result = Success();
|
|
174
|
+
if (command == "safe-path") { using (new Chain(file, true, allowMissing)) { } return result; }
|
|
175
|
+
using (new Chain(file, false, false)) {
|
|
176
|
+
if (command == "ensure-directory") {
|
|
177
|
+
IntPtr descriptor = Descriptor(true);
|
|
178
|
+
try {
|
|
179
|
+
SecurityAttributes security = new SecurityAttributes { Length = Marshal.SizeOf<SecurityAttributes>(), Descriptor = descriptor };
|
|
180
|
+
if (!CreateDirectoryW(file, ref security)) { int e = Marshal.GetLastWin32Error(); if (e != 183) throw new Win32Exception(e); }
|
|
181
|
+
} finally { LocalFree(descriptor); }
|
|
182
|
+
}
|
|
183
|
+
if (command == "directory" || command == "ensure-directory") {
|
|
184
|
+
try { using (SafeFileHandle h = Open(file, true, 0, ShareRead | ShareWrite)) Private(h); result["exists"] = true; }
|
|
185
|
+
catch (Win32Exception e) { if (!allowMissing || (e.NativeErrorCode != 2 && e.NativeErrorCode != 3)) throw; result["exists"] = false; }
|
|
186
|
+
return result;
|
|
187
|
+
}
|
|
188
|
+
ParentPrivate(file);
|
|
189
|
+
if (command == "read") { using (SafeFileHandle h = Open(file, false, GenericRead)) result["content"] = Read(h); return result; }
|
|
190
|
+
if (command == "delete" || command == "release-lock") {
|
|
191
|
+
using (SafeFileHandle h = Open(file, false, GenericRead | DeleteAccess)) {
|
|
192
|
+
Private(h);
|
|
193
|
+
if (command == "release-lock") {
|
|
194
|
+
// Read through a non-owning SafeFileHandle so the delete handle stays live.
|
|
195
|
+
using (SafeFileHandle borrowed = new SafeFileHandle(h.DangerousGetHandle(), false)) {
|
|
196
|
+
if (Read(borrowed) != expected) throw Unsafe();
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
Remove(h);
|
|
200
|
+
}
|
|
201
|
+
return result;
|
|
202
|
+
}
|
|
203
|
+
byte[] bytes = Utf8.GetBytes(content);
|
|
204
|
+
if (bytes.Length > Limit) throw Unsafe();
|
|
205
|
+
if (command == "create") { Write(file, bytes); return result; }
|
|
206
|
+
if (command != "replace") throw Unsafe();
|
|
207
|
+
using (SafeFileHandle h = Open(file, false, GenericRead)) Private(h);
|
|
208
|
+
string temp = file + "." + Guid.NewGuid().ToString("N") + ".tmp";
|
|
209
|
+
bool created = false;
|
|
210
|
+
try {
|
|
211
|
+
Write(temp, bytes); created = true;
|
|
212
|
+
using (SafeFileHandle h = Open(file, false, GenericRead)) Private(h);
|
|
213
|
+
// No COPY_ALLOWED: same-directory local-NTFS replacement only.
|
|
214
|
+
// Both files are flushed first. Failure is never acknowledged.
|
|
215
|
+
Check(MoveFileExW(temp, file, 1 | 8)); created = false;
|
|
216
|
+
using (SafeFileHandle h = Open(file, false, GenericRead)) { if (Read(h) != content) throw Unsafe(); }
|
|
217
|
+
} finally {
|
|
218
|
+
if (created) {
|
|
219
|
+
using (SafeFileHandle h = Open(temp, false, DeleteAccess)) { Private(h); Remove(h); }
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
return result;
|
|
223
|
+
}
|
|
224
|
+
} catch (Win32Exception e) {
|
|
225
|
+
return new Dictionary<string, object> { { "ok", false }, { "code", e.NativeErrorCode == 80 || e.NativeErrorCode == 183 ? "EEXIST" : e.NativeErrorCode == 2 || e.NativeErrorCode == 3 ? "ENOENT" : "EUNSAFE" } };
|
|
226
|
+
} catch { return new Dictionary<string, object> { { "ok", false }, { "code", "EUNSAFE" } }; }
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
#requires -Version 7.0
|
|
2
|
+
$ErrorActionPreference = 'Stop'
|
|
3
|
+
$ProgressPreference = 'SilentlyContinue'
|
|
4
|
+
[Console]::InputEncoding = [System.Text.UTF8Encoding]::new($false)
|
|
5
|
+
[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false)
|
|
6
|
+
try {
|
|
7
|
+
if (-not $IsWindows) { throw 'Windows is required' }
|
|
8
|
+
Add-Type -Path (Join-Path $PSScriptRoot 'testClaimFilesystemWindows.cs')
|
|
9
|
+
while ($null -ne ($line = [Console]::In.ReadLine())) {
|
|
10
|
+
$request = $line | ConvertFrom-Json -AsHashtable
|
|
11
|
+
$result = [Cabloy.TestClaimFilesystem]::Execute(
|
|
12
|
+
[string]$request.command, [string]$request.path, [bool]$request.allowMissing,
|
|
13
|
+
[string]$request.content, [string]$request.expected)
|
|
14
|
+
[Console]::Out.WriteLine(($result | ConvertTo-Json -Compress))
|
|
15
|
+
[Console]::Out.Flush()
|
|
16
|
+
}
|
|
17
|
+
} catch {
|
|
18
|
+
# Never copy request contents, paths, or native error diagnostics into logs.
|
|
19
|
+
[Console]::Out.WriteLine('{"ok":false,"code":"EUNSAFE"}')
|
|
20
|
+
}
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import type { ChildProcess } from 'node:child_process';
|
|
2
|
+
|
|
3
|
+
import { execFile, execFileSync } from 'node:child_process';
|
|
4
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
|
|
8
|
+
export interface WindowsTestClaimFilesystemRequest {
|
|
9
|
+
command:
|
|
10
|
+
| 'safe-path'
|
|
11
|
+
| 'directory'
|
|
12
|
+
| 'ensure-directory'
|
|
13
|
+
| 'read'
|
|
14
|
+
| 'create'
|
|
15
|
+
| 'replace'
|
|
16
|
+
| 'delete'
|
|
17
|
+
| 'release-lock';
|
|
18
|
+
path: string;
|
|
19
|
+
allowMissing?: boolean;
|
|
20
|
+
content?: string;
|
|
21
|
+
expected?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Both source and bundled dist consumers use the checked-in, published src assets.
|
|
25
|
+
export function getWindowsTestClaimFilesystemHelper(
|
|
26
|
+
directory = import.meta.dirname ?? path.dirname(fileURLToPath(import.meta.url)),
|
|
27
|
+
) {
|
|
28
|
+
let current = directory;
|
|
29
|
+
while (true) {
|
|
30
|
+
const manifest = path.join(current, 'package.json');
|
|
31
|
+
if (existsSync(manifest)) {
|
|
32
|
+
const value = JSON.parse(readFileSync(manifest, 'utf8'));
|
|
33
|
+
if (value.name === 'vona-core') {
|
|
34
|
+
const helper = path.join(current, 'src/lib/core/testClaimFilesystemWindows.ps1');
|
|
35
|
+
if (!existsSync(helper) || !existsSync(helper.replace(/\.ps1$/, '.cs')))
|
|
36
|
+
throw new Error('Windows test claim filesystem helper assets are missing');
|
|
37
|
+
return helper;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
const parent = path.dirname(current);
|
|
41
|
+
if (parent === current)
|
|
42
|
+
throw new Error('Cannot locate the vona-core Windows filesystem helper');
|
|
43
|
+
current = parent;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function invocation() {
|
|
48
|
+
if (process.platform !== 'win32') throw new Error('Windows claim filesystem requires Windows');
|
|
49
|
+
return [
|
|
50
|
+
'-NoLogo',
|
|
51
|
+
'-NoProfile',
|
|
52
|
+
'-NonInteractive',
|
|
53
|
+
'-File',
|
|
54
|
+
getWindowsTestClaimFilesystemHelper(),
|
|
55
|
+
];
|
|
56
|
+
}
|
|
57
|
+
function decode(source: string): { exists?: boolean; content?: string } {
|
|
58
|
+
let value;
|
|
59
|
+
try {
|
|
60
|
+
value = JSON.parse(source);
|
|
61
|
+
} catch {
|
|
62
|
+
throw new Error('Windows test claim filesystem helper returned an invalid response');
|
|
63
|
+
}
|
|
64
|
+
if (!value || value.ok !== true) {
|
|
65
|
+
const error = new Error(
|
|
66
|
+
value?.code === 'EEXIST'
|
|
67
|
+
? 'Test claim file already exists'
|
|
68
|
+
: `Unsafe Windows test claim filesystem operation (${value?.code ?? 'unknown'})`,
|
|
69
|
+
);
|
|
70
|
+
Object.assign(error, { code: value?.code ?? 'EUNSAFE' });
|
|
71
|
+
throw error;
|
|
72
|
+
}
|
|
73
|
+
return value;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function windowsTestClaimFilesystemSync(request: WindowsTestClaimFilesystemRequest) {
|
|
77
|
+
try {
|
|
78
|
+
return decode(
|
|
79
|
+
execFileSync('pwsh', invocation(), {
|
|
80
|
+
input: JSON.stringify(request),
|
|
81
|
+
encoding: 'utf8',
|
|
82
|
+
windowsHide: true,
|
|
83
|
+
timeout: 30000,
|
|
84
|
+
maxBuffer: 8 * 1024 * 1024,
|
|
85
|
+
}),
|
|
86
|
+
);
|
|
87
|
+
} catch (error) {
|
|
88
|
+
if ((error as NodeJS.ErrnoException).code === 'ENOENT')
|
|
89
|
+
throw new Error('Native Windows test claims require PowerShell 7 (pwsh) on PATH');
|
|
90
|
+
throw error;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
type Response = { exists?: boolean; content?: string };
|
|
95
|
+
type Pending = {
|
|
96
|
+
resolve: (value: Response) => void;
|
|
97
|
+
reject: (error: Error) => void;
|
|
98
|
+
timer: ReturnType<typeof setTimeout>;
|
|
99
|
+
};
|
|
100
|
+
let bridge:
|
|
101
|
+
| {
|
|
102
|
+
child: ChildProcess;
|
|
103
|
+
pending: Pending[];
|
|
104
|
+
output: string;
|
|
105
|
+
idle?: ReturnType<typeof setTimeout>;
|
|
106
|
+
}
|
|
107
|
+
| undefined;
|
|
108
|
+
|
|
109
|
+
export function windowsTestClaimFilesystem(
|
|
110
|
+
request: WindowsTestClaimFilesystemRequest,
|
|
111
|
+
): Promise<Response> {
|
|
112
|
+
if (!bridge) {
|
|
113
|
+
// Explicit executable + argv, never a shell. A burst shares Add-Type startup;
|
|
114
|
+
// the bridge owns no persistent native handles and exits when idle.
|
|
115
|
+
const child = execFile('pwsh', invocation(), { windowsHide: true, maxBuffer: Infinity });
|
|
116
|
+
// execFile's collection listeners are for its callback API. This bounded
|
|
117
|
+
// line protocol consumes the streams itself, without retaining burst history.
|
|
118
|
+
child.stdout?.removeAllListeners('data');
|
|
119
|
+
child.stderr?.removeAllListeners('data');
|
|
120
|
+
const state = {
|
|
121
|
+
child,
|
|
122
|
+
pending: [] as Pending[],
|
|
123
|
+
output: '',
|
|
124
|
+
idle: undefined as ReturnType<typeof setTimeout> | undefined,
|
|
125
|
+
};
|
|
126
|
+
bridge = state;
|
|
127
|
+
const fail = (error: Error) => {
|
|
128
|
+
if (bridge === state) bridge = undefined;
|
|
129
|
+
clearTimeout(state.idle);
|
|
130
|
+
for (const operation of state.pending.splice(0)) {
|
|
131
|
+
clearTimeout(operation.timer);
|
|
132
|
+
operation.reject(error);
|
|
133
|
+
}
|
|
134
|
+
child.stdin?.destroy();
|
|
135
|
+
child.kill();
|
|
136
|
+
};
|
|
137
|
+
child.once('error', error =>
|
|
138
|
+
fail(
|
|
139
|
+
new Error(
|
|
140
|
+
(error as NodeJS.ErrnoException).code === 'ENOENT'
|
|
141
|
+
? 'Native Windows test claims require PowerShell 7 (pwsh) on PATH'
|
|
142
|
+
: 'Windows test claim filesystem helper failed; operation not acknowledged',
|
|
143
|
+
),
|
|
144
|
+
),
|
|
145
|
+
);
|
|
146
|
+
child.once('exit', () =>
|
|
147
|
+
fail(new Error('Windows test claim filesystem helper exited; operation not acknowledged')),
|
|
148
|
+
);
|
|
149
|
+
child.stdin?.on('error', () =>
|
|
150
|
+
fail(new Error('Windows test claim filesystem control stream failed')),
|
|
151
|
+
);
|
|
152
|
+
child.stdout?.setEncoding('utf8');
|
|
153
|
+
child.stderr?.resume();
|
|
154
|
+
child.stdout?.on('data', (chunk: string) => {
|
|
155
|
+
state.output += chunk;
|
|
156
|
+
if (Buffer.byteLength(state.output) > 8 * 1024 * 1024) {
|
|
157
|
+
fail(new Error('Windows test claim filesystem response exceeded limit'));
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
while (state.output.includes('\n')) {
|
|
161
|
+
const end = state.output.indexOf('\n');
|
|
162
|
+
const line = state.output.slice(0, end);
|
|
163
|
+
state.output = state.output.slice(end + 1);
|
|
164
|
+
const operation = state.pending.shift();
|
|
165
|
+
if (!operation) {
|
|
166
|
+
fail(new Error('Unexpected Windows test claim filesystem response'));
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
clearTimeout(operation.timer);
|
|
170
|
+
try {
|
|
171
|
+
operation.resolve(decode(line));
|
|
172
|
+
} catch (error) {
|
|
173
|
+
operation.reject(error as Error);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
if (!state.pending.length) {
|
|
177
|
+
state.idle = setTimeout(() => {
|
|
178
|
+
if (bridge === state) bridge = undefined;
|
|
179
|
+
child.stdin?.end();
|
|
180
|
+
}, 1000);
|
|
181
|
+
}
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
const state = bridge;
|
|
185
|
+
clearTimeout(state.idle);
|
|
186
|
+
return new Promise((resolve, reject) => {
|
|
187
|
+
const timer = setTimeout(() => {
|
|
188
|
+
if (bridge === state) bridge = undefined;
|
|
189
|
+
for (const operation of state.pending.splice(0)) {
|
|
190
|
+
clearTimeout(operation.timer);
|
|
191
|
+
operation.reject(
|
|
192
|
+
new Error('Windows test claim filesystem operation timed out; not acknowledged'),
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
state.child.kill();
|
|
196
|
+
}, 30000);
|
|
197
|
+
state.pending.push({ resolve, reject, timer });
|
|
198
|
+
state.child.stdin?.write(`${JSON.stringify(request)}\n`);
|
|
199
|
+
});
|
|
200
|
+
}
|