From fe62cc12725c339234299d366b1aa93084050123 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 21 Feb 2026 22:26:14 +0000 Subject: [PATCH 1/6] Initial plan From 3436694812460df1e68359669adfa106d7fcbff3 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 21 Feb 2026 22:39:19 +0000 Subject: [PATCH 2/6] Fix: backslash before {{ treated as literal; change escape to {{{{ Co-authored-by: blindzero <13959569+blindzero@users.noreply.github.com> --- .../Private/Resolve-IdleTemplateString.ps1 | 43 ++++++++++++------- .../Resolve-IdleWorkflowTemplates.Tests.ps1 | 14 ++++++ .../template-tests/template-backslash.psd1 | 13 ++++++ .../template-escaped-mixed.psd1 | 2 +- .../template-tests/template-escaped.psd1 | 2 +- 5 files changed, 56 insertions(+), 18 deletions(-) create mode 100644 tests/fixtures/workflows/template-tests/template-backslash.psd1 diff --git a/src/IdLE.Core/Private/Resolve-IdleTemplateString.ps1 b/src/IdLE.Core/Private/Resolve-IdleTemplateString.ps1 index 93a6e6c2..7e905ea3 100644 --- a/src/IdLE.Core/Private/Resolve-IdleTemplateString.ps1 +++ b/src/IdLE.Core/Private/Resolve-IdleTemplateString.ps1 @@ -22,7 +22,7 @@ function Resolve-IdleTemplateString { - Request.Actor Escaping: - - \{{ → literal {{ (escape removed after resolution) + - {{{{ → literal {{ (four consecutive opening braces produce two literal opening braces) .PARAMETER Value The string value to resolve. If not a string, returns the value unchanged. @@ -64,10 +64,6 @@ function Resolve-IdleTemplateString { # Quick exit: no template markers present if ($stringValue -notlike '*{{*' -and $stringValue -notlike '*}}*') { - # Handle escaped braces with no actual templates - if ($stringValue -like '*\{{*') { - return $stringValue -replace '\\{{', '{{' - } return $stringValue } @@ -201,36 +197,51 @@ function Resolve-IdleTemplateString { } } + # Escape sequence normalization: {{{{ → literal {{ in output. + # Use a Unicode Private Use Area character as placeholder so template matching does not see the + # escaped braces. This character is extremely unlikely to appear in real workflow configuration. + # Backslash is NOT an escape character; use {{{{ for a literal {{ in the result. + $litOpenPlaceholder = [string][char]0xE001 + $normalizedValue = if ($stringValue -like '*{{{{*') { + $stringValue.Replace('{{{{', $litOpenPlaceholder) + } else { + $stringValue + } + # Check if this is a pure placeholder (no prefix/suffix text, single placeholder) # If so, we can preserve the type instead of coercing to string $purePattern = '^\s*\{\{([^}]+)\}\}\s*$' - $pureMatch = [regex]::Match($stringValue, $purePattern) + $pureMatch = [regex]::Match($normalizedValue, $purePattern) $isPurePlaceholder = $pureMatch.Success # Check for unbalanced braces (typo safety) # Skip this validation for pure placeholders as we already validated them if (-not $isPurePlaceholder) { - # Count non-escaped opening braces - $openCount = ([regex]::Matches($stringValue, '(? Date: Sat, 21 Feb 2026 22:51:53 +0000 Subject: [PATCH 3/6] Fix: restore \{{ escape syntax; \{{ before a template path resolves normally Co-authored-by: blindzero <13959569+blindzero@users.noreply.github.com> --- .../Private/Resolve-IdleTemplateString.ps1 | 20 +++++++++++-------- .../template-escaped-mixed.psd1 | 2 +- .../template-tests/template-escaped.psd1 | 2 +- 3 files changed, 14 insertions(+), 10 deletions(-) diff --git a/src/IdLE.Core/Private/Resolve-IdleTemplateString.ps1 b/src/IdLE.Core/Private/Resolve-IdleTemplateString.ps1 index 7e905ea3..dc109e02 100644 --- a/src/IdLE.Core/Private/Resolve-IdleTemplateString.ps1 +++ b/src/IdLE.Core/Private/Resolve-IdleTemplateString.ps1 @@ -22,7 +22,9 @@ function Resolve-IdleTemplateString { - Request.Actor Escaping: - - {{{{ → literal {{ (four consecutive opening braces produce two literal opening braces) + - \{{ → literal {{ (backslash escapes the opening braces when not followed by a template path) + - \{{path}} treats \ as a literal character and resolves the template normally + (e.g. DOMAIN\{{Request.IdentityKeys.sAMAccountName}} → DOMAIN\) .PARAMETER Value The string value to resolve. If not a string, returns the value unchanged. @@ -197,13 +199,15 @@ function Resolve-IdleTemplateString { } } - # Escape sequence normalization: {{{{ → literal {{ in output. + # Escape sequence normalization: \{{ (not followed by a valid template path+}}) → literal {{. # Use a Unicode Private Use Area character as placeholder so template matching does not see the # escaped braces. This character is extremely unlikely to appear in real workflow configuration. - # Backslash is NOT an escape character; use {{{{ for a literal {{ in the result. + # When \{{ is immediately followed by a valid path and }} (e.g. DOMAIN\{{Request.Input.Name}}), + # the backslash is treated as a literal character and the template is resolved normally. $litOpenPlaceholder = [string][char]0xE001 - $normalizedValue = if ($stringValue -like '*{{{{*') { - $stringValue.Replace('{{{{', $litOpenPlaceholder) + $backslashEscapePattern = '\\{{(?![A-Za-z][A-Za-z0-9_.]*}})' + $normalizedValue = if ($stringValue -match '\\{{') { + [regex]::Replace($stringValue, $backslashEscapePattern, $litOpenPlaceholder) } else { $stringValue } @@ -217,8 +221,8 @@ function Resolve-IdleTemplateString { # Check for unbalanced braces (typo safety) # Skip this validation for pure placeholders as we already validated them if (-not $isPurePlaceholder) { - # Count opening and closing braces on the normalized string so that {{{{ escape sequences - # are correctly excluded from the counts (each {{{{ is replaced by the placeholder). + # Count opening and closing braces on the normalized string so that \{{ escape sequences + # are correctly excluded from the counts (each \{{ escape is replaced by the placeholder). $openCount = ([regex]::Matches($normalizedValue, '\{\{')).Count # For closing braces, only count those that belong to templates (have a corresponding opening) # We can do this by counting matches of the full template pattern @@ -287,7 +291,7 @@ function Resolve-IdleTemplateString { $result = $result.Replace($placeholder, $stringReplacement) } - # Post-process: restore {{{{ escape sequences → literal {{ + # Post-process: restore \{{ escape sequences → literal {{ $result = $result.Replace($litOpenPlaceholder, '{{') return $result diff --git a/tests/fixtures/workflows/template-tests/template-escaped-mixed.psd1 b/tests/fixtures/workflows/template-tests/template-escaped-mixed.psd1 index afed1182..9c183fbf 100644 --- a/tests/fixtures/workflows/template-tests/template-escaped-mixed.psd1 +++ b/tests/fixtures/workflows/template-tests/template-escaped-mixed.psd1 @@ -6,7 +6,7 @@ Name = 'TestStep' Type = 'IdLE.Step.Test' With = @{ - Value = 'Literal {{{{ and template {{Request.Input.Name}}' + Value = 'Literal \{{ and template {{Request.Input.Name}}' } } ) diff --git a/tests/fixtures/workflows/template-tests/template-escaped.psd1 b/tests/fixtures/workflows/template-tests/template-escaped.psd1 index d4f27b71..ceca8fd5 100644 --- a/tests/fixtures/workflows/template-tests/template-escaped.psd1 +++ b/tests/fixtures/workflows/template-tests/template-escaped.psd1 @@ -6,7 +6,7 @@ Name = 'TestStep' Type = 'IdLE.Step.Test' With = @{ - Value = 'Literal {{{{ braces here' + Value = 'Literal \{{ braces here' } } ) From aa719aff7d4fefed6d9023b329408399d7630ecd Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 21 Feb 2026 22:56:51 +0000 Subject: [PATCH 4/6] docs: add template substitution reference page and link from workflows.md Co-authored-by: blindzero <13959569+blindzero@users.noreply.github.com> --- docs/reference/intro-reference.md | 1 + docs/reference/specs/template-substitution.md | 128 ++++++++++++++++++ docs/use/workflows.md | 18 +++ 3 files changed, 147 insertions(+) create mode 100644 docs/reference/specs/template-substitution.md diff --git a/docs/reference/intro-reference.md b/docs/reference/intro-reference.md index 8f1ecd23..45f7ea8e 100644 --- a/docs/reference/intro-reference.md +++ b/docs/reference/intro-reference.md @@ -23,3 +23,4 @@ Content here is not tutorial-style and usually assumes prior knowledge from the - [Capability reference](capabilities.md) - Specifications (schemas, formats, contracts) - [Plan Export JSON Schema](./specs/plan-export.md) + - [Template Substitution](./specs/template-substitution.md) diff --git a/docs/reference/specs/template-substitution.md b/docs/reference/specs/template-substitution.md new file mode 100644 index 00000000..dd1a3f82 --- /dev/null +++ b/docs/reference/specs/template-substitution.md @@ -0,0 +1,128 @@ +--- +title: Template Substitution +sidebar_label: Template Substitution +--- + +# Template Substitution + +IdLE supports **template placeholders** in workflow step configuration (`With.*` values). +Placeholders are resolved during plan build (`New-IdlePlan`) before any step runs. + +--- + +## Syntax + +A placeholder is written as `{{path}}`, where `path` is a dot-separated property path into the +current lifecycle request: + +```powershell +IdentityKey = '{{Request.IdentityKeys.sAMAccountName}}' +DisplayName = '{{Request.DesiredState.GivenName}}' +Message = 'User {{Request.DesiredState.DisplayName}} is joining.' +``` + +Multiple placeholders may appear in a single string value. + +--- + +## Allowed roots + +For security, only the following path roots are permitted: + +| Root | Description | +| ---- | ----------- | +| `Request.DesiredState.*` | Intended target state of the identity | +| `Request.IdentityKeys.*` | Identifiers of the target identity | +| `Request.Changes.*` | Explicit deltas (Mover events) | +| `Request.LifecycleEvent` | Lifecycle event type (e.g. `Joiner`) | +| `Request.CorrelationId` | Stable correlation identifier | +| `Request.Actor` | Originator of the request | +| `Request.Input.*` | Alias for `Request.DesiredState.*` when no `Input` property exists | + +Accessing any other root (e.g. `Plan.*`, `Providers.*`) throws a **security error** during plan build. + +--- + +## Pure vs. mixed placeholders + +### Pure placeholder + +A value that contains **only** a single placeholder (no surrounding text) preserves the resolved +type (bool, int, datetime, guid, string): + +```powershell +# Resolves to the actual [bool] value — not the string "True" +Enabled = '{{Request.DesiredState.IsEnabled}}' +``` + +### Mixed placeholder (string interpolation) + +A value that contains text alongside one or more placeholders always produces a **string**: + +```powershell +# Always a string result +Message = 'Account for {{Request.DesiredState.DisplayName}} created.' +``` + +--- + +## Backslash and special characters + +Backslash (`\`) is a **literal character** in template strings and has no special meaning. +This allows Windows-style paths and domain-qualified names without any extra escaping: + +```powershell +# \ is kept as-is; only the placeholder is substituted +IdentityKey = 'DOMAIN\{{Request.IdentityKeys.sAMAccountName}}' +# → e.g. 'DOMAIN\jdoe' +``` + +--- + +## Escaping a literal `{{` + +To include a literal `{{` in the output (not treated as a placeholder), prefix with a backslash +**and** ensure no valid template path follows the opening braces: + +```powershell +# \{{ not followed by a valid path+}} → literal {{ in output +Value = 'Literal \{{ braces here' +# → 'Literal {{ braces here' +``` + +The escape is only applied when `\{{` is **not** immediately followed by a valid path identifier +and closing `}}`. This means the following works without any special escaping: + +```powershell +# \ before {{ followed by a valid path — treated as literal \ + resolved template +IdentityKey = 'DOMAIN\{{Request.IdentityKeys.sAMAccountName}}' +# → 'DOMAIN\jdoe' (not an escape; \ stays, template resolves) +``` + +Summary: + +| Input | Result | +| ----- | ------ | +| `DOMAIN\{{Request.IdentityKeys.sAMAccountName}}` | `DOMAIN\jdoe` — `\` literal, template resolved | +| `Literal \{{ braces here` | `Literal {{ braces here` — `\{{` escaped (no valid path follows) | +| `Literal \{{ and template {{Request.Input.Name}}` | `Literal {{ and template TestName` — escape + template | + +--- + +## Validation + +During plan build, IdLE validates every template value: + +- **Unbalanced braces** — mismatched `{{`/`}}` pairs throw a syntax error. +- **Invalid path pattern** — paths must use dot-separated identifiers (letters, numbers, underscores). Spaces and special characters are not allowed. +- **Disallowed root** — paths outside the allowlist throw a security error. +- **Null or missing value** — if the resolved value is `null` or the path does not exist, an error is thrown. Ensure the request contains all required values before building the plan. +- **Non-scalar value** — resolving to a hashtable or array is not allowed. Use a scalar property path or flatten the data before creating the request. + +--- + +## See also + +- [Walkthrough 1 — Workflow definition](../../use/walkthrough/01-workflow-definition.md) +- [Walkthrough 3 — Plan build](../../use/walkthrough/03-plan-creation.md) +- [Security](../../about/security.md) diff --git a/docs/use/workflows.md b/docs/use/workflows.md index e53ef74a..f736f818 100644 --- a/docs/use/workflows.md +++ b/docs/use/workflows.md @@ -67,6 +67,24 @@ Start with [Quick Start](quickstart.md). --- +## Template substitution + +Step configuration values (`With.*`) support `{{path}}` placeholders that are resolved against the +request during plan build. For example: + +```powershell +IdentityKey = '{{Request.IdentityKeys.sAMAccountName}}' +Message = 'User {{Request.DesiredState.DisplayName}} is joining.' + +# Backslash is a literal character — domain paths work without extra escaping: +IdentityKey = 'DOMAIN\{{Request.IdentityKeys.sAMAccountName}}' +``` + +See [Reference: Template Substitution](../reference/specs/template-substitution.md) for the full +syntax, allowed roots, escaping rules, and validation behaviour. + +--- + ## Reference For full definitions and reference, see: From e5e28fc06c24f6327ab3d525bf0c73c3b9d7fd01 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 21 Feb 2026 23:11:22 +0000 Subject: [PATCH 5/6] docs: consolidate template substitution docs into workflows.md; remove standalone spec file Co-authored-by: blindzero <13959569+blindzero@users.noreply.github.com> --- docs/reference/intro-reference.md | 1 - docs/reference/specs/template-substitution.md | 128 ------------------ docs/use/workflows.md | 72 +++++++++- 3 files changed, 68 insertions(+), 133 deletions(-) delete mode 100644 docs/reference/specs/template-substitution.md diff --git a/docs/reference/intro-reference.md b/docs/reference/intro-reference.md index 45f7ea8e..8f1ecd23 100644 --- a/docs/reference/intro-reference.md +++ b/docs/reference/intro-reference.md @@ -23,4 +23,3 @@ Content here is not tutorial-style and usually assumes prior knowledge from the - [Capability reference](capabilities.md) - Specifications (schemas, formats, contracts) - [Plan Export JSON Schema](./specs/plan-export.md) - - [Template Substitution](./specs/template-substitution.md) diff --git a/docs/reference/specs/template-substitution.md b/docs/reference/specs/template-substitution.md deleted file mode 100644 index dd1a3f82..00000000 --- a/docs/reference/specs/template-substitution.md +++ /dev/null @@ -1,128 +0,0 @@ ---- -title: Template Substitution -sidebar_label: Template Substitution ---- - -# Template Substitution - -IdLE supports **template placeholders** in workflow step configuration (`With.*` values). -Placeholders are resolved during plan build (`New-IdlePlan`) before any step runs. - ---- - -## Syntax - -A placeholder is written as `{{path}}`, where `path` is a dot-separated property path into the -current lifecycle request: - -```powershell -IdentityKey = '{{Request.IdentityKeys.sAMAccountName}}' -DisplayName = '{{Request.DesiredState.GivenName}}' -Message = 'User {{Request.DesiredState.DisplayName}} is joining.' -``` - -Multiple placeholders may appear in a single string value. - ---- - -## Allowed roots - -For security, only the following path roots are permitted: - -| Root | Description | -| ---- | ----------- | -| `Request.DesiredState.*` | Intended target state of the identity | -| `Request.IdentityKeys.*` | Identifiers of the target identity | -| `Request.Changes.*` | Explicit deltas (Mover events) | -| `Request.LifecycleEvent` | Lifecycle event type (e.g. `Joiner`) | -| `Request.CorrelationId` | Stable correlation identifier | -| `Request.Actor` | Originator of the request | -| `Request.Input.*` | Alias for `Request.DesiredState.*` when no `Input` property exists | - -Accessing any other root (e.g. `Plan.*`, `Providers.*`) throws a **security error** during plan build. - ---- - -## Pure vs. mixed placeholders - -### Pure placeholder - -A value that contains **only** a single placeholder (no surrounding text) preserves the resolved -type (bool, int, datetime, guid, string): - -```powershell -# Resolves to the actual [bool] value — not the string "True" -Enabled = '{{Request.DesiredState.IsEnabled}}' -``` - -### Mixed placeholder (string interpolation) - -A value that contains text alongside one or more placeholders always produces a **string**: - -```powershell -# Always a string result -Message = 'Account for {{Request.DesiredState.DisplayName}} created.' -``` - ---- - -## Backslash and special characters - -Backslash (`\`) is a **literal character** in template strings and has no special meaning. -This allows Windows-style paths and domain-qualified names without any extra escaping: - -```powershell -# \ is kept as-is; only the placeholder is substituted -IdentityKey = 'DOMAIN\{{Request.IdentityKeys.sAMAccountName}}' -# → e.g. 'DOMAIN\jdoe' -``` - ---- - -## Escaping a literal `{{` - -To include a literal `{{` in the output (not treated as a placeholder), prefix with a backslash -**and** ensure no valid template path follows the opening braces: - -```powershell -# \{{ not followed by a valid path+}} → literal {{ in output -Value = 'Literal \{{ braces here' -# → 'Literal {{ braces here' -``` - -The escape is only applied when `\{{` is **not** immediately followed by a valid path identifier -and closing `}}`. This means the following works without any special escaping: - -```powershell -# \ before {{ followed by a valid path — treated as literal \ + resolved template -IdentityKey = 'DOMAIN\{{Request.IdentityKeys.sAMAccountName}}' -# → 'DOMAIN\jdoe' (not an escape; \ stays, template resolves) -``` - -Summary: - -| Input | Result | -| ----- | ------ | -| `DOMAIN\{{Request.IdentityKeys.sAMAccountName}}` | `DOMAIN\jdoe` — `\` literal, template resolved | -| `Literal \{{ braces here` | `Literal {{ braces here` — `\{{` escaped (no valid path follows) | -| `Literal \{{ and template {{Request.Input.Name}}` | `Literal {{ and template TestName` — escape + template | - ---- - -## Validation - -During plan build, IdLE validates every template value: - -- **Unbalanced braces** — mismatched `{{`/`}}` pairs throw a syntax error. -- **Invalid path pattern** — paths must use dot-separated identifiers (letters, numbers, underscores). Spaces and special characters are not allowed. -- **Disallowed root** — paths outside the allowlist throw a security error. -- **Null or missing value** — if the resolved value is `null` or the path does not exist, an error is thrown. Ensure the request contains all required values before building the plan. -- **Non-scalar value** — resolving to a hashtable or array is not allowed. Use a scalar property path or flatten the data before creating the request. - ---- - -## See also - -- [Walkthrough 1 — Workflow definition](../../use/walkthrough/01-workflow-definition.md) -- [Walkthrough 3 — Plan build](../../use/walkthrough/03-plan-creation.md) -- [Security](../../about/security.md) diff --git a/docs/use/workflows.md b/docs/use/workflows.md index f736f818..e21fd2ea 100644 --- a/docs/use/workflows.md +++ b/docs/use/workflows.md @@ -70,18 +70,82 @@ Start with [Quick Start](quickstart.md). ## Template substitution Step configuration values (`With.*`) support `{{path}}` placeholders that are resolved against the -request during plan build. For example: +request during plan build (`New-IdlePlan`). Multiple placeholders may appear in a single value. ```powershell IdentityKey = '{{Request.IdentityKeys.sAMAccountName}}' +DisplayName = '{{Request.DesiredState.GivenName}}' Message = 'User {{Request.DesiredState.DisplayName}} is joining.' +``` + +### Allowed roots + +For security, only these path roots are permitted: + +| Root | Description | +| ---- | ----------- | +| `Request.DesiredState.*` | Intended target state of the identity | +| `Request.IdentityKeys.*` | Identifiers of the target identity | +| `Request.Changes.*` | Explicit deltas (Mover events) | +| `Request.LifecycleEvent` | Lifecycle event type (e.g. `Joiner`) | +| `Request.CorrelationId` | Stable correlation identifier | +| `Request.Actor` | Originator of the request | +| `Request.Input.*` | Alias for `Request.DesiredState.*` when no `Input` property exists | + +### Pure vs. mixed placeholders + +A value containing **only** a single placeholder preserves the resolved type (bool, int, datetime, guid, string): + +```powershell +# Resolves to the actual [bool] value, not the string "True" +Enabled = '{{Request.DesiredState.IsEnabled}}' +``` + +A value with surrounding text always produces a **string**: + +```powershell +Message = 'Account for {{Request.DesiredState.DisplayName}} created.' +``` -# Backslash is a literal character — domain paths work without extra escaping: +### Backslash and special characters + +Backslash (`\`) is a **literal character** in template strings and requires no escaping. +Windows-style paths and domain-qualified names work as-is: + +```powershell +# \ is kept as-is; only the placeholder is substituted IdentityKey = 'DOMAIN\{{Request.IdentityKeys.sAMAccountName}}' +# → e.g. 'DOMAIN\jdoe' +``` + +### Escaping a literal `{{` + +To include a literal `{{` in the output, prefix it with `\` and ensure no valid template path +follows the opening braces: + +```powershell +# \{{ not followed by a valid path+}} → literal {{ in output +Value = 'Literal \{{ braces here' +# → 'Literal {{ braces here' ``` -See [Reference: Template Substitution](../reference/specs/template-substitution.md) for the full -syntax, allowed roots, escaping rules, and validation behaviour. +Summary of backslash behaviour: + +| Input | Result | +| ----- | ------ | +| `DOMAIN\{{Request.IdentityKeys.sAMAccountName}}` | `DOMAIN\jdoe` — `\` literal, template resolved | +| `Literal \{{ braces here` | `Literal {{ braces here` — escape applied | +| `Literal \{{ and {{Request.Input.Name}}` | `Literal {{ and TestName` — escape + template | + +### Validation + +During plan build, IdLE validates every template value: + +- **Unbalanced braces** — mismatched `{{`/`}}` pairs throw a syntax error. +- **Invalid path** — paths must use dot-separated identifiers (letters, numbers, underscores). +- **Disallowed root** — paths outside the allowlist throw a security error. +- **Null or missing value** — if the resolved path does not exist, an error is thrown. +- **Non-scalar value** — resolving to a hashtable or array is not allowed. --- From c2efbb170c453429698958390df8c3e48d91c0ac Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 21 Feb 2026 23:55:20 +0000 Subject: [PATCH 6/6] Fix: tighten escape lookahead to exact allowed roots; add regression test Co-authored-by: blindzero <13959569+blindzero@users.noreply.github.com> --- docs/use/workflows.md | 11 ++++++++--- .../Private/Resolve-IdleTemplateString.ps1 | 14 ++++++++------ .../Core/Resolve-IdleWorkflowTemplates.Tests.ps1 | 16 ++++++++++++++++ .../template-escaped-invalid-root.psd1 | 13 +++++++++++++ 4 files changed, 45 insertions(+), 9 deletions(-) create mode 100644 tests/fixtures/workflows/template-tests/template-escaped-invalid-root.psd1 diff --git a/docs/use/workflows.md b/docs/use/workflows.md index e21fd2ea..c32f96c0 100644 --- a/docs/use/workflows.md +++ b/docs/use/workflows.md @@ -120,21 +120,26 @@ IdentityKey = 'DOMAIN\{{Request.IdentityKeys.sAMAccountName}}' ### Escaping a literal `{{` -To include a literal `{{` in the output, prefix it with `\` and ensure no valid template path -follows the opening braces: +To include a literal `{{` in the output, prefix it with `\`. The escape is applied whenever +`\{{` is **not** immediately followed by a valid allowed-root template path and `}}`: ```powershell # \{{ not followed by a valid path+}} → literal {{ in output Value = 'Literal \{{ braces here' # → 'Literal {{ braces here' + +# \{{ followed by an invalid/disallowed path → also escaped (literal {{ in output) +Value = '\{{Request.InvalidRoot}}' +# → '{{Request.InvalidRoot}}' ``` Summary of backslash behaviour: | Input | Result | | ----- | ------ | -| `DOMAIN\{{Request.IdentityKeys.sAMAccountName}}` | `DOMAIN\jdoe` — `\` literal, template resolved | +| `DOMAIN\{{Request.IdentityKeys.sAMAccountName}}` | `DOMAIN\jdoe` — `\` literal, valid template resolved | | `Literal \{{ braces here` | `Literal {{ braces here` — escape applied | +| `\{{Request.InvalidRoot}}` | `{{Request.InvalidRoot}}` — invalid root, escape applied | | `Literal \{{ and {{Request.Input.Name}}` | `Literal {{ and TestName` — escape + template | ### Validation diff --git a/src/IdLE.Core/Private/Resolve-IdleTemplateString.ps1 b/src/IdLE.Core/Private/Resolve-IdleTemplateString.ps1 index dc109e02..92ed6540 100644 --- a/src/IdLE.Core/Private/Resolve-IdleTemplateString.ps1 +++ b/src/IdLE.Core/Private/Resolve-IdleTemplateString.ps1 @@ -22,9 +22,10 @@ function Resolve-IdleTemplateString { - Request.Actor Escaping: - - \{{ → literal {{ (backslash escapes the opening braces when not followed by a template path) - - \{{path}} treats \ as a literal character and resolves the template normally + - \{{ → literal {{ (backslash escapes the opening braces when not followed by a valid allowed-root template path+}}) + - \{{path}} where path is a valid allowed-root path treats \ as a literal character and resolves the template normally (e.g. DOMAIN\{{Request.IdentityKeys.sAMAccountName}} → DOMAIN\) + - \{{Request.InvalidRoot}} or \{{Request..Bad}} → escaped to literal {{...}} (invalid paths are never treated as templates) .PARAMETER Value The string value to resolve. If not a string, returns the value unchanged. @@ -199,13 +200,14 @@ function Resolve-IdleTemplateString { } } - # Escape sequence normalization: \{{ (not followed by a valid template path+}}) → literal {{. + # Escape sequence normalization: \{{ (not followed by a valid allowed-root template path+}}) → literal {{. # Use a Unicode Private Use Area character as placeholder so template matching does not see the # escaped braces. This character is extremely unlikely to appear in real workflow configuration. - # When \{{ is immediately followed by a valid path and }} (e.g. DOMAIN\{{Request.Input.Name}}), - # the backslash is treated as a literal character and the template is resolved normally. + # The lookahead is intentionally restricted to the exact set of allowed roots so that sequences + # like \{{Request.Foo}} (invalid root) or \{{Request..Name}} (double dot) are still escaped to + # literal {{, rather than flowing into template parsing and failing with path/root errors. $litOpenPlaceholder = [string][char]0xE001 - $backslashEscapePattern = '\\{{(?![A-Za-z][A-Za-z0-9_.]*}})' + $backslashEscapePattern = '\\{{(?!Request\.(?:(?:Input|DesiredState|IdentityKeys|Changes)(?:\.[A-Za-z0-9_]+)*|LifecycleEvent|CorrelationId|Actor)}})' $normalizedValue = if ($stringValue -match '\\{{') { [regex]::Replace($stringValue, $backslashEscapePattern, $litOpenPlaceholder) } else { diff --git a/tests/Core/Resolve-IdleWorkflowTemplates.Tests.ps1 b/tests/Core/Resolve-IdleWorkflowTemplates.Tests.ps1 index 91a5c9a0..39aa3060 100644 --- a/tests/Core/Resolve-IdleWorkflowTemplates.Tests.ps1 +++ b/tests/Core/Resolve-IdleWorkflowTemplates.Tests.ps1 @@ -350,6 +350,22 @@ Describe 'Template Substitution' { $plan.Steps[0].With.IdentityKey | Should -Be 'DOMAIN\jdoe' } + + It 'escapes \{{ followed by an invalid (non-allowed) root — throws unbalanced braces, not path error' { + # With the tight allowed-root lookahead, \{{InvalidRoot}} is escaped (placeholder replaces \{{) + # leaving }} orphaned → "unbalanced braces" error, same as original code. + # A loose lookahead would let this through to template parsing → wrong "path not allowed" error. + $wfPath = Get-TemplateTestFixture 'template-escaped-invalid-root' + + $req = New-IdleRequest -LifecycleEvent 'Joiner' -DesiredState @{ Name = 'Test' } + $providers = @{ + StepRegistry = @{ 'IdLE.Step.Test' = 'Invoke-IdleTestNoopStep' } + StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.Test') + } + + { New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers } | + Should -Throw -ExpectedMessage '*Unbalanced braces*' + } } Context 'OnFailureSteps template resolution' { diff --git a/tests/fixtures/workflows/template-tests/template-escaped-invalid-root.psd1 b/tests/fixtures/workflows/template-tests/template-escaped-invalid-root.psd1 new file mode 100644 index 00000000..f1c2b57c --- /dev/null +++ b/tests/fixtures/workflows/template-tests/template-escaped-invalid-root.psd1 @@ -0,0 +1,13 @@ +@{ + Name = 'Template Test - Escaped Invalid Root' + LifecycleEvent = 'Joiner' + Steps = @( + @{ + Name = 'TestStep' + Type = 'IdLE.Step.Test' + With = @{ + Value = '\{{Request.InvalidRoot}}' + } + } + ) +}