diffusers-workflow 0.4.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 (260) hide show
  1. diffusers_workflow-0.4.0.dist-info/METADATA +318 -0
  2. diffusers_workflow-0.4.0.dist-info/RECORD +260 -0
  3. diffusers_workflow-0.4.0.dist-info/WHEEL +5 -0
  4. diffusers_workflow-0.4.0.dist-info/entry_points.txt +7 -0
  5. diffusers_workflow-0.4.0.dist-info/licenses/LICENSE +201 -0
  6. diffusers_workflow-0.4.0.dist-info/top_level.txt +2 -0
  7. dw/__init__.py +440 -0
  8. dw/adapter_compatibility.py +226 -0
  9. dw/arguments.py +1231 -0
  10. dw/assessment_rules.py +159 -0
  11. dw/assets.py +130 -0
  12. dw/cache_blocks.json +16 -0
  13. dw/cache_blocks.py +146 -0
  14. dw/community_pipelines/pipeline_flux_rf_inversion.py +1184 -0
  15. dw/content_types.py +150 -0
  16. dw/dissolve_frame_errors.py +121 -0
  17. dw/docs/ACCELERATION.md +352 -0
  18. dw/docs/AGENT_LOOP.md +95 -0
  19. dw/docs/DEPENDENCIES.md +91 -0
  20. dw/docs/IP_ADAPTER.md +109 -0
  21. dw/docs/LORAS.md +131 -0
  22. dw/docs/MCP.md +517 -0
  23. dw/docs/PROMPT_WEIGHTING.md +78 -0
  24. dw/docs/QUANTIZATION.md +230 -0
  25. dw/docs/RECIPES_24GB.md +201 -0
  26. dw/docs/RELEASING.md +195 -0
  27. dw/docs/REMOTE.md +140 -0
  28. dw/docs/REPL_COMMANDS.md +121 -0
  29. dw/docs/REPL_WORKER_GUIDE.md +51 -0
  30. dw/docs/SECURITY.md +272 -0
  31. dw/docs/SECURITY_QUICKREF.md +112 -0
  32. dw/docs/SERVER.md +679 -0
  33. dw/docs/TASKS.md +1741 -0
  34. dw/docs/TESTING.md +71 -0
  35. dw/docs/WORKFLOW_GUIDE.md +2038 -0
  36. dw/docs/WORKSPACES.md +316 -0
  37. dw/download_watch.py +335 -0
  38. dw/elision.py +306 -0
  39. dw/events.py +275 -0
  40. dw/for_each.py +409 -0
  41. dw/host_memory.py +258 -0
  42. dw/host_memory_projection.py +230 -0
  43. dw/hub_cache.py +432 -0
  44. dw/introspection.py +1228 -0
  45. dw/kernel_availability.py +208 -0
  46. dw/locations.py +599 -0
  47. dw/log_setup.py +45 -0
  48. dw/loudness.py +82 -0
  49. dw/media_audio.py +217 -0
  50. dw/media_frames.py +367 -0
  51. dw/media_info.py +297 -0
  52. dw/pipeline_processors/chain.py +821 -0
  53. dw/pipeline_processors/config_objects.py +237 -0
  54. dw/pipeline_processors/pipeline.py +2297 -0
  55. dw/pipeline_processors/remote.py +46 -0
  56. dw/plan.py +920 -0
  57. dw/previous_results.py +411 -0
  58. dw/probe_paths.py +59 -0
  59. dw/prompt_schema.json +48 -0
  60. dw/prompt_weighting.py +378 -0
  61. dw/prompts.py +159 -0
  62. dw/realize.py +250 -0
  63. dw/reference_limits.py +215 -0
  64. dw/reference_names.py +125 -0
  65. dw/repl.py +338 -0
  66. dw/repl_commands.py +836 -0
  67. dw/repl_worker.py +159 -0
  68. dw/result.py +1720 -0
  69. dw/result_fps.py +82 -0
  70. dw/run.py +162 -0
  71. dw/runs.py +768 -0
  72. dw/scalar_result_validation.py +97 -0
  73. dw/schema.py +283 -0
  74. dw/security.py +1038 -0
  75. dw/select_validation.py +115 -0
  76. dw/serve.py +277 -0
  77. dw/server/__init__.py +2 -0
  78. dw/server/app.py +4586 -0
  79. dw/server/assess.py +132 -0
  80. dw/server/catalog_shape.py +487 -0
  81. dw/server/enhancers.py +129 -0
  82. dw/server/exports.py +480 -0
  83. dw/server/guides.py +257 -0
  84. dw/server/jobs.py +1561 -0
  85. dw/server/mcp_mount.py +95 -0
  86. dw/server/netinfo.py +124 -0
  87. dw/server/observed_cost.py +379 -0
  88. dw/server/sysinfo.py +71 -0
  89. dw/server/ui/assets/abap-08VXUWAP.js +1 -0
  90. dw/server/ui/assets/apex-BWPQTe0t.js +1 -0
  91. dw/server/ui/assets/azcli-Bc_sGQ0U.js +1 -0
  92. dw/server/ui/assets/bat-i0X4ZdIN.js +1 -0
  93. dw/server/ui/assets/bicep-B5-_aFwp.js +2 -0
  94. dw/server/ui/assets/cameligo-DMUM7wLl.js +1 -0
  95. dw/server/ui/assets/clojure-Cm7r79vr.js +1 -0
  96. dw/server/ui/assets/codicon-Brq4_Ui5.ttf +0 -0
  97. dw/server/ui/assets/coffee-Ba7i2nA0.js +1 -0
  98. dw/server/ui/assets/cpp-C7h46wYY.js +1 -0
  99. dw/server/ui/assets/csharp-BKxtCVv1.js +1 -0
  100. dw/server/ui/assets/csp-bTuwJoIa.js +1 -0
  101. dw/server/ui/assets/css-DIMkf-bt.js +3 -0
  102. dw/server/ui/assets/css.worker-B3ciXF_0.js +93 -0
  103. dw/server/ui/assets/cssMode-CPznxfY8.js +1 -0
  104. dw/server/ui/assets/cypher-CVaqCwHa.js +1 -0
  105. dw/server/ui/assets/dart-onAF5SnQ.js +1 -0
  106. dw/server/ui/assets/dockerfile-DZFCIeNp.js +1 -0
  107. dw/server/ui/assets/ecl-D05T4iGw.js +1 -0
  108. dw/server/ui/assets/editor-jjEx9u7D.css +1 -0
  109. dw/server/ui/assets/editor.api-CpWcotrd.js +847 -0
  110. dw/server/ui/assets/editor.worker-q-txB4vs.js +30 -0
  111. dw/server/ui/assets/elixir-6RTg0lbw.js +1 -0
  112. dw/server/ui/assets/flow9-C5_-GSwl.js +1 -0
  113. dw/server/ui/assets/freemarker2-CXtRM8N4.js +3 -0
  114. dw/server/ui/assets/fsharp-C8Ef5oNN.js +1 -0
  115. dw/server/ui/assets/go-C-y9NEjX.js +1 -0
  116. dw/server/ui/assets/graphql-fmXr3nnJ.js +1 -0
  117. dw/server/ui/assets/handlebars-N7x-6NMY.js +1 -0
  118. dw/server/ui/assets/hcl-CpzslTdj.js +1 -0
  119. dw/server/ui/assets/html-PhsdjHSr.js +1 -0
  120. dw/server/ui/assets/html.worker-C93Ht9o9.js +506 -0
  121. dw/server/ui/assets/htmlMode-Dgj0SEok.js +1 -0
  122. dw/server/ui/assets/index-3Vw6WAPW.css +1 -0
  123. dw/server/ui/assets/index-DgrYhQd9.js +43 -0
  124. dw/server/ui/assets/ini-sBoK_t0W.js +1 -0
  125. dw/server/ui/assets/java-BEtHBSE6.js +1 -0
  126. dw/server/ui/assets/javascript-BJqN9Qhv.js +1 -0
  127. dw/server/ui/assets/json.worker-B2V3pomh.js +62 -0
  128. dw/server/ui/assets/jsonMode-DbM4SWSv.js +7 -0
  129. dw/server/ui/assets/julia-Bri6UV-V.js +1 -0
  130. dw/server/ui/assets/kotlin-BOotOW0E.js +1 -0
  131. dw/server/ui/assets/less-B9JPFI3C.js +2 -0
  132. dw/server/ui/assets/lexon-CfSJPG6W.js +1 -0
  133. dw/server/ui/assets/liquid-BWr8lEc4.js +1 -0
  134. dw/server/ui/assets/lspLanguageFeatures-C1iGuDyZ.js +4 -0
  135. dw/server/ui/assets/lua-CsQS60Ue.js +1 -0
  136. dw/server/ui/assets/m3-D-oSqn_W.js +1 -0
  137. dw/server/ui/assets/markdown-Cimd5fb3.js +1 -0
  138. dw/server/ui/assets/mdx-DAdMi_0p.js +1 -0
  139. dw/server/ui/assets/mips-CIPQ_RoX.js +1 -0
  140. dw/server/ui/assets/monaco--ixms01u.css +1 -0
  141. dw/server/ui/assets/monaco-BGCeEqaw.js +56 -0
  142. dw/server/ui/assets/msdax-DauUninz.js +1 -0
  143. dw/server/ui/assets/mysql-SOo6toE5.js +1 -0
  144. dw/server/ui/assets/objective-c-FvmIjYaQ.js +1 -0
  145. dw/server/ui/assets/pascal-DrH0SRf2.js +1 -0
  146. dw/server/ui/assets/pascaligo-D-ptJ9y-.js +1 -0
  147. dw/server/ui/assets/perl-oz_6vUea.js +1 -0
  148. dw/server/ui/assets/pgsql-DTj74zXo.js +1 -0
  149. dw/server/ui/assets/php-nr791fC2.js +1 -0
  150. dw/server/ui/assets/pla-CopQ2nXW.js +1 -0
  151. dw/server/ui/assets/postiats-43DmfD33.js +1 -0
  152. dw/server/ui/assets/powerquery-D3hlyOfw.js +1 -0
  153. dw/server/ui/assets/powershell-DmHpPYUd.js +1 -0
  154. dw/server/ui/assets/protobuf-C531GsRP.js +2 -0
  155. dw/server/ui/assets/pug-Z5eAx3Zn.js +1 -0
  156. dw/server/ui/assets/python-Bcn70HdC.js +1 -0
  157. dw/server/ui/assets/qsharp-DkqhCAOL.js +1 -0
  158. dw/server/ui/assets/r-BwWrilGY.js +1 -0
  159. dw/server/ui/assets/razor-D1HmNnby.js +1 -0
  160. dw/server/ui/assets/redis-ClamHrr6.js +1 -0
  161. dw/server/ui/assets/redshift-DT7zqm-g.js +1 -0
  162. dw/server/ui/assets/restructuredtext-BYgofb2h.js +1 -0
  163. dw/server/ui/assets/ruby-DezsRK8O.js +1 -0
  164. dw/server/ui/assets/rust-DdL9SqIa.js +1 -0
  165. dw/server/ui/assets/sb-CcwsVR0C.js +1 -0
  166. dw/server/ui/assets/scala-DHpiXF5c.js +1 -0
  167. dw/server/ui/assets/scheme-BeGwcela.js +1 -0
  168. dw/server/ui/assets/scss-gp-XZpBa.js +3 -0
  169. dw/server/ui/assets/shell-CC2rA5mh.js +1 -0
  170. dw/server/ui/assets/solidity-BEEn4gHE.js +1 -0
  171. dw/server/ui/assets/sophia-CRfGWb83.js +1 -0
  172. dw/server/ui/assets/sparql-D_Lu-MrJ.js +1 -0
  173. dw/server/ui/assets/sql-NEE52Syq.js +1 -0
  174. dw/server/ui/assets/st-DbInun42.js +1 -0
  175. dw/server/ui/assets/swift-Bxkupp3x.js +1 -0
  176. dw/server/ui/assets/systemverilog-Bz4Y3fRF.js +1 -0
  177. dw/server/ui/assets/tcl-DISqw1ZD.js +1 -0
  178. dw/server/ui/assets/ts.worker-D7T1-Ig5.js +67738 -0
  179. dw/server/ui/assets/tsMode-D6u0XmOW.js +11 -0
  180. dw/server/ui/assets/twig-De2hgUGE.js +1 -0
  181. dw/server/ui/assets/typescript-BU6v-LMV.js +1 -0
  182. dw/server/ui/assets/typespec-B8J7ngcE.js +1 -0
  183. dw/server/ui/assets/vb-DV3o63ZY.js +1 -0
  184. dw/server/ui/assets/wgsl-DpFanUEy.js +298 -0
  185. dw/server/ui/assets/workers-Cn7cTUKr.js +1 -0
  186. dw/server/ui/assets/xml--0LP2Lwk.js +1 -0
  187. dw/server/ui/assets/yaml-mpBg9jnt.js +1 -0
  188. dw/server/ui/index.html +17 -0
  189. dw/server/updater.py +192 -0
  190. dw/settings.py +98 -0
  191. dw/shot_span_preflight.py +116 -0
  192. dw/shots.py +359 -0
  193. dw/slice_preflight.py +148 -0
  194. dw/step.py +187 -0
  195. dw/step_cache.py +442 -0
  196. dw/subfolders.py +107 -0
  197. dw/task_domains.py +307 -0
  198. dw/tasks/assess.py +826 -0
  199. dw/tasks/audio_transcription.py +88 -0
  200. dw/tasks/audio_utils.py +1862 -0
  201. dw/tasks/background_remover.py +43 -0
  202. dw/tasks/borders.py +113 -0
  203. dw/tasks/compose_text.py +74 -0
  204. dw/tasks/concat_videos.py +300 -0
  205. dw/tasks/depth_estimator.py +54 -0
  206. dw/tasks/diffusion_upscale.py +109 -0
  207. dw/tasks/dissolve_videos.py +342 -0
  208. dw/tasks/format_messages.py +24 -0
  209. dw/tasks/gather.py +173 -0
  210. dw/tasks/grade.py +97 -0
  211. dw/tasks/image_to_text.py +43 -0
  212. dw/tasks/image_utils.py +764 -0
  213. dw/tasks/interpolate_frames.py +252 -0
  214. dw/tasks/judge.py +68 -0
  215. dw/tasks/model_cache.py +55 -0
  216. dw/tasks/pair_audio.py +268 -0
  217. dw/tasks/qr_code.py +19 -0
  218. dw/tasks/restore_faces.py +175 -0
  219. dw/tasks/rife_model.py +192 -0
  220. dw/tasks/segment.py +121 -0
  221. dw/tasks/select.py +111 -0
  222. dw/tasks/speech_generation.py +228 -0
  223. dw/tasks/stabilize.py +129 -0
  224. dw/tasks/task.py +920 -0
  225. dw/tasks/tensor_image.py +57 -0
  226. dw/tasks/text_generation.py +169 -0
  227. dw/tasks/text_sections.py +80 -0
  228. dw/tasks/upscale.py +203 -0
  229. dw/tasks/video_utils.py +624 -0
  230. dw/tasks/zoe_depth.py +71 -0
  231. dw/teacache.py +381 -0
  232. dw/teacache_models.json +99 -0
  233. dw/test.py +29 -0
  234. dw/type_helpers.py +231 -0
  235. dw/validate.py +68 -0
  236. dw/variable_constraints.py +444 -0
  237. dw/variables.py +443 -0
  238. dw/video_extensions.py +141 -0
  239. dw/vram_estimate.py +116 -0
  240. dw/worker.py +764 -0
  241. dw/workflow.py +2007 -0
  242. dw/workflow_schema.json +1346 -0
  243. dw/workflow_sources.py +383 -0
  244. dw/workflows/h3_context_ir.json +57 -0
  245. dw/workflows/test.json +31 -0
  246. dw/workspace.py +730 -0
  247. dw_mcp/__init__.py +6 -0
  248. dw_mcp/__main__.py +133 -0
  249. dw_mcp/assets.py +336 -0
  250. dw_mcp/authoring.py +114 -0
  251. dw_mcp/catalog.py +360 -0
  252. dw_mcp/client.py +486 -0
  253. dw_mcp/diagnose.py +371 -0
  254. dw_mcp/exports.py +84 -0
  255. dw_mcp/guides.py +35 -0
  256. dw_mcp/media.py +638 -0
  257. dw_mcp/models.py +97 -0
  258. dw_mcp/prompts.py +104 -0
  259. dw_mcp/server.py +1343 -0
  260. dw_mcp/workspaces.py +212 -0
dw/locations.py ADDED
@@ -0,0 +1,599 @@
1
+ """The one policy for a location a workflow's arguments supply.
2
+
3
+ A workflow JSON is untrusted input under the default posture (see
4
+ docs/SECURITY.md's Trust model section). Its media arguments name *where* to
5
+ read from, and until this module existed each loader answered that question
6
+ for itself: `fetch_image` accepted any absolute path the JSON wrote,
7
+ `gather_images` handed its `glob` straight to the filesystem, and any
8
+ `http(s)` URL was fetched whatever host it named. That is arbitrary file read
9
+ and SSRF from a document the server treats as data (#114, #115, #116, #112).
10
+
11
+ Two rules, applied wherever a caller-supplied location is resolved:
12
+
13
+ - A path must land inside one of the roots this installation already works
14
+ in - the workflow's own directory, the asset libraries on the search path,
15
+ the output root. `..` never appears in a legitimate one and `validate_path`
16
+ already refuses it, so in practice this closes the *absolute* path that
17
+ pointed somewhere else entirely. The remedy is an `asset:` reference, which
18
+ is what the roots exist for.
19
+ - An `http(s)` URL must not name a host inside the deployment - loopback,
20
+ link-local (the cloud metadata address), a private range, or anything else
21
+ that is not globally routable (100.64.0.0/10, Tailscale's range). The check
22
+ runs on the resolved address, not on the literal string, so a hostname that
23
+ answers 127.0.0.1 is caught too, and `safe_get` runs it again on every
24
+ redirect before following it.
25
+
26
+ Both yield to `--trust-workflows`, exactly as the import and remote-code
27
+ gates do: an operator who has vouched for a workflow's source may point it at
28
+ a scratch directory or an internal endpoint. Neither yields to anything else -
29
+ there is no per-argument opt-out, because the argument is the untrusted part.
30
+
31
+ Enforcement is in two places on purpose. `location_errors` runs at validation
32
+ time, so `validate_workflow` refuses the workflow before a model load is
33
+ spent on it; the loaders call the same functions at run time, because a
34
+ location that arrives through a variable or a previous result was never in
35
+ the document to check.
36
+ """
37
+
38
+ import ipaddress
39
+ import logging
40
+ import os
41
+ import socket
42
+ from urllib.parse import urljoin, urlparse
43
+
44
+ from .security import (
45
+ InvalidInputError,
46
+ PathTraversalError,
47
+ validate_path,
48
+ validate_url,
49
+ workflows_are_trusted,
50
+ )
51
+
52
+ logger = logging.getLogger("dw")
53
+
54
+ # Media argument names follow the same conventions realize_args dispatches on
55
+ # (dw/arguments.py): a key named like its media, or an explicit
56
+ # {"media_type", "location"} reference, or an object's "from_file"
57
+ MEDIA_KEY_SUFFIXES = ("_image", "_video", "_audio")
58
+ MEDIA_KEY_NAMES = ("image", "video", "audio", "location", "from_file")
59
+
60
+ # The tasks whose arguments name a filesystem pattern rather than one file
61
+ GLOB_ARGUMENT = "glob"
62
+
63
+
64
+ def is_http_url(value):
65
+ """Whether a value is a string the loaders would fetch over HTTP."""
66
+ return isinstance(value, str) and value.startswith(("http://", "https://"))
67
+
68
+
69
+ def media_roots(base_dir=None):
70
+ """Every directory a workflow's own locations may point inside, resolved.
71
+
72
+ The workflow's directory, each asset library on the search path, and the
73
+ output root - the three places this installation keeps the media a
74
+ workflow works with. A root that cannot be resolved (no workspace, no
75
+ active run) is dropped rather than failing the check open.
76
+
77
+ Args:
78
+ base_dir: The workflow file's directory, when one anchors the search
79
+ """
80
+ candidates = []
81
+ if base_dir:
82
+ candidates.append(base_dir)
83
+
84
+ from .assets import asset_search_path
85
+
86
+ try:
87
+ candidates.extend(asset_search_path(base_dir=base_dir))
88
+ except Exception:
89
+ logger.debug("Could not resolve the asset search path", exc_info=True)
90
+
91
+ from .runs import output_root
92
+
93
+ try:
94
+ candidates.append(output_root())
95
+ except Exception:
96
+ logger.debug("Could not resolve the output root", exc_info=True)
97
+
98
+ roots = []
99
+ for candidate in candidates:
100
+ if not candidate:
101
+ continue
102
+ try:
103
+ resolved = os.path.normpath(
104
+ os.path.realpath(os.path.abspath(os.path.expanduser(str(candidate))))
105
+ )
106
+ except (OSError, ValueError):
107
+ continue
108
+ if resolved not in roots:
109
+ roots.append(resolved)
110
+ return roots
111
+
112
+
113
+ def _within(path, root):
114
+ return path == root or path.startswith(root + os.sep)
115
+
116
+
117
+ def validate_media_path(
118
+ location, base_dir=None, what="a media argument", require_exists=True
119
+ ):
120
+ """The validated absolute path a media location names, confined.
121
+
122
+ Args:
123
+ location: The path the workflow supplied, relative or absolute
124
+ base_dir: Directory a relative path is resolved against - the
125
+ workflow file's directory
126
+ what: Short phrase naming the argument, for the error message
127
+ require_exists: Whether a contained path that does not exist is an
128
+ error. False for the validation-time pass, which is about policy
129
+ rather than about what happens to be on disk right now
130
+
131
+ Returns:
132
+ The absolute, resolved, contained path
133
+
134
+ Raises:
135
+ PathTraversalError: If the path resolves outside every root
136
+ InvalidInputError, PathTraversalError: Whatever validate_path raises
137
+ """
138
+ # base_dir is the first root, so a relative path keeps resolving against
139
+ # the workflow file exactly as it did before this check existed.
140
+ # allow_create here, with the existence check moved below the containment
141
+ # one: refusing an out-of-root path only once it turned out to exist made
142
+ # the refusal itself a file-existence oracle for the whole filesystem
143
+ # (#114)
144
+ resolved = validate_path(
145
+ location if os.path.isabs(str(location)) else _joined(location, base_dir),
146
+ allow_create=True,
147
+ )
148
+ if not workflows_are_trusted():
149
+ roots = media_roots(base_dir)
150
+ if not any(_within(resolved, root) for root in roots):
151
+ raise PathTraversalError(
152
+ f"Refusing to read {what} at '{location}': it resolves "
153
+ f"outside every directory this workflow may read "
154
+ f"({', '.join(roots) or 'none configured'}). This includes "
155
+ f"another workspace's own directories - each workspace is "
156
+ f"isolated by design, not just a generic path-traversal "
157
+ f"refusal, so a bare path into one is refused the same way "
158
+ f"a path outside the installation entirely would be. Put "
159
+ f"the file in the asset library and name it with an "
160
+ f"'asset:' reference, use keep_output(shared=True) to copy "
161
+ f"a generated file into the library every workspace shares "
162
+ f"if it needs to cross that boundary on purpose, or pass "
163
+ f"--trust-workflows if you trust this workflow's source."
164
+ )
165
+
166
+ if require_exists and not os.path.exists(resolved):
167
+ raise InvalidInputError(f"Path does not exist: {resolved}")
168
+ return resolved
169
+
170
+
171
+ def _joined(location, base_dir):
172
+ return os.path.join(base_dir, str(location)) if base_dir else str(location)
173
+
174
+
175
+ def validate_media_glob(pattern, base_dir=None, what="a glob argument"):
176
+ """The validated glob pattern, confined to one of the media roots.
177
+
178
+ A glob is a location with wildcards in it, and it is checked the same
179
+ way - but on the pattern's fixed leading directory, since the pattern
180
+ itself does not exist as a path. Each *match* is checked individually
181
+ too by the loader that opens it, which is what catches a wildcard
182
+ escaping through a symlink.
183
+
184
+ Returns:
185
+ The pattern, absolute, for glob to expand
186
+ """
187
+ absolute = pattern if os.path.isabs(str(pattern)) else _joined(pattern, base_dir)
188
+ if workflows_are_trusted():
189
+ return absolute
190
+
191
+ # The part of the pattern before the first wildcard: the directory the
192
+ # expansion starts from, which is the thing containment is about
193
+ fixed = str(absolute)
194
+ for wildcard in ("*", "?", "["):
195
+ cut = fixed.find(wildcard)
196
+ if cut >= 0:
197
+ fixed = fixed[:cut]
198
+ fixed = os.path.dirname(fixed) if not fixed.endswith(os.sep) else fixed
199
+ if ".." in fixed.replace("\\", "/").split("/"):
200
+ raise PathTraversalError(
201
+ f"Refusing {what} '{pattern}': it contains a '..' path segment."
202
+ )
203
+ try:
204
+ resolved = os.path.normpath(os.path.realpath(os.path.abspath(fixed or ".")))
205
+ except (OSError, ValueError) as e:
206
+ raise InvalidInputError(f"Invalid glob pattern {pattern!r}: {e}")
207
+
208
+ roots = media_roots(base_dir)
209
+ if not any(_within(resolved, root) for root in roots):
210
+ raise PathTraversalError(
211
+ f"Refusing {what} '{pattern}': it expands under {resolved}, "
212
+ f"outside every directory this workflow may read "
213
+ f"({', '.join(roots) or 'none configured'}). Glob inside the "
214
+ f"asset library, or pass --trust-workflows if you trust this "
215
+ f"workflow's source."
216
+ )
217
+ return absolute
218
+
219
+
220
+ def contained_matches(paths, base_dir=None, what="a glob argument"):
221
+ """The matches of an allowed glob that are themselves inside a root.
222
+
223
+ A pattern can be contained and still match outside its own tree through
224
+ a symlink, so every match is re-checked on its real path. A match that
225
+ escapes is dropped with a warning rather than failing the run: the
226
+ pattern was legitimate, one entry under it was not.
227
+ """
228
+ if workflows_are_trusted():
229
+ return list(paths)
230
+
231
+ roots = media_roots(base_dir)
232
+ kept = []
233
+ for path in paths:
234
+ try:
235
+ resolved = os.path.normpath(os.path.realpath(os.path.abspath(path)))
236
+ except (OSError, ValueError):
237
+ continue
238
+ if any(_within(resolved, root) for root in roots):
239
+ kept.append(path)
240
+ else:
241
+ logger.warning(
242
+ f"Skipping {what} match '{path}': it resolves to {resolved}, "
243
+ f"outside every directory this workflow may read"
244
+ )
245
+ return kept
246
+
247
+
248
+ # Hosts a workflow may not send the server to: its own loopback, the
249
+ # link-local range cloud metadata services answer on, and the private ranges
250
+ # that make up whatever network the box sits in. This is the SSRF boundary -
251
+ # an internal address is exactly the thing a caller cannot otherwise reach,
252
+ # which is why naming one is the attack rather than a mistake. `is_global`
253
+ # closes what the named ranges leave open: 100.64.0.0/10 is neither private
254
+ # nor reserved to `ipaddress`, and it is both Tailscale's tailnet and the
255
+ # range Alibaba's metadata service answers on (#407)
256
+ def _is_internal(address):
257
+ return (
258
+ not address.is_global
259
+ or address.is_loopback
260
+ or address.is_link_local
261
+ or address.is_private
262
+ or address.is_reserved
263
+ or address.is_multicast
264
+ or address.is_unspecified
265
+ )
266
+
267
+
268
+ def _resolved_addresses(host):
269
+ """Every IP a hostname answers on, as ip_address objects.
270
+
271
+ A literal is returned as itself without a lookup. A name that does not
272
+ resolve yields nothing - the fetch will fail on its own, and refusing it
273
+ here would turn a typo into a security error.
274
+ """
275
+ try:
276
+ return [ipaddress.ip_address(host)]
277
+ except ValueError:
278
+ pass
279
+ try:
280
+ infos = socket.getaddrinfo(host, None)
281
+ except (socket.gaierror, UnicodeError, OSError):
282
+ logger.debug(f"Could not resolve {host!r} for the host policy")
283
+ return []
284
+ addresses = []
285
+ for info in infos:
286
+ try:
287
+ addresses.append(ipaddress.ip_address(info[4][0]))
288
+ except ValueError:
289
+ continue
290
+ return addresses
291
+
292
+
293
+ def validate_media_url(url, what="a media argument"):
294
+ """The validated URL, refused if it names a host inside the deployment.
295
+
296
+ Args:
297
+ url: The http(s) URL the workflow supplied
298
+ what: Short phrase naming the argument, for the error message
299
+
300
+ Returns:
301
+ The URL, unchanged
302
+
303
+ Raises:
304
+ InvalidInputError: If the scheme is not http(s), or the host is
305
+ internal to the deployment
306
+ """
307
+ validated = validate_url(url)
308
+ if workflows_are_trusted():
309
+ return validated
310
+
311
+ host = (urlparse(validated).hostname or "").strip("[]")
312
+ internal = [
313
+ address for address in _resolved_addresses(host) if _is_internal(address)
314
+ ]
315
+ if internal:
316
+ raise InvalidInputError(
317
+ f"Refusing to fetch {what} from '{url}': {host} resolves to "
318
+ f"{internal[0]}, an address inside this deployment (loopback, "
319
+ f"link-local, private or otherwise not global). A workflow may not use the server to "
320
+ f"reach its own network. Pass --trust-workflows if you trust "
321
+ f"this workflow's source."
322
+ )
323
+ return validated
324
+
325
+
326
+ # How many redirects a media fetch follows before giving up. requests' own
327
+ # default is 30; a CDN needs one or two
328
+ MAX_MEDIA_REDIRECTS = 5
329
+
330
+
331
+ def safe_get(url, what="a media argument", timeout=60):
332
+ """GET a workflow-supplied media URL, re-checking every redirect.
333
+
334
+ `validate_media_url` checks the URL the document wrote, but a fetch that
335
+ follows redirects on its own goes wherever the first host tells it to -
336
+ a public URL answering 302 to 169.254.169.254 or to the server's own
337
+ loopback was fetched unchecked (#407). Redirects are followed here, one
338
+ hop at a time, and each `Location` passes the same host policy before it
339
+ is dialed.
340
+
341
+ Args:
342
+ url: The http(s) URL the workflow supplied
343
+ what: Short phrase naming the argument, for the error message
344
+ timeout: Seconds per request
345
+
346
+ Returns:
347
+ The final requests.Response, its status already checked
348
+
349
+ Raises:
350
+ InvalidInputError: If the URL or any redirect target is refused, or
351
+ the redirects run past MAX_MEDIA_REDIRECTS
352
+ requests.HTTPError: If the final answer is an error status
353
+ """
354
+ import requests
355
+
356
+ current = validate_media_url(url, what)
357
+ for _ in range(MAX_MEDIA_REDIRECTS + 1):
358
+ response = requests.get(current, timeout=timeout, allow_redirects=False)
359
+ if not response.is_redirect:
360
+ response.raise_for_status()
361
+ return response
362
+ target = urljoin(current, response.headers["Location"])
363
+ response.close()
364
+ logger.debug(f"{current} redirects to {target}")
365
+ current = validate_media_url(target, f"{what} (redirected from '{url}')")
366
+ raise InvalidInputError(
367
+ f"Refusing to fetch {what} from '{url}': it redirects more than "
368
+ f"{MAX_MEDIA_REDIRECTS} times"
369
+ )
370
+
371
+
372
+ # Hosts this machine's HuggingFace token belongs to. The token is the
373
+ # credential the box holds for the Hub; a workflow chooses
374
+ # `remote_text_encoder.url`, so attaching the token to whatever it named
375
+ # would let an untrusted document exfiltrate it with one POST (#112). An
376
+ # endpoint outside these is still reachable - it just does not get the
377
+ # credential, and an endpoint that needs one is by definition a HuggingFace
378
+ # endpoint
379
+ HF_TOKEN_HOST_SUFFIXES = (
380
+ "huggingface.co",
381
+ "huggingface.cloud",
382
+ "hf.space",
383
+ )
384
+
385
+
386
+ def token_host_allowed(host):
387
+ """Whether the HuggingFace token may be attached to a request to `host`."""
388
+ host = (host or "").lower()
389
+ return any(
390
+ host == suffix or host.endswith("." + suffix)
391
+ for suffix in HF_TOKEN_HOST_SUFFIXES
392
+ )
393
+
394
+
395
+ def validate_remote_encoder_url(url):
396
+ """The validated remote text-encoder URL, or a refusal saying why.
397
+
398
+ https only, and no address inside this deployment: the workflow file is
399
+ untrusted input, and this field sends a request - with a credential - to
400
+ an address it chooses. `--trust-workflows` lifts both, for an operator
401
+ running their own endpoint on the box or over plain http on a LAN.
402
+
403
+ Raises:
404
+ InvalidInputError: On a non-https scheme or an internal host
405
+ """
406
+ if not workflows_are_trusted():
407
+ scheme = urlparse(str(url)).scheme
408
+ if scheme != "https":
409
+ raise InvalidInputError(
410
+ f"Refusing the remote text encoder at '{url}': its scheme is "
411
+ f"'{scheme or 'none'}', and an untrusted workflow may only "
412
+ f"reach an https endpoint - the request carries this "
413
+ f"machine's HuggingFace token. Pass --trust-workflows if you "
414
+ f"trust this workflow's source."
415
+ )
416
+ return validate_media_url(url, "the remote text encoder url")
417
+
418
+
419
+ def validate_model_name(name, base_dir=None):
420
+ """A model identifier: a Hub repo id, or a path inside the media roots.
421
+
422
+ `download_model` has always refused a path-shaped `repo_id`; the same
423
+ name reached `from_pretrained_arguments.model_name` unchecked, so a
424
+ workflow could name an absolute path and let diffusers decide (#117).
425
+ A local model directory stays supported - inside a root, like any other
426
+ location.
427
+
428
+ Raises:
429
+ PathTraversalError, InvalidInputError: If it is neither
430
+ """
431
+ from huggingface_hub.utils import HFValidationError, validate_repo_id
432
+
433
+ try:
434
+ validate_repo_id(str(name))
435
+ return str(name)
436
+ except HFValidationError as e:
437
+ # A URL is neither a repo id nor a path, but joined onto the workflow
438
+ # directory it resolves inside a root and so passed the containment
439
+ # check below - the one shape of the four `download_model` refuses
440
+ # that got through here (#117). `from_pretrained` would refuse it
441
+ # downstream; the point of this check is not to rely on that
442
+ if "://" in str(name):
443
+ raise InvalidInputError(
444
+ f"Refusing a model_name of '{name}': it is a URL, not a Hub "
445
+ f"repo id or a local model directory ({e})."
446
+ )
447
+ return validate_media_path(
448
+ str(name), base_dir, "a model_name", require_exists=False
449
+ )
450
+
451
+
452
+ # ------------------------------------------------------------- validation
453
+
454
+
455
+ def _is_media_key(key):
456
+ return isinstance(key, str) and (
457
+ key in MEDIA_KEY_NAMES or key.endswith(MEDIA_KEY_SUFFIXES)
458
+ )
459
+
460
+
461
+ def _deferred(value):
462
+ """Whether a location is resolved later rather than being one now."""
463
+ return value.startswith(
464
+ (
465
+ "variable:",
466
+ "previous_result:",
467
+ "item:",
468
+ "gather:",
469
+ "asset:",
470
+ "output:",
471
+ "prompt:",
472
+ "constant:",
473
+ "builtin:",
474
+ )
475
+ )
476
+
477
+
478
+ def _check(value, base_dir, what):
479
+ """The policy message for one literal location, or None if it is fine."""
480
+ if not isinstance(value, str) or not value or _deferred(value):
481
+ return None
482
+ try:
483
+ if is_http_url(value):
484
+ validate_media_url(value, what)
485
+ elif os.path.isabs(value):
486
+ validate_media_path(value, base_dir, what, require_exists=False)
487
+ elif ".." in value.replace("\\", "/").split("/"):
488
+ # A relative path is under base_dir by construction unless it
489
+ # climbs out, and validate_path refuses '..' - but only when the
490
+ # loader reaches it, which is a queued job and three seconds in
491
+ # rather than a validation answer. Checked on the segments here
492
+ # the way validate_media_glob checks a pattern's, so the two
493
+ # spellings of "read outside the roots" are refused at the same
494
+ # moment (#124)
495
+ raise PathTraversalError(
496
+ f"Refusing to read {what} at '{value}': it contains a '..' "
497
+ f"path segment, so it does not resolve inside any directory "
498
+ f"this workflow may read. Put the file in the asset library "
499
+ f"and name it with an 'asset:' reference."
500
+ )
501
+ except (PathTraversalError, InvalidInputError) as e:
502
+ return str(e)
503
+ except Exception:
504
+ # A path that simply does not exist is not this check's business -
505
+ # the loader will say so, with the run's own error
506
+ logger.debug(f"Location check skipped for {value!r}", exc_info=True)
507
+ return None
508
+
509
+
510
+ def location_errors(definition, source_indices=None, base_dir=None):
511
+ """Every media location in the definition that policy refuses.
512
+
513
+ Reported as [{path, message}] like the other validation passes, so a
514
+ caller learns before `run_workflow` that the workflow will not be
515
+ allowed to read what it names - rather than after a pipeline load.
516
+
517
+ Args:
518
+ definition: The expanded, substituted workflow definition
519
+ source_indices: Step index -> index in the file the author wrote
520
+ base_dir: The workflow file's directory
521
+ """
522
+ errors = []
523
+ steps = definition.get("steps") or []
524
+ for index, step in enumerate(steps):
525
+ if not isinstance(step, dict):
526
+ continue
527
+ source = (
528
+ source_indices[index]
529
+ if source_indices and index < len(source_indices)
530
+ else index
531
+ )
532
+ _walk(step, f"steps[{source}]", base_dir, errors)
533
+ return errors
534
+
535
+
536
+ def _walk(node, path, base_dir, errors):
537
+ if isinstance(node, dict):
538
+ for key, value in node.items():
539
+ here = f"{path}.{key}"
540
+ if key == "model_name" and isinstance(value, str):
541
+ message = _model_name_message(value, base_dir)
542
+ if message:
543
+ errors.append({"path": here, "message": message})
544
+ continue
545
+ if key == "remote_text_encoder" and isinstance(value, dict):
546
+ url = value.get("url")
547
+ if isinstance(url, str) and not _deferred(url):
548
+ message = _refusal(validate_remote_encoder_url, url)
549
+ if message:
550
+ errors.append({"path": f"{here}.url", "message": message})
551
+ continue
552
+ if key == GLOB_ARGUMENT and isinstance(value, str):
553
+ message = _glob_message(value, base_dir)
554
+ if message:
555
+ errors.append({"path": here, "message": message})
556
+ continue
557
+ if _is_media_key(key):
558
+ for sub_path, item in _each(value, here):
559
+ message = _check(item, base_dir, f"'{key}'")
560
+ if message:
561
+ errors.append({"path": sub_path, "message": message})
562
+ if key == "urls" and isinstance(value, list):
563
+ for sub_path, item in _each(value, here):
564
+ message = _check(item, base_dir, f"'{key}'")
565
+ if message:
566
+ errors.append({"path": sub_path, "message": message})
567
+ _walk(value, here, base_dir, errors)
568
+ elif isinstance(node, list):
569
+ for index, item in enumerate(node):
570
+ _walk(item, f"{path}[{index}]", base_dir, errors)
571
+
572
+
573
+ def _each(value, path):
574
+ if isinstance(value, list):
575
+ return [(f"{path}[{i}]", item) for i, item in enumerate(value)]
576
+ return [(path, value)]
577
+
578
+
579
+ def _glob_message(pattern, base_dir):
580
+ if _deferred(pattern):
581
+ return None
582
+ return _refusal(validate_media_glob, pattern, base_dir)
583
+
584
+
585
+ def _model_name_message(name, base_dir):
586
+ if _deferred(name):
587
+ return None
588
+ return _refusal(validate_model_name, name, base_dir)
589
+
590
+
591
+ def _refusal(check, *args):
592
+ """The message `check` refused its argument with, or None if it allowed it."""
593
+ try:
594
+ check(*args)
595
+ except (PathTraversalError, InvalidInputError) as e:
596
+ return str(e)
597
+ except Exception:
598
+ logger.debug(f"Location check skipped for {args[0]!r}", exc_info=True)
599
+ return None
dw/log_setup.py ADDED
@@ -0,0 +1,45 @@
1
+ import logging
2
+ from concurrent_log_handler import ConcurrentRotatingFileHandler
3
+
4
+ LOG_LEVELS = {
5
+ "CRITICAL": logging.CRITICAL,
6
+ "ERROR": logging.ERROR,
7
+ "WARNING": logging.WARNING,
8
+ "INFO": logging.INFO,
9
+ "DEBUG": logging.DEBUG,
10
+ }
11
+
12
+ _FORMATTER = logging.Formatter(
13
+ fmt="%(asctime)s [%(levelname)s] (%(name)s) %(message)s",
14
+ datefmt="%Y-%m-%dT%H:%M:%S",
15
+ )
16
+
17
+
18
+ def setup_logging(log_path, log_level="INFO", log_to_console=False):
19
+ """Configure the 'dw' logger. Safe to call more than once - existing
20
+ handlers are replaced, not stacked, so a reconfiguring caller (the REPL
21
+ worker between runs) does not multiply every line."""
22
+ logger = logging.getLogger("dw")
23
+ logger.setLevel(LOG_LEVELS.get(log_level, logging.INFO))
24
+
25
+ for handler in list(logger.handlers):
26
+ logger.removeHandler(handler)
27
+ handler.close()
28
+
29
+ file_handler = ConcurrentRotatingFileHandler(
30
+ log_path, "a", maxBytes=50 * 1024 * 1024, backupCount=7
31
+ )
32
+ file_handler.setFormatter(_FORMATTER)
33
+ logger.addHandler(file_handler)
34
+
35
+ if log_to_console:
36
+ console_handler = logging.StreamHandler()
37
+ console_handler.setFormatter(_FORMATTER)
38
+ logger.addHandler(console_handler)
39
+
40
+ return logger
41
+
42
+
43
+ def set_log_level(log_level):
44
+ """Change the 'dw' logger's level without touching its handlers."""
45
+ logging.getLogger("dw").setLevel(LOG_LEVELS.get(log_level, logging.INFO))