Script 'mail_helper' called by obssrc Hello community, here is the log from the commit of package git-bug for openSUSE:Factory checked in at 2026-10-03 20:13:37 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Comparing /work/SRC/openSUSE:Factory/git-bug (Old) and /work/SRC/openSUSE:Factory/.git-bug.new.1631729 (New) ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Package is "git-bug" Sat Oct 3 20:13:37 2026 rev:18 rq:1382186 version:0.11.0 Changes: -------- --- /work/SRC/openSUSE:Factory/git-bug/git-bug.changes 2026-09-08 17:15:01.710392118 +0200 +++ /work/SRC/openSUSE:Factory/.git-bug.new.1631729/git-bug.changes 2026-10-03 20:14:39.441425609 +0200 @@ -1,0 +2,57 @@ +Thu Sep 24 12:00:23 UTC 2026 - Matej Cepl <[email protected]> + +- Enable the Web UI RPM build by default on x86_64, retaining + an explicit CLI-only fallback and CLI builds elsewhere. + - Add the manual node_modules service and its legacy source + container and spec include. + - Install locked frontend dependencies through the local + registry, use Factory esbuild, and embed the Vite output + with the webui Go tag. +- Require Go 1.26 and disable Go toolchain/module downloads. +- Replace ignored test failures with offline handler and HTTP + smoke checks. +- Install available third-party license notices and declare + a conservative frontend License expression. +- Validate both RPM variants in local network-isolated builds. + Native-tool source policy, dependency pruning, missing upstream + notices, and clean OBS validation remain outstanding. + +------------------------------------------------------------------- +Thu Sep 24 10:59:34 UTC 2026 - Matej Cepl <[email protected]> + +- Add preparatory Web UI source tooling: convert the pnpm lock + into a verified obs-service-node_modules inventory, generate + a separate npm installation lock in a network-isolated + environment, and add unit and HTTP smoke tests. Document the + successful x86-64 baseline using Factory esbuild. Web UI + enablement in the spec remains pending native-tool source + builds, dependency pruning, license review, and OBS + integration. + +------------------------------------------------------------------- +Thu Sep 24 08:07:36 UTC 2026 - Matej Cepl <[email protected]> + +- Update to 0.11.0: + Sixteen months and close to 300 commits after v0.10.1, git-bug + v0.11.0 is available. This release is about two things. The + obvious one is a new web UI, rewritten from scratch and far + more capable than the one it replaces. The less obvious one is + a deep pass on correctness, which closes a handful of long + standing bugs. + - A new and more powerful web UI + - A Code Browser + - Correctness + - Forward compatibility + - A specification + - Breaking changes + - Web UI + - Core + - CLI + - Terminal UI + - API + - Bridges + - Documentation +- Remove upstreamed patches: + - remote-config.patch + +------------------------------------------------------------------- Old: ---- git-bug-0.10.1.tar.gz remote-config.patch New: ---- WEBUI-PACKAGING.md collect-webui-licenses.py git-bug-0.11.0.tar.gz node-sources.json node_modules.obscpio node_modules.spec.inc package-lock.json pnpm-to-obs.py prepare-webui-lock.py test-webui.py test_collect_webui_licenses.py test_pnpm_to_obs.py test_prepare_webui_lock.py webui-lock-report.json webui-package.json ----------(Old B)---------- Old:- Remove upstreamed patches: - remote-config.patch ----------(Old E)---------- ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Other differences: ------------------ ++++++ git-bug.spec ++++++ --- /var/tmp/diff_new_pack.Plrrgj/_old 2026-10-03 20:15:35.002749966 +0200 +++ /var/tmp/diff_new_pack.Plrrgj/_new 2026-10-03 20:15:35.004750049 +0200 @@ -16,25 +16,56 @@ # +# The npm native build tools have only been validated on x86_64. Keep the +# CLI available elsewhere; enable other architectures after testing them. +%ifarch x86_64 +%bcond_without webui +%else +%bcond_with webui +%endif + Name: git-bug -Version: 0.10.1 +Version: 0.11.0 Release: 0 Summary: Distributed, offline-first bug tracker embedded in git, with bridges +%if %{with webui} +# Conservative union of the frontend production dependency licenses. The +# notices collector includes dependencies even when Vite tree-shakes them. +License: 0BSD AND Apache-2.0 AND BSD-2-Clause AND BSD-3-Clause AND BlueOak-1.0.0 AND CC-BY-4.0 AND ISC AND MIT AND OFL-1.1 AND Python-2.0 AND Unlicense +%else License: MIT +%endif URL: https://github.com/MichaelMure/git-bug Source0: https://github.com/MichaelMure/%{name}/archive/refs/tags/v%{version}.tar.gz#/git-bug-%{version}.tar.gz # Source0: git-bug-%%{version}.tar.gz Source1: vendor.tar.gz -# PATCH-FIX-UPSTREAM remote-config.patch gh#MichaelMure/git-bug!1076 [email protected] -# try reading git-bug.remote config value before defaulting to 'origin' when no explicit REMOTE argument -Patch0: remote-config.patch -BuildRequires: golang(API) = 1.24 -# # PATCH-FEATURE-UPSTREAM 501-export.patch gh#MichaelMure/git-bug!501 [email protected] -# # add a command to export bugs as raw operations -# Patch0: 501-export.patch -BuildRequires: golang-packaging +Source2: package-lock.json +Source3: webui-package.json +Source4: node_modules.spec.inc +Source5: test-webui.py +Source6: node-sources.json +Source7: pnpm-to-obs.py +Source8: prepare-webui-lock.py +Source9: test_pnpm_to_obs.py +Source10: test_prepare_webui_lock.py +Source11: webui-lock-report.json +Source12: WEBUI-PACKAGING.md +Source13: collect-webui-licenses.py +Source14: test_collect_webui_licenses.py BuildRequires: git -BuildRequires: golang(API) >= 1.25 +BuildRequires: golang(API) >= 1.26 +BuildRequires: golang-packaging +%if %{with webui} +# OBS/osc unpacks node_modules.obscpio into the source directory. This include +# declares its npm tarballs as individual sources for the source RPM. +%include %{_sourcedir}/node_modules.spec.inc +# The npm esbuild wrapper requires an executable with the identical version. +BuildRequires: esbuild = 0.28.2 +BuildRequires: local-npm-registry >= 1.1.0 +BuildRequires: nodejs24 +BuildRequires: npm24 +BuildRequires: python3-base +%endif %description git-bug is a bug tracker that: @@ -90,14 +121,39 @@ %prep %autosetup -p1 -a1 +%if %{with webui} +# Use the npm-generated installation tree, not the service download inventory. +cp %{SOURCE2} webui/package-lock.json +cp %{SOURCE3} webui/package.json +%endif %build -# COMMANDS_PATH="github.com/git-bug/git-bug/commands" -# LDFLAGS="-X ${COMMANDS_PATH}.GitCommit=${GIT_COMMIT} \ -# -X ${COMMANDS_PATH}.GitLastTag=${GIT_LAST_TAG} \ -# -X ${COMMANDS_PATH}.GitExactTag=${GIT_EXACT_TAG}" -export GOFLAGS="-buildmode=pie" +export GOTOOLCHAIN=local +export GOPROXY=off +export GOSUMDB=off +export GOFLAGS="-mod=vendor -buildmode=pie" +%if %{with webui} +# Keep npm configuration and caches inside the build tree. All dependencies +# come from source tarballs served over loopback; disable install scripts. +export npm_config_cache="$PWD/.npm-cache" +export npm_config_userconfig="$PWD/.npmrc" +export npm_config_globalconfig="$PWD/.npmrc-global" +export npm_config_update_notifier=false +export NODE_OPTIONS="--max-old-space-size=1536" +export ESBUILD_BINARY_PATH="$(command -v esbuild)" +test -x "$ESBUILD_BINARY_PATH" +pushd webui +local-npm-registry %{_sourcedir} ci \ + --include=dev --ignore-scripts --legacy-peer-deps \ + --no-audit --no-fund --maxsockets=2 +npm run build +test -s dist/index.html.gz +popd +python3 %{SOURCE13} webui webui-licenses +go build -tags webui +%else go build +%endif %install install -Dm755 git-bug %{buildroot}%{_bindir}/git-bug @@ -112,11 +168,24 @@ %{buildroot}%{_sysconfdir}/zsh_completion.d/git-bug %check -# before we mark network requiring tests (gh#git-bug/git-bug#1313) -go test -v -bench=. ./... || true +export GOTOOLCHAIN=local +export GOPROXY=off +export GOSUMDB=off +export GOFLAGS="-mod=vendor -buildmode=pie" +# The full suite includes network-dependent bridge tests (gh#git-bug/git-bug#1313). +# Run the offline Web UI handler tests without suppressing failures. +%if %{with webui} +go test -v -tags webui ./webui +python3 %{SOURCE5} ./git-bug +%else +go test -v ./webui +%endif %files %license LICENSE +%if %{with webui} +%license webui-licenses +%endif %doc README.md %{_bindir}/git-bug %{_mandir}/man1/git*.1%{?ext_man} ++++++ WEBUI-PACKAGING.md ++++++ # Building the git-bug Web UI for openSUSE ## Status The spec now builds and embeds the Web UI by default on x86-64. Other architectures retain the CLI-only build until their native frontend tools are validated. Use `--without webui` for the CLI-only variant or `--with webui` to explicitly test the frontend on another architecture. `_service` generates `node_modules.obscpio` and `node_modules.spec.inc` for the traditional OSC workflow. Both the Web UI-enabled and CLI-only RPM builds have passed locally with external networking disabled. This proof used upstream npm native binaries for Rolldown, Oxc, Tailwind, and Lightning CSS. Only esbuild was replaced with the matching Factory executable. A successful baseline is not proof that the package meets source-build policy or supports every openSUSE architecture. | Plan step | Status | |---|---| | 1. Separate downloading and npm resolution | Complete | | 2. Implement converter | Complete | | 3. Use service's multi-lock input | Complete and tested with installed service | | 4. Test conversion | Complete; 27 unit tests across preparation and notice collection | | 5. Generate genuine npm lock | Generated; provenance checked and fresh-cache installation passed. Exact peer-graph equivalence is not claimed. | | 6. Integrate service/submission workflow | Implemented with a manual service, legacy cpio container, and generated source include | | 7. Factory tools and native dependencies | Factory esbuild validated; remaining native tooling unresolved | | 8. Reduce dependencies | Pending; baseline established | | 9. Update spec and source patches | Spec integration complete; no upstream source patch required | | 10. Offline checks and packaging review | Both local RPM variants passed; packaged Web UI binary passed smoke tests. Clean OBS build and full license review pending | | 11. Maintenance documentation | Service regeneration, RPM build, and fallback documented | ## Two different artifacts - `node-sources.json` is a generated download inventory for `obs-service-node_modules`. It describes tarball URLs and checksums, not an installation layout. Never pass it to npm as `package-lock.json`. - `package-lock.json` must be produced and validated by npm. It records the actual installation tree and peer resolution. It is not generated by the converter. The upstream `webui/pnpm-lock.yaml` remains the authority for the initial source inventory. Generating an npm lock must not silently fetch newer versions from an external registry. ## Converter Requirements: Python 3.8 or newer and PyYAML, available in openSUSE as the appropriate `python3*-PyYAML` package for the interpreter. Tests use the standard library. The optional installed-service integration test also uses that service's own dependencies. Run from the packaging directory, with the release unpacked separately: ```sh python3 pnpm-to-obs.py \ --input /path/to/git-bug-0.11.0/webui/pnpm-lock.yaml \ --output node-sources.json PYTHONDONTWRITEBYTECODE=1 python3 -m unittest -v \ test_pnpm_to_obs.py test_prepare_webui_lock.py test_collect_webui_licenses.py ``` The converter supports pnpm lockfile version 9. It keeps all package versions and platform variants, including optional native bindings. It preserves explicit HTTPS tarball URLs and derives standard npm registry URLs for integrity-only registry records. It does not read npm registry configuration or contact the network. The output uses the installed service's support for a mapping of npm-v3-shaped objects. Each object contains one package identity. Peer-specific records with the same source collapse into one download. Different versions remain distinct. The converter rejects missing or malformed hashes, unsupported lock versions, local/workspace/Git resolutions, patched dependencies, duplicate YAML keys, conflicting source records, and service tarball filename collisions. Unsupported inputs need explicit implementation, not an automatic fallback to current npm registry contents. ### Download and verify For a standalone download cache, the service can consume the inventory directly: ```sh /usr/lib/obs/service/node_modules \ --input node-sources.json \ --node-dir /path/to/downloaded-tarballs \ --download python3 pnpm-to-obs.py \ --input /path/to/git-bug-0.11.0/webui/pnpm-lock.yaml \ --output node-sources.json \ --verify-dir /path/to/downloaded-tarballs ``` Verification is required. The installed service can log a download error without returning a failing exit status. The converter's verification checks that every expected tarball exists and matches its recorded integrity hashes. These downloads are source preparation. They must not happen during rpmbuild. The configured manual service entry is: ```xml <service name="node_modules" mode="manual"> <param name="input">node-sources.json</param> <param name="legacy-container">true</param> <param name="cpio">node_modules.obscpio</param> <param name="output">node_modules.spec.inc</param> <param name="source-offset">10000</param> </service> ``` After regenerating `node-sources.json`, update the submitted sources with: ```sh osc service manualrun node_modules ``` Submit both `node_modules.obscpio` and `node_modules.spec.inc` along with the manifest, npm lock, scripts, spec, and service definition. The cpio container currently occupies about 809 MiB. `.gitignore` explicitly allows this file; the existing Git LFS attributes cover it. OBS/osc expands the container into individual source tarballs before RPM runs. The generated include declares them as sources numbered from 10000. The RPM build uses these regular files from the source directory. Do not replace them with symlinks in a local test: the registry helper skips symlink directory entries. For a plain `rpmbuild` invocation outside OSC, first extract the container in your RPM source directory: ```sh cd /path/to/rpm/SOURCES cpio -idm < /path/to/package/node_modules.obscpio ``` Use the extracted directory as `--verify-dir` for the converter and as `--tarballs` for the npm-lock preparation tool. Generating the installation lock is a maintainer operation; the spec only consumes the submitted lock. ## Generate the genuine npm lock `prepare-webui-lock.py` automates this stage. It uses an empty temporary cache, pins direct dependencies to their upstream importer versions, translates the reviewed pnpm override to npm, and starts `local-npm-registry` with the verified tarballs. It disables scripts and auditing and runs in a new Linux user/network namespace with only loopback enabled. It requires `unshare` and permission to create unprivileged user namespaces. ```sh python3 prepare-webui-lock.py \ --webui /path/to/git-bug-0.11.0/webui \ --tarballs /path/to/downloaded-tarballs \ --output-dir . ``` The outputs are: - `webui-package.json`, the npm-compatible manifest with pinned direct versions. - `package-lock.json`, a genuine npm-generated installation lock. - `webui-lock-report.json`, the selected source count and unused source identities. The helper verifies the complete tarball inventory before starting npm. It rejects selected sources or integrity hashes absent from the pnpm inventory. For dependencies bundled inside another npm tarball, it checks their manifests inside the already verified parent archive. The current tree has six such bundled entries, including versions not listed separately in the pnpm lock. The override translation is deliberately specific to this release. If upstream changes its overrides, the helper fails and requires review rather than guessing how pnpm-specific selectors map to npm. There is an important limitation in Factory's `local-npm-registry` 1.1.0: its normal wrapper appends `--no-package-lock` to the npm command. Do not assume that `local-npm-registry ... install --package-lock-only` will generate a lock. The preparation tool uses the helper's `--debug` server mode and invokes npm separately with its explicit localhost registry URL. It passes `--omit-lockfile-registry-resolved`, so npm writes a portable lock without temporary localhost URLs. The integrity hashes and installation tree remain locked. The normal helper successfully installs this lock with `ci`, despite appending `--no-package-lock`. Use `--legacy-peer-deps` consistently. Strict resolution repeatedly reported incompatible upstream TypeScript peer requirements and was stopped after three minutes. Legacy resolution completed in four seconds. This choice does not prove that npm reproduces pnpm's peer graph. Any generated lock must be checked for: - All selected package versions belonging to the verified source inventory. - Peer-resolution changes relative to pnpm snapshots. - Locally generated registry URLs that must not survive into a portable lock. - Successful installation with a new empty cache and a fresh local registry. - No omitted platform bindings needed on the intended build architectures. ### Validated baseline The full inventory contains 1,167 verified tarballs, about 811 MiB on disk. npm selected 1,041 distinct source identities for 1,089 installation entries, of which six are bundled entries. On x86-64, `ci` installed 907 packages. With the generated files copied into `webui/` as `package.json` and `package-lock.json`, the tested installation command was equivalent to: ```sh local-npm-registry /path/to/downloaded-tarballs ci \ --ignore-scripts --legacy-peer-deps --no-audit --no-fund --maxsockets=2 ``` The test used a fresh cache and a network namespace without external interfaces. The local registry helper was the upstream 1.1.0 release referenced by Factory's spec, unpacked in `/tmp`, rather than a system-installed RPM. The preparation tool accepts `--registry-helper node /absolute/path/to/dist/index.js` for this case. The final package should use Factory's `local-npm-registry` RPM. Factory's signed `esbuild-0.28.2-1.1.x86_64.rpm` was verified and unpacked in a temporary directory. `ESBUILD_BINARY_PATH` pointed at that executable during `npm run build`. Vite successfully transformed 3,546 modules and generated `webui/dist`, including compressed assets and Inter webfonts. The generated assets were embedded with Go 1.26.8 using the supplied vendor archive, `GOTOOLCHAIN=local`, `GOPROXY=off`, `GOSUMDB=off`, and `GOFLAGS='-mod=vendor -buildmode=pie'`. Both the build and `go test -tags webui ./webui` passed with external networking disabled. Run the standalone smoke test against a built executable: ```sh python3 test-webui.py /path/to/git-bug ``` It creates a temporary Git repository, starts a read-only server on localhost, and checks HTML, a client-side route, gzip handling, referenced JS/CSS assets, webfonts, and a GraphQL query. The baseline passed checks for 11 referenced assets and seven fonts. This is an HTTP smoke test, not a browser interaction test. A trial using `file:` tarball URLs instead of the local registry caused an OOM kill; a heap-limited retry did not complete within two minutes. That transport is not used by the generated lock or recommended build commands. ## RPM integration and validation The spec now: - Requires Go 1.26 or newer, matching `go.mod`, instead of the contradictory Go 1.24 and 1.25 requirements. - Uses Factory Node 24, npm 24, the local registry helper, Python for checks, and esbuild 0.28.2 only when Web UI support is enabled. - Copies the npm-compatible manifest and lock into `webui/`, installs through the loopback registry with lifecycle scripts disabled, and runs the Vite build. - Uses the vendored Go dependencies and disables Go toolchain/module downloads. - Embeds the generated assets with `go build -tags webui`. - Collects available production dependency notices and installs them as license files. The License expression conservatively covers the production dependency inventory, including dependencies that tree shaking may remove. - Runs handler tests and the HTTP smoke test without suppressing failures. The broader bridge suite remains excluded because it needs external services. `collect-webui-licenses.py` checks license identifiers against the spec's declared set and records the copied notices in `webui-licenses/index.json`. It reports missing upstream notice files rather than inventing copyright notices. The current archives for `@wry/context` and `@wry/trie` omit top-level license text; their metadata and READMEs are retained. These omissions still need review. The latest local validation ran the actual spec with `rpmbuild -bb --nodeps`, inside a network namespace with only loopback enabled, for both the default x86-64 variant and `--without webui`. Dependency checks were bypassed because the registry helper and signed Factory esbuild RPM were staged locally rather than installed system-wide. This is a full local RPM build, not a clean OBS buildroot validation. Both runs produced the main RPM and completion subpackages. The binary extracted from the Web UI RPM passed the HTTP smoke test after RPM's stripping and packaging steps. Its runtime requirements contain no Node.js, npm, or esbuild dependency. The builds emitted an empty debugsource file-list warning, which remains to be reviewed separately. The generated lock and successful RPM builds do not settle native source-build requirements, other architectures, or the complete frontend license audit. ## Remaining implementation stages 1. Review npm/pnpm graph differences beyond the verified source inventory; retain the explicit peer-resolution policy. 2. Prune development dependencies now that the full frontend baseline works, regenerate the npm lock, and reduce the service inventory to the required dependency closure without dropping necessary optional platform bindings. 3. Decide how to build native modules from source and which architectures can enable the UI. Do not treat npm binary tarballs as source-built tooling. 4. Complete the license/notice audit, including the two missing upstream license texts noted above. 5. Run a clean OBS build with dependency resolution enabled and review the debugsource warning. Use Quilt for upstream source patches, registering files before editing them. The packaging directory itself currently has no Quilt series. ++++++ _scmsync.obsinfo ++++++ --- /var/tmp/diff_new_pack.Plrrgj/_old 2026-10-03 20:15:35.086753478 +0200 +++ /var/tmp/diff_new_pack.Plrrgj/_new 2026-10-03 20:15:35.089753604 +0200 @@ -1,5 +1,5 @@ -mtime: 1788765301 -commit: d87dd61b4cbb641fc2c79eed6b4f75e6a70a0855e1aa8666c0e60cad7dfed8c6 +mtime: 1790364639 +commit: 4918e1e1bf7b46b2e3826b6bd4bafee6f8fca331c7aff42cd84b803466444c8d url: https://src.opensuse.org/mcepl/git-bug revision: factory ++++++ _service ++++++ --- /var/tmp/diff_new_pack.Plrrgj/_old 2026-10-03 20:15:35.113754607 +0200 +++ /var/tmp/diff_new_pack.Plrrgj/_new 2026-10-03 20:15:35.116754733 +0200 @@ -14,17 +14,15 @@ </service> <service name="set_version" mode="manual"/ --> <service name="go_modules" mode="manual"> - <param name="replace">golang.org/x/crypto=golang.org/x/[email protected]</param> - <param name="replace">golang.org/x/net=golang.org/x/[email protected]</param> - <param name="replace">golang.org/x/text=golang.org/x/[email protected]</param> - <param name="replace">golang.org/x/oauth=golang.org/x/[email protected]</param> - <param name="replace">github.com/go-viper/mapstructure/v2=github.com/go-viper/mapstructure/[email protected]</param> - <!-- param name="replace">github.com/blevesearch/bleve=github.com/blevesearch/[email protected]</param --> - <param name="replace">github.com/cloudflare/circl=github.com/cloudflare/[email protected]</param> - <param name="replace">github.com/go-git/go-git/v5=github.com/go-git/go-git/[email protected]</param> - <param name="replace">golang.org/x/crypto/ssh=golang.org/x/crypto/[email protected]</param> - <param name="replace">golang.org/x/crypto/ssh/agent=golang.org/x/crypto/ssh/[email protected]</param> - <param name="replace">github.com/go-git/go-billy=github.com/go-git/go-billy/[email protected]</param> + </service> + <!-- Traditional osc workflow: archive the npm sources and declare each + tarball in the generated spec include. No build-time downloads. --> + <service name="node_modules" mode="manual"> + <param name="input">node-sources.json</param> + <param name="legacy-container">true</param> + <param name="cpio">node_modules.obscpio</param> + <param name="output">node_modules.spec.inc</param> + <param name="source-offset">10000</param> </service> </services> ++++++ build.specials.obscpio ++++++ --- old/.gitignore 2026-09-07 09:15:01.000000000 +0200 +++ new/.gitignore 2026-09-25 21:30:39.000000000 +0200 @@ -2,6 +2,7 @@ build.specials.obscpio git-bug/ *.obscpio +!node_modules.obscpio *.osc .osc .pbuild ++++++ build.specials.obscpio ++++++ diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/.gitignore new/.gitignore --- old/.gitignore 1970-01-01 01:00:00.000000000 +0100 +++ new/.gitignore 2026-09-25 21:30:39.000000000 +0200 @@ -0,0 +1,11 @@ +_build.* +build.specials.obscpio +git-bug/ +*.obscpio +!node_modules.obscpio +*.osc +.osc +.pbuild +_scmsync.obsinfo +_service:* +git-bug-*-build/ ++++++ collect-webui-licenses.py ++++++ #!/usr/bin/env python3 # SPDX-License-Identifier: MIT """Collect available notices for installed non-dev npm dependencies. This deliberately includes more packages than Vite may retain after tree shaking. Missing upstream license files are reported, not invented. """ import argparse import json from pathlib import Path import shutil import sys def collect(webui, destination): lock = json.loads((webui / "package-lock.json").read_text()) index = [] # Keep this in sync with the conservative License expression in the spec. declared = {"0BSD", "Apache-2.0", "BSD-2-Clause", "BSD-3-Clause", "BlueOak-1.0.0", "CC-BY-4.0", "ISC", "MIT", "OFL-1.1", "Python-2.0", "Unlicense"} for path, package in sorted(lock["packages"].items()): if not path or package.get("dev"): continue relative = Path(path) if relative.is_absolute() or ".." in relative.parts or relative.parts[0] != "node_modules": raise ValueError(f"Invalid package path: {path}") source = webui / relative if not source.is_dir(): # Optional packages for other platforms are not installed. if package.get("optional"): continue raise ValueError(f"Missing installed package: {path}") if package.get("license") not in declared: raise ValueError(f"Review the spec License expression for {path}: {package.get('license')}") target = destination / relative target.mkdir(parents=True, exist_ok=True) shutil.copyfile(source / "package.json", target / "package-metadata.json") notices = [] for item in sorted(source.iterdir()): if item.is_file() and item.name.lower().startswith( ("license", "licence", "copying", "notice", "copyright", "ofl")): shutil.copyfile(item, target / item.name) notices.append(item.name) if not notices: print(f"warning: no top-level license text supplied by {path}", file=sys.stderr) for item in source.glob("README*"): if item.is_file(): shutil.copyfile(item, target / item.name) index.append({"path": path, "version": package["version"], "license": package.get("license", "UNKNOWN"), "notices": notices}) (destination / "index.json").write_text(json.dumps(index, indent=2) + "\n") print(f"Collected available notices for {len(index)} installed production dependencies") if __name__ == "__main__": parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("webui", type=Path) parser.add_argument("destination", type=Path) args = parser.parse_args() collect(args.webui, args.destination) ++++++ git-bug-0.10.1.tar.gz -> git-bug-0.11.0.tar.gz ++++++ ++++ 133620 lines of diff (skipped) ++++++ node-sources.json ++++++ ++++ 11673 lines (skipped) ++++++ node_modules.spec.inc ++++++ ++++ 1168 lines (skipped) ++++++ package-lock.json ++++++ ++++ 15300 lines (skipped) ++++++ pnpm-to-obs.py ++++++ #!/usr/bin/env python3 # SPDX-License-Identifier: MIT """Convert a pnpm v9 lock to an obs-service-node_modules download inventory. Requires Python 3 and PyYAML. No network access, dependency resolution, or installation is performed. The output is NOT an npm installation lockfile. """ import argparse import base64 import binascii import hashlib import json import re import sys from pathlib import Path from urllib.parse import quote, urlsplit import yaml class ConversionError(ValueError): """Input cannot be represented safely as a service download inventory.""" class UniqueKeyLoader(yaml.SafeLoader): """Do not silently overwrite conflicting YAML keys.""" def unique_mapping(loader, node, deep=False): result = {} for key_node, value_node in node.value: key = loader.construct_object(key_node, deep=deep) try: if key in result: raise ConversionError(f"Duplicate YAML key: {key!r}") result[key] = loader.construct_object(value_node, deep=deep) except TypeError as exc: raise ConversionError("YAML mapping keys must be scalar values") from exc return result UniqueKeyLoader.add_constructor( yaml.resolver.BaseResolver.DEFAULT_MAPPING_TAG, unique_mapping ) NAME = r"(?:@[a-z0-9_~][a-z0-9._~-]*/)?[a-z0-9_~][a-z0-9._~-]*" NUMBER = r"(?:0|[1-9][0-9]*)" VERSION = rf"{NUMBER}\.{NUMBER}\.{NUMBER}(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?" PACKAGE = re.compile(rf"^(?P<name>{NAME})@(?P<version>{VERSION})(?P<peers>\(.*\))?$") DIGEST_SIZES = {"sha1": 20, "sha256": 32, "sha384": 48, "sha512": 64} UNSUPPORTED = ("link:", "workspace:", "file:", "git:", "git+", "github:", "http:", "https:") def mapping(value, context): if not isinstance(value, dict): raise ConversionError(f"{context}: expected a mapping") return value def integrity_value(value, context): if not isinstance(value, str) or not value.split(): raise ConversionError(f"{context}: missing integrity hash") algorithms = {} for token in value.split(): algorithm, separator, encoded = token.partition("-") if not separator or algorithm not in DIGEST_SIZES: raise ConversionError(f"{context}: unsupported integrity algorithm") try: digest = base64.b64decode(encoded, validate=True) except (ValueError, binascii.Error) as exc: raise ConversionError(f"{context}: malformed integrity hash") from exc if len(digest) != DIGEST_SIZES[algorithm]: raise ConversionError(f"{context}: incorrect {algorithm} digest length") if algorithm in algorithms and algorithms[algorithm] != digest: raise ConversionError(f"{context}: conflicting {algorithm} integrity hashes") algorithms[algorithm] = digest return " ".join(sorted(set(value.split()))) def https_url(value, context): if not isinstance(value, str) or any(c.isspace() for c in value): raise ConversionError(f"{context}: expected an HTTPS tarball URL") try: url = urlsplit(value) valid = (url.scheme == "https" and url.hostname and url.path.rsplit("/", 1)[-1] not in {"", ".", ".."} and not url.username and not url.password and not url.fragment) _ = url.port except ValueError as exc: raise ConversionError(f"{context}: malformed URL") from exc if not valid: raise ConversionError(f"{context}: expected HTTPS without credentials or fragment") return value def service_filename(url): """Match obs-service-node_modules' make_unique_fn_from_path convention.""" parts = urlsplit(url).path.split("/") basename = parts[-1] prefix = [part for part in parts[1:-1] if part != "-" and not basename.startswith(part)] return "-".join([*prefix, basename]) def check_references(lock): # Local/workspace dependencies may occur only in importers, with no package # record. Refuse these instead of producing an apparently complete inventory. for section in ("importers", "snapshots"): for key, record in mapping(lock.get(section, {}), section).items(): record = mapping(record, f"{section}.{key}") for kind in ("dependencies", "devDependencies", "optionalDependencies"): for name, reference in mapping(record.get(kind, {}), f"{key}.{kind}").items(): if isinstance(reference, dict): reference = reference.get("version") if not isinstance(reference, str): raise ConversionError(f"{key}: invalid reference for {name}") if reference.startswith(UNSUPPORTED) or "patch_hash=" in reference: raise ConversionError(f"{key}: unsupported dependency {name}: {reference}") def convert(lock): lock = mapping(lock, "lockfile") if str(lock.get("lockfileVersion")) != "9.0": raise ConversionError("Only pnpm lockfileVersion 9.0 is supported") if lock.get("patchedDependencies"): raise ConversionError("patchedDependencies require explicit patch handling") check_references(lock) packages = mapping(lock.get("packages"), "packages") if not packages: raise ConversionError("The package inventory is empty") output = {} by_url = {} by_filename = {} for key, metadata in packages.items(): match = PACKAGE.fullmatch(key) if isinstance(key, str) else None if not match: raise ConversionError(f"Unsupported registry package identity: {key!r}") name, version, peers = match.group("name", "version", "peers") if peers: depth = 0 for char in peers: depth += (char == "(") - (char == ")") if depth < 0: break if depth != 0 or "patch_hash=" in peers: raise ConversionError(f"Unsupported peer/patch suffix: {key}") metadata = mapping(metadata, key) if metadata.get("patched"): raise ConversionError(f"{key}: patched package is unsupported") resolution = mapping(metadata.get("resolution"), f"{key}.resolution") unknown = set(resolution) - {"integrity", "tarball"} if unknown: raise ConversionError(f"{key}: unsupported resolution fields: {sorted(unknown)}") integrity = integrity_value(resolution.get("integrity"), key) url = resolution.get("tarball") if url is None: basename = name.rsplit("/", 1)[-1] url = (f"https://registry.npmjs.org/{quote(name, safe='@/')}/-/" f"{quote(basename, safe='')}-{quote(version, safe='')}.tgz") url = https_url(url, key) identity = f"{name}@{version}" entry = { "lockfileVersion": 3, "packages": { f"node_modules/{name}": { "version": version, "resolved": url, "integrity": integrity } }, } if identity in output and output[identity] != entry: raise ConversionError(f"Conflicting sources for {identity}") if url in by_url and by_url[url] != integrity: raise ConversionError(f"Conflicting integrity hashes for {url}") filename = service_filename(url) if filename in by_filename and by_filename[filename] != (url, integrity): raise ConversionError(f"Service tarball filename collision: {filename}") by_filename[filename] = (url, integrity) by_url[url] = integrity output[identity] = entry return dict(sorted(output.items())) def verify_downloads(inventory, directory): """The service may log download errors without failing; check every file.""" errors = [] seen = set() for lock in inventory.values(): entry = next(iter(lock["packages"].values())) filename = service_filename(entry["resolved"]) if filename in seen: continue seen.add(filename) expected = dict(token.split("-", 1) for token in entry["integrity"].split()) hashes = {algorithm: hashlib.new(algorithm) for algorithm in expected} try: with (directory / filename).open("rb") as stream: while chunk := stream.read(1024 * 1024): for digest in hashes.values(): digest.update(chunk) except OSError as exc: errors.append(f"{filename}: {exc.strerror}") continue for algorithm, digest in hashes.items(): if base64.b64encode(digest.digest()).decode("ascii") != expected[algorithm]: errors.append(f"{filename}: {algorithm} checksum mismatch") if errors: raise ConversionError("Download verification failed:\n" + "\n".join(errors)) return len(seen) def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--input", type=Path, required=True, help="pnpm-lock.yaml") parser.add_argument("--output", type=Path, required=True, help="service-only JSON inventory") parser.add_argument("--verify-dir", type=Path, help="also verify every downloaded tarball in this directory") args = parser.parse_args() try: if args.input.resolve() == args.output.resolve(): raise ConversionError("Input and output paths must differ") if args.output.name in {"package-lock.json", "npm-shrinkwrap.json"}: raise ConversionError("Use node-sources.json: output is NOT an npm install lock") lock = yaml.load(args.input.read_text(encoding="utf-8"), Loader=UniqueKeyLoader) result = convert(lock) if args.verify_dir is not None: count = verify_downloads(result, args.verify_dir) print(f"Verified {count} tarballs.", file=sys.stderr) content = json.dumps(result, indent=2, sort_keys=True, ensure_ascii=True) + "\n" args.output.write_text(content, encoding="utf-8") except (ConversionError, OSError, yaml.YAMLError) as exc: parser.exit(1, f"error: {exc}\n") print(f"Wrote {len(result)} package identities to {args.output}; service input only.", file=sys.stderr) if __name__ == "__main__": main() ++++++ prepare-webui-lock.py ++++++ #!/usr/bin/env python3 # SPDX-License-Identifier: MIT """Generate git-bug's npm manifest and lock using only verified pnpm sources. Maintainer-side tool, not an rpmbuild step. Requires Python/PyYAML, Node/npm, local-npm-registry, and Linux unshare with unprivileged user namespaces enabled. The npm resolver runs in a new network namespace with only loopback enabled. """ import argparse import fcntl import json import os from pathlib import Path import re import runpy import socket import struct import subprocess import sys import tarfile import tempfile import time import yaml CONVERTER = runpy.run_path(str(Path(__file__).with_name("pnpm-to-obs.py"))) Error = CONVERTER["ConversionError"] def build_manifest(package, lock): """Pin direct dependencies; leave transitive resolution to npm.""" result = json.loads(json.dumps(package)) if set(lock.get("importers", {})) != {"."}: raise Error("Expected a single root importer for the git-bug Web UI") importer = lock["importers"]["."] for kind in ("dependencies", "devDependencies", "optionalDependencies"): records = importer.get(kind, {}) if set(result.get(kind, {})) != set(records): raise Error(f"package.json and pnpm importer disagree on {kind}") for name, record in records.items(): if record["specifier"] != result[kind][name]: raise Error(f"package.json and pnpm importer disagree on {name}") version = record["version"].split("(", 1)[0] if not re.fullmatch(CONVERTER["VERSION"], version): raise Error(f"Unsupported direct dependency reference: {name}={version}") result[kind][name] = version overrides = lock.get("overrides", {}) if overrides != {"js-yaml@4": "^4.3.2"}: raise Error("Upstream overrides changed; review their npm translation") result["overrides"] = overrides result.pop("pnpm", None) result.pop("packageManager", None) return result def validate_lock(lock, inventory, tarballs): """Validate source provenance, including dependencies bundled in tarballs. This does not assert identical npm and pnpm peer/dependency graphs. """ if lock.get("lockfileVersion") != 3: raise Error("Expected an npm version-3 lock") selected = set() bundled_count = 0 packages = lock["packages"] for path, entry in packages.items(): if not path: continue name = entry.get("name", path.rsplit("node_modules/", 1)[-1]) identity = f"{name}@{entry['version']}" if "resolved" in entry or entry.get("link"): raise Error(f"Non-portable URL or link in npm lock: {path}") if entry.get("inBundle"): parent = path.rsplit("/node_modules/", 1)[0] while packages.get(parent, {}).get("inBundle"): parent = parent.rsplit("/node_modules/", 1)[0] if parent == path or parent not in packages: raise Error(f"No source package for bundled entry: {path}") ancestor = packages[parent] parent_name = ancestor.get("name", parent.rsplit("node_modules/", 1)[-1]) source = inventory.get(f"{parent_name}@{ancestor['version']}") if source is None: raise Error(f"Unknown bundle parent: {parent}") source_entry = next(iter(source["packages"].values())) filename = CONVERTER["service_filename"](source_entry["resolved"]) relative = path[len(parent) + 1:] if ".." in relative.split("/"): raise Error(f"Invalid bundled path: {path}") with tarfile.open(tarballs / filename) as archive: member = archive.getmember(f"package/{relative}/package.json") if not member.isfile() or member.size > 1024 * 1024: raise Error(f"Invalid bundled manifest: {path}") metadata = json.load(archive.extractfile(member)) if metadata.get("name") != name or metadata.get("version") != entry["version"]: raise Error(f"Bundled manifest disagrees with npm: {path}") bundled_count += 1 continue if identity not in inventory: raise Error(f"Npm selected a source absent from pnpm: {identity}") expected = next(iter(inventory[identity]["packages"].values())) if entry.get("integrity") != expected["integrity"]: raise Error(f"Npm integrity disagrees with pnpm: {identity}") selected.add(identity) return {"source_identities": len(selected), "installation_entries": len(packages) - 1, "bundled_entries": bundled_count, "not_selected_by_npm": sorted(set(inventory) - selected)} def enable_loopback(): # Executed only after unshare creates the private namespace. with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock: flags = fcntl.ioctl(sock, 0x8913, struct.pack("16sH14x", b"lo", 0)) value = struct.unpack("16sH14x", flags)[1] fcntl.ioctl(sock, 0x8914, struct.pack("16sH14x", b"lo", value | 1)) def resolve(manifest, tarballs, helper, timeout): with tempfile.TemporaryDirectory(prefix="git-bug-npm-lock-") as directory: work = Path(directory) (work / "package.json").write_text(json.dumps(manifest, indent=2) + "\n") # Do not inherit registry/cache settings or NODE_OPTIONS from the caller. env = {key: value for key, value in os.environ.items() if not key.lower().startswith("npm_config_") and key != "NODE_OPTIONS"} env.update(HOME=directory, npm_config_cache=str(work / "cache"), npm_config_userconfig=str(work / "npmrc"), npm_config_globalconfig=str(work / "global-npmrc"), npm_config_update_notifier="false", NODE_OPTIONS="--max-old-space-size=1536") log_path = work / "registry.log" with log_path.open("w") as log: server = subprocess.Popen([*helper, str(tarballs), "--debug"], cwd=work, env=env, stdout=log, stderr=subprocess.STDOUT) try: deadline = time.monotonic() + timeout while time.monotonic() < deadline: text = log_path.read_text() match = re.search(r"port: (\d+)", text) if match: port = match[1] break if server.poll() is not None: raise Error("Local registry exited:\n" + text[-4000:]) time.sleep(0.1) else: raise Error("Local registry startup timed out") subprocess.run([ "npm", "install", "--package-lock-only", "--omit-lockfile-registry-resolved", "--ignore-scripts", "--legacy-peer-deps", "--include=dev", "--no-audit", "--no-fund", "--fetch-retries=0", "--maxsockets=2", "--registry", f"http://localhost:{port}", ], cwd=work, env=env, check=True, timeout=timeout) return json.loads((work / "package-lock.json").read_text()) finally: server.terminate() try: server.wait(timeout=10) except subprocess.TimeoutExpired: server.kill() server.wait() def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--webui", type=Path, required=True) parser.add_argument("--tarballs", type=Path, required=True) parser.add_argument("--output-dir", type=Path, required=True) parser.add_argument("--registry-helper", nargs="+", default=["local-npm-registry"], help="helper command; defaults to local-npm-registry") parser.add_argument("--timeout", type=int, default=180) parser.add_argument("--_in-namespace", action="store_true", help=argparse.SUPPRESS) args = parser.parse_args() try: if not args._in_namespace: result = subprocess.run(["unshare", "-Urn", sys.executable, str(Path(__file__).resolve()), *sys.argv[1:], "--_in-namespace"]) return result.returncode enable_loopback() lock = yaml.load((args.webui / "pnpm-lock.yaml").read_text(), Loader=CONVERTER["UniqueKeyLoader"]) inventory = CONVERTER["convert"](lock) tarballs = args.tarballs.resolve() CONVERTER["verify_downloads"](inventory, tarballs) manifest = build_manifest(json.loads((args.webui / "package.json").read_text()), lock) npm_lock = resolve(manifest, tarballs, args.registry_helper, args.timeout) report = validate_lock(npm_lock, inventory, tarballs) args.output_dir.mkdir(parents=True, exist_ok=True) for name, value in (("webui-package.json", manifest), ("package-lock.json", npm_lock), ("webui-lock-report.json", report)): (args.output_dir / name).write_text(json.dumps(value, indent=2) + "\n") print(f"Validated {report['source_identities']} tarball identities and " f"{report['bundled_entries']} bundled entries. Output: {args.output_dir}") return 0 except (Error, OSError, ValueError, KeyError, tarfile.TarError, subprocess.SubprocessError, yaml.YAMLError) as exc: parser.exit(1, f"error: {exc}\n") if __name__ == "__main__": sys.exit(main()) ++++++ test-webui.py ++++++ #!/usr/bin/env python3 # SPDX-License-Identifier: MIT """Smoke-test an already built git-bug binary. Requires Python 3 and Git.""" import argparse import gzip from html.parser import HTMLParser import json import os from pathlib import Path import re import signal import subprocess import tempfile import time import urllib.error import urllib.parse import urllib.request class Assets(HTMLParser): def __init__(self): super().__init__() self.urls = [] def handle_starttag(self, tag, attrs): attrs = dict(attrs) value = attrs.get("src") if tag == "script" else attrs.get("href") if tag == "link" else None if value and value.endswith((".js", ".css")): self.urls.append(value) def require(condition, message): if not condition: raise RuntimeError(message) def smoke_test(binary): with tempfile.TemporaryDirectory(prefix="git-bug-webui-test-") as directory: work = Path(directory) env = {k: v for k, v in os.environ.items() if not k.startswith("GIT_")} env.update(HOME=directory, GIT_CONFIG_NOSYSTEM="1", GIT_CONFIG_GLOBAL="/dev/null") subprocess.run(["git", "init", "--quiet", str(work)], env=env, check=True) log_path = work / "server.log" opener = urllib.request.build_opener(urllib.request.ProxyHandler({})) with log_path.open("w") as log: server = subprocess.Popen([str(binary), "webui", "--no-open", "--read-only", "--bind", "127.0.0.1"], cwd=work, env=env, stdout=log, stderr=subprocess.STDOUT) try: base = None for _ in range(100): text = log_path.read_text() require(server.poll() is None, "Server exited:\n" + text) match = re.search(r"Web UI: (http://127\.0\.0\.1:\d+)", text) if match: base = match[1] try: with opener.open(base, timeout=2) as response: html = response.read() break except (urllib.error.URLError, ConnectionError): pass time.sleep(0.1) else: raise RuntimeError("Server did not become ready:\n" + log_path.read_text()) def get(path, compressed=False): url = urllib.parse.urljoin(base, path) require(urllib.parse.urlsplit(url).netloc == urllib.parse.urlsplit(base).netloc, "Unexpected external asset: " + path) request = urllib.request.Request(url, headers={ "Accept-Encoding": "gzip" if compressed else "identity"}) with opener.open(request, timeout=5) as response: return response.read(), response.headers require(b"<html" in html.lower(), "Root response is not HTML") spa, _ = get("/_/issues") require(spa == html, "SPA fallback differs from root page") compressed, headers = get("/", True) require(headers.get("Content-Encoding") == "gzip", "HTML gzip response missing") require(gzip.decompress(compressed) == html, "Gzip HTML does not match plain response") parser = Assets() parser.feed(html.decode()) require(any(url.endswith(".js") for url in parser.urls), "No embedded JavaScript") require(any(url.endswith(".css") for url in parser.urls), "No embedded stylesheet") fonts = set() for url in parser.urls: plain, _ = get(url) encoded, headers = get(url, True) decoded = gzip.decompress(encoded) if headers.get("Content-Encoding") == "gzip" else encoded require(plain and decoded == plain, "Asset encoding mismatch: " + url) if url.endswith(".css"): for font in re.findall(r'url\([\"\']?([^\s)\"\']+\.woff2)[\"\']?\)', plain.decode()): fonts.add(urllib.parse.urljoin(url, font)) require(fonts, "No embedded webfonts referenced by CSS") for font in fonts: content, _ = get(font) require(content.startswith(b"wOF2"), "Invalid webfont: " + font) request = urllib.request.Request(base + "/graphql", data=json.dumps({"query": "{ __typename }"}).encode(), headers={"Content-Type": "application/json"}) with opener.open(request, timeout=5) as response: result = json.load(response) require(result.get("data", {}).get("__typename") == "Query" and not result.get("errors"), "GraphQL query failed: " + repr(result)) print(f"PASS: HTML, SPA route, gzip, {len(parser.urls)} assets, {len(fonts)} fonts, GraphQL") finally: if server.poll() is None: server.send_signal(signal.SIGINT) try: server.wait(timeout=10) except subprocess.TimeoutExpired: server.kill() server.wait() if __name__ == "__main__": parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("binary", type=Path) args = parser.parse_args() smoke_test(args.binary.resolve()) ++++++ test_collect_webui_licenses.py ++++++ # SPDX-License-Identifier: MIT import contextlib import io import json from pathlib import Path import runpy import tempfile import unittest COLLECT = runpy.run_path(str(Path(__file__).with_name("collect-webui-licenses.py")))["collect"] class LicenseTests(unittest.TestCase): def setUp(self): self.tmp = tempfile.TemporaryDirectory() self.addCleanup(self.tmp.cleanup) self.webui = Path(self.tmp.name) / "webui" self.webui.mkdir() self.destination = Path(self.tmp.name) / "notices" self.packages = {"": {"name": "test"}} def package(self, name, license="MIT", **fields): path = "node_modules/" + name self.packages[path] = {"version": "1.0.0", "license": license, **fields} directory = self.webui / path directory.mkdir(parents=True) (directory / "package.json").write_text(json.dumps({"name": name, "license": license})) (directory / "LICENSE").write_text("test notice\n") return directory def collect(self): (self.webui / "package-lock.json").write_text(json.dumps({"packages": self.packages})) with contextlib.redirect_stdout(io.StringIO()): COLLECT(self.webui, self.destination) def test_copy_production_notices_without_npm_manifest_filename(self): self.package("@scope/runtime", license="OFL-1.1") self.package("build-tool", dev=True) self.collect() target = self.destination / "node_modules/@scope/runtime" self.assertEqual((target / "LICENSE").read_text(), "test notice\n") self.assertTrue((target / "package-metadata.json").is_file()) self.assertFalse((target / "package.json").exists()) self.assertFalse((self.destination / "node_modules/build-tool").exists()) def test_undeclared_license_requires_review(self): self.package("changed-license", license="GPL-3.0-only") with self.assertRaisesRegex(ValueError, "Review the spec License"): self.collect() def test_missing_notice_is_reported(self): package = self.package("missing-notice") (package / "LICENSE").unlink() (package / "README.md").write_text("upstream README\n") with contextlib.redirect_stderr(io.StringIO()) as errors: self.collect() self.assertIn("missing-notice", errors.getvalue()) self.assertTrue((self.destination / "node_modules/missing-notice/README.md").is_file()) def test_reject_path_traversal(self): self.packages["node_modules/../../outside"] = {"license": "MIT", "version": "1.0.0"} with self.assertRaisesRegex(ValueError, "Invalid package path"): self.collect() if __name__ == "__main__": unittest.main() ++++++ test_pnpm_to_obs.py ++++++ # SPDX-License-Identifier: MIT """Offline tests: python3 -m unittest -v test_pnpm_to_obs.py.""" import base64 import copy import hashlib import importlib.util import json from pathlib import Path import runpy import subprocess import sys import tempfile import unittest import yaml SCRIPT = Path(__file__).with_name("pnpm-to-obs.py") spec = importlib.util.spec_from_file_location("pnpm_to_obs", SCRIPT) converter = importlib.util.module_from_spec(spec) spec.loader.exec_module(converter) HASH = "sha512-" + base64.b64encode(bytes(64)).decode() OTHER_HASH = "sha512-" + base64.b64encode(bytes([1]) * 64).decode() SERVICE = Path("/usr/lib/obs/service/node_modules") def record(**resolution): return {"resolution": {"integrity": HASH, **resolution}} def fixture(): return { "lockfileVersion": "9.0", "importers": {".": {"dependencies": {"react": {"version": "19.3.0"}}}}, "packages": { "[email protected]": record(), "[email protected]": record(), "@scope/[email protected]": record(), }, } def package_entry(output, identity): return next(iter(output[identity]["packages"].values())) class ConverterTests(unittest.TestCase): def test_names_versions_and_scopes(self): result = converter.convert(fixture()) self.assertEqual(len(result), 3) self.assertEqual(package_entry(result, "@scope/[email protected]")["resolved"], "https://registry.npmjs.org/@scope/example/-/example-1.2.3.tgz") self.assertEqual(package_entry(result, "[email protected]")["integrity"], HASH) def test_peer_contexts_share_source(self): lock = fixture() lock["packages"]["[email protected]([email protected]([email protected]))"] = record() self.assertEqual(len(converter.convert(lock)), 3) def test_explicit_tarball(self): lock = fixture() url = "https://example.org/releases/react.tgz?download=1" lock["packages"]["[email protected]"] = record(tarball=url) self.assertEqual(package_entry(converter.convert(lock), "[email protected]")["resolved"], url) def test_optional_and_foreign_platforms_are_retained(self): lock = fixture() lock["packages"]["@scope/[email protected]"] = { **record(), "optional": True, "os": ["darwin"], "cpu": ["arm64"]} self.assertIn("@scope/[email protected]", converter.convert(lock)) def test_deterministic(self): lock = fixture() reverse = copy.deepcopy(lock) reverse["packages"] = dict(reversed(list(reverse["packages"].items()))) self.assertEqual(json.dumps(converter.convert(lock)), json.dumps(converter.convert(reverse))) def test_reject_bad_integrities(self): for value in (None, "", "sha512-AA==", "sha512-!", "unknown-AAAA", HASH + " " + OTHER_HASH): with self.subTest(value=value): lock = fixture() lock["packages"]["[email protected]"] = record(integrity=value) with self.assertRaises(converter.ConversionError): converter.convert(lock) def test_reject_conflicting_peer_records(self): lock = fixture() lock["packages"]["[email protected]([email protected])"] = record(integrity=OTHER_HASH) with self.assertRaisesRegex(converter.ConversionError, "Conflicting"): converter.convert(lock) def test_reject_conflicting_url(self): lock = fixture() lock["packages"]["[email protected]"] = record(tarball="https://example.org/a.tgz") lock["packages"]["[email protected]"] = record(tarball="https://example.org/a.tgz", integrity=OTHER_HASH) with self.assertRaisesRegex(converter.ConversionError, "Conflicting"): converter.convert(lock) def test_reject_service_filename_collision(self): lock = fixture() lock["packages"]["[email protected]"] = record(tarball="https://one.example/a.tgz") lock["packages"]["[email protected]"] = record(tarball="https://two.example/a.tgz") with self.assertRaisesRegex(converter.ConversionError, "filename collision"): converter.convert(lock) def test_reject_unsupported_resolutions(self): for resolution in ({"commit": "abc"}, {"directory": "../foo"}, {"tarball": "http://example.org/a.tgz"}, {"tarball": "https://user:[email protected]/a.tgz"}, {"tarball": "https://example.org/.."}, {"tarball": "https://example.org/"}): with self.subTest(resolution=resolution): lock = fixture() lock["packages"]["[email protected]"] = record(**resolution) with self.assertRaises(converter.ConversionError): converter.convert(lock) def test_reject_nonregistry_importer_references(self): for version in ("workspace:*", "link:../foo", "file:../foo", "git+https://example.org/foo"): with self.subTest(version=version): lock = fixture() lock["importers"]["."]["dependencies"]["react"]["version"] = version with self.assertRaises(converter.ConversionError): converter.convert(lock) def test_reject_patches_and_versions(self): for change in ({"patchedDependencies": {"react": "react.patch"}}, {"lockfileVersion": "6.0"}, {"packages": {}}): with self.subTest(change=change): with self.assertRaises(converter.ConversionError): converter.convert({**fixture(), **change}) def test_reject_duplicate_yaml_keys(self): with self.assertRaisesRegex(converter.ConversionError, "Duplicate YAML"): yaml.load("packages: {}\npackages: {}\n", Loader=converter.UniqueKeyLoader) def test_verify_downloads(self): content = b"test tarball contents" integrity = "sha512-" + base64.b64encode(hashlib.sha512(content).digest()).decode() lock = {"lockfileVersion": "9.0", "packages": {"[email protected]": record(integrity=integrity)}} inventory = converter.convert(lock) with tempfile.TemporaryDirectory() as directory: path = Path(directory) with self.assertRaisesRegex(converter.ConversionError, "verification failed"): converter.verify_downloads(inventory, path) tarball = path / "example-1.0.0.tgz" tarball.write_bytes(content) self.assertEqual(converter.verify_downloads(inventory, path), 1) tarball.write_bytes(b"damaged") with self.assertRaisesRegex(converter.ConversionError, "checksum mismatch"): converter.verify_downloads(inventory, path) def test_cli_does_not_overwrite_output_on_invalid_input(self): with tempfile.TemporaryDirectory() as directory: source = Path(directory) / "pnpm-lock.yaml" target = Path(directory) / "node-sources.json" source.write_text("lockfileVersion: '6.0'\n") target.write_text("keep me") result = subprocess.run([sys.executable, str(SCRIPT), "--input", str(source), "--output", str(target)], capture_output=True, text=True) self.assertNotEqual(result.returncode, 0) self.assertEqual(target.read_text(), "keep me") def test_cli_refuses_npm_lock_filename(self): with tempfile.TemporaryDirectory() as directory: result = subprocess.run([sys.executable, str(SCRIPT), "--input", "unused.yaml", "--output", str(Path(directory) / "package-lock.json")], capture_output=True, text=True) self.assertNotEqual(result.returncode, 0) self.assertIn("NOT an npm install lock", result.stderr) @unittest.skipUnless(SERVICE.exists(), "obs-service-node_modules is not installed") def test_installed_service_parses_inventory_without_downloads(self): service = runpy.run_path(str(SERVICE), run_name="service_test") output = converter.convert(fixture()) for lock in output.values(): service["process_packagelock_file"](lock) modules = service["MODULE_MAP"] self.assertEqual(len(modules), 3) expected = {package_entry(output, key)["resolved"] for key in output} self.assertEqual({entry["url"] for entry in modules.values()}, expected) for filename, entry in modules.items(): self.assertEqual(converter.service_filename(entry["url"]), filename) self.assertEqual(entry["algo"], "sha512") self.assertEqual(entry["chksum"], "00" * 64) if __name__ == "__main__": unittest.main() ++++++ test_prepare_webui_lock.py ++++++ # SPDX-License-Identifier: MIT """Unit tests for npm preparation. No Node processes or network access.""" import base64 import copy import io import json from pathlib import Path import runpy import tarfile import tempfile import unittest HELPER = runpy.run_path(str(Path(__file__).with_name("prepare-webui-lock.py"))) HASH = "sha512-" + base64.b64encode(bytes(64)).decode() class PreparationTests(unittest.TestCase): def fixture(self): package = {"dependencies": {"react": "^19.3.0"}, "packageManager": "[email protected]", "pnpm": {"overrides": {"js-yaml@4": "^4.3.2"}}} lock = {"importers": {".": {"dependencies": { "react": {"specifier": "^19.3.0", "version": "19.3.0([email protected])"}}}}, "overrides": {"js-yaml@4": "^4.3.2"}} return package, lock def test_pin_and_translate_without_mutating_input(self): package, lock = self.fixture() before = copy.deepcopy(package) result = HELPER["build_manifest"](package, lock) self.assertEqual(package, before) self.assertEqual(result["dependencies"]["react"], "19.3.0") self.assertEqual(result["overrides"], {"js-yaml@4": "^4.3.2"}) self.assertNotIn("packageManager", result) self.assertNotIn("pnpm", result) def test_reject_manifest_mismatch(self): package, lock = self.fixture() package["dependencies"]["react"] = "^18.0.0" with self.assertRaisesRegex(ValueError, "disagree"): HELPER["build_manifest"](package, lock) def test_reject_unreviewed_overrides(self): package, lock = self.fixture() lock["overrides"] = {"parent>child": "1.0.0"} with self.assertRaisesRegex(ValueError, "overrides changed"): HELPER["build_manifest"](package, lock) def test_reject_workspace_importers(self): package, lock = self.fixture() lock["importers"]["another"] = {} with self.assertRaisesRegex(ValueError, "single root importer"): HELPER["build_manifest"](package, lock) def inventory(self): return {"[email protected]": {"packages": {"node_modules/example": { "resolved": "https://registry.npmjs.org/example/-/example-1.0.0.tgz", "version": "1.0.0", "integrity": HASH}}}} def npm_lock(self): return {"lockfileVersion": 3, "packages": { "": {"name": "test"}, "node_modules/example": {"version": "1.0.0", "integrity": HASH}}} def test_source_identity_and_integrity(self): lock = self.npm_lock() result = HELPER["validate_lock"](lock, self.inventory(), Path("unused")) self.assertEqual(result["source_identities"], 1) for field, value in (("version", "2.0.0"), ("integrity", "invalid"), ("resolved", "http://localhost:1234/a.tgz"), ("link", True)): with self.subTest(field=field): changed = copy.deepcopy(lock) changed["packages"]["node_modules/example"][field] = value with self.assertRaises(ValueError): HELPER["validate_lock"](changed, self.inventory(), Path("unused")) def test_bundled_dependencies_are_checked_against_parent_archive(self): lock = self.npm_lock() lock["packages"]["node_modules/example/node_modules/child"] = { "version": "2.0.0", "inBundle": True} with tempfile.TemporaryDirectory() as directory: content = json.dumps({"name": "child", "version": "2.0.0"}).encode() path = Path(directory) with tarfile.open(path / "example-1.0.0.tgz", "w:gz") as archive: entry = tarfile.TarInfo("package/node_modules/child/package.json") entry.size = len(content) archive.addfile(entry, io.BytesIO(content)) result = HELPER["validate_lock"](lock, self.inventory(), path) self.assertEqual(result["bundled_entries"], 1) lock["packages"]["node_modules/example/node_modules/child"]["version"] = "3.0.0" with self.assertRaisesRegex(ValueError, "Bundled manifest disagrees"): HELPER["validate_lock"](lock, self.inventory(), path) if __name__ == "__main__": unittest.main() ++++++ vendor.tar.gz ++++++ /work/SRC/openSUSE:Factory/git-bug/vendor.tar.gz /work/SRC/openSUSE:Factory/.git-bug.new.1631729/vendor.tar.gz differ: char 21, line 1 ++++++ webui-lock-report.json ++++++ { "source_identities": 1041, "installation_entries": 1089, "bundled_entries": 6, "not_selected_by_npm": [ "@adobe/[email protected]", "@asamuzakjp/[email protected]", "@asamuzakjp/[email protected]", "@asamuzakjp/[email protected]", "@asamuzakjp/[email protected]", "@babel/[email protected]", "@babel/[email protected]", "@babel/[email protected]", "@bramus/[email protected]", "@csstools/[email protected]", "@csstools/[email protected]", "@csstools/[email protected]", "@csstools/[email protected]", "@csstools/[email protected]", "@csstools/[email protected]", "@eslint-community/[email protected]", "@eslint/[email protected]", "@eslint/[email protected]", "@eslint/[email protected]", "@eslint/[email protected]", "@eslint/[email protected]", "@exodus/[email protected]", "@humanfs/[email protected]", "@humanfs/[email protected]", "@humanfs/[email protected]", "@humanwhocodes/[email protected]", "@humanwhocodes/[email protected]", "@inquirer/[email protected]", "@inquirer/[email protected]", "@inquirer/[email protected]", "@inquirer/[email protected]", "@inquirer/[email protected]", "@jridgewell/[email protected]", "@mswjs/[email protected]", "@noble/[email protected]", "@open-draft/[email protected]", "@open-draft/[email protected]", "@open-draft/[email protected]", "@types/[email protected]", "@types/[email protected]", "@types/[email protected]", "@types/[email protected]", "@types/[email protected]", "[email protected]", "[email protected]", "[email protected]", "[email protected]", "[email protected]", "[email protected]", "[email protected]", "[email protected]", "[email protected]", "[email protected]", "[email protected]", "[email protected]", "[email protected]", "[email protected]", "[email protected]", "[email protected]", "[email protected]", "[email protected]", "[email protected]", "[email protected]", "[email protected]", "[email protected]", "[email protected]", "[email protected]", "[email protected]", "[email protected]", "[email protected]", "[email protected]", "[email protected]", "[email protected]", "[email protected]", "[email protected]", "[email protected]", "[email protected]", "[email protected]", "[email protected]", "[email protected]", "[email protected]", "[email protected]", "[email protected]", "[email protected]", "[email protected]", "[email protected]", "[email protected]", "[email protected]", "[email protected]", "[email protected]", "[email protected]", "[email protected]", "[email protected]", "[email protected]", "[email protected]", "[email protected]", "[email protected]", "[email protected]", "[email protected]", "[email protected]", "[email protected]", "[email protected]", "[email protected]", "[email protected]", "[email protected]", "[email protected]", "[email protected]", "[email protected]", "[email protected]", "[email protected]", "[email protected]", "[email protected]", "[email protected]", "[email protected]", "[email protected]", "[email protected]", "[email protected]", "[email protected]", "[email protected]", "[email protected]", "[email protected]", "[email protected]", "[email protected]", "[email protected]", "[email protected]", "[email protected]" ] } ++++++ webui-package.json ++++++ { "name": "git-bug-webui", "version": "0.0.0", "private": true, "type": "module", "scripts": { "dev": "vite", "build": "vite build", "preview": "vite preview", "codegen": "graphql-codegen --config codegen.ts", "lint": "oxlint", "lint:fix": "oxlint --fix", "fmt": "oxfmt", "fmt:check": "oxfmt --check", "check": "oxlint && oxfmt --check", "test": "vitest", "test:coverage": "vitest run --coverage", "storybook": "storybook dev -p 6006", "build-storybook": "storybook build" }, "dependencies": { "@apollo/client": "4.3.0", "@base-ui/react": "1.8.0", "@floating-ui/react": "0.27.20", "@fontsource-variable/inter": "5.3.0", "@shikijs/langs": "4.4.3", "@shikijs/rehype": "4.4.3", "@shikijs/themes": "4.4.3", "@tanstack/react-router": "1.170.36", "class-variance-authority": "0.7.1", "clsx": "2.1.1", "date-fns": "4.4.0", "graphql": "17.0.2", "hast-util-to-jsx-runtime": "2.3.6", "lucide-react": "1.45.0", "react": "19.3.0", "react-dom": "19.3.0", "react-markdown": "10.1.0", "rehype-autolink-headings": "7.1.0", "rehype-external-links": "3.0.0", "rehype-raw": "7.0.0", "rehype-sanitize": "6.0.0", "rehype-slug": "6.0.0", "remark-emoji": "5.0.2", "remark-gfm": "4.0.1", "rxjs": "7.8.2", "shadcn": "4.21.0", "shiki": "4.4.3", "tailwind-merge": "3.7.0", "tw-animate-css": "1.4.0", "unified": "11.0.5", "valibot": "1.5.0" }, "devDependencies": { "@graphql-codegen/cli": "7.4.1", "@graphql-codegen/client-preset": "6.2.0", "@graphql-typed-document-node/core": "3.2.0", "@storybook/addon-a11y": "10.6.0", "@storybook/addon-vitest": "10.6.0", "@storybook/react": "10.6.0", "@storybook/react-vite": "10.6.0", "@tailwindcss/typography": "0.5.20", "@tailwindcss/vite": "4.3.3", "@tanstack/eslint-plugin-router": "1.162.0", "@tanstack/router-plugin": "1.168.38", "@testing-library/dom": "10.4.1", "@testing-library/jest-dom": "7.0.1", "@testing-library/react": "16.3.3", "@tsconfig/strictest": "2.0.8", "@tsconfig/vite-react": "8.1.1", "@types/node": "22.20.2", "@types/react": "19.3.0", "@types/react-dom": "19.3.0", "@vitejs/plugin-react": "6.1.1", "@vitest/browser-playwright": "4.1.11", "@vitest/coverage-v8": "4.1.11", "@vitest/ui": "4.1.11", "eslint-plugin-storybook": "10.6.0", "happy-dom": "20.14.5", "oxfmt": "0.67.0", "oxlint": "1.82.0", "oxlint-tsgolint": "7.0.2001", "playwright": "1.63.0", "storybook": "10.6.0", "tailwindcss": "4.3.3", "typescript": "7.0.2", "vite": "8.3.0", "vite-plugin-compression": "0.5.1", "vite-plugin-svgr": "5.2.0", "vitest": "4.1.11" }, "overrides": { "js-yaml@4": "^4.3.2" } }
