pkglint 0.1.0__tar.gz

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.
@@ -0,0 +1,5 @@
1
+ /pkglint
2
+ /public/
3
+ /.cache/
4
+ /dist/
5
+ __pycache__/
pkglint-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Jamison Lahman
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
pkglint-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,123 @@
1
+ Metadata-Version: 2.5
2
+ Name: pkglint
3
+ Version: 0.1.0
4
+ Summary: Security-focused linter for Arch Linux PKGBUILDs
5
+ Project-URL: Repository, https://github.com/jmelahman/pkglint
6
+ Author-email: Jamison Lahman <jamison@lahman.dev>
7
+ License: MIT License
8
+
9
+ Copyright (c) 2026 Jamison Lahman
10
+
11
+ Permission is hereby granted, free of charge, to any person obtaining a copy
12
+ of this software and associated documentation files (the "Software"), to deal
13
+ in the Software without restriction, including without limitation the rights
14
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
15
+ copies of the Software, and to permit persons to whom the Software is
16
+ furnished to do so, subject to the following conditions:
17
+
18
+ The above copyright notice and this permission notice shall be included in all
19
+ copies or substantial portions of the Software.
20
+
21
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
22
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
23
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
24
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
25
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
26
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
27
+ SOFTWARE.
28
+ License-File: LICENSE
29
+ Keywords: archlinux,aur,linter,pkgbuild,security,supply-chain,tooling,tools
30
+ Classifier: License :: OSI Approved :: MIT License
31
+ Classifier: Operating System :: MacOS
32
+ Classifier: Operating System :: POSIX
33
+ Classifier: Programming Language :: Go
34
+ Requires-Python: >=3.6
35
+ Description-Content-Type: text/markdown
36
+
37
+ # pkglint
38
+
39
+ A security-focused linter for Arch Linux PKGBUILDs.
40
+
41
+ pkglint statically analyzes PKGBUILDs and their install scriptlets — **without ever
42
+ sourcing them** — and reports findings about source integrity, build hermeticity, code
43
+ execution, and persistence patterns, condensed into a letter grade per package. It is
44
+ built on a real bash AST ([mvdan.cc/sh](https://github.com/mvdan/sh)), so the
45
+ quoting/line-continuation tricks that evade regex-based scanners don't work here.
46
+
47
+ ```
48
+ $ pkglint ~/pkgbuilds/somepkg
49
+ somepkg: grade F, 3 finding(s)
50
+ PKGBUILD:16:3: critical [PB304] a network download is piped straight into bash and executed
51
+ PKGBUILD:11:1: error [PB101] remote source "http://..." has no checksum (SKIP): the download is never verified
52
+ PKGBUILD:24:3: error [PB402] sudo escalates privileges during a build; ...
53
+ ```
54
+
55
+ ## Install
56
+
57
+ ```shell
58
+ go install github.com/jmelahman/pkglint@latest
59
+ ```
60
+
61
+ ## Usage
62
+
63
+ ```shell
64
+ pkglint [flags] [path ...] # paths are package dirs or PKGBUILD files (default: .)
65
+
66
+ --format text|json # output format
67
+ --fail-on SEVERITY # exit 1 at or above: info, warn, error (default), critical, never
68
+ --ignore PB105,PB206 # disable rules
69
+ --rules # list every rule with its documentation
70
+ ```
71
+
72
+ Suppress a reviewed, intentional finding inline:
73
+
74
+ ```bash
75
+ # pkglint: ignore=PB204
76
+ go build -o "$pkgname" .
77
+ ```
78
+
79
+ ## Rules
80
+
81
+ | Group | Rules | What they catch |
82
+ |-------|-------|-----------------|
83
+ | Integrity | PB101–PB107 | SKIP/weak checksums, unpinned VCS sources, unencrypted transports, source/url domain mismatches, DLAGENTS overrides |
84
+ | Hermeticity | PB201–PB206 | network access outside `prepare()`, `pip` without `--require-hashes`, unlocked `cargo`, implicit Go module downloads, disabled checksum databases |
85
+ | Execution | PB301–PB307 | top-level code, `eval`, decode-and-execute, download-and-execute (including `eval "$(curl ...)"` and `source <(wget ...)"` variants), `/dev/tcp`, unresolvable command names, embedded payloads |
86
+ | Filesystem | PB401–PB403 | writes outside `$srcdir`/`$pkgdir`, privilege escalation, setuid files |
87
+ | Scriptlets | PB501–PB502 | network access and persistence (crontabs, systemd units, shell profiles, login-capable users) in `.install` files running as root |
88
+ | Consistency | PB601–PB602 | PKGBUILD / .SRCINFO drift, network access in `pkgver()` |
89
+
90
+ `pkglint --rules` prints the full documentation for each.
91
+
92
+ Grading: any critical → **F**, any error → **D**, 3+ warns → **C**, 1–2 warns → **B**,
93
+ otherwise **A**.
94
+
95
+ A grade is a **static hygiene score, not a malware verdict** — it measures how reviewable
96
+ and reproducible a PKGBUILD is. A low grade means "worth reviewing", never "malicious",
97
+ and a high grade is not an endorsement. Static analysis cannot catch a malicious upstream
98
+ release pinned with a perfectly valid checksum.
99
+
100
+ ## Report card site
101
+
102
+ `site/` generates a static "AUR Report Card" — grades, per-package finding pages,
103
+ per-rule documentation pages, `results.json`, and embeddable SVG badges:
104
+
105
+ ```shell
106
+ go run ./site -maintainer Jamison -top 500 -out public
107
+ ```
108
+
109
+ It downloads the AUR metadata dump once a day, fetches package snapshots politely
110
+ (throttled, cached by `LastModified`), and scans everything in-process.
111
+
112
+ ## Roadmap
113
+
114
+ - A `makepkg` shim so AUR helpers lint before building (`yay --makepkg pkglint-makepkg`,
115
+ paru `[bin] Makepkg`)
116
+ - Sandboxed builds: containerized `makepkg` with the package artifact installed on the
117
+ host via `pacman -U`
118
+ - Hermetic builds: two-phase `makepkg -o` (network) / `makepkg -e` (`--network=none`),
119
+ with these lint rules enforcing the conventions that make that split work
120
+
121
+ ## License
122
+
123
+ MIT
@@ -0,0 +1,87 @@
1
+ # pkglint
2
+
3
+ A security-focused linter for Arch Linux PKGBUILDs.
4
+
5
+ pkglint statically analyzes PKGBUILDs and their install scriptlets — **without ever
6
+ sourcing them** — and reports findings about source integrity, build hermeticity, code
7
+ execution, and persistence patterns, condensed into a letter grade per package. It is
8
+ built on a real bash AST ([mvdan.cc/sh](https://github.com/mvdan/sh)), so the
9
+ quoting/line-continuation tricks that evade regex-based scanners don't work here.
10
+
11
+ ```
12
+ $ pkglint ~/pkgbuilds/somepkg
13
+ somepkg: grade F, 3 finding(s)
14
+ PKGBUILD:16:3: critical [PB304] a network download is piped straight into bash and executed
15
+ PKGBUILD:11:1: error [PB101] remote source "http://..." has no checksum (SKIP): the download is never verified
16
+ PKGBUILD:24:3: error [PB402] sudo escalates privileges during a build; ...
17
+ ```
18
+
19
+ ## Install
20
+
21
+ ```shell
22
+ go install github.com/jmelahman/pkglint@latest
23
+ ```
24
+
25
+ ## Usage
26
+
27
+ ```shell
28
+ pkglint [flags] [path ...] # paths are package dirs or PKGBUILD files (default: .)
29
+
30
+ --format text|json # output format
31
+ --fail-on SEVERITY # exit 1 at or above: info, warn, error (default), critical, never
32
+ --ignore PB105,PB206 # disable rules
33
+ --rules # list every rule with its documentation
34
+ ```
35
+
36
+ Suppress a reviewed, intentional finding inline:
37
+
38
+ ```bash
39
+ # pkglint: ignore=PB204
40
+ go build -o "$pkgname" .
41
+ ```
42
+
43
+ ## Rules
44
+
45
+ | Group | Rules | What they catch |
46
+ |-------|-------|-----------------|
47
+ | Integrity | PB101–PB107 | SKIP/weak checksums, unpinned VCS sources, unencrypted transports, source/url domain mismatches, DLAGENTS overrides |
48
+ | Hermeticity | PB201–PB206 | network access outside `prepare()`, `pip` without `--require-hashes`, unlocked `cargo`, implicit Go module downloads, disabled checksum databases |
49
+ | Execution | PB301–PB307 | top-level code, `eval`, decode-and-execute, download-and-execute (including `eval "$(curl ...)"` and `source <(wget ...)"` variants), `/dev/tcp`, unresolvable command names, embedded payloads |
50
+ | Filesystem | PB401–PB403 | writes outside `$srcdir`/`$pkgdir`, privilege escalation, setuid files |
51
+ | Scriptlets | PB501–PB502 | network access and persistence (crontabs, systemd units, shell profiles, login-capable users) in `.install` files running as root |
52
+ | Consistency | PB601–PB602 | PKGBUILD / .SRCINFO drift, network access in `pkgver()` |
53
+
54
+ `pkglint --rules` prints the full documentation for each.
55
+
56
+ Grading: any critical → **F**, any error → **D**, 3+ warns → **C**, 1–2 warns → **B**,
57
+ otherwise **A**.
58
+
59
+ A grade is a **static hygiene score, not a malware verdict** — it measures how reviewable
60
+ and reproducible a PKGBUILD is. A low grade means "worth reviewing", never "malicious",
61
+ and a high grade is not an endorsement. Static analysis cannot catch a malicious upstream
62
+ release pinned with a perfectly valid checksum.
63
+
64
+ ## Report card site
65
+
66
+ `site/` generates a static "AUR Report Card" — grades, per-package finding pages,
67
+ per-rule documentation pages, `results.json`, and embeddable SVG badges:
68
+
69
+ ```shell
70
+ go run ./site -maintainer Jamison -top 500 -out public
71
+ ```
72
+
73
+ It downloads the AUR metadata dump once a day, fetches package snapshots politely
74
+ (throttled, cached by `LastModified`), and scans everything in-process.
75
+
76
+ ## Roadmap
77
+
78
+ - A `makepkg` shim so AUR helpers lint before building (`yay --makepkg pkglint-makepkg`,
79
+ paru `[bin] Makepkg`)
80
+ - Sandboxed builds: containerized `makepkg` with the package artifact installed on the
81
+ host via `pacman -U`
82
+ - Hermetic builds: two-phase `makepkg -o` (network) / `makepkg -e` (`--network=none`),
83
+ with these lint rules enforcing the conventions that make that split work
84
+
85
+ ## License
86
+
87
+ MIT
@@ -0,0 +1,58 @@
1
+ # This file was autogenerated by uv via the following command:
2
+ # uv pip compile - -o build-constraints.txt --universal --generate-hashes
3
+ go-bin==1.26.6 \
4
+ --hash=sha256:13a3a133530ffd2005b2ce805a93b8bf3f4712807af16f31aeea6aecfaa55721 \
5
+ --hash=sha256:1f4b3a6f8d7987c032d821ebb69e84ab9be7913b1cd8c45514d9aea21e3dfb7d \
6
+ --hash=sha256:2440ec27b056a19e9ce7c7e4c323f93a1ec0aa220b83838b9adf319487091582 \
7
+ --hash=sha256:2f8d76337bdf2e8e12b655c2f2c4827a202d4198c5cb87f0fde8b286bfa5b816 \
8
+ --hash=sha256:46658f18ce37d71122d25fa48bd7457cf46a3389345e86c5b6decabf1d5ec6ad \
9
+ --hash=sha256:565537475730612936bf42edddff5d627133c108d9a01561f8033ed09dc0c2ff \
10
+ --hash=sha256:7f30d0141d8052d9349b6e26c679ff51d14465ae3362ca9d1623ba1076e376a4 \
11
+ --hash=sha256:ab703942cbb119637e5306085b6f1f14eb51bd6a98b56dd2ff0a06b5afb742f8 \
12
+ --hash=sha256:c0dbc08e9a5a827966c8f2d6fb627668e78ab0f9732035564e24d046a185d92b \
13
+ --hash=sha256:efc17389b78138c27ce3c74275a146ce5e2b39c3969f57a170bbcfe303baf91b
14
+ hatch-vcs==0.5.0 \
15
+ --hash=sha256:0395fa126940340215090c344a2bf4e2a77bcbe7daab16f41b37b98c95809ff9 \
16
+ --hash=sha256:b49677dbdc597460cc22d01b27ab3696f5e16a21ecf2700fb01bc28e1f2a04a7
17
+ hatchling==1.32.0 \
18
+ --hash=sha256:0bdbde4a52b06c37e3eca395f85a762bf0ef06fe374fd8ae429dc6be10230f5f \
19
+ --hash=sha256:0e17c9c3b9aa7c625acc8d0f5b622f107d5049af9ecf5ada4de1aada5be7cdbc
20
+ # via hatch-vcs
21
+ manygo==0.2.0 \
22
+ --hash=sha256:322567f555f0c9896e163f4cecdcd5aa6402996933a6a8ec1cea7f67dae9accd \
23
+ --hash=sha256:90a951179f0d294d7063a4fc5446b52646cff140c386f9c92ca391116a287098
24
+ packaging==26.3 \
25
+ --hash=sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79 \
26
+ --hash=sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c
27
+ # via
28
+ # hatchling
29
+ # setuptools-scm
30
+ # vcs-versioning
31
+ pathspec==1.1.1 \
32
+ --hash=sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a \
33
+ --hash=sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189
34
+ # via hatchling
35
+ pluggy==1.6.0 \
36
+ --hash=sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3 \
37
+ --hash=sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746
38
+ # via hatchling
39
+ setuptools==84.0.0 \
40
+ --hash=sha256:51a52592b3b99e102b609654876bd65f19f999935166d1352678931132b0c670 \
41
+ --hash=sha256:f4695c21257f0d9b537ec2692c941d02ee143b7cc1276941349a546573b2ef73
42
+ # via setuptools-scm
43
+ setuptools-scm==10.2.1 \
44
+ --hash=sha256:4fa7dd82cf8c800df59c9a288c90299b1657ff1ecfc3f5cc00287c5dbf5e27a9 \
45
+ --hash=sha256:b7c82f4102d389ee57dc66ccdb4f9b4bca3c40ba83b43f1f63d68ccd72db2580
46
+ # via hatch-vcs
47
+ tomlkit==0.15.1 \
48
+ --hash=sha256:177a05aece5a8ca5266fd3c448abb47b8d352f09d477d3ca8332db4d89b24304 \
49
+ --hash=sha256:e25bbf38843005246210a12982776f27f99cb9be67160e14434d0c0d21ee1e97
50
+ # via hatchling
51
+ trove-classifiers==2026.6.1.19 \
52
+ --hash=sha256:ab4c4ec93cc4a4e7815fa759906e05e6bb3f2fbd92ea0f897288c6a43efd15b3 \
53
+ --hash=sha256:c5132b4b61a829d11cfbd2d72e97f20a45ed6edb95e45c5efdeb5e00836b2745
54
+ # via hatchling
55
+ vcs-versioning==2.3.1 \
56
+ --hash=sha256:806635bd0ea653c96af98a70624be758d408a989f2cd0b390e63474d99f96b63 \
57
+ --hash=sha256:a11299394e101569a62ab061375260d08bc56cd33d685b7067d85312368f4457
58
+ # via setuptools-scm
pkglint-0.1.0/go.mod ADDED
@@ -0,0 +1,5 @@
1
+ module github.com/jmelahman/pkglint
2
+
3
+ go 1.26.5
4
+
5
+ require mvdan.cc/sh/v3 v3.13.1
pkglint-0.1.0/go.sum ADDED
@@ -0,0 +1,12 @@
1
+ github.com/go-quicktest/qt v1.101.0 h1:O1K29Txy5P2OK0dGo59b7b0LR6wKfIhttaAhHUyn7eI=
2
+ github.com/go-quicktest/qt v1.101.0/go.mod h1:14Bz/f7NwaXPtdYEgzsx46kqSxVwTbzVZsDC26tQJow=
3
+ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
4
+ github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
5
+ github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
6
+ github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
7
+ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
8
+ github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
9
+ github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
10
+ github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
11
+ mvdan.cc/sh/v3 v3.13.1 h1:DP3TfgZhDkT7lerUdnp6PTGKyxxzz6T+cOlY/xEvfWk=
12
+ mvdan.cc/sh/v3 v3.13.1/go.mod h1:lXJ8SexMvEVcHCoDvAGLZgFJ9Wsm2sulmoNEXGhYZD0=
@@ -0,0 +1,67 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import shutil
5
+ import subprocess
6
+ from pathlib import Path
7
+
8
+ from hatchling.builders.hooks.plugin.interface import BuildHookInterface
9
+
10
+ import manygo
11
+
12
+ # Central pin for the Go toolchain, so builds are as hermetic as PEP 517
13
+ # allows: the same compiler version everywhere, pinned in exactly one place.
14
+ # The static backend pins (hatchling, hatch-vcs, manygo) live in
15
+ # pyproject.toml [build-system].requires.
16
+ GO_BIN_PIN = "go-bin==1.26.6"
17
+
18
+
19
+ class GoBinaryBuildHook(BuildHookInterface):
20
+ def dependencies(self) -> list[str]:
21
+ """Wheel builds always use the pinned Go toolchain.
22
+
23
+ Never probing PATH keeps builds hermetic: the binary is produced by
24
+ the same compiler version on every machine. The sdist carries only
25
+ sources, so it doesn't need one.
26
+ """
27
+ if self.target_name != "wheel":
28
+ return []
29
+ return [GO_BIN_PIN]
30
+
31
+ def initialize(self, version, build_data) -> None: # noqa: ANN001, ARG002
32
+ if self.target_name != "wheel":
33
+ # The sdist carries sources only; the binary is built when the
34
+ # sdist is turned into a wheel on the installing machine.
35
+ return
36
+ build_data["pure_python"] = False
37
+ goos = os.getenv("GOOS")
38
+ goarch = os.getenv("GOARCH")
39
+ if manygo.is_goos(goos) and manygo.is_goarch(goarch):
40
+ build_data["tag"] = "py3-none-" + manygo.get_platform_tag(goos=goos, goarch=goarch)
41
+ else:
42
+ # Native build: let hatchling tag the wheel for this platform.
43
+ build_data["infer_tag"] = True
44
+ binary_name = self.config["binary_name"]
45
+
46
+ go = shutil.which("go")
47
+ if go is None:
48
+ raise RuntimeError("go is required to build the pkglint binary")
49
+ env = os.environ.copy()
50
+ # Pure Go: static binaries on every target, no C toolchain needed.
51
+ env["CGO_ENABLED"] = "0"
52
+
53
+ # Always rebuild: a leftover binary from another target would be
54
+ # packaged into a wheel tagged for the wrong platform.
55
+ print(f"Building Go binary '{binary_name}'...")
56
+ subprocess.check_call( # noqa: S603
57
+ [
58
+ go, "build", "-trimpath",
59
+ "-ldflags", f"-s -w -X main.version={self.metadata.version}",
60
+ "-o", str(Path(self.root) / binary_name),
61
+ ".",
62
+ ],
63
+ cwd=self.root,
64
+ env=env,
65
+ )
66
+
67
+ build_data["shared_scripts"] = {binary_name: binary_name}
@@ -0,0 +1,310 @@
1
+ // Package pkgbuild statically parses PKGBUILD and .install files.
2
+ //
3
+ // PKGBUILDs are untrusted input: they must never be sourced or executed
4
+ // (even `makepkg --printsrcinfo` runs top-level code). Everything here is
5
+ // pure static analysis on the bash AST via mvdan.cc/sh.
6
+ package pkgbuild
7
+
8
+ import (
9
+ "bytes"
10
+ "fmt"
11
+ "os"
12
+ "path/filepath"
13
+ "regexp"
14
+ "strings"
15
+
16
+ "mvdan.cc/sh/v3/syntax"
17
+ )
18
+
19
+ // Var is a top-level variable assignment in a PKGBUILD.
20
+ type Var struct {
21
+ Name string
22
+ Values []string // rendered values; one element for scalar assignments
23
+ Array bool
24
+ Pos syntax.Pos
25
+ }
26
+
27
+ // Unit is a single bash file under analysis: the PKGBUILD itself or an
28
+ // install scriptlet.
29
+ type Unit struct {
30
+ Path string
31
+ Raw []byte
32
+ File *syntax.File
33
+ Scriptlet bool
34
+ Functions map[string]*syntax.FuncDecl
35
+ TopLevel []*syntax.Stmt // top-level statements that are not function declarations
36
+ }
37
+
38
+ // Package is a fully loaded package directory.
39
+ type Package struct {
40
+ Dir string
41
+ PKGBUILD Unit
42
+ Scriptlets []Unit
43
+ Vars map[string]*Var
44
+ SrcInfo *SrcInfo // nil when no .SRCINFO is present
45
+
46
+ // Suppressions maps line number -> rule IDs disabled on that line via
47
+ // "# pkglint: ignore=PB123[,PB456]" (applies to the same and next line).
48
+ Suppressions map[int]map[string]bool
49
+ }
50
+
51
+ // newParser returns a fresh parser per parse: syntax.Parser is not safe for
52
+ // concurrent use, and Load is called from concurrent scanners.
53
+ func newParser() *syntax.Parser {
54
+ return syntax.NewParser(syntax.KeepComments(true), syntax.Variant(syntax.LangBash))
55
+ }
56
+
57
+ // Load reads the PKGBUILD at path (a file or a directory containing one),
58
+ // plus any install scriptlets referenced by install= or living next to it.
59
+ func Load(path string) (*Package, error) {
60
+ info, err := os.Stat(path)
61
+ if err != nil {
62
+ return nil, err
63
+ }
64
+ dir, file := filepath.Split(path)
65
+ if info.IsDir() {
66
+ dir = path
67
+ file = "PKGBUILD"
68
+ }
69
+ pkgPath := filepath.Join(dir, file)
70
+
71
+ raw, err := os.ReadFile(pkgPath)
72
+ if err != nil {
73
+ return nil, err
74
+ }
75
+ unit, err := parseUnit(pkgPath, raw, false)
76
+ if err != nil {
77
+ return nil, err
78
+ }
79
+
80
+ pkg := &Package{
81
+ Dir: dir,
82
+ PKGBUILD: unit,
83
+ Vars: map[string]*Var{},
84
+ Suppressions: parseSuppressions(raw),
85
+ }
86
+ pkg.extractTopLevel()
87
+
88
+ if data, err := os.ReadFile(filepath.Join(dir, ".SRCINFO")); err == nil {
89
+ pkg.SrcInfo = ParseSrcInfo(data)
90
+ }
91
+
92
+ for _, name := range pkg.installFiles() {
93
+ p := filepath.Join(dir, name)
94
+ data, err := os.ReadFile(p)
95
+ if err != nil {
96
+ continue // missing scriptlet is reported by a rule
97
+ }
98
+ su, err := parseUnit(p, data, true)
99
+ if err != nil {
100
+ continue
101
+ }
102
+ pkg.Scriptlets = append(pkg.Scriptlets, su)
103
+ for line, ids := range parseSuppressions(data) {
104
+ pkg.Suppressions[line] = ids // best effort; line collisions across files are acceptable
105
+ }
106
+ }
107
+ return pkg, nil
108
+ }
109
+
110
+ func parseUnit(path string, raw []byte, scriptlet bool) (Unit, error) {
111
+ f, err := newParser().Parse(bytes.NewReader(raw), path)
112
+ if err != nil {
113
+ return Unit{}, fmt.Errorf("parse %s: %w", path, err)
114
+ }
115
+ u := Unit{
116
+ Path: path,
117
+ Raw: raw,
118
+ File: f,
119
+ Scriptlet: scriptlet,
120
+ Functions: map[string]*syntax.FuncDecl{},
121
+ }
122
+ for _, stmt := range f.Stmts {
123
+ if fd, ok := stmt.Cmd.(*syntax.FuncDecl); ok {
124
+ u.Functions[fd.Name.Value] = fd
125
+ continue
126
+ }
127
+ u.TopLevel = append(u.TopLevel, stmt)
128
+ }
129
+ return u, nil
130
+ }
131
+
132
+ // extractTopLevel records top-level variable assignments from the PKGBUILD.
133
+ func (p *Package) extractTopLevel() {
134
+ record := func(as *syntax.Assign) {
135
+ if as.Name == nil {
136
+ return
137
+ }
138
+ v := &Var{Name: as.Name.Value, Pos: as.Pos()}
139
+ if as.Array != nil {
140
+ v.Array = true
141
+ for _, el := range as.Array.Elems {
142
+ s, _ := RenderWord(el.Value, nil)
143
+ v.Values = append(v.Values, s)
144
+ }
145
+ } else if as.Value != nil {
146
+ s, _ := RenderWord(as.Value, nil)
147
+ v.Values = []string{s}
148
+ }
149
+ p.Vars[v.Name] = v
150
+ }
151
+ for _, stmt := range p.PKGBUILD.TopLevel {
152
+ switch cmd := stmt.Cmd.(type) {
153
+ case *syntax.CallExpr:
154
+ if len(cmd.Args) == 0 {
155
+ for _, as := range cmd.Assigns {
156
+ record(as)
157
+ }
158
+ }
159
+ case *syntax.DeclClause:
160
+ for _, as := range cmd.Args {
161
+ record(as)
162
+ }
163
+ }
164
+ }
165
+ }
166
+
167
+ // Scalar returns the rendered value of a scalar variable, expanded against
168
+ // other known scalars.
169
+ func (p *Package) Scalar(name string) (string, bool) {
170
+ v, ok := p.Vars[name]
171
+ if !ok || v.Array || len(v.Values) != 1 {
172
+ return "", false
173
+ }
174
+ return p.Expand(v.Values[0]), true
175
+ }
176
+
177
+ var varRef = regexp.MustCompile(`\$(\{[A-Za-z_][A-Za-z0-9_]*\}|[A-Za-z_][A-Za-z0-9_]*)`)
178
+
179
+ // Expand substitutes $name / ${name} references using known top-level scalar
180
+ // variables. Unknown references are left as-is.
181
+ func (p *Package) Expand(s string) string {
182
+ for range 5 {
183
+ if !strings.Contains(s, "$") {
184
+ break
185
+ }
186
+ out := varRef.ReplaceAllStringFunc(s, func(m string) string {
187
+ name := strings.Trim(m[1:], "{}")
188
+ if v, ok := p.Vars[name]; ok && !v.Array && len(v.Values) == 1 {
189
+ return v.Values[0]
190
+ }
191
+ return m
192
+ })
193
+ if out == s {
194
+ break
195
+ }
196
+ s = out
197
+ }
198
+ return s
199
+ }
200
+
201
+ // installFiles returns the scriptlet filenames referenced by install= or
202
+ // declared per split package, deduplicated.
203
+ func (p *Package) installFiles() []string {
204
+ seen := map[string]bool{}
205
+ var out []string
206
+ add := func(name string) {
207
+ name = strings.TrimSpace(name)
208
+ if name == "" || seen[name] {
209
+ return
210
+ }
211
+ seen[name] = true
212
+ out = append(out, name)
213
+ }
214
+ if v, ok := p.Vars["install"]; ok {
215
+ for _, val := range v.Values {
216
+ add(p.Expand(val))
217
+ }
218
+ }
219
+ if p.SrcInfo != nil {
220
+ for _, val := range p.SrcInfo.All("install") {
221
+ add(val)
222
+ }
223
+ }
224
+ return out
225
+ }
226
+
227
+ // Units returns the PKGBUILD followed by any parsed scriptlets.
228
+ func (p *Package) Units() []Unit {
229
+ return append([]Unit{p.PKGBUILD}, p.Scriptlets...)
230
+ }
231
+
232
+ var suppressRe = regexp.MustCompile(`#\s*pkglint:\s*ignore=([A-Z0-9, ]+)`)
233
+
234
+ func parseSuppressions(raw []byte) map[int]map[string]bool {
235
+ out := map[int]map[string]bool{}
236
+ for i, line := range strings.Split(string(raw), "\n") {
237
+ m := suppressRe.FindStringSubmatch(line)
238
+ if m == nil {
239
+ continue
240
+ }
241
+ ids := map[string]bool{}
242
+ for _, id := range strings.Split(m[1], ",") {
243
+ if id = strings.TrimSpace(id); id != "" {
244
+ ids[id] = true
245
+ }
246
+ }
247
+ out[i+1] = ids
248
+ }
249
+ return out
250
+ }
251
+
252
+ // Suppressed reports whether ruleID is suppressed at the given line by a
253
+ // directive on that line or the line above it.
254
+ func (p *Package) Suppressed(ruleID string, line int) bool {
255
+ for _, l := range []int{line, line - 1} {
256
+ if ids, ok := p.Suppressions[l]; ok && ids[ruleID] {
257
+ return true
258
+ }
259
+ }
260
+ return false
261
+ }
262
+
263
+ // RenderWord returns an approximate textual form of a word. dynamic is true
264
+ // when the word contains constructs whose value cannot be determined
265
+ // statically (command substitution, process substitution, arithmetic,
266
+ // indirection, or parameter operations). Plain references to unknown
267
+ // variables render as "$name" and are not considered dynamic, so prefix
268
+ // checks like `$pkgdir/...` still work. vars, when non-nil, resolves simple
269
+ // parameter references.
270
+ func RenderWord(w *syntax.Word, vars map[string]string) (s string, dynamic bool) {
271
+ if w == nil {
272
+ return "", false
273
+ }
274
+ var b strings.Builder
275
+ dyn := renderParts(&b, w.Parts, vars)
276
+ return b.String(), dyn
277
+ }
278
+
279
+ func renderParts(b *strings.Builder, parts []syntax.WordPart, vars map[string]string) bool {
280
+ dynamic := false
281
+ for _, part := range parts {
282
+ switch x := part.(type) {
283
+ case *syntax.Lit:
284
+ b.WriteString(x.Value)
285
+ case *syntax.SglQuoted:
286
+ b.WriteString(x.Value)
287
+ case *syntax.DblQuoted:
288
+ if renderParts(b, x.Parts, vars) {
289
+ dynamic = true
290
+ }
291
+ case *syntax.ParamExp:
292
+ if x.Excl || x.Length || x.Width || x.Index != nil || x.Slice != nil || x.Repl != nil || x.Exp != nil || x.Names != 0 {
293
+ b.WriteString("\x00")
294
+ dynamic = true
295
+ break
296
+ }
297
+ if vars != nil {
298
+ if v, ok := vars[x.Param.Value]; ok {
299
+ b.WriteString(v)
300
+ break
301
+ }
302
+ }
303
+ b.WriteString("$" + x.Param.Value)
304
+ default:
305
+ b.WriteString("\x00")
306
+ dynamic = true
307
+ }
308
+ }
309
+ return dynamic
310
+ }