Skip to content

Commit a99b299

Browse files
nukeoptonur
authored andcommitted
fix(node): pnpm - support v10 offline tarballs
1 parent 37eb3e5 commit a99b299

4 files changed

Lines changed: 315 additions & 14 deletions

File tree

node/flatpak_node_generator/populate_pnpm_store.py

Lines changed: 45 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from __future__ import annotations
22

33
import base64
4+
import contextlib
45
import hashlib
56
import json
67
import os
@@ -37,6 +38,8 @@ def populate_store(manifest_path: str, tarball_dir: str, store_dir: str) -> None
3738
integrity_hex=info['integrity_hex'],
3839
store=store,
3940
now=now,
41+
tarball_url=info.get('tarball_url'),
42+
store_version=store_version,
4043
)
4144

4245

@@ -48,8 +51,12 @@ def _process_tarball(
4851
integrity_hex: str,
4952
store: str,
5053
now: int,
54+
tarball_url: str | None = None,
55+
store_version: str = 'v3',
5156
) -> None:
5257
index_files: dict[str, dict[str, object]] = {}
58+
real_pkg_name = pkg_name
59+
real_pkg_version = pkg_version
5360

5461
with tarfile.open(tarball_path, 'r:gz') as tf:
5562
for member in tf.getmembers():
@@ -60,6 +67,17 @@ def _process_tarball(
6067
continue
6168
data = fobj.read()
6269

70+
if member.name.endswith('package.json') and member.name.count('/') <= 1:
71+
with contextlib.suppress(ValueError, TypeError, UnicodeDecodeError):
72+
pkg_data = json.loads(data.decode('utf-8'))
73+
if isinstance(pkg_data, dict):
74+
if 'name' in pkg_data and isinstance(pkg_data['name'], str):
75+
real_pkg_name = pkg_data['name']
76+
if 'version' in pkg_data and isinstance(
77+
pkg_data['version'], str
78+
):
79+
real_pkg_version = pkg_data['version']
80+
6381
digest = hashlib.sha512(data).digest()
6482
file_hex = digest.hex()
6583
is_exec = bool(member.mode & 0o111)
@@ -86,20 +104,42 @@ def _process_tarball(
86104
'size': len(data),
87105
}
88106

107+
index_data = {
108+
'name': real_pkg_name,
109+
'version': real_pkg_version,
110+
'requiresBuild': False,
111+
'files': index_files,
112+
}
113+
89114
idx_prefix = integrity_hex[:2]
90115
idx_rest = integrity_hex[2:64]
91116
pkg_id = _SANITIZE_RE.sub('+', f'{pkg_name}@{pkg_version}')
92117
idx_dir = os.path.join(store, 'index', idx_prefix)
93118
os.makedirs(idx_dir, exist_ok=True)
94119
idx_path = os.path.join(idx_dir, f'{idx_rest}-{pkg_id}.json')
95-
index_data = {
96-
'name': pkg_name,
97-
'version': pkg_version,
98-
'files': index_files,
99-
}
100120
with open(idx_path, 'w', encoding='utf-8') as out:
101121
json.dump(index_data, out)
102122

123+
# For tarball-URL packages, also create an index entry keyed by the URL hash
124+
# this is how pnpm looks up tarball deps without integrity
125+
if tarball_url:
126+
if store_version == 'v3':
127+
url_hash = hashlib.sha256(tarball_url.encode()).hexdigest()
128+
url_idx_prefix = url_hash[:2]
129+
url_idx_rest = url_hash[2:64]
130+
url_idx_dir = os.path.join(store, 'index', url_idx_prefix)
131+
os.makedirs(url_idx_dir, exist_ok=True)
132+
url_idx_path = os.path.join(url_idx_dir, f'{url_idx_rest}-{pkg_id}.json')
133+
with open(url_idx_path, 'w', encoding='utf-8') as out:
134+
json.dump(index_data, out)
135+
else:
136+
url_dir_name = re.sub(r'[:/]', '+', tarball_url)
137+
url_idx_dir = os.path.join(store, url_dir_name)
138+
os.makedirs(url_idx_dir, exist_ok=True)
139+
url_idx_path = os.path.join(url_idx_dir, 'integrity.json')
140+
with open(url_idx_path, 'w', encoding='utf-8') as out:
141+
json.dump(index_data, out)
142+
103143

104144
if __name__ == '__main__':
105145
if len(sys.argv) != 4:

node/flatpak_node_generator/providers/pnpm.py

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -189,27 +189,27 @@ async def generate_package(self, package: Package) -> None:
189189
if isinstance(source, ResolvedSource):
190190
assert source.resolved is not None
191191

192-
if source.integrity is None:
192+
integrity = source.integrity
193+
if integrity is None:
193194
print(
194-
f'WARNING: skipping {package.name}@{package.version}: '
195-
'no integrity in lockfile (required for pnpm store)',
195+
f'INFO: {package.name}@{package.version}: '
196+
'no integrity in lockfile, fetching to compute...',
196197
file=sys.stderr,
197198
)
198-
return
199+
integrity = await source.retrieve_integrity()
199200

200-
# Use name-version as filename; replace / in scoped names
201201
tarball_name = f'{package.name.replace("/", "__")}-{package.version}.tgz'
202202
self.gen.add_url_source(
203203
url=source.resolved,
204-
integrity=source.integrity,
204+
integrity=integrity,
205205
destination=self.tarball_dir / tarball_name,
206206
)
207207
self._tarballs.append(
208208
self._TarballInfo(
209209
tarball_name=tarball_name,
210210
name=package.name,
211211
version=package.version,
212-
integrity=source.integrity,
212+
integrity=integrity,
213213
)
214214
)
215215

@@ -236,11 +236,14 @@ def _finalize(self) -> None:
236236
def _add_store_population_script(self) -> None:
237237
packages = {}
238238
for info in self._tarballs:
239-
packages[info.tarball_name] = {
239+
entry: dict[str, str] = {
240240
'name': info.name,
241241
'version': info.version,
242242
'integrity_hex': info.integrity.digest,
243243
}
244+
if info.version.startswith(('http://', 'https://')):
245+
entry['tarball_url'] = info.version
246+
packages[info.tarball_name] = entry
244247

245248
manifest = {
246249
'store_version': self._store_version,

node/tests/test_pnpm.py

Lines changed: 129 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,24 @@
1+
import hashlib
2+
import json
13
from pathlib import Path
24

35
import pytest
6+
from conftest import RequestsController
47

58
from flatpak_node_generator.integrity import Integrity
9+
from flatpak_node_generator.manifest import ManifestGenerator
610
from flatpak_node_generator.package import (
711
GitSource,
812
LocalSource,
913
Lockfile,
1014
Package,
1115
ResolvedSource,
1216
)
13-
from flatpak_node_generator.providers.pnpm import PnpmLockfileProvider
17+
from flatpak_node_generator.providers.pnpm import (
18+
PnpmLockfileProvider,
19+
PnpmModuleProvider,
20+
)
21+
from flatpak_node_generator.providers.special import SpecialSourceProvider
1422

1523
TEST_LOCKFILE_V9 = """
1624
lockfileVersion: '9.0'
@@ -325,3 +333,123 @@ def test_lockfile_v9_git_and_local(tmp_path: Path) -> None:
325333
source=LocalSource(path='../local-pkg'),
326334
),
327335
]
336+
337+
338+
def test_pnpm_module_provider_tarball_url(tmp_path: Path) -> None:
339+
gen = ManifestGenerator()
340+
special = SpecialSourceProvider(
341+
gen,
342+
SpecialSourceProvider.Options(
343+
node_chromedriver_from_electron=None,
344+
electron_ffmpeg=None,
345+
electron_node_headers=False,
346+
nwjs_version=None,
347+
nwjs_node_headers=False,
348+
nwjs_ffmpeg=False,
349+
xdg_layout=True,
350+
node_sdk_extension=None,
351+
),
352+
)
353+
provider = PnpmModuleProvider(gen, special, tmp_path)
354+
355+
provider._store_version = 'v3'
356+
provider._tarballs = [
357+
PnpmModuleProvider._TarballInfo(
358+
tarball_name='normal-pkg-1.0.0.tgz',
359+
name='normal-pkg',
360+
version='1.0.0',
361+
integrity=Integrity('sha512', 'abc123def456'),
362+
),
363+
PnpmModuleProvider._TarballInfo(
364+
tarball_name='url-pkg-http-123.tgz',
365+
name='url-pkg',
366+
version='http://example.com/url-pkg.tgz',
367+
integrity=Integrity('sha512', 'fedcba654'),
368+
),
369+
PnpmModuleProvider._TarballInfo(
370+
tarball_name='url-pkg-https-123.tgz',
371+
name='url-pkg-2',
372+
version='https://example.com/url-pkg-2.tgz',
373+
integrity=Integrity('sha512', '99999999'),
374+
),
375+
]
376+
377+
provider._add_store_population_script()
378+
379+
# Manifest data source should have been added to gen._sources
380+
manifest_source_dict = next(
381+
dict(s)
382+
for s in gen._sources
383+
if dict(s).get('dest-filename') == 'pnpm-manifest.json'
384+
)
385+
assert manifest_source_dict is not None
386+
387+
manifest_data = json.loads(manifest_source_dict['contents'])
388+
packages = manifest_data['packages']
389+
390+
# Check each package based on original tarballs for correct handling
391+
for tarball in provider._tarballs:
392+
pkg = packages[tarball.tarball_name]
393+
assert pkg['version'] == tarball.version
394+
if tarball.version.startswith(('http://', 'https://')):
395+
assert pkg['tarball_url'] == tarball.version
396+
else:
397+
assert 'tarball_url' not in pkg
398+
399+
400+
@pytest.mark.asyncio
401+
async def test_pnpm_module_provider_missing_integrity(
402+
tmp_path: Path, requests: RequestsController
403+
) -> None:
404+
405+
gen = ManifestGenerator()
406+
special = SpecialSourceProvider(
407+
gen,
408+
SpecialSourceProvider.Options(
409+
node_chromedriver_from_electron=None,
410+
electron_ffmpeg=None,
411+
electron_node_headers=False,
412+
nwjs_version=None,
413+
nwjs_node_headers=False,
414+
nwjs_ffmpeg=False,
415+
xdg_layout=True,
416+
node_sdk_extension=None,
417+
),
418+
)
419+
420+
provider = PnpmModuleProvider(gen, special, tmp_path)
421+
provider._store_version = 'v3'
422+
423+
lockfile = Lockfile(tmp_path / 'pnpm-lock.yaml', 9)
424+
425+
test_data = b'dummy tarball content'
426+
test_digest = hashlib.sha256(test_data).hexdigest()
427+
expected_integrity = Integrity('sha256', test_digest)
428+
429+
requests.server.expect_oneshot_request(
430+
'/test-pkg-1.0.0.tgz', 'GET'
431+
).respond_with_data(test_data)
432+
433+
source = ResolvedSource(
434+
resolved=requests.url_for('/test-pkg-1.0.0.tgz'),
435+
integrity=None,
436+
)
437+
438+
pkg = Package(
439+
lockfile=lockfile,
440+
name='test-pkg',
441+
version='1.0.0',
442+
source=source,
443+
)
444+
445+
await provider.generate_package(pkg)
446+
447+
# Assert tarball was added with computed integrity
448+
assert len(provider._tarballs) == 1
449+
assert provider._tarballs[0].integrity == expected_integrity
450+
451+
# Assert it was added to manifest generator with the right integrity
452+
tarball_source = next(
453+
dict(s) for s in gen._sources if dict(s).get('url') == source.resolved
454+
)
455+
assert tarball_source['sha256'] == expected_integrity.digest

0 commit comments

Comments
 (0)