probeowl 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.
- plugins/__init__.py +2 -0
- plugins/compare_files/__init__.py +2 -0
- plugins/compare_files/backend/__init__.py +361 -0
- plugins/compare_files/backend/parsers.py +310 -0
- plugins/compare_files/backend/secrets.py +155 -0
- plugins/compare_files/plugin.yaml +7 -0
- plugins/compare_files/web/CompareFilesPage.tsx +191 -0
- plugins/compare_files/web/DifferencesTable.tsx +121 -0
- plugins/compare_files/web/FileInput.tsx +118 -0
- plugins/compare_files/web/SideBySide.tsx +86 -0
- plugins/compare_files/web/compare-files.css +45 -0
- plugins/compare_files/web/exports.ts +59 -0
- plugins/compare_files/web/index.ts +2 -0
- plugins/compare_files/web/types.ts +21 -0
- probeowl/__init__.py +2 -0
- probeowl/api/__init__.py +2 -0
- probeowl/api/app.py +230 -0
- probeowl/api/guide_site.py +118 -0
- probeowl/api/local_only.py +122 -0
- probeowl/api/models.py +352 -0
- probeowl/api/openapi.py +32 -0
- probeowl/api/redaction.py +149 -0
- probeowl/api/routes.py +787 -0
- probeowl/api/titles.py +33 -0
- probeowl/api/web_app.py +87 -0
- probeowl/catalog/__init__.py +2 -0
- probeowl/catalog/drift.py +69 -0
- probeowl/catalog/edit.py +140 -0
- probeowl/catalog/review.py +80 -0
- probeowl/catalog/service.py +191 -0
- probeowl/catalog/suggest.py +179 -0
- probeowl/catalog/usage.py +55 -0
- probeowl/checks/__init__.py +2 -0
- probeowl/checks/plans.py +209 -0
- probeowl/checks/rules.py +582 -0
- probeowl/checks/saving.py +403 -0
- probeowl/checks/service.py +125 -0
- probeowl/checks/sql_shape.py +278 -0
- probeowl/checks/time_offsets.py +429 -0
- probeowl/cli/__init__.py +2 -0
- probeowl/cli/catalog.py +70 -0
- probeowl/cli/connector.py +353 -0
- probeowl/cli/connector_setup.py +242 -0
- probeowl/cli/defaults.py +20 -0
- probeowl/cli/init.py +251 -0
- probeowl/cli/inspect.py +127 -0
- probeowl/cli/kill.py +91 -0
- probeowl/cli/main.py +324 -0
- probeowl/cli/reload.py +49 -0
- probeowl/cli/runbook.py +25 -0
- probeowl/cli/server.py +174 -0
- probeowl/cli/validate.py +68 -0
- probeowl/comparison.py +409 -0
- probeowl/config_folder.py +223 -0
- probeowl/connector_status.py +333 -0
- probeowl/connectors/__init__.py +2 -0
- probeowl/connectors/factory.py +102 -0
- probeowl/connectors/failures.py +74 -0
- probeowl/connectors/lookup.py +31 -0
- probeowl/connectors/mariadb.py +236 -0
- probeowl/connectors/oracle.py +378 -0
- probeowl/connectors/plan.py +31 -0
- probeowl/connectors/rest.py +297 -0
- probeowl/connectors/schema.py +77 -0
- probeowl/connectors/sql.py +354 -0
- probeowl/connectors/sqlserver.py +354 -0
- probeowl/engine/__init__.py +2 -0
- probeowl/engine/expressions.py +504 -0
- probeowl/engine/identifiers.py +277 -0
- probeowl/engine/locations.py +367 -0
- probeowl/engine/masking.py +51 -0
- probeowl/engine/runner.py +391 -0
- probeowl/engine/typed_values.py +68 -0
- probeowl/errors.py +79 -0
- probeowl/guards/__init__.py +2 -0
- probeowl/guards/breaker.py +80 -0
- probeowl/guards/dialects.py +95 -0
- probeowl/guards/kill_switch.py +146 -0
- probeowl/guards/result.py +30 -0
- probeowl/guards/statement.py +341 -0
- probeowl/guide.py +15 -0
- probeowl/guide_site/404.html +883 -0
- probeowl/guide_site/assets/fonts/ibm-plex-mono-latin-400-normal.woff2 +0 -0
- probeowl/guide_site/assets/fonts/ibm-plex-sans-latin-400-italic.woff2 +0 -0
- probeowl/guide_site/assets/fonts/ibm-plex-sans-latin-400-normal.woff2 +0 -0
- probeowl/guide_site/assets/fonts/ibm-plex-sans-latin-700-italic.woff2 +0 -0
- probeowl/guide_site/assets/fonts/ibm-plex-sans-latin-700-normal.woff2 +0 -0
- probeowl/guide_site/assets/images/favicon.png +0 -0
- probeowl/guide_site/assets/javascripts/bundle.a5f9d56c.min.js +16 -0
- probeowl/guide_site/assets/javascripts/workers/search.a7633da7.min.js +42 -0
- probeowl/guide_site/assets/owl.svg +1 -0
- probeowl/guide_site/assets/plex.css +6 -0
- probeowl/guide_site/assets/probeowl.css +13 -0
- probeowl/guide_site/assets/screens/compare-files.png +0 -0
- probeowl/guide_site/assets/screens/connectors.png +0 -0
- probeowl/guide_site/assets/screens/inspect.png +0 -0
- probeowl/guide_site/assets/screens/knowledge-base.png +0 -0
- probeowl/guide_site/assets/screens/probes.png +0 -0
- probeowl/guide_site/assets/screens/tables.png +0 -0
- probeowl/guide_site/assets/screens/validate.png +0 -0
- probeowl/guide_site/assets/stylesheets/main.57a0d79d.min.css +6 -0
- probeowl/guide_site/assets/stylesheets/palette.fb2fffe2.min.css +4 -0
- probeowl/guide_site/cli/index.html +1489 -0
- probeowl/guide_site/concepts/index.html +1351 -0
- probeowl/guide_site/connectors/index.html +1337 -0
- probeowl/guide_site/first-application/index.html +1215 -0
- probeowl/guide_site/getting-started/index.html +1277 -0
- probeowl/guide_site/index.html +1014 -0
- probeowl/guide_site/licenses/MKDOCS_MATERIAL_LICENSE.txt +167 -0
- probeowl/guide_site/safety/index.html +1302 -0
- probeowl/guide_site/search/search_index.json +1 -0
- probeowl/guide_site/several-systems/index.html +1288 -0
- probeowl/guide_site/sitemap.xml +79 -0
- probeowl/guide_site/sitemap.xml.gz +0 -0
- probeowl/guide_site/troubleshooting/index.html +1332 -0
- probeowl/guide_site/web-app/compare-files/index.html +930 -0
- probeowl/guide_site/web-app/connectors/index.html +1018 -0
- probeowl/guide_site/web-app/index.html +968 -0
- probeowl/guide_site/web-app/inspect/index.html +948 -0
- probeowl/guide_site/web-app/knowledge-base/index.html +966 -0
- probeowl/guide_site/web-app/probes/index.html +1049 -0
- probeowl/guide_site/web-app/tables/index.html +943 -0
- probeowl/guide_site/web-app/validate/index.html +1041 -0
- probeowl/guide_site/writing-probes/index.html +1315 -0
- probeowl/guide_site/writing-runbooks/index.html +1328 -0
- probeowl/healthy.py +294 -0
- probeowl/model/__init__.py +70 -0
- probeowl/model/application.py +28 -0
- probeowl/model/catalog.py +124 -0
- probeowl/model/check.py +312 -0
- probeowl/model/common.py +97 -0
- probeowl/model/connector.py +183 -0
- probeowl/model/connector_status.py +88 -0
- probeowl/model/entity.py +290 -0
- probeowl/model/healthy.py +58 -0
- probeowl/model/kill_switch.py +59 -0
- probeowl/model/load.py +110 -0
- probeowl/model/probe.py +104 -0
- probeowl/model/reload.py +31 -0
- probeowl/model/review.py +56 -0
- probeowl/model/run.py +345 -0
- probeowl/model/runbook.py +138 -0
- probeowl/model/runbook_step.py +54 -0
- probeowl/model/save_check.py +53 -0
- probeowl/model/schema.py +55 -0
- probeowl/model/schemas.py +43 -0
- probeowl/plugins/api.py +127 -0
- probeowl/plugins/registry.py +125 -0
- probeowl/reload.py +65 -0
- probeowl/runbook_steps.py +490 -0
- probeowl/runbooks.py +203 -0
- probeowl/runs.py +1217 -0
- probeowl/secrets.py +86 -0
- probeowl/server_log.py +46 -0
- probeowl/store/__init__.py +2 -0
- probeowl/store/audit.py +67 -0
- probeowl/store/engine.py +47 -0
- probeowl/store/kill_switches.py +66 -0
- probeowl/store/migrate.py +55 -0
- probeowl/store/migrations/__init__.py +1 -0
- probeowl/store/migrations/env.py +29 -0
- probeowl/store/migrations/script.py.mako +26 -0
- probeowl/store/migrations/versions/0001_initial_store_schema.py +177 -0
- probeowl/store/migrations/versions/0002_run_stop_reason.py +26 -0
- probeowl/store/migrations/versions/0003_run_identifiers.py +30 -0
- probeowl/store/migrations/versions/0004_run_warnings.py +26 -0
- probeowl/store/migrations/versions/0005_schema_snapshots.py +43 -0
- probeowl/store/migrations/versions/0006_kill_switches.py +41 -0
- probeowl/store/migrations/versions/0007_healthy_comparison.py +31 -0
- probeowl/store/migrations/versions/0008_runbook_steps.py +35 -0
- probeowl/store/migrations/versions/0009_caveats.py +30 -0
- probeowl/store/runbook_steps.py +54 -0
- probeowl/store/runs.py +254 -0
- probeowl/store/schemas.py +85 -0
- probeowl/store/tables.py +264 -0
- probeowl/webapp/assets/ibm-plex-mono-cyrillic-400-normal-BSMlKf0J.woff2 +0 -0
- probeowl/webapp/assets/ibm-plex-mono-cyrillic-400-normal-CEL4l2ZJ.woff +0 -0
- probeowl/webapp/assets/ibm-plex-mono-cyrillic-ext-400-normal-DMdlQ8Kv.woff +0 -0
- probeowl/webapp/assets/ibm-plex-mono-cyrillic-ext-400-normal-xuaO2J-f.woff2 +0 -0
- probeowl/webapp/assets/ibm-plex-mono-latin-400-normal-CvHOgSBP.woff +0 -0
- probeowl/webapp/assets/ibm-plex-mono-latin-400-normal-DMJ8VG8y.woff2 +0 -0
- probeowl/webapp/assets/ibm-plex-mono-latin-ext-400-normal-BmRBH3aV.woff2 +0 -0
- probeowl/webapp/assets/ibm-plex-mono-latin-ext-400-normal-D3D2R8hC.woff +0 -0
- probeowl/webapp/assets/ibm-plex-mono-vietnamese-400-normal-BulugwFq.woff2 +0 -0
- probeowl/webapp/assets/ibm-plex-mono-vietnamese-400-normal-DDuiU_S-.woff +0 -0
- probeowl/webapp/assets/ibm-plex-sans-cyrillic-400-normal-BTotfTJu.woff +0 -0
- probeowl/webapp/assets/ibm-plex-sans-cyrillic-400-normal-DZqxrq2p.woff2 +0 -0
- probeowl/webapp/assets/ibm-plex-sans-cyrillic-500-normal-ByOcLdNv.woff +0 -0
- probeowl/webapp/assets/ibm-plex-sans-cyrillic-500-normal-CocWQlwt.woff2 +0 -0
- probeowl/webapp/assets/ibm-plex-sans-cyrillic-600-normal-71GNu3SW.woff2 +0 -0
- probeowl/webapp/assets/ibm-plex-sans-cyrillic-600-normal-BGq0mW3O.woff +0 -0
- probeowl/webapp/assets/ibm-plex-sans-cyrillic-ext-400-normal-Dsrv2Tcn.woff +0 -0
- probeowl/webapp/assets/ibm-plex-sans-cyrillic-ext-400-normal-g30qAdWV.woff2 +0 -0
- probeowl/webapp/assets/ibm-plex-sans-cyrillic-ext-500-normal-Cs5J6C77.woff2 +0 -0
- probeowl/webapp/assets/ibm-plex-sans-cyrillic-ext-500-normal-DB5PtV2g.woff +0 -0
- probeowl/webapp/assets/ibm-plex-sans-cyrillic-ext-600-normal-Bz0x94Yp.woff +0 -0
- probeowl/webapp/assets/ibm-plex-sans-cyrillic-ext-600-normal-DUMzJB7m.woff2 +0 -0
- probeowl/webapp/assets/ibm-plex-sans-greek-400-normal-D9ESIMu3.woff +0 -0
- probeowl/webapp/assets/ibm-plex-sans-greek-400-normal-_efipK4i.woff2 +0 -0
- probeowl/webapp/assets/ibm-plex-sans-greek-500-normal-CuWXN6rf.woff +0 -0
- probeowl/webapp/assets/ibm-plex-sans-greek-500-normal-JMMifIXV.woff2 +0 -0
- probeowl/webapp/assets/ibm-plex-sans-greek-600-normal-D-CqTdkO.woff +0 -0
- probeowl/webapp/assets/ibm-plex-sans-greek-600-normal-DzTrcv_p.woff2 +0 -0
- probeowl/webapp/assets/ibm-plex-sans-latin-400-normal-CDDApCn2.woff2 +0 -0
- probeowl/webapp/assets/ibm-plex-sans-latin-400-normal-CYLoc0-x.woff +0 -0
- probeowl/webapp/assets/ibm-plex-sans-latin-500-normal-6ng42L7E.woff2 +0 -0
- probeowl/webapp/assets/ibm-plex-sans-latin-500-normal-BgVn5rGT.woff +0 -0
- probeowl/webapp/assets/ibm-plex-sans-latin-600-normal-Cu4Hd6ag.woff +0 -0
- probeowl/webapp/assets/ibm-plex-sans-latin-600-normal-CuJfVYMP.woff2 +0 -0
- probeowl/webapp/assets/ibm-plex-sans-latin-ext-400-normal-C5H60-Va.woff2 +0 -0
- probeowl/webapp/assets/ibm-plex-sans-latin-ext-400-normal-RBey6euL.woff +0 -0
- probeowl/webapp/assets/ibm-plex-sans-latin-ext-500-normal-D0aIdm-b.woff +0 -0
- probeowl/webapp/assets/ibm-plex-sans-latin-ext-500-normal-DakdToA3.woff2 +0 -0
- probeowl/webapp/assets/ibm-plex-sans-latin-ext-600-normal-DIrixKbi.woff +0 -0
- probeowl/webapp/assets/ibm-plex-sans-latin-ext-600-normal-DOrvGEcy.woff2 +0 -0
- probeowl/webapp/assets/ibm-plex-sans-vietnamese-400-normal-DG4YqDda.woff2 +0 -0
- probeowl/webapp/assets/ibm-plex-sans-vietnamese-400-normal-fK1oJ5dG.woff +0 -0
- probeowl/webapp/assets/ibm-plex-sans-vietnamese-500-normal-BEb3_waV.woff +0 -0
- probeowl/webapp/assets/ibm-plex-sans-vietnamese-500-normal-e4dixQRQ.woff2 +0 -0
- probeowl/webapp/assets/ibm-plex-sans-vietnamese-600-normal-DgdngZtN.woff +0 -0
- probeowl/webapp/assets/ibm-plex-sans-vietnamese-600-normal-DpPYBSTl.woff2 +0 -0
- probeowl/webapp/assets/index-CBGLumXh.css +1 -0
- probeowl/webapp/assets/index-CG1KqICH.js +175 -0
- probeowl/webapp/index.html +14 -0
- probeowl/webapp/licenses/THIRD_PARTY_NOTICES.txt +1867 -0
- probeowl/webapp/licenses/ibm-plex-mono-OFL.txt +93 -0
- probeowl/webapp/licenses/ibm-plex-sans-OFL.txt +93 -0
- probeowl/webapp/owl.svg +1 -0
- probeowl-0.1.0.dist-info/METADATA +23 -0
- probeowl-0.1.0.dist-info/RECORD +232 -0
- probeowl-0.1.0.dist-info/WHEEL +4 -0
- probeowl-0.1.0.dist-info/entry_points.txt +2 -0
plugins/__init__.py
ADDED
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
# Author: @SaadZarook
|
|
2
|
+
"""The Compare files routes. Files are processed in memory and never stored.
|
|
3
|
+
|
|
4
|
+
The server always masks. Reveal happens in the browser, from the file the person supplied: a masked
|
|
5
|
+
difference says where the value sits, and nothing else about it.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from collections.abc import Callable, Coroutine
|
|
9
|
+
from decimal import Decimal
|
|
10
|
+
from enum import StrEnum
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from fastapi import APIRouter, Request, Response
|
|
14
|
+
from fastapi.routing import APIRoute
|
|
15
|
+
from pydantic import BaseModel, Field, JsonValue
|
|
16
|
+
|
|
17
|
+
from plugins.compare_files.backend.parsers import Format, Parsed, detect, parse
|
|
18
|
+
from plugins.compare_files.backend.secrets import (
|
|
19
|
+
Offsets,
|
|
20
|
+
contains_shared,
|
|
21
|
+
find_exactly,
|
|
22
|
+
line_masker,
|
|
23
|
+
looks_secret_line,
|
|
24
|
+
masker,
|
|
25
|
+
ranges_of,
|
|
26
|
+
secrets_in,
|
|
27
|
+
shared_secrets,
|
|
28
|
+
without_shared,
|
|
29
|
+
)
|
|
30
|
+
from probeowl.plugins.api import (
|
|
31
|
+
ArrayMatch,
|
|
32
|
+
Change,
|
|
33
|
+
Comparison,
|
|
34
|
+
InputTooLarge,
|
|
35
|
+
Leaf,
|
|
36
|
+
LocalInput,
|
|
37
|
+
Options,
|
|
38
|
+
PluginContext,
|
|
39
|
+
RequestError,
|
|
40
|
+
Side,
|
|
41
|
+
compare,
|
|
42
|
+
compare_lines,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
MAX_FILE_BYTES = 10_000_000
|
|
46
|
+
MAX_REQUEST_BYTES = 25_000_000
|
|
47
|
+
# Beyond what anyone can read, and each one costs memory in the browser: the summary stays exact.
|
|
48
|
+
MAX_DIFFERENCES = 10_000
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class FormatChoice(StrEnum):
|
|
52
|
+
"""What the person asked for: detected from the content, or overridden."""
|
|
53
|
+
|
|
54
|
+
AUTO = "auto"
|
|
55
|
+
JSON = "json"
|
|
56
|
+
YAML = "yaml"
|
|
57
|
+
CSV = "csv"
|
|
58
|
+
XML = "xml"
|
|
59
|
+
PROPERTIES = "properties"
|
|
60
|
+
INI = "ini"
|
|
61
|
+
ENV = "env"
|
|
62
|
+
TEXT = "text"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class SuppliedFile(BaseModel):
|
|
66
|
+
"""A file or text the person supplied."""
|
|
67
|
+
|
|
68
|
+
name: str = Field(min_length=1, max_length=255)
|
|
69
|
+
text: str
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class CompareOptions(BaseModel):
|
|
73
|
+
"""What counts as the same."""
|
|
74
|
+
|
|
75
|
+
ignore_paths: list[str] = Field(default_factory=list)
|
|
76
|
+
ignore_key_order: bool = True
|
|
77
|
+
arrays: ArrayMatch = ArrayMatch.BY_POSITION
|
|
78
|
+
array_key: str | None = Field(
|
|
79
|
+
default=None, description="With arrays by_key: the field, or the CSV column, to match on."
|
|
80
|
+
)
|
|
81
|
+
ignore_whitespace: bool = False
|
|
82
|
+
ignore_case: bool = Field(default=False, description="Applies to values, not to keys.")
|
|
83
|
+
number_tolerance: Decimal = Field(default=Decimal(0), ge=0, allow_inf_nan=False)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class CompareRequest(BaseModel):
|
|
87
|
+
"""Two files to compare."""
|
|
88
|
+
|
|
89
|
+
left: SuppliedFile
|
|
90
|
+
right: SuppliedFile
|
|
91
|
+
format: FormatChoice = FormatChoice.AUTO
|
|
92
|
+
options: CompareOptions = Field(default_factory=CompareOptions)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class Range(BaseModel):
|
|
96
|
+
"""Where text sits in the supplied file, in the units a browser's strings count."""
|
|
97
|
+
|
|
98
|
+
start: int
|
|
99
|
+
end: int
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class FileSummary(BaseModel):
|
|
103
|
+
"""What the audit also records. Never the content."""
|
|
104
|
+
|
|
105
|
+
name: str
|
|
106
|
+
size: int
|
|
107
|
+
sha256: str
|
|
108
|
+
format: Format
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class FileDifference(BaseModel):
|
|
112
|
+
"""One path that differs. left and right are `[masked]` if masked."""
|
|
113
|
+
|
|
114
|
+
path: str
|
|
115
|
+
change: Change
|
|
116
|
+
left: JsonValue = None
|
|
117
|
+
right: JsonValue = None
|
|
118
|
+
masked: bool = False
|
|
119
|
+
reveal_left: Range | None = Field(
|
|
120
|
+
default=None,
|
|
121
|
+
description="Masked only: where the value sits in the left file. Null if it cannot be "
|
|
122
|
+
"found exactly, so it has no Reveal.",
|
|
123
|
+
)
|
|
124
|
+
reveal_right: Range | None = None
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class CompareSummary(BaseModel):
|
|
128
|
+
"""How much differs."""
|
|
129
|
+
|
|
130
|
+
changed: int
|
|
131
|
+
added: int
|
|
132
|
+
removed: int
|
|
133
|
+
unchanged: int
|
|
134
|
+
ignored: int
|
|
135
|
+
masked: int
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
class CompareResult(BaseModel):
|
|
139
|
+
"""What differs, masked. Everything here is safe to show and export."""
|
|
140
|
+
|
|
141
|
+
left: FileSummary
|
|
142
|
+
right: FileSummary
|
|
143
|
+
summary: CompareSummary
|
|
144
|
+
differences: list[FileDifference]
|
|
145
|
+
notes: list[str]
|
|
146
|
+
masked_left: list[Range] | None = Field(
|
|
147
|
+
description="Everything to hide in the left file's side-by-side view. Null if a secret "
|
|
148
|
+
"could not be located, so that view must not be shown."
|
|
149
|
+
)
|
|
150
|
+
masked_right: list[Range] | None = None
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _json_value(value: object) -> JsonValue:
|
|
154
|
+
if value is None or isinstance(value, bool | int | float | str):
|
|
155
|
+
return value
|
|
156
|
+
return str(value) # a Decimal, or a date from YAML
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _format(choice: FormatChoice, left: str, right: str, notes: list[str]) -> Format:
|
|
160
|
+
if choice is not FormatChoice.AUTO:
|
|
161
|
+
return Format(choice.value)
|
|
162
|
+
found_left, found_right = detect(left), detect(right)
|
|
163
|
+
if found_left is found_right:
|
|
164
|
+
return found_left
|
|
165
|
+
notes.append(
|
|
166
|
+
f"the files look like different formats ({found_left.value} and {found_right.value}), so "
|
|
167
|
+
"they were compared as plain text"
|
|
168
|
+
)
|
|
169
|
+
return Format.TEXT
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _secrets(file: LocalInput, parsed: Parsed) -> list[object]:
|
|
173
|
+
if parsed.format is Format.TEXT:
|
|
174
|
+
return [line for line in file.text.splitlines() if looks_secret_line(line)]
|
|
175
|
+
try:
|
|
176
|
+
return list(secrets_in(parsed.tree))
|
|
177
|
+
except RecursionError:
|
|
178
|
+
# Too deep to walk means its secrets cannot be vouched for anywhere, so nothing is shown.
|
|
179
|
+
raise RequestError(f"{file.name} is nested too deeply to compare") from None
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _masked_ranges(file: LocalInput, own: list[object], shared: set[str]) -> list[Range] | None:
|
|
183
|
+
found = ranges_of(file.text, own, shared)
|
|
184
|
+
if found is None:
|
|
185
|
+
return None
|
|
186
|
+
units = Offsets(file.text)
|
|
187
|
+
return [Range(start=units(start), end=units(end)) for start, end in found]
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
class _Recorder:
|
|
191
|
+
"""Remembers masked values by path, to say where they sit. They go no further than this."""
|
|
192
|
+
|
|
193
|
+
def __init__(self, inner: Callable[[Leaf], bool]):
|
|
194
|
+
self._inner = inner
|
|
195
|
+
self.values: dict[tuple[Side, str], object] = {}
|
|
196
|
+
|
|
197
|
+
def __call__(self, leaf: Leaf) -> bool:
|
|
198
|
+
masked = self._inner(leaf)
|
|
199
|
+
if masked:
|
|
200
|
+
self.values[(leaf.side, leaf.path)] = leaf.value
|
|
201
|
+
return masked
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _reveal(
|
|
205
|
+
recorder: _Recorder, side: Side, path: str, file: LocalInput, units: Offsets
|
|
206
|
+
) -> Range | None:
|
|
207
|
+
if (side, path) not in recorder.values:
|
|
208
|
+
return None
|
|
209
|
+
found = find_exactly(file.text, recorder.values[(side, path)])
|
|
210
|
+
return Range(start=units(found[0]), end=units(found[1])) if found else None
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _shown(value: object, shared: set[str]) -> JsonValue:
|
|
214
|
+
shown = _json_value(value)
|
|
215
|
+
return without_shared(shown, shared) if isinstance(shown, str) else shown
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _differences(
|
|
219
|
+
result: Comparison,
|
|
220
|
+
recorder: _Recorder,
|
|
221
|
+
left: LocalInput,
|
|
222
|
+
right: LocalInput,
|
|
223
|
+
shared: set[str],
|
|
224
|
+
) -> list[FileDifference]:
|
|
225
|
+
left_units, right_units = Offsets(left.text), Offsets(right.text)
|
|
226
|
+
return [
|
|
227
|
+
FileDifference(
|
|
228
|
+
# A key can hold a secret too, and it reaches the path without passing the masker.
|
|
229
|
+
path=without_shared(d.path, shared),
|
|
230
|
+
change=d.change,
|
|
231
|
+
left=_shown(d.left, shared),
|
|
232
|
+
right=_shown(d.right, shared),
|
|
233
|
+
masked=d.masked,
|
|
234
|
+
reveal_left=_reveal(recorder, Side.LEFT, d.path, left, left_units)
|
|
235
|
+
if d.masked
|
|
236
|
+
else None,
|
|
237
|
+
reveal_right=_reveal(recorder, Side.RIGHT, d.path, right, right_units)
|
|
238
|
+
if d.masked
|
|
239
|
+
else None,
|
|
240
|
+
)
|
|
241
|
+
for d in result.differences[:MAX_DIFFERENCES]
|
|
242
|
+
]
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def run(body: CompareRequest, context: PluginContext) -> CompareResult:
|
|
246
|
+
"""Compare two supplied files. Audits the names, sizes and hashes, and nothing else."""
|
|
247
|
+
left = context.local_input(body.left.name, body.left.text, MAX_FILE_BYTES)
|
|
248
|
+
right = context.local_input(body.right.name, body.right.text, MAX_FILE_BYTES)
|
|
249
|
+
notes: list[str] = []
|
|
250
|
+
format_ = _format(body.format, left.text, right.text, notes)
|
|
251
|
+
parsed_left, parsed_right = (
|
|
252
|
+
parse(left.name, left.text, format_),
|
|
253
|
+
parse(right.name, right.text, format_),
|
|
254
|
+
)
|
|
255
|
+
secrets_left, secrets_right = _secrets(left, parsed_left), _secrets(right, parsed_right)
|
|
256
|
+
# Found on either side, hidden on both: the same text under an ordinary key is the same secret.
|
|
257
|
+
shared = shared_secrets([*secrets_left, *secrets_right])
|
|
258
|
+
own_rule = line_masker if format_ is Format.TEXT else masker
|
|
259
|
+
|
|
260
|
+
def masked_here(leaf: Leaf) -> bool:
|
|
261
|
+
return own_rule(leaf) or contains_shared(leaf.value, shared)
|
|
262
|
+
|
|
263
|
+
recorder = _Recorder(masked_here)
|
|
264
|
+
options = Options(
|
|
265
|
+
ignore_paths=tuple(body.options.ignore_paths),
|
|
266
|
+
ignore_key_order=body.options.ignore_key_order,
|
|
267
|
+
arrays=body.options.arrays,
|
|
268
|
+
array_key=body.options.array_key,
|
|
269
|
+
ignore_whitespace=body.options.ignore_whitespace,
|
|
270
|
+
ignore_case=body.options.ignore_case,
|
|
271
|
+
number_tolerance=body.options.number_tolerance,
|
|
272
|
+
masker=recorder,
|
|
273
|
+
)
|
|
274
|
+
if format_ is Format.TEXT:
|
|
275
|
+
result = compare_lines(left.text, right.text, options)
|
|
276
|
+
else:
|
|
277
|
+
result = compare(parsed_left.tree, parsed_right.tree, options)
|
|
278
|
+
notes += result.notes
|
|
279
|
+
if len(result.differences) > MAX_DIFFERENCES:
|
|
280
|
+
notes.append(
|
|
281
|
+
f"showing the first {MAX_DIFFERENCES:,} of {len(result.differences):,} differences"
|
|
282
|
+
)
|
|
283
|
+
masked_left = _masked_ranges(left, secrets_left, shared)
|
|
284
|
+
masked_right = _masked_ranges(right, secrets_right, shared)
|
|
285
|
+
if masked_left is None or masked_right is None:
|
|
286
|
+
notes.append(
|
|
287
|
+
"the side-by-side view is hidden: a masked value could not be located in the file"
|
|
288
|
+
)
|
|
289
|
+
masked_left = masked_right = None
|
|
290
|
+
context.audit(
|
|
291
|
+
"files_compared",
|
|
292
|
+
{
|
|
293
|
+
"format": format_.value,
|
|
294
|
+
"left": {"name": left.name, "size": left.size, "sha256": left.sha256},
|
|
295
|
+
"right": {"name": right.name, "size": right.size, "sha256": right.sha256},
|
|
296
|
+
},
|
|
297
|
+
)
|
|
298
|
+
return CompareResult(
|
|
299
|
+
left=FileSummary(name=left.name, size=left.size, sha256=left.sha256, format=format_),
|
|
300
|
+
right=FileSummary(name=right.name, size=right.size, sha256=right.sha256, format=format_),
|
|
301
|
+
summary=CompareSummary(
|
|
302
|
+
changed=result.count(Change.CHANGED),
|
|
303
|
+
added=result.count(Change.ADDED),
|
|
304
|
+
removed=result.count(Change.REMOVED),
|
|
305
|
+
unchanged=result.unchanged,
|
|
306
|
+
ignored=result.ignored,
|
|
307
|
+
masked=result.masked,
|
|
308
|
+
),
|
|
309
|
+
differences=_differences(result, recorder, left, right, shared),
|
|
310
|
+
notes=notes,
|
|
311
|
+
masked_left=masked_left,
|
|
312
|
+
masked_right=masked_right,
|
|
313
|
+
)
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
class _CappedRoute(APIRoute):
|
|
317
|
+
"""Refuses a request past the limit while reading it, before anything is parsed."""
|
|
318
|
+
|
|
319
|
+
def get_route_handler(self) -> Callable[[Request], Coroutine[Any, Any, Response]]:
|
|
320
|
+
original = super().get_route_handler()
|
|
321
|
+
|
|
322
|
+
async def capped(request: Request) -> Response:
|
|
323
|
+
declared = request.headers.get("content-length", "")
|
|
324
|
+
if declared.isdigit() and int(declared) > MAX_REQUEST_BYTES:
|
|
325
|
+
raise _too_large(int(declared))
|
|
326
|
+
# Counted as it arrives too: a chunked request declares no length.
|
|
327
|
+
body = bytearray()
|
|
328
|
+
async for chunk in request.stream():
|
|
329
|
+
body.extend(chunk)
|
|
330
|
+
if len(body) > MAX_REQUEST_BYTES:
|
|
331
|
+
raise _too_large(len(body))
|
|
332
|
+
# What request.body() returns from here on; the stream can only be read once.
|
|
333
|
+
request._body = bytes(body)
|
|
334
|
+
return await original(request)
|
|
335
|
+
|
|
336
|
+
return capped
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def _too_large(size: int) -> InputTooLarge:
|
|
340
|
+
return InputTooLarge(
|
|
341
|
+
f"the request was too large: at least {size:,} bytes, and the limit is "
|
|
342
|
+
f"{MAX_REQUEST_BYTES:,} bytes. Compare smaller files or excerpts."
|
|
343
|
+
)
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def register(context: PluginContext) -> APIRouter:
|
|
347
|
+
"""The plugin's routes."""
|
|
348
|
+
router = APIRouter(route_class=_CappedRoute)
|
|
349
|
+
|
|
350
|
+
@router.post("/compare", responses={413: {}, 422: {}})
|
|
351
|
+
def compare_files(body: CompareRequest) -> CompareResult:
|
|
352
|
+
"""Compare two files a person uploaded or pasted. Nothing is stored.
|
|
353
|
+
|
|
354
|
+
Masked values never come back: only where each sits in the file supplied. A secret found
|
|
355
|
+
on either side is masked wherever its exact text appears in both, if 8 characters or more.
|
|
356
|
+
413 past 10 MB per file or 25 MB per request; 422 if a file is not valid in the chosen
|
|
357
|
+
format, never quoting its content.
|
|
358
|
+
"""
|
|
359
|
+
return run(body, context)
|
|
360
|
+
|
|
361
|
+
return router
|
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
# Author: @SaadZarook
|
|
2
|
+
"""Read the eight formats into one shape: a tree of mappings, lists and scalars, or lines.
|
|
3
|
+
|
|
4
|
+
Everything here reads text a person supplied, which may not be what it claims to be. Errors never
|
|
5
|
+
quote the content, because a file may hold a secret: a line number at most.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import configparser
|
|
9
|
+
import csv
|
|
10
|
+
import io
|
|
11
|
+
import json
|
|
12
|
+
import re
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from decimal import Decimal
|
|
15
|
+
from enum import StrEnum
|
|
16
|
+
from typing import Any
|
|
17
|
+
from xml.etree.ElementTree import Element, ParseError
|
|
18
|
+
|
|
19
|
+
import yaml
|
|
20
|
+
from defusedxml import ElementTree
|
|
21
|
+
from defusedxml.common import DefusedXmlException
|
|
22
|
+
|
|
23
|
+
from probeowl.plugins.api import RequestError
|
|
24
|
+
|
|
25
|
+
# YAML aliases can expand a small file into billions of nodes. Past this it is refused.
|
|
26
|
+
MAX_YAML_NODES = 2_000_000
|
|
27
|
+
MAX_CSV_FIELD = 10_000_000
|
|
28
|
+
_NUMBER = re.compile(r"^-?(0|[1-9]\d*)(\.\d+)?$")
|
|
29
|
+
_ENV_LINE = re.compile(r"^(export\s+)?([A-Za-z_][A-Za-z0-9_]*)=(.*)$")
|
|
30
|
+
_INI_SECTION = re.compile(r"^\[[^\]\n]+\]\s*$", re.MULTILINE)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class Format(StrEnum):
|
|
34
|
+
"""What a file is."""
|
|
35
|
+
|
|
36
|
+
JSON = "json"
|
|
37
|
+
YAML = "yaml"
|
|
38
|
+
CSV = "csv"
|
|
39
|
+
XML = "xml"
|
|
40
|
+
PROPERTIES = "properties"
|
|
41
|
+
INI = "ini"
|
|
42
|
+
ENV = "env"
|
|
43
|
+
TEXT = "text"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass(frozen=True)
|
|
47
|
+
class Parsed:
|
|
48
|
+
"""A file read: a tree for a structured format, and None for plain text."""
|
|
49
|
+
|
|
50
|
+
format: Format
|
|
51
|
+
tree: object
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _refuse(name: str, what: str, detail: str = "") -> RequestError:
|
|
55
|
+
suffix = f" ({detail})" if detail else ""
|
|
56
|
+
return RequestError(f"{name} is not valid {what}{suffix}")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _scalar(text: str) -> object:
|
|
60
|
+
# So a number tolerance works on formats where everything is text. Leading zeros stay text:
|
|
61
|
+
# 007 is an identifier, and 7 would hide a real difference.
|
|
62
|
+
return Decimal(text) if _NUMBER.match(text) else text
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _json(name: str, text: str) -> object:
|
|
66
|
+
try:
|
|
67
|
+
return json.loads(text, parse_float=Decimal)
|
|
68
|
+
except json.JSONDecodeError as err:
|
|
69
|
+
raise _refuse(name, "JSON", f"line {err.lineno}") from None
|
|
70
|
+
except (RecursionError, ValueError):
|
|
71
|
+
raise _refuse(name, "JSON", "nested too deeply, or a number too long") from None
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _yaml_nodes_within_limit(node: yaml.Node | None, name: str) -> None:
|
|
75
|
+
# Counts what walking the document would visit, aliases expanded, and stops at the limit.
|
|
76
|
+
pending = [node] if node is not None else []
|
|
77
|
+
seen = 0
|
|
78
|
+
while pending:
|
|
79
|
+
current = pending.pop()
|
|
80
|
+
seen += 1
|
|
81
|
+
if seen > MAX_YAML_NODES:
|
|
82
|
+
raise _refuse(name, "YAML", "it expands to too many values")
|
|
83
|
+
if isinstance(current, yaml.SequenceNode):
|
|
84
|
+
pending.extend(current.value)
|
|
85
|
+
elif isinstance(current, yaml.MappingNode):
|
|
86
|
+
pending.extend(item for pair in current.value for item in pair)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _yaml(name: str, text: str) -> object:
|
|
90
|
+
try:
|
|
91
|
+
for node in yaml.compose_all(text, Loader=yaml.SafeLoader):
|
|
92
|
+
_yaml_nodes_within_limit(node, name)
|
|
93
|
+
documents = list(yaml.safe_load_all(text))
|
|
94
|
+
except yaml.MarkedYAMLError as err:
|
|
95
|
+
mark = err.problem_mark
|
|
96
|
+
raise _refuse(name, "YAML", f"line {mark.line + 1}" if mark else "") from None
|
|
97
|
+
except (yaml.YAMLError, RecursionError, ValueError):
|
|
98
|
+
raise _refuse(name, "YAML") from None
|
|
99
|
+
return documents[0] if len(documents) == 1 else documents
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _csv(name: str, text: str) -> object:
|
|
103
|
+
# The field limit is process-wide, and csv's default would refuse a long field in a legal file.
|
|
104
|
+
csv.field_size_limit(MAX_CSV_FIELD)
|
|
105
|
+
delimiter = (
|
|
106
|
+
";" if text.split("\n", 1)[0].count(";") > text.split("\n", 1)[0].count(",") else ","
|
|
107
|
+
)
|
|
108
|
+
try:
|
|
109
|
+
reader = csv.reader(io.StringIO(text, newline=""), delimiter=delimiter, strict=True)
|
|
110
|
+
rows = [row for row in reader if row]
|
|
111
|
+
except csv.Error:
|
|
112
|
+
raise _refuse(name, "CSV") from None
|
|
113
|
+
if not rows:
|
|
114
|
+
return []
|
|
115
|
+
header, *body = rows
|
|
116
|
+
if len(set(header)) != len(header):
|
|
117
|
+
raise _refuse(name, "CSV", "a column name is repeated")
|
|
118
|
+
for number, row in enumerate(body, start=2):
|
|
119
|
+
if len(row) != len(header):
|
|
120
|
+
raise _refuse(name, "CSV", f"row {number} has {len(row)} of {len(header)} columns")
|
|
121
|
+
return [{k: _scalar(v) for k, v in zip(header, row, strict=True)} for row in body]
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _element(element: Element) -> object:
|
|
125
|
+
children: dict[str, object] = {f"@{k}": _scalar(v) for k, v in element.attrib.items()}
|
|
126
|
+
for child in element:
|
|
127
|
+
value = _element(child)
|
|
128
|
+
existing = children.get(child.tag)
|
|
129
|
+
if child.tag not in children:
|
|
130
|
+
children[child.tag] = value
|
|
131
|
+
elif isinstance(existing, list):
|
|
132
|
+
existing.append(value)
|
|
133
|
+
else:
|
|
134
|
+
children[child.tag] = [existing, value]
|
|
135
|
+
text = (element.text or "").strip()
|
|
136
|
+
if not children:
|
|
137
|
+
return _scalar(text) if text else None
|
|
138
|
+
if text:
|
|
139
|
+
children["#text"] = _scalar(text)
|
|
140
|
+
return children
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _xml(name: str, text: str) -> object:
|
|
144
|
+
# defusedxml, because entity expansion and external entities are refused rather than obeyed.
|
|
145
|
+
try:
|
|
146
|
+
root = ElementTree.fromstring(text)
|
|
147
|
+
except ParseError as err:
|
|
148
|
+
raise _refuse(name, "XML", f"line {err.position[0]}") from None
|
|
149
|
+
except DefusedXmlException:
|
|
150
|
+
raise _refuse(name, "XML", "it uses entities or a DTD, which are refused") from None
|
|
151
|
+
except RecursionError:
|
|
152
|
+
raise _refuse(name, "XML", "nested too deeply") from None
|
|
153
|
+
try:
|
|
154
|
+
return {root.tag: _element(root)}
|
|
155
|
+
except RecursionError:
|
|
156
|
+
raise _refuse(name, "XML", "nested too deeply") from None
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _nest(flat: dict[str, object]) -> dict[str, Any]:
|
|
160
|
+
# So a.b.c is the path it looks like. A key that is both a value and a parent stays flat.
|
|
161
|
+
tree: dict[str, Any] = {}
|
|
162
|
+
for key, value in flat.items():
|
|
163
|
+
parts = key.split(".")
|
|
164
|
+
node = tree
|
|
165
|
+
for part in parts[:-1]:
|
|
166
|
+
child = node.get(part)
|
|
167
|
+
if child is None:
|
|
168
|
+
child = node[part] = {}
|
|
169
|
+
elif not isinstance(child, dict):
|
|
170
|
+
tree[key] = value
|
|
171
|
+
break
|
|
172
|
+
node = child
|
|
173
|
+
else:
|
|
174
|
+
if isinstance(node.get(parts[-1]), dict):
|
|
175
|
+
tree[key] = value
|
|
176
|
+
else:
|
|
177
|
+
node[parts[-1]] = value
|
|
178
|
+
return tree
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _properties(text: str) -> object:
|
|
182
|
+
flat: dict[str, object] = {}
|
|
183
|
+
pending = ""
|
|
184
|
+
for raw in text.splitlines():
|
|
185
|
+
line = pending + raw.strip()
|
|
186
|
+
pending = ""
|
|
187
|
+
if line.endswith("\\") and not line.endswith("\\\\"):
|
|
188
|
+
pending = line[:-1]
|
|
189
|
+
continue
|
|
190
|
+
if not line or line[0] in "#!":
|
|
191
|
+
continue
|
|
192
|
+
match = re.match(r"^((?:\\.|[^\s=:\\])+)\s*[=:\s]\s*(.*)$", line)
|
|
193
|
+
key, value = (match.group(1), match.group(2)) if match else (line, "")
|
|
194
|
+
flat[key.replace("\\", "")] = _scalar(value)
|
|
195
|
+
return _nest(flat)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _ini(name: str, text: str) -> object:
|
|
199
|
+
# No interpolation: a value containing % is data, and resolving it would change what it says.
|
|
200
|
+
parser = configparser.RawConfigParser(strict=False, interpolation=None, default_section="\0")
|
|
201
|
+
parser.optionxform = str # type: ignore[assignment, method-assign]
|
|
202
|
+
try:
|
|
203
|
+
parser.read_string(text)
|
|
204
|
+
except configparser.MissingSectionHeaderError as err:
|
|
205
|
+
# Its own case: it is a ParsingError with no errors list, and its text quotes the line.
|
|
206
|
+
raise _refuse(name, "INI", f"line {err.lineno}") from None
|
|
207
|
+
except configparser.ParsingError as err:
|
|
208
|
+
line = err.errors[0][0] if err.errors else 0
|
|
209
|
+
raise _refuse(name, "INI", f"line {line}") from None
|
|
210
|
+
except configparser.Error:
|
|
211
|
+
raise _refuse(name, "INI") from None
|
|
212
|
+
return {
|
|
213
|
+
section: {key: _scalar(value or "") for key, value in parser.items(section)}
|
|
214
|
+
for section in parser.sections()
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _env(name: str, text: str) -> object:
|
|
219
|
+
values: dict[str, object] = {}
|
|
220
|
+
for number, raw in enumerate(text.splitlines(), start=1):
|
|
221
|
+
line = raw.strip()
|
|
222
|
+
if not line or line.startswith("#"):
|
|
223
|
+
continue
|
|
224
|
+
match = _ENV_LINE.match(line)
|
|
225
|
+
if match is None:
|
|
226
|
+
raise _refuse(name, ".env", f"line {number}")
|
|
227
|
+
value = match.group(3).strip()
|
|
228
|
+
if len(value) >= 2 and value[0] == value[-1] and value[0] in "'\"":
|
|
229
|
+
value = value[1:-1]
|
|
230
|
+
values[match.group(2)] = _scalar(value)
|
|
231
|
+
return values
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _looks_like(text: str) -> Format:
|
|
235
|
+
lines = [
|
|
236
|
+
ln.strip() for ln in text.splitlines() if ln.strip() and not ln.lstrip().startswith("#")
|
|
237
|
+
]
|
|
238
|
+
if not lines:
|
|
239
|
+
return Format.TEXT
|
|
240
|
+
if all(
|
|
241
|
+
_ENV_LINE.match(line) and line.split("=", 1)[0].replace("export ", "").isupper()
|
|
242
|
+
for line in lines
|
|
243
|
+
):
|
|
244
|
+
return Format.ENV
|
|
245
|
+
if _INI_SECTION.search(text) and all(
|
|
246
|
+
"=" in ln or ":" in ln or ln.startswith(("[", ";")) for ln in lines
|
|
247
|
+
):
|
|
248
|
+
return Format.INI
|
|
249
|
+
if all(re.match(r"^[\w.\-]+\s*=", line) for line in lines):
|
|
250
|
+
return Format.PROPERTIES
|
|
251
|
+
return Format.TEXT
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def detect(text: str) -> Format:
|
|
255
|
+
"""What the content looks like. The person can override it."""
|
|
256
|
+
start = text.lstrip()[:1]
|
|
257
|
+
if start in {"{", "["}:
|
|
258
|
+
try:
|
|
259
|
+
json.loads(text)
|
|
260
|
+
return Format.JSON
|
|
261
|
+
except (ValueError, RecursionError):
|
|
262
|
+
pass # [section] or a YAML flow mapping: the checks below decide
|
|
263
|
+
if start == "<":
|
|
264
|
+
return Format.XML
|
|
265
|
+
found = _looks_like(text)
|
|
266
|
+
if found is not Format.TEXT:
|
|
267
|
+
return found
|
|
268
|
+
if _is_csv(text):
|
|
269
|
+
return Format.CSV
|
|
270
|
+
if _is_yaml_structure(text):
|
|
271
|
+
return Format.YAML
|
|
272
|
+
return Format.TEXT
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def _is_csv(text: str) -> bool:
|
|
276
|
+
lines = [ln for ln in text.splitlines() if ln.strip()]
|
|
277
|
+
if len(lines) < 2:
|
|
278
|
+
return False
|
|
279
|
+
for delimiter in (",", ";"):
|
|
280
|
+
try:
|
|
281
|
+
widths = {
|
|
282
|
+
len(r) for r in csv.reader(io.StringIO(text, newline=""), delimiter=delimiter) if r
|
|
283
|
+
}
|
|
284
|
+
except csv.Error:
|
|
285
|
+
continue
|
|
286
|
+
if len(widths) == 1 and widths != {1}:
|
|
287
|
+
return True
|
|
288
|
+
return False
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _is_yaml_structure(text: str) -> bool:
|
|
292
|
+
try:
|
|
293
|
+
return isinstance(yaml.safe_load(text), dict | list)
|
|
294
|
+
except (yaml.YAMLError, RecursionError, ValueError):
|
|
295
|
+
return False
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def parse(name: str, text: str, wanted: Format) -> Parsed:
|
|
299
|
+
"""The file read as wanted; RequestError naming the file, never quoting it, if it cannot be."""
|
|
300
|
+
readers = {
|
|
301
|
+
Format.JSON: lambda: _json(name, text),
|
|
302
|
+
Format.YAML: lambda: _yaml(name, text),
|
|
303
|
+
Format.CSV: lambda: _csv(name, text),
|
|
304
|
+
Format.XML: lambda: _xml(name, text),
|
|
305
|
+
Format.PROPERTIES: lambda: _properties(text),
|
|
306
|
+
Format.INI: lambda: _ini(name, text),
|
|
307
|
+
Format.ENV: lambda: _env(name, text),
|
|
308
|
+
Format.TEXT: lambda: None,
|
|
309
|
+
}
|
|
310
|
+
return Parsed(wanted, readers[wanted]())
|