windows-development-sandbox 1.13.2 → 1.15.0
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/config.example.json +2 -1
- package/dist/app/features/defs.d.ts +1 -1
- package/dist/app/features/projects/projects.def.d.ts +1 -1
- package/dist/app/features/projects/projects.run.d.ts +1 -1
- package/dist/app/features/projects/projects.run.js +10 -2
- package/dist/app/features/sandbox/sandbox.def.js +3 -1
- package/dist/app/features/settings/categories.def.js +1 -0
- package/dist/app/features/storage/storage.run.js +1 -1
- package/dist/bin/agent-sandbox.js +2 -2
- package/dist/bin/{chunk-VENWSGX2.js → chunk-D7CM63BS.js} +2303 -1462
- package/dist/bin/{chunk-K4SU3MHJ.js → chunk-HTQT5SBY.js} +1 -1
- package/dist/bin/{chunk-IL7BXJF2.js → chunk-RQ4BWBYX.js} +1 -1
- package/dist/bin/claude-host.js +2 -2
- package/dist/bin/claude-sbx.js +2 -2
- package/dist/bin/codex-sbx.js +2 -2
- package/dist/bin/gemini-sbx.js +2 -2
- package/dist/bin/sandbox.js +2 -2
- package/dist/bin/sbx-hide.js +2 -2
- package/dist/bin/sbx-show.js +2 -2
- package/dist/cli/commands/launch/launch.js +1 -1
- package/dist/cli/commands/projects/here.js +2 -0
- package/dist/cli/commands/repair/checks/contained.js +1 -1
- package/dist/cli/services/health-service.js +15 -3
- package/dist/cli/services/projects-service.d.ts +8 -3
- package/dist/cli/services/projects-service.js +21 -4
- package/dist/core/config/defaults.js +1 -0
- package/dist/core/schema/config.d.ts +4 -0
- package/dist/core/schema/projects.d.ts +4 -0
- package/dist/core/schema/projects.js +3 -0
- package/dist/guest/boot/boot.js +8 -2
- package/dist/guest/daemon/daemon.js +3 -0
- package/dist/guest/daemon/live-folders.d.ts +48 -0
- package/dist/guest/daemon/live-folders.js +245 -0
- package/dist/guest/daemon/live.d.ts +4 -0
- package/dist/guest/daemon/live.js +57 -0
- package/dist/guest/daemon/sessions.d.ts +7 -0
- package/dist/guest/daemon/sessions.js +23 -2
- package/dist/guest/entries/daemon.js +5 -2
- package/dist/guest/runtime/csc.d.ts +1 -0
- package/dist/guest/runtime/csc.js +1 -0
- package/dist/guest/runtime/secrets.d.ts +5 -2
- package/dist/guest/runtime/secrets.js +12 -5
- package/dist/guest/sessions/pty-host.d.ts +10 -0
- package/dist/guest/sessions/pty-host.js +48 -3
- package/dist/host/channel/channel.js +1 -1
- package/dist/host/engines/hcs/live-folders.d.ts +117 -0
- package/dist/host/engines/hcs/live-folders.js +347 -0
- package/dist/host/engines/index.d.ts +1 -0
- package/dist/host/engines/index.js +1 -0
- package/dist/host/install/install-record.d.ts +1 -0
- package/dist/host/install/install-record.js +2 -0
- package/dist/host/launch/generate.js +8 -1
- package/dist/host/projects/join.d.ts +4 -0
- package/dist/host/projects/join.js +11 -3
- package/dist/host/projects/mapper.d.ts +34 -5
- package/dist/host/projects/mapper.js +88 -20
- package/dist/host/projects/registry.d.ts +2 -0
- package/dist/host/projects/registry.js +1 -1
- package/dist/host/sandbox/contained-deps.d.ts +2 -1
- package/dist/host/sandbox/contained-deps.js +6 -4
- package/dist/host/secrets/launch-secrets.js +2 -1
- package/dist/host/sharing/lifecycle.js +9 -1
- package/dist/host/storage/contained/contained-storage.js +13 -0
- package/dist/host/storage/contained/disk-port.d.ts +5 -0
- package/dist/host/storage/contained/helper-port.d.ts +72 -0
- package/dist/host/storage/contained/helper-port.js +162 -0
- package/dist/host/storage/contained/index.d.ts +1 -0
- package/dist/host/storage/contained/index.js +1 -0
- package/dist/host/web/bridge.d.ts +5 -0
- package/dist/host/web/bridge.js +11 -1
- package/dist/host/web/server.d.ts +2 -0
- package/dist/host/web/server.js +11 -1
- package/dist/runtime/agent.js +23 -0
- package/dist/runtime/daemon.js +523 -135
- package/dist/runtime/db.js +23 -0
- package/dist/runtime/main.js +221 -15
- package/dist/runtime/netproxy.js +23 -0
- package/dist/runtime/view.js +209 -12
- package/dist/selftest/channel.test.js +72 -1
- package/dist/selftest/contained.test.js +1 -37
- package/dist/selftest/hcs.test.js +5 -2
- package/dist/selftest/index.js +4 -0
- package/dist/selftest/install.test.js +11 -0
- package/dist/selftest/layout.test.js +1 -0
- package/dist/selftest/live-daemon.test.js +51 -1
- package/dist/selftest/live-folders.test.d.ts +2 -0
- package/dist/selftest/live-folders.test.js +588 -0
- package/dist/selftest/live-terminal.test.js +112 -4
- package/dist/selftest/projects.test.js +7 -7
- package/dist/selftest/storage-helper.test.d.ts +2 -0
- package/dist/selftest/storage-helper.test.js +493 -0
- package/dist/selftest/support/engine-golden.js +1 -0
- package/dist/selftest/support/fixtures.d.ts +1 -0
- package/dist/selftest/support/live.js +2 -0
- package/dist/selftest/support/vhdx.d.ts +14 -0
- package/dist/selftest/support/vhdx.js +72 -0
- package/dist/selftest/web.test.js +14 -2
- package/dist/shared/channel/index.d.ts +1 -0
- package/dist/shared/channel/index.js +1 -0
- package/dist/shared/channel/live.d.ts +53 -0
- package/dist/shared/channel/live.js +92 -0
- package/dist/shared/channel/run-dir.d.ts +11 -0
- package/dist/shared/channel/run-dir.js +20 -0
- package/dist/shared/channel/secrets.d.ts +1 -0
- package/dist/shared/channel/secrets.js +5 -0
- package/dist/shared/paths/projects.d.ts +6 -1
- package/dist/shared/paths/projects.js +21 -1
- package/dist/shared/pty/index.d.ts +3 -1
- package/dist/shared/pty/index.js +3 -1
- package/dist/shared/pty/key-gate.d.ts +24 -0
- package/dist/shared/pty/key-gate.js +45 -0
- package/dist/shared/pty/mouse-modes.d.ts +27 -0
- package/dist/shared/pty/mouse-modes.js +155 -0
- package/dist/shared/pty/terminal.d.ts +0 -5
- package/dist/shared/pty/terminal.js +51 -20
- package/dist/sync/watcher.js +10 -1
- package/dist/web/index.html +1 -1
- package/dist/web/{main-NBQ3LF4E.js → main-7DJZ2M27.js} +34 -34
- package/native/csharp/HcsHost/AssemblyInfo.cs +4 -4
- package/native/csharp/HcsHost/FolderAccess.cs +361 -0
- package/native/csharp/HcsHost/Hcs.cs +31 -3
- package/native/csharp/HcsHost/Program.cs +54 -6
- package/native/csharp/HcsHost/Share.cs +64 -11
- package/native/csharp/HcsHost/WsbGuard.cs +130 -0
- package/native/csharp/HcsHost/WsbShare.cs +295 -0
- package/native/csharp/HostTools/AssemblyInfo.cs +3 -3
- package/native/csharp/StorageHelper/AssemblyInfo.cs +15 -0
- package/native/csharp/StorageHelper/Client.cs +297 -0
- package/native/csharp/StorageHelper/Log.cs +177 -0
- package/native/csharp/StorageHelper/MountRecords.cs +160 -0
- package/native/csharp/StorageHelper/Operations.cs +596 -0
- package/native/csharp/StorageHelper/Pins.cs +270 -0
- package/native/csharp/StorageHelper/Privileges.cs +78 -0
- package/native/csharp/StorageHelper/Program.cs +349 -0
- package/native/csharp/StorageHelper/Protocol.cs +107 -0
- package/native/csharp/StorageHelper/RateLimiter.cs +97 -0
- package/native/csharp/StorageHelper/Rules.cs +156 -0
- package/native/csharp/StorageHelper/Server.cs +656 -0
- package/native/csharp/StorageHelper/Settings.cs +297 -0
- package/native/csharp/StorageHelper/StorageService.cs +53 -0
- package/native/csharp/StorageHelper/UserSettings.cs +206 -0
- package/native/csharp/StorageHelper/Vhdx.cs +230 -0
- package/native/csharp/StorageHelper/VirtualDisk.cs +241 -0
- package/native/csharp/StorageHelper/Volumes.cs +379 -0
- package/native/csharp/StorageHelper/Watchdog.cs +128 -0
- package/native/csharp/StorageHelper/Win32.cs +250 -0
- package/native/scripts/contained-common.ps1 +4 -0
- package/native/scripts/hcs-live-share.ps1 +191 -0
- package/native/scripts/set-storage-helper.ps1 +203 -0
- package/package.json +1 -1
- package/sandbox/csharp/BindMount.cs +120 -0
- package/sandbox/csharp/PtyHost.cs +236 -1
- package/schemas/config.schema.json +8 -0
|
@@ -0,0 +1,493 @@
|
|
|
1
|
+
// The storage helper (native\csharp\StorageHelper, the AgentSandboxStorage service an all-users install can add): it
|
|
2
|
+
// compiles with the csc that ships with Windows; its checks refuse what contained-common.ps1 refuses (run through its
|
|
3
|
+
// `check` dry run against temp folders); it works out the storage folder from settings the way the CLI does; its pipe
|
|
4
|
+
// protocol takes exactly a verb and a set; the CLI's client reads its answers and falls back to the permission prompt;
|
|
5
|
+
// the install script checks its arguments. No test installs the service or attaches a disk: the server runs in its
|
|
6
|
+
// console mode, as this user, on a pipe name of its own, with --dry-run.
|
|
7
|
+
import fs from 'node:fs';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
10
|
+
import { isWin } from '../core/util/index.js';
|
|
11
|
+
import { currentUserSid } from '../host/sharing/index.js';
|
|
12
|
+
import { containedPaths, HELPER_PIPE, HelperDiskPort, helperApplies, HelperRequestSchema, pipeConnector, readAnswer, } from '../host/storage/index.js';
|
|
13
|
+
import { SYSTEM } from '../shared/system.js';
|
|
14
|
+
import { fakeVhdx } from './support/vhdx.js';
|
|
15
|
+
class RecordingPort {
|
|
16
|
+
calls = [];
|
|
17
|
+
mount() {
|
|
18
|
+
this.calls.push('mount');
|
|
19
|
+
return Promise.resolve({ ok: true, message: 'prompt', created: false, alreadyMounted: false, notes: [] });
|
|
20
|
+
}
|
|
21
|
+
dismount() {
|
|
22
|
+
this.calls.push('dismount');
|
|
23
|
+
return Promise.resolve({ ok: true, message: 'prompt', created: false, alreadyMounted: false, notes: [] });
|
|
24
|
+
}
|
|
25
|
+
reality() {
|
|
26
|
+
this.calls.push('reality');
|
|
27
|
+
return Promise.resolve({
|
|
28
|
+
imageExists: true,
|
|
29
|
+
imageBytes: 1,
|
|
30
|
+
mountExists: true,
|
|
31
|
+
mountIsMountPoint: false,
|
|
32
|
+
attached: false,
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
function buildHelper(t) {
|
|
37
|
+
const dir = path.join(t.packageRoot, 'native', 'csharp', 'StorageHelper');
|
|
38
|
+
const sources = fs
|
|
39
|
+
.readdirSync(dir)
|
|
40
|
+
.filter((f) => f.endsWith('.cs'))
|
|
41
|
+
.sort()
|
|
42
|
+
.map((f) => path.join(dir, f));
|
|
43
|
+
const exe = t.at('storage-helper', 'bin', 'AgentSandboxStorage.exe');
|
|
44
|
+
fs.mkdirSync(path.dirname(exe), { recursive: true });
|
|
45
|
+
const r = spawnSync(SYSTEM.csc(), [
|
|
46
|
+
'/nologo',
|
|
47
|
+
'/optimize',
|
|
48
|
+
'/warnaserror',
|
|
49
|
+
'/target:exe',
|
|
50
|
+
'/r:System.Web.Extensions.dll',
|
|
51
|
+
'/r:System.ServiceProcess.dll',
|
|
52
|
+
'/out:' + exe,
|
|
53
|
+
...sources,
|
|
54
|
+
], { encoding: 'utf8', windowsHide: true, timeout: 120000 });
|
|
55
|
+
t.check(r.status === 0 && fs.existsSync(exe), 'AgentSandboxStorage.exe builds without warnings (C# 5)', r.stdout + r.stderr);
|
|
56
|
+
return fs.existsSync(exe) ? exe : null;
|
|
57
|
+
}
|
|
58
|
+
/** The folder and everything in it belong to `sid` (as the CLI would make them), not to Administrators. */
|
|
59
|
+
function own(dir, sid) {
|
|
60
|
+
spawnSync(SYSTEM.icacls(), [dir, '/setowner', '*' + sid, '/T', '/C', '/Q'], { windowsHide: true });
|
|
61
|
+
}
|
|
62
|
+
function units(t) {
|
|
63
|
+
t.section('the request the CLI sends, and what it makes of the answer');
|
|
64
|
+
t.check(HelperRequestSchema.safeParse({ verb: 'mount', set: 'main' }).success, 'a request is a verb and a set');
|
|
65
|
+
t.check(!HelperRequestSchema.safeParse({ verb: 'mount', set: 'main', image: 'C:\\x.vhdx' }).success &&
|
|
66
|
+
!HelperRequestSchema.safeParse({ verb: 'format', set: 'main' }).success &&
|
|
67
|
+
!HelperRequestSchema.safeParse({ verb: 'mount', set: '..\\x' }).success, 'nothing else: no paths, no other verbs, no set name with a path in it');
|
|
68
|
+
t.check(helperApplies({}, false), 'the helper is asked with your usual settings');
|
|
69
|
+
t.check(!helperApplies({}, true) &&
|
|
70
|
+
!helperApplies({ AGENT_SANDBOX_CONFIG: 'x' }, false) &&
|
|
71
|
+
!helperApplies({ AGENT_SANDBOX_HOME: 'x' }, false), 'but not with --config, AGENT_SANDBOX_CONFIG or AGENT_SANDBOX_HOME (it would read other settings)');
|
|
72
|
+
const paths = containedPaths('C:\\Store', 'main');
|
|
73
|
+
const answer = (over) => ({
|
|
74
|
+
kind: 'answer',
|
|
75
|
+
text: JSON.stringify({
|
|
76
|
+
protocol: 1,
|
|
77
|
+
ok: true,
|
|
78
|
+
message: 'The image was mounted.',
|
|
79
|
+
created: false,
|
|
80
|
+
alreadyMounted: false,
|
|
81
|
+
fallback: false,
|
|
82
|
+
image: paths.image,
|
|
83
|
+
mount: paths.mount,
|
|
84
|
+
notes: [],
|
|
85
|
+
...over,
|
|
86
|
+
}),
|
|
87
|
+
});
|
|
88
|
+
const ok = readAnswer(answer({}), paths);
|
|
89
|
+
t.check(ok.use === 'outcome' && ok.outcome.ok, 'an answer for our own paths is the outcome');
|
|
90
|
+
t.check(readAnswer(answer({ image: paths.image.toUpperCase(), mount: paths.mount + '\\' }), paths).use === 'outcome', 'case and a trailing backslash do not matter');
|
|
91
|
+
const elsewhere = readAnswer(answer({ mount: 'C:\\Other\\mounts\\main' }), paths);
|
|
92
|
+
t.check(elsewhere.use === 'outcome' && !elsewhere.outcome.ok && /other folders/.test(elsewhere.outcome.message), 'a mount in other folders than ours is a failure, never taken as done');
|
|
93
|
+
t.equal(readAnswer({ kind: 'absent', reason: 'ENOENT' }, paths).use, 'fallback', 'no helper: the permission prompt');
|
|
94
|
+
t.equal(readAnswer(answer({ protocol: 2 }), paths).use, 'fallback', 'a helper of another version: the prompt');
|
|
95
|
+
t.equal(readAnswer({ kind: 'answer', text: 'not json' }, paths).use, 'fallback', 'garbage: the prompt');
|
|
96
|
+
t.equal(readAnswer(answer({ ok: false, fallback: true }), paths).use, 'fallback', 'the helper says so: the prompt');
|
|
97
|
+
const half = readAnswer({ kind: 'failed', reason: 'timeout' }, paths);
|
|
98
|
+
t.check(half.use === 'outcome' && !half.outcome.ok, 'a helper that stopped half way is a failure (no second try through the prompt)');
|
|
99
|
+
}
|
|
100
|
+
async function ports(t) {
|
|
101
|
+
t.section('the disk port: the helper when it applies, the permission prompt otherwise');
|
|
102
|
+
const paths = containedPaths(t.dir('storage-helper', 'port'), 'main');
|
|
103
|
+
const request = { paths, maxGb: 64, create: false, userSid: 'S-1-5-21-1-2-3-1001' };
|
|
104
|
+
const seen = [];
|
|
105
|
+
const connector = (result) => (_pipe, line) => {
|
|
106
|
+
seen.push(line);
|
|
107
|
+
return Promise.resolve(result);
|
|
108
|
+
};
|
|
109
|
+
const prompt1 = new RecordingPort();
|
|
110
|
+
await new HelperDiskPort(prompt1, false, connector({ kind: 'absent', reason: 'x' }), 'test').mount(request);
|
|
111
|
+
t.check(prompt1.calls.includes('mount') && seen.length === 0, 'not applying: the helper is not even asked');
|
|
112
|
+
const prompt2 = new RecordingPort();
|
|
113
|
+
await new HelperDiskPort(prompt2, true, connector({ kind: 'absent', reason: 'ENOENT' }), 'test').dismount(request);
|
|
114
|
+
t.check(prompt2.calls.includes('dismount'), 'not installed: the permission prompt');
|
|
115
|
+
t.equal(JSON.parse(seen[0]), { verb: 'dismount', set: 'main' }, 'the line sent is the verb and the set');
|
|
116
|
+
const prompt3 = new RecordingPort();
|
|
117
|
+
const done = await new HelperDiskPort(prompt3, true, connector({
|
|
118
|
+
kind: 'answer',
|
|
119
|
+
text: JSON.stringify({
|
|
120
|
+
protocol: 1,
|
|
121
|
+
ok: true,
|
|
122
|
+
message: 'The image was created and mounted.',
|
|
123
|
+
created: true,
|
|
124
|
+
alreadyMounted: false,
|
|
125
|
+
fallback: false,
|
|
126
|
+
image: paths.image,
|
|
127
|
+
mount: paths.mount,
|
|
128
|
+
notes: ['n'],
|
|
129
|
+
}),
|
|
130
|
+
}), 'test').mount(request);
|
|
131
|
+
t.check(done.ok && done.created && prompt3.calls.length === 0, 'an answer from the helper: no prompt');
|
|
132
|
+
await new HelperDiskPort(prompt3, true, connector({ kind: 'absent', reason: 'x' }), 'test').reality(paths);
|
|
133
|
+
t.check(prompt3.calls.includes('reality'), 'looking never needs the helper');
|
|
134
|
+
await t.throws(() => new HelperDiskPort(new RecordingPort(), true, connector({ kind: 'absent', reason: 'x' }), HELPER_PIPE).mount(request), 'does not mount or dismount', 'the self-test never talks to the real helper');
|
|
135
|
+
const unconfirmed = await new HelperDiskPort(new RecordingPort(), true, connector({ kind: 'absent', reason: 'x' }), 'test').confirmMounted(request);
|
|
136
|
+
t.equal(unconfirmed, null, 'a vouched mount without the helper stays vouched (no prompt for a check)');
|
|
137
|
+
const refusedMount = await new HelperDiskPort(new RecordingPort(), true, connector({
|
|
138
|
+
kind: 'answer',
|
|
139
|
+
text: JSON.stringify({
|
|
140
|
+
protocol: 1,
|
|
141
|
+
ok: false,
|
|
142
|
+
message: 'The mount folder is a mount point already.',
|
|
143
|
+
created: false,
|
|
144
|
+
alreadyMounted: false,
|
|
145
|
+
fallback: false,
|
|
146
|
+
image: paths.image,
|
|
147
|
+
mount: paths.mount,
|
|
148
|
+
notes: [],
|
|
149
|
+
}),
|
|
150
|
+
}), 'test').confirmMounted(request);
|
|
151
|
+
t.check(refusedMount !== null && !refusedMount.ok, 'with the helper, a vouched mount is checked: a mount point of another disk is a failure');
|
|
152
|
+
const absent = await pipeConnector('\\\\.\\pipe\\agent-sandbox-test-no-such-pipe-' + process.pid, '{}', 5000);
|
|
153
|
+
t.equal(absent.kind, 'absent', 'a pipe that is not there reads as "no helper"');
|
|
154
|
+
}
|
|
155
|
+
/** The helper's `check` dry run on one data set; the parsed answer. */
|
|
156
|
+
function checkRun(exe, verb, root, set, sid, maxGb) {
|
|
157
|
+
const r = spawnSync(exe, ['check', verb, root, set, sid, String(maxGb)], { encoding: 'utf8', windowsHide: true });
|
|
158
|
+
try {
|
|
159
|
+
return JSON.parse(r.stdout);
|
|
160
|
+
}
|
|
161
|
+
catch {
|
|
162
|
+
return { ok: false, message: 'no answer: ' + r.stdout + r.stderr };
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
function checks(t, exe, sid) {
|
|
166
|
+
t.section('the checks (dry run): what the helper accepts and refuses');
|
|
167
|
+
const root = t.dir('storage-helper', 'store');
|
|
168
|
+
const paths = containedPaths(root, 'main');
|
|
169
|
+
for (const dir of [paths.imagesDir, paths.mountsDir, paths.mount])
|
|
170
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
171
|
+
own(root, sid);
|
|
172
|
+
const run = (over = {}) => checkRun(exe, over.verb ?? 'mount', over.root ?? root, over.set ?? 'main', over.sid ?? sid, over.maxGb ?? 64);
|
|
173
|
+
const refused = (what, a, text) => t.check(!a.ok && a.message.toLowerCase().includes(text.toLowerCase()), 'refused: ' + what, a.message);
|
|
174
|
+
const first = run();
|
|
175
|
+
t.check(first.ok && first.created === true, 'a first mount (no image yet) is accepted: it would create one', first.message);
|
|
176
|
+
t.check(first.image === paths.image && first.mount === paths.mount, 'the paths are <storage>\\images\\<set>.vhdx and <storage>\\mounts\\<set>, built by the helper');
|
|
177
|
+
fakeVhdx(paths.image, { gb: 64 });
|
|
178
|
+
own(paths.image, sid);
|
|
179
|
+
const existing = run();
|
|
180
|
+
t.check(existing.ok && existing.created === false, 'an existing image is accepted (only attached)', existing.message);
|
|
181
|
+
t.check(run({ verb: 'dismount' }).ok, 'a dismount is accepted');
|
|
182
|
+
fakeVhdx(paths.image, { gb: 64, staleLog: true });
|
|
183
|
+
t.check(run().ok, 'a log named only in the older header is fine');
|
|
184
|
+
refused('another user (the folders are not theirs)', run({ sid: 'S-1-5-21-1-2-3-1001' }), 'not yours');
|
|
185
|
+
refused('a data set name that is not allowed', run({ set: 'a b' }), 'not allowed');
|
|
186
|
+
refused('a data set name with a path', run({ set: '..\\x' }), 'not allowed');
|
|
187
|
+
refused('a relative storage folder', run({ root: 'store' }), 'plain full path');
|
|
188
|
+
refused('a storage folder with ..', run({ root: root + '\\..\\store' }), 'plain full path');
|
|
189
|
+
refused('a network storage folder', run({ root: '\\\\server\\share\\store' }), 'plain full path');
|
|
190
|
+
refused('a drive root', run({ root: 'C:\\' }), 'drive root');
|
|
191
|
+
refused('a size limit out of range', run({ maxGb: 100000 }), 'between 8 and 4096');
|
|
192
|
+
fakeVhdx(paths.image, { gb: 64, parent: true });
|
|
193
|
+
refused('a differencing image (it has a parent)', run(), 'differencing');
|
|
194
|
+
fakeVhdx(paths.image, { gb: 128 });
|
|
195
|
+
refused('an image bigger than the limit', run(), 'bigger than the limit');
|
|
196
|
+
fs.writeFileSync(paths.image, 'not a vhdx');
|
|
197
|
+
refused('a file that is not a VHDX', run(), 'not a VHDX');
|
|
198
|
+
fakeVhdx(paths.image, { gb: 64, log: true });
|
|
199
|
+
const log = run();
|
|
200
|
+
t.check(!log.ok && log.fallback === true && /not closed cleanly/.test(log.message), 'an image with a log to replay is left to the permission prompt (fallback)', log.message);
|
|
201
|
+
fakeVhdx(paths.image, { gb: 64 });
|
|
202
|
+
fs.writeFileSync(path.join(paths.mount, 'stray.txt'), 'x');
|
|
203
|
+
refused('a mount folder that is not empty', run(), 'not empty');
|
|
204
|
+
fs.rmSync(path.join(paths.mount, 'stray.txt'));
|
|
205
|
+
const twin = path.join(root, 'twin.vhdx');
|
|
206
|
+
try {
|
|
207
|
+
fs.linkSync(paths.image, twin);
|
|
208
|
+
refused('an image with a second name (hard link)', run(), 'hard links');
|
|
209
|
+
fs.rmSync(twin);
|
|
210
|
+
}
|
|
211
|
+
catch {
|
|
212
|
+
t.skip('image as a hard link (could not make one here)');
|
|
213
|
+
}
|
|
214
|
+
const users = spawnSync(SYSTEM.icacls(), [paths.imagesDir, '/grant', '*S-1-5-32-545:(M)', '/Q'], {
|
|
215
|
+
windowsHide: true,
|
|
216
|
+
});
|
|
217
|
+
if (users.status === 0) {
|
|
218
|
+
refused('a folder others may change (Users: modify)', run(), 'someone else may change');
|
|
219
|
+
spawnSync(SYSTEM.icacls(), [paths.imagesDir, '/remove:g', '*S-1-5-32-545', '/Q'], { windowsHide: true });
|
|
220
|
+
t.check(run().ok, 'and accepted again once only the user may change it');
|
|
221
|
+
}
|
|
222
|
+
else
|
|
223
|
+
t.skip('a folder others may change (icacls failed)');
|
|
224
|
+
const linkTarget = t.dir('storage-helper', 'linktarget');
|
|
225
|
+
const junctionRoot = t.dir('storage-helper', 'junction');
|
|
226
|
+
const junction = containedPaths(junctionRoot, 'main');
|
|
227
|
+
fs.mkdirSync(junction.mount, { recursive: true });
|
|
228
|
+
const mk = spawnSync(SYSTEM.cmd(), ['/c', 'mklink', '/J', junction.imagesDir, linkTarget], { windowsHide: true });
|
|
229
|
+
if (mk.status === 0) {
|
|
230
|
+
own(junctionRoot, sid);
|
|
231
|
+
fakeVhdx(path.join(linkTarget, 'main.vhdx'), { gb: 8 });
|
|
232
|
+
refused('an images folder that is a link', run({ root: junctionRoot }), 'link');
|
|
233
|
+
fs.rmdirSync(junction.imagesDir);
|
|
234
|
+
}
|
|
235
|
+
else
|
|
236
|
+
t.skip('images folder as a junction');
|
|
237
|
+
const above = t.dir('storage-helper', 'above');
|
|
238
|
+
const realParent = t.dir('storage-helper', 'above-real');
|
|
239
|
+
const inner = containedPaths(path.join(realParent, 'store'), 'main');
|
|
240
|
+
for (const dir of [inner.imagesDir, inner.mount])
|
|
241
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
242
|
+
own(path.join(realParent, 'store'), sid);
|
|
243
|
+
fs.rmdirSync(above);
|
|
244
|
+
const mk2 = spawnSync(SYSTEM.cmd(), ['/c', 'mklink', '/J', above, realParent], { windowsHide: true });
|
|
245
|
+
if (mk2.status === 0) {
|
|
246
|
+
refused('a link above the storage folder', run({ root: path.join(above, 'store') }), 'link');
|
|
247
|
+
fs.rmdirSync(above);
|
|
248
|
+
}
|
|
249
|
+
else
|
|
250
|
+
t.skip('a junction above the storage folder');
|
|
251
|
+
const mountRoot = t.dir('storage-helper', 'mountlink');
|
|
252
|
+
const mountLinked = containedPaths(mountRoot, 'main');
|
|
253
|
+
fs.mkdirSync(mountLinked.imagesDir, { recursive: true });
|
|
254
|
+
fs.mkdirSync(mountLinked.mountsDir, { recursive: true });
|
|
255
|
+
const mk3 = spawnSync(SYSTEM.cmd(), ['/c', 'mklink', '/J', mountLinked.mount, linkTarget], { windowsHide: true });
|
|
256
|
+
if (mk3.status === 0) {
|
|
257
|
+
own(mountRoot, sid);
|
|
258
|
+
refused('a mount folder that is a link to a folder (not a volume)', run({ root: mountRoot }), 'not a volume');
|
|
259
|
+
refused('a dismount through such a link', run({ root: mountRoot, verb: 'dismount' }), 'missing');
|
|
260
|
+
fs.rmdirSync(mountLinked.mount);
|
|
261
|
+
}
|
|
262
|
+
else
|
|
263
|
+
t.skip('mount folder as a junction');
|
|
264
|
+
const missing = t.dir('storage-helper', 'missing');
|
|
265
|
+
own(missing, sid);
|
|
266
|
+
refused('a storage folder without images and mounts', run({ root: missing }), 'missing');
|
|
267
|
+
}
|
|
268
|
+
function derive(t, exe) {
|
|
269
|
+
t.section('the storage folder, worked out from the settings like the CLI does');
|
|
270
|
+
const env = { USERNAME: 'ann', ProgramData: 'C:\\ProgramData', USERPROFILE: 'C:\\Users\\ann' };
|
|
271
|
+
let n = 0;
|
|
272
|
+
const run = (input) => {
|
|
273
|
+
const file = t.writeJson(t.at('storage-helper', 'derive', 'in' + n++ + '.json'), {
|
|
274
|
+
base: 'C:\\Users\\ann\\.agent-sandbox',
|
|
275
|
+
profile: 'C:\\Users\\ann',
|
|
276
|
+
env,
|
|
277
|
+
...input,
|
|
278
|
+
});
|
|
279
|
+
const r = spawnSync(exe, ['derive', file], { encoding: 'utf8', windowsHide: true });
|
|
280
|
+
try {
|
|
281
|
+
return JSON.parse(r.stdout);
|
|
282
|
+
}
|
|
283
|
+
catch {
|
|
284
|
+
return { ok: false, message: r.stdout + r.stderr };
|
|
285
|
+
}
|
|
286
|
+
};
|
|
287
|
+
t.equal(run({}), { ok: true, root: 'C:\\ProgramData\\AgentSandbox\\users\\ann', maxGb: 64 }, 'the defaults');
|
|
288
|
+
t.equal(run({ machine: '{ "storage": { "root": "D:\\\\Sandbox\\\\%USERNAME%" } }' }).root, 'D:\\Sandbox\\ann', 'the machine file, with %USERNAME% from the user');
|
|
289
|
+
t.equal(run({
|
|
290
|
+
machine: '{ "storage": { "root": "D:\\\\Sandbox" } }',
|
|
291
|
+
user: '// mine\n{ "storage": { "root": "E:\\\\Mine", /* why */ "containedMaxGb": 32, }, }',
|
|
292
|
+
}), { ok: true, root: 'E:\\Mine', maxGb: 32 }, 'the user file wins, with comments and trailing commas');
|
|
293
|
+
t.equal(run({ user: '{ "storage": { "root": "store" } }' }).root, 'C:\\Users\\ann\\.agent-sandbox\\store', 'a relative folder is relative to the file');
|
|
294
|
+
t.equal(run({ user: '{ "storage": { "root": "~\\\\data" } }' }).root, 'C:\\Users\\ann\\data', '~ is the profile');
|
|
295
|
+
t.equal(run({ user: '{ "storage": { "root": "D:/a/../b" } }' }).root, 'D:\\b', 'slashes and .. are worked out');
|
|
296
|
+
t.equal(run({ machine: '{ "storage": { "containedMaxGb": 100 } }', user: '{ "storage": { "containedMaxGb": 500 } }' })
|
|
297
|
+
.maxGb, 100, 'a size limit set for the PC is a ceiling the user file cannot raise');
|
|
298
|
+
t.equal(run({ machine: '{ "storage": { "containedMaxGb": 100 } }', user: '{ "storage": { "containedMaxGb": 20 } }' }).maxGb, 20, 'but may lower');
|
|
299
|
+
const secret = run({ user: 'SECRET-CONTENT {' });
|
|
300
|
+
t.check(!secret.ok && !(secret.message ?? '').includes('SECRET'), 'a file that is not JSON is refused without repeating it');
|
|
301
|
+
t.check(!run({ machine: '{ "storage": { "root": "relative" } }' }).ok, 'the machine file must give a full path');
|
|
302
|
+
}
|
|
303
|
+
function protocol(t, exe) {
|
|
304
|
+
t.section('the pipe protocol: exactly a verb and a set');
|
|
305
|
+
const parse = (line) => {
|
|
306
|
+
const r = spawnSync(exe, ['parse'], { input: line + '\n', encoding: 'utf8', windowsHide: true });
|
|
307
|
+
try {
|
|
308
|
+
return JSON.parse(r.stdout);
|
|
309
|
+
}
|
|
310
|
+
catch {
|
|
311
|
+
return { ok: false };
|
|
312
|
+
}
|
|
313
|
+
};
|
|
314
|
+
t.equal(parse('{"verb":"mount","set":"main"}'), { ok: true, verb: 'mount', set: 'main' }, 'mount main');
|
|
315
|
+
t.check(parse('{"verb":"dismount","set":"work.2"}').ok, 'dismount work.2');
|
|
316
|
+
for (const [line, what] of [
|
|
317
|
+
['{"verb":"mount","set":"main","image":"C:\\\\Windows\\\\x.vhdx"}', 'an extra field (a path)'],
|
|
318
|
+
['{"verb":"mount"}', 'no set'],
|
|
319
|
+
['{"verb":"format","set":"main"}', 'another verb'],
|
|
320
|
+
['{"verb":"mount","set":"..\\\\x"}', 'a set with a path'],
|
|
321
|
+
['{"verb":"mount","set":["main"]}', 'a set that is not a string'],
|
|
322
|
+
['["mount","main"]', 'not an object'],
|
|
323
|
+
['not json', 'not JSON'],
|
|
324
|
+
['{"verb":"mount","set":"' + 'a'.repeat(600) + '"}', 'longer than 512 bytes'],
|
|
325
|
+
])
|
|
326
|
+
t.check(!parse(line).ok, 'refused: ' + what);
|
|
327
|
+
}
|
|
328
|
+
async function startConsole(exe, args) {
|
|
329
|
+
const child = spawn(exe, ['console', ...args], { stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true });
|
|
330
|
+
let out = '';
|
|
331
|
+
let err = '';
|
|
332
|
+
child.stdout?.on('data', (d) => (out += d.toString()));
|
|
333
|
+
child.stderr?.on('data', (d) => (err += d.toString()));
|
|
334
|
+
const ready = await new Promise((resolve) => {
|
|
335
|
+
const timer = setInterval(() => {
|
|
336
|
+
if (out.includes('ready'))
|
|
337
|
+
done(true);
|
|
338
|
+
}, 50);
|
|
339
|
+
const done = (value) => {
|
|
340
|
+
clearInterval(timer);
|
|
341
|
+
resolve(value);
|
|
342
|
+
};
|
|
343
|
+
child.on('exit', () => done(false));
|
|
344
|
+
setTimeout(() => done(out.includes('ready')), 20000);
|
|
345
|
+
});
|
|
346
|
+
return { child, ready, stderr: () => err };
|
|
347
|
+
}
|
|
348
|
+
async function server(t, exe, sid) {
|
|
349
|
+
t.section('the server in its console mode (this user, a pipe of its own, dry run)');
|
|
350
|
+
const root = t.dir('storage-helper', 'served');
|
|
351
|
+
const paths = containedPaths(root, 'main');
|
|
352
|
+
for (const dir of [paths.imagesDir, paths.mount])
|
|
353
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
354
|
+
own(root, sid);
|
|
355
|
+
const profile = t.dir('storage-helper', 'profile');
|
|
356
|
+
t.writeJson(path.join(profile, '.agent-sandbox', 'config.json'), { storage: { root, containedMaxGb: 16 } });
|
|
357
|
+
const logs = t.dir('storage-helper', 'logs');
|
|
358
|
+
const name = 'agent-sandbox-test-' + process.pid;
|
|
359
|
+
const pipe = '\\\\.\\pipe\\' + name;
|
|
360
|
+
const common = ['--group', sid, '--log-folder', logs, '--machine-config', 'none', '--profile', profile, '--dry-run'];
|
|
361
|
+
const first = await startConsole(exe, ['--pipe', name, ...common]);
|
|
362
|
+
t.check(first.ready, 'it starts', first.stderr());
|
|
363
|
+
if (!first.ready)
|
|
364
|
+
return;
|
|
365
|
+
try {
|
|
366
|
+
const port = new HelperDiskPort(new RecordingPort(), true, pipeConnector, pipe);
|
|
367
|
+
const mounted = await port.mount({ paths, maxGb: 64, create: true, userSid: sid });
|
|
368
|
+
t.check(mounted.ok && mounted.created && /would be created/.test(mounted.message), 'a mount through the pipe: the client is you, the folders come from your settings', JSON.stringify(mounted));
|
|
369
|
+
const otherPaths = containedPaths(t.dir('storage-helper', 'elsewhere'), 'main');
|
|
370
|
+
const mismatch = await port.mount({ paths: otherPaths, maxGb: 64, create: true, userSid: sid });
|
|
371
|
+
t.check(!mismatch.ok && /other folders/.test(mismatch.message), 'a CLI with other folders gets a failure, not a mount');
|
|
372
|
+
const raw = async (line) => {
|
|
373
|
+
const r = await pipeConnector(pipe, line, 10000);
|
|
374
|
+
return r.kind === 'answer' ? r.text : r.kind + ': ' + r.reason;
|
|
375
|
+
};
|
|
376
|
+
t.includes(await raw('{"verb":"mount","set":"main","image":"C:\\\\Windows\\\\x.vhdx"}'), 'exactly a verb and a set', 'a request with a path in it is refused over the pipe');
|
|
377
|
+
t.includes(await raw('{"verb":"mount","set":"other"}'), 'missing', 'another set is checked like any other');
|
|
378
|
+
const second = await startConsole(exe, ['--pipe', name, ...common]);
|
|
379
|
+
t.check(!second.ready && /exists already/.test(second.stderr()), 'a second server on the same pipe name does not start (someone else made the pipe first)', second.stderr());
|
|
380
|
+
const holder = path.join(t.packageRoot, 'src', 'selftest', 'fixtures', 'storage-helper', 'hold-mutex.ps1');
|
|
381
|
+
if (fs.existsSync(holder)) {
|
|
382
|
+
const lockName = 'Local\\agent-sandbox-test-lock-' + process.pid;
|
|
383
|
+
const hold = spawn(SYSTEM.powershell(), ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File', holder, '-Name', lockName], { stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true });
|
|
384
|
+
let held = '';
|
|
385
|
+
hold.stdout.on('data', (d) => (held += d.toString()));
|
|
386
|
+
await t.waitFor(() => held.includes('held'), 20000);
|
|
387
|
+
const squatted = await startConsole(exe, ['--pipe', name + '-lock', '--mutex', lockName, ...common]);
|
|
388
|
+
t.check(!squatted.ready && /not by an administrator/.test(squatted.stderr()), 'a lock that a normal user made first stops the service from starting (it could be held to block mounts)', squatted.stderr());
|
|
389
|
+
hold.stdin.end();
|
|
390
|
+
}
|
|
391
|
+
else
|
|
392
|
+
t.skip('a lock made first by a normal user (the fixture is only in the repository)');
|
|
393
|
+
let limited = 0;
|
|
394
|
+
for (let i = 0; i < 12; i++)
|
|
395
|
+
if (/Too many calls/.test(await raw('{"verb":"dismount","set":"main"}')))
|
|
396
|
+
limited++;
|
|
397
|
+
t.check(limited >= 3, 'more than 10 calls a minute from one user are refused');
|
|
398
|
+
const log = fs.readFileSync(path.join(logs, 'storage-helper.log'), 'utf8');
|
|
399
|
+
t.check(log.includes('sid=' + sid) &&
|
|
400
|
+
log.includes('verb=mount') &&
|
|
401
|
+
log.includes('set="main"') &&
|
|
402
|
+
log.includes('result="ok:'), 'every call is logged: who, what, the result');
|
|
403
|
+
t.equal((log.match(/too many calls/gi) ?? []).length, 1, 'a refused call is logged once per minute, not each time');
|
|
404
|
+
}
|
|
405
|
+
finally {
|
|
406
|
+
first.child.stdin?.end();
|
|
407
|
+
await new Promise((resolve) => {
|
|
408
|
+
first.child.on('exit', resolve);
|
|
409
|
+
setTimeout(resolve, 10000);
|
|
410
|
+
});
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
function installScript(t) {
|
|
414
|
+
t.section('set-storage-helper.ps1: encoding, syntax, what it would do');
|
|
415
|
+
const script = path.join(t.packageRoot, 'native', 'scripts', 'set-storage-helper.ps1');
|
|
416
|
+
const text = fs.readFileSync(script, 'utf8');
|
|
417
|
+
t.check(!/(?<!\r)\n/.test(text) && text.startsWith('#Requires -Version 5.1'), 'CRLF and #Requires -Version 5.1');
|
|
418
|
+
const parse = spawnSync(SYSTEM.powershell(), [
|
|
419
|
+
'-NoProfile',
|
|
420
|
+
'-NonInteractive',
|
|
421
|
+
'-ExecutionPolicy',
|
|
422
|
+
'Bypass',
|
|
423
|
+
'-File',
|
|
424
|
+
path.join(t.packageRoot, 'native', 'scripts', 'check-syntax.ps1'),
|
|
425
|
+
'-Path',
|
|
426
|
+
script,
|
|
427
|
+
], { encoding: 'utf8', windowsHide: true });
|
|
428
|
+
t.check(parse.status === 0, 'it parses in PowerShell 5.1', parse.stderr + parse.stdout);
|
|
429
|
+
const installRoot = t.dir('storage-helper', 'install-root');
|
|
430
|
+
const sources = t.dir('storage-helper', 'install-root', 'versions', '1.0.0', 'package', 'native', 'csharp', 'StorageHelper');
|
|
431
|
+
t.write(path.join(sources, 'A.cs'), '// placeholder');
|
|
432
|
+
const dry = (args) => {
|
|
433
|
+
const r = spawnSync(SYSTEM.powershell(), [
|
|
434
|
+
'-NoProfile',
|
|
435
|
+
'-NonInteractive',
|
|
436
|
+
'-ExecutionPolicy',
|
|
437
|
+
'Bypass',
|
|
438
|
+
'-File',
|
|
439
|
+
script,
|
|
440
|
+
...args,
|
|
441
|
+
'-DryRun',
|
|
442
|
+
'-ServiceName',
|
|
443
|
+
'AgentSandboxStorageDryRun',
|
|
444
|
+
'-MachineData',
|
|
445
|
+
t.dir('storage-helper', 'machine'),
|
|
446
|
+
], { encoding: 'utf8', windowsHide: true, timeout: 60000 });
|
|
447
|
+
return { status: r.status, out: (r.stdout ?? '') + (r.stderr ?? '') };
|
|
448
|
+
};
|
|
449
|
+
const group = 'S-1-5-21-111-222-333-1009';
|
|
450
|
+
const ok = dry(['-Install', '-GroupSid', group, '-InstallRoot', installRoot, '-Sources', sources]);
|
|
451
|
+
t.check(ok.status === 0, 'a dry run of the install is accepted', ok.out);
|
|
452
|
+
for (const step of [
|
|
453
|
+
'build AgentSandboxStorage.exe',
|
|
454
|
+
'admin-only folder',
|
|
455
|
+
'create the service',
|
|
456
|
+
'LocalSystem, automatic start',
|
|
457
|
+
'service --group ' + group,
|
|
458
|
+
'SeManageVolumePrivilege',
|
|
459
|
+
'start the service',
|
|
460
|
+
])
|
|
461
|
+
t.includes(ok.out, step, 'it would: ' + step);
|
|
462
|
+
t.excludes(ok.out, 'SeDebugPrivilege', 'and no privilege it does not need');
|
|
463
|
+
const refused = (what, args, text) => {
|
|
464
|
+
const r = dry(args);
|
|
465
|
+
t.check(r.status === 1 && r.out.includes(text), 'refused: ' + what, r.out);
|
|
466
|
+
};
|
|
467
|
+
refused('a group that is not a local group SID', ['-Install', '-GroupSid', 'S-1-1-0', '-InstallRoot', installRoot, '-Sources', sources], 'GroupSid');
|
|
468
|
+
refused('sources outside the install folder', ['-Install', '-GroupSid', group, '-InstallRoot', installRoot, '-Sources', t.dir('storage-helper', 'elsewhere-src')], 'Sources');
|
|
469
|
+
const removal = dry(['-Remove', '-InstallRoot', installRoot]);
|
|
470
|
+
t.check(removal.status === 0 && removal.out.includes('remove ' + path.join(installRoot, 'helper')), 'a dry run of the removal', removal.out);
|
|
471
|
+
}
|
|
472
|
+
export const storageHelperSuite = {
|
|
473
|
+
name: 'storage-helper',
|
|
474
|
+
title: 'the storage helper service: builds, checks like the scripts, settings, pipe protocol, client fallback',
|
|
475
|
+
timeoutMs: 300000,
|
|
476
|
+
run: async (t) => {
|
|
477
|
+
units(t);
|
|
478
|
+
await ports(t);
|
|
479
|
+
if (!isWin || !fs.existsSync(SYSTEM.csc())) {
|
|
480
|
+
t.skip('the helper itself (needs Windows and its csc)');
|
|
481
|
+
return;
|
|
482
|
+
}
|
|
483
|
+
const exe = buildHelper(t);
|
|
484
|
+
if (!exe)
|
|
485
|
+
return;
|
|
486
|
+
const sid = currentUserSid();
|
|
487
|
+
checks(t, exe, sid);
|
|
488
|
+
derive(t, exe);
|
|
489
|
+
protocol(t, exe);
|
|
490
|
+
await server(t, exe, sid);
|
|
491
|
+
installScript(t);
|
|
492
|
+
},
|
|
493
|
+
};
|
|
@@ -46,6 +46,7 @@ export const LAUNCH_FILES_GOLDEN = [
|
|
|
46
46
|
'sandbox/boot/bootstrap/status.ps1',
|
|
47
47
|
'sandbox/boot/logon.cmd',
|
|
48
48
|
'sandbox/boot/trust.cmd',
|
|
49
|
+
'sandbox/csharp/BindMount.cs',
|
|
49
50
|
'sandbox/csharp/CargoShim.cs',
|
|
50
51
|
'sandbox/csharp/Desktop.cs',
|
|
51
52
|
'sandbox/csharp/EnvBroadcast.cs',
|
|
@@ -347,6 +347,7 @@ export declare function createFixtures(t: Harness): {
|
|
|
347
347
|
declinedRoots: string[];
|
|
348
348
|
allowOutsideMappings: boolean | "ask";
|
|
349
349
|
wsbShare: boolean;
|
|
350
|
+
liveFolders: "hcs" | "off";
|
|
350
351
|
};
|
|
351
352
|
resume: {
|
|
352
353
|
sessions: "never" | "ask" | "always" | "afterCrash";
|
|
@@ -98,6 +98,8 @@ export function startDaemon(t, f, options) {
|
|
|
98
98
|
AGENT_DATA_DIR: data,
|
|
99
99
|
AGENT_WORKSPACE: options.workspace,
|
|
100
100
|
AGENT_LOCAL_DIR: f.at(path.basename(data) + '-local'),
|
|
101
|
+
// The daemon's live-folder state: a temp folder, never C:\ProgramData of the machine running the test.
|
|
102
|
+
AGENT_DAEMON_STATE_DIR: f.at(path.basename(data) + '-daemon-state'),
|
|
101
103
|
AGENT_LOG_PREFIX: 'daemon',
|
|
102
104
|
AGENT_NO_TABS: '1',
|
|
103
105
|
...options.env,
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/** Mixed-endian GUID bytes, as VHDX (and .NET's Guid) store them. */
|
|
2
|
+
export declare function guidBytes(text: string): Buffer;
|
|
3
|
+
/** CRC-32C (Castagnoli), the checksum of a VHDX header. */
|
|
4
|
+
export declare function crc32c(data: Buffer): number;
|
|
5
|
+
/**
|
|
6
|
+
* `gb`: the virtual size; `parent`: a parent locator item (a differencing disk); `log`: the current header names a log
|
|
7
|
+
* to replay; `staleLog`: only the older header names one (fine).
|
|
8
|
+
*/
|
|
9
|
+
export declare function fakeVhdx(file: string, o: {
|
|
10
|
+
gb: number;
|
|
11
|
+
parent?: boolean;
|
|
12
|
+
log?: boolean;
|
|
13
|
+
staleLog?: boolean;
|
|
14
|
+
}): void;
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// A file with the parts of a VHDX the contained-storage checks read (the signature, the two headers with their CRC-32C,
|
|
2
|
+
// the region table and the metadata table), shared by the script tests and the storage helper tests. Nothing here is a
|
|
3
|
+
// usable disk.
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
/** Mixed-endian GUID bytes, as VHDX (and .NET's Guid) store them. */
|
|
6
|
+
export function guidBytes(text) {
|
|
7
|
+
const hex = text.replace(/-/g, '');
|
|
8
|
+
const raw = Buffer.from(hex, 'hex');
|
|
9
|
+
return Buffer.concat([
|
|
10
|
+
Buffer.from(raw.subarray(0, 4)).reverse(),
|
|
11
|
+
Buffer.from(raw.subarray(4, 6)).reverse(),
|
|
12
|
+
Buffer.from(raw.subarray(6, 8)).reverse(),
|
|
13
|
+
raw.subarray(8, 16),
|
|
14
|
+
]);
|
|
15
|
+
}
|
|
16
|
+
const CRC_TABLE = (() => {
|
|
17
|
+
const table = new Uint32Array(256);
|
|
18
|
+
for (let i = 0; i < 256; i++) {
|
|
19
|
+
let c = i;
|
|
20
|
+
for (let k = 0; k < 8; k++)
|
|
21
|
+
c = c & 1 ? 0x82f63b78 ^ (c >>> 1) : c >>> 1;
|
|
22
|
+
table[i] = c >>> 0;
|
|
23
|
+
}
|
|
24
|
+
return table;
|
|
25
|
+
})();
|
|
26
|
+
/** CRC-32C (Castagnoli), the checksum of a VHDX header. */
|
|
27
|
+
export function crc32c(data) {
|
|
28
|
+
let crc = 0xffffffff;
|
|
29
|
+
for (const byte of data)
|
|
30
|
+
crc = CRC_TABLE[(crc ^ byte) & 0xff] ^ (crc >>> 8);
|
|
31
|
+
return (crc ^ 0xffffffff) >>> 0;
|
|
32
|
+
}
|
|
33
|
+
function header(sequence, log) {
|
|
34
|
+
const h = Buffer.alloc(4096);
|
|
35
|
+
h.write('head', 0, 'ascii');
|
|
36
|
+
h.writeBigUInt64LE(BigInt(sequence), 8);
|
|
37
|
+
if (log)
|
|
38
|
+
guidBytes('11111111-2222-3333-4444-555555555555').copy(h, 48);
|
|
39
|
+
h.writeUInt32LE(crc32c(h), 4);
|
|
40
|
+
return h;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* `gb`: the virtual size; `parent`: a parent locator item (a differencing disk); `log`: the current header names a log
|
|
44
|
+
* to replay; `staleLog`: only the older header names one (fine).
|
|
45
|
+
*/
|
|
46
|
+
export function fakeVhdx(file, o) {
|
|
47
|
+
const buf = Buffer.alloc(2 * 1024 * 1024);
|
|
48
|
+
buf.write('vhdxfile', 0, 'ascii');
|
|
49
|
+
header(2, o.log === true).copy(buf, 65536);
|
|
50
|
+
header(1, o.staleLog === true).copy(buf, 131072);
|
|
51
|
+
const table = 196608;
|
|
52
|
+
buf.write('regi', table, 'ascii');
|
|
53
|
+
buf.writeUInt32LE(1, table + 8);
|
|
54
|
+
guidBytes('8b7ca206-4790-4b9a-b8fe-575f050f886e').copy(buf, table + 16);
|
|
55
|
+
buf.writeBigUInt64LE(BigInt(1048576), table + 32);
|
|
56
|
+
buf.writeUInt32LE(65536, table + 40);
|
|
57
|
+
const md = 1048576;
|
|
58
|
+
buf.write('metadata', md, 'ascii');
|
|
59
|
+
const items = [
|
|
60
|
+
{ guid: '2fa54224-cd1b-4876-b211-5dbed83bf4b8', offset: 65536 },
|
|
61
|
+
...(o.parent ? [{ guid: 'a8d35f2d-b30b-454d-abf7-d3d84834ab0c', offset: 65600 }] : []),
|
|
62
|
+
];
|
|
63
|
+
buf.writeUInt16LE(items.length, md + 10);
|
|
64
|
+
items.forEach((item, i) => {
|
|
65
|
+
const at = md + 32 + 32 * i;
|
|
66
|
+
guidBytes(item.guid).copy(buf, at);
|
|
67
|
+
buf.writeUInt32LE(item.offset, at + 16);
|
|
68
|
+
buf.writeUInt32LE(8, at + 20);
|
|
69
|
+
});
|
|
70
|
+
buf.writeBigUInt64LE(BigInt(o.gb) * 1024n ** 3n, md + 65536);
|
|
71
|
+
fs.writeFileSync(file, buf);
|
|
72
|
+
}
|