graphitect 0.2.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.
Files changed (336) hide show
  1. graphify/__init__.py +30 -0
  2. graphify/__main__.py +757 -0
  3. graphify/_minhash.py +107 -0
  4. graphify/affected.py +318 -0
  5. graphify/always_on/agents-md.md +12 -0
  6. graphify/always_on/antigravity-rules.md +14 -0
  7. graphify/always_on/claude-md.md +9 -0
  8. graphify/always_on/gemini-md.md +9 -0
  9. graphify/always_on/kiro-steering.md +5 -0
  10. graphify/always_on/vscode-instructions.md +17 -0
  11. graphify/analyze.py +769 -0
  12. graphify/benchmark.py +152 -0
  13. graphify/build.py +2300 -0
  14. graphify/cache.py +1746 -0
  15. graphify/callflow_html.py +2051 -0
  16. graphify/cargo_introspect.py +109 -0
  17. graphify/cli.py +4745 -0
  18. graphify/cluster.py +409 -0
  19. graphify/command-kilo.md +15 -0
  20. graphify/cross_repo_calls.py +216 -0
  21. graphify/cross_repo_types.py +75 -0
  22. graphify/csharp_dispatch.py +154 -0
  23. graphify/dedup.py +1213 -0
  24. graphify/detect.py +2566 -0
  25. graphify/diagnostics.py +406 -0
  26. graphify/export.py +1349 -0
  27. graphify/exporters/__init__.py +1 -0
  28. graphify/exporters/base.py +14 -0
  29. graphify/exporters/graphdb.py +173 -0
  30. graphify/exporters/html.py +637 -0
  31. graphify/extract.py +7856 -0
  32. graphify/extractors/MIGRATION.md +107 -0
  33. graphify/extractors/__init__.py +66 -0
  34. graphify/extractors/apex.py +215 -0
  35. graphify/extractors/base.py +85 -0
  36. graphify/extractors/bash.py +579 -0
  37. graphify/extractors/blade.py +53 -0
  38. graphify/extractors/commonlisp.py +540 -0
  39. graphify/extractors/csharp.py +448 -0
  40. graphify/extractors/dart.py +564 -0
  41. graphify/extractors/dm.py +494 -0
  42. graphify/extractors/elixir.py +241 -0
  43. graphify/extractors/engine.py +6509 -0
  44. graphify/extractors/fortran.py +311 -0
  45. graphify/extractors/go.py +527 -0
  46. graphify/extractors/json_config.py +240 -0
  47. graphify/extractors/julia.py +289 -0
  48. graphify/extractors/markdown.py +408 -0
  49. graphify/extractors/models.py +131 -0
  50. graphify/extractors/objc.py +566 -0
  51. graphify/extractors/ocaml.py +289 -0
  52. graphify/extractors/pascal.py +688 -0
  53. graphify/extractors/pascal_forms.py +196 -0
  54. graphify/extractors/powershell.py +522 -0
  55. graphify/extractors/razor.py +192 -0
  56. graphify/extractors/resolution.py +3584 -0
  57. graphify/extractors/robot.py +296 -0
  58. graphify/extractors/rust.py +470 -0
  59. graphify/extractors/sln.py +92 -0
  60. graphify/extractors/sql.py +720 -0
  61. graphify/extractors/terraform.py +181 -0
  62. graphify/extractors/verilog.py +329 -0
  63. graphify/extractors/zig.py +181 -0
  64. graphify/file_slice.py +246 -0
  65. graphify/global_graph.py +194 -0
  66. graphify/google_workspace.py +237 -0
  67. graphify/hooks.py +933 -0
  68. graphify/ids.py +93 -0
  69. graphify/ingest.py +358 -0
  70. graphify/install.py +2366 -0
  71. graphify/llm.py +3544 -0
  72. graphify/manifest.py +4 -0
  73. graphify/manifest_ingest.py +311 -0
  74. graphify/mcp_ingest.py +386 -0
  75. graphify/multigraph_compat.py +212 -0
  76. graphify/pascal_resolution.py +129 -0
  77. graphify/paths.py +436 -0
  78. graphify/pg_introspect.py +165 -0
  79. graphify/prs.py +770 -0
  80. graphify/querylog.py +80 -0
  81. graphify/reflect.py +882 -0
  82. graphify/report.py +346 -0
  83. graphify/resolver_registry.py +85 -0
  84. graphify/ruby_resolution.py +242 -0
  85. graphify/scip_ingest.py +363 -0
  86. graphify/security.py +460 -0
  87. graphify/semantic_cleanup.py +336 -0
  88. graphify/serve.py +2608 -0
  89. graphify/skill-agents.md +710 -0
  90. graphify/skill-aider.md +1283 -0
  91. graphify/skill-amp.md +710 -0
  92. graphify/skill-claw.md +713 -0
  93. graphify/skill-codex.md +710 -0
  94. graphify/skill-copilot.md +713 -0
  95. graphify/skill-devin.md +1410 -0
  96. graphify/skill-droid.md +710 -0
  97. graphify/skill-kilo.md +722 -0
  98. graphify/skill-kiro.md +713 -0
  99. graphify/skill-opencode.md +705 -0
  100. graphify/skill-pi.md +713 -0
  101. graphify/skill-trae.md +711 -0
  102. graphify/skill-vscode.md +709 -0
  103. graphify/skill-windows.md +755 -0
  104. graphify/skill.md +713 -0
  105. graphify/skills/agents/references/add-watch.md +56 -0
  106. graphify/skills/agents/references/exports.md +87 -0
  107. graphify/skills/agents/references/extraction-spec.md +70 -0
  108. graphify/skills/agents/references/github-and-merge.md +46 -0
  109. graphify/skills/agents/references/hooks.md +33 -0
  110. graphify/skills/agents/references/query.md +311 -0
  111. graphify/skills/agents/references/transcribe.md +52 -0
  112. graphify/skills/agents/references/update.md +210 -0
  113. graphify/skills/amp/references/add-watch.md +56 -0
  114. graphify/skills/amp/references/exports.md +87 -0
  115. graphify/skills/amp/references/extraction-spec.md +70 -0
  116. graphify/skills/amp/references/github-and-merge.md +46 -0
  117. graphify/skills/amp/references/hooks.md +33 -0
  118. graphify/skills/amp/references/query.md +311 -0
  119. graphify/skills/amp/references/transcribe.md +52 -0
  120. graphify/skills/amp/references/update.md +210 -0
  121. graphify/skills/claude/references/add-watch.md +56 -0
  122. graphify/skills/claude/references/exports.md +87 -0
  123. graphify/skills/claude/references/extraction-spec.md +70 -0
  124. graphify/skills/claude/references/github-and-merge.md +46 -0
  125. graphify/skills/claude/references/hooks.md +33 -0
  126. graphify/skills/claude/references/query.md +311 -0
  127. graphify/skills/claude/references/transcribe.md +52 -0
  128. graphify/skills/claude/references/update.md +210 -0
  129. graphify/skills/claw/references/add-watch.md +56 -0
  130. graphify/skills/claw/references/exports.md +87 -0
  131. graphify/skills/claw/references/extraction-spec.md +31 -0
  132. graphify/skills/claw/references/github-and-merge.md +46 -0
  133. graphify/skills/claw/references/hooks.md +33 -0
  134. graphify/skills/claw/references/query.md +311 -0
  135. graphify/skills/claw/references/transcribe.md +52 -0
  136. graphify/skills/claw/references/update.md +210 -0
  137. graphify/skills/codex/references/add-watch.md +56 -0
  138. graphify/skills/codex/references/exports.md +87 -0
  139. graphify/skills/codex/references/extraction-spec.md +31 -0
  140. graphify/skills/codex/references/github-and-merge.md +46 -0
  141. graphify/skills/codex/references/hooks.md +33 -0
  142. graphify/skills/codex/references/query.md +311 -0
  143. graphify/skills/codex/references/transcribe.md +52 -0
  144. graphify/skills/codex/references/update.md +210 -0
  145. graphify/skills/copilot/references/add-watch.md +56 -0
  146. graphify/skills/copilot/references/exports.md +87 -0
  147. graphify/skills/copilot/references/extraction-spec.md +70 -0
  148. graphify/skills/copilot/references/github-and-merge.md +46 -0
  149. graphify/skills/copilot/references/hooks.md +33 -0
  150. graphify/skills/copilot/references/query.md +311 -0
  151. graphify/skills/copilot/references/transcribe.md +52 -0
  152. graphify/skills/copilot/references/update.md +210 -0
  153. graphify/skills/droid/references/add-watch.md +56 -0
  154. graphify/skills/droid/references/exports.md +87 -0
  155. graphify/skills/droid/references/extraction-spec.md +70 -0
  156. graphify/skills/droid/references/github-and-merge.md +46 -0
  157. graphify/skills/droid/references/hooks.md +33 -0
  158. graphify/skills/droid/references/query.md +311 -0
  159. graphify/skills/droid/references/transcribe.md +52 -0
  160. graphify/skills/droid/references/update.md +210 -0
  161. graphify/skills/kilo/references/add-watch.md +56 -0
  162. graphify/skills/kilo/references/exports.md +87 -0
  163. graphify/skills/kilo/references/extraction-spec.md +70 -0
  164. graphify/skills/kilo/references/github-and-merge.md +46 -0
  165. graphify/skills/kilo/references/hooks.md +33 -0
  166. graphify/skills/kilo/references/query.md +311 -0
  167. graphify/skills/kilo/references/transcribe.md +52 -0
  168. graphify/skills/kilo/references/update.md +210 -0
  169. graphify/skills/kiro/references/add-watch.md +56 -0
  170. graphify/skills/kiro/references/exports.md +87 -0
  171. graphify/skills/kiro/references/extraction-spec.md +31 -0
  172. graphify/skills/kiro/references/github-and-merge.md +46 -0
  173. graphify/skills/kiro/references/hooks.md +33 -0
  174. graphify/skills/kiro/references/query.md +311 -0
  175. graphify/skills/kiro/references/transcribe.md +52 -0
  176. graphify/skills/kiro/references/update.md +210 -0
  177. graphify/skills/opencode/references/add-watch.md +56 -0
  178. graphify/skills/opencode/references/exports.md +87 -0
  179. graphify/skills/opencode/references/extraction-spec.md +70 -0
  180. graphify/skills/opencode/references/github-and-merge.md +46 -0
  181. graphify/skills/opencode/references/hooks.md +33 -0
  182. graphify/skills/opencode/references/query.md +311 -0
  183. graphify/skills/opencode/references/transcribe.md +52 -0
  184. graphify/skills/opencode/references/update.md +210 -0
  185. graphify/skills/pi/references/add-watch.md +56 -0
  186. graphify/skills/pi/references/exports.md +87 -0
  187. graphify/skills/pi/references/extraction-spec.md +31 -0
  188. graphify/skills/pi/references/github-and-merge.md +46 -0
  189. graphify/skills/pi/references/hooks.md +33 -0
  190. graphify/skills/pi/references/query.md +311 -0
  191. graphify/skills/pi/references/transcribe.md +52 -0
  192. graphify/skills/pi/references/update.md +210 -0
  193. graphify/skills/trae/references/add-watch.md +56 -0
  194. graphify/skills/trae/references/exports.md +87 -0
  195. graphify/skills/trae/references/extraction-spec.md +70 -0
  196. graphify/skills/trae/references/github-and-merge.md +46 -0
  197. graphify/skills/trae/references/hooks.md +35 -0
  198. graphify/skills/trae/references/query.md +311 -0
  199. graphify/skills/trae/references/transcribe.md +52 -0
  200. graphify/skills/trae/references/update.md +210 -0
  201. graphify/skills/vscode/references/add-watch.md +56 -0
  202. graphify/skills/vscode/references/exports.md +87 -0
  203. graphify/skills/vscode/references/extraction-spec.md +70 -0
  204. graphify/skills/vscode/references/github-and-merge.md +46 -0
  205. graphify/skills/vscode/references/hooks.md +33 -0
  206. graphify/skills/vscode/references/query.md +311 -0
  207. graphify/skills/vscode/references/transcribe.md +52 -0
  208. graphify/skills/vscode/references/update.md +210 -0
  209. graphify/skills/windows/references/add-watch.md +56 -0
  210. graphify/skills/windows/references/exports.md +87 -0
  211. graphify/skills/windows/references/extraction-spec.md +70 -0
  212. graphify/skills/windows/references/github-and-merge.md +46 -0
  213. graphify/skills/windows/references/hooks.md +33 -0
  214. graphify/skills/windows/references/query.md +311 -0
  215. graphify/skills/windows/references/transcribe.md +52 -0
  216. graphify/skills/windows/references/update.md +210 -0
  217. graphify/symbol_resolution.py +556 -0
  218. graphify/transcribe.py +186 -0
  219. graphify/tree_html.py +603 -0
  220. graphify/validate.py +95 -0
  221. graphify/watch.py +2280 -0
  222. graphify/wiki.py +405 -0
  223. graphitect/__init__.py +28 -0
  224. graphitect/__main__.py +4 -0
  225. graphitect/_vendor/__init__.py +2 -0
  226. graphitect/_vendor/archify/LICENSE +22 -0
  227. graphitect/_vendor/archify/SKILL.md +137 -0
  228. graphitect/_vendor/archify/THIRD_PARTY_NOTICES.md +69 -0
  229. graphitect/_vendor/archify/assets/JetBrainsMono-OFL.txt +93 -0
  230. graphitect/_vendor/archify/assets/template.html +14935 -0
  231. graphitect/_vendor/archify/bin/archify.mjs +2091 -0
  232. graphitect/_vendor/archify/bin/open-artifact.mjs +86 -0
  233. graphitect/_vendor/archify/bin/preview.mjs +653 -0
  234. graphitect/_vendor/archify/bin/visual-check.mjs +829 -0
  235. graphitect/_vendor/archify/brand-marks/README.md +31 -0
  236. graphitect/_vendor/archify/brand-marks/catalog.json +131 -0
  237. graphitect/_vendor/archify/delta/architecture-delta.mjs +1221 -0
  238. graphitect/_vendor/archify/examples/agent-run.lifecycle.json +60 -0
  239. graphitect/_vendor/archify/examples/agent-tool-call.workflow.json +94 -0
  240. graphitect/_vendor/archify/examples/async-job-roundtrip.sequence.json +61 -0
  241. graphitect/_vendor/archify/examples/brand-aware-delivery.architecture.json +47 -0
  242. graphitect/_vendor/archify/examples/cache-miss-request.sequence.json +82 -0
  243. graphitect/_vendor/archify/examples/checkout-platform.base.architecture.json +31 -0
  244. graphitect/_vendor/archify/examples/checkout-platform.head.architecture.json +31 -0
  245. graphitect/_vendor/archify/examples/dataflow-product-analytics.html +15045 -0
  246. graphitect/_vendor/archify/examples/deployment-release.lifecycle.json +49 -0
  247. graphitect/_vendor/archify/examples/event-stream.dataflow.json +57 -0
  248. graphitect/_vendor/archify/examples/incident-response.workflow.json +64 -0
  249. graphitect/_vendor/archify/examples/lifecycle-agent-run.html +14980 -0
  250. graphitect/_vendor/archify/examples/product-analytics.dataflow.json +76 -0
  251. graphitect/_vendor/archify/examples/production-deployment.architecture.json +71 -0
  252. graphitect/_vendor/archify/examples/release-delivery.workflow.json +62 -0
  253. graphitect/_vendor/archify/examples/sequence-cache-miss-request.html +15060 -0
  254. graphitect/_vendor/archify/examples/web-app-rendered.html +15009 -0
  255. graphitect/_vendor/archify/examples/web-app.architecture.json +46 -0
  256. graphitect/_vendor/archify/examples/workflow-agent-tool-call-rendered.html +15051 -0
  257. graphitect/_vendor/archify/migrations/workflow-v2.mjs +279 -0
  258. graphitect/_vendor/archify/package-lock.json +149 -0
  259. graphitect/_vendor/archify/package.json +39 -0
  260. graphitect/_vendor/archify/recipes/scenarios.mjs +391 -0
  261. graphitect/_vendor/archify/references/authoring-contract.md +243 -0
  262. graphitect/_vendor/archify/references/brand-marks.md +65 -0
  263. graphitect/_vendor/archify/references/delivery-contract.md +120 -0
  264. graphitect/_vendor/archify/references/viewer-runtime.md +45 -0
  265. graphitect/_vendor/archify/renderers/architecture/grid.mjs +62 -0
  266. graphitect/_vendor/archify/renderers/architecture/render-architecture.mjs +1078 -0
  267. graphitect/_vendor/archify/renderers/dataflow/README.md +104 -0
  268. graphitect/_vendor/archify/renderers/dataflow/render-dataflow.mjs +483 -0
  269. graphitect/_vendor/archify/renderers/lifecycle/README.md +115 -0
  270. graphitect/_vendor/archify/renderers/lifecycle/render-lifecycle.mjs +561 -0
  271. graphitect/_vendor/archify/renderers/sequence/README.md +114 -0
  272. graphitect/_vendor/archify/renderers/sequence/render-sequence.mjs +464 -0
  273. graphitect/_vendor/archify/renderers/shared/brand-marks.mjs +563 -0
  274. graphitect/_vendor/archify/renderers/shared/cli.mjs +218 -0
  275. graphitect/_vendor/archify/renderers/shared/desktop-readability.mjs +26 -0
  276. graphitect/_vendor/archify/renderers/shared/diagnostics.mjs +127 -0
  277. graphitect/_vendor/archify/renderers/shared/engineering-profiles.mjs +157 -0
  278. graphitect/_vendor/archify/renderers/shared/generated-brand-marks.mjs +2003 -0
  279. graphitect/_vendor/archify/renderers/shared/generated-validators.mjs +13 -0
  280. graphitect/_vendor/archify/renderers/shared/geometry.mjs +1423 -0
  281. graphitect/_vendor/archify/renderers/shared/i18n.mjs +595 -0
  282. graphitect/_vendor/archify/renderers/shared/layout-report.mjs +40 -0
  283. graphitect/_vendor/archify/renderers/shared/legend.mjs +217 -0
  284. graphitect/_vendor/archify/renderers/shared/output-path.mjs +340 -0
  285. graphitect/_vendor/archify/renderers/shared/repository-evidence.mjs +238 -0
  286. graphitect/_vendor/archify/renderers/shared/repository-location.mjs +58 -0
  287. graphitect/_vendor/archify/renderers/shared/text-fit.mjs +49 -0
  288. graphitect/_vendor/archify/renderers/shared/utils.mjs +232 -0
  289. graphitect/_vendor/archify/renderers/shared/validator.mjs +86 -0
  290. graphitect/_vendor/archify/renderers/workflow/README.md +223 -0
  291. graphitect/_vendor/archify/renderers/workflow/render-workflow.mjs +35 -0
  292. graphitect/_vendor/archify/renderers/workflow/workflow-compiler.mjs +4400 -0
  293. graphitect/_vendor/archify/renderers/workflow/workflow-migration-geometry.mjs +144 -0
  294. graphitect/_vendor/archify/schemas/README.md +211 -0
  295. graphitect/_vendor/archify/schemas/architecture.schema.json +178 -0
  296. graphitect/_vendor/archify/schemas/common.schema.json +115 -0
  297. graphitect/_vendor/archify/schemas/dataflow.schema.json +243 -0
  298. graphitect/_vendor/archify/schemas/lifecycle.schema.json +266 -0
  299. graphitect/_vendor/archify/schemas/sequence.schema.json +223 -0
  300. graphitect/_vendor/archify/schemas/workflow.schema.json +428 -0
  301. graphitect/_vendor/archify/scripts/check-render-output.mjs +836 -0
  302. graphitect/_vendor/archify/scripts/check-update.mjs +1667 -0
  303. graphitect/_vendor/archify/scripts/generate-brand-marks.mjs +141 -0
  304. graphitect/_vendor/archify/scripts/generate-validators.mjs +66 -0
  305. graphitect/_vendor/archify/scripts/render-examples.mjs +26 -0
  306. graphitect/_vendor/archify/scripts/update-contract.mjs +182 -0
  307. graphitect/_vendor/archify/skill-release.json +10 -0
  308. graphitect/cli.py +981 -0
  309. graphitect/deliver/__init__.py +5 -0
  310. graphitect/deliver/archify_adapter.py +1877 -0
  311. graphitect/deliver/archify_ir.py +160 -0
  312. graphitect/deliver/archify_repair.py +135 -0
  313. graphitect/deliver/doc_compiler.py +916 -0
  314. graphitect/ground/__init__.py +5 -0
  315. graphitect/ground/describe_source.py +27 -0
  316. graphitect/ground/fullread_source.py +56 -0
  317. graphitect/ground/graphify_source.py +107 -0
  318. graphitect/models.py +118 -0
  319. graphitect/skill/SKILL.md +80 -0
  320. graphitect/skill/agents/openai.yaml +4 -0
  321. graphitect/synthesize/__init__.py +5 -0
  322. graphitect/synthesize/engine.py +281 -0
  323. graphitect/synthesize/llm_backend.py +331 -0
  324. graphitect/synthesize/questions.py +139 -0
  325. graphitect/synthesize/rubric.py +104 -0
  326. graphitect-0.2.0.dist-info/METADATA +284 -0
  327. graphitect-0.2.0.dist-info/RECORD +336 -0
  328. graphitect-0.2.0.dist-info/WHEEL +5 -0
  329. graphitect-0.2.0.dist-info/entry_points.txt +2 -0
  330. graphitect-0.2.0.dist-info/licenses/LICENSE +21 -0
  331. graphitect-0.2.0.dist-info/licenses/LICENSE-ARCHIFY-MIT +22 -0
  332. graphitect-0.2.0.dist-info/licenses/LICENSE-GRAPHIFY-APACHE-2.0 +202 -0
  333. graphitect-0.2.0.dist-info/licenses/LICENSE-GRAPHIFY-MIT +21 -0
  334. graphitect-0.2.0.dist-info/licenses/NOTICE-ARCHIFY-THIRD-PARTY.md +69 -0
  335. graphitect-0.2.0.dist-info/licenses/NOTICE-GRAPHIFY +8 -0
  336. graphitect-0.2.0.dist-info/top_level.txt +2 -0
graphify/security.py ADDED
@@ -0,0 +1,460 @@
1
+ # Security helpers - URL validation, safe fetch, path guards, label sanitisation
2
+ from __future__ import annotations
3
+
4
+ import html
5
+ import http.client
6
+ import os
7
+ import re
8
+ import urllib.error
9
+ import urllib.parse
10
+ import urllib.request
11
+ from collections.abc import Mapping
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+ import ipaddress
16
+ import socket
17
+
18
+ from graphify.paths import GRAPHIFY_OUT, GRAPHIFY_OUT_NAME
19
+
20
+ _ALLOWED_SCHEMES = {"http", "https"}
21
+ _MAX_FETCH_BYTES = 52_428_800 # 50 MB hard cap for binary downloads
22
+ _MAX_TEXT_BYTES = 10_485_760 # 10 MB hard cap for HTML / text
23
+
24
+ # Graph-load memory-bomb cap: reject .json files larger than this before
25
+ # JSON-parsing them into a dict. Without this, a multi-gigabyte (or
26
+ # specifically crafted) graph.json can exhaust process memory during
27
+ # json.loads + node_link_graph rehydration.
28
+ # Default fallback cap. Kept as a module-level constant so the value is
29
+ # discoverable and so existing callers/tests that reference it directly keep
30
+ # working; the effective cap is resolved at call time by
31
+ # ``_max_graph_file_bytes`` (which lets ``GRAPHIFY_MAX_GRAPH_BYTES`` override it).
32
+ _MAX_GRAPH_FILE_BYTES = 512 * 1024 * 1024 # 512 MiB
33
+
34
+
35
+ def _max_graph_file_bytes() -> int:
36
+ """Return the graph.json size cap in bytes.
37
+
38
+ Honors the ``GRAPHIFY_MAX_GRAPH_BYTES`` environment variable so users with
39
+ large codebases can raise the limit without editing source. The value may
40
+ be plain bytes (``671088640``) or carry an ``MB`` / ``GB`` suffix
41
+ (``640MB``, ``2GB`` — case-insensitive, binary multipliers: ``MB`` is
42
+ 1024*1024 and ``GB`` is 1024*1024*1024, i.e. MiB / GiB).
43
+ Falls back to ``_MAX_GRAPH_FILE_BYTES`` (512 MiB) when the env var is unset,
44
+ blank, or unparseable.
45
+
46
+ Read fresh on every call so the env var can be set before import and still
47
+ take effect.
48
+ """
49
+ raw = os.environ.get("GRAPHIFY_MAX_GRAPH_BYTES", "").strip()
50
+ if not raw:
51
+ return _MAX_GRAPH_FILE_BYTES
52
+ text = raw.upper()
53
+ multiplier = 1
54
+ if text.endswith("GB"):
55
+ multiplier = 1024 * 1024 * 1024
56
+ text = text[:-2].strip()
57
+ elif text.endswith("MB"):
58
+ multiplier = 1024 * 1024
59
+ text = text[:-2].strip()
60
+ try:
61
+ value = int(text)
62
+ except ValueError:
63
+ return _MAX_GRAPH_FILE_BYTES
64
+ if value <= 0:
65
+ return _MAX_GRAPH_FILE_BYTES
66
+ return value * multiplier
67
+
68
+ # AWS metadata, link-local, and common cloud metadata endpoints
69
+ _BLOCKED_HOSTS = {"metadata.google.internal", "metadata.google.com"}
70
+
71
+ # RFC 6598 Shared Address Space (CGN) -- is_private misses this on Python <3.11
72
+ _CGN_NETWORK = ipaddress.ip_network("100.64.0.0/10")
73
+
74
+ # RFC 6052 NAT64 Well-Known Prefix -- is_reserved=True in Python but these embed
75
+ # public IPv4 addresses and are legitimate public internet traffic, not SSRF vectors.
76
+ _NAT64_WKP = ipaddress.ip_network("64:ff9b::/96")
77
+
78
+
79
+ # ---------------------------------------------------------------------------
80
+ # URL validation
81
+ # ---------------------------------------------------------------------------
82
+
83
+ def _ip_is_blocked(ip: ipaddress.IPv4Address | ipaddress.IPv6Address) -> bool:
84
+ """Return True if *ip* falls in a private/reserved/internal range.
85
+
86
+ Shared by validate_url (pre-flight DNS check) and the SSRF-guarded
87
+ connection classes (connect-time check) so both use identical logic.
88
+ NAT64 well-known-prefix addresses are unwrapped to their embedded IPv4
89
+ before the check, since those carry legitimate public traffic.
90
+ """
91
+ # For NAT64 addresses, check the embedded IPv4 instead of the wrapper
92
+ if isinstance(ip, ipaddress.IPv6Address) and ip in _NAT64_WKP:
93
+ ip = ipaddress.ip_address(int(ip) & 0xFFFFFFFF)
94
+ return (
95
+ ip.is_private
96
+ or ip.is_reserved
97
+ or ip.is_loopback
98
+ or ip.is_link_local
99
+ or ip in _CGN_NETWORK
100
+ )
101
+
102
+
103
+ def validate_url(url: str) -> str:
104
+ """Raise ValueError if *url* is not http or https, or targets a private/internal IP.
105
+
106
+ Blocks file://, ftp://, data:, and any other scheme that could be used
107
+ for SSRF or local file access. Also blocks requests to private/reserved
108
+ IP ranges (127.x, 10.x, 169.254.x, etc.) and cloud metadata endpoints
109
+ to prevent SSRF in cloud environments.
110
+ """
111
+ parsed = urllib.parse.urlparse(url)
112
+ if parsed.scheme.lower() not in _ALLOWED_SCHEMES:
113
+ raise ValueError(
114
+ f"Blocked URL scheme '{parsed.scheme}' - only http and https are allowed. "
115
+ f"Got: {url!r}"
116
+ )
117
+
118
+ hostname = parsed.hostname
119
+ if hostname:
120
+ # Block known cloud metadata hostnames
121
+ if hostname.lower() in _BLOCKED_HOSTS:
122
+ raise ValueError(
123
+ f"Blocked cloud metadata endpoint '{hostname}'. "
124
+ f"Got: {url!r}"
125
+ )
126
+
127
+ # Resolve hostname and block private/reserved IP ranges
128
+ try:
129
+ infos = socket.getaddrinfo(hostname, None, socket.AF_UNSPEC, socket.SOCK_STREAM)
130
+ for info in infos:
131
+ addr = info[4][0]
132
+ ip = ipaddress.ip_address(addr)
133
+ if _ip_is_blocked(ip):
134
+ raise ValueError(
135
+ f"Blocked private/internal IP {addr} (resolved from '{hostname}'). "
136
+ f"Got: {url!r}"
137
+ )
138
+ except socket.gaierror as exc:
139
+ raise ValueError(
140
+ f"DNS resolution failed for '{hostname}': {exc}. Got: {url!r}"
141
+ ) from exc
142
+
143
+ return url
144
+
145
+
146
+ # ---------------------------------------------------------------------------
147
+ # SSRF-guarded connections
148
+ #
149
+ # Instead of monkey-patching the process-global socket.getaddrinfo (a
150
+ # non-thread-safe TOCTOU hazard when multiple fetches run concurrently),
151
+ # we subclass the HTTP(S) connection so each connection resolves DNS exactly
152
+ # once, validates the resulting IP, and then connects to that exact IP. There
153
+ # is no second resolution, so a DNS-rebind attack cannot swap in a private
154
+ # address (e.g. 169.254.169.254) between validation and connection.
155
+ # ---------------------------------------------------------------------------
156
+
157
+
158
+ def _resolve_and_validate(host: str, port: int) -> tuple[int, str]:
159
+ """Resolve *host* once and return (family, validated_ip) for the first
160
+ address that is not in a blocked range.
161
+
162
+ Raises OSError if every resolved address is private/reserved/internal,
163
+ matching the failure mode urllib/http.client expect from connect().
164
+ """
165
+ infos = socket.getaddrinfo(host, port, socket.AF_UNSPEC, socket.SOCK_STREAM)
166
+ for family, _type, _proto, _canon, sockaddr in infos:
167
+ addr = sockaddr[0]
168
+ try:
169
+ ip = ipaddress.ip_address(addr)
170
+ except ValueError:
171
+ continue
172
+ if _ip_is_blocked(ip):
173
+ raise OSError(
174
+ f"SSRF blocked: IP {addr} resolved from '{host}' is private/reserved"
175
+ )
176
+ return family, addr
177
+ raise OSError(f"SSRF blocked: no usable address resolved from '{host}'")
178
+
179
+
180
+ class _SSRFGuardedHTTPConnection(http.client.HTTPConnection):
181
+ """HTTPConnection that resolves + validates DNS once, then connects to the
182
+ exact validated IP (no second resolution = no DNS-rebind TOCTOU)."""
183
+
184
+ def connect(self) -> None:
185
+ family, ip = _resolve_and_validate(self.host, self.port)
186
+ self.sock = socket.create_connection(
187
+ (ip, self.port),
188
+ self.timeout,
189
+ self.source_address,
190
+ )
191
+ if self._tunnel_host:
192
+ self._tunnel()
193
+
194
+
195
+ class _SSRFGuardedHTTPSConnection(http.client.HTTPSConnection):
196
+ """HTTPSConnection variant of _SSRFGuardedHTTPConnection.
197
+
198
+ Connects to the validated IP but performs the TLS handshake with
199
+ server_hostname set to the original hostname so SNI / certificate
200
+ validation work correctly (validating against the IP would break TLS).
201
+ """
202
+
203
+ def connect(self) -> None:
204
+ family, ip = _resolve_and_validate(self.host, self.port)
205
+ sock = socket.create_connection(
206
+ (ip, self.port),
207
+ self.timeout,
208
+ self.source_address,
209
+ )
210
+ if self._tunnel_host:
211
+ self.sock = sock
212
+ self._tunnel()
213
+ sock = self.sock
214
+ self.sock = self._context.wrap_socket(sock, server_hostname=self.host)
215
+
216
+
217
+ class _SSRFGuardedHTTPHandler(urllib.request.HTTPHandler):
218
+ """urllib handler that routes http:// through _SSRFGuardedHTTPConnection."""
219
+
220
+ def http_open(self, req):
221
+ return self.do_open(_SSRFGuardedHTTPConnection, req)
222
+
223
+
224
+ class _SSRFGuardedHTTPSHandler(urllib.request.HTTPSHandler):
225
+ """urllib handler that routes https:// through _SSRFGuardedHTTPSConnection."""
226
+
227
+ def https_open(self, req):
228
+ return self.do_open(_SSRFGuardedHTTPSConnection, req)
229
+
230
+
231
+ class _NoFileRedirectHandler(urllib.request.HTTPRedirectHandler):
232
+ """Redirect handler that re-validates every redirect target.
233
+
234
+ Prevents open-redirect SSRF attacks where an http:// URL redirects
235
+ to file:// or an internal address.
236
+ """
237
+
238
+ def redirect_request(self, req, fp, code, msg, headers, newurl):
239
+ validate_url(newurl) # raises ValueError if scheme is wrong
240
+ return super().redirect_request(req, fp, code, msg, headers, newurl)
241
+
242
+
243
+ def _build_opener() -> urllib.request.OpenerDirector:
244
+ # build_opener replaces the default HTTP(S)Handlers with our SSRF-guarded
245
+ # subclasses, so every connection resolves+validates DNS once and connects
246
+ # to that exact IP. Thread-safe: no process-global state is mutated.
247
+ return urllib.request.build_opener(
248
+ _SSRFGuardedHTTPHandler,
249
+ _SSRFGuardedHTTPSHandler,
250
+ _NoFileRedirectHandler,
251
+ )
252
+
253
+
254
+ # ---------------------------------------------------------------------------
255
+ # Safe fetch
256
+ # ---------------------------------------------------------------------------
257
+
258
+ def safe_fetch(url: str, max_bytes: int = _MAX_FETCH_BYTES, timeout: int = 30) -> bytes:
259
+ """Fetch *url* and return raw bytes.
260
+
261
+ Protections applied:
262
+ - URL scheme validated (http / https only)
263
+ - Redirects re-validated via _NoFileRedirectHandler
264
+ - Response body capped at *max_bytes* (streaming read)
265
+ - Non-2xx status raises urllib.error.HTTPError
266
+ - Network errors propagate as urllib.error.URLError / OSError
267
+
268
+ Raises:
269
+ ValueError - disallowed scheme or redirect target
270
+ urllib.error.HTTPError - non-2xx HTTP status
271
+ urllib.error.URLError - DNS / connection failure
272
+ OSError - size cap exceeded
273
+ """
274
+ validate_url(url)
275
+ opener = _build_opener()
276
+ req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0 graphify/1.0"})
277
+
278
+ with opener.open(req, timeout=timeout) as resp:
279
+ # urllib raises HTTPError for non-2xx when using urlopen directly;
280
+ # with a custom opener we check manually to be safe.
281
+ status = getattr(resp, "status", None) or getattr(resp, "code", None)
282
+ if status is not None and not (200 <= status < 300):
283
+ raise urllib.error.HTTPError(url, status, f"HTTP {status}", {}, None)
284
+
285
+ chunks: list[bytes] = []
286
+ total = 0
287
+ while True:
288
+ chunk = resp.read(65_536)
289
+ if not chunk:
290
+ break
291
+ total += len(chunk)
292
+ if total > max_bytes:
293
+ raise OSError(
294
+ f"Response from {url!r} exceeds size limit "
295
+ f"({max_bytes // 1_048_576} MB). Aborting download."
296
+ )
297
+ chunks.append(chunk)
298
+
299
+ return b"".join(chunks)
300
+
301
+
302
+ def safe_fetch_text(url: str, max_bytes: int = _MAX_TEXT_BYTES, timeout: int = 15) -> str:
303
+ """Fetch *url* and return decoded text (UTF-8, replacing bad bytes).
304
+
305
+ Wraps safe_fetch with tighter defaults for HTML / text content.
306
+ """
307
+ raw = safe_fetch(url, max_bytes=max_bytes, timeout=timeout)
308
+ return raw.decode("utf-8", errors="replace")
309
+
310
+
311
+ # ---------------------------------------------------------------------------
312
+ # Path validation
313
+ # ---------------------------------------------------------------------------
314
+
315
+ def validate_graph_path(path: str | Path, base: Path | None = None) -> Path:
316
+ """Resolve *path* and verify it stays inside *base*.
317
+
318
+ *base* defaults to the `graphify-out` directory relative to CWD.
319
+ Also requires the base directory to exist, so a caller cannot
320
+ trick graphify into reading files before any graph has been built.
321
+
322
+ Raises:
323
+ ValueError - path escapes base, or base does not exist
324
+ FileNotFoundError - resolved path does not exist
325
+ """
326
+ if base is None:
327
+ resolved_hint = Path(path).resolve()
328
+ for candidate in [resolved_hint, *resolved_hint.parents]:
329
+ if candidate.name == GRAPHIFY_OUT_NAME:
330
+ base = candidate
331
+ break
332
+ if base is None:
333
+ base = Path(GRAPHIFY_OUT).resolve()
334
+
335
+ base = base.resolve()
336
+ if not base.exists():
337
+ raise ValueError(
338
+ f"Graph base directory does not exist: {base}. "
339
+ "Run /graphify first to build the graph."
340
+ )
341
+
342
+ resolved = Path(path).resolve()
343
+ try:
344
+ resolved.relative_to(base)
345
+ except ValueError:
346
+ raise ValueError(
347
+ f"Path {path!r} escapes the allowed directory {base}. "
348
+ "Only paths inside graphify-out/ are permitted."
349
+ )
350
+
351
+ if not resolved.exists():
352
+ raise FileNotFoundError(f"Graph file not found: {resolved}")
353
+
354
+ return resolved
355
+
356
+
357
+ def check_graph_file_size_cap(path: Path) -> None:
358
+ """Reject *path* if its size exceeds the configured graph-file cap.
359
+
360
+ Protects callers from memory bombs by failing fast before a multi-GiB
361
+ graph.json is read into memory and JSON-parsed. Silently returns when
362
+ ``path.stat()`` cannot be read — the caller's own existence/path check
363
+ is expected to surface a clearer error in that case.
364
+
365
+ The cap is resolved on every call via :func:`_max_graph_file_bytes`, so the
366
+ ``GRAPHIFY_MAX_GRAPH_BYTES`` env var can be set before import and still
367
+ apply.
368
+
369
+ Raises:
370
+ ValueError - file size exceeds the cap. The message includes the
371
+ observed size, the cap, and how to raise the limit.
372
+ """
373
+ cap = _max_graph_file_bytes()
374
+ try:
375
+ size = path.stat().st_size
376
+ except OSError:
377
+ return
378
+ if size > cap:
379
+ raise ValueError(
380
+ f"graph file {path} is {size:_d} bytes, exceeds {cap:_d}-byte cap\n"
381
+ f"(set GRAPHIFY_MAX_GRAPH_BYTES=<bytes> or "
382
+ f"GRAPHIFY_MAX_GRAPH_BYTES=<N>GB to raise the limit)"
383
+ )
384
+
385
+
386
+ # ---------------------------------------------------------------------------
387
+ # Label sanitisation (mirrors code-review-graph's _sanitize_name pattern)
388
+ # ---------------------------------------------------------------------------
389
+
390
+ _CONTROL_CHAR_RE = re.compile(r"[\x00-\x1f\x7f]")
391
+ _MAX_LABEL_LEN = 256
392
+
393
+
394
+ def sanitize_label(text: str | None) -> str:
395
+ """Strip control characters and cap length.
396
+
397
+ Safe for embedding in JSON data (inside <script> tags) and plain text.
398
+ For direct HTML injection, wrap the result with html.escape().
399
+ """
400
+ if text is None:
401
+ return ""
402
+ text = _CONTROL_CHAR_RE.sub("", str(text))
403
+ if len(text) > _MAX_LABEL_LEN:
404
+ text = text[:_MAX_LABEL_LEN]
405
+ return text
406
+
407
+
408
+ # ---------------------------------------------------------------------------
409
+ # Metadata sanitisation (recursive, bounded, HTML-safe)
410
+ # ---------------------------------------------------------------------------
411
+
412
+ _METADATA_MAX_VALUE_LEN = 512
413
+ _METADATA_MAX_LIST_ITEMS = 50
414
+
415
+
416
+ def _sanitize_metadata_string(value: object) -> str:
417
+ """Return a control-character-free, HTML-escaped, bounded string."""
418
+ text = _CONTROL_CHAR_RE.sub("", str(value))
419
+ text = html.escape(text, quote=True)
420
+ if len(text) > _METADATA_MAX_VALUE_LEN:
421
+ text = text[:_METADATA_MAX_VALUE_LEN]
422
+ return text # html is imported at module level (line 5)
423
+
424
+
425
+ def _sanitize_metadata_value(value: object) -> object:
426
+ """Sanitize a metadata value while preserving simple JSON-compatible types."""
427
+ if isinstance(value, bool):
428
+ # bool is a subclass of int — must be checked first to avoid coercion.
429
+ return value
430
+ if isinstance(value, str):
431
+ return _sanitize_metadata_string(value)
432
+ if isinstance(value, dict):
433
+ return sanitize_metadata(value)
434
+ if isinstance(value, (list, tuple)):
435
+ return [_sanitize_metadata_value(item) for item in value[:_METADATA_MAX_LIST_ITEMS]]
436
+ if isinstance(value, (int, float)) or value is None:
437
+ return value
438
+ return _sanitize_metadata_string(value)
439
+
440
+
441
+ def sanitize_metadata(metadata: Mapping[str, Any] | None) -> dict[str, object]:
442
+ """Sanitize metadata keys and values before graph export.
443
+
444
+ Metadata is less constrained than node labels: it can contain nested
445
+ dicts, lists, source snippets, external index symbols, and docstring
446
+ text. This helper keeps the data JSON-compatible, strips control
447
+ characters, escapes HTML-sensitive characters in strings, caps long
448
+ strings/lists, and drops entries whose key becomes empty after
449
+ sanitization.
450
+ """
451
+ if metadata is None:
452
+ return {}
453
+
454
+ result: dict[str, object] = {}
455
+ for key, value in metadata.items():
456
+ clean_key = _sanitize_metadata_string(key)
457
+ if not clean_key:
458
+ continue
459
+ result[clean_key] = _sanitize_metadata_value(value)
460
+ return result