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/security.py ADDED
@@ -0,0 +1,1038 @@
1
+ """
2
+ Security utilities for input validation and safe file operations.
3
+ """
4
+
5
+ import os
6
+ import re
7
+ import logging
8
+ from pathlib import Path
9
+ from urllib.parse import urlparse
10
+ from typing import Union, List, Optional
11
+
12
+ logger = logging.getLogger("dw")
13
+
14
+ # Security constants
15
+ MAX_PATH_LENGTH = 4096
16
+ MAX_FILENAME_LENGTH = 255
17
+ MAX_JSON_SIZE = 50 * 1024 * 1024 # 50MB
18
+ MAX_VARIABLE_NAME_LENGTH = 100
19
+ # A stored prompt resolved into a sub-workflow's argument (the H3 Context-IR system
20
+ # prompt, about 11k characters) passes through this guard, so it sits above that with
21
+ # headroom.
22
+ MAX_VARIABLE_VALUE_LENGTH = 20000
23
+ MAX_CONSTANT_NAME_LENGTH = 200
24
+ DEFAULT_MAX_STRING_LENGTH = 1000
25
+ MAX_FILE_PATH_LENGTH = 1000
26
+ ALLOWED_JSON_EXTENSIONS = {".json"}
27
+ ALLOWED_IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp"}
28
+ ALLOWED_VIDEO_EXTENSIONS = {".mp4", ".avi", ".mkv", ".mov", ".webm"}
29
+ ALLOWED_AUDIO_EXTENSIONS = {".wav", ".mp3", ".flac", ".ogg"}
30
+ # The most pixels an image a caller names is decoded at. A PNG header can claim
31
+ # any size, and Pillow only warns below twice its own MAX_IMAGE_PIXELS, so this
32
+ # is checked after Image.open and before anything decodes. An 8K frame is 33M.
33
+ # dw_mcp keeps its own copy (it cannot import dw), pinned equal by a test.
34
+ MAX_DECODE_PIXELS = 50_000_000
35
+
36
+ # Dangerous path patterns (handle both Unix and Windows paths)
37
+ DANGEROUS_PATTERNS = [
38
+ r"\.\.", # Parent directory traversal (.. anywhere)
39
+ r"~[\\/]", # Home directory expansion
40
+ r"^/dev/", # Device files (Unix)
41
+ r"^/proc/", # Process files (Unix)
42
+ r"^/sys/", # System files (Unix)
43
+ ]
44
+
45
+
46
+ class SecurityError(Exception):
47
+ """Base exception for security-related errors."""
48
+
49
+ pass
50
+
51
+
52
+ class PathTraversalError(SecurityError):
53
+ """Raised when path traversal attempt is detected."""
54
+
55
+ pass
56
+
57
+
58
+ class InvalidInputError(SecurityError):
59
+ """Raised when input validation fails."""
60
+
61
+ pass
62
+
63
+
64
+ class UntrustedWorkflowError(SecurityError):
65
+ """Raised when an untrusted workflow reaches the code-execution surface.
66
+
67
+ Loading a workflow JSON file is not a passive data-load: pre_load_modules,
68
+ a dotted '*_type'/'*_dtype'/'dtype'/'config_type' value, and a 'constant:'
69
+ reference all run importlib.import_module() on a name the file supplies,
70
+ which executes that module's top-level code. See docs/SECURITY.md's Trust
71
+ model section. Untrusted here means "not explicitly vouched for by
72
+ --trust-workflows" - the default - not "known malicious".
73
+ """
74
+
75
+ pass
76
+
77
+
78
+ # The environment variable a parent process sets to hand its --trust-workflows
79
+ # choice down to a spawned worker subprocess - multiprocessing's 'spawn' start
80
+ # method launches a fresh interpreter that inherits os.environ, the same way
81
+ # DW_PROMPT_DIR reaches the worker (see dw/serve.py, dw/repl.py)
82
+ TRUST_WORKFLOWS_ENV_VAR = "DW_TRUST_WORKFLOWS"
83
+
84
+ # Dotted type/pre_load_modules references that resolve under one of these
85
+ # top-level packages are treated as part of the diffusers ecosystem the tool
86
+ # already assumes, and are allowed even for an untrusted workflow - everything
87
+ # else requires --trust-workflows. This is the dependency set pyproject.toml
88
+ # declares for exactly this purpose ("Quantization backends - config_objects.py
89
+ # loads them dynamically"), plus the framework packages workflows target
90
+ # directly, plus 'dw' itself - a workflow's own community_pipelines component
91
+ # lives in this package, not a third party one
92
+ TRUSTED_TOP_LEVEL_PACKAGES = (
93
+ "diffusers",
94
+ "torch",
95
+ "torchvision",
96
+ "transformers",
97
+ "accelerate",
98
+ "peft",
99
+ "sdnq",
100
+ "torchao",
101
+ "optimum", # optimum-quanto
102
+ "gguf",
103
+ "bitsandbytes",
104
+ "dw",
105
+ )
106
+
107
+
108
+ # What an untrusted workflow may name as a class. The package allowlist above
109
+ # is necessary but not sufficient: every '*_type' value is a class the run
110
+ # constructs or calls from_pretrained on with the workflow's own arguments,
111
+ # and a class defined inside an allowed package can still do anything in its
112
+ # constructor - start a process, open a file for writing. So a class reached
113
+ # untrusted must also be one of the kinds below, each of which is a
114
+ # checkpoint-loading component or plain data. Each base is (module, name) and
115
+ # is matched by identity through sys.modules: a class can only subclass a
116
+ # base whose module is already imported, so nothing is imported to check,
117
+ # and a package that is not installed simply contributes no bases.
118
+ CONSTRUCTIBLE_BASE_CLASSES = (
119
+ # diffusers: models, pipelines, schedulers, quantization configs
120
+ ("diffusers.models.modeling_utils", "ModelMixin"),
121
+ ("diffusers.pipelines.pipeline_utils", "DiffusionPipeline"),
122
+ ("diffusers.modular_pipelines.modular_pipeline", "ModularPipeline"),
123
+ ("diffusers.schedulers.scheduling_utils", "SchedulerMixin"),
124
+ ("diffusers.quantizers.quantization_config", "QuantizationConfigMixin"),
125
+ # transformers: models, tokenizers, processors, quantization configs
126
+ ("transformers.modeling_utils", "PreTrainedModel"),
127
+ ("transformers.tokenization_utils_base", "PreTrainedTokenizerBase"),
128
+ ("transformers.processing_utils", "ProcessorMixin"),
129
+ ("transformers.feature_extraction_utils", "FeatureExtractionMixin"),
130
+ ("transformers.image_processing_base", "ImageProcessingMixin"),
131
+ ("transformers.utils.quantization_config", "QuantizationConfigMixin"),
132
+ # torchao quantization configs (quant_type); sdnq's SDNQConfig is a
133
+ # diffusers QuantizationConfigMixin and is accepted through that
134
+ ("torchao.core.config", "AOBaseConfig"),
135
+ )
136
+
137
+ # Modules whose classes are the libraries' auto-dispatch factories
138
+ # (AutoPipelineFor*, transformers' Auto*): they have no base in common with
139
+ # what they dispatch to, construct nothing themselves, and load only through
140
+ # from_pretrained - whose remote-code arguments are gated separately
141
+ # (require_trusted_from_pretrained_arguments)
142
+ CONSTRUCTIBLE_FACTORY_MODULES = (
143
+ "diffusers.pipelines.auto_pipeline",
144
+ "transformers.models.auto.",
145
+ )
146
+
147
+ # Reference and condition descriptions (MiniMaxH3ImageReference,
148
+ # LTX2VideoCondition, ...) are dataclasses with no base of their own. A
149
+ # dataclass's generated __init__ only assigns its fields, so one defined in
150
+ # these packages is plain data - unless it adds a __post_init__, which runs
151
+ # arbitrary code; those are accepted only once reviewed and listed below
152
+ CONSTRUCTIBLE_DATACLASS_PACKAGES = (
153
+ "diffusers.pipelines.",
154
+ "diffusers.modular_pipelines.",
155
+ )
156
+ REVIEWED_POST_INIT_DATACLASSES = (
157
+ # __post_init__ only defaults fps to a module constant
158
+ "diffusers.modular_pipelines.minimax_h3.references.MiniMaxH3VideoReference",
159
+ )
160
+
161
+ # attn_processor_type names an attention processor, constructed with no
162
+ # arguments. They share no base class; diffusers defines them in its models
163
+ # package, named '...Processor' (or '...Processor2_0'). Their constructors
164
+ # record configuration, except that one (LTX2VideoVaeNeighborhoodNattenProcessor)
165
+ # fetches a kernel from the Hub - from a repo diffusers hardcodes, never one
166
+ # its arguments name, and only when DIFFUSERS_DISABLE_REMOTE_CODE is unset
167
+ # (see dw/kernel_availability.py)
168
+ _ATTENTION_PROCESSOR_NAME = re.compile(r"Processor(\d+_\d+)?$")
169
+ _ATTENTION_PROCESSOR_PACKAGE = "diffusers.models."
170
+
171
+
172
+ def _constructible_bases():
173
+ import sys
174
+
175
+ bases = []
176
+ for module_name, class_name in CONSTRUCTIBLE_BASE_CLASSES:
177
+ module = sys.modules.get(module_name)
178
+ base = getattr(module, class_name, None) if module is not None else None
179
+ if isinstance(base, type):
180
+ bases.append(base)
181
+ return tuple(bases)
182
+
183
+
184
+ def is_constructible_class(cls) -> bool:
185
+ """Whether an untrusted workflow may name `cls` as a type to construct.
186
+
187
+ True for a subclass of CONSTRUCTIBLE_BASE_CLASSES, a class defined in
188
+ CONSTRUCTIBLE_FACTORY_MODULES, a dataclass defined in
189
+ CONSTRUCTIBLE_DATACLASS_PACKAGES with no unreviewed __post_init__, or a
190
+ diffusers attention processor. See the comments on each for why.
191
+ """
192
+ import dataclasses
193
+
194
+ if not isinstance(cls, type):
195
+ return False
196
+ # Real inheritance only: issubclass would also honour an ABC's register()
197
+ # and __subclasshook__, and AOBaseConfig is an ABC
198
+ bases = _constructible_bases()
199
+ if any(base in cls.__mro__ for base in bases):
200
+ return True
201
+ module = getattr(cls, "__module__", None)
202
+ if not isinstance(module, str):
203
+ return False
204
+ qualified = f"{module}.{cls.__qualname__}"
205
+ if any(
206
+ module == prefix.rstrip(".") or module.startswith(prefix)
207
+ for prefix in CONSTRUCTIBLE_FACTORY_MODULES
208
+ ):
209
+ return True
210
+ if dataclasses.is_dataclass(cls) and module.startswith(
211
+ CONSTRUCTIBLE_DATACLASS_PACKAGES
212
+ ):
213
+ return (
214
+ getattr(cls, "__post_init__", None) is None
215
+ or qualified in REVIEWED_POST_INIT_DATACLASSES
216
+ )
217
+ return module.startswith(_ATTENTION_PROCESSOR_PACKAGE) and bool(
218
+ _ATTENTION_PROCESSOR_NAME.search(cls.__name__)
219
+ )
220
+
221
+
222
+ def require_constructible_class(name: str, cls, what: str) -> None:
223
+ """Refuse a class an untrusted workflow may not construct.
224
+
225
+ Raises:
226
+ UntrustedWorkflowError: If untrusted and `cls` is not
227
+ is_constructible_class
228
+ """
229
+ if workflows_are_trusted() or is_constructible_class(cls):
230
+ return
231
+ raise UntrustedWorkflowError(
232
+ f"Refusing to load {what} '{name}': an untrusted workflow may only "
233
+ f"name a model, pipeline, scheduler, tokenizer, processor, "
234
+ f"quantization config, auto-pipeline/auto-model factory, attention "
235
+ f"processor or reference/condition dataclass from the diffusers "
236
+ f"ecosystem, and '{getattr(cls, '__module__', '?')}."
237
+ f"{getattr(cls, '__qualname__', '?')}' is none of these - its "
238
+ f"constructor could run anything with the workflow's arguments. Pass "
239
+ f"--trust-workflows if you trust this workflow's source."
240
+ )
241
+
242
+
243
+ def set_trust_workflows(trusted: bool) -> None:
244
+ """Record the process-wide --trust-workflows choice.
245
+
246
+ Called once at CLI/server startup, before any workflow loads. A spawned
247
+ worker subprocess reads the same choice back from the environment
248
+ variable this sets, rather than needing it passed as an argument.
249
+ """
250
+ os.environ[TRUST_WORKFLOWS_ENV_VAR] = "1" if trusted else "0"
251
+
252
+
253
+ def workflows_are_trusted() -> bool:
254
+ """Whether the process has been told to trust workflow files fully.
255
+
256
+ Defaults to untrusted (False) when nothing has set the flag, which is
257
+ the secure default for any code path that loads a workflow without
258
+ going through the CLI/server startup that calls set_trust_workflows().
259
+ """
260
+ return os.environ.get(TRUST_WORKFLOWS_ENV_VAR) == "1"
261
+
262
+
263
+ def _top_level_package(dotted_name: str) -> str:
264
+ return dotted_name.split(".", 1)[0]
265
+
266
+
267
+ def require_trusted_dotted_name(dotted_name: str, what: str) -> None:
268
+ """Refuse a dotted-name import outside the diffusers ecosystem unless
269
+ the workflow is trusted.
270
+
271
+ Args:
272
+ dotted_name: The module.path.Name a workflow supplied
273
+ what: Short phrase naming what kind of value this was ('a *_type
274
+ value', 'a config_type value', ...), for the error message
275
+
276
+ Raises:
277
+ UntrustedWorkflowError: If untrusted and the name is outside
278
+ TRUSTED_TOP_LEVEL_PACKAGES
279
+ """
280
+ if workflows_are_trusted():
281
+ return
282
+
283
+ top_level = _top_level_package(dotted_name)
284
+ if top_level in TRUSTED_TOP_LEVEL_PACKAGES:
285
+ return
286
+
287
+ raise UntrustedWorkflowError(
288
+ f"Refusing to load {what} '{dotted_name}': it imports the "
289
+ f"'{top_level}' module, which is outside the ecosystem "
290
+ f"({', '.join(TRUSTED_TOP_LEVEL_PACKAGES)}) this workflow is "
291
+ f"allowed to reach untrusted. Loading a workflow JSON file can "
292
+ f"execute arbitrary Python - see docs/SECURITY.md. Pass "
293
+ f"--trust-workflows if you trust this workflow's source."
294
+ )
295
+
296
+
297
+ def require_trusted_pre_load_modules(module_names) -> None:
298
+ """Refuse a pre_load_modules entry outside the diffusers ecosystem
299
+ unless the workflow is trusted.
300
+
301
+ pre_load_modules exists to run a module's import-time registration
302
+ side effects - sdnq registering its quantization method with diffusers
303
+ is the pattern the bundled example workflows use - so an in-ecosystem
304
+ module name is allowed the same way an in-ecosystem dotted type
305
+ reference is; anything else requires trust.
306
+
307
+ Raises:
308
+ UntrustedWorkflowError: If untrusted and any name is outside
309
+ TRUSTED_TOP_LEVEL_PACKAGES
310
+ """
311
+ for module_name in module_names or []:
312
+ require_trusted_dotted_name(module_name, "pre_load_modules entry")
313
+
314
+
315
+ REMOTE_CODE_ARGUMENTS = ("trust_remote_code", "custom_pipeline")
316
+
317
+
318
+ def require_trusted_from_pretrained_arguments(arguments, what: str) -> None:
319
+ """Refuse from_pretrained arguments that make diffusers/transformers
320
+ download and execute Python from the Hub unless the workflow is trusted.
321
+
322
+ `trust_remote_code: true` runs a repo's own modeling code; `custom_pipeline`
323
+ fetches a pipeline module from the Hub (or a local path) and imports it.
324
+ Both are arbitrary code chosen by the workflow file, reached without any
325
+ importlib call of ours - so the importlib gate alone would leave them open.
326
+
327
+ Args:
328
+ arguments: The from_pretrained_arguments block
329
+ what: The component being loaded, for the error message
330
+
331
+ Raises:
332
+ UntrustedWorkflowError: If untrusted and either argument is set
333
+ """
334
+ if workflows_are_trusted() or not arguments:
335
+ return
336
+ for key in REMOTE_CODE_ARGUMENTS:
337
+ if arguments.get(key):
338
+ raise UntrustedWorkflowError(
339
+ f"Refusing to load {what}: its from_pretrained_arguments set "
340
+ f"'{key}', which downloads and executes Python from the model "
341
+ f"repository. Loading a workflow JSON file can execute arbitrary "
342
+ f"Python - see docs/SECURITY.md. Pass --trust-workflows if you "
343
+ f"trust this workflow's source."
344
+ )
345
+
346
+
347
+ def validate_path(
348
+ path: Union[str, Path], base_dir: Optional[str] = None, allow_create: bool = True
349
+ ) -> str:
350
+ """
351
+ Validate and sanitize file paths to prevent path traversal attacks.
352
+
353
+ Args:
354
+ path: The path to validate
355
+ base_dir: Optional base directory to restrict access to
356
+ allow_create: Whether to allow creation of non-existent paths
357
+
358
+ Returns:
359
+ Absolute, sanitized path
360
+
361
+ Raises:
362
+ PathTraversalError: If path contains dangerous patterns
363
+ InvalidInputError: If path is invalid or too long
364
+ """
365
+ if not path:
366
+ raise InvalidInputError("Path cannot be empty")
367
+
368
+ path_str = str(path)
369
+
370
+ # Check path length
371
+ if len(path_str) > MAX_PATH_LENGTH:
372
+ raise InvalidInputError(f"Path too long: {len(path_str)} > {MAX_PATH_LENGTH}")
373
+
374
+ # Check for null bytes
375
+ if "\x00" in path_str:
376
+ raise InvalidInputError("Path contains null bytes")
377
+
378
+ # Normalize path separators for consistent checking across platforms
379
+ normalized_path = path_str.replace("\\", "/")
380
+
381
+ # Check for dangerous patterns
382
+ for pattern in DANGEROUS_PATTERNS:
383
+ if re.search(pattern, normalized_path, re.IGNORECASE):
384
+ raise PathTraversalError(
385
+ f"Path contains dangerous pattern matching {pattern}"
386
+ )
387
+
388
+ # Convert to absolute path and resolve. normpath after realpath is a
389
+ # no-op for correctness but keeps the value in the normalized shape
390
+ # security scanners recognize as sanitized
391
+ try:
392
+ abs_path = os.path.abspath(os.path.expanduser(path_str))
393
+ resolved_path = os.path.normpath(os.path.realpath(abs_path))
394
+ except (OSError, ValueError) as e:
395
+ raise InvalidInputError(f"Invalid path: {e}")
396
+
397
+ # Check if path is within base directory if specified
398
+ if base_dir:
399
+ try:
400
+ base_abs = os.path.abspath(os.path.expanduser(base_dir))
401
+ base_real = os.path.normpath(os.path.realpath(base_abs))
402
+ except (OSError, ValueError) as e:
403
+ raise InvalidInputError(f"Invalid base directory: {e}")
404
+
405
+ # Containment on the fully resolved paths: equal to the base, or a
406
+ # descendant of it. The os.sep suffix stops a sibling with the base
407
+ # as a name prefix (/base-evil vs /base); realpath above already
408
+ # collapsed symlinks and '..' on both sides, which also makes a
409
+ # different-drive Windows path fail the prefix test
410
+ if resolved_path != base_real and not resolved_path.startswith(
411
+ base_real + os.sep
412
+ ):
413
+ raise PathTraversalError(f"Path outside allowed directory: {resolved_path}")
414
+
415
+ # Check filename length
416
+ filename = os.path.basename(resolved_path)
417
+ if len(filename) > MAX_FILENAME_LENGTH:
418
+ raise InvalidInputError(
419
+ f"Filename too long: {len(filename)} > {MAX_FILENAME_LENGTH}"
420
+ )
421
+
422
+ # Check if path exists or creation is allowed
423
+ if not os.path.exists(resolved_path) and not allow_create:
424
+ raise InvalidInputError(f"Path does not exist: {resolved_path}")
425
+
426
+ logger.debug(f"Validated path: {path_str} -> {resolved_path}")
427
+ return resolved_path
428
+
429
+
430
+ def contained(path: Union[str, Path], root: Union[str, Path]) -> bool:
431
+ """Whether path, symlinks resolved, lies inside root, symlinks resolved.
432
+
433
+ For a listing that walks a directory with os.walk: a file symlink shows
434
+ up among the names like any other file, and naming it would carry the
435
+ target's name, size or content out of the root. Both sides are resolved,
436
+ so a root that is itself a link (a data volume) still contains its own
437
+ files, while a link inside it pointing elsewhere does not.
438
+ """
439
+ real_root = os.path.realpath(root)
440
+ real = os.path.realpath(path)
441
+ return real == real_root or real.startswith(real_root.rstrip(os.sep) + os.sep)
442
+
443
+
444
+ def validate_file_extension(path: str, allowed_extensions: set) -> str:
445
+ """
446
+ Validate file extension against allowed list.
447
+
448
+ Args:
449
+ path: File path to validate
450
+ allowed_extensions: Set of allowed extensions (with dots)
451
+
452
+ Returns:
453
+ The validated path
454
+
455
+ Raises:
456
+ InvalidInputError: If extension is not allowed
457
+ """
458
+ ext = os.path.splitext(path)[1].lower()
459
+ if ext not in allowed_extensions:
460
+ raise InvalidInputError(f"File extension not allowed: {ext}")
461
+ return path
462
+
463
+
464
+ def validate_workflow_path(path: str, workflow_dir: str = None) -> str:
465
+ """Validate workflow file paths."""
466
+ validated = validate_path(path, workflow_dir, allow_create=False)
467
+ return validate_file_extension(validated, ALLOWED_JSON_EXTENSIONS)
468
+
469
+
470
+ def validate_prompt_path(path: str, prompt_dir: str) -> str:
471
+ """Validate stored prompt file paths, confined to the prompt directory."""
472
+ validated = validate_path(path, prompt_dir, allow_create=False)
473
+ return validate_file_extension(validated, ALLOWED_JSON_EXTENSIONS)
474
+
475
+
476
+ def validate_output_path(path: str, output_dir: str) -> str:
477
+ """Validate output file paths."""
478
+ return validate_path(path, output_dir, allow_create=True)
479
+
480
+
481
+ def validate_url(url: str) -> str:
482
+ """
483
+ Validate URL format and scheme.
484
+
485
+ Args:
486
+ url: URL to validate
487
+
488
+ Returns:
489
+ The validated URL
490
+
491
+ Raises:
492
+ InvalidInputError: If URL is invalid or uses dangerous scheme
493
+ """
494
+ if not url:
495
+ raise InvalidInputError("URL cannot be empty")
496
+
497
+ # urllib.parse and urllib3 disagree on where a backslash ends the host:
498
+ # 'http://169.254.169.254\@example.com/' is example.com to the check and
499
+ # 169.254.169.254 to the request. No valid URL needs one, so refuse it
500
+ # rather than pick a parser
501
+ if "\\" in url:
502
+ raise InvalidInputError(
503
+ f"Invalid URL: '{url}' contains a backslash, which parsers read "
504
+ f"differently - percent-encode it as %5C if it belongs in the path"
505
+ )
506
+
507
+ try:
508
+ parsed = urlparse(url)
509
+ if parsed.scheme not in {"http", "https"}:
510
+ raise InvalidInputError(f"URL scheme not allowed: {parsed.scheme}")
511
+ if not parsed.netloc:
512
+ raise InvalidInputError("URL must have a valid domain")
513
+ return url
514
+ except Exception as e:
515
+ raise InvalidInputError(f"Invalid URL: {e}")
516
+
517
+
518
+ def sanitize_command_args(args: List[str]) -> List[str]:
519
+ """
520
+ Sanitize command arguments for subprocess execution with shell=False.
521
+
522
+ When using subprocess with a list of arguments and shell=False, Python
523
+ handles argument separation safely without shell interpretation. This
524
+ function validates that arguments don't contain shell metacharacters
525
+ that could be dangerous if shell=True were accidentally used.
526
+
527
+ Args:
528
+ args: List of command arguments
529
+
530
+ Returns:
531
+ List of validated arguments (no modification needed for shell=False)
532
+
533
+ Raises:
534
+ InvalidInputError: If arguments contain dangerous content
535
+ """
536
+ sanitized = []
537
+
538
+ for arg in args:
539
+ if not isinstance(arg, str):
540
+ arg = str(arg)
541
+
542
+ # Check for dangerous characters that would be problematic with shell=True
543
+ # Even though we use shell=False, this prevents accidental security issues
544
+ if any(char in arg for char in ["`", "$", "|", "&", ";", ">", "<", "\n", "\r"]):
545
+ raise InvalidInputError(f"Argument contains dangerous characters: {arg}")
546
+
547
+ # With shell=False, we don't need shlex.quote() - Python handles it safely
548
+ # Just validate and pass through
549
+ sanitized.append(arg)
550
+
551
+ return sanitized
552
+
553
+
554
+ def validate_variable_name(name: str) -> str:
555
+ """
556
+ Validate variable names to prevent injection attacks.
557
+
558
+ Args:
559
+ name: Variable name to validate
560
+
561
+ Returns:
562
+ The validated variable name
563
+
564
+ Raises:
565
+ InvalidInputError: If name is invalid
566
+ """
567
+ if not name:
568
+ raise InvalidInputError("Variable name cannot be empty")
569
+
570
+ # Allow only alphanumeric characters, underscores, and hyphens
571
+ if not re.match(r"^[a-zA-Z_][a-zA-Z0-9_-]*\Z", name):
572
+ raise InvalidInputError(f"Invalid variable name: {name}")
573
+
574
+ if len(name) > MAX_VARIABLE_NAME_LENGTH:
575
+ raise InvalidInputError(
576
+ f"Variable name too long: {len(name)} > {MAX_VARIABLE_NAME_LENGTH}"
577
+ )
578
+
579
+ return name
580
+
581
+
582
+ # A stored prompt's name: a file name, optionally under one folder. Each
583
+ # segment starts with a word character, which precludes '..', hidden files,
584
+ # and absolute paths without a second scan. Anchored with \Z, not $ - $ also
585
+ # matches before a trailing newline, which would admit names no listing can
586
+ # round-trip (the same reason the variable and constant patterns use \Z)
587
+ PROMPT_REFERENCE_CHARACTERS = r"[\w.-]"
588
+ PROMPT_REFERENCE_PATTERN = r"^[\w][\w.-]*(/[\w][\w.-]*)?\Z"
589
+ MAX_PROMPT_REFERENCE_LENGTH = 200
590
+
591
+
592
+ def _name_fault(name: str, allowed: str) -> str:
593
+ """Which part of `name` the pattern objected to, as a clause, or ''.
594
+
595
+ A name that fails one of these patterns used to be echoed back beside a
596
+ description of a *valid* name and nothing else, so a caller who had
597
+ passed a name the server itself produced had to bisect it character by
598
+ character to find the objection (#162). Naming the character turns that
599
+ into a one-line fix.
600
+ """
601
+ if not allowed:
602
+ return ""
603
+ for position, character in enumerate(name):
604
+ if character == "/":
605
+ continue
606
+ if not re.fullmatch(allowed, character):
607
+ return (
608
+ f" - {character!r} (position {position}) is not a character "
609
+ f"this kind of name may contain"
610
+ )
611
+ for position, segment in enumerate(name.split("/")):
612
+ if not segment:
613
+ return f" - segment {position + 1} is empty"
614
+ if not re.fullmatch(r"\w", segment[0]):
615
+ return (
616
+ f" - segment {segment!r} starts with {segment[0]!r}, and every "
617
+ f"segment must start with a letter, digit or underscore"
618
+ )
619
+ return ""
620
+
621
+
622
+ def _validate_name(
623
+ name: str,
624
+ pattern: str,
625
+ max_length: int,
626
+ what: str,
627
+ hint: str,
628
+ allowed: str = "",
629
+ ) -> str:
630
+ """Shared body of the reference/name validators below: an empty check, a
631
+ length check, then the pattern - length before pattern so a name that
632
+ fails both reports the shorter, cheaper-to-fix complaint first, matching
633
+ what each validator already reported on its own.
634
+
635
+ Args:
636
+ name: The value to validate
637
+ pattern: Regex the name must fully match
638
+ max_length: Longest allowed length
639
+ what: Short label for the messages ('Prompt name', 'Asset name', ...)
640
+ hint: The rest of the "invalid" message, describing what a valid one
641
+ looks like
642
+
643
+ Raises:
644
+ InvalidInputError: If name is invalid
645
+ """
646
+ if not name:
647
+ raise InvalidInputError(f"{what} cannot be empty")
648
+
649
+ if len(name) > max_length:
650
+ raise InvalidInputError(f"{what} too long: {len(name)} > {max_length}")
651
+
652
+ if not re.match(pattern, name):
653
+ raise InvalidInputError(
654
+ f"Invalid {what.lower()}: {name}{_name_fault(name, allowed)} - {hint}"
655
+ )
656
+
657
+ return name
658
+
659
+
660
+ def validate_prompt_reference(name: str) -> str:
661
+ """
662
+ Validate the name a 'prompt:' reference points at.
663
+
664
+ The name is joined onto the prompt directory to find the file, so it is
665
+ checked before anything touches the filesystem - a plain name or one
666
+ folder deep, matching how the prompt library is organized.
667
+
668
+ Args:
669
+ name: Prompt name to validate
670
+
671
+ Returns:
672
+ The validated name
673
+
674
+ Raises:
675
+ InvalidInputError: If name is invalid
676
+ """
677
+ return _validate_name(
678
+ name,
679
+ PROMPT_REFERENCE_PATTERN,
680
+ MAX_PROMPT_REFERENCE_LENGTH,
681
+ "Prompt name",
682
+ "a prompt is named by its file under the prompt directory, at most "
683
+ "one folder deep, like 'scenic_landscape' or 'minimax/fox_dawn'",
684
+ allowed=PROMPT_REFERENCE_CHARACTERS,
685
+ )
686
+
687
+
688
+ # A stored asset's name: a file name with its extension, optionally under
689
+ # folders. Each segment starts with a word character, which precludes '..',
690
+ # hidden files and absolute paths; the depth cap keeps a name a name. A prompt
691
+ # is named without its extension and lives at most one folder deep - an asset
692
+ # carries its extension, because which file it is depends on it, and media
693
+ # libraries nest deeper than prompt libraries do
694
+ # '@' for the same reason OUTPUT_REFERENCE_PATTERN carries it: keeping a
695
+ # `for_each` member's file as an asset defaults its name to that file's
696
+ # base name, which carries the '@' the engine wrote (#162)
697
+ ASSET_REFERENCE_CHARACTERS = r"[\w.@-]"
698
+ ASSET_REFERENCE_PATTERN = r"^[\w][\w.@-]*(/[\w][\w.@-]*){0,4}\Z"
699
+ MAX_ASSET_REFERENCE_LENGTH = 400
700
+
701
+
702
+ def validate_asset_reference(name: str) -> str:
703
+ """
704
+ Validate the name an 'asset:' reference points at.
705
+
706
+ The name is joined onto the asset directory to find the file, so it is
707
+ checked before anything touches the filesystem. Containment in the
708
+ library is checked separately, by the validate_path call that joins it.
709
+
710
+ Args:
711
+ name: Asset name to validate
712
+
713
+ Returns:
714
+ The validated name
715
+
716
+ Raises:
717
+ InvalidInputError: If name is invalid
718
+ """
719
+ return _validate_name(
720
+ name,
721
+ ASSET_REFERENCE_PATTERN,
722
+ MAX_ASSET_REFERENCE_LENGTH,
723
+ "Asset name",
724
+ "an asset is named by its file under the asset directory, with its "
725
+ "extension and at most four folders deep, like 'iris.jpg' or "
726
+ "'gyre/frames/iris.jpg'",
727
+ allowed=ASSET_REFERENCE_CHARACTERS,
728
+ )
729
+
730
+
731
+ # A generated output's name: the workflow's identity, the run, and the file -
732
+ # deeper than an asset name because the identity itself can nest, and the run
733
+ # id is a segment of its own.
734
+ #
735
+ # '@' is here because the engine writes it: a `for_each` member is named
736
+ # '<group>@<entry>' and its files carry that in their base name, so a whole
737
+ # class of files the server named could not be named back to it (#162). It
738
+ # is safe in a path - not a separator, not '..', and containment is still
739
+ # checked by the validate_path that joins the name onto the output root -
740
+ # and a name still may not *start* with it.
741
+ OUTPUT_REFERENCE_CHARACTERS = r"[\w.@-]"
742
+ OUTPUT_REFERENCE_PATTERN = r"^[\w][\w.@-]*(/[\w][\w.@-]*){1,6}\Z"
743
+ MAX_OUTPUT_REFERENCE_LENGTH = 500
744
+
745
+
746
+ def validate_output_reference(name: str) -> str:
747
+ """
748
+ Validate the name an 'output:' reference points at.
749
+
750
+ The name is joined onto the output directory to find the file, so it is
751
+ checked before anything touches the filesystem. Containment is checked
752
+ separately, by the validate_path call that joins it - after any 'latest'
753
+ segment has been expanded, so what is checked is the real path.
754
+
755
+ Args:
756
+ name: Output name to validate
757
+
758
+ Returns:
759
+ The validated name
760
+
761
+ Raises:
762
+ InvalidInputError: If name is invalid
763
+ """
764
+ return _validate_name(
765
+ name,
766
+ OUTPUT_REFERENCE_PATTERN,
767
+ MAX_OUTPUT_REFERENCE_LENGTH,
768
+ "Output name",
769
+ "an output is named by the workflow that made it, the run, and the "
770
+ "file, like 'ltx2/Gyre/latest/Gyre-still.0-0.0.png'",
771
+ allowed=OUTPUT_REFERENCE_CHARACTERS,
772
+ )
773
+
774
+
775
+ # A step's result 'subfolder': a relative path under the run directory. The
776
+ # segment rule is OUTPUT_REFERENCE_PATTERN's, so every subfolder the engine
777
+ # writes is one a later workflow can name with 'output:'. It also refuses a
778
+ # backslash, which DANGEROUS_PATTERNS does not - '"final\\x"' would be one
779
+ # directory on POSIX and two on Windows
780
+ SUBFOLDER_PATTERN = r"^[\w][\w.-]*(/[\w][\w.-]*)*\Z"
781
+ MAX_SUBFOLDER_LENGTH = 200
782
+
783
+
784
+ def validate_subfolder(name: str) -> str:
785
+ """
786
+ Validate the shape of a result 'subfolder'.
787
+
788
+ Containment is checked separately, by the validate_output_path call
789
+ that joins it onto the run directory.
790
+
791
+ Args:
792
+ name: The subfolder as written in the workflow
793
+
794
+ Returns:
795
+ The validated name
796
+
797
+ Raises:
798
+ InvalidInputError: If the name is not a valid subfolder
799
+ """
800
+ return _validate_name(
801
+ name,
802
+ SUBFOLDER_PATTERN,
803
+ MAX_SUBFOLDER_LENGTH,
804
+ "Subfolder",
805
+ "a subfolder is one or more path segments under the run directory, "
806
+ "each starting with a letter, digit or underscore, like 'final' or "
807
+ "'shots/act-1'",
808
+ )
809
+
810
+
811
+ def validate_file_base_name(name: str) -> str:
812
+ """
813
+ Validate a result 'file_base_name': a name, never a path.
814
+
815
+ A separator here used to pass validation and then fail at open() because
816
+ the directory did not exist. Placement is what 'subfolder' is for.
817
+
818
+ Raises:
819
+ InvalidInputError: If the name carries a path separator
820
+ """
821
+ if "/" in name or "\\" in name:
822
+ raise InvalidInputError(
823
+ f"Invalid file_base_name: {name} - a file_base_name is a name, not "
824
+ f"a path; to write into a subfolder of the run directory set "
825
+ f"'subfolder' on the result instead"
826
+ )
827
+ return name
828
+
829
+
830
+ def validate_content_type(value: str) -> str:
831
+ """
832
+ Validate a result 'content_type': a MIME type, never a bare word.
833
+
834
+ A bare word like 'video' passed here clean and then failed deep inside
835
+ a writer, in a traceback naming neither the field nor the value - the
836
+ writer's own dispatch matches 'video' as a startswith prefix of
837
+ 'video/mp4' and takes that branch anyway, then fails for lack of a real
838
+ extension to write. This only checks the shape; which MIME types this
839
+ engine actually has a writer for is the caller's business.
840
+
841
+ Raises:
842
+ InvalidInputError: If the value is not a string, or not shaped like
843
+ a MIME type
844
+ """
845
+ if not isinstance(value, str):
846
+ raise InvalidInputError(f"Invalid content_type: {value!r} - expected a string")
847
+ if value.count("/") != 1 or "" in value.split("/"):
848
+ raise InvalidInputError(
849
+ f"Invalid content_type: {value!r} - content_type wants a MIME "
850
+ f"type like 'video/mp4' or 'image/png', not a bare word"
851
+ )
852
+ return value
853
+
854
+
855
+ # A workspace's name: one path segment, starting with a word character, so
856
+ # '..', hidden names and anything with a separator in it are all excluded
857
+ # before the name is joined onto the workspace root
858
+ WORKSPACE_NAME_PATTERN = r"^[\w][\w.-]*\Z"
859
+ MAX_WORKSPACE_NAME_LENGTH = 100
860
+
861
+
862
+ def validate_workspace_name(name: str) -> str:
863
+ """
864
+ Validate a workspace name.
865
+
866
+ Args:
867
+ name: Workspace name to validate
868
+
869
+ Returns:
870
+ The validated name
871
+
872
+ Raises:
873
+ InvalidInputError: If the name is not one a workspace can take
874
+ """
875
+ from .workspace import RESERVED_WORKSPACE_NAMES
876
+
877
+ _validate_name(
878
+ name,
879
+ WORKSPACE_NAME_PATTERN,
880
+ MAX_WORKSPACE_NAME_LENGTH,
881
+ "Workspace name",
882
+ "a workspace is one folder under the workspace root, named with "
883
+ "letters, numbers, dot, dash or underscore",
884
+ )
885
+
886
+ if name in RESERVED_WORKSPACE_NAMES:
887
+ raise InvalidInputError(
888
+ f"'{name}' is one of the workspace root's own folders "
889
+ f"({', '.join(RESERVED_WORKSPACE_NAMES)}) and cannot name a workspace"
890
+ )
891
+
892
+ return name
893
+
894
+
895
+ # A dotted python name: identifiers separated by dots, and nothing else
896
+ CONSTANT_NAME_PATTERN = r"^[a-zA-Z_][a-zA-Z0-9_]*(\.[a-zA-Z_][a-zA-Z0-9_]*)*\Z"
897
+
898
+
899
+ def validate_constant_name(name: str) -> str:
900
+ """
901
+ Validate the dotted name of a python constant a workflow references.
902
+
903
+ Resolving a name imports the module it lives in, which runs that module's
904
+ code, so the name is checked before anything is imported - identifiers and
905
+ dots only, no relative paths and nothing to evaluate.
906
+
907
+ Args:
908
+ name: Dotted name to validate
909
+
910
+ Returns:
911
+ The validated name
912
+
913
+ Raises:
914
+ InvalidInputError: If name is invalid
915
+ """
916
+ if not name:
917
+ raise InvalidInputError("Constant name cannot be empty")
918
+
919
+ if not re.match(CONSTANT_NAME_PATTERN, name):
920
+ raise InvalidInputError(
921
+ f"Invalid constant name: {name} - a constant is named by its module "
922
+ f"and the attribute to read from it, like "
923
+ f"'diffusers.pipelines.ltx2.utils.DISTILLED_SIGMA_VALUES'"
924
+ )
925
+
926
+ if len(name) > MAX_CONSTANT_NAME_LENGTH:
927
+ raise InvalidInputError(
928
+ f"Constant name too long: {len(name)} > {MAX_CONSTANT_NAME_LENGTH}"
929
+ )
930
+
931
+ return name
932
+
933
+
934
+ # A git commit: hex digits only, short (7) to full (40) SHA-1 length. Used
935
+ # to pin the diffusers updater's "git+URL@<commit>" install target - a git
936
+ # ref grammar accepts far more than a hash would (branch names, shell-
937
+ # adjacent punctuation), so this is deliberately narrower than "anything
938
+ # git allows"
939
+ COMMIT_HASH_PATTERN = r"^[0-9a-fA-F]{7,40}\Z"
940
+
941
+
942
+ def validate_commit_hash(commit: str) -> str:
943
+ """
944
+ Validate a git commit hash before it is interpolated into a
945
+ 'git+<url>@<commit>' pip install target.
946
+
947
+ Args:
948
+ commit: Commit hash to validate
949
+
950
+ Returns:
951
+ The validated commit hash
952
+
953
+ Raises:
954
+ InvalidInputError: If the hash is not 7-40 hex characters
955
+ """
956
+ if not commit:
957
+ raise InvalidInputError("Commit hash cannot be empty")
958
+
959
+ if not re.match(COMMIT_HASH_PATTERN, commit):
960
+ raise InvalidInputError(
961
+ f"Invalid commit hash: {commit} - expected 7 to 40 hex characters"
962
+ )
963
+
964
+ return commit
965
+
966
+
967
+ def validate_json_size(file_path: str) -> None:
968
+ """
969
+ Validate JSON file size before loading.
970
+
971
+ Args:
972
+ file_path: Path to JSON file
973
+
974
+ Raises:
975
+ InvalidInputError: If file is too large
976
+ """
977
+ try:
978
+ size = os.path.getsize(file_path)
979
+ if size > MAX_JSON_SIZE:
980
+ raise InvalidInputError(f"JSON file too large: {size} > {MAX_JSON_SIZE}")
981
+ except OSError as e:
982
+ raise InvalidInputError(f"Cannot check file size: {e}")
983
+
984
+
985
+ def validate_string_input(
986
+ value: str, max_length: int = DEFAULT_MAX_STRING_LENGTH, allow_empty: bool = False
987
+ ) -> str:
988
+ """
989
+ Validate string input for basic safety.
990
+
991
+ Args:
992
+ value: String to validate
993
+ max_length: Maximum allowed length
994
+ allow_empty: Whether empty strings are allowed
995
+
996
+ Returns:
997
+ The validated string
998
+
999
+ Raises:
1000
+ InvalidInputError: If string is invalid
1001
+ """
1002
+ if not allow_empty and not value:
1003
+ raise InvalidInputError("String cannot be empty")
1004
+
1005
+ if len(value) > max_length:
1006
+ raise InvalidInputError(f"String too long: {len(value)} > {max_length}")
1007
+
1008
+ # Check for null bytes and control characters
1009
+ if "\x00" in value or any(ord(c) < 32 for c in value if c not in "\t\n\r"):
1010
+ raise InvalidInputError("String contains invalid characters")
1011
+
1012
+ return value
1013
+
1014
+
1015
+ def safe_join_path(*parts: str) -> str:
1016
+ """
1017
+ Safely join path components with validation.
1018
+
1019
+ Args:
1020
+ *parts: Path components to join
1021
+
1022
+ Returns:
1023
+ Safely joined path
1024
+
1025
+ Raises:
1026
+ InvalidInputError: If any component is invalid
1027
+ """
1028
+ # Validate each component
1029
+ for part in parts:
1030
+ if not part:
1031
+ continue
1032
+ validate_string_input(part, MAX_FILENAME_LENGTH)
1033
+ if ".." in part or "/" in part or "\\" in part:
1034
+ raise InvalidInputError(
1035
+ f"Path component contains invalid characters: {part}"
1036
+ )
1037
+
1038
+ return os.path.join(*parts)