php-radar 0.1.0__py3-none-any.whl

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,579 @@
1
+ """Finds where a local checkout pins or declares a PHP runtime.
2
+
3
+ scan_path walks a tree and reads the files that name a PHP version. Those are Dockerfiles, compose
4
+ files, GitHub Actions workflows that call setup-php, .php-version, .tool-versions, composer.json,
5
+ and devcontainer.json. Each file is read as text and picked apart with string handling. Nothing in
6
+ the tree is imported, run, or evaluated, and no link leads the scan out of it.
7
+
8
+ Each find is one Evidence, and its confidence follows how exactly the file names a build. An image
9
+ tag with a patch release scores high, and a floating tag such as 8.3-apache scores low. A Composer
10
+ requirement only sets a floor, so it carries the kind composer-constraint and no version, and the
11
+ grader sets it aside.
12
+ """
13
+ import codecs
14
+ import hashlib
15
+ import json
16
+ import os
17
+ import stat
18
+ import string
19
+ from functools import partial
20
+ from typing import Callable, Iterator, NamedTuple, Optional
21
+
22
+ from php_radar.model import Confidence, Evidence, Sapi, Source
23
+ from php_radar.versions import PhpVersion
24
+
25
+ _Pins = list[tuple[str, Confidence]]
26
+
27
+ # No config file is this big. The cap bounds what one hostile file can cost to read and parse.
28
+ _MAX_BYTES = 1 << 20
29
+
30
+ # Past this many pins a file is a flood and not a config.
31
+ _MAX_PINS = 100
32
+
33
+ # A tag, version, or constraint longer than this is none of those. Docker caps a tag at 128
34
+ # characters. A longer value is reported as skipped and never read as a pin.
35
+ _MAX_TEXT = 256
36
+
37
+ # Folders that hold other people's code. Each dependency in vendor/ declares the PHP it supports.
38
+ # That says nothing about the project that installed it.
39
+ _SKIP_DIRS = frozenset({".git", "node_modules", "vendor"})
40
+
41
+ # A file with one of these endings documents a Dockerfile and is not one, so Dockerfile.md can
42
+ # quote any FROM line it likes.
43
+ _DOC_ENDINGS = (".md", ".markdown", ".rst", ".txt")
44
+
45
+ # What can follow the version in a tag of the official php image.
46
+ _VARIANTS = frozenset({
47
+ "cli", "fpm", "apache", "zts", "alpine", "jessie", "stretch", "buster", "bullseye", "bookworm",
48
+ "trixie", "windowsservercore", "nanoserver"})
49
+ # The variants that end in a number, like alpine3.20 and ltsc2022.
50
+ _NUMBERED = frozenset({"alpine", "ltsc"})
51
+
52
+ # Digest algorithm to hex length. Docker prints digests in lower case.
53
+ _DIGESTS = {"sha256": 64, "sha512": 128}
54
+ _HEX = frozenset("0123456789abcdef")
55
+
56
+ # A tag that names its server API says which SAPI the image runs. The first match wins.
57
+ _SAPIS = (("apache", Sapi.MOD_PHP), ("fpm", Sapi.FPM), ("cli", Sapi.CLI), ("zts", Sapi.CLI))
58
+
59
+ # The grader sets this kind aside, because a requirement says nothing about what runs.
60
+ _FLOOR_KIND = "composer-constraint"
61
+
62
+ # Confidence is declared strongest first.
63
+ _RANK = {c: i for i, c in enumerate(Confidence)}
64
+
65
+
66
+ def _is_digest(text: str) -> bool:
67
+ algorithm, _, digits = text.partition(":")
68
+ return len(digits) == _DIGESTS.get(algorithm, -1) and _HEX.issuperset(digits)
69
+
70
+
71
+ def _known(word: str) -> bool:
72
+ return word in _VARIANTS or word.rstrip(string.digits + ".") in _NUMBERED
73
+
74
+
75
+ def _release(tag: str) -> Optional[PhpVersion]:
76
+ # The digest is no part of the version, and parse would read its @ as junk after the micro.
77
+ return PhpVersion.parse(tag.partition("@")[0])
78
+
79
+
80
+ def tag_confidence(tag: str) -> Confidence:
81
+ """How exactly an image tag names a build.
82
+
83
+ HIGH for a digest, or for a full major.minor.micro followed by nothing or by variants of the
84
+ official php image (8.3.35, 8.3.35-fpm-alpine3.20). MEDIUM for a full version with a suffix
85
+ those images do not use, since the tag may belong to some other image. LOW for anything that
86
+ floats: 8.3, 8.3-apache, 8, latest, or a tag that a build variable fills in.
87
+ """
88
+ tag = tag.strip()
89
+ name, at, digest = tag.partition("@")
90
+ if _is_digest(digest if at else name):
91
+ return Confidence.HIGH
92
+ release = _release(tag)
93
+ if release is None:
94
+ return Confidence.LOW
95
+ words = release.suffix.lstrip("-+~_").split("-")
96
+ return Confidence.HIGH if not release.suffix or all(map(_known, words)) else Confidence.MEDIUM
97
+
98
+
99
+ def _pin_confidence(text: str) -> Confidence:
100
+ # A version named outside an image tag, in .php-version or a workflow, is not what gets built.
101
+ # So it stops at MEDIUM, and a version with no micro is LOW.
102
+ return Confidence.MEDIUM if PhpVersion.parse(text) is not None else Confidence.LOW
103
+
104
+
105
+ def _at_most(confidence: Confidence, ceiling: Confidence) -> Confidence:
106
+ return max(confidence, ceiling, key=_RANK.get)
107
+
108
+
109
+ def _uncomment(line: str) -> str:
110
+ """line up to its comment. A # opens one at the start of the line or after whitespace."""
111
+ for at, ch in enumerate(line):
112
+ if ch == "#" and (not at or line[at - 1].isspace()):
113
+ return line[:at]
114
+ return line
115
+
116
+
117
+ def _scalar(value: str) -> str:
118
+ """A YAML scalar without its quotes or its trailing comment."""
119
+ value = value.strip()
120
+ if value[:1] in ("'", '"'):
121
+ end = value.find(value[0], 1)
122
+ return value[1:end] if end > 0 else value[1:]
123
+ return _uncomment(value).rstrip()
124
+
125
+
126
+ def _pair(line: str) -> tuple[str, str]:
127
+ """The key and scalar value of a YAML "key: value" line, or two empty strings."""
128
+ key, colon, value = line.strip().lstrip("- ").partition(":")
129
+ # YAML needs a space after the colon, so image:php:8 is one string and no pair.
130
+ if not colon or (value and not value[0].isspace()):
131
+ return "", ""
132
+ return key.strip().strip("\"'"), _scalar(value)
133
+
134
+
135
+ def _indent(line: str) -> int:
136
+ return len(line) - len(line.lstrip(" "))
137
+
138
+
139
+ def _column(line: str) -> int:
140
+ """Where the first key starts, past the indentation and a list dash."""
141
+ return len(line) - len(line.lstrip(" -"))
142
+
143
+
144
+ def _unique(pins: _Pins) -> _Pins:
145
+ seen: dict[str, Confidence] = {}
146
+ for text, confidence in pins:
147
+ if text:
148
+ seen.setdefault(text, confidence)
149
+ return list(seen.items())
150
+
151
+
152
+ def _member(node: object, key: str) -> object:
153
+ return node.get(key) if isinstance(node, dict) else None
154
+
155
+
156
+ def _split_ref(ref: str) -> tuple[str, str, str]:
157
+ """The repository, tag, and digest of an image reference. The tag and digest may be empty."""
158
+ name, _, digest = ref.partition("@")
159
+ head, slash, last = name.rpartition("/")
160
+ base, _, tag = last.partition(":")
161
+ return head + slash + base, tag, digest
162
+
163
+
164
+ def _is_php(repository: str) -> bool:
165
+ path = repository.lower().split("/")
166
+ # A first part with a dot or a colon is a registry host, as in localhost:5000/php.
167
+ if len(path) > 1 and ("." in path[0] or ":" in path[0] or path[0] == "localhost"):
168
+ path = path[1:]
169
+ return path == ["php"] or path[-2:] in (["library", "php"], ["devcontainers", "php"])
170
+
171
+
172
+ def _php_image_tag(ref: str) -> Optional[str]:
173
+ """The tag of a reference to the official php image, or None for any other image.
174
+
175
+ No tag means latest. A digest stays on the end of the tag, where tag_confidence reads it.
176
+ """
177
+ repository, tag, digest = _split_ref(ref.strip())
178
+ if not _is_php(repository):
179
+ return None
180
+ if digest:
181
+ return tag + "@" + digest
182
+ return tag or "latest"
183
+
184
+
185
+ def _sapi(tag: str) -> Sapi:
186
+ words = tag.partition("@")[0].split("-")
187
+ return next((sapi for word, sapi in _SAPIS if word in words), Sapi.UNKNOWN)
188
+
189
+
190
+ def _image_pin(tag: Optional[str]) -> _Pins:
191
+ return [] if tag is None else [(tag, tag_confidence(tag))]
192
+
193
+
194
+ def _instructions(text: str) -> Iterator[str]:
195
+ """Dockerfile lines with backslash continuations joined and comment lines dropped."""
196
+ parts: list[str] = []
197
+ for raw in text.splitlines():
198
+ line = raw.strip()
199
+ if line.startswith("#"):
200
+ continue
201
+ if line.endswith("\\"):
202
+ parts.append(line[:-1])
203
+ continue
204
+ parts.append(line)
205
+ yield " ".join(parts)
206
+ parts = []
207
+ if parts:
208
+ yield " ".join(parts)
209
+
210
+
211
+ def parse_dockerfile(text: str) -> _Pins:
212
+ """The tag of each FROM line that pulls the official php image."""
213
+ found: _Pins = []
214
+ for line in _instructions(text):
215
+ words = line.split()
216
+ if len(words) > 1 and words[0].upper() == "FROM":
217
+ # Flags such as --platform come before the image.
218
+ image = next((w for w in words[1:] if not w.startswith("--")), "")
219
+ found += _image_pin(_php_image_tag(image))
220
+ return _unique(found)
221
+
222
+
223
+ def parse_compose(text: str) -> _Pins:
224
+ """The tag of each image: entry that names the official php image."""
225
+ found: _Pins = []
226
+ for line in text.splitlines():
227
+ key, value = _pair(line)
228
+ if key == "image":
229
+ found += _image_pin(_php_image_tag(value))
230
+ return _unique(found)
231
+
232
+
233
+ def parse_ci_setup_php(text: str) -> _Pins:
234
+ """The php-version of each shivammathur/setup-php step in a GitHub Actions workflow.
235
+
236
+ A step runs from its uses: line to the first line indented less than that line's keys. The
237
+ version is returned as written, so a matrix expression comes back as ${{ matrix.php }}.
238
+ """
239
+ found: _Pins = []
240
+ floor: Optional[int] = None # the column of the keys of the setup-php step now open
241
+ for line in text.splitlines():
242
+ if not line.strip() or line.strip().startswith("#"):
243
+ continue
244
+ if floor is not None and _indent(line) < floor:
245
+ floor = None
246
+ key, value = _pair(line)
247
+ if key == "uses" and value.lower().startswith("shivammathur/setup-php"):
248
+ floor = _column(line)
249
+ elif key == "php-version" and floor is not None and _indent(line) > floor:
250
+ found.append((value, _pin_confidence(value)))
251
+ return _unique(found)
252
+
253
+
254
+ def parse_dotfiles(name: str, text: str) -> _Pins:
255
+ """The versions in a .php-version file (its first line) or a .tool-versions file (php lines)."""
256
+ found: list[str] = []
257
+ for line in text.splitlines():
258
+ words = _uncomment(line).split()
259
+ if not words:
260
+ continue
261
+ if name == ".php-version":
262
+ found.append(words[0])
263
+ break
264
+ if name == ".tool-versions" and words[0] == "php":
265
+ found.extend(words[1:])
266
+ return _unique([(word, _pin_confidence(word)) for word in found])
267
+
268
+
269
+ def _no_number(text: str) -> None:
270
+ # Neither parser reads a number. Turning a megabyte of digits into an int takes seconds before
271
+ # Python 3.11, and 3.11 and later refuse it, which would cost the file its real pins.
272
+ return None
273
+
274
+
275
+ def _load(text: str) -> object:
276
+ return json.loads(text, parse_int=_no_number, parse_float=_no_number)
277
+
278
+
279
+ def parse_composer(text: str) -> _Pins:
280
+ """The php requirement and the config.platform.php entry of a composer.json.
281
+
282
+ Both only say what the project needs or asks Composer to assume, never what runs, so each
283
+ comes back LOW. Raises ValueError or RecursionError on text that is not JSON.
284
+ """
285
+ data = _load(text)
286
+ declared = (_member(_member(data, "require"), "php"),
287
+ _member(_member(_member(data, "config"), "platform"), "php"))
288
+ return _unique([(d.strip(), Confidence.LOW) for d in declared if isinstance(d, str)])
289
+
290
+
291
+ def _strip_jsonc(text: str) -> str:
292
+ """text with its // and /* */ comments and trailing commas removed. VS Code allows both."""
293
+ out: list[str] = []
294
+ in_string = False
295
+ comma = -1 # where in out a comma sits that no other token has followed yet
296
+ i, n = 0, len(text)
297
+ while i < n:
298
+ c = text[i]
299
+ if in_string:
300
+ out.append(c)
301
+ if c == "\\":
302
+ out.append(text[i + 1:i + 2]) # the escaped character, whatever it is
303
+ i += 1
304
+ elif c == '"':
305
+ in_string = False
306
+ i += 1
307
+ elif text.startswith("//", i):
308
+ end = text.find("\n", i)
309
+ i = n if end < 0 else end
310
+ elif text.startswith("/*", i):
311
+ end = text.find("*/", i + 2)
312
+ i = n if end < 0 else end + 2
313
+ else:
314
+ if c in "}]" and comma >= 0:
315
+ out[comma] = ""
316
+ if c == ",":
317
+ comma = len(out)
318
+ elif not c.isspace():
319
+ comma = -1
320
+ in_string = c == '"'
321
+ out.append(c)
322
+ i += 1
323
+ return "".join(out)
324
+
325
+
326
+ def parse_devcontainer(text: str) -> _Pins:
327
+ """The image tag and the php feature's version in a devcontainer.json.
328
+
329
+ A dev container is not what ships, so even an exact tag stops at MEDIUM. Raises ValueError or
330
+ RecursionError on text that is not JSON once its comments are gone.
331
+ """
332
+ data = _load(_strip_jsonc(text))
333
+ image = _member(data, "image")
334
+ tag = _php_image_tag(image) if isinstance(image, str) else None
335
+ found: _Pins = []
336
+ if tag is not None:
337
+ found.append((tag, _at_most(tag_confidence(tag), Confidence.MEDIUM)))
338
+ features = _member(data, "features")
339
+ if not isinstance(features, dict):
340
+ features = {}
341
+ for ref, options in features.items():
342
+ if _split_ref(ref)[0].rsplit("/", 1)[-1] == "php":
343
+ # A bare string is the spec's shorthand for the version option, and latest its default.
344
+ version = options.get("version") if isinstance(options, dict) else options
345
+ version = version.strip() if isinstance(version, str) and version.strip() else "latest"
346
+ found.append((version, _pin_confidence(version)))
347
+ return _unique(found)
348
+
349
+
350
+ class _Reader(NamedTuple):
351
+ kind: str # the kind signal of what it finds
352
+ parse: Callable[[str], _Pins]
353
+
354
+
355
+ _DOCKERFILE = _Reader("dockerfile", parse_dockerfile)
356
+ _COMPOSE = _Reader("compose", parse_compose)
357
+ _CI = _Reader("ci-setup-php", parse_ci_setup_php)
358
+ _COMPOSER = _Reader(_FLOOR_KIND, parse_composer)
359
+ _DEVCONTAINER = _Reader("devcontainer", parse_devcontainer)
360
+ _BY_NAME = {
361
+ ".php-version": _Reader("php-version-file", partial(parse_dotfiles, ".php-version")),
362
+ ".tool-versions": _Reader("tool-versions", partial(parse_dotfiles, ".tool-versions")),
363
+ "composer.json": _COMPOSER,
364
+ "devcontainer.json": _DEVCONTAINER,
365
+ ".devcontainer.json": _DEVCONTAINER,
366
+ }
367
+
368
+
369
+ def _is_dockerfile(name: str) -> bool:
370
+ if name.endswith(_DOC_ENDINGS):
371
+ return False
372
+ for stem in ("dockerfile", "containerfile"):
373
+ if name == stem or (name.startswith(stem) and name[len(stem)] in ".-_"):
374
+ return True
375
+ return name.endswith((".dockerfile", ".containerfile"))
376
+
377
+
378
+ def _is_compose(name: str) -> bool:
379
+ stem = name.rsplit(".", 1)[0]
380
+ return stem in ("compose", "docker-compose") or stem.startswith(
381
+ ("compose.", "compose-", "docker-compose.", "docker-compose-"))
382
+
383
+
384
+ def _reader(rel: str) -> Optional[_Reader]:
385
+ """The reader for a file, by its path relative to the repository, or None to leave it alone."""
386
+ folder, _, name = rel.rpartition("/")
387
+ name = name.lower()
388
+ if _is_dockerfile(name):
389
+ return _DOCKERFILE
390
+ if name.endswith((".yml", ".yaml")):
391
+ if folder == ".github/workflows" or folder.endswith("/.github/workflows"):
392
+ return _CI
393
+ return _COMPOSE if _is_compose(name) else None
394
+ return _BY_NAME.get(name)
395
+
396
+
397
+ def _why(exc: OSError) -> str:
398
+ # The message alone, because str(exc) carries the file name and so the absolute path.
399
+ return exc.strerror or type(exc).__name__
400
+
401
+
402
+ def _skipped(repo: str, rel: str, reason: str) -> Evidence:
403
+ """Evidence that a file or folder was passed over, with the reason in note.
404
+
405
+ A skip is a gap in coverage. The scan knew the file by a name it reads, or met a folder it
406
+ could not list or would not enter, and so could not see whether anything there pins an old
407
+ build. It counts as PHP seen with no version. That holds the repo at INDETERMINATE while the
408
+ gap is open, because a patched pin elsewhere must not clear a repo that has unread content.
409
+ Every skip follows this one rule, including a link (its real content can sit under a name the
410
+ scan does not read), a linked folder that leads out of the tree, and a composer.json.
411
+ """
412
+ signals = {"repo": repo, "kind": "skipped", "confidence": Confidence.LOW.name,
413
+ "php_present": "true"}
414
+ return Evidence(Source.REPO, rel, None, Sapi.UNKNOWN, signals, "skipped: " + reason)
415
+
416
+
417
+ def _evidence(repo: str, rel: str, reader: _Reader, declared: str,
418
+ confidence: Confidence) -> Evidence:
419
+ signals = {"repo": repo, "kind": reader.kind, "confidence": confidence.name,
420
+ "declared": declared}
421
+ version: Optional[str] = None
422
+ if reader.kind == _FLOOR_KIND:
423
+ # Not a version at all: the report shows detected_version as the PHP of an asset that
424
+ # grades INDETERMINATE, and a requirement must not read as a build.
425
+ note = f"php {declared} is a requirement, not the running build"
426
+ else:
427
+ release = _release(declared)
428
+ if release is None:
429
+ signals["php_present"] = "true"
430
+ note = f"{declared} pins no patch release"
431
+ else:
432
+ version = f"{release.major}.{release.minor}.{release.micro}"
433
+ note = ""
434
+ return Evidence(Source.REPO, rel, version, _sapi(declared), signals, note)
435
+
436
+
437
+ def _finds(repo: str, rel: str, reader: _Reader, text: str) -> list[Evidence]:
438
+ """The evidence in one file's text. It never raises, whatever the text holds."""
439
+ try:
440
+ pins = reader.parse(text)
441
+ except Exception as exc:
442
+ # The parsers read hostile text. Whatever one bad file makes them raise ends that file,
443
+ # and the scan goes on with the next.
444
+ return [_skipped(repo, rel, f"cannot parse it ({type(exc).__name__})")]
445
+ fits = [pin for pin in pins if len(pin[0]) <= _MAX_TEXT]
446
+ found = [_evidence(repo, rel, reader, declared, confidence)
447
+ for declared, confidence in fits[:_MAX_PINS]]
448
+ if len(fits) > _MAX_PINS:
449
+ found.append(_skipped(
450
+ repo, rel, f"it names more than {_MAX_PINS} PHP pins, and only the first {_MAX_PINS} "
451
+ "were read"))
452
+ if len(fits) < len(pins):
453
+ over = len(pins) - len(fits)
454
+ found.append(_skipped(
455
+ repo, rel, f"it holds {over} {'value' if over == 1 else 'values'} over {_MAX_TEXT} "
456
+ "characters, too long to be read as a pin"))
457
+ return found
458
+
459
+
460
+ class _Skip(Exception):
461
+ """A file to pass over. The message is the reason to record."""
462
+
463
+
464
+ def _decode(data: bytes) -> str:
465
+ # PowerShell writes UTF-16 with a byte order mark when it redirects, and editors add a UTF-8
466
+ # one. A file in any other encoding still reads, with the odd character replaced, because the
467
+ # tokens that matter are ASCII.
468
+ if data.startswith((codecs.BOM_UTF16_LE, codecs.BOM_UTF16_BE)):
469
+ return data.decode("utf-16", errors="replace")
470
+ return data.decode("utf-8-sig", errors="replace")
471
+
472
+
473
+ def _read(path: str) -> str:
474
+ if not stat.S_ISREG(os.lstat(path).st_mode):
475
+ # A named pipe with no writer would block the open for good.
476
+ raise _Skip("it is not a regular file")
477
+ with open(path, "rb") as f:
478
+ # One byte past the cap tells a file at the limit from one over it. The size on disk is
479
+ # no guide, because a pseudo-file can report none and then never end.
480
+ data = f.read(_MAX_BYTES + 1)
481
+ if len(data) > _MAX_BYTES:
482
+ raise _Skip(f"it is larger than {_MAX_BYTES} bytes")
483
+ return _decode(data)
484
+
485
+
486
+ def _scan_file(repo: str, rel: str, path: str, reader: _Reader) -> list[Evidence]:
487
+ try:
488
+ text = _read(path)
489
+ except _Skip as skip:
490
+ return [_skipped(repo, rel, str(skip))]
491
+ except OSError as exc:
492
+ return [_skipped(repo, rel, f"cannot read it ({_why(exc)})")]
493
+ return _finds(repo, rel, reader, text)
494
+
495
+
496
+ def _is_plain(path: str) -> bool:
497
+ # scandir calls a Windows junction an ordinary folder, but realpath resolves it, and then the
498
+ # two spellings of the path differ.
499
+ return os.path.normcase(os.path.realpath(path)) == os.path.normcase(path)
500
+
501
+
502
+ def _inside(root: str, path: str) -> bool:
503
+ """True when path is root or sits under it. Both are resolved paths."""
504
+ base = os.path.normcase(root).rstrip(os.sep)
505
+ path = os.path.normcase(path)
506
+ # The separator after the root keeps /srv/site2 from counting as inside /srv/site. A
507
+ # filesystem root already ends in one, which is why the root is stripped before it is added.
508
+ return path == base or path.startswith(base + os.sep)
509
+
510
+
511
+ def _repo_name(top: str) -> str:
512
+ # The directory name keeps the id readable. Eight hex characters of a hash of the resolved path
513
+ # keep /a/app and /b/app apart in one inventory, and only those eight leave the machine, not
514
+ # the path. A filesystem root has no name, so its path stands in. The joiner is a tilde because
515
+ # the report masks the value in name:value when the name holds a word like auth or token.
516
+ digest = hashlib.sha256(os.fsencode(top)).hexdigest()[:8]
517
+ return f"{os.path.basename(top) or top}~{digest}"
518
+
519
+
520
+ def scan_path(root: str) -> list[Evidence]:
521
+ """One Evidence per PHP pin under root, in a fixed order, located relative to root.
522
+
523
+ The repo signal on each is the name of the directory scanned, a tilde, and eight hex characters
524
+ of a hash of its resolved path, so two checkouts that share a name stay two assets. Links
525
+ inside the tree are not followed. A linked folder that leads out of the tree, and a linked
526
+ file with a name the scan reads, are reported as skipped, and so is a file that cannot be read,
527
+ is too large, or will not parse, with the reason in note. A folder link that stays inside the
528
+ tree adds nothing, because the folder is read where it really sits. A skip counts as PHP seen
529
+ with no version, so a repo with one cannot grade as patched. A root that is itself a link is
530
+ resolved first. A root that cannot be listed raises OSError, because an empty list would read
531
+ as a clean repository.
532
+ """
533
+ top = os.path.realpath(root)
534
+ repo = _repo_name(top)
535
+ found: list[Evidence] = []
536
+ todo = [("", top)] # folders still to read, as (path relative to root, path on disk)
537
+ while todo:
538
+ rel_dir, directory = todo.pop()
539
+ try:
540
+ with os.scandir(directory) as entries:
541
+ listing = sorted(entries, key=lambda entry: entry.name)
542
+ except OSError as exc:
543
+ if not rel_dir:
544
+ raise
545
+ found.append(_skipped(repo, rel_dir, f"cannot list it ({_why(exc)})"))
546
+ continue
547
+ subdirs = []
548
+ for entry in listing:
549
+ rel = f"{rel_dir}/{entry.name}" if rel_dir else entry.name
550
+ try:
551
+ linked = entry.is_symlink()
552
+ folder = entry.is_dir(follow_symlinks=False)
553
+ # A link leads to a folder when its target is one. A dangling link leads nowhere.
554
+ leads_to_folder = linked and entry.is_dir()
555
+ except OSError as exc:
556
+ found.append(_skipped(repo, rel, f"cannot inspect it ({_why(exc)})"))
557
+ continue
558
+ if folder or leads_to_folder:
559
+ if entry.name in _SKIP_DIRS:
560
+ continue
561
+ if folder and _is_plain(entry.path):
562
+ subdirs.append((rel, entry.path))
563
+ elif not _inside(top, os.path.realpath(entry.path)):
564
+ # A symlink, or a junction that scandir takes for an ordinary folder, that
565
+ # leads out of this tree. Nothing in it is read, and a patched pin elsewhere
566
+ # must not clear the repo past it. A link that stays inside leads to a folder
567
+ # the walk reads where it really sits, so it is no gap and gets no row.
568
+ found.append(_skipped(repo, rel, "linked directory not traversed"))
569
+ continue
570
+ reader = _reader(rel)
571
+ if reader is None:
572
+ continue
573
+ if linked:
574
+ found.append(
575
+ _skipped(repo, rel, "it is a symbolic link, and links are not followed"))
576
+ else:
577
+ found += _scan_file(repo, rel, entry.path, reader)
578
+ todo.extend(reversed(subdirs))
579
+ return found
php_radar/versions.py ADDED
@@ -0,0 +1,51 @@
1
+ import string
2
+ from dataclasses import dataclass, field
3
+ from typing import Optional
4
+
5
+
6
+ def _num(s: str) -> Optional[int]:
7
+ # ASCII digits only, because int() also takes Arabic-Indic digits and "3_5". The length cap
8
+ # keeps a hostile header from tripping the int size limit on 3.11 and later, and no PHP
9
+ # component is this long.
10
+ return int(s) if s.isascii() and s.isdigit() and len(s) <= 4 else None
11
+
12
+
13
+ def parse_branch(text: str) -> Optional[tuple[int, int]]:
14
+ """Turn "8.3" into (8, 3). Anything that is not exactly major.minor is None."""
15
+ major, dot, minor = text.strip().partition(".")
16
+ a, b = _num(major), _num(minor)
17
+ return (a, b) if dot and a is not None and b is not None else None
18
+
19
+
20
+ @dataclass(frozen=True, order=True)
21
+ class PhpVersion:
22
+ major: int
23
+ minor: int
24
+ micro: int
25
+ # The text after the micro, such as -fpm or -1ubuntu2.28. It stays out of equality, hashing
26
+ # and ordering, so 8.3.35-fpm is the same version as 8.3.35. Only the grader reads it.
27
+ suffix: str = field(default="", compare=False)
28
+
29
+ @property
30
+ def branch(self) -> str:
31
+ return f"{self.major}.{self.minor}"
32
+
33
+ @classmethod
34
+ def parse(cls, text: str) -> Optional["PhpVersion"]:
35
+ """Read major.minor.micro off the front of text, ignoring a build suffix like -fpm.
36
+
37
+ A floating tag ("8.3-apache"), a constraint (">=8.3.36"), or a header line has no
38
+ single micro to grade, so none of them parse.
39
+ """
40
+ major, dot1, rest = text.strip().partition(".")
41
+ minor, dot2, tail = rest.partition(".")
42
+ if not (dot1 and dot2):
43
+ return None
44
+ suffix = tail.lstrip(string.digits)
45
+ micro = tail[:len(tail) - len(suffix)]
46
+ # What follows the micro has to be a build suffix (-fpm, +dfsg, RC1). A further number or
47
+ # a space means this is a range or a header line.
48
+ if suffix and suffix[0] not in "-+~_" + string.ascii_letters:
49
+ return None
50
+ nums = (_num(major), _num(minor), _num(micro))
51
+ return None if None in nums else cls(*nums, suffix)