-
Notifications
You must be signed in to change notification settings - Fork 0
UserCode Surface Reduction Plan
Status: active — Phase 0 complete (0a/0b/0c); Phase 1 complete (1a/1b/1c); Phase 2 complete (2a/2b/2c; the original hard-delete 2d folded into Phase 3); Phase 3 complete (3a/3b/3c/3d) — both raw-C# calculators now run DSL-only, no Roslyn fallback anywhere; Phase 5a complete (widen equation translation + persist saved equations to DSL, backup-guarded); Phase 5b complete (statement blocks in the equation DSL); Phase 4 complete (ColorGen runs on the interpreter, no Roslyn on the theme render path). Phase 6 (CalcGen DSL parity, #215) outstanding. Tracking issues: #27 (umbrella) + per-phase children (see Tracking).
Three runtime surfaces accept user-authored text, compile it to a live .NET assembly via Roslyn, and load it in-process with full trust:
| Surface | Fractal / feature | Compile site | Input today |
|---|---|---|---|
| User Equation (2D) | UserEquation |
UserEquationCalculator.WrapUserSource |
raw C# |
| User Bulb (3D) |
UserBulb (Roslyn compiler) |
UserBulbCalculator.WrapUserSource{,Quat,Chain} |
raw C# |
| ColorGen theme | custom color themes | ColorGenHotLoad.TryCompileAndLoad |
validated DSL → emitted C# → Roslyn |
The two "raw C#" surfaces string-interpolate user text directly into a class
template and compile it with RoslynRefs.GatherAllTpaRefs() — every BCL
assembly referenced. A user body may contain any statement
(System.IO.File.Delete, Process.Start, P/Invoke, reflection). This is
arbitrary code execution in the host process.
ColorGen's DSL input is already validated (lexer restricts identifiers to
[A-Za-z_][A-Za-z0-9_]*; function names come from a fixed emitter switch), so
there is no injection path through it. Its residual concern is
architectural: it still round-trips through Roslyn codegen + a collectible
AssemblyLoadContext at runtime — a heavy, RCE-adjacent mechanism to keep on
the theme hot path.
- Mechanism: raw-C# paths are a textbook RCE primitive. No sandbox; full process privileges.
-
Network vector: already closed.
Server/Guard/FractalTypeAllowlist.csrefusesUserEquation,Sandbox,UserBulbover RPC. Keep it as defense-in-depth even after the surfaces are made safe. -
Residual real risk = untrusted content files. A shared region / preset /
scene JSON (or a
.csunder%LOCALAPPDATA%/FracturingFog/UserCalculators/auto-loaded at startup) can carry a hostileUserBulbSource/UserEquationSource. Opening the file triggers a lazy compile on render — no explicit "Compile" click required — so code runs on open. - Exposure today: low-to-medium (desktop app, content is user-supplied), rising if region / scene / theme sharing becomes a product feature.
-
2D:
SandboxExpression(Engine/Models/SandboxExpression.cs) — no-BCL interpreted DSL:+ - * / ^, comparisons, ternary,let..in, functionssin cos tan sinh cosh tanh exp log sqrt abs conj re im arg pow, constantsz c n pi e i. Lexer throws on any unknown identifier. Driven bySandboxCalculator(mirrorsUserEquationCalculator's pixel loop). -
3D:
SandboxBulbExpression+UserBulbSandboxEmitter— rich vec/quat DSL (vec qvec triplex length dot cross normalize rot boxfold spherefold mod smin clamp qmul qpow qexp…qcoth+ scalar math). Vec3 and Quat modes; CPU interpret and GPU emit. -
ColorGen:
CgProgramAST +ColorGenEmitter— rich color DSL (hsv hsl oklab oklch palette cosine brightness contrast gamma mix hash+ scalar math). Today it emits C# and compiles; it has no interpreter yet.
The reduction is therefore mostly make the safe path the only path + close DSL feature gaps so no existing artwork regresses.
Not every compile is untrusted. Define UserCodeOrigin:
- Interactive — user is in the editor and clicked Compile. Trusted.
-
BuiltIn — app-shipped preset from
UserBulbStore/ built-in themes. Trusted (ships in the binary; all current built-in bulb presets are Roslyn C#, so they must keep working). - ExternalFile — source arrived by loading a region / scene / preset / theme file, or the startup persisted-calculator scan. Untrusted.
Policy (UserCodeSecurityPolicy, env-overridable):
| Origin | Roslyn raw-C# compile | DSL interpret |
|---|---|---|
| Interactive | allow | allow |
| BuiltIn | allow | allow |
| ExternalFile | deny (default) → error, no execution | allow |
Env FF_ROSLYN_USERCODE = allow-all | trusted-only(default) | deny-all is the
global override / kill-switch. trusted-only closes the file-borne RCE while
leaving interactive editing and shipped presets untouched → no regression.
Each phase = one child issue, its own branch, one commit per sub-phase, one PR at phase completion. Ship in order.
-
0a
UserCodeSecurityPolicy+UserCodeOrigin+ single chokepointUserCodeGate.EnsureRoslynAllowed(origin). Both raw-C# calculators route their Roslyn compile through it. Defaulttrusted-only; calculators default toInteractiveorigin (no behavior change yet). Unit tests for the policy matrix. Commit. -
0b Thread
UserCodeOrigin.ExternalFilefrom the region / scene / preset load paths and the startup persisted-.csscan into the compile calls. Add a test that a hostile external source is refused (no assembly emitted) under the default policy. Commit. -
0c User-facing surfacing: when a compile is denied,
LastErrorexplains the block and points at the DSL. Yellow (#FFCC00) advisory in the editor — not red (red/green colorblind users). Commit. Done: the deny reason flowscalc.LastError → CompileUser{Equation,Bulb} → vm.ShowError, rendered by the editor's existingClasses.error→#FFCC00status style (never red). Contract pinned by a test.
-
1a ✅ Closed the
SandboxExpressionmath gap vsComplex/Math: addedasin acos atan asinh acosh atanh(real inside the principal real domain, complex continuation outside — the inverse-hyperbolic complex branches use log/sqrt identities since BCLComplexlacks them), per-componentfloor sign, real-valuedatan2 min max clamp, and centered per-componentmod(matchesVec3.Modso 2D and 3D share one meaning). 28 unit tests. -
1b ✅
UserEquationCalculatoris DSL-first:EquationPreprocessor(reused via a new Engine →CalculatorGen.Libreference) translates the C#Complex.*source; when it translates + parses it runs onSandboxExpression(no Roslyn, no BCL, no assembly load). Roslyn stays only as a fallback for sources with no DSL form, still gated by origin — so an untrusted equation the DSL can express now executes safely instead of being refused, and trusted editing never regresses. Untranslatable sources surface a crisp error and stay editable.UsingDslexposes which path ran. Interim note: translatable equations now run interpreted rather than JIT-native (a per-step slowdown), accepted as the direction toward Phase 3. -
1c ✅ Parity harness (
UserEquationDslParityTests): a 16-equation corpus proves the translated DSL step equals the identical C# expression evaluated natively (= the old Roslyn semantics) within relative 1e-10 over a (z, c, n) grid; a render-level check confirmsUserEquationCalculator(DSL) andSandboxCalculatoremit bitwise-identical pixel buffers.
-
2a ✅ Gap-audited
SandboxBulbExpressionvs every built-in preset idiom: all math has a DSL form (new Vec3→vec,Vec3.Fn/Math.Fn→lowercase builtins,.X→.x,^/triplex/boxfold/spherefold/rot/abs*present); imperativevar/ifmap tolet..in/ternary. The one gap was comments — added//and/* */skipping.SandboxBulbDslAuditTestsmakes it executable (DSL == nativeVec3math over a grid). -
2b ✅ Migrated all built-in
UserBulbStorepresets and chain primitives from raw C# to DSL and pinned each toCompiler = Sandboxin itsSettingssnapshot.MigrateBuiltinsToDsl()upgrades a pre-2buserbulbs.jsononly when the stored source still exactly matches the shipped C# (or the new DSL awaiting a pin), so a user's own edit is preserved (read-only built-in contract). ("Cosh × Sin bulb" usedVec3*Vec3— no operator, never compiled under Roslyn — the DSL Hadamard repairs it.) -
2c ✅ Flipped
FractalParameters.UserBulbCompilerdefault →Sandboxand madeUserBulbCalculator.CompileDSL-first with a trusted Roslyn fallback: a DSL parse failure falls through to the gated Roslyn path only for a trusted origin and only when the body looks like C#; a DSL typo keeps its DSL error; untrusted C# is refused with the gate's block notice. So no user-authored C# bulb breaks, and the file-borne surface is not widened. -
2d(original hard-delete) → folded into Phase 3. Per the "keep trusted fallback" decision,WrapUserSource{,Quat,Chain}stays as the trusted-origin fallback and is deleted alongside the UserEquation raw path in Phase 3, not here.
Covered both raw-C# calculators (UserEquation from Phase 1, UserBulb from Phase 2 — both had kept a trusted-origin Roslyn fallback until here).
-
3a ✅ Deleted
UserEquationCalculator's raw Roslyn path (WrapUserSource, theCSharpCompilation/AssemblyLoadContextbranch, the_compileddelegate + pinned context, the origin gate). The type runs onSandboxExpressiononly; a source with no DSL form surfaces an editable error pointing at the DSL instead of executing. -
3b ✅ Deleted
UserBulbCalculator'sWrapUserSource{,Quat,Chain}+ the full-BCL Roslyn branch + the Phase-2c trusted fallback (LooksLikeCSharp/ChainSourceTextprobes) + the origin gate + theGatherRefshelper. The type runs onSandboxBulbExpression/SandboxBulbChainonly; the persistedUserBulbCompilerselector is ignored and the now-dead "Roslyn (full C#)" editor dropdown was retired (view-model pins Sandbox). AddedUserBulbDslRenderParityTests(the old 2d harness): every seeded built-in compiles on the interpreter, a representative DSL bulb renders deterministically + non-blank end-to-end, and a raw-C# body no longer compiles. (Per-step DSL-vs-native math parity stays covered bySandboxBulbDslAuditTests.) -
3c ✅ Audited the remaining runtime Roslyn sites (
CalculatorGenHotLoad,ColorGenHotLoad, and the Sandbox-GPU emitters): each compiles only the source its generator emits, andCalculatorGenApi.Generate/ColorGenApi.Generategate that onEquationParser/ColorGenParser— restricted grammars that reject any construct outside the DSL. Raw user text embedded in the generated file (anEQUATION_SOURCEtoken; a per-line//comment block, never a/* */block) can't break out because the parse refuses it first.NoRawUserTextReachesRoslynTestsasserts 12 injection-shaped inputs are refused by both generators while benign DSL still generates. -
3d ✅ Kept
FractalTypeAllowlistas defense-in-depth (still blocks UserEquation / Sandbox / UserBulb over RPC — they run user step math + open iteration budgets even though the RCE primitive is gone). Full regression sweep green (Server.Tests 867/867), solution builds clean.
Follow-up to Phase 3: after the raw-C# path was removed, a saved raw-C# equation with no DSL form stopped running. This widens the translation surface and persists translatable saved equations as DSL so shipped/user content keeps working. Issue #209 (PR #210, stacked on #208).
-
5a-1 ✅ Extended
EquationPreprocessorto translate the C#Complexmember accessors it used to reject (they had DSL equivalents; only the syntax rewrite was missing):x.Real → re(x),x.Imaginary → im(x),x.Phase → arg(x),x.Magnitude → sqrt(x*conj(x)). Magnitude deliberately avoidsabs, whose meaning differs between the CalcGen DSL (|x|²) and theSandboxExpressionruntime (|x|);x*conj(x)=|x|² in both and sqrt of that is |x| (the parity harness — which evaluates viaSandboxExpression— caught the divergence). Operand extraction covers identifier / paren-group / call-result.UserEquationDslParityTestsgains a member-access corpus (DSL == nativeComplexwithin 1e-10). Near-misses also closed:Complex.Divide(a,b)→((a)/(b))(preprocessor);SandboxExpressionnow resolves constants case-insensitively (E/PI), skips//and/* */comments, and tolerates a single trailing;— so saved equations using those forms translate. Bare Math statics (Sin/Pow/…) already worked (the call parser lowercases). -
5a-2 ✅
UserDataBackup.SnapshotBeforeMigration— a timestamped<name>.<stamp>.<reason>.baksnapshot taken before a store rewrites a user JSON in place, distinct fromAtomicFile's rolling.bak. Retrofitted the existingUserBulbStorebuilt-in migration to snapshot first. -
5a-3 ✅ On startup (after
UserEquationStore.Load(), viaAvaloniaShellBootstrap),UserEquationDslMigration.Runconverts translatableKind=UserEquationentries to DSL text +Kind=Dsl— same translate-then-validate (EquationPreprocessor→SandboxExpression.Parse) the live calculator runs.UserEquationStore.MigrateUserEquationsToDsl(translate)owns the file + backup + save (translation injected because the store is UI-free); idempotent; untranslatable entries left editable. The migration lives in Engine (needs both the preprocessor and the interpreter, which Abstractions doesn't reference).
The saved equations still erroring after 5a were C# statement blocks
(var/if/multi-statement/return). The DSL already had let..in (= var)
and ternary (= if), so this was a front-end parser extension — not a
capability or security change (still pure, no BCL, no loops, no braces).
Shipped in SandboxExpression's parser (ParseBlock + BindBlock +
TryParseAssignment, desugaring to the existing let/ternary AST):
-
TYPE? ident = expr ;→let ident = expr in <rest>(TYPE ∈ var/Complex/double/int/float/long/decimal, discarded — dynamically typed). -
ident = expr ;(reassignment, incl.z = …) → shadowinglet; the RHS sees the prior binding, the body sees the new one (sequential semantics). -
if (cond) ident = expr ;→let ident = (cond ? expr : ident) in <rest>(the seed form; the assigned name must already be bound). -
if (cond) return expr ;→cond ? expr : <rest-of-block>(early-guard). -
return expr ;/ bare trailing expr → the block's value.
ParsePrimary now resolves scope before the pi/e/i constants so a
block-local of that spelling shadows the constant (C# scoping). Translator
side (EquationPreprocessor): statement tokens pass through untouched (no
Complex. prefix); added Math.Abs → abs (Burning Ship's new Complex(|Re|, |Im|) fold) and Math.PI/Math.E → pi/e. The startup migration converts these
blocks with no wiring change (translate-then-parse now succeeds). Tests:
SandboxExpressionStmtBlockTests (parser) + StmtBlockCorpus in
UserEquationDslParityTests (native-C# parity over the classic
Newton/Nova/Tricorn/Burning-Ship/if-seed/if-return corpus) +
Migration_ConvertsStatementBlockEquation_AndItRenders.
One hard case still out of scope: Phoenix-style maps need the previous z
carried between iterations, which the (z, c, n) step signature can't supply
(needs a prev slot — tracked separately, not part of the parser work).
No C#→DSL translator exists for Vec3/Quat bulb bodies (Phase 2b only swapped
built-in strings). A bulb analogue of EquationPreprocessor + a backup-guarded
startup migration would give saved user bulbs the same DSL conversion equations
got. Deferred; soft-depends on Phase 5b (shared statement-block support).
Goal: the ColorGen DSL runs with no Roslyn codegen and no assembly load at
runtime. Branch feat/usercode-phase4-204 off main.
-
4a ✅ DSL richness audit: the ColorGen DSL already covers every rich-theme
idiom — multi-stop
palette,hsv/hsl/oklab/oklch, IQcosine,brightness/contrast/gamma,mix/mix_oklab, the full scalar-math set, and all 15 render inputs (smooth dist iter maxIter t nx ny zr zi dzr dzi arg mag isInSet pxScale) + constantspi tau e phi. No missing builtins; the audit reduces to "the interpreter must cover everyColorGenEmittercase", which the parity harness enforces. -
4b ✅
InterpretedColorMap(Engine) parses a source to aCgProgramonce and walks the typed AST per pixel over a scalar/CgRgbvalue union, mirroringColorGenEmitter's per-node C# exactly, against a compiledCgRgb/CgMathruntime (Engine/Models/ColorGenRuntime.cs) ported verbatim from the template's nestedCg3/CgScalar. Pure, noMathbeyond the template's, no codegen, noAssemblyLoadContext. Let-bindings use per-thread slot scratch (no per-pixel alloc). Engine gains aColorGen.LibProjectReference (leaf; mirrorsCalculatorGen.Lib; no cycle). -
4c ✅ The interactive ColorGen editor's "Compile & Load"
(
AvaloniaShellBootstrap.OpenColorGenEditor→HotLoadRequested) now callsInterpretedColorMap.TryCreateinstead ofColorGenHotLoad.TryCompileAndLoad— no Roslyn on the theme runtime path.InterpretedColorMapstill implementsIGpuHlslPalette(HLSL viaColorGenHlslEmitter— text, not Roslyn) so GPU SP palettes are unaffected, andIColorMapHandlesInSetsoiter/isInSetkeep their meaning.ColorGenApi.Generatestays for the "Generate to file" export (writes.csfor a future build — off the render path);ColorGenHotLoadis retired from the hot path (kept for the parity test + the export/CLI). -
4d ✅
ColorGenInterpreterParityTests: a 10-theme corpus spanning the whole DSL surface is both Roslyn-compiled and interpreted; over a grid of sample inputs (exterior + in-set, tilted normals, non-trivial final state) the twoMap()results are bit-identical. Suite 916/916.
Goal: bring the CalcGen codegen path ("Compile & Load" → generate typed C# → Roslyn) to functional + semantic parity with the live SandboxExpression interpreter on the shared feature set, at shallow zoom (deep-zoom SIMD / SA / perturbation / DD-QD divergence is out of scope by design).
Tranche 1 — real-lift function set (SHIPPED, branch feat/usercode-phase6-215):
Added the expression-position functions re(x), im(x), abs(x), and
clamp(x, lo, hi) to CalcGen (lexer → parser → AST nodes ReOp/ImOp/AbsOp/
Clamp → EmitterBase + all five direct emitters: Scalar / AVX2 / DdDirect /
QdDirect / Qd). These are non-holomorphic real-lifts, wired exactly like the
existing arg/min/max/mod: they gate off SA + perturbation (hasTrans) and
analytic DE (hasArg), so the generated calculator runs the direct scalar/AVX/
DD/QD path where shallow-zoom parity holds. Semantic reconciliation: the
expression abs(x) is |x| (matches Complex.Abs + the SandboxExpression
runtime) — distinct from the pre-existing condition-only abs shorthand
(CondAbs2 = |x|²), which is a different grammar position (inside if) and
unchanged. Defensive cases added to AstDifferentiator (unconditional
derivUpdate build) and AstPrinter (unconditional source echo); AstHelpers. Contains recurses into the new nodes. Parity harness
CalcGenSandboxParityTests: for a corpus using the new functions, the CalcGen
generated calculator compiles with no CS errors and its in-set escape mask
matches SandboxCalculator's at shallow zoom (pipeline-independent — isolates the
maths from the two renderers' differing smooth/colour code).
Tranche 2 — general pow / inverse trig / per-component (SHIPPED, branch
feat/usercode-phase6b-215):
-
General
pow(base, exp)— arbitrary complex exponent, matching the SandboxExpression runtime exactly: both-real operands →Math.Pow(sopow(-2, 3) = -8), elseSystem.Numerics.Complex.Pow(zero-guarded principal branch,pow(0, 0) = 1,pow(0, k) = 0). NewPowCnode, distinct from the integer-only^operator (Pow). Unblocks negative/fractional-power maps (Donut Star, Movie Reel) via Compile & Load — the zero guard removes the0·(1/0)blank the old1/(z)^ndesugar produced at the z = 0 seed. -
Inverse trig
asin acos atan asinh acosh atanh— emitted through the SAME BCL calls (Complex.Asin/Acos/Atan) and log-formula continuations (asinh/acosh/atanh) the interpreter uses, so the complex branch is bit-identical; the real-domain fast path differs only by an escape-mask- invisible amount. Uniform-complex form reuses theOpSin/ScalarComplex/AVX per-lane machinery. DE disabled (no analytic rules implemented) → gated withhasTrans+ the DE gate. -
Per-component
floor round ceil trunc fract sign— real functions applied to Re and Im independently ((F(re), F(im)), ImZero-preserving), like the existingfold. AVX2 uses the native round intrinsics (Avx.Floor/Ceiling/ RoundToNearestInteger/RoundToZero— round-to-even / toward-zero matchMath.Round/Math.Truncate);signper-lane scalarises. DD/QD degrade to double on the high limb.
All three families fold into hasTrans + the DE gate (perturbation / SA /
analytic DE off) and render on the direct scalar / AVX2 / DD / QD paths at shallow
zoom. CalcGenSandboxParityTests grew 9 corpus entries (3 per family; escape-mask
parity ≥ 98%).
Analytic DE for inverse trig — SHIPPED (branch
feat/usercode-invtrig-de-215, Closes #215). The inverse trig / hyperbolic functions were later lifted OUT of the DE gate:AstDifferentiatornow carries their first-order dz/dc chain rules —∂asin(u)/∂v = u'/√(1−u²),∂acos = −u'/√(1−u²),∂atan = u'/(1+u²),∂asinh = u'/√(u²+1),∂acosh = u'/√(u²−1),∂atanh = u'/(1−u²)— soSupportsDestays ON and surface normals / exterior distance estimate work for these maps on the shallow direct path. They still gate perturbation / SA off viahasTrans(transcendental, no closed-form δ-Taylor). The √ radicand lowers through a new INTERNALSqrtAST node (nosqrtsurface syntax — produced only by the differentiator), emitted via the full complexComplex.Sqrtso a negative real radicand still yields the correct imaginary result. Only the emitters that walk a derivative AST lower it (direct Scalar / AVX2 + the three perturbation-deriv emitters); the DD / QD z-update emitters never see it. Tests: symbolic rules inCalculatorGenUnitTests(8 cases incl. chain rule) +CalcGenInverseTrigDeTests(Preview flag state, Roslyn compile of all six, and az*z + asin(c)mixed-render whose ∂p/∂c carries the √ term).
#213 emitter bug (FIXED, same branch — Closes #213): the perturbation
c-broadcast locals (Cr_v/Ci_v for AVX, Cr/Ci in the scalar rebase path)
were declared inside the generated derivative block, but the sibling δ
block also binds CRef → those names. Any perturbation-eligible c-COEFFICIENT map
(δ carries c, e.g. c·z²) referenced undeclared locals → CS0103, failing the
whole Roslyn compile. Fixed by hoisting the declarations to the shared
per-iteration scope (AVX-2 + AVX-512 template blocks and scalar
TryIterateRebasePixel). Bug 2 (negative-power blank) is resolved by the new
zero-guarded pow(). Regression tests in CalcGenHotLoad213RegressionTests.
Remaining: analytic DE for the inverse trig is now DONE (see the callout
above — Closes #215). Any further SandboxExpression functions not yet mirrored
would be new tranches under fresh issues.
Every phase that retires a Roslyn path first ships the DSL features that path
depended on, then proves equivalence with a render-level parity harness gated in
CI. Existing self-tests (UserBulbSelfTest.cs exercises Sandbox vs Roslyn
per-mode) fold into those harnesses. No equation, bulb, or theme regresses
because the safe path replaces the unsafe one only after parity is demonstrated.
- Umbrella: #27.
- Children: one issue per phase (0–4). Each PR body carries an explicit
Closes #<n>line per the repo's auto-close convention (one number per line; no ranges).