pinokiod 8.2.10 → 8.2.11
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/anywhere/cli.js +100 -0
- package/kernel/anywhere/index.js +236 -160
- package/kernel/anywhere/ports.js +6 -9
- package/kernel/anywhere/serve.js +17 -7
- package/kernel/anywhere/state.js +14 -0
- package/kernel/bin/conda-meta.js +19 -1
- package/kernel/bin/conda-pins.js +7 -3
- package/kernel/bin/conda.js +67 -15
- package/kernel/bin/index.js +5 -2
- package/kernel/bin/setup.js +3 -17
- package/kernel/bin/tailscaled.js +23 -13
- package/kernel/index.js +11 -9
- package/kernel/shell.js +14 -6
- package/kernel/util.js +1 -0
- package/package.json +1 -1
- package/server/index.js +15 -19
- package/server/public/universal-launcher.js +5 -0
- package/server/views/anywhere.ejs +13 -13
- package/server/views/partials/main_sidebar.ejs +0 -1
- package/server/views/remote.ejs +12 -4
- package/system/pip_seed.json +6 -0
- package/system/repair_pip_seed.py +100 -0
- package/test/anywhere-remote.test.js +182 -8
- package/test/anywhere.test.js +1051 -131
- package/test/conda-bin.test.js +254 -4
- package/test/pip_seed_test.py +183 -0
- package/test/shell-run-template.test.js +147 -140
- package/test/tailscale-cli.test.js +239 -0
- package/kernel/anywhere/localapi.js +0 -147
package/test/conda-bin.test.js
CHANGED
|
@@ -16,7 +16,8 @@ const {
|
|
|
16
16
|
isExpectedPythonPinned,
|
|
17
17
|
TLS_PACKAGE_SPECS,
|
|
18
18
|
PIP_MIN_VERSION,
|
|
19
|
-
|
|
19
|
+
PIP_WHEEL,
|
|
20
|
+
PIP_SEED_SCRIPT,
|
|
20
21
|
} = require('../kernel/bin/conda-pins')
|
|
21
22
|
|
|
22
23
|
test.beforeEach(t => {
|
|
@@ -24,6 +25,12 @@ test.beforeEach(t => {
|
|
|
24
25
|
t.mock.method(childProcess, 'execFile', (binary, args, options, done) => {
|
|
25
26
|
if (args[0] !== '-I') return realExecFile(binary, args, options, done)
|
|
26
27
|
const prefix = path.basename(binary) === 'python.exe' ? path.dirname(binary) : path.dirname(path.dirname(binary))
|
|
28
|
+
if (args.includes(PIP_SEED_SCRIPT)) {
|
|
29
|
+
const marker = path.join(prefix, 'test-pip-seed')
|
|
30
|
+
const operation = args.includes('--check') ? fs.readFile(marker, 'utf8') : fs.writeFile(marker, 'ready')
|
|
31
|
+
operation.then(() => done(null, ''), error => done(error, '', error.message))
|
|
32
|
+
return
|
|
33
|
+
}
|
|
27
34
|
assert.deepEqual(args, ['-I', '-m', 'pip', '--version'])
|
|
28
35
|
fs.readFile(path.join(prefix, 'test-pip-version'), 'utf8').then(
|
|
29
36
|
version => done(null, `pip ${version.trim()} from ${prefix} (python 3.10)`), error => done(error, '')
|
|
@@ -73,6 +80,7 @@ async function writeHealthyCondaMeta(root, platform = 'win32', condaVersion = CO
|
|
|
73
80
|
await writeCondaMeta(root, name, '2026.10.19')
|
|
74
81
|
}
|
|
75
82
|
await fs.writeFile(path.join(root, 'bin/miniforge/test-pip-version'), PIP_MIN_VERSION)
|
|
83
|
+
await fs.writeFile(path.join(root, 'bin/miniforge/test-pip-seed'), 'ready')
|
|
76
84
|
}
|
|
77
85
|
|
|
78
86
|
async function writeFakeManagedConda(root, platform = 'win32') {
|
|
@@ -112,6 +120,7 @@ function createConda(kernel) {
|
|
|
112
120
|
const conda = new Conda()
|
|
113
121
|
conda.kernel = kernel
|
|
114
122
|
conda.verifyInstaller = async () => {} // Installer lifecycle tests mock the download.
|
|
123
|
+
conda.cachePipWheel = async () => kernel.bin.path(PIP_WHEEL.filename)
|
|
115
124
|
return conda
|
|
116
125
|
}
|
|
117
126
|
|
|
@@ -278,7 +287,7 @@ test('Conda install requests the Python 3.10.20 pin on macOS/Linux', async () =>
|
|
|
278
287
|
assert.equal(await pathExists(path.join(root, 'bin', 'miniconda')), true)
|
|
279
288
|
const condaInstall = calls.find((call) => Array.isArray(call.message))
|
|
280
289
|
assert.ok(condaInstall)
|
|
281
|
-
assert.
|
|
290
|
+
assert.deepEqual(condaInstall.message[2], { _: ['python', '-I', '-m', 'pip', '--isolated', 'install', '--no-index', PIP_WHEEL.filename] })
|
|
282
291
|
assert.equal(
|
|
283
292
|
condaInstall.message[1],
|
|
284
293
|
'conda install -y --override-channels -c conda-forge "conda=26.5.3" "python=3.10.20" "conda-libmamba-solver>=25.4.0" "ca-certificates>=2026.10.19" "certifi>=2026.10.19"'
|
|
@@ -639,6 +648,7 @@ test('tampered Miniforge installers stop immediately and leave the runtime untou
|
|
|
639
648
|
kernel.bin.mod = { caddy: { running: () => assert.fail('must verify before stopping services') } }
|
|
640
649
|
const conda = new Conda()
|
|
641
650
|
conda.kernel = kernel
|
|
651
|
+
conda.cachePipWheel = async () => kernel.bin.path(PIP_WHEEL.filename)
|
|
642
652
|
await assert.rejects(conda.install({}, () => {}), /SHA-256 verification failed/)
|
|
643
653
|
assert.equal(downloads, 1)
|
|
644
654
|
assert.equal(await pathExists(path.join(root, 'bin/miniforge/Scripts/conda.exe')), true)
|
|
@@ -667,6 +677,9 @@ test('certificate refresh preserves named environments and existing configuratio
|
|
|
667
677
|
const dir = path.join(root, platform)
|
|
668
678
|
await writeHealthyCondaMeta(dir, platform)
|
|
669
679
|
await writeFakeManagedConda(dir, platform)
|
|
680
|
+
for (const name of ['ca-certificates', 'certifi']) {
|
|
681
|
+
await fs.rm(path.join(dir, 'bin/miniforge/conda-meta', `${name}-2026.10.19.json`))
|
|
682
|
+
}
|
|
670
683
|
await fs.writeFile(path.join(dir, 'bin/miniforge/test-pip-version'), '23.0.1')
|
|
671
684
|
const marker = path.join(dir, 'bin/miniforge/envs/app/keep')
|
|
672
685
|
await fs.mkdir(path.dirname(marker), { recursive: true })
|
|
@@ -690,16 +703,150 @@ test('certificate refresh preserves named environments and existing configuratio
|
|
|
690
703
|
const python = platform === 'win32' ? WINDOWS_PYTHON_SSL_FIX_SPEC : PYTHON_INSTALL_SPEC
|
|
691
704
|
assert.deepEqual(calls[0].message, [
|
|
692
705
|
`conda install -y --override-channels -c conda-forge "${python}" "conda-libmamba-solver>=25.4.0" ${TLS_PACKAGE_SPECS.map(spec => `"${spec}"`).join(' ')}`,
|
|
693
|
-
PIP_INSTALL_COMMAND,
|
|
694
706
|
])
|
|
695
707
|
}
|
|
696
708
|
})
|
|
697
709
|
|
|
710
|
+
test('a missing or old solver is repaired in place when TLS and pip are already current', async t => {
|
|
711
|
+
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'pinokio-solver-repair-'))
|
|
712
|
+
t.after(() => fs.rm(root, { recursive: true, force: true }))
|
|
713
|
+
for (const platform of ['darwin', 'win32', 'linux']) {
|
|
714
|
+
for (const version of [null, '24.9.0', '25.3.0']) {
|
|
715
|
+
const dir = path.join(root, platform, String(version))
|
|
716
|
+
await writeHealthyCondaMeta(dir, platform)
|
|
717
|
+
await writeFakeManagedConda(dir, platform)
|
|
718
|
+
await fs.rm(path.join(dir, 'bin/miniforge/conda-meta/conda-libmamba-solver-25.4.0.json'))
|
|
719
|
+
if (version) await writeCondaMeta(dir, 'conda-libmamba-solver', version)
|
|
720
|
+
const marker = path.join(dir, 'bin/miniforge/envs/app/keep')
|
|
721
|
+
await fs.mkdir(path.dirname(marker), { recursive: true })
|
|
722
|
+
await fs.writeFile(marker, 'existing app')
|
|
723
|
+
const kernel = createKernel(dir, platform)
|
|
724
|
+
let installs = 0
|
|
725
|
+
kernel.bin.download = async () => assert.fail('must not replace the runtime')
|
|
726
|
+
kernel.bin.exec = async ({ message }) => {
|
|
727
|
+
installs++
|
|
728
|
+
assert.match(message[0], /conda install -y --solver classic /)
|
|
729
|
+
assert.match(message[0], /"conda-libmamba-solver>=25\.4\.0"/)
|
|
730
|
+
if (version) await fs.rm(path.join(dir, `bin/miniforge/conda-meta/conda-libmamba-solver-${version}.json`))
|
|
731
|
+
await writeHealthyCondaMeta(dir, platform)
|
|
732
|
+
return {}
|
|
733
|
+
}
|
|
734
|
+
const conda = createConda(kernel)
|
|
735
|
+
assert.equal(await conda.check(), false)
|
|
736
|
+
await conda.install({}, () => {})
|
|
737
|
+
assert.equal(await conda.check(), true)
|
|
738
|
+
await conda.install({}, () => {})
|
|
739
|
+
assert.equal(installs, 1, 'a retry uses the repaired solver')
|
|
740
|
+
assert.equal(await fs.readFile(marker, 'utf8'), 'existing app')
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
})
|
|
744
|
+
|
|
745
|
+
test('in-place repair checks pip after Conda changes it and never downgrades newer pip', async t => {
|
|
746
|
+
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'pinokio-pip-refresh-'))
|
|
747
|
+
t.after(() => fs.rm(root, { recursive: true, force: true }))
|
|
748
|
+
for (const platform of ['darwin', 'win32', 'linux']) {
|
|
749
|
+
for (const [before, after] of [['27.0', '27.0'], ['27.0', '23.0.1'], ['23.0.1', '23.0.1'], ['23.0.1', '27.0']]) {
|
|
750
|
+
const dir = path.join(root, platform, before, after)
|
|
751
|
+
await writeHealthyCondaMeta(dir, platform)
|
|
752
|
+
await writeFakeManagedConda(dir, platform)
|
|
753
|
+
// Force a repair independently of the starting pip version.
|
|
754
|
+
await fs.rm(path.join(dir, 'bin/miniforge/conda-meta/conda-libmamba-solver-25.4.0.json'))
|
|
755
|
+
const version = path.join(dir, 'bin/miniforge/test-pip-version')
|
|
756
|
+
await fs.writeFile(version, before)
|
|
757
|
+
const marker = path.join(dir, 'bin/miniforge/envs/app/keep')
|
|
758
|
+
await fs.mkdir(path.dirname(marker), { recursive: true })
|
|
759
|
+
await fs.writeFile(marker, 'existing app')
|
|
760
|
+
const kernel = createKernel(dir, platform)
|
|
761
|
+
let pipInstalls = 0
|
|
762
|
+
kernel.bin.download = async () => assert.fail('must not replace the runtime')
|
|
763
|
+
kernel.bin.exec = async ({ message }) => {
|
|
764
|
+
if (Array.isArray(message)) {
|
|
765
|
+
assert.equal(message.length, 1, 'Conda repair must not include an unconditional pip install')
|
|
766
|
+
await writeHealthyCondaMeta(dir, platform)
|
|
767
|
+
await fs.writeFile(version, after)
|
|
768
|
+
} else {
|
|
769
|
+
pipInstalls++
|
|
770
|
+
assert.deepEqual(message, { _: ['python', '-I', '-m', 'pip', '--isolated', 'install', '--no-index', PIP_WHEEL.filename] })
|
|
771
|
+
await fs.writeFile(version, PIP_MIN_VERSION)
|
|
772
|
+
}
|
|
773
|
+
return {}
|
|
774
|
+
}
|
|
775
|
+
await createConda(kernel).install({}, () => {})
|
|
776
|
+
assert.equal(pipInstalls, after === '27.0' ? 0 : 1)
|
|
777
|
+
assert.equal(await fs.readFile(version, 'utf8'), after === '27.0' ? '27.0' : PIP_MIN_VERSION)
|
|
778
|
+
assert.equal(await fs.readFile(marker, 'utf8'), 'existing app')
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
})
|
|
782
|
+
|
|
783
|
+
test('old or missing pip is repaired without a Conda transaction when solver and certificates are current', async t => {
|
|
784
|
+
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'pinokio-pip-only-'))
|
|
785
|
+
t.after(() => fs.rm(root, { recursive: true, force: true }))
|
|
786
|
+
for (const platform of ['darwin', 'win32', 'linux']) {
|
|
787
|
+
for (const version of ['23.0.1', null]) {
|
|
788
|
+
const dir = path.join(root, platform, String(version))
|
|
789
|
+
await writeHealthyCondaMeta(dir, platform)
|
|
790
|
+
await writeFakeManagedConda(dir, platform)
|
|
791
|
+
const pip = path.join(dir, 'bin/miniforge/test-pip-version')
|
|
792
|
+
if (version) await fs.writeFile(pip, version)
|
|
793
|
+
else await fs.rm(pip)
|
|
794
|
+
const seed = path.join(dir, 'bin/miniforge/test-pip-seed')
|
|
795
|
+
await fs.rm(seed)
|
|
796
|
+
const marker = path.join(dir, 'bin/miniforge/envs/app/keep')
|
|
797
|
+
await fs.mkdir(path.dirname(marker), { recursive: true })
|
|
798
|
+
await fs.writeFile(marker, 'existing app')
|
|
799
|
+
const kernel = createKernel(dir, platform)
|
|
800
|
+
let installs = 0
|
|
801
|
+
kernel.bin.download = async () => assert.fail('must not replace the runtime')
|
|
802
|
+
kernel.bin.exec = async ({ message }) => {
|
|
803
|
+
installs++
|
|
804
|
+
assert.deepEqual(message, { _: ['python', '-I', '-m', 'pip', '--isolated', 'install', '--no-index', PIP_WHEEL.filename] })
|
|
805
|
+
await fs.writeFile(pip, PIP_MIN_VERSION)
|
|
806
|
+
return {}
|
|
807
|
+
}
|
|
808
|
+
const conda = createConda(kernel)
|
|
809
|
+
conda._install = async () => assert.fail('must not reinstall the runtime')
|
|
810
|
+
await conda.install({}, () => {})
|
|
811
|
+
assert.equal(installs, 1, 'only the verified pip wheel is installed')
|
|
812
|
+
assert.equal(await conda.check(), true)
|
|
813
|
+
assert.equal(await fs.readFile(seed, 'utf8'), 'ready')
|
|
814
|
+
assert.equal(await fs.readFile(marker, 'utf8'), 'existing app')
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
})
|
|
818
|
+
|
|
819
|
+
test('failed in-place pip repair preserves app environments and does not proceed to seed repair', async t => {
|
|
820
|
+
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'pinokio-pip-refresh-fail-'))
|
|
821
|
+
t.after(() => fs.rm(root, { recursive: true, force: true }))
|
|
822
|
+
await writeHealthyCondaMeta(root, 'darwin')
|
|
823
|
+
await writeFakeManagedConda(root, 'darwin')
|
|
824
|
+
const version = path.join(root, 'bin/miniforge/test-pip-version')
|
|
825
|
+
await fs.writeFile(version, '23.0.1')
|
|
826
|
+
const marker = path.join(root, 'bin/miniforge/envs/app/keep')
|
|
827
|
+
await fs.mkdir(path.dirname(marker), { recursive: true })
|
|
828
|
+
await fs.writeFile(marker, 'existing app')
|
|
829
|
+
const kernel = createKernel(root, 'darwin')
|
|
830
|
+
let calls = 0
|
|
831
|
+
kernel.bin.exec = async ({ message }) => {
|
|
832
|
+
calls++
|
|
833
|
+
assert.equal(Array.isArray(message), false, 'pip-only failure must not run Conda')
|
|
834
|
+
return { error: ['pip failed'] }
|
|
835
|
+
}
|
|
836
|
+
const conda = createConda(kernel)
|
|
837
|
+
conda.preparePipSeed = async () => assert.fail('a failed pip repair must stop setup')
|
|
838
|
+
conda._install = async () => assert.fail('must not replace the runtime')
|
|
839
|
+
await assert.rejects(conda.install({}, () => {}), /existing environments were preserved/)
|
|
840
|
+
assert.equal(calls, 1)
|
|
841
|
+
assert.equal(await fs.readFile(marker, 'utf8'), 'existing app')
|
|
842
|
+
})
|
|
843
|
+
|
|
698
844
|
test('failed refresh reports an error without falling back to runtime replacement', async t => {
|
|
699
845
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'pinokio-ca-failed-'))
|
|
700
846
|
t.after(() => fs.rm(root, { recursive: true, force: true }))
|
|
701
847
|
await writeHealthyCondaMeta(root, 'darwin')
|
|
702
848
|
await writeFakeManagedConda(root, 'darwin')
|
|
849
|
+
await fs.rm(path.join(root, 'bin/miniforge/conda-meta/certifi-2026.10.19.json'))
|
|
703
850
|
const marker = path.join(root, 'bin/miniforge/envs/app/keep')
|
|
704
851
|
await fs.mkdir(path.dirname(marker), { recursive: true })
|
|
705
852
|
await fs.writeFile(marker, 'preserve')
|
|
@@ -730,7 +877,7 @@ test('fresh installation retains readiness-based retries after package or pip er
|
|
|
730
877
|
return {}
|
|
731
878
|
}
|
|
732
879
|
assert.deepEqual(payload.env, { PIP_REQUIRE_VIRTUALENV: 'false' })
|
|
733
|
-
assert.
|
|
880
|
+
assert.deepEqual(payload.message[2], { _: ['python', '-I', '-m', 'pip', '--isolated', 'install', '--no-index', PIP_WHEEL.filename] })
|
|
734
881
|
if (attempts === 1) return { error: ['ERROR: pip download failed'] }
|
|
735
882
|
await fs.writeFile(path.join(root, 'bin/miniforge/test-pip-version'), PIP_MIN_VERSION)
|
|
736
883
|
return {}
|
|
@@ -741,6 +888,47 @@ test('fresh installation retains readiness-based retries after package or pip er
|
|
|
741
888
|
assert.equal(await conda.check(), true)
|
|
742
889
|
})
|
|
743
890
|
|
|
891
|
+
test('post-install seed verification failure preserves the runtime and retries repair in place', async t => {
|
|
892
|
+
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'pinokio-post-install-seed-'))
|
|
893
|
+
t.after(() => fs.rm(root, { recursive: true, force: true }))
|
|
894
|
+
await fs.mkdir(path.join(root, 'bin'), { recursive: true })
|
|
895
|
+
const kernel = createKernel(root, 'darwin')
|
|
896
|
+
let downloads = 0
|
|
897
|
+
kernel.bin.mods = []
|
|
898
|
+
kernel.bin.download = async () => { downloads++ }
|
|
899
|
+
kernel.bin.rm = async () => {}
|
|
900
|
+
kernel.bin.exec = async payload => {
|
|
901
|
+
if (payload.conda?.skip) {
|
|
902
|
+
await writeHealthyCondaMeta(root, 'darwin')
|
|
903
|
+
await writeFakeManagedConda(root, 'darwin')
|
|
904
|
+
}
|
|
905
|
+
return {}
|
|
906
|
+
}
|
|
907
|
+
const conda = createConda(kernel)
|
|
908
|
+
const prepare = conda.preparePipSeed.bind(conda)
|
|
909
|
+
const marker = path.join(root, 'bin/miniforge/envs/app/keep')
|
|
910
|
+
let damaged = false
|
|
911
|
+
conda.preparePipSeed = async ondata => {
|
|
912
|
+
await prepare(ondata)
|
|
913
|
+
if (!damaged) {
|
|
914
|
+
damaged = true
|
|
915
|
+
await fs.mkdir(path.dirname(marker), { recursive: true })
|
|
916
|
+
await fs.writeFile(marker, 'preserve')
|
|
917
|
+
// Lose the seed after preparation, before the final readiness check.
|
|
918
|
+
await fs.rm(path.join(root, 'bin/miniforge/test-pip-seed'))
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
await assert.rejects(conda.install({}, () => {}), /pip seed verification failed; the runtime was preserved/)
|
|
922
|
+
assert.equal(downloads, 1)
|
|
923
|
+
assert.equal(await fs.readFile(marker, 'utf8'), 'preserve')
|
|
924
|
+
assert.equal(await conda.check(), false)
|
|
925
|
+
kernel.bin.exec = async () => assert.fail('retry must repair the seed without reinstalling the runtime')
|
|
926
|
+
await conda.install({}, () => {})
|
|
927
|
+
assert.equal(downloads, 1)
|
|
928
|
+
assert.equal(await fs.readFile(marker, 'utf8'), 'preserve')
|
|
929
|
+
assert.equal(await conda.check(), true)
|
|
930
|
+
})
|
|
931
|
+
|
|
744
932
|
test('pip readiness accepts two-part release versions through the actual pip CLI', async t => {
|
|
745
933
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'pinokio-pip-release-'))
|
|
746
934
|
t.after(() => fs.rm(root, { recursive: true, force: true }))
|
|
@@ -754,3 +942,65 @@ test('pip readiness accepts two-part release versions through the actual pip CLI
|
|
|
754
942
|
assert.equal(bin.correct_conda, true)
|
|
755
943
|
}
|
|
756
944
|
})
|
|
945
|
+
|
|
946
|
+
test('pip wheel cache verifies downloads, reuses valid data and cleans failed attempts', async t => {
|
|
947
|
+
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'pinokio-pip-cache-'))
|
|
948
|
+
t.after(() => fs.rm(root, { recursive: true, force: true }))
|
|
949
|
+
await fs.mkdir(path.join(root, 'bin'))
|
|
950
|
+
const kernel = createKernel(root, 'darwin')
|
|
951
|
+
const conda = new Conda()
|
|
952
|
+
conda.kernel = kernel
|
|
953
|
+
const bytes = Buffer.from('test wheel')
|
|
954
|
+
const originalHash = PIP_WHEEL.sha256
|
|
955
|
+
PIP_WHEEL.sha256 = require('node:crypto').createHash('sha256').update(bytes).digest('hex')
|
|
956
|
+
t.after(() => { PIP_WHEEL.sha256 = originalHash })
|
|
957
|
+
const destination = kernel.bin.path(PIP_WHEEL.filename)
|
|
958
|
+
let downloads = 0
|
|
959
|
+
kernel.bin.download = async (url, name) => {
|
|
960
|
+
assert.equal(url, PIP_WHEEL.url)
|
|
961
|
+
downloads++
|
|
962
|
+
await fs.writeFile(kernel.bin.path(name), downloads === 1 ? 'bad download' : bytes)
|
|
963
|
+
}
|
|
964
|
+
await assert.rejects(conda.cachePipWheel(() => {}), /SHA-256 verification failed/)
|
|
965
|
+
assert.deepEqual(await fs.readdir(kernel.bin.path()), [])
|
|
966
|
+
assert.equal(await conda.cachePipWheel(() => {}), destination)
|
|
967
|
+
assert.equal(await conda.cachePipWheel(() => {}), destination)
|
|
968
|
+
assert.equal(downloads, 2)
|
|
969
|
+
await fs.writeFile(destination, 'damaged cache')
|
|
970
|
+
await Promise.all([conda.cachePipWheel(() => {}), conda.cachePipWheel(() => {})])
|
|
971
|
+
assert.deepEqual(await fs.readFile(destination), bytes)
|
|
972
|
+
assert.deepEqual(await fs.readdir(kernel.bin.path()), [PIP_WHEEL.filename])
|
|
973
|
+
})
|
|
974
|
+
|
|
975
|
+
test('seed-only damage and repair failure never reinstall Conda or delete app environments', async t => {
|
|
976
|
+
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'pinokio-seed-only-'))
|
|
977
|
+
t.after(() => fs.rm(root, { recursive: true, force: true }))
|
|
978
|
+
for (const platform of ['darwin', 'win32', 'linux']) {
|
|
979
|
+
const dir = path.join(root, platform)
|
|
980
|
+
await writeHealthyCondaMeta(dir, platform)
|
|
981
|
+
await writeFakeManagedConda(dir, platform)
|
|
982
|
+
await fs.rm(path.join(dir, 'bin/miniforge/test-pip-seed'))
|
|
983
|
+
const marker = path.join(dir, 'bin/miniforge/envs/app/keep')
|
|
984
|
+
await fs.mkdir(path.dirname(marker), { recursive: true })
|
|
985
|
+
await fs.writeFile(marker, 'preserve')
|
|
986
|
+
const kernel = createKernel(dir, platform)
|
|
987
|
+
kernel.bin.exec = async () => assert.fail('seed repair must not run Conda install')
|
|
988
|
+
kernel.bin.download = async () => assert.fail('seed repair must not download a runtime installer')
|
|
989
|
+
const conda = createConda(kernel)
|
|
990
|
+
const repair = conda.preparePipSeed.bind(conda)
|
|
991
|
+
assert.equal(await conda.check(), false)
|
|
992
|
+
const bin = createBin(dir, platform)
|
|
993
|
+
await bin.tryList()
|
|
994
|
+
assert.equal(bin.correct_conda, false)
|
|
995
|
+
conda.preparePipSeed = async () => { throw new Error('simulated seed repair failure') }
|
|
996
|
+
await assert.rejects(conda.install({}, () => {}), /simulated seed repair failure/)
|
|
997
|
+
assert.equal(await fs.readFile(marker, 'utf8'), 'preserve')
|
|
998
|
+
conda.preparePipSeed = repair
|
|
999
|
+
await conda.install({}, () => {})
|
|
1000
|
+
assert.equal(await conda.check(), true)
|
|
1001
|
+
assert.equal(await pathExists(path.join(dir, 'bin/miniconda')), true)
|
|
1002
|
+
await bin.tryList()
|
|
1003
|
+
assert.equal(bin.correct_conda, true)
|
|
1004
|
+
assert.equal(await fs.readFile(marker, 'utf8'), 'preserve')
|
|
1005
|
+
}
|
|
1006
|
+
})
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import hashlib
|
|
2
|
+
import importlib.util
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
import py_compile
|
|
7
|
+
import stat
|
|
8
|
+
import sys
|
|
9
|
+
import tempfile
|
|
10
|
+
import types
|
|
11
|
+
import unittest
|
|
12
|
+
from unittest.mock import patch
|
|
13
|
+
import zipfile
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
SCRIPT = Path(__file__).resolve().parents[1] / "system" / "repair_pip_seed.py"
|
|
17
|
+
spec = importlib.util.spec_from_file_location("repair_pip_seed", SCRIPT)
|
|
18
|
+
seed = importlib.util.module_from_spec(spec)
|
|
19
|
+
spec.loader.exec_module(seed)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class PipSeedTests(unittest.TestCase):
|
|
23
|
+
def setUp(self):
|
|
24
|
+
self.temporary = tempfile.TemporaryDirectory(prefix="pip seed O'Brien & (test) ")
|
|
25
|
+
self.addCleanup(self.temporary.cleanup)
|
|
26
|
+
self.prefix = Path(self.temporary.name).resolve()
|
|
27
|
+
self.directory = self.prefix / "Lib" / "ensurepip"
|
|
28
|
+
self.bundled = self.directory / "_bundled"
|
|
29
|
+
self.bundled.mkdir(parents=True)
|
|
30
|
+
self.source = self.directory / "__init__.py"
|
|
31
|
+
self.original = b'_PIP_VERSION = "23.0.1"\n_SETUPTOOLS_VERSION = "79.0.1"\n'
|
|
32
|
+
self.source.write_bytes(self.original)
|
|
33
|
+
self.source.chmod(0o640 if os.name != "nt" else 0o666)
|
|
34
|
+
self.source_mode = stat.S_IMODE(self.source.stat().st_mode)
|
|
35
|
+
self.old_wheel = self.bundled / "pip-23.0.1-py3-none-any.whl"
|
|
36
|
+
self.old_wheel.write_bytes(b"old wheel stays")
|
|
37
|
+
self.setuptools = self.bundled / "setuptools-79.0.1.whl"
|
|
38
|
+
self.setuptools.write_bytes(b"keep setuptools")
|
|
39
|
+
self.legacy = self.prefix / "existing-venv" / "pip"
|
|
40
|
+
self.legacy.parent.mkdir()
|
|
41
|
+
self.legacy.write_bytes(b"existing pip stays")
|
|
42
|
+
self.pin = json.loads(SCRIPT.with_name("pip_seed.json").read_text())
|
|
43
|
+
self.wheel = self.prefix / self.pin["filename"]
|
|
44
|
+
self.wheel.write_bytes(b"verified fixture wheel")
|
|
45
|
+
self.pin["sha256"] = hashlib.sha256(self.wheel.read_bytes()).hexdigest()
|
|
46
|
+
self.destination = self.bundled / self.pin["filename"]
|
|
47
|
+
self.module = types.ModuleType("ensurepip")
|
|
48
|
+
self.module.__file__ = str(self.source)
|
|
49
|
+
self.module.version = lambda: self.source.read_text().split('"')[1]
|
|
50
|
+
for mock in [patch.object(sys, "prefix", str(self.prefix)),
|
|
51
|
+
patch.object(sys, "base_prefix", str(self.prefix)),
|
|
52
|
+
patch.dict(sys.modules, {"ensurepip": self.module}),
|
|
53
|
+
patch("json.loads", return_value=self.pin)]:
|
|
54
|
+
mock.start()
|
|
55
|
+
self.addCleanup(mock.stop)
|
|
56
|
+
|
|
57
|
+
def repair(self, check=False):
|
|
58
|
+
seed.repair(str(self.wheel), str(self.prefix), check)
|
|
59
|
+
|
|
60
|
+
def test_hardlinks_permissions_packages_and_repeat(self):
|
|
61
|
+
alias = self.prefix / "conda-cache-source"
|
|
62
|
+
os.link(self.source, alias)
|
|
63
|
+
wheel_alias = self.prefix / "conda-cache-wheel"
|
|
64
|
+
self.destination.write_bytes(b"corrupt old destination")
|
|
65
|
+
os.link(self.destination, wheel_alias)
|
|
66
|
+
self.repair()
|
|
67
|
+
self.assertEqual(alias.read_bytes(), self.original)
|
|
68
|
+
self.assertEqual(wheel_alias.read_bytes(), b"corrupt old destination")
|
|
69
|
+
self.assertEqual(stat.S_IMODE(self.source.stat().st_mode), self.source_mode)
|
|
70
|
+
self.assertEqual(self.old_wheel.read_bytes(), b"old wheel stays")
|
|
71
|
+
self.assertEqual(self.setuptools.read_bytes(), b"keep setuptools")
|
|
72
|
+
self.assertEqual(self.legacy.read_bytes(), b"existing pip stays")
|
|
73
|
+
inode = self.source.stat().st_ino
|
|
74
|
+
self.repair()
|
|
75
|
+
self.assertEqual(self.source.stat().st_ino, inode)
|
|
76
|
+
self.repair(check=True)
|
|
77
|
+
|
|
78
|
+
def test_bad_download_changes_nothing(self):
|
|
79
|
+
self.wheel.write_bytes(b"bad download")
|
|
80
|
+
with self.assertRaisesRegex(RuntimeError, "SHA-256"):
|
|
81
|
+
self.repair()
|
|
82
|
+
self.assertEqual(self.source.read_bytes(), self.original)
|
|
83
|
+
self.assertFalse(self.destination.exists())
|
|
84
|
+
|
|
85
|
+
def test_interrupted_source_publish_is_retryable(self):
|
|
86
|
+
replace = os.replace
|
|
87
|
+
|
|
88
|
+
def interrupt(source, destination):
|
|
89
|
+
if Path(destination) == self.source:
|
|
90
|
+
raise PermissionError("simulated source replacement failure")
|
|
91
|
+
replace(source, destination)
|
|
92
|
+
|
|
93
|
+
with patch("os.replace", side_effect=interrupt):
|
|
94
|
+
with self.assertRaises(PermissionError):
|
|
95
|
+
self.repair()
|
|
96
|
+
self.assertEqual(self.source.read_bytes(), self.original)
|
|
97
|
+
self.assertEqual(self.destination.read_bytes(), self.wheel.read_bytes())
|
|
98
|
+
self.assertEqual(self.old_wheel.read_bytes(), b"old wheel stays")
|
|
99
|
+
self.assertEqual(sorted(p.name for p in self.directory.iterdir()), ["__init__.py", "_bundled"])
|
|
100
|
+
self.repair()
|
|
101
|
+
self.repair(check=True)
|
|
102
|
+
|
|
103
|
+
def test_readiness_detects_missing_and_corrupt_seed_and_cache(self):
|
|
104
|
+
self.repair()
|
|
105
|
+
for value in [None, b"corrupt seed"]:
|
|
106
|
+
if value is None:
|
|
107
|
+
self.destination.unlink()
|
|
108
|
+
else:
|
|
109
|
+
self.destination.write_bytes(value)
|
|
110
|
+
with self.assertRaises((RuntimeError, FileNotFoundError)):
|
|
111
|
+
self.repair(check=True)
|
|
112
|
+
self.repair()
|
|
113
|
+
self.repair(check=True)
|
|
114
|
+
self.wheel.unlink()
|
|
115
|
+
with self.assertRaises(FileNotFoundError):
|
|
116
|
+
self.repair(check=True)
|
|
117
|
+
|
|
118
|
+
def test_same_size_source_update_invalidates_bytecode(self):
|
|
119
|
+
cache = Path(py_compile.compile(str(self.source), doraise=True))
|
|
120
|
+
before = self.source.stat()
|
|
121
|
+
self.repair()
|
|
122
|
+
self.assertEqual(self.source.stat().st_size, before.st_size)
|
|
123
|
+
self.assertFalse(cache.exists())
|
|
124
|
+
self.assertEqual(self.module.version(), self.pin["version"])
|
|
125
|
+
# A Conda Python reinstall restores the old seed reference.
|
|
126
|
+
self.source.write_bytes(self.original)
|
|
127
|
+
with self.assertRaisesRegex(RuntimeError, "needs repair"):
|
|
128
|
+
self.repair(check=True)
|
|
129
|
+
self.repair()
|
|
130
|
+
self.repair(check=True)
|
|
131
|
+
|
|
132
|
+
def test_newer_seed_is_preserved_or_repaired_when_incomplete(self):
|
|
133
|
+
newer_source = self.original.replace(b"23.0.1", b"27.0")
|
|
134
|
+
self.source.write_bytes(newer_source)
|
|
135
|
+
newer = self.bundled / "pip-27.0-py3-none-any.whl"
|
|
136
|
+
with zipfile.ZipFile(newer, "w") as archive:
|
|
137
|
+
archive.writestr("pip/__init__.py", "__version__ = '27.0'")
|
|
138
|
+
archive.writestr("pip-27.0.dist-info/METADATA", "Name: pip\nVersion: 27.0\n")
|
|
139
|
+
before = newer.read_bytes()
|
|
140
|
+
self.repair()
|
|
141
|
+
self.repair(check=True)
|
|
142
|
+
self.assertEqual(self.source.read_bytes(), newer_source)
|
|
143
|
+
self.assertEqual(newer.read_bytes(), before)
|
|
144
|
+
self.assertFalse(self.destination.exists())
|
|
145
|
+
newer.unlink()
|
|
146
|
+
with self.assertRaisesRegex(RuntimeError, "needs repair"):
|
|
147
|
+
self.repair(check=True)
|
|
148
|
+
self.repair()
|
|
149
|
+
self.repair(check=True)
|
|
150
|
+
|
|
151
|
+
def test_unsupported_python_and_wrong_prefix_cannot_be_modified(self):
|
|
152
|
+
with patch.object(sys, "version_info", (3, 9, 0)):
|
|
153
|
+
self.repair()
|
|
154
|
+
with self.assertRaisesRegex(RuntimeError, "3.10"):
|
|
155
|
+
self.repair(check=True)
|
|
156
|
+
self.assertEqual(self.source.read_bytes(), self.original)
|
|
157
|
+
with self.assertRaisesRegex(RuntimeError, "selected Python prefix"):
|
|
158
|
+
seed.repair(str(self.wheel), str(self.prefix / "another"))
|
|
159
|
+
with patch.object(sys, "base_prefix", str(self.prefix / "another")):
|
|
160
|
+
with self.assertRaisesRegex(RuntimeError, "selected Python prefix"):
|
|
161
|
+
self.repair()
|
|
162
|
+
self.assertFalse(self.destination.exists())
|
|
163
|
+
|
|
164
|
+
def test_unrecognized_layout_is_rejected_without_changes(self):
|
|
165
|
+
self.source.write_bytes(b"# unexpected vendor layout\n")
|
|
166
|
+
with self.assertRaisesRegex(RuntimeError, "Unrecognized"):
|
|
167
|
+
self.repair()
|
|
168
|
+
self.assertFalse(self.destination.exists())
|
|
169
|
+
|
|
170
|
+
@unittest.skipIf(os.name == "nt", "directory symlinks may require Windows administrator rights")
|
|
171
|
+
def test_bundled_directory_cannot_redirect_writes_outside_the_prefix(self):
|
|
172
|
+
import shutil
|
|
173
|
+
with tempfile.TemporaryDirectory() as other:
|
|
174
|
+
shutil.rmtree(self.bundled)
|
|
175
|
+
self.bundled.symlink_to(other, target_is_directory=True)
|
|
176
|
+
with self.assertRaisesRegex(RuntimeError, "selected Python prefix"):
|
|
177
|
+
self.repair()
|
|
178
|
+
self.assertEqual(list(Path(other).iterdir()), [])
|
|
179
|
+
self.assertEqual(self.source.read_bytes(), self.original)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
if __name__ == "__main__":
|
|
183
|
+
unittest.main()
|