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.
- diffusers_workflow-0.4.0.dist-info/METADATA +318 -0
- diffusers_workflow-0.4.0.dist-info/RECORD +260 -0
- diffusers_workflow-0.4.0.dist-info/WHEEL +5 -0
- diffusers_workflow-0.4.0.dist-info/entry_points.txt +7 -0
- diffusers_workflow-0.4.0.dist-info/licenses/LICENSE +201 -0
- diffusers_workflow-0.4.0.dist-info/top_level.txt +2 -0
- dw/__init__.py +440 -0
- dw/adapter_compatibility.py +226 -0
- dw/arguments.py +1231 -0
- dw/assessment_rules.py +159 -0
- dw/assets.py +130 -0
- dw/cache_blocks.json +16 -0
- dw/cache_blocks.py +146 -0
- dw/community_pipelines/pipeline_flux_rf_inversion.py +1184 -0
- dw/content_types.py +150 -0
- dw/dissolve_frame_errors.py +121 -0
- dw/docs/ACCELERATION.md +352 -0
- dw/docs/AGENT_LOOP.md +95 -0
- dw/docs/DEPENDENCIES.md +91 -0
- dw/docs/IP_ADAPTER.md +109 -0
- dw/docs/LORAS.md +131 -0
- dw/docs/MCP.md +517 -0
- dw/docs/PROMPT_WEIGHTING.md +78 -0
- dw/docs/QUANTIZATION.md +230 -0
- dw/docs/RECIPES_24GB.md +201 -0
- dw/docs/RELEASING.md +195 -0
- dw/docs/REMOTE.md +140 -0
- dw/docs/REPL_COMMANDS.md +121 -0
- dw/docs/REPL_WORKER_GUIDE.md +51 -0
- dw/docs/SECURITY.md +272 -0
- dw/docs/SECURITY_QUICKREF.md +112 -0
- dw/docs/SERVER.md +679 -0
- dw/docs/TASKS.md +1741 -0
- dw/docs/TESTING.md +71 -0
- dw/docs/WORKFLOW_GUIDE.md +2038 -0
- dw/docs/WORKSPACES.md +316 -0
- dw/download_watch.py +335 -0
- dw/elision.py +306 -0
- dw/events.py +275 -0
- dw/for_each.py +409 -0
- dw/host_memory.py +258 -0
- dw/host_memory_projection.py +230 -0
- dw/hub_cache.py +432 -0
- dw/introspection.py +1228 -0
- dw/kernel_availability.py +208 -0
- dw/locations.py +599 -0
- dw/log_setup.py +45 -0
- dw/loudness.py +82 -0
- dw/media_audio.py +217 -0
- dw/media_frames.py +367 -0
- dw/media_info.py +297 -0
- dw/pipeline_processors/chain.py +821 -0
- dw/pipeline_processors/config_objects.py +237 -0
- dw/pipeline_processors/pipeline.py +2297 -0
- dw/pipeline_processors/remote.py +46 -0
- dw/plan.py +920 -0
- dw/previous_results.py +411 -0
- dw/probe_paths.py +59 -0
- dw/prompt_schema.json +48 -0
- dw/prompt_weighting.py +378 -0
- dw/prompts.py +159 -0
- dw/realize.py +250 -0
- dw/reference_limits.py +215 -0
- dw/reference_names.py +125 -0
- dw/repl.py +338 -0
- dw/repl_commands.py +836 -0
- dw/repl_worker.py +159 -0
- dw/result.py +1720 -0
- dw/result_fps.py +82 -0
- dw/run.py +162 -0
- dw/runs.py +768 -0
- dw/scalar_result_validation.py +97 -0
- dw/schema.py +283 -0
- dw/security.py +1038 -0
- dw/select_validation.py +115 -0
- dw/serve.py +277 -0
- dw/server/__init__.py +2 -0
- dw/server/app.py +4586 -0
- dw/server/assess.py +132 -0
- dw/server/catalog_shape.py +487 -0
- dw/server/enhancers.py +129 -0
- dw/server/exports.py +480 -0
- dw/server/guides.py +257 -0
- dw/server/jobs.py +1561 -0
- dw/server/mcp_mount.py +95 -0
- dw/server/netinfo.py +124 -0
- dw/server/observed_cost.py +379 -0
- dw/server/sysinfo.py +71 -0
- dw/server/ui/assets/abap-08VXUWAP.js +1 -0
- dw/server/ui/assets/apex-BWPQTe0t.js +1 -0
- dw/server/ui/assets/azcli-Bc_sGQ0U.js +1 -0
- dw/server/ui/assets/bat-i0X4ZdIN.js +1 -0
- dw/server/ui/assets/bicep-B5-_aFwp.js +2 -0
- dw/server/ui/assets/cameligo-DMUM7wLl.js +1 -0
- dw/server/ui/assets/clojure-Cm7r79vr.js +1 -0
- dw/server/ui/assets/codicon-Brq4_Ui5.ttf +0 -0
- dw/server/ui/assets/coffee-Ba7i2nA0.js +1 -0
- dw/server/ui/assets/cpp-C7h46wYY.js +1 -0
- dw/server/ui/assets/csharp-BKxtCVv1.js +1 -0
- dw/server/ui/assets/csp-bTuwJoIa.js +1 -0
- dw/server/ui/assets/css-DIMkf-bt.js +3 -0
- dw/server/ui/assets/css.worker-B3ciXF_0.js +93 -0
- dw/server/ui/assets/cssMode-CPznxfY8.js +1 -0
- dw/server/ui/assets/cypher-CVaqCwHa.js +1 -0
- dw/server/ui/assets/dart-onAF5SnQ.js +1 -0
- dw/server/ui/assets/dockerfile-DZFCIeNp.js +1 -0
- dw/server/ui/assets/ecl-D05T4iGw.js +1 -0
- dw/server/ui/assets/editor-jjEx9u7D.css +1 -0
- dw/server/ui/assets/editor.api-CpWcotrd.js +847 -0
- dw/server/ui/assets/editor.worker-q-txB4vs.js +30 -0
- dw/server/ui/assets/elixir-6RTg0lbw.js +1 -0
- dw/server/ui/assets/flow9-C5_-GSwl.js +1 -0
- dw/server/ui/assets/freemarker2-CXtRM8N4.js +3 -0
- dw/server/ui/assets/fsharp-C8Ef5oNN.js +1 -0
- dw/server/ui/assets/go-C-y9NEjX.js +1 -0
- dw/server/ui/assets/graphql-fmXr3nnJ.js +1 -0
- dw/server/ui/assets/handlebars-N7x-6NMY.js +1 -0
- dw/server/ui/assets/hcl-CpzslTdj.js +1 -0
- dw/server/ui/assets/html-PhsdjHSr.js +1 -0
- dw/server/ui/assets/html.worker-C93Ht9o9.js +506 -0
- dw/server/ui/assets/htmlMode-Dgj0SEok.js +1 -0
- dw/server/ui/assets/index-3Vw6WAPW.css +1 -0
- dw/server/ui/assets/index-DgrYhQd9.js +43 -0
- dw/server/ui/assets/ini-sBoK_t0W.js +1 -0
- dw/server/ui/assets/java-BEtHBSE6.js +1 -0
- dw/server/ui/assets/javascript-BJqN9Qhv.js +1 -0
- dw/server/ui/assets/json.worker-B2V3pomh.js +62 -0
- dw/server/ui/assets/jsonMode-DbM4SWSv.js +7 -0
- dw/server/ui/assets/julia-Bri6UV-V.js +1 -0
- dw/server/ui/assets/kotlin-BOotOW0E.js +1 -0
- dw/server/ui/assets/less-B9JPFI3C.js +2 -0
- dw/server/ui/assets/lexon-CfSJPG6W.js +1 -0
- dw/server/ui/assets/liquid-BWr8lEc4.js +1 -0
- dw/server/ui/assets/lspLanguageFeatures-C1iGuDyZ.js +4 -0
- dw/server/ui/assets/lua-CsQS60Ue.js +1 -0
- dw/server/ui/assets/m3-D-oSqn_W.js +1 -0
- dw/server/ui/assets/markdown-Cimd5fb3.js +1 -0
- dw/server/ui/assets/mdx-DAdMi_0p.js +1 -0
- dw/server/ui/assets/mips-CIPQ_RoX.js +1 -0
- dw/server/ui/assets/monaco--ixms01u.css +1 -0
- dw/server/ui/assets/monaco-BGCeEqaw.js +56 -0
- dw/server/ui/assets/msdax-DauUninz.js +1 -0
- dw/server/ui/assets/mysql-SOo6toE5.js +1 -0
- dw/server/ui/assets/objective-c-FvmIjYaQ.js +1 -0
- dw/server/ui/assets/pascal-DrH0SRf2.js +1 -0
- dw/server/ui/assets/pascaligo-D-ptJ9y-.js +1 -0
- dw/server/ui/assets/perl-oz_6vUea.js +1 -0
- dw/server/ui/assets/pgsql-DTj74zXo.js +1 -0
- dw/server/ui/assets/php-nr791fC2.js +1 -0
- dw/server/ui/assets/pla-CopQ2nXW.js +1 -0
- dw/server/ui/assets/postiats-43DmfD33.js +1 -0
- dw/server/ui/assets/powerquery-D3hlyOfw.js +1 -0
- dw/server/ui/assets/powershell-DmHpPYUd.js +1 -0
- dw/server/ui/assets/protobuf-C531GsRP.js +2 -0
- dw/server/ui/assets/pug-Z5eAx3Zn.js +1 -0
- dw/server/ui/assets/python-Bcn70HdC.js +1 -0
- dw/server/ui/assets/qsharp-DkqhCAOL.js +1 -0
- dw/server/ui/assets/r-BwWrilGY.js +1 -0
- dw/server/ui/assets/razor-D1HmNnby.js +1 -0
- dw/server/ui/assets/redis-ClamHrr6.js +1 -0
- dw/server/ui/assets/redshift-DT7zqm-g.js +1 -0
- dw/server/ui/assets/restructuredtext-BYgofb2h.js +1 -0
- dw/server/ui/assets/ruby-DezsRK8O.js +1 -0
- dw/server/ui/assets/rust-DdL9SqIa.js +1 -0
- dw/server/ui/assets/sb-CcwsVR0C.js +1 -0
- dw/server/ui/assets/scala-DHpiXF5c.js +1 -0
- dw/server/ui/assets/scheme-BeGwcela.js +1 -0
- dw/server/ui/assets/scss-gp-XZpBa.js +3 -0
- dw/server/ui/assets/shell-CC2rA5mh.js +1 -0
- dw/server/ui/assets/solidity-BEEn4gHE.js +1 -0
- dw/server/ui/assets/sophia-CRfGWb83.js +1 -0
- dw/server/ui/assets/sparql-D_Lu-MrJ.js +1 -0
- dw/server/ui/assets/sql-NEE52Syq.js +1 -0
- dw/server/ui/assets/st-DbInun42.js +1 -0
- dw/server/ui/assets/swift-Bxkupp3x.js +1 -0
- dw/server/ui/assets/systemverilog-Bz4Y3fRF.js +1 -0
- dw/server/ui/assets/tcl-DISqw1ZD.js +1 -0
- dw/server/ui/assets/ts.worker-D7T1-Ig5.js +67738 -0
- dw/server/ui/assets/tsMode-D6u0XmOW.js +11 -0
- dw/server/ui/assets/twig-De2hgUGE.js +1 -0
- dw/server/ui/assets/typescript-BU6v-LMV.js +1 -0
- dw/server/ui/assets/typespec-B8J7ngcE.js +1 -0
- dw/server/ui/assets/vb-DV3o63ZY.js +1 -0
- dw/server/ui/assets/wgsl-DpFanUEy.js +298 -0
- dw/server/ui/assets/workers-Cn7cTUKr.js +1 -0
- dw/server/ui/assets/xml--0LP2Lwk.js +1 -0
- dw/server/ui/assets/yaml-mpBg9jnt.js +1 -0
- dw/server/ui/index.html +17 -0
- dw/server/updater.py +192 -0
- dw/settings.py +98 -0
- dw/shot_span_preflight.py +116 -0
- dw/shots.py +359 -0
- dw/slice_preflight.py +148 -0
- dw/step.py +187 -0
- dw/step_cache.py +442 -0
- dw/subfolders.py +107 -0
- dw/task_domains.py +307 -0
- dw/tasks/assess.py +826 -0
- dw/tasks/audio_transcription.py +88 -0
- dw/tasks/audio_utils.py +1862 -0
- dw/tasks/background_remover.py +43 -0
- dw/tasks/borders.py +113 -0
- dw/tasks/compose_text.py +74 -0
- dw/tasks/concat_videos.py +300 -0
- dw/tasks/depth_estimator.py +54 -0
- dw/tasks/diffusion_upscale.py +109 -0
- dw/tasks/dissolve_videos.py +342 -0
- dw/tasks/format_messages.py +24 -0
- dw/tasks/gather.py +173 -0
- dw/tasks/grade.py +97 -0
- dw/tasks/image_to_text.py +43 -0
- dw/tasks/image_utils.py +764 -0
- dw/tasks/interpolate_frames.py +252 -0
- dw/tasks/judge.py +68 -0
- dw/tasks/model_cache.py +55 -0
- dw/tasks/pair_audio.py +268 -0
- dw/tasks/qr_code.py +19 -0
- dw/tasks/restore_faces.py +175 -0
- dw/tasks/rife_model.py +192 -0
- dw/tasks/segment.py +121 -0
- dw/tasks/select.py +111 -0
- dw/tasks/speech_generation.py +228 -0
- dw/tasks/stabilize.py +129 -0
- dw/tasks/task.py +920 -0
- dw/tasks/tensor_image.py +57 -0
- dw/tasks/text_generation.py +169 -0
- dw/tasks/text_sections.py +80 -0
- dw/tasks/upscale.py +203 -0
- dw/tasks/video_utils.py +624 -0
- dw/tasks/zoe_depth.py +71 -0
- dw/teacache.py +381 -0
- dw/teacache_models.json +99 -0
- dw/test.py +29 -0
- dw/type_helpers.py +231 -0
- dw/validate.py +68 -0
- dw/variable_constraints.py +444 -0
- dw/variables.py +443 -0
- dw/video_extensions.py +141 -0
- dw/vram_estimate.py +116 -0
- dw/worker.py +764 -0
- dw/workflow.py +2007 -0
- dw/workflow_schema.json +1346 -0
- dw/workflow_sources.py +383 -0
- dw/workflows/h3_context_ir.json +57 -0
- dw/workflows/test.json +31 -0
- dw/workspace.py +730 -0
- dw_mcp/__init__.py +6 -0
- dw_mcp/__main__.py +133 -0
- dw_mcp/assets.py +336 -0
- dw_mcp/authoring.py +114 -0
- dw_mcp/catalog.py +360 -0
- dw_mcp/client.py +486 -0
- dw_mcp/diagnose.py +371 -0
- dw_mcp/exports.py +84 -0
- dw_mcp/guides.py +35 -0
- dw_mcp/media.py +638 -0
- dw_mcp/models.py +97 -0
- dw_mcp/prompts.py +104 -0
- dw_mcp/server.py +1343 -0
- 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)
|