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,480 @@
|
|
|
1
|
+
"""OSCAL catalogues as a second, structured source: ``python -m primarysource.nist.ingest.oscal``.
|
|
2
|
+
|
|
3
|
+
NIST publishes machine-readable catalogues in the ``usnistgov/oscal-content`` repository. Each catalogue is
|
|
4
|
+
rendered to a plain-text file with one section per control (the control id is the section id, the control
|
|
5
|
+
title its heading) and indexed through the same writer as the PDF pipeline, under a doc_id ending in
|
|
6
|
+
``-oscal`` so the two versions of a publication cannot collide.
|
|
7
|
+
|
|
8
|
+
The catalogues to fetch are the ``[[oscal]]`` tables of data/sources.toml, each pinned to a commit and to the
|
|
9
|
+
git blob sha of the file. The PDF pipeline ignores those tables. This module touches the network only for those URLs.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from primarysource.nist.paths import data_dir as default_data_dir, sources_file
|
|
15
|
+
|
|
16
|
+
import argparse
|
|
17
|
+
import hashlib
|
|
18
|
+
import json
|
|
19
|
+
import os
|
|
20
|
+
import re
|
|
21
|
+
import sys
|
|
22
|
+
import time
|
|
23
|
+
import tomllib
|
|
24
|
+
import urllib.error
|
|
25
|
+
import urllib.request
|
|
26
|
+
import xml.etree.ElementTree as ET
|
|
27
|
+
from collections.abc import Sequence
|
|
28
|
+
from dataclasses import dataclass
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
|
|
31
|
+
from primarysource.nist.ingest.index import read_index, sha256_bytes, write_index
|
|
32
|
+
from primarysource.nist.spec import SPEC
|
|
33
|
+
from primarysource.pdfcorpus.fts import build_after_fetch
|
|
34
|
+
from primarysource.nist.models import Document, Section
|
|
35
|
+
|
|
36
|
+
NAMESPACE = "http://csrc.nist.gov/ns/oscal/1.0"
|
|
37
|
+
USER_AGENT = "nistlib-oscal/0.1 (corpus fetcher; one request per second)"
|
|
38
|
+
TIMEOUT_SECONDS = 120
|
|
39
|
+
MAX_BYTES = 50 * 1024 * 1024
|
|
40
|
+
THROTTLE_SECONDS = 1.0
|
|
41
|
+
DOC_ID_SUFFIX = "-oscal"
|
|
42
|
+
ALLOWED_HOST_PREFIX = "https://raw.githubusercontent.com/usnistgov/oscal-content/"
|
|
43
|
+
|
|
44
|
+
_DATE = re.compile(r"^(\d{4}-(?:0[1-9]|1[0-2]))")
|
|
45
|
+
_DOC_ID = re.compile(r"^[a-z0-9][a-z0-9._-]*$")
|
|
46
|
+
_HEX40 = re.compile(r"^[0-9a-f]{40}$")
|
|
47
|
+
_REQUIRED = ("doc_id", "url", "number", "title", "date", "status", "commit", "blob")
|
|
48
|
+
_SKIPPED_CHILDREN = frozenset({"prop", "link", "title", "param", "label"})
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class OscalError(RuntimeError):
|
|
52
|
+
"""Raised when a catalogue cannot be fetched, verified or parsed."""
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _q(name: str) -> str:
|
|
56
|
+
return f"{{{NAMESPACE}}}{name}"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _local(element: ET.Element) -> str:
|
|
60
|
+
tag = element.tag
|
|
61
|
+
if not isinstance(tag, str):
|
|
62
|
+
return ""
|
|
63
|
+
prefix = f"{{{NAMESPACE}}}"
|
|
64
|
+
return tag[len(prefix) :] if tag.startswith(prefix) else tag
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _norm(text: str) -> str:
|
|
68
|
+
return " ".join(text.split())
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
# -- sources -----------------------------------------------------------------------------------------------
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@dataclass(frozen=True, slots=True)
|
|
75
|
+
class OscalSource:
|
|
76
|
+
"""One ``[[oscal]]`` table of sources.toml."""
|
|
77
|
+
|
|
78
|
+
doc_id: str
|
|
79
|
+
url: str
|
|
80
|
+
number: str
|
|
81
|
+
title: str
|
|
82
|
+
date: str
|
|
83
|
+
status: str
|
|
84
|
+
commit: str
|
|
85
|
+
blob: str
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def parse_oscal_sources(text: str) -> tuple[OscalSource, ...]:
|
|
89
|
+
try:
|
|
90
|
+
data = tomllib.loads(text)
|
|
91
|
+
except tomllib.TOMLDecodeError as error:
|
|
92
|
+
raise OscalError(f"sources.toml is not valid TOML: {error}") from error
|
|
93
|
+
entries = data.get("oscal", [])
|
|
94
|
+
if not isinstance(entries, list):
|
|
95
|
+
raise OscalError("sources.toml: oscal must be an array of tables")
|
|
96
|
+
result: list[OscalSource] = []
|
|
97
|
+
seen: set[str] = set()
|
|
98
|
+
for position, entry in enumerate(entries, start=1):
|
|
99
|
+
label = f"oscal #{position}"
|
|
100
|
+
if not isinstance(entry, dict):
|
|
101
|
+
raise OscalError(f"{label}: not a table")
|
|
102
|
+
missing = [
|
|
103
|
+
k
|
|
104
|
+
for k in _REQUIRED
|
|
105
|
+
if not isinstance(entry.get(k), str) or not entry[k].strip()
|
|
106
|
+
]
|
|
107
|
+
if missing:
|
|
108
|
+
raise OscalError(
|
|
109
|
+
f"{label}: missing or empty field(s): {', '.join(missing)}"
|
|
110
|
+
)
|
|
111
|
+
unknown = sorted(set(entry) - set(_REQUIRED))
|
|
112
|
+
if unknown:
|
|
113
|
+
raise OscalError(f"{label}: unknown field(s): {', '.join(unknown)}")
|
|
114
|
+
doc_id = entry["doc_id"]
|
|
115
|
+
label = f"oscal {doc_id!r}"
|
|
116
|
+
if not _DOC_ID.match(doc_id) or not doc_id.endswith(DOC_ID_SUFFIX):
|
|
117
|
+
raise OscalError(
|
|
118
|
+
f"{label}: doc_id must be a file-safe slug ending in {DOC_ID_SUFFIX}"
|
|
119
|
+
)
|
|
120
|
+
if doc_id in seen:
|
|
121
|
+
raise OscalError(f"{label}: duplicate doc_id")
|
|
122
|
+
seen.add(doc_id)
|
|
123
|
+
if not entry["url"].startswith(ALLOWED_HOST_PREFIX):
|
|
124
|
+
raise OscalError(f"{label}: url must be under {ALLOWED_HOST_PREFIX}")
|
|
125
|
+
if not _DATE.fullmatch(entry["date"]):
|
|
126
|
+
raise OscalError(f"{label}: date must be YYYY-MM")
|
|
127
|
+
for key in ("commit", "blob"):
|
|
128
|
+
if not _HEX40.match(entry[key]):
|
|
129
|
+
raise OscalError(f"{label}: {key} must be 40 lowercase hex digits")
|
|
130
|
+
if entry["commit"] not in entry["url"]:
|
|
131
|
+
raise OscalError(f"{label}: url is not pinned to the recorded commit")
|
|
132
|
+
result.append(OscalSource(**{k: entry[k] for k in _REQUIRED}))
|
|
133
|
+
return tuple(result)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def load_oscal_sources(path: Path) -> tuple[OscalSource, ...]:
|
|
137
|
+
try:
|
|
138
|
+
return parse_oscal_sources(path.read_text(encoding="utf-8"))
|
|
139
|
+
except OSError as error:
|
|
140
|
+
raise OscalError(f"cannot read {path}: {error}") from error
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
# -- rendering ---------------------------------------------------------------------------------------------
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _inline(element: ET.Element, params: dict[str, str]) -> str:
|
|
147
|
+
"""Plain text of a mixed-content element. ``insert`` becomes its parameter's label, ``q`` is quoted."""
|
|
148
|
+
pieces: list[str] = [element.text or ""]
|
|
149
|
+
for child in element:
|
|
150
|
+
name = _local(child)
|
|
151
|
+
if name == "insert":
|
|
152
|
+
ref = child.get("id-ref", "")
|
|
153
|
+
pieces.append(f"[{params.get(ref) or ref}]")
|
|
154
|
+
elif name == "q":
|
|
155
|
+
pieces.append(f'"{_inline(child, params)}"')
|
|
156
|
+
else:
|
|
157
|
+
pieces.append(_inline(child, params))
|
|
158
|
+
pieces.append(child.tail or "")
|
|
159
|
+
return _norm("".join(pieces))
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _part_label(part: ET.Element) -> str:
|
|
163
|
+
for prop in part.findall(_q("prop")):
|
|
164
|
+
if prop.get("name") == "label":
|
|
165
|
+
return _norm(prop.get("value", ""))
|
|
166
|
+
return ""
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _part_lines(part: ET.Element, params: dict[str, str], depth: int) -> list[str]:
|
|
170
|
+
"""Lines for one part: its prose, list items and nested parts, indented by nesting depth."""
|
|
171
|
+
indent = " " * depth
|
|
172
|
+
out: list[str] = []
|
|
173
|
+
pending = _part_label(part)
|
|
174
|
+
|
|
175
|
+
def emit(text: str) -> None:
|
|
176
|
+
nonlocal pending
|
|
177
|
+
if not text:
|
|
178
|
+
return
|
|
179
|
+
out.append(f"{indent}{pending + ' ' if pending else ''}{text}")
|
|
180
|
+
pending = ""
|
|
181
|
+
|
|
182
|
+
for child in part:
|
|
183
|
+
name = _local(child)
|
|
184
|
+
if name == "p":
|
|
185
|
+
emit(_inline(child, params))
|
|
186
|
+
elif name in ("ul", "ol"):
|
|
187
|
+
for item in child.findall(_q("li")):
|
|
188
|
+
emit("- " + _inline(item, params))
|
|
189
|
+
elif name == "part":
|
|
190
|
+
if pending:
|
|
191
|
+
out.append(f"{indent}{pending}")
|
|
192
|
+
pending = ""
|
|
193
|
+
out.extend(_part_lines(child, params, depth + 1))
|
|
194
|
+
elif name not in _SKIPPED_CHILDREN:
|
|
195
|
+
emit(_inline(child, params))
|
|
196
|
+
if pending:
|
|
197
|
+
out.append(f"{indent}{pending}")
|
|
198
|
+
return out
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _top_part_lines(part: ET.Element, params: dict[str, str]) -> list[str]:
|
|
202
|
+
"""A control's top-level part. Everything but the statement gets a heading line naming it."""
|
|
203
|
+
name = part.get("name", "")
|
|
204
|
+
if name == "statement":
|
|
205
|
+
return _part_lines(part, params, 0)
|
|
206
|
+
title = (
|
|
207
|
+
_norm(part.findtext(_q("title")) or "") or name.replace("-", " ").capitalize()
|
|
208
|
+
)
|
|
209
|
+
return [f"{title}:", *_part_lines(part, params, 1)]
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _select_text(param: ET.Element) -> str:
|
|
213
|
+
"""A parameter without a label may offer choices: ``one-or-more: a | b``, else ``a | b``."""
|
|
214
|
+
select = param.find(_q("select"))
|
|
215
|
+
if select is None:
|
|
216
|
+
return ""
|
|
217
|
+
choices = " | ".join(
|
|
218
|
+
_norm("".join(c.itertext())) for c in select.findall(_q("choice"))
|
|
219
|
+
)
|
|
220
|
+
how_many = select.get("how-many")
|
|
221
|
+
return f"{how_many}: {choices}" if how_many and choices else choices
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _collect_params(root: ET.Element) -> dict[str, str]:
|
|
225
|
+
"""Parameter id -> label, so a prose ``insert`` can name what it stands for."""
|
|
226
|
+
params: dict[str, str] = {}
|
|
227
|
+
for param in root.iter(_q("param")):
|
|
228
|
+
pid = param.get("id")
|
|
229
|
+
if pid:
|
|
230
|
+
params[pid] = _norm(param.findtext(_q("label")) or "") or _select_text(
|
|
231
|
+
param
|
|
232
|
+
)
|
|
233
|
+
return params
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def render_catalog(root: ET.Element) -> tuple[str, tuple[Section, ...]]:
|
|
237
|
+
"""Render a parsed OSCAL catalogue to (text, sections), one section per control, in document order.
|
|
238
|
+
|
|
239
|
+
Groups are walked, nested controls become sections of their own right after their parent, and a blank
|
|
240
|
+
line separates sections. A section's line range covers its heading and its own prose only.
|
|
241
|
+
"""
|
|
242
|
+
if root.tag != _q("catalog"):
|
|
243
|
+
raise OscalError(
|
|
244
|
+
f"root element is {root.tag!r}, expected {{{NAMESPACE}}}catalog"
|
|
245
|
+
)
|
|
246
|
+
params = _collect_params(root)
|
|
247
|
+
lines: list[str] = []
|
|
248
|
+
sections: list[Section] = []
|
|
249
|
+
seen: set[str] = set()
|
|
250
|
+
|
|
251
|
+
def add_control(control: ET.Element) -> None:
|
|
252
|
+
cid = (control.get("id") or "").strip()
|
|
253
|
+
if not cid:
|
|
254
|
+
raise OscalError("a control has no id")
|
|
255
|
+
if cid in seen:
|
|
256
|
+
raise OscalError(f"duplicate control id {cid!r}")
|
|
257
|
+
seen.add(cid)
|
|
258
|
+
title = _norm(control.findtext(_q("title")) or "") or cid
|
|
259
|
+
body = [f"{cid} {title}"]
|
|
260
|
+
for part in control.findall(_q("part")):
|
|
261
|
+
body.extend(_top_part_lines(part, params))
|
|
262
|
+
start = len(lines) + 1
|
|
263
|
+
lines.extend(body)
|
|
264
|
+
sections.append(Section(cid, title, start, len(lines)))
|
|
265
|
+
lines.append("")
|
|
266
|
+
for nested in control.findall(_q("control")):
|
|
267
|
+
add_control(nested)
|
|
268
|
+
|
|
269
|
+
def walk(container: ET.Element) -> None:
|
|
270
|
+
for child in container:
|
|
271
|
+
name = _local(child)
|
|
272
|
+
if name == "group":
|
|
273
|
+
walk(child)
|
|
274
|
+
elif name == "control":
|
|
275
|
+
add_control(child)
|
|
276
|
+
|
|
277
|
+
walk(root)
|
|
278
|
+
return "\n".join(lines) + "\n", tuple(sections)
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def catalog_metadata(root: ET.Element) -> tuple[str, str, str]:
|
|
282
|
+
"""(title, date YYYY-MM, status) as the catalogue itself states them.
|
|
283
|
+
|
|
284
|
+
Status is the ``publication-status`` prop, lowercased, or ``unspecified`` when the catalogue has none.
|
|
285
|
+
"""
|
|
286
|
+
meta = root.find(_q("metadata"))
|
|
287
|
+
if meta is None:
|
|
288
|
+
raise OscalError("catalogue has no metadata")
|
|
289
|
+
title = _norm(meta.findtext(_q("title")) or "")
|
|
290
|
+
modified = _DATE.match((meta.findtext(_q("last-modified")) or "").strip())
|
|
291
|
+
if not title or modified is None:
|
|
292
|
+
raise OscalError("metadata lacks a title or a last-modified date")
|
|
293
|
+
status = "unspecified"
|
|
294
|
+
for prop in meta.findall(_q("prop")):
|
|
295
|
+
if prop.get("name") == "publication-status" and prop.get("value"):
|
|
296
|
+
status = prop.get("value", "").strip().lower()
|
|
297
|
+
return title, modified.group(1), status
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def parse_catalog(data: bytes) -> ET.Element:
|
|
301
|
+
"""Parse catalogue bytes with the standard library, refusing DTDs and entity declarations."""
|
|
302
|
+
if b"<!DOCTYPE" in data or b"<!ENTITY" in data:
|
|
303
|
+
raise OscalError("catalogue declares a DTD or entities, refusing to parse it")
|
|
304
|
+
try:
|
|
305
|
+
return ET.fromstring(data)
|
|
306
|
+
except ET.ParseError as error:
|
|
307
|
+
raise OscalError(f"catalogue is not well-formed XML: {error}") from error
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
# -- fetching and indexing ---------------------------------------------------------------------------------
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def git_blob_sha(data: bytes) -> str:
|
|
314
|
+
"""The sha1 git assigns to a file's content, which the GitHub contents API reports as ``sha``."""
|
|
315
|
+
return hashlib.sha1( # noqa: S324 - git object id, not a security digest
|
|
316
|
+
b"blob %d\0" % len(data) + data
|
|
317
|
+
).hexdigest()
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def download(url: str, *, timeout: float = TIMEOUT_SECONDS) -> bytes:
|
|
321
|
+
request = urllib.request.Request(url, headers={"User-Agent": USER_AGENT}) # noqa: S310
|
|
322
|
+
try:
|
|
323
|
+
with urllib.request.urlopen(request, timeout=timeout) as response: # noqa: S310
|
|
324
|
+
body: bytes = response.read(MAX_BYTES + 1)
|
|
325
|
+
except (urllib.error.URLError, TimeoutError, OSError) as error:
|
|
326
|
+
raise OscalError(f"download failed for {url}: {error}") from error
|
|
327
|
+
if len(body) > MAX_BYTES:
|
|
328
|
+
raise OscalError(f"{url}: larger than {MAX_BYTES} bytes, refusing")
|
|
329
|
+
return body
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def build_document(source: OscalSource, corpus: Path, data: bytes) -> Document:
|
|
333
|
+
"""Verify, parse and render ``data`` for ``source``; write ``.xml`` and ``.txt`` and return the entry."""
|
|
334
|
+
if git_blob_sha(data) != source.blob:
|
|
335
|
+
raise OscalError(
|
|
336
|
+
f"{source.doc_id}: blob sha does not match the pinned {source.blob}"
|
|
337
|
+
)
|
|
338
|
+
root = parse_catalog(data)
|
|
339
|
+
title, date, status = catalog_metadata(root)
|
|
340
|
+
for field, actual, recorded in (
|
|
341
|
+
("title", title, source.title),
|
|
342
|
+
("date", date, source.date),
|
|
343
|
+
("status", status, source.status),
|
|
344
|
+
):
|
|
345
|
+
if actual != recorded:
|
|
346
|
+
raise OscalError(
|
|
347
|
+
f"{source.doc_id}: catalogue {field} is {actual!r}, sources.toml says {recorded!r}"
|
|
348
|
+
)
|
|
349
|
+
text, sections = render_catalog(root)
|
|
350
|
+
if not sections:
|
|
351
|
+
raise OscalError(f"{source.doc_id}: catalogue has no controls")
|
|
352
|
+
(corpus / f"{source.doc_id}.xml").write_bytes(data)
|
|
353
|
+
(corpus / f"{source.doc_id}.txt").write_text(text, encoding="utf-8", newline="")
|
|
354
|
+
return Document(
|
|
355
|
+
doc_id=source.doc_id,
|
|
356
|
+
number=source.number,
|
|
357
|
+
title=source.title,
|
|
358
|
+
date=source.date,
|
|
359
|
+
status=source.status,
|
|
360
|
+
url=source.url,
|
|
361
|
+
sha256=sha256_bytes(data),
|
|
362
|
+
text_file=f"{source.doc_id}.txt",
|
|
363
|
+
sections=sections,
|
|
364
|
+
)
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def merge_into_index(index_path: Path, new: list[Document]) -> None:
|
|
368
|
+
"""Add or replace ``new`` in the index, leaving every other entry exactly as it is."""
|
|
369
|
+
existing: list[Document] = []
|
|
370
|
+
if index_path.exists():
|
|
371
|
+
try:
|
|
372
|
+
raw = json.loads(index_path.read_text(encoding="utf-8"))
|
|
373
|
+
except (OSError, ValueError) as error:
|
|
374
|
+
raise OscalError(
|
|
375
|
+
f"{index_path} is unreadable, not overwriting it: {error}"
|
|
376
|
+
) from error
|
|
377
|
+
if not isinstance(raw, dict) or raw.get("schema") != 1:
|
|
378
|
+
raise OscalError(
|
|
379
|
+
f"{index_path} is not a schema 1 index, not overwriting it"
|
|
380
|
+
)
|
|
381
|
+
existing = read_index(index_path)
|
|
382
|
+
if len(existing) != len(raw.get("documents", [])):
|
|
383
|
+
raise OscalError(
|
|
384
|
+
f"{index_path} has entries that do not parse, not overwriting it"
|
|
385
|
+
)
|
|
386
|
+
replaced = {d.doc_id for d in new}
|
|
387
|
+
by_id = {d.doc_id: d for d in new}
|
|
388
|
+
merged = [by_id.get(d.doc_id, d) for d in existing]
|
|
389
|
+
merged.extend(d for d in new if d.doc_id not in {e.doc_id for e in existing})
|
|
390
|
+
assert {d.doc_id for d in merged} >= replaced
|
|
391
|
+
write_index(index_path, merged)
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def run(
|
|
395
|
+
data_dir: Path,
|
|
396
|
+
from_files: dict[str, Path],
|
|
397
|
+
only: Sequence[str],
|
|
398
|
+
*,
|
|
399
|
+
sleep: float = THROTTLE_SECONDS,
|
|
400
|
+
) -> int:
|
|
401
|
+
sources = load_oscal_sources(sources_file(data_dir))
|
|
402
|
+
known = {s.doc_id for s in sources}
|
|
403
|
+
unknown = sorted((set(from_files) | set(only)) - known)
|
|
404
|
+
if unknown:
|
|
405
|
+
raise OscalError(
|
|
406
|
+
f"not an [[oscal]] entry in sources.toml: {', '.join(unknown)}"
|
|
407
|
+
)
|
|
408
|
+
corpus = data_dir / "corpus"
|
|
409
|
+
corpus.mkdir(parents=True, exist_ok=True)
|
|
410
|
+
wanted = set(only) | set(from_files) if (only or from_files) else known
|
|
411
|
+
documents: list[Document] = []
|
|
412
|
+
failures = 0
|
|
413
|
+
requests = 0
|
|
414
|
+
for source in sources:
|
|
415
|
+
if source.doc_id not in wanted:
|
|
416
|
+
continue
|
|
417
|
+
try:
|
|
418
|
+
if source.doc_id in from_files:
|
|
419
|
+
data = from_files[source.doc_id].read_bytes()
|
|
420
|
+
else:
|
|
421
|
+
if requests:
|
|
422
|
+
time.sleep(sleep)
|
|
423
|
+
requests += 1
|
|
424
|
+
data = download(source.url)
|
|
425
|
+
document = build_document(source, corpus, data)
|
|
426
|
+
except (OscalError, OSError) as error:
|
|
427
|
+
print(f"{source.doc_id}: {error}", file=sys.stderr)
|
|
428
|
+
failures += 1
|
|
429
|
+
continue
|
|
430
|
+
print(
|
|
431
|
+
f"{source.doc_id}: {len(document.sections)} controls, sha256 {document.sha256[:12]}"
|
|
432
|
+
)
|
|
433
|
+
documents.append(document)
|
|
434
|
+
if documents:
|
|
435
|
+
merge_into_index(corpus / "index.json", documents)
|
|
436
|
+
print(
|
|
437
|
+
f"updated {corpus / 'index.json'} with {len(documents)} OSCAL document(s)"
|
|
438
|
+
)
|
|
439
|
+
if not build_after_fetch(SPEC, corpus):
|
|
440
|
+
failures += 1
|
|
441
|
+
return 1 if failures else 0
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
445
|
+
parser = argparse.ArgumentParser(
|
|
446
|
+
prog="python -m primarysource.nist.ingest.oscal", description=__doc__
|
|
447
|
+
)
|
|
448
|
+
parser.add_argument(
|
|
449
|
+
"--data-dir",
|
|
450
|
+
type=Path,
|
|
451
|
+
default=default_data_dir(),
|
|
452
|
+
help="directory holding sources.toml and corpus/ (default: $NISTLIB_DATA, else the collection's home)",
|
|
453
|
+
)
|
|
454
|
+
parser.add_argument(
|
|
455
|
+
"--from-file",
|
|
456
|
+
action="append",
|
|
457
|
+
default=[],
|
|
458
|
+
metavar="DOC_ID=PATH",
|
|
459
|
+
help="use a catalogue file you already have for DOC_ID instead of downloading it (repeatable)",
|
|
460
|
+
)
|
|
461
|
+
parser.add_argument(
|
|
462
|
+
"doc_ids", nargs="*", help="only these documents (default: all)"
|
|
463
|
+
)
|
|
464
|
+
args = parser.parse_args(argv)
|
|
465
|
+
from_files: dict[str, Path] = {}
|
|
466
|
+
for value in args.from_file:
|
|
467
|
+
doc_id, sep, path = value.partition("=")
|
|
468
|
+
if not sep or not doc_id or not path:
|
|
469
|
+
print(f"--from-file expects DOC_ID=PATH, got {value!r}", file=sys.stderr)
|
|
470
|
+
return 2
|
|
471
|
+
from_files[doc_id] = Path(path)
|
|
472
|
+
try:
|
|
473
|
+
return run(args.data_dir, from_files, args.doc_ids)
|
|
474
|
+
except OscalError as error:
|
|
475
|
+
print(f"nistlib-oscal: {error}", file=sys.stderr)
|
|
476
|
+
return 2
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
if __name__ == "__main__":
|
|
480
|
+
raise SystemExit(main())
|