primarysource 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.
- primarysource/__init__.py +5 -0
- primarysource/bbf/__init__.py +56 -0
- primarysource/bbf/_xml.py +63 -0
- primarysource/bbf/catalogue.json +777 -0
- primarysource/bbf/catalogue.py +137 -0
- primarysource/bbf/cli.py +79 -0
- primarysource/bbf/datamodel.py +281 -0
- primarysource/bbf/importer.py +151 -0
- primarysource/bbf/library.py +380 -0
- primarysource/bbf/server.py +72 -0
- primarysource/budget.py +63 -0
- primarysource/c2sp/__init__.py +53 -0
- primarysource/c2sp/_fetch.py +5 -0
- primarysource/c2sp/catalogue.json +231 -0
- primarysource/c2sp/catalogue.py +95 -0
- primarysource/c2sp/cli.py +62 -0
- primarysource/c2sp/importer.py +90 -0
- primarysource/c2sp/library.py +309 -0
- primarysource/c2sp/markdown.py +31 -0
- primarysource/c2sp/server.py +68 -0
- primarysource/cavp/__init__.py +42 -0
- primarysource/cavp/cli.py +84 -0
- primarysource/cavp/data/vectors.toml +47 -0
- primarysource/cavp/importer.py +156 -0
- primarysource/cavp/model.py +279 -0
- primarysource/cavp/parse.py +269 -0
- primarysource/cavp/paths.py +17 -0
- primarysource/cavp/server.py +67 -0
- primarysource/cavp/tools.py +169 -0
- primarysource/cli.py +107 -0
- primarysource/cursor.py +82 -0
- primarysource/cve/__init__.py +5 -0
- primarysource/cve/__main__.py +6 -0
- primarysource/cve/cli.py +144 -0
- primarysource/cve/collection.py +20 -0
- primarysource/cve/config.py +58 -0
- primarysource/cve/errors.py +16 -0
- primarysource/cve/kernel.py +232 -0
- primarysource/cve/mcp.py +139 -0
- primarysource/cve/parse.py +318 -0
- primarysource/cve/py.typed +0 -0
- primarysource/cve/service.py +760 -0
- primarysource/cve/store.py +214 -0
- primarysource/cve/sync.py +438 -0
- primarysource/cve/views.py +304 -0
- primarysource/debpolicy/__init__.py +16 -0
- primarysource/debpolicy/catalogue.py +263 -0
- primarysource/debpolicy/cli.py +12 -0
- primarysource/debpolicy/server.py +11 -0
- primarysource/draft/__init__.py +11 -0
- primarysource/draft/catalogue.py +117 -0
- primarysource/draft/cli.py +149 -0
- primarysource/draft/collection.py +21 -0
- primarysource/draft/datatracker.py +158 -0
- primarysource/draft/importer.py +163 -0
- primarysource/draft/licence.py +89 -0
- primarysource/draft/names.py +64 -0
- primarysource/draft/net.py +133 -0
- primarysource/draft/segment.py +192 -0
- primarysource/draft/server.py +91 -0
- primarysource/draft/status.py +146 -0
- primarysource/draft/store.py +199 -0
- primarysource/draft/tracked.json +104 -0
- primarysource/draft/views.py +430 -0
- primarysource/draft/xmlmeta.py +74 -0
- primarysource/ecma/__init__.py +52 -0
- primarysource/ecma/_pdf.py +143 -0
- primarysource/ecma/catalogue.json +22 -0
- primarysource/ecma/catalogue.py +100 -0
- primarysource/ecma/cli.py +67 -0
- primarysource/ecma/identify.py +25 -0
- primarysource/ecma/importer.py +108 -0
- primarysource/ecma/library.py +240 -0
- primarysource/ecma/server.py +67 -0
- primarysource/errors.py +29 -0
- primarysource/fetch.py +360 -0
- primarysource/fsutil.py +150 -0
- primarysource/gitdocs/__init__.py +15 -0
- primarysource/gitdocs/catalogue.py +116 -0
- primarysource/gitdocs/cli.py +12 -0
- primarysource/gitdocs/server.py +8 -0
- primarysource/home.py +65 -0
- primarysource/iana/__init__.py +32 -0
- primarysource/iana/catalogue.json +244 -0
- primarysource/iana/catalogue.py +69 -0
- primarysource/iana/cli.py +69 -0
- primarysource/iana/importer.py +157 -0
- primarysource/iana/model.py +325 -0
- primarysource/iana/reader.py +315 -0
- primarysource/iana/server.py +67 -0
- primarysource/iana/store.py +83 -0
- primarysource/iana/xmlsafe.py +47 -0
- primarysource/identify.py +186 -0
- primarysource/ieee/__init__.py +58 -0
- primarysource/ieee/_html.py +49 -0
- primarysource/ieee/catalogue.json +74 -0
- primarysource/ieee/catalogue.py +123 -0
- primarysource/ieee/cli.py +79 -0
- primarysource/ieee/identify.py +24 -0
- primarysource/ieee/ieee802.py +47 -0
- primarysource/ieee/importer.py +305 -0
- primarysource/ieee/posix.py +233 -0
- primarysource/ieee/server.py +79 -0
- primarysource/index.py +446 -0
- primarysource/iso/__init__.py +51 -0
- primarysource/iso/cli.py +98 -0
- primarysource/iso/designation.py +95 -0
- primarysource/iso/identify.py +100 -0
- primarysource/iso/importer.py +224 -0
- primarysource/iso/library.py +249 -0
- primarysource/iso/server.py +69 -0
- primarysource/itu/__init__.py +41 -0
- primarysource/itu/__main__.py +8 -0
- primarysource/itu/asn1mod.py +478 -0
- primarysource/itu/catalog.py +472 -0
- primarysource/itu/cli.py +445 -0
- primarysource/itu/collection.py +20 -0
- primarysource/itu/corpus.py +118 -0
- primarysource/itu/errata.py +16 -0
- primarysource/itu/formal.py +565 -0
- primarysource/itu/identify.py +26 -0
- primarysource/itu/index.py +490 -0
- primarysource/itu/mcp.py +474 -0
- primarysource/itu/py.typed +0 -0
- primarysource/itu/sources.py +600 -0
- primarysource/itu/text.py +609 -0
- primarysource/kconfig/__init__.py +16 -0
- primarysource/kconfig/catalogue.py +98 -0
- primarysource/kconfig/cli.py +12 -0
- primarysource/kconfig/server.py +8 -0
- primarysource/model.py +63 -0
- primarysource/nist/__init__.py +5 -0
- primarysource/nist/collection.py +46 -0
- primarysource/nist/data/acquired-nist.json +1657 -0
- primarysource/nist/data/nist-catalog.json +12090 -0
- primarysource/nist/data/sources.toml +1996 -0
- primarysource/nist/identify.py +41 -0
- primarysource/nist/ingest/__init__.py +1 -0
- primarysource/nist/ingest/acquire_nist.py +128 -0
- primarysource/nist/ingest/controls.py +82 -0
- primarysource/nist/ingest/discover_nist.py +218 -0
- primarysource/nist/ingest/extract.py +48 -0
- primarysource/nist/ingest/fetch.py +211 -0
- primarysource/nist/ingest/index.py +14 -0
- primarysource/nist/ingest/oscal.py +480 -0
- primarysource/nist/ingest/prose_layouts.py +236 -0
- primarysource/nist/ingest/sources.py +94 -0
- primarysource/nist/ingest/status.py +255 -0
- primarysource/nist/models.py +7 -0
- primarysource/nist/paths.py +33 -0
- primarysource/nist/server/__init__.py +3 -0
- primarysource/nist/server/corpus.py +38 -0
- primarysource/nist/server/mcp.py +19 -0
- primarysource/nist/server/tools.py +35 -0
- primarysource/nist/spec.py +38 -0
- primarysource/pdfcorpus/__init__.py +16 -0
- primarysource/pdfcorpus/corpus.py +159 -0
- primarysource/pdfcorpus/fts.py +175 -0
- primarysource/pdfcorpus/index.py +70 -0
- primarysource/pdfcorpus/models.py +30 -0
- primarysource/pdfcorpus/server.py +96 -0
- primarysource/pdfcorpus/spec.py +37 -0
- primarysource/pdfcorpus/split.py +391 -0
- primarysource/pdfcorpus/tools.py +244 -0
- primarysource/pep/__init__.py +15 -0
- primarysource/pep/catalogue.py +191 -0
- primarysource/pep/cli.py +12 -0
- primarysource/pep/server.py +8 -0
- primarysource/pkgdocs/__init__.py +19 -0
- primarysource/pkgdocs/fetch.py +356 -0
- primarysource/pkgdocs/index.py +89 -0
- primarysource/pkgdocs/model.py +76 -0
- primarysource/pkgdocs/readers/__init__.py +1 -0
- primarysource/pkgdocs/readers/_common.py +134 -0
- primarysource/pkgdocs/readers/asciidoc.py +160 -0
- primarysource/pkgdocs/readers/latex.py +315 -0
- primarysource/pkgdocs/readers/markdown.py +173 -0
- primarysource/pkgdocs/readers/pod.py +132 -0
- primarysource/pkgdocs/readers/rst.py +239 -0
- primarysource/pkgdocs/readers/scdoc.py +108 -0
- primarysource/pkgdocs/runtime.py +552 -0
- primarysource/pkgdocs/serving.py +158 -0
- primarysource/pkgdocs/store.py +167 -0
- primarysource/pms/__init__.py +16 -0
- primarysource/pms/catalogue.py +88 -0
- primarysource/pms/cli.py +12 -0
- primarysource/pms/server.py +8 -0
- primarysource/py.typed +0 -0
- primarysource/readers/__init__.py +10 -0
- primarysource/readers/bookmarks.py +192 -0
- primarysource/readers/html.py +1312 -0
- primarysource/readers/markdown.py +189 -0
- primarysource/readers/pdf.py +187 -0
- primarysource/readers/xml.py +230 -0
- primarysource/refs.py +87 -0
- primarysource/registry.py +161 -0
- primarysource/render.py +49 -0
- primarysource/rfc/__init__.py +3 -0
- primarysource/rfc/__main__.py +6 -0
- primarysource/rfc/cite.py +204 -0
- primarysource/rfc/cli.py +903 -0
- primarysource/rfc/collection.py +19 -0
- primarysource/rfc/config.py +138 -0
- primarysource/rfc/errata.py +203 -0
- primarysource/rfc/fsutil.py +82 -0
- primarysource/rfc/graph.py +213 -0
- primarysource/rfc/index/__init__.py +1 -0
- primarysource/rfc/index/chunker.py +281 -0
- primarysource/rfc/index/embed.py +166 -0
- primarysource/rfc/index/hybrid.py +305 -0
- primarysource/rfc/index/lexical.py +223 -0
- primarysource/rfc/index/rfcxml.py +673 -0
- primarysource/rfc/index/search.py +508 -0
- primarysource/rfc/index/txtseg.py +585 -0
- primarysource/rfc/library.py +294 -0
- primarysource/rfc/mcp/__init__.py +1 -0
- primarysource/rfc/mcp/server.py +381 -0
- primarysource/rfc/packs/__init__.py +1 -0
- primarysource/rfc/packs/builder.py +228 -0
- primarysource/rfc/packs/collections/all-current-txt.json +10 -0
- primarysource/rfc/packs/collections/essentials.json +19 -0
- primarysource/rfc/packs/collections/fyi-current.json +10 -0
- primarysource/rfc/packs/collections/standards-current.json +10 -0
- primarysource/rfc/packs/collections/year-2025.json +10 -0
- primarysource/rfc/packs/collections/year-2026.json +10 -0
- primarysource/rfc/packs/collections.py +163 -0
- primarysource/rfc/packs/docsetexport.py +163 -0
- primarysource/rfc/packs/epub.py +159 -0
- primarysource/rfc/packs/export.py +306 -0
- primarysource/rfc/packs/htmlexport.py +82 -0
- primarysource/rfc/packs/serve.py +143 -0
- primarysource/rfc/packs/zimexport.py +171 -0
- primarysource/rfc/query.py +466 -0
- primarysource/rfc/subjects.py +457 -0
- primarysource/rfc/sync/__init__.py +1 -0
- primarysource/rfc/sync/downloader.py +132 -0
- primarysource/rfc/sync/manifest.py +162 -0
- primarysource/rfc/sync/scanner.py +49 -0
- primarysource/rfc/sync/state.py +464 -0
- primarysource/rfc/views.py +1067 -0
- primarysource/rpmdocs/__init__.py +15 -0
- primarysource/rpmdocs/catalogue.py +117 -0
- primarysource/rpmdocs/cli.py +12 -0
- primarysource/rpmdocs/server.py +8 -0
- primarysource/scaffold.py +223 -0
- primarysource/serve.py +75 -0
- primarysource/settings.py +152 -0
- primarysource/usb/__init__.py +5 -0
- primarysource/usb/collection.py +37 -0
- primarysource/usb/data/acquired.json +2025 -0
- primarysource/usb/data/sources-usb.toml +852 -0
- primarysource/usb/data/sources.toml +10 -0
- primarysource/usb/data/usb-catalog.json +1663 -0
- primarysource/usb/data/usb-classification.json +1986 -0
- primarysource/usb/data/usb-corpus-build.json +608 -0
- primarysource/usb/data/usb-pdf-choice.json +839 -0
- primarysource/usb/data/usb-split-measure.json +1803 -0
- primarysource/usb/ingest/__init__.py +1 -0
- primarysource/usb/ingest/acquire.py +130 -0
- primarysource/usb/ingest/discover_usb.py +88 -0
- primarysource/usb/ingest/extract.py +48 -0
- primarysource/usb/ingest/fetch.py +197 -0
- primarysource/usb/ingest/index.py +14 -0
- primarysource/usb/ingest/sources.py +85 -0
- primarysource/usb/models.py +7 -0
- primarysource/usb/paths.py +33 -0
- primarysource/usb/server/__init__.py +3 -0
- primarysource/usb/server/corpus.py +38 -0
- primarysource/usb/server/mcp.py +19 -0
- primarysource/usb/server/tools.py +35 -0
- primarysource/usb/spec.py +20 -0
- primarysource/usbids/__init__.py +56 -0
- primarysource/usbids/cli.py +65 -0
- primarysource/usbids/importer.py +272 -0
- primarysource/usbids/parse.py +283 -0
- primarysource/usbids/server.py +55 -0
- primarysource/usbids/store.py +80 -0
- primarysource/usbids/tools.py +174 -0
- primarysource/zim/__init__.py +5 -0
- primarysource/zim/__main__.py +8 -0
- primarysource/zim/_libzim.py +37 -0
- primarysource/zim/catalog.py +594 -0
- primarysource/zim/cli.py +555 -0
- primarysource/zim/collection.py +41 -0
- primarysource/zim/errors.py +100 -0
- primarysource/zim/groups.py +30 -0
- primarysource/zim/library.py +668 -0
- primarysource/zim/manage.py +636 -0
- primarysource/zim/markdown.py +1247 -0
- primarysource/zim/mcp.py +201 -0
- primarysource/zim/purl.py +130 -0
- primarysource/zim/py.typed +0 -0
- primarysource/zim/refs.py +90 -0
- primarysource/zim/service.py +1182 -0
- primarysource/zim/snapshot.py +197 -0
- primarysource/zim/sync.py +269 -0
- primarysource/zim/views.py +230 -0
- primarysource-0.1.0.dist-info/METADATA +118 -0
- primarysource-0.1.0.dist-info/RECORD +303 -0
- primarysource-0.1.0.dist-info/WHEEL +4 -0
- primarysource-0.1.0.dist-info/entry_points.txt +2 -0
- primarysource-0.1.0.dist-info/licenses/LICENSE +203 -0
- primarysource-0.1.0.dist-info/licenses/NOTICE +51 -0
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""How the nist collection recognises a NIST publication in a supplied PDF (`registry.Collection.identify`).
|
|
2
|
+
|
|
3
|
+
Covers read `NIST Special Publication 800-53` with `Revision 5` near it, or `FIPS PUB 201-3`; running headers
|
|
4
|
+
and DOIs repeat `NIST SP 800-53r5`. The nist collection fetches its own pinned copies (`data/sources.toml`), so
|
|
5
|
+
the command it suggests is the fetch.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
|
|
12
|
+
from primarysource.identify import Candidate, Probe, candidates
|
|
13
|
+
|
|
14
|
+
_SP = re.compile(
|
|
15
|
+
r"\bNIST\s+(?:Special\s+Publication|SP)\s+(?P<num>800-\d{1,3}(?:(?-i:[A-Z])(?!\d))?)"
|
|
16
|
+
r"(?:(?:\s*,?\s*(?:Revision|Rev\.?)\s*|r)(?P<rev>\d{1,2}))?",
|
|
17
|
+
re.IGNORECASE,
|
|
18
|
+
)
|
|
19
|
+
_FIPS = re.compile(
|
|
20
|
+
r"\b(?:FIPS|Federal\s+Information\s+Processing\s+Standards?\s+Publication)\s+(?:PUB\s+)?(?P<num>\d{2,3}(?:-\d{1,2})?)\b",
|
|
21
|
+
re.IGNORECASE,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _sp(match: re.Match[str]) -> str:
|
|
26
|
+
rev = f" Rev. {match['rev']}" if match["rev"] else ""
|
|
27
|
+
return f"NIST SP {match['num'].upper()}{rev}"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _fips(match: re.Match[str]) -> str:
|
|
31
|
+
return f"NIST FIPS {match['num']}"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def identify(probe: Probe) -> list[Candidate]:
|
|
35
|
+
def command(_label: str) -> str:
|
|
36
|
+
return "primarysource nist fetch (nist fetches its own pinned copies listed in data/sources.toml)"
|
|
37
|
+
|
|
38
|
+
return [
|
|
39
|
+
*candidates(probe, "nist", _SP, _sp, command),
|
|
40
|
+
*candidates(probe, "nist", _FIPS, _fips, command),
|
|
41
|
+
]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Ingest side of nistlib: fetch the publications listed in data/sources.toml and index them."""
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""Download the in-scope PDFs named in data/nist-catalog.json into data/corpus/<doc_id>.pdf.
|
|
2
|
+
|
|
3
|
+
Each download goes to a .part file and is renamed only when complete. The result must start with ``%PDF``.
|
|
4
|
+
data/acquired-nist.json records url, path, size and sha256 per file. A file already present whose sha256
|
|
5
|
+
matches the manifest is skipped. Requests are throttled to one per second with a generic User-Agent.
|
|
6
|
+
Pass --dry-run to print the plan and the total size (from HEAD requests) before downloading anything.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from primarysource.nist.paths import PACKAGE_DATA
|
|
12
|
+
|
|
13
|
+
import argparse
|
|
14
|
+
import hashlib
|
|
15
|
+
import json
|
|
16
|
+
import sys
|
|
17
|
+
import time
|
|
18
|
+
import urllib.error
|
|
19
|
+
import urllib.request
|
|
20
|
+
from collections.abc import Sequence
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
USER_AGENT = "nistlib-acquire/0.1 (documentation corpus)"
|
|
24
|
+
DELAY = 1.0
|
|
25
|
+
CHUNK = 1 << 20
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _size(url: str) -> int | None:
|
|
29
|
+
req = urllib.request.Request(url, method="HEAD", headers={"User-Agent": USER_AGENT})
|
|
30
|
+
try:
|
|
31
|
+
with urllib.request.urlopen(req, timeout=60) as resp:
|
|
32
|
+
length = resp.headers.get("Content-Length")
|
|
33
|
+
return int(length) if length else None
|
|
34
|
+
except (urllib.error.URLError, TimeoutError):
|
|
35
|
+
return None
|
|
36
|
+
finally:
|
|
37
|
+
time.sleep(DELAY)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _sha256_file(path: Path) -> str:
|
|
41
|
+
digest = hashlib.sha256()
|
|
42
|
+
with path.open("rb") as f:
|
|
43
|
+
while chunk := f.read(CHUNK):
|
|
44
|
+
digest.update(chunk)
|
|
45
|
+
return digest.hexdigest()
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _download(url: str, dest: Path) -> tuple[int, str]:
|
|
49
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
50
|
+
part = dest.with_suffix(dest.suffix + ".part")
|
|
51
|
+
digest = hashlib.sha256()
|
|
52
|
+
size = 0
|
|
53
|
+
req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})
|
|
54
|
+
try:
|
|
55
|
+
with urllib.request.urlopen(req, timeout=300) as resp, part.open("wb") as out:
|
|
56
|
+
while chunk := resp.read(CHUNK):
|
|
57
|
+
if size == 0 and not chunk.startswith(b"%PDF"):
|
|
58
|
+
raise OSError("response does not start with %PDF")
|
|
59
|
+
out.write(chunk)
|
|
60
|
+
digest.update(chunk)
|
|
61
|
+
size += len(chunk)
|
|
62
|
+
if size == 0:
|
|
63
|
+
raise OSError("empty response")
|
|
64
|
+
part.replace(dest)
|
|
65
|
+
finally:
|
|
66
|
+
part.unlink(missing_ok=True)
|
|
67
|
+
time.sleep(DELAY)
|
|
68
|
+
return size, digest.hexdigest()
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
72
|
+
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
|
73
|
+
parser.add_argument("--catalog", type=Path, default=PACKAGE_DATA / "nist-catalog.json")
|
|
74
|
+
parser.add_argument("--data-dir", type=Path, default=Path("data"))
|
|
75
|
+
parser.add_argument("--dry-run", action="store_true")
|
|
76
|
+
args = parser.parse_args(argv)
|
|
77
|
+
catalog = json.loads(args.catalog.read_text(encoding="utf-8"))
|
|
78
|
+
items = [e for e in catalog["entries"] if e["in_scope"]]
|
|
79
|
+
if args.dry_run:
|
|
80
|
+
total = 0
|
|
81
|
+
for e in items:
|
|
82
|
+
size = _size(e["pdf_url"])
|
|
83
|
+
total += size or 0
|
|
84
|
+
print(f"{e['doc_id']:<20} {size or '?':>10} {e['pdf_url']}")
|
|
85
|
+
print(f"{len(items)} files, {total / 1e9:.2f} GB known", file=sys.stderr)
|
|
86
|
+
return 0
|
|
87
|
+
manifest_path = args.data_dir / "acquired-nist.json"
|
|
88
|
+
manifest = (
|
|
89
|
+
json.loads(manifest_path.read_text(encoding="utf-8"))
|
|
90
|
+
if manifest_path.exists()
|
|
91
|
+
else {"schema": 1, "files": []}
|
|
92
|
+
)
|
|
93
|
+
by_id = {f["doc_id"]: f for f in manifest["files"]}
|
|
94
|
+
failed = 0
|
|
95
|
+
for e in items:
|
|
96
|
+
doc_id = e["doc_id"]
|
|
97
|
+
dest = args.data_dir / "corpus" / f"{doc_id}.pdf"
|
|
98
|
+
old = by_id.get(doc_id)
|
|
99
|
+
if old and dest.exists() and _sha256_file(dest) == old["sha256"]:
|
|
100
|
+
continue
|
|
101
|
+
try:
|
|
102
|
+
if (
|
|
103
|
+
dest.exists() and not old
|
|
104
|
+
): # a file placed earlier (the PIV set): record it, never overwrite
|
|
105
|
+
size, sha = dest.stat().st_size, _sha256_file(dest)
|
|
106
|
+
else:
|
|
107
|
+
size, sha = _download(e["pdf_url"], dest)
|
|
108
|
+
except (urllib.error.URLError, OSError, TimeoutError) as err:
|
|
109
|
+
print(f"FAILED {e['pdf_url']}: {err}", file=sys.stderr)
|
|
110
|
+
failed += 1
|
|
111
|
+
continue
|
|
112
|
+
record = {
|
|
113
|
+
"doc_id": doc_id,
|
|
114
|
+
"url": e["pdf_url"],
|
|
115
|
+
"path": f"corpus/{doc_id}.pdf",
|
|
116
|
+
"size": size,
|
|
117
|
+
"sha256": sha,
|
|
118
|
+
}
|
|
119
|
+
by_id[doc_id] = record
|
|
120
|
+
manifest["files"] = list(by_id.values())
|
|
121
|
+
manifest_path.write_text(json.dumps(manifest, indent=2), encoding="utf-8")
|
|
122
|
+
print(f"ok {doc_id} {size}", file=sys.stderr)
|
|
123
|
+
print(f"{len(items) - failed} ok, {failed} failed", file=sys.stderr)
|
|
124
|
+
return 1 if failed else 0
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
if __name__ == "__main__":
|
|
128
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""Section splitter for NIST control catalogues printed as PDF (SP 800-53 and its relatives).
|
|
2
|
+
|
|
3
|
+
Control catalogues do not number their sections the way prose documents do. A control is written as its
|
|
4
|
+
identifier followed by an upper-case title (``AC-2 ACCOUNT MANAGEMENT``), and its enhancements are written
|
|
5
|
+
relative to the parent (``(1) ACCOUNT MANAGEMENT | AUTOMATED ...`` under ``AC-2``, which is ``ac-2.1``).
|
|
6
|
+
This splitter recognises both, keeps the first occurrence of each identifier (later ones are the appendix
|
|
7
|
+
summary tables), and accepts a control identifier only where it is anchored: at the start of a line, after a
|
|
8
|
+
run of spaces, or after the page header. Prose that mentions a control in capitals is not a heading. An enhancement heading may also be glued to
|
|
9
|
+
the end of the page header when it opens a page, and is accepted there for the same reason.
|
|
10
|
+
|
|
11
|
+
Where the text has a ``-1`` control, indexing starts at the first one. The worked example of a control's structure in the introduction
|
|
12
|
+
(Figure 1 prints AU-4 and its first enhancement) would otherwise take those two ids, and the real AU-4 would
|
|
13
|
+
then be dropped as a repeat.
|
|
14
|
+
|
|
15
|
+
Validated against the XML catalogue of SP 800-53 Rev 5 (see the -oscal entries): every identifier found is in
|
|
16
|
+
the catalogue, with no false positives.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import re
|
|
22
|
+
|
|
23
|
+
from primarysource.nist.models import Section
|
|
24
|
+
|
|
25
|
+
_CONTROL = re.compile(r"([A-Z]{2})-(\d+)(?:\((\d+)\))?\s{1,6}([A-Z][A-Z ,;:\-/'—]{3,})")
|
|
26
|
+
_CHILD = re.compile(
|
|
27
|
+
r"^(?:\s*|.*(?:doi\.org|NIST\.SP)\S*\s+)\((\d+)\)\s+([A-Z][A-Z ,;:\-/'—|&]{3,})"
|
|
28
|
+
)
|
|
29
|
+
_PAGE_HEADER = ("doi.org", "NIST.SP")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _anchored(line: str, start: int) -> bool:
|
|
33
|
+
before = line[:start]
|
|
34
|
+
return (
|
|
35
|
+
before.strip() == ""
|
|
36
|
+
or before.endswith(" ")
|
|
37
|
+
or any(h in before for h in _PAGE_HEADER)
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _heading_at(line: str, parent: str | None) -> tuple[str, str, bool] | None:
|
|
42
|
+
"""Return (control id, title, is_parent) when ``line`` opens a control, else None."""
|
|
43
|
+
for m in _CONTROL.finditer(line):
|
|
44
|
+
if _anchored(line, m.start()):
|
|
45
|
+
base = f"{m.group(1).lower()}-{m.group(2)}"
|
|
46
|
+
if m.group(3):
|
|
47
|
+
return f"{base}.{m.group(3)}", m.group(4).strip(), False
|
|
48
|
+
return base, m.group(4).strip(), True
|
|
49
|
+
child = _CHILD.match(line)
|
|
50
|
+
if child and parent:
|
|
51
|
+
return f"{parent}.{child.group(1)}", child.group(2).strip(), False
|
|
52
|
+
return None
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def split_control_sections(text: str) -> tuple[Section, ...]:
|
|
56
|
+
"""One Section per control or enhancement, in document order, each running to the next heading."""
|
|
57
|
+
lines = text.split("\n")
|
|
58
|
+
if lines and lines[-1] == "":
|
|
59
|
+
lines.pop()
|
|
60
|
+
found: list[tuple[int, str, str, bool]] = []
|
|
61
|
+
parent: str | None = None
|
|
62
|
+
for number, line in enumerate(lines, start=1):
|
|
63
|
+
hit = _heading_at(line, parent)
|
|
64
|
+
if hit is None:
|
|
65
|
+
continue
|
|
66
|
+
if hit[2]:
|
|
67
|
+
parent = hit[0]
|
|
68
|
+
found.append((number, *hit))
|
|
69
|
+
first = next((i for i, f in enumerate(found) if f[3] and f[1].endswith("-1")), 0)
|
|
70
|
+
heads: list[tuple[int, str, str]] = []
|
|
71
|
+
seen: set[str] = set()
|
|
72
|
+
for number, control_id, title, _ in found[first:]:
|
|
73
|
+
if control_id not in seen:
|
|
74
|
+
seen.add(control_id)
|
|
75
|
+
heads.append((number, control_id, title))
|
|
76
|
+
sections: list[Section] = []
|
|
77
|
+
for index, (start, control_id, title) in enumerate(heads):
|
|
78
|
+
end = heads[index + 1][0] - 1 if index + 1 < len(heads) else len(lines)
|
|
79
|
+
sections.append(
|
|
80
|
+
Section(id=control_id, heading=title, start_line=start, end_line=end)
|
|
81
|
+
)
|
|
82
|
+
return tuple(sections)
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
"""Build data/nist-catalog.json: every FIPS and SP 800 / SP 1800 publication CSRC lists, marked in or out.
|
|
2
|
+
|
|
3
|
+
CSRC has no machine-readable feed, but its publications search is server-rendered and takes
|
|
4
|
+
``ipp-lg=all``, so one request per series returns the whole listing (FIPS: 70 rows, SP: 841 rows).
|
|
5
|
+
Candidates (listing status Final, FIPS or SP 800 or SP 1800) then get one detail-page request each,
|
|
6
|
+
for the PDF link and any supersession notice. Requests are throttled to one per second with a generic
|
|
7
|
+
User-Agent. Nothing is downloaded except HTML pages; PDFs are fetched by ``acquire_nist``.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from primarysource.nist.paths import PACKAGE_DATA
|
|
13
|
+
|
|
14
|
+
import argparse
|
|
15
|
+
import html
|
|
16
|
+
import json
|
|
17
|
+
import re
|
|
18
|
+
import sys
|
|
19
|
+
import time
|
|
20
|
+
import urllib.error
|
|
21
|
+
import urllib.request
|
|
22
|
+
from collections.abc import Sequence
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
|
|
25
|
+
BASE = "https://csrc.nist.gov"
|
|
26
|
+
LISTING = (
|
|
27
|
+
BASE + "/publications/search?sortBy-lg=Number%20ASC&viewMode-lg=brief&ipp-lg=all"
|
|
28
|
+
"&series-lg={series}&status-lg=Final&status-lg=Draft&status-lg=Withdrawn"
|
|
29
|
+
)
|
|
30
|
+
USER_AGENT = "nistlib-discover/0.1 (documentation catalogue)"
|
|
31
|
+
DELAY = 1.0
|
|
32
|
+
ROW = re.compile(r'<tr id="result-\d+">(.*?)</tr>', re.S)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def fetch_text(url: str) -> str | None:
|
|
36
|
+
req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})
|
|
37
|
+
try:
|
|
38
|
+
with urllib.request.urlopen(req, timeout=120) as resp:
|
|
39
|
+
return resp.read().decode("utf-8", errors="replace")
|
|
40
|
+
except (urllib.error.URLError, TimeoutError) as e:
|
|
41
|
+
print(f" {url}: {e}", file=sys.stderr)
|
|
42
|
+
return None
|
|
43
|
+
finally:
|
|
44
|
+
time.sleep(DELAY)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _clean(s: str) -> str:
|
|
48
|
+
return re.sub(r"\s+", " ", html.unescape(re.sub(r"<[^>]+>", " ", s))).strip()
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def parse_listing(page: str) -> list[dict]:
|
|
52
|
+
rows = []
|
|
53
|
+
for row in ROW.findall(page):
|
|
54
|
+
|
|
55
|
+
def g(pattern: str, row: str = row) -> str:
|
|
56
|
+
m = re.search(pattern, row, re.S)
|
|
57
|
+
return _clean(m.group(1)) if m else ""
|
|
58
|
+
|
|
59
|
+
rows.append(
|
|
60
|
+
{
|
|
61
|
+
"series": g(r'id="pub-series-\d+">(.*?)</td>'),
|
|
62
|
+
"number": g(r'id="pub-number-\d+">(.*?)</td>'),
|
|
63
|
+
"title": g(r'id="pub-title-link-\d+">(.*?)</a>'),
|
|
64
|
+
"status": g(r'id="pub-status-\d+">(.*?)</td>'),
|
|
65
|
+
"date": g(r'id="pub-release-date-\d+">(.*?)</td>'),
|
|
66
|
+
"href": g(r'href="(/pubs/[^"]+)"'),
|
|
67
|
+
}
|
|
68
|
+
)
|
|
69
|
+
return rows
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def parse_detail(page: str) -> dict:
|
|
73
|
+
text = _clean(
|
|
74
|
+
re.sub(r"<script.*?</script>|<style.*?</style>", "", page, flags=re.S)
|
|
75
|
+
)
|
|
76
|
+
pdf = re.search(r'href="([^"]+)"[^>]*id="pub-local-download-link"', page)
|
|
77
|
+
head = re.search(
|
|
78
|
+
r"Date Published:(.*?)(?:Planning Note|Author\(s\)|Abstract)", text
|
|
79
|
+
)
|
|
80
|
+
sup = re.search(
|
|
81
|
+
r"Superseded By:\s*(.*?)(?:Supersedes:|Planning Note|Author\(s\))", text
|
|
82
|
+
)
|
|
83
|
+
return {
|
|
84
|
+
"pdf_url": pdf.group(1) if pdf else "",
|
|
85
|
+
"date_published": _clean(head.group(1)) if head else "",
|
|
86
|
+
"superseded_by": _clean(sup.group(1)) if sup else "",
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def parse_href(href: str) -> dict | None:
|
|
91
|
+
"""Split /pubs/<series>/<number>/... into the parts a doc_id and a revision order are built from."""
|
|
92
|
+
parts = href.removeprefix("/pubs/").removesuffix("/final").split("/")
|
|
93
|
+
if parts[0] == "fips":
|
|
94
|
+
num, _, rev = parts[1].partition("-")
|
|
95
|
+
return {
|
|
96
|
+
"base": f"fips{num}",
|
|
97
|
+
"rev": int(rev) if rev.isdigit() else 0,
|
|
98
|
+
"doc_id": f"fips{parts[1]}",
|
|
99
|
+
}
|
|
100
|
+
if parts[0] != "sp" or parts[1] not in {"800", "1800"}:
|
|
101
|
+
return None
|
|
102
|
+
if parts[1] == "1800":
|
|
103
|
+
return {"base": f"sp1800-{parts[2]}", "rev": 0, "doc_id": f"sp1800-{parts[2]}"}
|
|
104
|
+
base = f"sp800-{parts[2]}"
|
|
105
|
+
rev_tok = ""
|
|
106
|
+
rev = 0
|
|
107
|
+
sup = False
|
|
108
|
+
for tok in parts[3:]:
|
|
109
|
+
if re.fullmatch(r"[a-z]", tok) or re.fullmatch(r"(pt|v)\d+", tok):
|
|
110
|
+
base += tok
|
|
111
|
+
elif m := re.fullmatch(r"(r|ver)(\d+)", tok):
|
|
112
|
+
rev, rev_tok = int(m.group(2)), tok
|
|
113
|
+
elif tok.isdigit():
|
|
114
|
+
rev, rev_tok = int(tok), f"-{tok}"
|
|
115
|
+
elif tok == "sup":
|
|
116
|
+
sup = True
|
|
117
|
+
elif re.fullmatch(r"upd\d+", tok):
|
|
118
|
+
pass
|
|
119
|
+
else:
|
|
120
|
+
return None
|
|
121
|
+
if sup:
|
|
122
|
+
base += "-add"
|
|
123
|
+
return {"base": base, "rev": rev, "doc_id": base + rev_tok}
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def classify(entries: list[dict]) -> None:
|
|
127
|
+
"""Set entry['in_scope'] and entry['reason'] for every entry, in place."""
|
|
128
|
+
best: dict[str, int] = {}
|
|
129
|
+
for e in entries:
|
|
130
|
+
p = e["parts"]
|
|
131
|
+
if e["candidate"] and p and not e["superseded_by"]:
|
|
132
|
+
best[p["base"]] = max(best.get(p["base"], 0), p["rev"])
|
|
133
|
+
for e in entries:
|
|
134
|
+
p = e["parts"]
|
|
135
|
+
if e["series"] not in {"FIPS", "SP 800", "SP 1800"}:
|
|
136
|
+
e["in_scope"], e["reason"] = False, f"outside scope: {e['series']} series"
|
|
137
|
+
elif e["status"] != "Final":
|
|
138
|
+
e["in_scope"], e["reason"] = False, f"status {e['status']}"
|
|
139
|
+
elif e["superseded_by"]:
|
|
140
|
+
e["in_scope"], e["reason"] = False, f"superseded by {e['superseded_by']}"
|
|
141
|
+
elif p and p["rev"] < best.get(p["base"], 0):
|
|
142
|
+
e["in_scope"], e["reason"] = (
|
|
143
|
+
False,
|
|
144
|
+
"older revision; a later final revision is listed",
|
|
145
|
+
)
|
|
146
|
+
elif not e["pdf_url"]:
|
|
147
|
+
e["in_scope"], e["reason"] = False, "no PDF link on CSRC page"
|
|
148
|
+
else:
|
|
149
|
+
e["in_scope"], e["reason"] = True, "final and current"
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def series_group(row: dict) -> str:
|
|
153
|
+
if row["series"] == "FIPS":
|
|
154
|
+
return "FIPS"
|
|
155
|
+
return (
|
|
156
|
+
f"SP {row['number'].split('-')[0]}" if row["series"] == "SP" else row["series"]
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
161
|
+
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
|
162
|
+
parser.add_argument("--out", type=Path, default=PACKAGE_DATA / "nist-catalog.json")
|
|
163
|
+
args = parser.parse_args(argv)
|
|
164
|
+
rows: list[dict] = []
|
|
165
|
+
for series in ("FIPS", "SP"):
|
|
166
|
+
page = fetch_text(LISTING.format(series=series))
|
|
167
|
+
if page is None:
|
|
168
|
+
print(f"cannot list {series}", file=sys.stderr)
|
|
169
|
+
return 1
|
|
170
|
+
found = parse_listing(page)
|
|
171
|
+
print(f"{series}: {len(found)} rows", file=sys.stderr)
|
|
172
|
+
rows += found
|
|
173
|
+
entries = []
|
|
174
|
+
for row in rows:
|
|
175
|
+
group = series_group(row)
|
|
176
|
+
parts = parse_href(row["href"])
|
|
177
|
+
candidate = (
|
|
178
|
+
row["status"] == "Final"
|
|
179
|
+
and group in {"FIPS", "SP 800", "SP 1800"}
|
|
180
|
+
and parts is not None
|
|
181
|
+
)
|
|
182
|
+
entry = {
|
|
183
|
+
"series": group,
|
|
184
|
+
"number": row["number"],
|
|
185
|
+
"title": row["title"],
|
|
186
|
+
"date": row["date"],
|
|
187
|
+
"status": row["status"],
|
|
188
|
+
"detail_url": BASE + row["href"],
|
|
189
|
+
"pdf_url": "",
|
|
190
|
+
"superseded_by": "",
|
|
191
|
+
"doc_id": parts["doc_id"] if parts else "",
|
|
192
|
+
"candidate": candidate,
|
|
193
|
+
"parts": parts,
|
|
194
|
+
}
|
|
195
|
+
if candidate:
|
|
196
|
+
page = fetch_text(entry["detail_url"])
|
|
197
|
+
if page is None:
|
|
198
|
+
print(f"detail failed: {entry['detail_url']}", file=sys.stderr)
|
|
199
|
+
else:
|
|
200
|
+
d = parse_detail(page)
|
|
201
|
+
entry.update(pdf_url=d["pdf_url"], superseded_by=d["superseded_by"])
|
|
202
|
+
if d["date_published"]:
|
|
203
|
+
entry["date_published"] = d["date_published"]
|
|
204
|
+
entries.append(entry)
|
|
205
|
+
classify(entries)
|
|
206
|
+
for e in entries:
|
|
207
|
+
e.pop("candidate")
|
|
208
|
+
e.pop("parts")
|
|
209
|
+
args.out.parent.mkdir(parents=True, exist_ok=True)
|
|
210
|
+
doc = {"schema": 1, "source": LISTING.format(series="FIPS|SP"), "entries": entries}
|
|
211
|
+
args.out.write_text(json.dumps(doc, indent=2, ensure_ascii=False), encoding="utf-8")
|
|
212
|
+
kept = sum(1 for e in entries if e["in_scope"])
|
|
213
|
+
print(f"wrote {args.out}: {len(entries)} entries, {kept} in scope", file=sys.stderr)
|
|
214
|
+
return 0
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
if __name__ == "__main__":
|
|
218
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Text extraction with ``pdftotext -layout``. The section splitter is primarysource.pdfcorpus.split."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import shutil
|
|
6
|
+
import subprocess
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
MISSING_MESSAGE = (
|
|
11
|
+
"pdftotext was not found on PATH. Install poppler-utils (on MSYS2: pacman -S mingw-w64-x86_64-poppler) "
|
|
12
|
+
"and make sure pdftotext is on PATH."
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ExtractError(RuntimeError):
|
|
17
|
+
"""Raised when text extraction cannot run or fails."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def find_pdftotext() -> str:
|
|
21
|
+
"""Return the path of pdftotext, or raise ExtractError saying how to get it."""
|
|
22
|
+
found = shutil.which("pdftotext")
|
|
23
|
+
if found is None:
|
|
24
|
+
raise ExtractError(MISSING_MESSAGE)
|
|
25
|
+
return found
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def extract_text(pdf: Path, *, timeout: float = 300.0) -> str:
|
|
29
|
+
"""Run ``pdftotext -layout`` on ``pdf`` and return the text (pages separated by form feeds)."""
|
|
30
|
+
exe = find_pdftotext()
|
|
31
|
+
try:
|
|
32
|
+
result = subprocess.run( # noqa: S603
|
|
33
|
+
[exe, "-layout", "-enc", "UTF-8", str(pdf), "-"],
|
|
34
|
+
capture_output=True,
|
|
35
|
+
timeout=timeout,
|
|
36
|
+
check=False,
|
|
37
|
+
)
|
|
38
|
+
except subprocess.TimeoutExpired as error:
|
|
39
|
+
raise ExtractError(f"pdftotext timed out after {timeout:.0f}s on {pdf}") from error
|
|
40
|
+
if result.returncode != 0:
|
|
41
|
+
detail = result.stderr.decode("utf-8", "replace").strip()
|
|
42
|
+
raise ExtractError(f"pdftotext failed on {pdf} (exit {result.returncode}): {detail}")
|
|
43
|
+
return result.stdout.decode("utf-8", "replace")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
# The splitter is shared (primarysource.pdfcorpus.split); it is re-exported here for the importers that have
|
|
47
|
+
# always found it in this module.
|
|
48
|
+
from primarysource.pdfcorpus.split import split_sections as split_sections # noqa: E402
|