pinokiod 8.0.8 → 8.0.9
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/kernel/bin/conda.js +17 -17
- package/kernel/path_removal.js +133 -0
- package/kernel/windows_restart_manager.js +153 -0
- package/package.json +1 -1
- package/server/index.js +39 -4
- package/server/public/common.js +190 -0
- package/server/public/style.css +65 -0
- package/server/views/app.ejs +2 -4
- package/server/views/download.ejs +5 -5
- package/server/views/editor.ejs +5 -5
- package/server/views/env_editor.ejs +4 -2
- package/server/views/file_explorer.ejs +2 -4
- package/server/views/index.ejs +2 -4
- package/server/views/install.ejs +25 -0
- package/server/views/net.ejs +2 -4
- package/server/views/settings.ejs +10 -10
- package/server/views/setup.ejs +5 -5
- package/server/views/terminal.ejs +5 -5
- package/test/conda-bin.test.js +11 -11
- package/test/fixtures/hold-windows-dll.js +21 -0
- package/test/path-removal-ui.test.js +316 -0
- package/test/path-removal.test.js +189 -0
- package/test/windows-path-removal.integration.test.js +78 -0
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
const assert = require("node:assert/strict")
|
|
2
|
+
const fs = require("node:fs/promises")
|
|
3
|
+
const path = require("node:path")
|
|
4
|
+
const test = require("node:test")
|
|
5
|
+
const { JSDOM, VirtualConsole } = require("jsdom")
|
|
6
|
+
|
|
7
|
+
const repoRoot = path.resolve(__dirname, "..")
|
|
8
|
+
const commonScriptPath = path.join(repoRoot, "server/public/common.js")
|
|
9
|
+
|
|
10
|
+
async function waitFor(predicate, message = "condition") {
|
|
11
|
+
const deadline = Date.now() + 1000
|
|
12
|
+
while (Date.now() < deadline) {
|
|
13
|
+
if (predicate()) return
|
|
14
|
+
await new Promise((resolve) => setTimeout(resolve, 5))
|
|
15
|
+
}
|
|
16
|
+
assert.fail(`Timed out waiting for ${message}`)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function response(payload, { ok = false, status = 423 } = {}) {
|
|
20
|
+
return {
|
|
21
|
+
ok,
|
|
22
|
+
status,
|
|
23
|
+
json: async () => payload,
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function createHarness() {
|
|
28
|
+
const source = await fs.readFile(commonScriptPath, "utf8")
|
|
29
|
+
|
|
30
|
+
const state = {
|
|
31
|
+
buttonsDisabled: false,
|
|
32
|
+
clearedIntervals: new Set(),
|
|
33
|
+
closeCount: 0,
|
|
34
|
+
fireOptions: null,
|
|
35
|
+
htmlContainer: null,
|
|
36
|
+
intervals: new Map(),
|
|
37
|
+
nextIntervalId: 1,
|
|
38
|
+
resolveFire: null,
|
|
39
|
+
validationMessage: "",
|
|
40
|
+
visible: false,
|
|
41
|
+
}
|
|
42
|
+
const virtualConsole = new VirtualConsole()
|
|
43
|
+
virtualConsole.on("jsdomError", (error) => {
|
|
44
|
+
throw error
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
const dom = new JSDOM("<!doctype html><body></body>", {
|
|
48
|
+
runScripts: "dangerously",
|
|
49
|
+
url: "http://localhost/",
|
|
50
|
+
virtualConsole,
|
|
51
|
+
beforeParse(window) {
|
|
52
|
+
window.fetch = async () => response({}, { ok: true, status: 200 })
|
|
53
|
+
window.matchMedia = () => ({
|
|
54
|
+
matches: false,
|
|
55
|
+
addEventListener() {},
|
|
56
|
+
removeEventListener() {},
|
|
57
|
+
})
|
|
58
|
+
window.ResizeObserver = class {
|
|
59
|
+
observe() {}
|
|
60
|
+
disconnect() {}
|
|
61
|
+
}
|
|
62
|
+
window.setInterval = (callback, delay) => {
|
|
63
|
+
const id = state.nextIntervalId++
|
|
64
|
+
state.intervals.set(id, { callback, delay })
|
|
65
|
+
return id
|
|
66
|
+
}
|
|
67
|
+
window.clearInterval = (id) => {
|
|
68
|
+
state.clearedIntervals.add(id)
|
|
69
|
+
state.intervals.delete(id)
|
|
70
|
+
}
|
|
71
|
+
window.Swal = {
|
|
72
|
+
disableButtons() {
|
|
73
|
+
state.buttonsDisabled = true
|
|
74
|
+
},
|
|
75
|
+
enableButtons() {
|
|
76
|
+
state.buttonsDisabled = false
|
|
77
|
+
},
|
|
78
|
+
close() {
|
|
79
|
+
if (!state.visible) return
|
|
80
|
+
state.closeCount += 1
|
|
81
|
+
state.visible = false
|
|
82
|
+
if (state.fireOptions && typeof state.fireOptions.willClose === "function") {
|
|
83
|
+
state.fireOptions.willClose()
|
|
84
|
+
}
|
|
85
|
+
if (state.resolveFire) state.resolveFire({ isConfirmed: false })
|
|
86
|
+
},
|
|
87
|
+
fire(options) {
|
|
88
|
+
state.fireOptions = options
|
|
89
|
+
state.htmlContainer = window.document.createElement("div")
|
|
90
|
+
state.htmlContainer.innerHTML = options.html || ""
|
|
91
|
+
window.document.body.appendChild(state.htmlContainer)
|
|
92
|
+
state.visible = true
|
|
93
|
+
if (typeof options.didOpen === "function") options.didOpen()
|
|
94
|
+
return new Promise((resolve) => {
|
|
95
|
+
state.resolveFire = resolve
|
|
96
|
+
})
|
|
97
|
+
},
|
|
98
|
+
getHtmlContainer() {
|
|
99
|
+
return state.htmlContainer
|
|
100
|
+
},
|
|
101
|
+
isVisible() {
|
|
102
|
+
return state.visible
|
|
103
|
+
},
|
|
104
|
+
showValidationMessage(message) {
|
|
105
|
+
state.validationMessage = message
|
|
106
|
+
},
|
|
107
|
+
}
|
|
108
|
+
},
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
const script = dom.window.document.createElement("script")
|
|
112
|
+
script.textContent = source
|
|
113
|
+
dom.window.document.body.appendChild(script)
|
|
114
|
+
|
|
115
|
+
state.dismiss = () => {
|
|
116
|
+
if (!state.visible || state.buttonsDisabled) return false
|
|
117
|
+
state.visible = false
|
|
118
|
+
if (state.fireOptions && typeof state.fireOptions.willClose === "function") {
|
|
119
|
+
state.fireOptions.willClose()
|
|
120
|
+
}
|
|
121
|
+
state.resolveFire({ isConfirmed: false })
|
|
122
|
+
return true
|
|
123
|
+
}
|
|
124
|
+
state.getRetryIntervalId = () => Array.from(state.intervals.entries())
|
|
125
|
+
.filter(([, interval]) => interval.delay === 2000)
|
|
126
|
+
.map(([id]) => id)
|
|
127
|
+
.pop()
|
|
128
|
+
state.runInterval = async () => {
|
|
129
|
+
const interval = state.intervals.get(state.getRetryIntervalId())
|
|
130
|
+
assert.ok(interval, "expected an active retry interval")
|
|
131
|
+
return await interval.callback()
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return { dom, state, api: dom.window.PinokioPathRemoval }
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
test("path-removal modal renders identified blockers and escapes backend text", async (t) => {
|
|
138
|
+
const { api, dom, state } = await createHarness()
|
|
139
|
+
t.after(() => dom.window.close())
|
|
140
|
+
|
|
141
|
+
const shown = api.show({
|
|
142
|
+
details: {
|
|
143
|
+
operation: "Delete <app>",
|
|
144
|
+
blockers: [{
|
|
145
|
+
name: "G <HUB>",
|
|
146
|
+
executable: "C:\\Program Files\\G & HUB\\lghub.exe",
|
|
147
|
+
pid: 4321,
|
|
148
|
+
}],
|
|
149
|
+
remainingCount: 25,
|
|
150
|
+
remaining: Array.from({ length: 20 }, (_, index) => `C:\\runtime\\file-${index}.dll`),
|
|
151
|
+
},
|
|
152
|
+
retry: async () => ({ ok: true }),
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
assert.equal(state.fireOptions.title, "Delete <app>")
|
|
156
|
+
assert.match(state.htmlContainer.innerHTML, /G <HUB>/)
|
|
157
|
+
assert.doesNotMatch(state.htmlContainer.innerHTML, /G <HUB>/)
|
|
158
|
+
assert.match(state.htmlContainer.textContent, /C:\\Program Files\\G & HUB\\lghub\.exe · PID 4321/)
|
|
159
|
+
assert.match(state.htmlContainer.textContent, /Show first 20 of 25 remaining items/)
|
|
160
|
+
assert.match(state.htmlContainer.textContent, /Close the app above/)
|
|
161
|
+
assert.equal(state.htmlContainer.querySelector(".pinokio-path-blocker-icon"), null)
|
|
162
|
+
|
|
163
|
+
const intervalId = state.getRetryIntervalId()
|
|
164
|
+
state.dismiss()
|
|
165
|
+
const result = await shown
|
|
166
|
+
assert.equal(result.cancelled, true)
|
|
167
|
+
assert.equal(state.intervals.has(intervalId), false)
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
test("path-removal modal reports unattributed EACCES without naming an application", async (t) => {
|
|
171
|
+
const { api, dom, state } = await createHarness()
|
|
172
|
+
t.after(() => dom.window.close())
|
|
173
|
+
|
|
174
|
+
const shown = api.show({
|
|
175
|
+
details: {
|
|
176
|
+
operation: "Delete app",
|
|
177
|
+
causeCode: "EACCES",
|
|
178
|
+
blockers: [],
|
|
179
|
+
remainingCount: 1,
|
|
180
|
+
remaining: ["C:\\runtime\\protected.dll"],
|
|
181
|
+
},
|
|
182
|
+
retry: async () => ({ ok: true }),
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
assert.match(state.htmlContainer.textContent, /Windows couldn't remove one remaining item \(EACCES\)/)
|
|
186
|
+
assert.match(state.htmlContainer.textContent, /Check permissions or security software/)
|
|
187
|
+
assert.match(state.htmlContainer.textContent, /Resolve the access issue/)
|
|
188
|
+
assert.doesNotMatch(state.htmlContainer.textContent, /Close the app above/)
|
|
189
|
+
|
|
190
|
+
state.dismiss()
|
|
191
|
+
assert.equal((await shown).cancelled, true)
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
test("path-removal request retries automatically without overlapping and resolves on success", async (t) => {
|
|
195
|
+
const { api, dom, state } = await createHarness()
|
|
196
|
+
t.after(() => dom.window.close())
|
|
197
|
+
|
|
198
|
+
let requestCount = 0
|
|
199
|
+
let resolveRetry
|
|
200
|
+
const retryResponse = new Promise((resolve) => {
|
|
201
|
+
resolveRetry = resolve
|
|
202
|
+
})
|
|
203
|
+
const resultPromise = api.request(async () => {
|
|
204
|
+
requestCount += 1
|
|
205
|
+
if (requestCount === 1) {
|
|
206
|
+
return response({
|
|
207
|
+
success: false,
|
|
208
|
+
error: {
|
|
209
|
+
code: "PINOKIO_PATH_REMOVE_BLOCKED",
|
|
210
|
+
operation: "Replace Miniforge",
|
|
211
|
+
blockers: [{ name: "First blocker", pid: 10 }],
|
|
212
|
+
remainingCount: 1,
|
|
213
|
+
remaining: ["C:\\runtime\\first.dll"],
|
|
214
|
+
},
|
|
215
|
+
})
|
|
216
|
+
}
|
|
217
|
+
if (requestCount === 2) return await retryResponse
|
|
218
|
+
return response({ ok: true }, { ok: true, status: 200 })
|
|
219
|
+
})
|
|
220
|
+
|
|
221
|
+
await waitFor(() => state.fireOptions, "path-removal modal")
|
|
222
|
+
const intervalId = state.getRetryIntervalId()
|
|
223
|
+
assert.equal(state.intervals.get(intervalId).delay, 2000)
|
|
224
|
+
|
|
225
|
+
const firstAttempt = state.runInterval()
|
|
226
|
+
assert.equal(state.buttonsDisabled, true)
|
|
227
|
+
assert.equal(state.dismiss(), false, "Stop must be unavailable during an active removal")
|
|
228
|
+
assert.equal(state.visible, true)
|
|
229
|
+
await state.runInterval()
|
|
230
|
+
assert.equal(requestCount, 2, "a pending retry must suppress overlapping retries")
|
|
231
|
+
|
|
232
|
+
resolveRetry(response({
|
|
233
|
+
success: false,
|
|
234
|
+
error: {
|
|
235
|
+
code: "PINOKIO_PATH_REMOVE_BLOCKED",
|
|
236
|
+
operation: "Replace Miniforge",
|
|
237
|
+
blockers: [{ name: "Second blocker", pid: 20 }],
|
|
238
|
+
remainingCount: 1,
|
|
239
|
+
remaining: ["C:\\runtime\\second.dll"],
|
|
240
|
+
},
|
|
241
|
+
}))
|
|
242
|
+
await firstAttempt
|
|
243
|
+
assert.equal(state.buttonsDisabled, false)
|
|
244
|
+
assert.match(state.htmlContainer.textContent, /Second blocker/)
|
|
245
|
+
assert.doesNotMatch(state.htmlContainer.textContent, /First blocker/)
|
|
246
|
+
|
|
247
|
+
await state.runInterval()
|
|
248
|
+
const result = await resultPromise
|
|
249
|
+
assert.equal(result.ok, true)
|
|
250
|
+
assert.equal(requestCount, 3)
|
|
251
|
+
assert.equal(state.closeCount, 1)
|
|
252
|
+
assert.equal(state.intervals.has(intervalId), false)
|
|
253
|
+
assert.ok(state.clearedIntervals.has(intervalId))
|
|
254
|
+
})
|
|
255
|
+
|
|
256
|
+
test("manual retry keeps the modal open when blocked and Stop cancels future retries", async (t) => {
|
|
257
|
+
const { api, dom, state } = await createHarness()
|
|
258
|
+
t.after(() => dom.window.close())
|
|
259
|
+
|
|
260
|
+
let retryCount = 0
|
|
261
|
+
const shown = api.show({
|
|
262
|
+
details: {
|
|
263
|
+
operation: "Delete app",
|
|
264
|
+
blockers: [{ name: "Lock holder", pid: 99 }],
|
|
265
|
+
remainingCount: 1,
|
|
266
|
+
remaining: ["C:\\app\\locked.dll"],
|
|
267
|
+
},
|
|
268
|
+
retry: async () => {
|
|
269
|
+
retryCount += 1
|
|
270
|
+
return {
|
|
271
|
+
code: "PINOKIO_PATH_REMOVE_BLOCKED",
|
|
272
|
+
operation: "Delete app",
|
|
273
|
+
blockers: [{ name: "Lock holder", pid: 99 }],
|
|
274
|
+
remainingCount: 1,
|
|
275
|
+
remaining: ["C:\\app\\locked.dll"],
|
|
276
|
+
}
|
|
277
|
+
},
|
|
278
|
+
})
|
|
279
|
+
|
|
280
|
+
assert.equal(await state.fireOptions.preConfirm(), false)
|
|
281
|
+
assert.equal(retryCount, 1)
|
|
282
|
+
assert.equal(state.visible, true)
|
|
283
|
+
assert.equal(state.validationMessage, "Windows still cannot remove the remaining items.")
|
|
284
|
+
assert.equal(state.buttonsDisabled, false)
|
|
285
|
+
|
|
286
|
+
const intervalId = state.getRetryIntervalId()
|
|
287
|
+
state.dismiss()
|
|
288
|
+
const result = await shown
|
|
289
|
+
assert.equal(result.cancelled, true)
|
|
290
|
+
assert.equal(state.intervals.has(intervalId), false)
|
|
291
|
+
assert.ok(state.clearedIntervals.has(intervalId))
|
|
292
|
+
})
|
|
293
|
+
|
|
294
|
+
test("manual retry escapes unexpected error messages", async (t) => {
|
|
295
|
+
const { api, dom, state } = await createHarness()
|
|
296
|
+
t.after(() => dom.window.close())
|
|
297
|
+
|
|
298
|
+
const shown = api.show({
|
|
299
|
+
details: {
|
|
300
|
+
operation: "Delete app",
|
|
301
|
+
blockers: [],
|
|
302
|
+
remainingCount: 1,
|
|
303
|
+
remaining: ["C:\\app\\locked.dll"],
|
|
304
|
+
},
|
|
305
|
+
retry: async () => {
|
|
306
|
+
throw new Error('<img src=x onerror="window.compromised=true">')
|
|
307
|
+
},
|
|
308
|
+
})
|
|
309
|
+
|
|
310
|
+
assert.equal(await state.fireOptions.preConfirm(), false)
|
|
311
|
+
assert.equal(state.validationMessage, "<img src=x onerror="window.compromised=true">")
|
|
312
|
+
assert.equal(state.buttonsDisabled, false)
|
|
313
|
+
|
|
314
|
+
state.dismiss()
|
|
315
|
+
assert.equal((await shown).cancelled, true)
|
|
316
|
+
})
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
const test = require("node:test")
|
|
2
|
+
const assert = require("node:assert/strict")
|
|
3
|
+
const fs = require("fs")
|
|
4
|
+
const os = require("os")
|
|
5
|
+
const path = require("path")
|
|
6
|
+
|
|
7
|
+
const {
|
|
8
|
+
PATH_REMOVAL_BLOCKED_CODE,
|
|
9
|
+
collectRemaining,
|
|
10
|
+
createPathRemover,
|
|
11
|
+
} = require("../kernel/path_removal")
|
|
12
|
+
|
|
13
|
+
test("removePath preserves ordinary fs.rm behavior", async () => {
|
|
14
|
+
const calls = []
|
|
15
|
+
const removePath = createPathRemover({
|
|
16
|
+
platform: "darwin",
|
|
17
|
+
fsPromises: {
|
|
18
|
+
rm: async (target, options) => calls.push({ target, options }),
|
|
19
|
+
},
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
const target = path.resolve(os.tmpdir(), "pinokio-remove-success")
|
|
23
|
+
const result = await removePath(target, {
|
|
24
|
+
recursive: true,
|
|
25
|
+
force: true,
|
|
26
|
+
operation: "Deleting test data",
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
assert.deepEqual(result, { removed: true, target })
|
|
30
|
+
assert.deepEqual(calls, [{ target, options: { recursive: true, force: true } }])
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
test("removePath rethrows lock-shaped errors outside Windows", async () => {
|
|
34
|
+
const original = Object.assign(new Error("busy"), { code: "EPERM" })
|
|
35
|
+
const removePath = createPathRemover({
|
|
36
|
+
platform: "darwin",
|
|
37
|
+
fsPromises: {
|
|
38
|
+
rm: async () => { throw original },
|
|
39
|
+
},
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
await assert.rejects(
|
|
43
|
+
removePath("/tmp/pinokio-remove-busy", { recursive: true }),
|
|
44
|
+
(error) => error === original,
|
|
45
|
+
)
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
test("removePath reports Windows blockers after partial recursive deletion and succeeds on retry", async () => {
|
|
49
|
+
const root = await fs.promises.mkdtemp(path.join(os.tmpdir(), "pinokio-remove-blocked-"))
|
|
50
|
+
const lockedDir = path.join(root, "pkgs", "git", "Library", "bin")
|
|
51
|
+
const lockedFile = path.join(lockedDir, "zlib1.dll")
|
|
52
|
+
const removableFile = path.join(root, "already-removed.txt")
|
|
53
|
+
await fs.promises.mkdir(lockedDir, { recursive: true })
|
|
54
|
+
await fs.promises.writeFile(lockedFile, "locked")
|
|
55
|
+
await fs.promises.writeFile(removableFile, "remove me")
|
|
56
|
+
|
|
57
|
+
let locked = true
|
|
58
|
+
const inspected = []
|
|
59
|
+
const fsPromises = {
|
|
60
|
+
lstat: fs.promises.lstat.bind(fs.promises),
|
|
61
|
+
readdir: fs.promises.readdir.bind(fs.promises),
|
|
62
|
+
rm: async (target, options) => {
|
|
63
|
+
if (!locked) {
|
|
64
|
+
return fs.promises.rm(target, options)
|
|
65
|
+
}
|
|
66
|
+
await fs.promises.rm(removableFile, { force: true })
|
|
67
|
+
throw Object.assign(new Error("operation not permitted"), { code: "EPERM" })
|
|
68
|
+
},
|
|
69
|
+
}
|
|
70
|
+
const removePath = createPathRemover({
|
|
71
|
+
platform: "win32",
|
|
72
|
+
fsPromises,
|
|
73
|
+
inspectLocks: async (files) => {
|
|
74
|
+
inspected.push(files)
|
|
75
|
+
return {
|
|
76
|
+
blockers: [{ pid: 8544, name: "Logitech G HUB" }],
|
|
77
|
+
}
|
|
78
|
+
},
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
await assert.rejects(
|
|
82
|
+
removePath(root, {
|
|
83
|
+
recursive: true,
|
|
84
|
+
force: true,
|
|
85
|
+
operation: "Updating Miniforge",
|
|
86
|
+
}),
|
|
87
|
+
(error) => {
|
|
88
|
+
assert.equal(error.code, PATH_REMOVAL_BLOCKED_CODE)
|
|
89
|
+
assert.equal(error.operation, "Updating Miniforge")
|
|
90
|
+
assert.equal(error.causeCode, "EPERM")
|
|
91
|
+
assert.deepEqual(error.remaining, [lockedFile])
|
|
92
|
+
assert.equal(error.remainingCount, 1)
|
|
93
|
+
assert.equal(error.blockers[0].pid, 8544)
|
|
94
|
+
assert.equal(error.toJSON().code, PATH_REMOVAL_BLOCKED_CODE)
|
|
95
|
+
return true
|
|
96
|
+
},
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
assert.equal(await fs.promises.access(removableFile).then(() => true).catch(() => false), false)
|
|
100
|
+
assert.deepEqual(inspected, [[lockedFile]])
|
|
101
|
+
|
|
102
|
+
locked = false
|
|
103
|
+
const result = await removePath(root, { recursive: true, force: true })
|
|
104
|
+
assert.equal(result.removed, true)
|
|
105
|
+
assert.equal(await fs.promises.access(root).then(() => true).catch(() => false), false)
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
test("removePath reports and logs an unattributed Windows removal failure without claiming a file lock", async (t) => {
|
|
109
|
+
const root = await fs.promises.mkdtemp(path.join(os.tmpdir(), "pinokio-remove-denied-"))
|
|
110
|
+
t.after(() => fs.promises.rm(root, { recursive: true, force: true }))
|
|
111
|
+
const remainingFile = path.join(root, "denied.txt")
|
|
112
|
+
await fs.promises.writeFile(remainingFile, "denied")
|
|
113
|
+
|
|
114
|
+
const removePath = createPathRemover({
|
|
115
|
+
platform: "win32",
|
|
116
|
+
fsPromises: {
|
|
117
|
+
lstat: fs.promises.lstat.bind(fs.promises),
|
|
118
|
+
readdir: fs.promises.readdir.bind(fs.promises),
|
|
119
|
+
rm: async () => { throw Object.assign(new Error("access denied"), { code: "EACCES" }) },
|
|
120
|
+
},
|
|
121
|
+
inspectLocks: async () => { throw new Error("diagnostic failed") },
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
const originalWarn = console.warn
|
|
125
|
+
let warning
|
|
126
|
+
console.warn = (...args) => { warning = args }
|
|
127
|
+
try {
|
|
128
|
+
await assert.rejects(
|
|
129
|
+
removePath(root, { recursive: true, force: true }),
|
|
130
|
+
(error) => {
|
|
131
|
+
assert.equal(error.code, PATH_REMOVAL_BLOCKED_CODE)
|
|
132
|
+
assert.equal(error.causeCode, "EACCES")
|
|
133
|
+
assert.deepEqual(error.blockers, [])
|
|
134
|
+
assert.doesNotMatch(error.message, /still in use/)
|
|
135
|
+
return true
|
|
136
|
+
},
|
|
137
|
+
)
|
|
138
|
+
} finally {
|
|
139
|
+
console.warn = originalWarn
|
|
140
|
+
}
|
|
141
|
+
assert.equal(warning[0], "[path removal] Restart Manager inspection failed")
|
|
142
|
+
assert.match(warning[1].message, /diagnostic failed/)
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
test("removePath inspects every remaining file but limits the serialized preview", async (t) => {
|
|
146
|
+
const root = await fs.promises.mkdtemp(path.join(os.tmpdir(), "pinokio-remove-preview-"))
|
|
147
|
+
t.after(() => fs.promises.rm(root, { recursive: true, force: true }))
|
|
148
|
+
const files = Array.from({ length: 25 }, (_, index) => path.join(root, `${index}.txt`))
|
|
149
|
+
await Promise.all(files.map((file) => fs.promises.writeFile(file, "remaining")))
|
|
150
|
+
|
|
151
|
+
let inspected = []
|
|
152
|
+
const removePath = createPathRemover({
|
|
153
|
+
platform: "win32",
|
|
154
|
+
fsPromises: {
|
|
155
|
+
lstat: fs.promises.lstat.bind(fs.promises),
|
|
156
|
+
readdir: fs.promises.readdir.bind(fs.promises),
|
|
157
|
+
rm: async () => { throw Object.assign(new Error("busy"), { code: "EBUSY" }) },
|
|
158
|
+
},
|
|
159
|
+
inspectLocks: async (remaining) => {
|
|
160
|
+
inspected = remaining
|
|
161
|
+
return { blockers: [] }
|
|
162
|
+
},
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
await assert.rejects(removePath(root, { recursive: true, force: true }), (error) => {
|
|
166
|
+
assert.equal(error.remainingCount, 25)
|
|
167
|
+
assert.equal(error.remaining.length, 20)
|
|
168
|
+
assert.equal(error.toJSON().remaining.length, 20)
|
|
169
|
+
return true
|
|
170
|
+
})
|
|
171
|
+
assert.equal(inspected.length, 25)
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
test("remaining-file discovery does not traverse symbolic links", async (t) => {
|
|
175
|
+
const root = await fs.promises.mkdtemp(path.join(os.tmpdir(), "pinokio-remove-links-"))
|
|
176
|
+
const outside = await fs.promises.mkdtemp(path.join(os.tmpdir(), "pinokio-remove-outside-"))
|
|
177
|
+
const outsideFile = path.join(outside, "outside.txt")
|
|
178
|
+
const link = path.join(root, "linked")
|
|
179
|
+
await fs.promises.writeFile(outsideFile, "outside")
|
|
180
|
+
await fs.promises.symlink(outside, link, process.platform === "win32" ? "junction" : "dir")
|
|
181
|
+
t.after(async () => {
|
|
182
|
+
await fs.promises.rm(root, { recursive: true, force: true })
|
|
183
|
+
await fs.promises.rm(outside, { recursive: true, force: true })
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
const remaining = await collectRemaining(root)
|
|
187
|
+
assert.equal(remaining.files.includes(link), true)
|
|
188
|
+
assert.equal(remaining.paths.includes(outsideFile), false)
|
|
189
|
+
})
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
const test = require("node:test")
|
|
2
|
+
const assert = require("node:assert/strict")
|
|
3
|
+
const fs = require("fs")
|
|
4
|
+
const os = require("os")
|
|
5
|
+
const path = require("path")
|
|
6
|
+
const { spawn } = require("child_process")
|
|
7
|
+
|
|
8
|
+
const { PATH_REMOVAL_BLOCKED_CODE, removePath } = require("../kernel/path_removal")
|
|
9
|
+
|
|
10
|
+
const waitForExit = (child) => {
|
|
11
|
+
if (child.exitCode !== null || child.signalCode !== null) {
|
|
12
|
+
return Promise.resolve()
|
|
13
|
+
}
|
|
14
|
+
return new Promise((resolve) => child.once("exit", resolve))
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const waitForReady = (child) => new Promise((resolve, reject) => {
|
|
18
|
+
let output = ""
|
|
19
|
+
const timer = setTimeout(() => reject(new Error("DLL holder did not become ready")), 10000)
|
|
20
|
+
child.stdout.on("data", (chunk) => {
|
|
21
|
+
output += chunk.toString()
|
|
22
|
+
if (output.includes("READY")) {
|
|
23
|
+
clearTimeout(timer)
|
|
24
|
+
resolve()
|
|
25
|
+
}
|
|
26
|
+
})
|
|
27
|
+
child.once("exit", (code) => {
|
|
28
|
+
clearTimeout(timer)
|
|
29
|
+
reject(new Error(`DLL holder exited before ready (${code})`))
|
|
30
|
+
})
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
test("Windows integration: loaded DLL is diagnosed and removable after release", {
|
|
34
|
+
skip: process.platform !== "win32",
|
|
35
|
+
timeout: 30000,
|
|
36
|
+
}, async (t) => {
|
|
37
|
+
const root = await fs.promises.mkdtemp(path.join(os.tmpdir(), "pinokio-windows-lock-"))
|
|
38
|
+
const dllPath = path.join(root, "version.dll")
|
|
39
|
+
const ordinaryFile = path.join(root, "ordinary.txt")
|
|
40
|
+
await fs.promises.copyFile(path.join(process.env.SystemRoot, "System32", "version.dll"), dllPath)
|
|
41
|
+
await fs.promises.writeFile(ordinaryFile, "ordinary")
|
|
42
|
+
|
|
43
|
+
const child = spawn(process.execPath, [path.join(__dirname, "fixtures", "hold-windows-dll.js"), dllPath], {
|
|
44
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
45
|
+
})
|
|
46
|
+
t.after(async () => {
|
|
47
|
+
if (child.exitCode === null && child.signalCode === null) {
|
|
48
|
+
const exited = waitForExit(child)
|
|
49
|
+
child.kill()
|
|
50
|
+
await exited
|
|
51
|
+
}
|
|
52
|
+
await fs.promises.rm(root, { recursive: true, force: true }).catch(() => {})
|
|
53
|
+
})
|
|
54
|
+
await waitForReady(child)
|
|
55
|
+
|
|
56
|
+
await assert.rejects(
|
|
57
|
+
removePath(root, {
|
|
58
|
+
recursive: true,
|
|
59
|
+
force: true,
|
|
60
|
+
maxRetries: 1,
|
|
61
|
+
retryDelay: 50,
|
|
62
|
+
operation: "Windows integration test",
|
|
63
|
+
}),
|
|
64
|
+
(error) => {
|
|
65
|
+
assert.equal(error.code, PATH_REMOVAL_BLOCKED_CODE)
|
|
66
|
+
assert.equal(error.remaining.includes(dllPath), true)
|
|
67
|
+
assert.equal(error.blockers.some((blocker) => blocker.pid === child.pid), true)
|
|
68
|
+
return true
|
|
69
|
+
},
|
|
70
|
+
)
|
|
71
|
+
assert.equal(await fs.promises.access(ordinaryFile).then(() => true).catch(() => false), false)
|
|
72
|
+
|
|
73
|
+
const exited = new Promise((resolve) => child.once("exit", resolve))
|
|
74
|
+
child.stdin.write("release\n")
|
|
75
|
+
await exited
|
|
76
|
+
await removePath(root, { recursive: true, force: true })
|
|
77
|
+
assert.equal(await fs.promises.access(root).then(() => true).catch(() => false), false)
|
|
78
|
+
})
|