diff mbox series

[RFC,10/21] fetch: npm: rework

Message ID 20241220112613.22647-11-stefan.herbrechtsmeier-oss@weidmueller.com
State New
Headers show
Series Concept for tightly coupled package manager (Node.js, Go, Rust) | expand

Commit Message

Stefan Herbrechtsmeier Dec. 20, 2024, 11:26 a.m. UTC
From: Stefan Herbrechtsmeier <stefan.herbrechtsmeier@weidmueller.com>

Rework the npm class regarding testability and integrity:
* Remove dependency to npm binary.
* Construct URL via a fix style and don’t resolve the URL via package
  registry.
* Use the checksum from the recipe or URI and don’t depend on the
  checksum from the package registry.
* Add common name and version schema.
* Mark unused NpmEnvironment and npm_unpack function as deprecated.
* Use Wget class as base and remove foreign done stamp handling.
* Add support to compute the latest release version.
* Remove support for latest version because it requires a package
  registry and should be rarely used.

Signed-off-by: Stefan Herbrechtsmeier <stefan.herbrechtsmeier@weidmueller.com>
---

 lib/bb/fetch2/npm.py | 244 +++++++++++--------------------------------
 1 file changed, 63 insertions(+), 181 deletions(-)
diff mbox series

Patch

diff --git a/lib/bb/fetch2/npm.py b/lib/bb/fetch2/npm.py
index ac76d64cd..120dddbfd 100644
--- a/lib/bb/fetch2/npm.py
+++ b/lib/bb/fetch2/npm.py
@@ -10,20 +10,19 @@  SRC_URI = "npm://some.registry.url;OptionA=xxx;OptionB=xxx;..."
 
 Supported SRC_URI options are:
 
-- package
+- dn
    The npm package name. This is a mandatory parameter.
 
-- version
+- dv
     The npm package version. This is a mandatory parameter.
 
 - downloadfilename
     Specifies the filename used when storing the downloaded file.
 
 - destsuffix
-    Specifies the directory to use to unpack the package (default: npm).
+    The name of the path in which to place the package (default: npm).
 """
 
-import base64
 import json
 import os
 import re
@@ -40,6 +39,8 @@  from bb.fetch2 import check_network_access
 from bb.fetch2 import runfetchcmd
 from bb.utils import is_semver
 
+from bb.fetch2.wget import Wget
+
 def npm_package(package):
     """Convert the npm package name to remove unsupported character"""
     # For scoped package names ('@user/package') the '/' is replaced by a '-'.
@@ -64,14 +65,7 @@  def npm_localfile(package, version=None):
         filename = package
     return os.path.join("npm2", filename)
 
-def npm_integrity(integrity):
-    """
-    Get the checksum name and expected value from the subresource integrity
-        https://www.w3.org/TR/SRI/
-    """
-    algo, value = integrity.split("-", maxsplit=1)
-    return "%ssum" % algo, base64.b64decode(value).hex()
-
+# Deprecated
 def npm_unpack(tarball, destdir, d):
     """Unpack a npm tarball"""
     bb.utils.mkdirhier(destdir)
@@ -80,8 +74,8 @@  def npm_unpack(tarball, destdir, d):
     cmd += " --delay-directory-restore"
     cmd += " --strip-components=1"
     runfetchcmd(cmd, d, workdir=destdir)
-    runfetchcmd("chmod -R +X '%s'" % (destdir), d, quiet=True, workdir=destdir)
 
+# Deprecated
 class NpmEnvironment(object):
     """
     Using a npm config file seems more reliable than using cli arguments.
@@ -130,7 +124,15 @@  class NpmEnvironment(object):
 
             return _run(cmd)
 
-class Npm(FetchMethod):
+
+def construct_url_path(name, version):
+    return f"/{name}/-/{name.split('/')[-1]}-{version}.tgz"
+
+def construct_url(registry, name, version):
+    path = construct_url_path(name, version)
+    return f"https://{registry}{path}"
+
+class Npm(Wget):
     """Class to fetch a package from a npm registry"""
 
     def supports(self, ud, d):
@@ -139,178 +141,58 @@  class Npm(FetchMethod):
 
     def urldata_init(self, ud, d):
         """Init npm specific variables within url data"""
-        ud.package = None
-        ud.version = None
-        ud.registry = None
 
-        # Get the 'package' parameter
         if "package" in ud.parm:
-            ud.package = ud.parm.get("package")
+            bb.warn(f"Parameter 'package' in '{ud.url}' is deprecated."
+                    "Please use 'dn' parameter instead.")
+            ud.parm["dn"] = ud.parm["package"]
+            del ud.parm["package"]
+        if "version" in ud.parm:
+            bb.warn(f"Parameter 'version' in '{ud.url}' is deprecated."
+                    "Please use 'dv' parameter instead.")
+            ud.parm["dv"] = ud.parm["version"]
+            del ud.parm["version"]
+
+        if any(x not in ud.parm for x in ["dn", "dv"]):
+            return
+
+        registry = ud.host
+        if ud.path != '/':
+            registry += ud.path
+        name = ud.parm["dn"]
+        version = ud.parm["dv"]
+
+        if not is_semver(version):
+            if version == "latest":
+                raise ParameterError("Value 'latest' for parameter 'version' is no longer supported", ud.url)
+            else:
+                raise ParameterError("Invalid 'version' parameter", ud.url)
 
-        if not ud.package:
-            raise MissingParameterError("Parameter 'package' required", ud.url)
+        ud.url = construct_url(registry, name, version)
+        ud.info_url = f"https://{registry}/{name}"
 
-        # Get the 'version' parameter
-        if "version" in ud.parm:
-            ud.version = ud.parm.get("version")
+        if not "downloadfilename" in ud.parm:
+            ud.parm['downloadfilename'] = npm_localfile(name, version)
 
-        if not ud.version:
-            raise MissingParameterError("Parameter 'version' required", ud.url)
+        destsuffix = ud.parm.get("destsuffix", "npm")
+        subdir = ud.parm.get("subdir", "")
+        ud.parm["destsuffix"] = destsuffix
+        ud.parm["subdir"] = os.path.join(subdir, destsuffix)
 
-        if not is_semver(ud.version) and not ud.version == "latest":
-            raise ParameterError("Invalid 'version' parameter", ud.url)
+        if 'name' not in ud.parm:
+            ud.parm["name"] = f"{npm_package(name)}-{version}"
 
-        # Extract the 'registry' part of the url
-        ud.registry = re.sub(r"^npm://", "https://", ud.url.split(";")[0])
+        ud.parm["striplevel"] = 1
 
-        # Using the 'downloadfilename' parameter as local filename
-        # or the npm package name.
-        if "downloadfilename" in ud.parm:
-            ud.localfile = npm_localfile(d.expand(ud.parm["downloadfilename"]))
-        else:
-            ud.localfile = npm_localfile(ud.package, ud.version)
-
-        # Get the base 'npm' command
-        ud.basecmd = d.getVar("FETCHCMD_npm") or "npm"
-
-        # This fetcher resolves a URI from a npm package name and version and
-        # then forwards it to a proxy fetcher. A resolve file containing the
-        # resolved URI is created to avoid unwanted network access (if the file
-        # already exists). The management of the donestamp file, the lockfile
-        # and the checksums are forwarded to the proxy fetcher.
-        ud.proxy = None
-        ud.needdonestamp = False
-        ud.resolvefile = self.localpath(ud, d) + ".resolved"
-
-    def _resolve_proxy_url(self, ud, d):
-        def _npm_view():
-            args = []
-            args.append(("json", "true"))
-            args.append(("registry", ud.registry))
-            pkgver = shlex.quote(ud.package + "@" + ud.version)
-            cmd = ud.basecmd + " view %s" % pkgver
-            env = NpmEnvironment(d)
-            check_network_access(d, cmd, ud.registry)
-            view_string = env.run(cmd, args=args)
-
-            if not view_string:
-                raise FetchError("Unavailable package %s" % pkgver, ud.url)
-
-            try:
-                view = json.loads(view_string)
-
-                error = view.get("error")
-                if error is not None:
-                    raise FetchError(error.get("summary"), ud.url)
-
-                if ud.version == "latest":
-                    bb.warn("The npm package %s is using the latest " \
-                            "version available. This could lead to " \
-                            "non-reproducible builds." % pkgver)
-                elif ud.version != view.get("version"):
-                    raise ParameterError("Invalid 'version' parameter", ud.url)
-
-                return view
-
-            except Exception as e:
-                raise FetchError("Invalid view from npm: %s" % str(e), ud.url)
-
-        def _get_url(view):
-            tarball_url = view.get("dist", {}).get("tarball")
-
-            if tarball_url is None:
-                raise FetchError("Invalid 'dist.tarball' in view", ud.url)
-
-            uri = URI(tarball_url)
-            uri.params["downloadfilename"] = ud.localfile
-
-            integrity = view.get("dist", {}).get("integrity")
-            shasum = view.get("dist", {}).get("shasum")
-
-            if integrity is not None:
-                checksum_name, checksum_expected = npm_integrity(integrity)
-                uri.params[checksum_name] = checksum_expected
-            elif shasum is not None:
-                uri.params["sha1sum"] = shasum
-            else:
-                raise FetchError("Invalid 'dist.integrity' in view", ud.url)
-
-            return str(uri)
-
-        url = _get_url(_npm_view())
-
-        bb.utils.mkdirhier(os.path.dirname(ud.resolvefile))
-        with open(ud.resolvefile, "w") as f:
-            f.write(url)
-
-    def _setup_proxy(self, ud, d):
-        if ud.proxy is None:
-            if not os.path.exists(ud.resolvefile):
-                self._resolve_proxy_url(ud, d)
-
-            with open(ud.resolvefile, "r") as f:
-                url = f.read()
-
-            # Avoid conflicts between the environment data and:
-            # - the proxy url checksum
-            data = bb.data.createCopy(d)
-            data.delVarFlags("SRC_URI")
-            ud.proxy = Fetch([url], data)
-
-    def _get_proxy_method(self, ud, d):
-        self._setup_proxy(ud, d)
-        proxy_url = ud.proxy.urls[0]
-        proxy_ud = ud.proxy.ud[proxy_url]
-        proxy_d = ud.proxy.d
-        proxy_ud.setup_localpath(proxy_d)
-        return proxy_ud.method, proxy_ud, proxy_d
-
-    def verify_donestamp(self, ud, d):
-        """Verify the donestamp file"""
-        proxy_m, proxy_ud, proxy_d = self._get_proxy_method(ud, d)
-        return proxy_m.verify_donestamp(proxy_ud, proxy_d)
-
-    def update_donestamp(self, ud, d):
-        """Update the donestamp file"""
-        proxy_m, proxy_ud, proxy_d = self._get_proxy_method(ud, d)
-        proxy_m.update_donestamp(proxy_ud, proxy_d)
-
-    def need_update(self, ud, d):
-        """Force a fetch, even if localpath exists ?"""
-        if not os.path.exists(ud.resolvefile):
-            return True
-        if ud.version == "latest":
-            return True
-        proxy_m, proxy_ud, proxy_d = self._get_proxy_method(ud, d)
-        return proxy_m.need_update(proxy_ud, proxy_d)
-
-    def try_mirrors(self, fetch, ud, d, mirrors):
-        """Try to use a mirror"""
-        proxy_m, proxy_ud, proxy_d = self._get_proxy_method(ud, d)
-        return proxy_m.try_mirrors(fetch, proxy_ud, proxy_d, mirrors)
-
-    def download(self, ud, d):
-        """Fetch url"""
-        self._setup_proxy(ud, d)
-        ud.proxy.download()
-
-    def unpack(self, ud, rootdir, d):
-        """Unpack the downloaded archive"""
-        destsuffix = ud.parm.get("destsuffix", "npm")
-        destdir = os.path.join(rootdir, destsuffix)
-        npm_unpack(ud.localpath, destdir, d)
-        ud.unpack_tracer.unpack("npm", destdir)
-
-    def clean(self, ud, d):
-        """Clean any existing full or partial download"""
-        if os.path.exists(ud.resolvefile):
-            self._setup_proxy(ud, d)
-            ud.proxy.clean()
-            bb.utils.remove(ud.resolvefile)
-
-    def done(self, ud, d):
-        """Is the download done ?"""
-        if not os.path.exists(ud.resolvefile):
-            return False
-        proxy_m, proxy_ud, proxy_d = self._get_proxy_method(ud, d)
-        return proxy_m.done(proxy_ud, proxy_d)
+        super().urldata_init(ud, d)
+
+    def latest_versionstring(self, ud, d):
+        from functools import cmp_to_key
+        info = json.loads(self._fetch_index(ud.info_url, ud, d))
+        versions = [(0, v, "") for v in info["versions"]]
+        versions = sorted(versions, key=cmp_to_key(bb.utils.vercmp))
+
+        return (versions[-1][1], "")
+
+    def require_download_metadata(self):
+        return True