pinokiod 8.2.23 → 8.2.24
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/cert_env.js +18 -4
- package/kernel/environment.js +2 -2
- package/kernel/shell.js +0 -3
- package/package.json +1 -1
- package/test/cert-env.test.js +56 -0
- package/test/cert-repair-log.test.js +97 -0
package/kernel/cert_env.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
const fs = require('fs')
|
|
2
2
|
const path = require('path')
|
|
3
|
-
const { randomUUID } = require('crypto')
|
|
3
|
+
const { randomUUID, createHash } = require('crypto')
|
|
4
4
|
|
|
5
5
|
// The managed CA bundle: the conda-forge ca-certificates package inside Miniforge.
|
|
6
6
|
// Every app's Python HTTPS traffic is pointed at this one file so a revoked root is
|
|
@@ -83,25 +83,39 @@ async function readManagedBundle(file) {
|
|
|
83
83
|
async function repairPipVendoredBundle(prefix, bundleFile, platform = process.platform) {
|
|
84
84
|
const managed = await readManagedBundle(bundleFile).catch(() => null)
|
|
85
85
|
if (!managed) return []
|
|
86
|
+
const sha256 = data => createHash('sha256').update(data).digest('hex')
|
|
86
87
|
const repaired = []
|
|
87
88
|
for (const target of await pipVendoredBundles(prefix, platform)) {
|
|
88
89
|
const stat = await fs.promises.stat(target).catch(() => null)
|
|
89
90
|
if (stat && !stat.isFile()) continue
|
|
91
|
+
const current = stat ? await fs.promises.readFile(target).catch(() => null) : null
|
|
90
92
|
if (!stat) {
|
|
91
93
|
// No pip here: nothing to do. pip present but its bundle gone (the user deleted a file
|
|
92
94
|
// Pinokio could not replace): write a fresh one, or pip fails on every network call.
|
|
93
95
|
const certifiDir = await fs.promises.stat(path.dirname(target)).catch(() => null)
|
|
94
96
|
if (!certifiDir || !certifiDir.isDirectory()) continue
|
|
95
|
-
} else if (
|
|
96
|
-
|
|
97
|
-
|
|
97
|
+
} else if (current && current.equals(managed)) {
|
|
98
|
+
console.log(`[cert] pip's vendored CA bundle already current: ${target}`, JSON.stringify({
|
|
99
|
+
timestamp: new Date().toISOString(), size_bytes: current.length, sha256: sha256(current),
|
|
100
|
+
}))
|
|
101
|
+
continue
|
|
98
102
|
}
|
|
99
103
|
const temporary = path.join(path.dirname(target), `.cacert-${randomUUID()}.tmp`)
|
|
100
104
|
try {
|
|
101
105
|
await fs.promises.writeFile(temporary, managed, { mode: stat ? stat.mode & 0o777 : 0o644 })
|
|
102
106
|
await fs.promises.rename(temporary, target)
|
|
107
|
+
console.log(`[cert] replaced pip's vendored CA bundle: ${target}`, JSON.stringify({
|
|
108
|
+
timestamp: new Date().toISOString(),
|
|
109
|
+
before_size_bytes: current ? current.length : stat ? stat.size : null,
|
|
110
|
+
after_size_bytes: managed.length,
|
|
111
|
+
before_sha256: current ? sha256(current) : null,
|
|
112
|
+
after_sha256: sha256(managed),
|
|
113
|
+
}))
|
|
103
114
|
repaired.push(target)
|
|
104
115
|
} catch (error) {
|
|
116
|
+
console.log(`[cert] could not replace pip's vendored CA bundle: ${target}`, JSON.stringify({
|
|
117
|
+
timestamp: new Date().toISOString(), error: error.message,
|
|
118
|
+
}))
|
|
105
119
|
// The caller decides what a failed replacement means; it needs to know which file.
|
|
106
120
|
error.target = target
|
|
107
121
|
error.prefix = prefix
|
package/kernel/environment.js
CHANGED
|
@@ -623,8 +623,8 @@ const init_folders = async (homedir, kernel) => {
|
|
|
623
623
|
let is_relative = val.startsWith("./")
|
|
624
624
|
if (is_absolute || is_relative) {
|
|
625
625
|
|
|
626
|
-
// skip
|
|
627
|
-
if (["PIP_CONFIG_FILE", "CONDARC", "HF_TOKEN_PATH"].includes(key)) {
|
|
626
|
+
// skip values that name a file, not a folder: config files and CA bundles
|
|
627
|
+
if (["PIP_CONFIG_FILE", "CONDARC", "HF_TOKEN_PATH", "SSL_CERT_FILE", "REQUESTS_CA_BUNDLE", "CURL_CA_BUNDLE", "PIP_CERT"].includes(kernel.platform === "win32" ? key.toUpperCase() : key)) {
|
|
628
628
|
continue
|
|
629
629
|
}
|
|
630
630
|
// it's a path
|
package/kernel/shell.js
CHANGED
package/package.json
CHANGED
package/test/cert-env.test.js
CHANGED
|
@@ -310,6 +310,62 @@ test('Shell.activate stops the step when the pip bundle cannot be replaced', { s
|
|
|
310
310
|
await fs.chmod(certifiDir, 0o700)
|
|
311
311
|
})
|
|
312
312
|
|
|
313
|
+
test('init_folders never turns a certificate file path into a directory', async t => {
|
|
314
|
+
const Environment = require('../kernel/environment')
|
|
315
|
+
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'cert-env-folders-'))
|
|
316
|
+
t.after(() => fs.rm(root, { recursive: true, force: true }).catch(() => {}))
|
|
317
|
+
const app = path.join(root, 'api', 'demo.git')
|
|
318
|
+
await fs.mkdir(app, { recursive: true })
|
|
319
|
+
const lines = [
|
|
320
|
+
`SSL_CERT_FILE=${path.join(root, 'custom', 'ssl.pem')}`,
|
|
321
|
+
`REQUESTS_CA_BUNDLE=${path.join(root, 'custom', 'requests.pem')}`,
|
|
322
|
+
`CURL_CA_BUNDLE=${path.join(root, 'custom', 'curl.pem')}`,
|
|
323
|
+
`PIP_CERT=${path.join(root, 'custom', 'pip.pem')}`,
|
|
324
|
+
`HF_HOME=${path.join(root, 'custom', 'hf')}`,
|
|
325
|
+
]
|
|
326
|
+
await fs.writeFile(path.join(app, 'ENVIRONMENT'), lines.join('\n') + '\n')
|
|
327
|
+
|
|
328
|
+
await Environment.init_folders(app, createKernel(root))
|
|
329
|
+
|
|
330
|
+
for (const name of ['ssl.pem', 'requests.pem', 'curl.pem', 'pip.pem']) {
|
|
331
|
+
assert.equal(await fs.stat(path.join(root, 'custom', name)).catch(() => null), null, `${name} must not be created as a folder`)
|
|
332
|
+
}
|
|
333
|
+
assert.ok((await fs.stat(path.join(root, 'custom', 'hf'))).isDirectory(), 'ordinary path values are still created as folders')
|
|
334
|
+
})
|
|
335
|
+
|
|
336
|
+
test('init_folders handles Windows certificate key casing without changing POSIX key semantics', async t => {
|
|
337
|
+
const Environment = require('../kernel/environment')
|
|
338
|
+
for (const platform of ['win32', 'linux']) {
|
|
339
|
+
for (const casing of ['upper', 'lower', 'mixed']) {
|
|
340
|
+
await t.test(`${platform} ${casing}`, async t => {
|
|
341
|
+
const root = await tempRoot(t, 'cert-env-case-')
|
|
342
|
+
const app = path.join(root, 'api', 'demo.git')
|
|
343
|
+
await fs.mkdir(app, { recursive: true })
|
|
344
|
+
const keys = ['SSL_CERT_FILE', 'REQUESTS_CA_BUNDLE', 'CURL_CA_BUNDLE', 'PIP_CERT']
|
|
345
|
+
const lines = keys.map((key, index) => {
|
|
346
|
+
if (casing === 'lower') key = key.toLowerCase()
|
|
347
|
+
if (casing === 'mixed') key = key[0] + key.slice(1).toLowerCase()
|
|
348
|
+
return `${key}=${path.join(root, 'custom', `${index}.pem`)}`
|
|
349
|
+
})
|
|
350
|
+
lines.push(`HF_HOME=${path.join(root, 'custom', 'hf')}`)
|
|
351
|
+
await fs.writeFile(path.join(app, 'ENVIRONMENT'), lines.join('\n') + '\n')
|
|
352
|
+
const kernel = createKernel(root)
|
|
353
|
+
kernel.platform = platform
|
|
354
|
+
await Environment.init_folders(app, kernel)
|
|
355
|
+
for (let index = 0; index < keys.length; index++) {
|
|
356
|
+
const stat = await fs.stat(path.join(root, 'custom', `${index}.pem`)).catch(() => null)
|
|
357
|
+
if (platform === 'win32' || casing === 'upper') {
|
|
358
|
+
assert.equal(stat, null, 'recognized certificate files must not become folders')
|
|
359
|
+
} else {
|
|
360
|
+
assert.ok(stat.isDirectory(), 'nonstandard POSIX keys retain ordinary folder handling')
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
assert.ok((await fs.stat(path.join(root, 'custom', 'hf'))).isDirectory())
|
|
364
|
+
})
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
})
|
|
368
|
+
|
|
313
369
|
function escapeForHtml(value) {
|
|
314
370
|
return require('escape-html')(value)
|
|
315
371
|
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
const assert = require('node:assert/strict')
|
|
2
|
+
const fs = require('node:fs/promises')
|
|
3
|
+
const os = require('node:os')
|
|
4
|
+
const path = require('node:path')
|
|
5
|
+
const { createHash } = require('node:crypto')
|
|
6
|
+
const test = require('node:test')
|
|
7
|
+
const { repairPipVendoredBundle } = require('../kernel/cert_env')
|
|
8
|
+
|
|
9
|
+
const sha256 = data => createHash('sha256').update(data).digest('hex')
|
|
10
|
+
const MANAGED = 'new certificate bundle\n'
|
|
11
|
+
const STALE = 'old certificate bundle\n' // Equal sizes must still produce different hashes.
|
|
12
|
+
|
|
13
|
+
async function fixture(t, platform = 'linux') {
|
|
14
|
+
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'pinokio-cert-log-'))
|
|
15
|
+
t.after(() => fs.rm(root, { recursive: true, force: true }))
|
|
16
|
+
const prefix = path.join(root, 'env')
|
|
17
|
+
const site = platform === 'win32' ? 'Lib/site-packages' : 'lib/python3.10/site-packages'
|
|
18
|
+
const target = path.join(prefix, site, 'pip/_vendor/certifi/cacert.pem')
|
|
19
|
+
const managed = path.join(root, 'managed.pem')
|
|
20
|
+
await fs.mkdir(path.dirname(target), { recursive: true })
|
|
21
|
+
await fs.writeFile(target, STALE)
|
|
22
|
+
await fs.writeFile(managed, MANAGED)
|
|
23
|
+
const messages = []
|
|
24
|
+
t.mock.method(console, 'log', (...args) => messages.push(args))
|
|
25
|
+
return { root, prefix, target, managed, messages }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
test('console logs contain independently verifiable evidence even for equal-size replacements', async t => {
|
|
29
|
+
for (const platform of ['linux', 'win32']) {
|
|
30
|
+
await t.test(platform, async t => {
|
|
31
|
+
const f = await fixture(t, platform)
|
|
32
|
+
assert.deepEqual(await repairPipVendoredBundle(f.prefix, f.managed, platform), [f.target])
|
|
33
|
+
assert.equal(f.messages.length, 1)
|
|
34
|
+
assert.ok(f.messages[0][0].includes(f.target))
|
|
35
|
+
const record = JSON.parse(f.messages[0][1])
|
|
36
|
+
assert.equal(record.before_size_bytes, Buffer.byteLength(STALE))
|
|
37
|
+
assert.equal(record.after_size_bytes, record.before_size_bytes)
|
|
38
|
+
assert.equal(record.before_sha256, sha256(STALE))
|
|
39
|
+
assert.equal(record.after_sha256, sha256(await fs.readFile(f.target)))
|
|
40
|
+
assert.notEqual(record.before_sha256, record.after_sha256)
|
|
41
|
+
assert.equal(new Date(record.timestamp).toISOString(), record.timestamp)
|
|
42
|
+
const mtime = (await fs.stat(f.target)).mtimeMs
|
|
43
|
+
assert.deepEqual(await repairPipVendoredBundle(f.prefix, f.managed, platform), [])
|
|
44
|
+
assert.equal((await fs.stat(f.target)).mtimeMs, mtime)
|
|
45
|
+
assert.ok(f.messages[1][0].includes('already current'))
|
|
46
|
+
assert.equal(JSON.parse(f.messages[1][1]).sha256, record.after_sha256)
|
|
47
|
+
})
|
|
48
|
+
}
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
test('a created PEM logs a missing baseline and the written size/hash', async t => {
|
|
52
|
+
const f = await fixture(t)
|
|
53
|
+
await fs.rm(f.target)
|
|
54
|
+
await repairPipVendoredBundle(f.prefix, f.managed, 'linux')
|
|
55
|
+
const record = JSON.parse(f.messages[0][1])
|
|
56
|
+
assert.equal(record.before_size_bytes, null)
|
|
57
|
+
assert.equal(record.before_sha256, null)
|
|
58
|
+
assert.equal(record.after_size_bytes, Buffer.byteLength(MANAGED))
|
|
59
|
+
assert.equal(record.after_sha256, sha256(MANAGED))
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
test('a failed rename logs failure and leaves the stale file and no temp file', async t => {
|
|
63
|
+
const f = await fixture(t)
|
|
64
|
+
t.mock.method(fs, 'rename', async () => { throw Object.assign(new Error('file locked'), { code: 'EPERM' }) })
|
|
65
|
+
await assert.rejects(repairPipVendoredBundle(f.prefix, f.managed, 'linux'), /file locked/)
|
|
66
|
+
assert.equal(f.messages.length, 1)
|
|
67
|
+
assert.equal(JSON.parse(f.messages[0][1]).error, 'file locked')
|
|
68
|
+
assert.equal(await fs.readFile(f.target, 'utf8'), STALE)
|
|
69
|
+
assert.deepEqual(await fs.readdir(path.dirname(f.target)), ['cacert.pem'])
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
test('logging a successful replacement adds no verification read or launch failure', async t => {
|
|
73
|
+
const f = await fixture(t)
|
|
74
|
+
const read = fs.readFile.bind(fs)
|
|
75
|
+
const rename = fs.rename.bind(fs)
|
|
76
|
+
let renamed = false
|
|
77
|
+
let reread = false
|
|
78
|
+
t.mock.method(fs, 'rename', async (...args) => {
|
|
79
|
+
await rename(...args)
|
|
80
|
+
renamed = true
|
|
81
|
+
})
|
|
82
|
+
t.mock.method(fs, 'readFile', async (file, ...args) => {
|
|
83
|
+
if (file === f.target && renamed) {
|
|
84
|
+
reread = true
|
|
85
|
+
throw Object.assign(new Error('reread denied'), { code: 'EACCES' })
|
|
86
|
+
}
|
|
87
|
+
return read(file, ...args)
|
|
88
|
+
})
|
|
89
|
+
assert.deepEqual(await repairPipVendoredBundle(f.prefix, f.managed, 'linux'), [f.target])
|
|
90
|
+
const record = JSON.parse(f.messages[0][1])
|
|
91
|
+
assert.equal(reread, false)
|
|
92
|
+
assert.equal(record.after_size_bytes, Buffer.byteLength(MANAGED))
|
|
93
|
+
assert.equal(record.after_sha256, sha256(MANAGED))
|
|
94
|
+
assert.equal(Object.hasOwn(record, 'verified'), false)
|
|
95
|
+
assert.equal(await read(f.target, 'utf8'), MANAGED)
|
|
96
|
+
assert.deepEqual(await fs.readdir(path.dirname(f.target)), ['cacert.pem'])
|
|
97
|
+
})
|