From 2c6427f417d46dbc10c99bc9d0648fef11bf7baa Mon Sep 17 00:00:00 2001 From: "G.Reijn" <26114636+Gijsreyn@users.noreply.github.com> Date: Wed, 21 Oct 2026 00:08:30 +0200 Subject: [PATCH 1/6] Remove vNext --- CHANGELOG.md | 21 + docs/reference/cli/index.md | 8 + docs/reference/cli/lint/index.md | 386 +++++++++ docs/reference/cli/lint/rules.md | 347 ++++++++ docs/reference/schemas/outputs/lint/result.md | 95 ++ docs/reference/schemas/outputs/lint/rules.md | 109 +++ dsc/dsc.settings.json | 3 + dsc/dsc_default.settings.json | 3 + dsc/locales/en-us.toml | 14 + dsc/src/args.rs | 26 + dsc/src/main.rs | 83 +- dsc/src/subcommand.rs | 214 ++++- dsc/src/util.rs | 61 +- dsc/tests/dsc_lint.tests.ps1 | 301 +++++++ lib/dsc-lib/locales/en-us.toml | 131 +++ lib/dsc-lib/locales/schemas.outputs.yaml | 27 + lib/dsc-lib/src/configure/mod.rs | 11 +- lib/dsc-lib/src/dscerror.rs | 3 + lib/dsc-lib/src/dscresources/dscresource.rs | 47 + lib/dsc-lib/src/functions/mod.rs | 109 ++- lib/dsc-lib/src/lib.rs | 1 + lib/dsc-lib/src/lint/context.rs | 202 +++++ lib/dsc-lib/src/lint/diagnostic.rs | 234 +++++ lib/dsc-lib/src/lint/expression_index.rs | 366 ++++++++ lib/dsc-lib/src/lint/mod.rs | 818 ++++++++++++++++++ lib/dsc-lib/src/lint/options.rs | 220 +++++ lib/dsc-lib/src/lint/pointer.rs | 165 ++++ lib/dsc-lib/src/lint/rule.rs | 158 ++++ lib/dsc-lib/src/lint/rules/dependency.rs | 438 ++++++++++ lib/dsc-lib/src/lint/rules/deprecation.rs | 206 +++++ lib/dsc-lib/src/lint/rules/document.rs | 364 ++++++++ lib/dsc-lib/src/lint/rules/environment.rs | 124 +++ lib/dsc-lib/src/lint/rules/expression.rs | 331 +++++++ lib/dsc-lib/src/lint/rules/mod.rs | 49 ++ lib/dsc-lib/src/lint/rules/parameter.rs | 648 ++++++++++++++ lib/dsc-lib/src/lint/rules/resource.rs | 427 +++++++++ lib/dsc-lib/src/parser/expressions.rs | 12 + lib/dsc-lib/src/parser/functions.rs | 6 + lib/dsc-lib/src/parser/mod.rs | 88 +- lib/dsc-lib/src/util.rs | 28 + .../tests/integration/schemas/schema_for.rs | 11 + xtask/src/schemas/export.rs | 2 + 42 files changed, 6818 insertions(+), 79 deletions(-) create mode 100644 docs/reference/cli/lint/index.md create mode 100644 docs/reference/cli/lint/rules.md create mode 100644 docs/reference/schemas/outputs/lint/result.md create mode 100644 docs/reference/schemas/outputs/lint/rules.md create mode 100644 dsc/tests/dsc_lint.tests.ps1 create mode 100644 lib/dsc-lib/locales/schemas.outputs.yaml create mode 100644 lib/dsc-lib/src/lint/context.rs create mode 100644 lib/dsc-lib/src/lint/diagnostic.rs create mode 100644 lib/dsc-lib/src/lint/expression_index.rs create mode 100644 lib/dsc-lib/src/lint/mod.rs create mode 100644 lib/dsc-lib/src/lint/options.rs create mode 100644 lib/dsc-lib/src/lint/pointer.rs create mode 100644 lib/dsc-lib/src/lint/rule.rs create mode 100644 lib/dsc-lib/src/lint/rules/dependency.rs create mode 100644 lib/dsc-lib/src/lint/rules/deprecation.rs create mode 100644 lib/dsc-lib/src/lint/rules/document.rs create mode 100644 lib/dsc-lib/src/lint/rules/environment.rs create mode 100644 lib/dsc-lib/src/lint/rules/expression.rs create mode 100644 lib/dsc-lib/src/lint/rules/mod.rs create mode 100644 lib/dsc-lib/src/lint/rules/parameter.rs create mode 100644 lib/dsc-lib/src/lint/rules/resource.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index bc5d85e86..cab2b748d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -77,6 +77,25 @@ changes since the last release, see the [diff on GitHub][unreleased]. ### Added +- Added the `dsc lint` command, which checks a configuration document for problems without + applying it. The command validates the document against the configuration document schema and + runs a set of rules that report duplicate resource names, unresolved and circular dependencies, + expressions that don't parse or call unknown functions, undeclared or unused parameters and + variables, invalid parameter constraints, deprecated features, and, with resource discovery, + resource types that aren't installed or literal properties that don't match a resource's schema. + You can change the level of a rule for a run with the `--rule` option or for every run in the + new `lint` section of the settings file, and control which level makes the command fail with + `--fail-on`. The `dsc schema` command exposes the new `lint-result` and `lint-rules` schemas. + +
Related work items + + - Issues: + - [#591][#591] + - [#842][#842] + - PRs: _None_ + +
+ - Added what-if support to the MCP server tools. The `invoke_dsc_config` tool now accepts a `what_if` option for the `set` operation, and the `invoke_dsc_resource` tool accepts `what_if` for the `set` and `delete` operations. This mirrors the `--what-if` flag on the `dsc config set`, @@ -1764,6 +1783,7 @@ Version `3.0.0` is the first generally available release of DSC. [#515]: https://github.com/PowerShell/DSC/issues/515 [#543]: https://github.com/PowerShell/DSC/issues/543 [#566]: https://github.com/PowerShell/DSC/issues/566 +[#591]: https://github.com/PowerShell/DSC/issues/591 [#57]: https://github.com/PowerShell/DSC/issues/57 [#598]: https://github.com/PowerShell/DSC/issues/598 [#628]: https://github.com/PowerShell/DSC/issues/628 @@ -1828,6 +1848,7 @@ Version `3.0.0` is the first generally available release of DSC. [#832]: https://github.com/PowerShell/DSC/issues/832 [#834]: https://github.com/PowerShell/DSC/issues/834 [#841]: https://github.com/PowerShell/DSC/issues/841 +[#842]: https://github.com/PowerShell/DSC/issues/842 [#843]: https://github.com/PowerShell/DSC/issues/843 [#844]: https://github.com/PowerShell/DSC/issues/844 [#845]: https://github.com/PowerShell/DSC/issues/845 diff --git a/docs/reference/cli/index.md b/docs/reference/cli/index.md index f0823aa37..ca243e89c 100644 --- a/docs/reference/cli/index.md +++ b/docs/reference/cli/index.md @@ -42,6 +42,13 @@ The `function` command manages DSC functions. You can use it to: For more information, see [dsc function][03] +### lint + +The `lint` command checks a configuration document for problems without applying it. It reports +duplicate resources, invalid expressions, unresolved dependencies, invalid parameters, deprecated +features, and, with resource discovery, resource types that aren't installed. For more +information, see [dsc lint][07]. + ### mcp The `mcp` command starts DSC as a Model Context Protocol (MCP) server. For more information, see @@ -252,3 +259,4 @@ file, ensure that the input is encoded as UTF-8. [04]: server/index.md [05]: resource/index.md [06]: schema/index.md +[07]: lint/index.md diff --git a/docs/reference/cli/lint/index.md b/docs/reference/cli/lint/index.md new file mode 100644 index 000000000..487cc412c --- /dev/null +++ b/docs/reference/cli/lint/index.md @@ -0,0 +1,386 @@ +--- +description: Command line reference for the 'dsc lint' command +ms.date: 10/20/2026 +ms.topic: reference +title: dsc lint +--- + +# dsc lint + +## Synopsis + +Checks a configuration document for problems without applying it. + +## Syntax + +### Configuration document from file + +```sh +dsc lint [Options] --file +``` + +### Configuration document from option string + +```sh +dsc lint [Options] --input +``` + +### Configuration document from stdin + +```sh +cat | dsc lint [Options] --file - +``` + +### Several configuration documents + +```sh +dsc lint [Options] --file --file +``` + +### List the rules + +```sh +dsc lint --list-rules [--output-format ] +``` + +## Description + +The `lint` command analyzes a configuration document and reports problems as diagnostics. It never +invokes a resource operation, so it's safe to run on any machine, including CI agents. + +The command works in stages: + +1. It parses the document as JSON or YAML. A document that doesn't parse is reported with the + `parse-error` rule. +1. It validates the document against the configuration document JSON schema. Every violation is + reported with the `schema-invalid` rule and the JSON pointer to the problem. +1. It runs the static rules, which only need the document. These rules find duplicate resources, + expressions that don't parse or call unknown functions, undeclared parameters and variables, + unresolved and circular dependencies, invalid parameter constraints, deprecated features, and + more. +1. Unless you specify [--no-discovery](#--no-discovery), it discovers the resource types the + document uses and runs the resource rules, which report types that aren't installed, deprecated + types, and literal properties that don't match the resource's schema. + +Each diagnostic has a level: `error`, `warning`, or `info`. By default the command exits with code +`5` when it reports at least one error. Use [--fail-on](#--fail-on) to fail on warnings or +informational diagnostics instead. Use [--rule](#--rule) or the `lint` section of the DSC settings +file to change the level of a rule or turn it off. A few rules have a fixed level that can't be +changed: `parse-error`, `schema-invalid`, `unsupported-document-type`, and `discovery-failed`. + +For the list of rules and what each one checks, see [dsc lint rules][01]. + +## Examples + +### Example 1 - Lint a configuration document + + + +The command reports a duplicate resource name and exits with code `5`. + +```yaml +# example.dsc.config.yaml +$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json +resources: +- name: EnsureTest1Value + type: Microsoft.DSC.Debug/Echo + properties: + output: one +- name: EnsureTest1Value + type: Microsoft.DSC.Debug/Echo + properties: + output: two +``` + +```sh +dsc lint --file ./example.dsc.config.yaml +``` + +```yaml +diagnostics: +- level: error + rule: duplicate-resource + category: document + message: Resource 'EnsureTest1Value' of type 'Microsoft.DSC.Debug/Echo' is declared more than once + path: /resources/1 + file: ./example.dsc.config.yaml +summary: + errors: 1 + warnings: 0 + info: 0 +valid: false +discovery: true +``` + +### Example 2 - Fail on warnings in a pipeline + + + +The command exits with code `5` when any warning or error is reported, so a pipeline step fails on +an unused parameter as well as on an error. Discovery is skipped because the build agent doesn't +have the resources installed. + +```sh +dsc lint --file ./example.dsc.config.yaml --no-discovery --fail-on warning +``` + +### Example 3 - Change the level of a rule + + + +The command turns off the `no-unused-parameters` rule and raises `missing-require-version`, which +is off by default, to a warning. + +```sh +dsc lint --file ./example.dsc.config.yaml --rule no-unused-parameters=off --rule missing-require-version=warning +``` + +### Example 4 - Check a document that targets another system root + + + +When you pass `--system-root`, the `system-root-unused` rule warns if no expression in the document +calls the `systemRoot()` function, because the document wouldn't target the specified root. + +```sh +dsc lint --file ./example.dsc.config.yaml --system-root D:\ +``` + +### Example 5 - List the rules + + + +The command lists every rule with its category, the level it reports at, whether it needs resource +discovery, and a description. + +```sh +dsc lint --list-rules +``` + +## Options + +### -i, --input + + + + +Specifies the configuration document to lint. + +The document must be a string containing a JSON or YAML object. + +This option is mutually exclusive with the `--file` option. + +```yaml +Type : string +Mandatory : false +LongSyntax : --input +ShortSyntax : -i +``` + +### -f, --file + + + + +Defines the path to a configuration document to lint. Specify the option more than once to lint +several documents in one run; the diagnostics then include the `file` property. + +You can also use this option to pass a configuration document from stdin with `-` as the path. + +This option is mutually exclusive with the `--input` option. + +```yaml +Type : string +Mandatory : false +LongSyntax : --file +ShortSyntax : -f +``` + +### -p, --parameters + + + + +Specifies the parameter values that would be passed to the configuration, as a JSON or YAML +object. When you specify parameters, the command checks that every supplied value matches a +declared parameter, that every parameter without a default value has a value, and that each value +satisfies the parameter's type and constraints. + +```yaml +Type : string +Mandatory : false +LongSyntax : --parameters +ShortSyntax : -p +``` + +### --parameters-file + + + +Defines the path to a JSON or YAML file with the parameter values. Use `-` to read the parameters +from stdin. Values from `--parameters` take precedence over values from the file. + +```yaml +Type : string +Mandatory : false +LongSyntax : --parameters-file +``` + +### -r, --system-root + + + + +Specifies the operating system root path the configuration would target. The command doesn't +check that the path exists; it only enables the `system-root-unused` rule. + +```yaml +Type : string +Mandatory : false +LongSyntax : --system-root +ShortSyntax : -r +``` + +### --no-discovery + + + +Skips resource discovery. The rules that inspect installed resources don't run, and the output +reports `discovery: false`. Use this option on machines that don't have the resources installed, +or to lint faster. + +```yaml +Type : boolean +Mandatory : false +LongSyntax : --no-discovery +``` + +### --fail-on + + + +Defines the lowest diagnostic level that makes the command exit with code `5`. The default is +`error`. + +```yaml +Type : string +Mandatory : false +DefaultValue : error +ValidValues : [error, warning, info] +LongSyntax : --fail-on +``` + +### --rule + + + +Overrides the level of a rule for this run, as `=`. The level is one of `off`, +`info`, `warning`, or `error`. Specify the option more than once to override several rules. The +command exits with code `1` for an unknown rule. + +Overrides from this option take precedence over the `lint` section of the DSC settings file. The +policy settings file takes precedence over this option. + +```yaml +Type : string +Mandatory : false +LongSyntax : --rule +``` + +### --list-rules + + + +Lists the rules with the level each reports at in this run instead of linting a document. When +the output isn't redirected, the command shows a table; otherwise it emits the list in the format +specified by `--output-format`. For the schema of the list, see [dsc lint rules schema][03]. + +This option is mutually exclusive with the `--input` and `--file` options. + +```yaml +Type : boolean +Mandatory : false +LongSyntax : --list-rules +``` + +### -o, --output-format + + + + +The `--output-format` option controls which format DSC uses for the data the command returns. The +available formats are: + +- `json` to emit the data as a [JSON Line][02]. +- `pretty-json` to emit the data as JSON with newlines, indentation, and spaces for readability. +- `yaml` to emit the data as YAML. + +The default output format depends on whether DSC detects that the output is being redirected or +captured as a variable: + +- If the command isn't being redirected or captured, DSC displays the output as the `yaml` format + in the console. +- If the command output is redirected or captured, DSC emits the data as the `json` format to + stdout. + +```yaml +Type : string +Mandatory : false +ValidValues : [json, pretty-json, yaml] +LongSyntax : --output-format +ShortSyntax : -o +``` + +### -h, --help + + + + +Displays the help for the current command or subcommand. When you specify this option, the +application ignores all other options and arguments. + +```yaml +Type : boolean +Mandatory : false +LongSyntax : --help +ShortSyntax : -h +``` + +## Output + +This command returns the diagnostics for the document, counts of the diagnostics by level, whether +the document has no errors, and whether resource discovery ran. The command always writes the +result to stdout, including when the document doesn't parse. For more information, see +[dsc lint result schema][04]. + +## Exit codes + +| Exit Code | Meaning | +| :-------: | :------------------------------------------------------------------------------- | +| `0` | No diagnostic at or above the `--fail-on` level was reported. | +| `1` | The command received invalid arguments, such as an unknown rule. | +| `2` | The command failed for another reason, such as an unreadable settings file. | +| `4` | A document or the parameters couldn't be read. | +| `5` | At least one diagnostic at or above the `--fail-on` level was reported. | + +## Settings + +The `lint` section of the DSC settings file sets the level of rules for every run. The same +section in the policy settings file takes precedence over the `--rule` option. + +```json +{ + "lint": { + "rules": { + "no-unused-parameters": { "level": "off" }, + "missing-require-version": { "level": "warning" } + } + } +} +``` + + +[01]: rules.md +[02]: https://jsonlines.org/ +[03]: ../../schemas/outputs/lint/rules.md +[04]: ../../schemas/outputs/lint/result.md diff --git a/docs/reference/cli/lint/rules.md b/docs/reference/cli/lint/rules.md new file mode 100644 index 000000000..79b0cf21a --- /dev/null +++ b/docs/reference/cli/lint/rules.md @@ -0,0 +1,347 @@ +--- +description: Reference for the rules the 'dsc lint' command checks configuration documents against +ms.date: 10/20/2026 +ms.topic: reference +title: dsc lint rules +--- + +# dsc lint rules + +The `dsc lint` command checks a configuration document against the rules on this page. Each rule +has an identifier you can use with the `--rule` option or in the `lint` section of the settings +file, a category, a default level, and a tier: + +- `static` rules only need the document. +- `resource` rules need the installed resources and only run when discovery is enabled. + +Rules with a fixed level can't be changed or turned off. + +## Rules at a glance + +| Rule | Category | Default level | Tier | +| :------------------------------- | :---------- | :------------ | :------- | +| `parse-error` | document | error (fixed) | static | +| `schema-invalid` | document | error (fixed) | static | +| `unsupported-document-type` | document | error (fixed) | static | +| `duplicate-resource` | document | error | static | +| `duplicate-resource-name` | document | warning | static | +| `empty-resources` | document | warning | static | +| `unknown-microsoft-dsc-metadata` | document | warning | static | +| `api-version-alias` | document | info | static | +| `date-version` | document | warning | static | +| `missing-require-version` | document | off | static | +| `invalid-expression` | expression | error | static | +| `unknown-function` | expression | error | static | +| `invalid-argument-count` | expression | error | static | +| `invalid-argument-type` | expression | error | static | +| `undefined-parameter` | expression | error | static | +| `undefined-variable` | expression | error | static | +| `condition-not-expression` | expression | warning | static | +| `no-unused-parameters` | parameter | warning | static | +| `no-unused-variables` | parameter | warning | static | +| `parameter-default-type` | parameter | error | static | +| `invalid-parameter-constraints` | parameter | error | static | +| `secure-parameter-default` | parameter | warning | static | +| `prefer-secure-type` | parameter | warning | static | +| `secure-value-in-output` | parameter | warning | static | +| `use-parameter-description` | parameter | off | static | +| `undeclared-parameter-value` | parameter | error | static | +| `missing-parameter-value` | parameter | error | static | +| `parameter-value-type` | parameter | error | static | +| `unresolved-dependency` | dependency | error | static | +| `circular-dependency` | dependency | error | static | +| `reference-without-depends-on` | dependency | warning | static | +| `duplicate-depends-on` | dependency | info | static | +| `copy-loop-deprecated` | deprecation | warning | static | +| `copy-options-unsupported` | deprecation | error | static | +| `security-context-in-metadata` | deprecation | warning | static | +| `security-context-conflict` | deprecation | error | static | +| `dsc-version-unsatisfied` | deprecation | warning | static | +| `system-root-unused` | environment | warning | static | +| `hardcoded-system-path` | environment | off | static | +| `discovery-failed` | resource | warning (fixed) | resource | +| `unknown-resource-type` | resource | warning | resource | +| `unsatisfied-require-version` | resource | warning | resource | +| `deprecated-resource` | resource | warning | resource | +| `property-schema-mismatch` | resource | error | resource | +| `nested-resources-unsupported` | resource | warning | resource | +| `exist-false-unsupported` | resource | error | resource | +| `canonical-type-name` | resource | info | resource | + +## Document rules + +### parse-error + +The document must be valid JSON or YAML. The diagnostic includes the parser's message and, when +known, the line and column of the problem. This rule has a fixed level. + +### schema-invalid + +The document must validate against the configuration document JSON schema. The command reports +every violation with the JSON pointer to the problem, such as an unknown property, a missing +`$schema`, or a `dependsOn` entry that isn't a `resourceId()` expression. This rule has a fixed +level. + +### unsupported-document-type + +Only configuration documents can be linted. A document whose `$schema` identifies a resource or +extension manifest is rejected. This rule has a fixed level. + +### duplicate-resource + +Resource names must be unique among resources of the same type at the same level. DSC refuses to +run a document that declares the same resource twice. Types are compared without regard to case. + +```yaml +resources: +- name: Same + type: Microsoft.DSC.Debug/Echo +- name: Same # duplicate-resource + type: microsoft.dsc.debug/echo +``` + +### duplicate-resource-name + +Resources at the same level should have distinct names even when their types differ, because DSC +resolves dependencies by name first and then checks the type. + +### empty-resources + +The configuration should declare at least one resource. + +### unknown-microsoft-dsc-metadata + +Keys under `Microsoft.DSC` metadata must be ones DSC recognizes. DSC silently ignores unknown +keys, so a typo such as `securityContxt` has no effect. + +### api-version-alias + +Resources should use `requireVersion` rather than its alias `apiVersion`. + +### date-version + +Version requirements should be semantic. Date-based versions such as `2025-01-01` are deprecated +and only supported for compatibility. + +### missing-require-version + +Resources should pin the version they require with `requireVersion`. This rule is off by default; +enable it to require reproducible documents. + +## Expression rules + +### invalid-expression + +Every string that starts with `[` is evaluated as an expression and must parse. To use a literal +string that starts with a bracket, escape it with `[[`. + +```yaml +properties: + output: "[concat('unterminated)]" # invalid-expression + literal: "[[not an expression]" # fine +``` + +### unknown-function + +Expressions may only call built-in functions or user-defined functions declared in `functions`. +Function names are case-sensitive; the diagnostic suggests the correct casing when the name +matches a built-in function except for case. + +### invalid-argument-count + +Function calls must pass the number of arguments the function accepts. The message is the same one +DSC raises at runtime. + +### invalid-argument-type + +Literal arguments must be of a kind the function accepts at that position. Arguments that are +nested expressions aren't checked, because their value is only known at runtime. + +### undefined-parameter + +`parameters()` must reference a parameter declared in `parameters`. Calls inside user-defined +function bodies refer to the function's own parameters and aren't checked. + +### undefined-variable + +`variables()` must reference a variable declared in `variables`. + +### condition-not-expression + +A `condition` on a resource or output must be an expression that evaluates to `true`. A plain +string such as `"true"` never does, so the item is always skipped. + +## Parameter rules + +### no-unused-parameters + +Declared parameters should be referenced by at least one expression. + +### no-unused-variables + +Declared variables should be referenced by at least one expression. + +### parameter-default-type + +A literal default value must satisfy the parameter's type, `allowedValues`, value limits, and +length limits. Default values that are expressions aren't checked. + +### invalid-parameter-constraints + +Parameter constraints must be consistent with each other and with the parameter type: `minValue` +can't exceed `maxValue`, `minLength` can't exceed `maxLength`, value limits only apply to `int` +parameters, length limits only apply to `string`, `secureString`, and `array` parameters, +`allowedValues` can't be empty, and every allowed value must be of the parameter's type. + +### secure-parameter-default + +A `secureString` or `secureObject` parameter shouldn't have a hard-coded default value, because +anyone with the document can read it. An empty string or an expression is allowed. + +### prefer-secure-type + +A `string` or `object` parameter whose name suggests a secret, such as `password`, `token`, or +`apiKey`, should use the `secureString` or `secureObject` type. + +### secure-value-in-output + +Outputs shouldn't expose a secure parameter or a `secret()` value. DSC skips such outputs at +runtime. + +### use-parameter-description + +Parameters should have a description. This rule is off by default. + +### undeclared-parameter-value + +When you pass parameters with `--parameters` or `--parameters-file`, every supplied value must +match a declared parameter. + +### missing-parameter-value + +When you pass parameters, every declared parameter without a default value must be supplied a +value. Without parameters this rule doesn't run, because a document can declare required +parameters on purpose. + +### parameter-value-type + +When you pass parameters, every supplied value must satisfy the parameter's type and constraints. + +## Dependency rules + +### unresolved-dependency + +Every `dependsOn` entry and every `reference(resourceId(...))` call must point at a resource +declared at the same level. The diagnostic distinguishes a missing resource from a resource whose +type doesn't match. Resources in copy loops aren't checked, because their names are only known at +runtime. + +```yaml +resources: +- name: first + type: Microsoft.DSC.Debug/Echo +- name: second + type: Microsoft.DSC.Debug/Echo + dependsOn: + - "[resourceId('Microsoft.DSC.Debug/Echo', 'missing')]" # unresolved-dependency +``` + +### circular-dependency + +Resource dependencies must not form a cycle, including a resource that depends on itself. The +message shows the cycle, like `a -> b -> a`. + +### reference-without-depends-on + +A resource that references another resource with `reference()` must list it in `dependsOn`. DSC +only makes the output of resources that have already run available to `reference()`, so the +reference fails when the referenced resource is declared later. + +### duplicate-depends-on + +A dependency should be listed once. + +## Deprecation rules + +### copy-loop-deprecated + +Copy loops are deprecated and will be removed in a future release. See +[issue #1429][01] for details. + +### copy-options-unsupported + +Copy loops don't support the `mode` and `batchSize` options; DSC rejects a document that sets +them. + +### security-context-in-metadata + +The security context should be set with `directives.securityContext` rather than the deprecated +`Microsoft.DSC` metadata. + +### security-context-conflict + +When both the metadata and the directive set a security context, they must agree; DSC refuses to +run the document otherwise. + +### dsc-version-unsatisfied + +The `directives.version` requirement should be satisfied by the version of DSC in use. The rule +checks the version of the `dsc` command that runs the linter. + +## Environment rules + +### system-root-unused + +When you pass `--system-root`, at least one expression in the document should call `systemRoot()`. +Otherwise the document doesn't target the specified root. + +### hardcoded-system-path + +Absolute operating system paths such as `C:\Windows` or `/etc` should be built with `systemRoot()` +so the document can target another root. This rule is off by default because many documents +intentionally target the running system. + +## Resource rules + +These rules run after resource discovery and are skipped with `--no-discovery`. + +### discovery-failed + +Resource discovery must succeed for the resource rules to run. When it fails, the command reports +this diagnostic and continues with the static results only. This rule has a fixed level. + +### unknown-resource-type + +Each resource type must be installed. This rule reports a warning rather than an error, because a +document can be valid on a machine that has the resource. + +### unsatisfied-require-version + +An installed version of the resource type must satisfy `requireVersion`. + +### deprecated-resource + +Resources shouldn't use resource types whose manifest marks them as deprecated. The diagnostic +includes the deprecation message from the manifest. + +### property-schema-mismatch + +Literal property values must validate against the resource's embedded schema. Properties that hold +expressions aren't checked, because their value is only known at runtime. The rule never invokes +the resource to retrieve its schema. + +### nested-resources-unsupported + +Only group and adapter resources can declare nested `resources`. + +### exist-false-unsupported + +A resource type must have the `delete` or `setHandlesExist` capability for `_exist: false` to +work. Without either, `dsc config set` fails for the resource. + +### canonical-type-name + +Resource types should be written with the casing their manifest uses. + + +[01]: https://github.com/PowerShell/DSC/issues/1429 diff --git a/docs/reference/schemas/outputs/lint/result.md b/docs/reference/schemas/outputs/lint/result.md new file mode 100644 index 000000000..736dae6ea --- /dev/null +++ b/docs/reference/schemas/outputs/lint/result.md @@ -0,0 +1,95 @@ +--- +description: JSON schema reference for the data returned by the 'dsc lint' command. +ms.date: 10/20/2026 +ms.topic: reference +title: dsc lint result schema reference +--- + +# dsc lint result schema reference + +## Synopsis + +The result output from the `dsc lint` command. + +## Metadata + +```yaml +SchemaDialect: https://json-schema.org/draft/2020-12/schema +SchemaID: https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/v3.1.0/outputs/lint/result.json +Type: object +``` + +## Description + +The output from the `dsc lint` command includes every diagnostic the rules reported, counts of the +diagnostics by level, whether the document is free of error-level diagnostics, and whether +resource discovery ran. The command always emits this object, including when the document doesn't +parse. + +## Required properties + +The output always includes these properties: + +- [diagnostics](#diagnostics) +- [summary](#summary) +- [valid](#valid) +- [discovery](#discovery) + +## Properties + +### diagnostics + +The problems the rules reported, ordered by file, path, and rule. Each entry is an object with the +following properties: + +- `level` is the severity of the problem: `error`, `warning`, or `info`. +- `rule` is the identifier of the rule that reported the problem, such as `duplicate-resource`. +- `category` is the area of the document the rule inspects: `document`, `expression`, `parameter`, + `dependency`, `deprecation`, `environment`, or `resource`. +- `message` describes the problem. +- `path` is the JSON pointer ([RFC 6901][01]) to the location of the problem in the document. An + empty string identifies the document root. +- `file` is the file the document was read from. The property is omitted for documents passed + with `--input` or from stdin. +- `location` is the position of the problem in the source text as an object with `line` and + `column` properties, both starting at `1`. The property is only present for problems that + prevented the document from parsing. + +```yaml +Type: array +Required: true +``` + +### summary + +Counts of the diagnostics by level, as an object with the `errors`, `warnings`, and `info` +properties. + +```yaml +Type: object +Required: true +``` + +### valid + +Whether no error-level diagnostics were reported. This property doesn't depend on the `--fail-on` +option. + +```yaml +Type: boolean +Required: true +``` + +### discovery + +Whether resource discovery ran. When `false`, the rules that inspect installed resources were +skipped, either because of the `--no-discovery` option, because the document sets +`directives.resourceDiscovery` to `duringDeployment`, or because discovery failed. + +```yaml +Type: boolean +Required: true +``` + + +[01]: https://www.rfc-editor.org/rfc/rfc6901 diff --git a/docs/reference/schemas/outputs/lint/rules.md b/docs/reference/schemas/outputs/lint/rules.md new file mode 100644 index 000000000..649e1c819 --- /dev/null +++ b/docs/reference/schemas/outputs/lint/rules.md @@ -0,0 +1,109 @@ +--- +description: JSON schema reference for the data returned by the 'dsc lint --list-rules' command. +ms.date: 10/20/2026 +ms.topic: reference +title: dsc lint rules schema reference +--- + +# dsc lint rules schema reference + +## Synopsis + +The output from the `dsc lint --list-rules` command. + +## Metadata + +```yaml +SchemaDialect: https://json-schema.org/draft/2020-12/schema +SchemaID: https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/v3.1.0/outputs/lint/rules.json +Type: object +``` + +## Description + +The `dsc lint --list-rules` command returns an array of objects with this schema, one per rule, +sorted by rule identifier. Each object describes the rule and the level it reports at after the +settings file and any `--rule` overrides are applied. + +## Required properties + +Each entry always includes these properties: + +- [id](#id) +- [category](#category) +- [tier](#tier) +- [defaultLevel](#defaultlevel) +- [level](#level) +- [fixed](#fixed) +- [description](#description) + +## Properties + +### id + +The identifier of the rule, such as `duplicate-resource`. + +```yaml +Type: string +Required: true +``` + +### category + +The area of the document the rule inspects: `document`, `expression`, `parameter`, `dependency`, +`deprecation`, `environment`, or `resource`. + +```yaml +Type: string +Required: true +``` + +### tier + +When the rule can run: `static` rules only need the document, `resource` rules need the installed +resources and only run when discovery is enabled. + +```yaml +Type: string +Required: true +ValidValues: [static, resource] +``` + +### defaultLevel + +The level the rule reports at unless it is overridden: `off`, `info`, `warning`, or `error`. + +```yaml +Type: string +Required: true +ValidValues: [off, info, warning, error] +``` + +### level + +The level the rule reports at in this run, after the settings file and any `--rule` overrides are +applied. + +```yaml +Type: string +Required: true +ValidValues: [off, info, warning, error] +``` + +### fixed + +Whether the level of the rule can't be changed. + +```yaml +Type: boolean +Required: true +``` + +### description + +What the rule checks. + +```yaml +Type: string +Required: true +``` diff --git a/dsc/dsc.settings.json b/dsc/dsc.settings.json index 19acd63ea..0ca7abad1 100644 --- a/dsc/dsc.settings.json +++ b/dsc/dsc.settings.json @@ -8,5 +8,8 @@ "level": "WARN", "format": "Default", "allowOverride": true + }, + "lint": { + "rules": {} } } diff --git a/dsc/dsc_default.settings.json b/dsc/dsc_default.settings.json index fe37d458f..aa66a7830 100644 --- a/dsc/dsc_default.settings.json +++ b/dsc/dsc_default.settings.json @@ -9,6 +9,9 @@ "level": "WARN", "format": "Default", "allowOverride": true + }, + "lint": { + "rules": {} } } } diff --git a/dsc/locales/en-us.toml b/dsc/locales/en-us.toml index c9b761889..0a0c9640f 100644 --- a/dsc/locales/en-us.toml +++ b/dsc/locales/en-us.toml @@ -40,6 +40,13 @@ version = "The version requirement for the resource, like '^1.2' or '>1.2.3, <1. serverAbout = "Use DSC as a server over JSON-RPC (useful as MCP server)" bicepAbout = "Use DSC as a Bicep server over gRPC" ignoreSettingsFile = "Ignore the settings file when running the command" +lintAbout = "Check a configuration document for problems without applying it" +lintInput = "The configuration document as JSON or YAML to lint" +lintFile = "The path to a configuration document to lint; specify multiple times to lint several documents. Use '-' to read from STDIN." +noDiscovery = "Skip resource discovery and the rules that inspect installed resources" +failOn = "The lowest diagnostic level that makes the command fail (default: error)" +rule = "Override the level of a rule, like 'no-unused-parameters=off'; specify multiple times for several rules" +listRules = "List the lint rules and the level each reports at instead of linting" [main] failedToSpawnMain = "Failed to spawn dsc main thread: %{error}" @@ -162,6 +169,13 @@ invalidFunctionDescriptionFilter = "Invalid function description filter" maxInt = "maxInt" invalidManifest = "Error in manifest for" jsonArrayNotSupported = "JSON array output format is only supported for `--all'" +lintNoInput = "No configuration document specified; use '--file' or '--input'" +lintFailed = "Linting failed" +invalidLintRule = "Invalid lint rule option" +tableHeader_lintRule = "Rule" +tableHeader_lintCategory = "Category" +tableHeader_lintLevel = "Level" +tableHeader_lintTier = "Tier" [util] failedToConvertJsonToString = "Failed to convert JSON to string" diff --git a/dsc/src/args.rs b/dsc/src/args.rs index 69e6dba72..096d72c7e 100644 --- a/dsc/src/args.rs +++ b/dsc/src/args.rs @@ -5,6 +5,7 @@ use clap::{Parser, Subcommand, ValueEnum}; use clap_complete::Shell; use dsc_lib::dscresources::command_resource::TraceLevel; use dsc_lib::functions::FunctionCategory; +use dsc_lib::lint::{LintLevel, RuleLevelOverride}; use dsc_lib::progress::ProgressFormat; use dsc_lib::types::{FullyQualifiedTypeName, ResourceVersionReq, TypeNameFilter}; use rust_i18n::t; @@ -96,6 +97,29 @@ pub enum SubCommand { #[clap(subcommand)] subcommand: FunctionSubCommand, }, + #[clap(name = "lint", about = t!("args.lintAbout").to_string())] + Lint { + #[clap(short = 'i', long, help = t!("args.lintInput").to_string(), conflicts_with = "file")] + input: Option, + #[clap(short = 'f', long, help = t!("args.lintFile").to_string(), conflicts_with = "input")] + file: Vec, + #[clap(short = 'p', long, help = t!("args.parameters").to_string())] + parameters: Option, + #[clap(long, help = t!("args.parametersFile").to_string())] + parameters_file: Option, + #[clap(short = 'r', long, help = t!("args.systemRoot").to_string())] + system_root: Option, + #[clap(long, help = t!("args.noDiscovery").to_string())] + no_discovery: bool, + #[clap(long, help = t!("args.failOn").to_string(), value_enum)] + fail_on: Option, + #[clap(long = "rule", value_name = "RULE=LEVEL", help = t!("args.rule").to_string())] + rules: Vec, + #[clap(long, help = t!("args.listRules").to_string(), conflicts_with_all = ["input", "file"])] + list_rules: bool, + #[clap(short = 'o', long, help = t!("args.outputFormat").to_string())] + output_format: Option, + }, #[clap(name = "server", alias = "mcp", about = t!("args.serverAbout").to_string())] Server, #[clap(name = "resource", about = t!("args.resourceAbout").to_string())] @@ -318,6 +342,8 @@ pub enum SchemaType { FunctionDefinition, GetResult, Include, + LintResult, + LintRules, ManifestList, ResolveResult, Resource, diff --git a/dsc/src/main.rs b/dsc/src/main.rs index b2c83c020..ef65bef25 100644 --- a/dsc/src/main.rs +++ b/dsc/src/main.rs @@ -98,33 +98,24 @@ fn dsc_main() -> Result<(), ExitCode> { generate(shell, &mut cmd, "dsc", &mut io::stdout()); }, SubCommand::Config { subcommand, parameters, parameters_file, system_root, as_group, as_assert, as_include } => { - let Ok(params) = get_input(None, parameters_file.as_ref()) else { - return Err(ExitCode::from(util::EXIT_INVALID_INPUT)); - }; - let file_params = if params.is_empty() { - None - } else { - Some(params) - }; - - let merged_parameters = match (file_params, parameters) { - (Some(file_content), Some(inline_content)) => { - info!("{}", t!("main.mergingParameters")); - match util::merge_parameters(&file_content, &inline_content) { - Ok(merged) => Some(merged), - Err(err) => { - error!("{}: {err}", t!("main.failedMergingParameters")); - return Err(ExitCode::from(util::EXIT_INVALID_INPUT)) ; - } - } - }, - (Some(file_content), None) => Some(file_content), - (None, Some(inline_content)) => Some(inline_content), - (None, None) => None, - }; - + let merged_parameters = merge_parameter_inputs(parameters, parameters_file.as_ref())?; subcommand::config(&subcommand, &merged_parameters, system_root.as_ref(), &as_group, &as_assert, &as_include, progress_format)?; }, + SubCommand::Lint { input, file, parameters, parameters_file, system_root, no_discovery, fail_on, rules, list_rules, output_format } => { + let merged_parameters = merge_parameter_inputs(parameters, parameters_file.as_ref())?; + let args = subcommand::LintArgs { + input: input.as_ref(), + files: &file, + parameters: merged_parameters.as_ref(), + system_root: system_root.as_ref(), + no_discovery, + fail_on, + rules: &rules, + list_rules, + output_format: output_format.as_ref(), + }; + subcommand::lint(&args, progress_format)?; + }, SubCommand::Extension { subcommand } => { subcommand::extension(&subcommand, progress_format)?; }, @@ -157,6 +148,48 @@ fn dsc_main() -> Result<(), ExitCode> { Ok(()) } +/// Combine the inline parameters with the parameters file, if either was given. +/// +/// # Arguments +/// +/// * `parameters` - The inline parameters, as JSON or YAML. +/// * `parameters_file` - The path to a parameters file, or `-` to read it from STDIN. +/// +/// # Returns +/// +/// The merged parameters as JSON, or `None` when neither was given. Inline values take precedence +/// over values from the file. +/// +/// # Errors +/// +/// Returns `EXIT_INVALID_INPUT` when the file can't be read or the two inputs can't be merged. +fn merge_parameter_inputs(parameters: Option, parameters_file: Option<&String>) -> Result, ExitCode> { + let Ok(file_params) = get_input(None, parameters_file) else { + return Err(ExitCode::from(util::EXIT_INVALID_INPUT)); + }; + let file_params = if file_params.is_empty() { + None + } else { + Some(file_params) + }; + + match (file_params, parameters) { + (Some(file_content), Some(inline_content)) => { + info!("{}", t!("main.mergingParameters")); + match util::merge_parameters(&file_content, &inline_content) { + Ok(merged) => Ok(Some(merged)), + Err(err) => { + error!("{}: {err}", t!("main.failedMergingParameters")); + Err(ExitCode::from(util::EXIT_INVALID_INPUT)) + } + } + }, + (Some(file_content), None) => Ok(Some(file_content)), + (None, Some(inline_content)) => Ok(Some(inline_content)), + (None, None) => Ok(None), + } +} + fn ctrlc_handler() { use std::process::exit; warn!("{}", t!("main.ctrlCReceived")); diff --git a/dsc/src/subcommand.rs b/dsc/src/subcommand.rs index e776e5c2f..92cb1bd7b 100644 --- a/dsc/src/subcommand.rs +++ b/dsc/src/subcommand.rs @@ -5,7 +5,8 @@ use crate::args::{ConfigSubCommand, SchemaType, ExtensionSubCommand, FunctionSub use crate::resolve::{get_contents, Include}; use crate::resource_command::{get_resource, self}; use crate::tablewriter::Table; -use crate::util::{current_dsc_version, get_input, get_schema, in_desired_state, set_dscconfigroot, write_object, DSC_CONFIG_ROOT, EXIT_DSC_ASSERTION_FAILED, EXIT_DSC_ERROR, EXIT_INVALID_ARGS, EXIT_INVALID_INPUT, EXIT_JSON_ERROR}; +use crate::util::{current_dsc_version, get_input, get_schema, in_desired_state, read_input, set_dscconfigroot, write_object, DSC_CONFIG_ROOT, EXIT_DSC_ASSERTION_FAILED, EXIT_DSC_ERROR, EXIT_INVALID_ARGS, EXIT_INVALID_INPUT, EXIT_JSON_ERROR, EXIT_VALIDATION_FAILED}; +use dsc_lib::lint::{LintError, LintLevel, LintOptions, LintResult, Linter, RuleLevelOverride}; use dsc_lib::types::{FullyQualifiedTypeName, ResourceVersionReq, TypeNameFilter}; use dsc_lib::{ configure::{ @@ -38,7 +39,7 @@ use std::process::ExitCode; use std::{ collections::HashMap, io::{self, IsTerminal}, - path::Path + path::{Path, PathBuf} }; use tracing::{debug, error, trace}; @@ -333,26 +334,7 @@ pub fn config(subcommand: &ConfigSubCommand, parameters: &Option, mounte }, Some(parameters) => { debug!("{}", t!("subcommand.parameters")); - match serde_json::from_str(parameters) { - Ok(json) => Some(json), - Err(_) => { - match serde_yaml::from_str::(parameters) { - Ok(yaml) => { - match serde_json::to_value(yaml) { - Ok(json) => Some(json), - Err(err) => { - error!("{}: {err}", t!("subcommand.failedConvertJson")); - return Err(ExitCode::from(EXIT_DSC_ERROR)); - } - } - }, - Err(err) => { - error!("{}: {err}", t!("subcommand.invalidParameters")); - return Err(ExitCode::from(EXIT_INVALID_INPUT)); - } - } - } - } + Some(parse_parameters(parameters)?) } }; @@ -459,6 +441,194 @@ pub fn config(subcommand: &ConfigSubCommand, parameters: &Option, mounte Ok(()) } +/// Parse parameter input as JSON, falling back to YAML. +/// +/// # Arguments +/// +/// * `parameters` - The parameters as JSON or YAML text. +/// +/// # Errors +/// +/// Returns `EXIT_INVALID_INPUT` when the text is neither JSON nor YAML, or `EXIT_DSC_ERROR` when +/// the YAML can't be converted to JSON. +fn parse_parameters(parameters: &str) -> Result { + match serde_json::from_str(parameters) { + Ok(json) => Ok(json), + Err(_) => { + match serde_yaml::from_str::(parameters) { + Ok(yaml) => { + match serde_json::to_value(yaml) { + Ok(json) => Ok(json), + Err(err) => { + error!("{}: {err}", t!("subcommand.failedConvertJson")); + Err(ExitCode::from(EXIT_DSC_ERROR)) + } + } + }, + Err(err) => { + error!("{}: {err}", t!("subcommand.invalidParameters")); + Err(ExitCode::from(EXIT_INVALID_INPUT)) + } + } + } + } +} + +/// The arguments of the `dsc lint` command. +pub struct LintArgs<'a> { + /// The inline document, if given. + pub input: Option<&'a String>, + /// The documents to read from files; `-` reads STDIN. + pub files: &'a [String], + /// The merged parameters, if any were given. + pub parameters: Option<&'a String>, + /// The system root the configuration targets, if not the running OS. + pub system_root: Option<&'a String>, + /// Whether to skip resource discovery and the rules that need it. + pub no_discovery: bool, + /// The lowest diagnostic level that makes the command fail; `error` when not given. + pub fail_on: Option, + /// Rule level overrides for this run. + pub rules: &'a [RuleLevelOverride], + /// Whether to list the rules instead of linting. + pub list_rules: bool, + /// The output format. + pub output_format: Option<&'a OutputFormat>, +} + +/// Lint one or more configuration documents. +/// +/// # Arguments +/// +/// * `args` - The command arguments. +/// * `progress_format` - How to report progress during resource discovery. +/// +/// # Errors +/// +/// Returns `EXIT_INVALID_ARGS` for an unknown rule or missing input, `EXIT_INVALID_INPUT` when a +/// document or the parameters can't be read, `EXIT_VALIDATION_FAILED` when a diagnostic at or +/// above the `--fail-on` level was reported, `EXIT_JSON_ERROR` when the result can't be +/// serialized, and `EXIT_DSC_ERROR` for other failures. +pub fn lint(args: &LintArgs, progress_format: ProgressFormat) -> Result<(), ExitCode> { + let parameters = match args.parameters { + Some(parameters) => Some(parse_parameters(parameters)?), + None => None, + }; + let options = LintOptions { + discovery: !args.no_discovery, + rule_levels: args.rules.to_vec(), + parameters, + system_root: args.system_root.map(PathBuf::from), + dsc_version: Some(current_dsc_version()), + progress_format, + ..LintOptions::default() + }; + let mut linter = match Linter::new(options) { + Ok(linter) => linter, + Err(DscError::Lint(err @ (LintError::UnknownRule(_) | LintError::InvalidRuleOverride(_) | LintError::InvalidRuleLevel(_)))) => { + error!("{}: {err}", t!("subcommand.invalidLintRule")); + return Err(ExitCode::from(EXIT_INVALID_ARGS)); + }, + Err(err) => { + error!("{}: {err}", t!("subcommand.lintFailed")); + return Err(ExitCode::from(EXIT_DSC_ERROR)); + }, + }; + + if args.list_rules { + return list_lint_rules(&linter, args.output_format); + } + + let mut documents: Vec<(Option, String)> = Vec::new(); + if let Some(input) = args.input { + documents.push((None, read_input(Some(input), None)?)); + } + for file in args.files { + let document = read_input(None, Some(file))?; + let name = if file == "-" { None } else { Some(file.clone()) }; + documents.push((name, document)); + } + if documents.is_empty() { + error!("{}", t!("subcommand.lintNoInput")); + return Err(ExitCode::from(EXIT_INVALID_ARGS)); + } + + let mut result = LintResult::from_diagnostics(Vec::new(), true); + for (file, document) in documents { + linter.set_file(file); + let document_result = match linter.lint(&document) { + Ok(document_result) => document_result, + Err(DscError::Lint(err @ LintError::InvalidParameters(_))) => { + error!("{}: {err}", t!("subcommand.lintFailed")); + return Err(ExitCode::from(EXIT_INVALID_INPUT)); + }, + Err(err) => { + error!("{}: {err}", t!("subcommand.lintFailed")); + return Err(ExitCode::from(EXIT_DSC_ERROR)); + }, + }; + result.merge(document_result); + } + + let json = match serde_json::to_string(&result) { + Ok(json) => json, + Err(err) => { + error!("JSON: {err}"); + return Err(ExitCode::from(EXIT_JSON_ERROR)); + } + }; + write_object(&json, args.output_format, false)?; + + let threshold = args.fail_on.unwrap_or(LintLevel::Error); + if result.has_diagnostic_at_least(threshold) { + return Err(ExitCode::from(EXIT_VALIDATION_FAILED)); + } + Ok(()) +} + +/// Print the lint rules with the level each reports at in this run. +/// +/// # Arguments +/// +/// * `linter` - The linter whose rule levels to show. +/// * `format` - The output format; a table when not given and stdout is a terminal. +/// +/// # Errors +/// +/// Returns `EXIT_JSON_ERROR` when the rules can't be serialized. +fn list_lint_rules(linter: &Linter, format: Option<&OutputFormat>) -> Result<(), ExitCode> { + let rules = linter.rules(); + if format.is_none() && io::stdout().is_terminal() { + let mut table = Table::new(&[ + t!("subcommand.tableHeader_lintRule").to_string().as_ref(), + t!("subcommand.tableHeader_lintCategory").to_string().as_ref(), + t!("subcommand.tableHeader_lintLevel").to_string().as_ref(), + t!("subcommand.tableHeader_lintTier").to_string().as_ref(), + t!("subcommand.tableHeader_description").to_string().as_ref(), + ]); + for rule in rules { + table.add_row(vec![ + rule.id, + rule.category.to_string(), + rule.level.to_string(), + rule.tier.to_string(), + rule.description, + ]); + } + table.print(true); + return Ok(()); + } + + let json = match serde_json::to_string(&rules) { + Ok(json) => json, + Err(err) => { + error!("JSON: {err}"); + return Err(ExitCode::from(EXIT_JSON_ERROR)); + } + }; + write_object(&json, format, false) +} + /// Validate configuration. /// /// # Arguments diff --git a/dsc/src/util.rs b/dsc/src/util.rs index 7323cce51..c10b101a3 100644 --- a/dsc/src/util.rs +++ b/dsc/src/util.rs @@ -41,6 +41,7 @@ use dsc_lib::{ extension_manifest::ExtensionManifest, }, functions::FunctionDefinition, + lint::{LintResult, LintRuleInfo}, schemas::dsc_repo::{DscRepoSchema, RecognizedSchemaVersion, SchemaForm, SchemaUriPrefix}, types::SemanticVersion, util::{ @@ -227,6 +228,12 @@ pub fn get_schema(schema: SchemaType) -> Schema { SchemaType::Include => { schema_for!(Include) }, + SchemaType::LintResult => { + repo_schema::() + }, + SchemaType::LintRules => { + repo_schema::() + }, SchemaType::ManifestList => { schema_for!(ManifestList) }, @@ -455,7 +462,53 @@ pub fn enable_tracing(trace_level_arg: Option<&TraceLevel>, trace_format_arg: Op info!("Trace-level is {:?}", tracing_setting.level); } +/// Read an input document and convert it to a JSON string. +/// +/// # Arguments +/// +/// * `input` - The inline document, if provided. +/// * `file` - The path to the document, or `-` to read it from STDIN. +/// +/// # Returns +/// +/// The document as JSON, or an empty string when neither argument is provided. +/// +/// # Errors +/// +/// Returns `EXIT_INVALID_INPUT` when the document can't be read (see [`read_input`]) or isn't +/// valid JSON or YAML. pub fn get_input(input: Option<&String>, file: Option<&String>) -> Result { + let value = read_input(input, file)?; + if value.is_empty() { + return Ok(value); + } + + match parse_input_to_json(&value) { + Ok(json) => Ok(json), + Err(err) => { + error!("{}: {err}", t!("util.failedToParseInput")); + Err(ExitCode::from(EXIT_INVALID_INPUT)) + } + } +} + +/// Read an input document as text without parsing it. +/// +/// # Arguments +/// +/// * `input` - The inline document, if provided. +/// * `file` - The path to the document, or `-` to read it from STDIN. Extensions with the +/// `import` capability get the first chance to handle a file path. +/// +/// # Returns +/// +/// The document text, or an empty string when neither argument is provided. +/// +/// # Errors +/// +/// Returns `EXIT_INVALID_INPUT` when the inline input is actually a path to an existing file, the +/// file can't be read, STDIN isn't valid UTF-8, or the document is empty. +pub fn read_input(input: Option<&String>, file: Option<&String>) -> Result { trace!("Input: {input:?}, File: {file:?}"); let value = if let Some(input) = input { debug!("{}", t!("util.readingInput")); @@ -524,13 +577,7 @@ pub fn get_input(input: Option<&String>, file: Option<&String>) -> Result Ok(json), - Err(err) => { - error!("{}: {err}", t!("util.failedToParseInput")); - Err(ExitCode::from(EXIT_INVALID_INPUT)) - } - } + Ok(value) } /// Sets `DSC_CONFIG_ROOT` env var and makes path absolute. diff --git a/dsc/tests/dsc_lint.tests.ps1 b/dsc/tests/dsc_lint.tests.ps1 new file mode 100644 index 000000000..9f1c3989c --- /dev/null +++ b/dsc/tests/dsc_lint.tests.ps1 @@ -0,0 +1,301 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +Describe 'dsc lint tests' { + BeforeAll { + $schemaUri = 'https://aka.ms/dsc/schemas/v3/bundled/config/document.json' + $cleanConfig = @" +`$schema: $schemaUri +parameters: + greeting: + type: string + defaultValue: hello +resources: +- name: Echo + type: Microsoft.DSC.Debug/Echo + properties: + output: "[parameters('greeting')]" +"@ + # The document from https://github.com/PowerShell/DSC/issues/842 + $duplicateConfig = @" +`$schema: $schemaUri +resources: +- name: EnsureTest1Value + type: Microsoft.DSC.Debug/Echo + properties: + output: one +- name: EnsureTest2Value + type: Microsoft.DSC.Debug/Echo + properties: + output: two +- name: EnsureTest1Value + type: Microsoft.DSC.Debug/Echo + properties: + output: three +"@ + $unusedParameterConfig = @" +`$schema: $schemaUri +parameters: + unused: + type: string + defaultValue: x +resources: +- name: Echo + type: Microsoft.DSC.Debug/Echo + properties: + output: hello +"@ + } + + It 'reports no diagnostics for a clean document' { + $result = $cleanConfig | dsc lint --file - 2>$TestDrive/error.log | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 -Because (Get-Content $TestDrive/error.log -Raw) + $result.valid | Should -BeTrue + $result.discovery | Should -BeTrue + $result.diagnostics.Count | Should -Be 0 + $result.summary.errors | Should -Be 0 + $result.summary.warnings | Should -Be 0 + } + + It 'reports a duplicate resource and exits with code 5' { + $result = $duplicateConfig | dsc lint --file - --no-discovery 2>$TestDrive/error.log | ConvertFrom-Json + $LASTEXITCODE | Should -Be 5 -Because (Get-Content $TestDrive/error.log -Raw) + $result.valid | Should -BeFalse + $result.discovery | Should -BeFalse + $result.summary.errors | Should -Be 1 + $result.diagnostics.Count | Should -Be 1 + $result.diagnostics[0].rule | Should -BeExactly 'duplicate-resource' + $result.diagnostics[0].level | Should -BeExactly 'error' + $result.diagnostics[0].category | Should -BeExactly 'document' + $result.diagnostics[0].path | Should -BeExactly '/resources/2' + $result.diagnostics[0].message | Should -BeLike "*EnsureTest1Value*" + } + + It 'accepts the document with --input' { + $result = dsc lint --input $duplicateConfig --no-discovery 2>$TestDrive/error.log | ConvertFrom-Json + $LASTEXITCODE | Should -Be 5 -Because (Get-Content $TestDrive/error.log -Raw) + $result.diagnostics[0].rule | Should -BeExactly 'duplicate-resource' + } + + It 'treats warnings as non-fatal unless --fail-on warning is set' { + $result = $unusedParameterConfig | dsc lint --file - --no-discovery 2>$TestDrive/error.log | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 -Because (Get-Content $TestDrive/error.log -Raw) + $result.valid | Should -BeTrue + $result.summary.warnings | Should -Be 1 + $result.diagnostics[0].rule | Should -BeExactly 'no-unused-parameters' + $result.diagnostics[0].level | Should -BeExactly 'warning' + $result.diagnostics[0].path | Should -BeExactly '/parameters/unused' + + $null = $unusedParameterConfig | dsc lint --file - --no-discovery --fail-on warning 2>$TestDrive/error.log + $LASTEXITCODE | Should -Be 5 -Because (Get-Content $TestDrive/error.log -Raw) + } + + It 'can change the level of a rule with --rule' { + $result = $unusedParameterConfig | dsc lint --file - --no-discovery --rule no-unused-parameters=off 2>$TestDrive/error.log | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 -Because (Get-Content $TestDrive/error.log -Raw) + $result.diagnostics.Count | Should -Be 0 + + $result = $unusedParameterConfig | dsc lint --file - --no-discovery --rule no-unused-parameters=error 2>$TestDrive/error.log | ConvertFrom-Json + $LASTEXITCODE | Should -Be 5 -Because (Get-Content $TestDrive/error.log -Raw) + $result.valid | Should -BeFalse + $result.diagnostics[0].level | Should -BeExactly 'error' + } + + It 'rejects an unknown rule with exit code 1' { + $null = $cleanConfig | dsc lint --file - --no-discovery --rule no-such-rule=off 2>$TestDrive/error.log + $LASTEXITCODE | Should -Be 1 + (Get-Content $TestDrive/error.log -Raw) | Should -BeLike '*no-such-rule*' + } + + It 'reports a document that is not valid YAML as a diagnostic' { + $result = "resources:`n- name: a`n type: [unclosed" | dsc lint --file - --no-discovery 2>$TestDrive/error.log | ConvertFrom-Json + $LASTEXITCODE | Should -Be 5 -Because (Get-Content $TestDrive/error.log -Raw) + $result.valid | Should -BeFalse + $result.diagnostics.Count | Should -Be 1 + $result.diagnostics[0].rule | Should -BeExactly 'parse-error' + $result.diagnostics[0].path | Should -BeExactly '' + $result.diagnostics[0].location.line | Should -BeGreaterThan 0 + } + + It 'reports schema violations with the path of the problem' { + $config = @" +`$schema: $schemaUri +resources: +- name: a + type: Microsoft.DSC.Debug/Echo + unknownKey: true +"@ + $result = $config | dsc lint --file - --no-discovery 2>$TestDrive/error.log | ConvertFrom-Json + $LASTEXITCODE | Should -Be 5 -Because (Get-Content $TestDrive/error.log -Raw) + $violations = @($result.diagnostics | Where-Object rule -eq 'schema-invalid') + $violations.Count | Should -BeGreaterThan 0 + $violations.path | Should -Contain '/resources/0' + } + + It 'reports at ' -TestCases @( + @{ rule = 'undefined-parameter'; path = '/resources/0/properties/output'; property = "[parameters('missing')]" } + @{ rule = 'undefined-variable'; path = '/resources/0/properties/output'; property = "[variables('missing')]" } + @{ rule = 'unknown-function'; path = '/resources/0/properties/output'; property = "[noSuchFunction()]" } + @{ rule = 'invalid-argument-count'; path = '/resources/0/properties/output'; property = "[int('1', '2')]" } + @{ rule = 'invalid-argument-type'; path = '/resources/0/properties/output'; property = "[int(true)]" } + @{ rule = 'invalid-expression'; path = '/resources/0/properties/output'; property = "[concat('unterminated)]" } + @{ rule = 'unresolved-dependency'; path = '/resources/0/properties/output'; property = "[reference(resourceId('Microsoft.DSC.Debug/Echo', 'nobody'))]" } + ) { + param($rule, $path, $property) + $config = @" +`$schema: $schemaUri +resources: +- name: a + type: Microsoft.DSC.Debug/Echo + properties: + output: "$property" +"@ + $result = $config | dsc lint --file - --no-discovery 2>$TestDrive/error.log | ConvertFrom-Json + $LASTEXITCODE | Should -Be 5 -Because (Get-Content $TestDrive/error.log -Raw) + $diagnostic = $result.diagnostics | Where-Object rule -eq $rule + $diagnostic | Should -Not -BeNullOrEmpty -Because ($result.diagnostics | ConvertTo-Json -Depth 5) + $diagnostic.path | Should -BeExactly $path + $diagnostic.level | Should -BeExactly 'error' + } + + It 'reports circular dependencies' { + $config = @" +`$schema: $schemaUri +resources: +- name: a + type: Microsoft.DSC.Debug/Echo + dependsOn: + - "[resourceId('Microsoft.DSC.Debug/Echo', 'b')]" +- name: b + type: Microsoft.DSC.Debug/Echo + dependsOn: + - "[resourceId('Microsoft.DSC.Debug/Echo', 'a')]" +"@ + $result = $config | dsc lint --file - --no-discovery 2>$TestDrive/error.log | ConvertFrom-Json + $LASTEXITCODE | Should -Be 5 -Because (Get-Content $TestDrive/error.log -Raw) + $diagnostic = $result.diagnostics | Where-Object rule -eq 'circular-dependency' + $diagnostic.path | Should -BeExactly '/resources/0/dependsOn' + $diagnostic.message | Should -BeLike '*a -> b -> a*' + } + + It 'warns when --system-root is given but systemRoot() is not used' { + $result = $cleanConfig | dsc lint --file - --no-discovery --system-root $TestDrive 2>$TestDrive/error.log | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 -Because (Get-Content $TestDrive/error.log -Raw) + $diagnostic = $result.diagnostics | Where-Object rule -eq 'system-root-unused' + $diagnostic | Should -Not -BeNullOrEmpty + $diagnostic.level | Should -BeExactly 'warning' + $diagnostic.path | Should -BeExactly '' + + $config = @" +`$schema: $schemaUri +resources: +- name: a + type: Microsoft.DSC.Debug/Echo + properties: + output: "[systemRoot()]" +"@ + $result = $config | dsc lint --file - --no-discovery --system-root $TestDrive 2>$TestDrive/error.log | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 -Because (Get-Content $TestDrive/error.log -Raw) + $result.diagnostics | Where-Object rule -eq 'system-root-unused' | Should -BeNullOrEmpty + } + + It 'checks supplied parameter values' { + $config = @" +`$schema: $schemaUri +parameters: + count: + type: int + required: + type: string +resources: +- name: a + type: Microsoft.DSC.Debug/Echo + properties: + output: "[concat(string(parameters('count')), parameters('required'))]" +"@ + $result = $config | dsc lint --file - --no-discovery --parameters '{"parameters": {"count": "five", "extra": 1}}' 2>$TestDrive/error.log | ConvertFrom-Json + $LASTEXITCODE | Should -Be 5 -Because (Get-Content $TestDrive/error.log -Raw) + $result.diagnostics.rule | Should -Contain 'parameter-value-type' + $result.diagnostics.rule | Should -Contain 'missing-parameter-value' + $result.diagnostics.rule | Should -Contain 'undeclared-parameter-value' + } + + It 'reports unknown resource types only when discovery runs' { + $config = @" +`$schema: $schemaUri +resources: +- name: a + type: Not.Installed/Anywhere +"@ + $result = $config | dsc lint --file - 2>$TestDrive/error.log | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 -Because (Get-Content $TestDrive/error.log -Raw) + $result.discovery | Should -BeTrue + $diagnostic = $result.diagnostics | Where-Object rule -eq 'unknown-resource-type' + $diagnostic.level | Should -BeExactly 'warning' + $diagnostic.path | Should -BeExactly '/resources/0/type' + + $result = $config | dsc lint --file - --no-discovery 2>$TestDrive/error.log | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 -Because (Get-Content $TestDrive/error.log -Raw) + $result.discovery | Should -BeFalse + $result.diagnostics | Where-Object rule -eq 'unknown-resource-type' | Should -BeNullOrEmpty + } + + It 'lints several files in one run and records the file on each diagnostic' { + Set-Content -Path "$TestDrive/clean.dsc.yaml" -Value $cleanConfig + Set-Content -Path "$TestDrive/duplicate.dsc.yaml" -Value $duplicateConfig + $result = dsc lint --file "$TestDrive/clean.dsc.yaml" --file "$TestDrive/duplicate.dsc.yaml" --no-discovery 2>$TestDrive/error.log | ConvertFrom-Json + $LASTEXITCODE | Should -Be 5 -Because (Get-Content $TestDrive/error.log -Raw) + $result.diagnostics.Count | Should -Be 1 + $result.diagnostics[0].file | Should -BeLike '*duplicate.dsc.yaml' + } + + It 'rejects a resource manifest' { + $manifest = @" +`$schema: https://aka.ms/dsc/schemas/v3/bundled/resource/manifest.json +type: Test/Example +version: 1.0.0 +"@ + $result = $manifest | dsc lint --file - --no-discovery 2>$TestDrive/error.log | ConvertFrom-Json + $LASTEXITCODE | Should -Be 5 -Because (Get-Content $TestDrive/error.log -Raw) + $result.diagnostics.Count | Should -Be 1 + $result.diagnostics[0].rule | Should -BeExactly 'unsupported-document-type' + } + + It 'lists the rules with their levels' { + $rules = dsc lint --list-rules --output-format json 2>$TestDrive/error.log | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 -Because (Get-Content $TestDrive/error.log -Raw) + $rules.id | Should -Contain 'duplicate-resource' + $rules.id | Should -Contain 'parse-error' + ($rules | Where-Object id -eq 'parse-error').fixed | Should -BeTrue + $duplicate = $rules | Where-Object id -eq 'duplicate-resource' + $duplicate.defaultLevel | Should -BeExactly 'error' + $duplicate.level | Should -BeExactly 'error' + $duplicate.tier | Should -BeExactly 'static' + ($rules | Where-Object id -eq 'unknown-resource-type').tier | Should -BeExactly 'resource' + + $rules = dsc lint --list-rules --rule duplicate-resource=warning --output-format json 2>$TestDrive/error.log | ConvertFrom-Json + ($rules | Where-Object id -eq 'duplicate-resource').level | Should -BeExactly 'warning' + } + + It 'exposes the output schemas' { + $schema = dsc schema --type lint-result 2>$TestDrive/error.log | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 -Because (Get-Content $TestDrive/error.log -Raw) + $schema.properties.diagnostics | Should -Not -BeNullOrEmpty + $schema.properties.summary | Should -Not -BeNullOrEmpty + + $schema = dsc schema --type lint-rules 2>$TestDrive/error.log | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 -Because (Get-Content $TestDrive/error.log -Raw) + $schema.properties.defaultLevel | Should -Not -BeNullOrEmpty + } + + It 'lints the shipped example without errors: ' -TestCases ( + Get-ChildItem (Join-Path $PSScriptRoot '../examples') -Filter '*.dsc.*' | + Where-Object { $_.Extension -in @('.yaml', '.json') -and $_.Name -ne 'invalid_schema.dsc.yaml' } | + ForEach-Object { @{ name = $_.Name; path = $_.FullName } } + ) { + param($name, $path) + $result = dsc lint --file $path --no-discovery 2>$TestDrive/error.log | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 -Because ($result.diagnostics | ConvertTo-Json -Depth 5) + $result.summary.errors | Should -Be 0 + } +} diff --git a/lib/dsc-lib/locales/en-us.toml b/lib/dsc-lib/locales/en-us.toml index a376a7db7..eb0da3faa 100644 --- a/lib/dsc-lib/locales/en-us.toml +++ b/lib/dsc-lib/locales/en-us.toml @@ -1048,3 +1048,134 @@ invalidRegex = "wildcard type name '%{name}' could not be converted into a valid invalidTypeName = "invalid wildcard type name '%{text}': %{err}" missingNameSegment = "missing name segment when no wildcard in the pattern can match a name segment" noWildcard = "wildcard type name '%{text}' doesn't contain any wildcard characters (`*`)" + +[lint.mod] +unknownRule = "Unknown lint rule" +invalidRuleOverride = "Invalid rule level override, expected '='" +invalidRuleLevel = "Invalid rule level, expected 'off', 'info', 'warning', or 'error'" +invalidSettings = "The 'lint' setting is invalid" +invalidParameters = "The supplied parameters are invalid" +fixedRuleOverrideIgnored = "The level of lint rule '%{rule}' is fixed and can't be changed; the override was ignored" +unknownRuleInSettings = "The settings file configures unknown lint rule '%{rule}'; the entry was ignored" +parseError = "The document isn't valid JSON or YAML: %{error}" +parseErrorDescription = "The document must be valid JSON or YAML." +schemaInvalidDescription = "The document must validate against the configuration document JSON schema." +documentInvalid = "The document couldn't be loaded as a configuration: %{error}" +unsupportedDocumentType = "Only configuration documents can be linted; the '$schema' identifies a %{kind}" +unsupportedDocumentTypeDescription = "The document must be a configuration document; manifests aren't supported yet." +discoveryFailed = "Resource discovery failed, so the rules that inspect installed resources were skipped: %{error}" +discoveryFailedDescription = "Resource discovery must succeed for the rules that inspect installed resources to run." + +[lint.options] +documentKindConfiguration = "configuration document" +documentKindResourceManifest = "resource manifest" +documentKindExtensionManifest = "extension manifest" + +[lint.rules.document] +duplicateResourceDescription = "Resource names must be unique among resources of the same type at the same level." +duplicateResource = "Resource '%{name}' of type '%{type_name}' is declared more than once" +duplicateResourceNameDescription = "Resources at the same level should have distinct names even when their types differ, because dependencies are resolved by name first." +duplicateResourceName = "Resource name '%{name}' is used by both '%{first_type}' and '%{second_type}'; dependencies are resolved by name first, so use distinct names" +emptyResourcesDescription = "The configuration should declare at least one resource." +emptyResources = "The configuration doesn't declare any resources" +unknownMicrosoftDscMetadataDescription = "Keys under 'Microsoft.DSC' metadata must be ones DSC recognizes; unknown keys are silently ignored." +unknownMicrosoftDscMetadata = "Unknown key '%{key}' under 'Microsoft.DSC' metadata is ignored" +apiVersionAliasDescription = "Resources should use 'requireVersion' rather than its alias 'apiVersion'." +apiVersionAlias = "'apiVersion' is an alias of 'requireVersion'; use 'requireVersion'" +dateVersionDescription = "Version requirements should be semantic; date-based versions are deprecated." +dateVersion = "Date-based version requirement '%{version}' is deprecated; use a semantic version requirement" +missingRequireVersionDescription = "Resources should pin the version they require with 'requireVersion'." +missingRequireVersion = "Resource '%{name}' doesn't pin the version of '%{type_name}' with 'requireVersion'" + +[lint.rules.expression] +invalidExpressionDescription = "Strings that start with '[' must be valid expressions; escape a literal bracket with '[['." +invalidExpression = "Expression '%{expression}' failed to parse: %{error}" +unknownFunctionDescription = "Expressions may only call built-in functions or user-defined functions declared in 'functions'." +unknownFunction = "Unknown function '%{name}'" +unknownFunctionSuggestion = "Unknown function '%{name}'; function names are case-sensitive, did you mean '%{suggestion}'?" +unknownUserFunction = "Unknown user-defined function '%{name}'; it isn't declared in 'functions'" +invalidArgumentCountDescription = "Function calls must pass the number of arguments the function accepts." +invalidArgumentTypeDescription = "Literal arguments must be of a kind the function accepts at that position." +undefinedParameterDescription = "parameters() must reference a parameter declared in 'parameters'." +undefinedParameter = "Parameter '%{name}' isn't declared in 'parameters'" +undefinedVariableDescription = "variables() must reference a variable declared in 'variables'." +undefinedVariable = "Variable '%{name}' isn't declared in 'variables'" +conditionNotExpressionDescription = "A 'condition' must be an expression that evaluates to true; a plain string never does." +conditionNotExpression = "Condition '%{condition}' is a plain string, so it never evaluates to true and the item is always skipped" + +[lint.rules.parameter] +noUnusedParametersDescription = "Declared parameters should be referenced by at least one expression." +noUnusedParameters = "Parameter '%{name}' is declared but never used" +noUnusedVariablesDescription = "Declared variables should be referenced by at least one expression." +noUnusedVariables = "Variable '%{name}' is declared but never used" +parameterDefaultTypeDescription = "A literal default value must satisfy the parameter's type and constraints." +parameterDefaultType = "The default value of parameter '%{name}' is invalid: %{reason}" +valueNotOfType = "the value isn't of type '%{type_name}'" +invalidParameterConstraintsDescription = "Parameter constraints must be consistent with each other and with the parameter type." +minValueExceedsMaxValue = "Parameter '%{name}' has a minValue of %{min} that is greater than its maxValue of %{max}" +minLengthExceedsMaxLength = "Parameter '%{name}' has a minLength of %{min} that is greater than its maxLength of %{max}" +valueLimitOnNonInt = "Parameter '%{name}' has '%{constraint}' but its type is '%{type_name}'; value limits only apply to 'int' parameters" +lengthLimitOnOtherType = "Parameter '%{name}' has '%{constraint}' but its type is '%{type_name}'; length limits only apply to 'string', 'secureString', and 'array' parameters" +negativeLength = "Parameter '%{name}' has a negative '%{constraint}'" +emptyAllowedValues = "Parameter '%{name}' has an empty 'allowedValues' list, so no value is allowed" +allowedValueTypeMismatch = "Parameter '%{name}' allows a value that isn't of type '%{type_name}'" +secureParameterDefaultDescription = "Secure parameters shouldn't have a hard-coded default value, because anyone with the document can read it." +secureParameterDefault = "Secure parameter '%{name}' has a hard-coded default value" +preferSecureTypeDescription = "Parameters whose names suggest a secret should use the 'secureString' or 'secureObject' type." +preferSecureType = "Parameter '%{name}' looks like a secret; use type '%{secure_type}' instead of '%{type_name}'" +secureValueInOutputDescription = "Outputs shouldn't expose secure values; DSC skips such outputs at runtime." +secureValueInOutput = "Output '%{name}' exposes the secure value from %{source}, so DSC skips it at runtime" +useParameterDescriptionDescription = "Parameters should have a description." +useParameterDescription = "Parameter '%{name}' has no description" +undeclaredParameterValueDescription = "Supplied parameter values must match a declared parameter." +undeclaredParameterValue = "A value was supplied for parameter '%{name}', which isn't declared in 'parameters'" +missingParameterValueDescription = "Parameters without a default value must be supplied a value." +missingParameterValue = "Parameter '%{name}' has no default value and no value was supplied" +parameterValueTypeDescription = "Supplied parameter values must satisfy the parameter's type and constraints." +parameterValueType = "The supplied value for parameter '%{name}' is invalid: %{reason}" + +[lint.rules.dependency] +unresolvedDependencyDescription = "dependsOn entries and reference() calls must point at a resource declared at the same level." +unresolvedDependency = "Resource '%{name}' depends on '%{dependency_type}' named '%{dependency_name}', which isn't declared" +dependencyTypeMismatch = "Resource '%{name}' depends on '%{dependency_type}' named '%{dependency_name}', but that resource has type '%{actual_type}'" +circularDependencyDescription = "Resource dependencies must not form a cycle." +circularDependency = "Resource '%{name}' is part of a dependency cycle: %{cycle}" +referenceWithoutDependsOnDescription = "A resource that references another resource must list it in 'dependsOn' so the referenced resource runs first." +referenceWithoutDependsOn = "Resource '%{name}' references '%{target}' but doesn't list it in 'dependsOn'; the reference fails unless that resource has already run" +duplicateDependsOnDescription = "A dependency should be listed once." +duplicateDependsOn = "Resource '%{name}' lists the dependency '%{target}' more than once" + +[lint.rules.deprecation] +copyLoopDeprecatedDescription = "Copy loops are deprecated and will be removed in a future release." +copyLoopDeprecated = "Copy loop '%{name}' is deprecated and will be removed in a future release; see https://github.com/PowerShell/DSC/issues/1429" +copyOptionsUnsupportedDescription = "Copy loops don't support 'mode' or 'batchSize'." +copyOptionsUnsupported = "Copy loop option '%{option}' isn't supported" +securityContextInMetadataDescription = "The security context should be set with 'directives.securityContext' rather than 'Microsoft.DSC' metadata." +securityContextInMetadata = "Setting the security context in 'Microsoft.DSC' metadata is deprecated; use 'directives.securityContext'" +securityContextConflictDescription = "The security context in metadata and in directives must agree." +securityContextConflict = "The security context '%{metadata}' in metadata conflicts with '%{directive}' in directives" +dscVersionUnsatisfiedDescription = "The DSC version requirement in 'directives.version' should be satisfied by the version of DSC in use." +dscVersionUnsatisfied = "The document requires DSC version '%{required}', but this is DSC version '%{current}'" + +[lint.rules.environment] +systemRootUnusedDescription = "When a system root is specified, the document should use systemRoot() so its paths target that root." +systemRootUnused = "A system root of '%{path}' was specified, but no expression calls systemRoot(), so the document doesn't target it" +hardcodedSystemPathDescription = "Absolute operating system paths should be built with systemRoot() so the document can target another root." +hardcodedSystemPath = "'%{value}' is an absolute operating system path; build it with systemRoot() so the document can target another root" + +[lint.rules.resource] +unknownResourceTypeDescription = "Each resource type must be installed." +unknownResourceType = "Resource type '%{type_name}' isn't installed" +unsatisfiedRequireVersionDescription = "An installed version of the resource type must satisfy 'requireVersion'." +unsatisfiedRequireVersion = "No installed version of '%{type_name}' satisfies the requirement '%{version}'" +deprecatedResourceDescription = "Resources shouldn't use deprecated resource types." +deprecatedResource = "Resource type '%{type_name}' is deprecated: %{message}" +propertySchemaMismatchDescription = "Literal property values must validate against the resource's schema." +propertySchemaMismatch = "Property doesn't validate against the schema of '%{type_name}': %{message}" +propertySchemaCompileFailed = "The schema of '%{type_name}' couldn't be compiled, so its properties weren't checked: %{error}" +nestedResourcesUnsupportedDescription = "Only group and adapter resources can declare nested resources." +nestedResourcesUnsupported = "Resource '%{name}' declares nested resources, but '%{type_name}' is a '%{kind}' resource, not a group or adapter" +existFalseUnsupportedDescription = "A resource type must support delete or handle _exist itself for '_exist: false' to work." +existFalseUnsupported = "Resource type '%{type_name}' has neither the 'delete' nor the 'setHandlesExist' capability, so 'dsc config set' fails for '_exist: false'" +canonicalTypeNameDescription = "Resource types should be written with the casing their manifest uses." +canonicalTypeName = "Resource type '%{type_name}' is written as '%{canonical}' in its manifest" diff --git a/lib/dsc-lib/locales/schemas.outputs.yaml b/lib/dsc-lib/locales/schemas.outputs.yaml new file mode 100644 index 000000000..6220b1efd --- /dev/null +++ b/lib/dsc-lib/locales/schemas.outputs.yaml @@ -0,0 +1,27 @@ +_version: 2 +schemas: + outputs: + lint: + result: + title: + en-us: dsc lint result + description: + en-us: >- + Describes the output of the `dsc lint` command. + markdownDescription: + en-us: |- + Describes the output of the `dsc lint` command: the diagnostics the lint rules + reported for the document, counts of the diagnostics by level, whether the document + is free of error-level diagnostics, and whether resource discovery ran. + rules: + title: + en-us: dsc lint rule + description: + en-us: >- + Describes a lint rule and the level it reports at, as listed by `dsc lint --list-rules`. + markdownDescription: + en-us: |- + Describes a lint rule and the level it reports at, as listed by + `dsc lint --list-rules`. The `defaultLevel` is the level the rule reports at unless + it is overridden; `level` is the level in effect after the settings file and any + `--rule` overrides are applied. diff --git a/lib/dsc-lib/src/configure/mod.rs b/lib/dsc-lib/src/configure/mod.rs index 3ed7fd523..a612970fc 100644 --- a/lib/dsc-lib/src/configure/mod.rs +++ b/lib/dsc-lib/src/configure/mod.rs @@ -331,7 +331,16 @@ fn add_metadata(dsc_resource: &DscResource, mut properties: Option) -> Option { +/// Get the adapter a resource requires from its directives, if one is specified. +/// +/// # Arguments +/// +/// * `resource_directives` - The directives of the resource. +/// +/// # Returns +/// +/// The required adapter type name, or `None` when the directives don't require one. +pub(crate) fn get_require_adapter_from_directive(resource_directives: &Option) -> Option { if let Some(directives) = resource_directives && let Some(require_adapter) = &directives.require_adapter { return Some(require_adapter.clone()); diff --git a/lib/dsc-lib/src/dscerror.rs b/lib/dsc-lib/src/dscerror.rs index 70d75140f..9a430b8ff 100644 --- a/lib/dsc-lib/src/dscerror.rs +++ b/lib/dsc-lib/src/dscerror.rs @@ -110,6 +110,9 @@ pub enum DscError { #[error("{t}: {0}", t = t!("dscerror.operation"))] Operation(String), + #[error(transparent)] + Lint(#[from] crate::lint::LintError), + #[error("{t}: {0}", t = t!("dscerror.parser"))] Parser(String), diff --git a/lib/dsc-lib/src/dscresources/dscresource.rs b/lib/dsc-lib/src/dscresources/dscresource.rs index 735dbf634..4b5f8f677 100644 --- a/lib/dsc-lib/src/dscresources/dscresource.rs +++ b/lib/dsc-lib/src/dscresources/dscresource.rs @@ -938,6 +938,53 @@ pub fn validate_json(source: &str, schema: &Value, json: &Value) -> Result<(), D Ok(()) } +/// A single violation found when validating JSON against a schema. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SchemaViolation { + /// JSON pointer (RFC 6901) to the location in the instance that failed validation. + pub instance_path: String, + /// JSON pointer to the keyword in the schema that the instance violated. + pub schema_path: String, + /// The validation error message. + pub message: String, +} + +/// Validate the JSON against the schema and return every violation. +/// +/// Unlike [`validate_json`], which stops at the first error, this function collects all +/// violations so callers can report each one with its location. +/// +/// # Arguments +/// +/// * `source` - The source of the JSON, used for logging +/// * `schema` - The schema to validate against +/// * `json` - The JSON to validate +/// +/// # Returns +/// +/// Every violation found; an empty list means the JSON is valid. +/// +/// # Errors +/// +/// * `DscError::Validation` - The schema failed to compile +pub fn validate_json_all(source: &str, schema: &Value, json: &Value) -> Result, DscError> { + debug!("{}: {source}", t!("dscresources.dscresource.validatingSchema")); + trace!("{}", t!("dscresources.dscresource.json", json = json)); + trace!("{}", t!("dscresources.dscresource.schema", schema = schema)); + let compiled_schema = match Validator::new(schema) { + Ok(compiled_schema) => compiled_schema, + Err(err) => { + return Err(DscError::Validation(format!("{}: {err}", t!("dscresources.dscresource.failedToCompileSchema")))); + } + }; + + Ok(compiled_schema.iter_errors(json).map(|error| SchemaViolation { + instance_path: error.instance_path().to_string(), + schema_path: error.schema_path().to_string(), + message: error.to_string(), + }).collect()) +} + /// Compares two arrays independent of order fn is_same_array(expected: &Vec, actual: &Vec) -> bool { if expected.len() != actual.len() { diff --git a/lib/dsc-lib/src/functions/mod.rs b/lib/dsc-lib/src/functions/mod.rs index 0afa178ea..6a85cbe62 100644 --- a/lib/dsc-lib/src/functions/mod.rs +++ b/lib/dsc-lib/src/functions/mod.rs @@ -286,22 +286,7 @@ impl FunctionDispatcher { let metadata = function.get_metadata(); - // check if arg number are valid - let min_args = metadata.min_args; - let max_args = metadata.max_args; - if args.len() < min_args || args.len() > max_args { - if max_args == 0 { - return Err(DscError::Parser(t!("functions.noArgsAccepted", name = name).to_string())); - } - else if min_args == max_args { - return Err(DscError::Parser(t!("functions.invalidArgCount", name = name, count = min_args).to_string())); - } - else if max_args == usize::MAX { - return Err(DscError::Parser(t!("functions.minArgsRequired", name = name, count = min_args).to_string())); - } - - return Err(DscError::Parser(t!("functions.argCountRequired", name = name, min = min_args, max = max_args).to_string())); - } + Self::check_arg_count(name, &metadata, args.len())?; for (index, value) in args.iter().enumerate() { if index >= metadata.accepted_arg_ordered_types.len() { @@ -330,6 +315,98 @@ impl FunctionDispatcher { } } + /// Get the metadata of a built-in function. + /// + /// # Arguments + /// + /// * `name` - The name of the function; names are case-sensitive. + /// + /// # Returns + /// + /// The metadata of the function, or `None` when no built-in function has that name. + #[must_use] + pub fn get_metadata(&self, name: &str) -> Option { + self.functions.get(name).map(|function| function.get_metadata()) + } + + /// Validate that a built-in function accepts the given number of arguments without invoking it. + /// + /// # Arguments + /// + /// * `name` - The name of the function; names are case-sensitive. + /// * `count` - The number of arguments that would be passed. + /// + /// # Errors + /// + /// Returns `DscError::Parser` when the function is unknown or the count is outside the range + /// the function accepts. The messages are the same ones [`invoke`](Self::invoke) raises. + pub fn validate_arg_count(&self, name: &str, count: usize) -> Result<(), DscError> { + let Some(metadata) = self.get_metadata(name) else { + return Err(DscError::Parser(t!("functions.unknownFunction", name = name).to_string())); + }; + Self::check_arg_count(name, &metadata, count) + } + + /// Validate literal arguments against the kinds a built-in function accepts without invoking it. + /// + /// # Arguments + /// + /// * `name` - The name of the function; names are case-sensitive. + /// * `args` - One entry per argument in order. `None` marks an argument whose value isn't + /// known statically, such as a nested expression; those entries are skipped. + /// + /// # Errors + /// + /// Returns `DscError::Parser` when the function is unknown or a literal argument has a kind + /// the function doesn't accept at that position. The messages are the same ones + /// [`invoke`](Self::invoke) raises. + pub fn validate_literal_args(&self, name: &str, args: &[Option<&Value>]) -> Result<(), DscError> { + let Some(metadata) = self.get_metadata(name) else { + return Err(DscError::Parser(t!("functions.unknownFunction", name = name).to_string())); + }; + for (index, arg) in args.iter().enumerate() { + let Some(value) = arg else { + continue; + }; + if let Some(expected_types) = metadata.accepted_arg_ordered_types.get(index) { + Self::check_arg_against_expected_types(name, value, expected_types)?; + } else if let Some(remaining_types) = &metadata.remaining_arg_accepted_types { + Self::check_arg_against_expected_types(name, value, remaining_types)?; + } + } + Ok(()) + } + + /// Check that an argument count is within the range a function accepts. + /// + /// # Arguments + /// + /// * `name` - The name of the function, used in the error message. + /// * `metadata` - The metadata of the function. + /// * `count` - The number of arguments. + /// + /// # Errors + /// + /// Returns `DscError::Parser` describing the accepted range when `count` is outside it. + fn check_arg_count(name: &str, metadata: &FunctionMetadata, count: usize) -> Result<(), DscError> { + let min_args = metadata.min_args; + let max_args = metadata.max_args; + if count < min_args || count > max_args { + if max_args == 0 { + return Err(DscError::Parser(t!("functions.noArgsAccepted", name = name).to_string())); + } + else if min_args == max_args { + return Err(DscError::Parser(t!("functions.invalidArgCount", name = name, count = min_args).to_string())); + } + else if max_args == usize::MAX { + return Err(DscError::Parser(t!("functions.minArgsRequired", name = name, count = min_args).to_string())); + } + + return Err(DscError::Parser(t!("functions.argCountRequired", name = name, min = min_args, max = max_args).to_string())); + } + Ok(()) + } + fn check_arg_against_expected_types(name: &str, arg: &Value, expected_types: &[FunctionArgKind]) -> Result<(), DscError> { let is_lambda = arg.as_str().is_some_and(|s| s.starts_with("__lambda_")); diff --git a/lib/dsc-lib/src/lib.rs b/lib/dsc-lib/src/lib.rs index 9540b2385..d6df364f7 100644 --- a/lib/dsc-lib/src/lib.rs +++ b/lib/dsc-lib/src/lib.rs @@ -17,6 +17,7 @@ pub mod dscerror; pub mod dscresources; pub mod extensions; pub mod functions; +pub mod lint; pub mod parser; pub mod progress; pub mod schemas; diff --git a/lib/dsc-lib/src/lint/context.rs b/lib/dsc-lib/src/lint/context.rs new file mode 100644 index 000000000..77e251df6 --- /dev/null +++ b/lib/dsc-lib/src/lint/context.rs @@ -0,0 +1,202 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use std::collections::{HashMap, HashSet}; + +use serde_json::Value; + +use crate::{ + configure::config_doc::{Configuration, Parameter, Resource}, + dscerror::DscError, + dscresources::dscresource::DscResource, + functions::FunctionDispatcher, +}; + +use super::{expression_index::ExpressionIndex, options::LintOptions, pointer::JsonPointer}; + +/// A resource declaration and where it sits in the document. +pub struct ResourceSite<'a> { + /// The pointer to the resource declaration. + pub pointer: JsonPointer, + /// The resource declaration. + pub resource: &'a Resource, + /// The index of the resource that declares this one in its nested `resources`, if any. + pub parent: Option, +} + +/// The outcome of discovering the resource type a declaration uses. +pub struct ResolvedResource { + /// The installed resource that satisfies the declaration, if one was found. + pub found: Option, + /// Whether any version of the resource type is installed, regardless of `requireVersion`. + pub type_exists: bool, +} + +/// Everything a rule can inspect. +pub struct LintContext<'a> { + /// The document as parsed, for checks that depend on how it was written. + pub raw: &'a Value, + /// The document as a typed configuration. + pub config: &'a Configuration, + /// Every resource declaration, depth first, with nested resources after their parent. + pub resources: Vec>, + /// Every expression in the document, parsed once. + pub expressions: ExpressionIndex, + /// The options of the lint run. + pub options: &'a LintOptions, + /// The built-in functions, for checking calls without evaluating them. + pub functions: &'a FunctionDispatcher, + /// The parameter values supplied for the run, when any were. + pub supplied_parameters: Option>, + /// The discovered resource for each entry of `resources`, when discovery ran. + pub discovered: Option>, +} + +impl LintContext<'_> { + /// Whether a string is an expression, as opposed to a plain or escaped (`[[`) string. + /// + /// # Arguments + /// + /// * `text` - The string to classify. + #[must_use] + pub fn is_expression(text: &str) -> bool { + text.starts_with('[') && !text.starts_with("[[") + } + + /// Group the resources that share a parent, in document order. + /// + /// # Returns + /// + /// The indexes into `resources` for each group, top-level resources first. + #[must_use] + pub fn sibling_groups(&self) -> Vec> { + let mut groups: Vec<(Option, Vec)> = Vec::new(); + for (index, site) in self.resources.iter().enumerate() { + match groups.iter_mut().find(|(parent, _)| *parent == site.parent) { + Some((_, members)) => members.push(index), + None => groups.push((site.parent, vec![index])), + } + } + groups.into_iter().map(|(_, members)| members).collect() + } + + /// The raw value of a resource declaration. + /// + /// # Arguments + /// + /// * `index` - The index into `resources`. + #[must_use] + pub fn raw_resource(&self, index: usize) -> Option<&Value> { + self.resources.get(index).and_then(|site| site.pointer.resolve(self.raw)) + } + + /// The declared parameter with the given name. + /// + /// # Arguments + /// + /// * `name` - The parameter name. + #[must_use] + pub fn declared_parameter(&self, name: &str) -> Option<&Parameter> { + self.config.parameters.as_ref().and_then(|parameters| parameters.get(name)) + } + + /// The fully qualified names (`namespace.member`) of the user-defined functions. + #[must_use] + pub fn user_function_names(&self) -> HashSet { + let mut names = HashSet::new(); + if let Some(functions) = &self.config.functions { + for function in functions { + for member in function.members.keys() { + names.insert(format!("{}.{member}", function.namespace)); + } + } + } + names + } + + /// The text of an error without the error-kind prefix, for use in a diagnostic message. + /// + /// # Arguments + /// + /// * `error` - The error to describe. + #[must_use] + pub fn error_message(error: &DscError) -> String { + match error { + DscError::Parser(message) | DscError::Validation(message) => message.clone(), + other => other.to_string(), + } + } +} + +/// Flatten the resources of a configuration into sites, depth first. +/// +/// # Arguments +/// +/// * `config` - The configuration. +#[must_use] +pub fn collect_resource_sites(config: &Configuration) -> Vec> { + let mut sites = Vec::new(); + collect_sites(&config.resources, &JsonPointer::root().push("resources"), None, &mut sites); + sites +} + +/// Append the sites for a list of resources and their nested resources. +/// +/// # Arguments +/// +/// * `resources` - The resources to add. +/// * `pointer` - The pointer to the `resources` array. +/// * `parent` - The index of the resource that declares this list, if any. +/// * `sites` - Where sites are appended. +fn collect_sites<'a>(resources: &'a [Resource], pointer: &JsonPointer, parent: Option, sites: &mut Vec>) { + for (index, resource) in resources.iter().enumerate() { + let resource_pointer = pointer.push_index(index); + let site_index = sites.len(); + sites.push(ResourceSite { + pointer: resource_pointer.clone(), + resource, + parent, + }); + if let Some(children) = &resource.resources { + collect_sites(children, &resource_pointer.push("resources"), Some(site_index), sites); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn collects_nested_resources_depth_first() { + let config: Configuration = serde_yaml::from_str(r" +$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json +resources: +- name: group + type: Microsoft.DSC/Group + properties: + resources: + - name: inner + type: Microsoft.DSC.Debug/Echo + properties: + output: hi + resources: + - name: nested + type: Microsoft.DSC.Debug/Echo +- name: last + type: Microsoft.DSC.Debug/Echo +").unwrap(); + let sites = collect_resource_sites(&config); + let pointers: Vec<&str> = sites.iter().map(|site| site.pointer.as_str()).collect(); + assert_eq!(pointers, vec!["/resources/0", "/resources/0/resources/0", "/resources/1"]); + assert_eq!(sites[1].parent, Some(0)); + assert_eq!(sites[2].parent, None); + } + + #[test] + fn classifies_expressions() { + assert!(LintContext::is_expression("[parameters('x')]")); + assert!(!LintContext::is_expression("[[literal]")); + assert!(!LintContext::is_expression("plain")); + } +} diff --git a/lib/dsc-lib/src/lint/diagnostic.rs b/lib/dsc-lib/src/lint/diagnostic.rs new file mode 100644 index 000000000..01a20a742 --- /dev/null +++ b/lib/dsc-lib/src/lint/diagnostic.rs @@ -0,0 +1,234 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use std::fmt::Display; + +use clap::ValueEnum; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use crate::schemas::{dsc_repo::DscRepoSchema, transforms::idiomaticize_string_enum}; + +/// The severity of a diagnostic. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Deserialize, Serialize, JsonSchema, ValueEnum)] +#[serde(rename_all = "camelCase")] +#[schemars(inline, transform = idiomaticize_string_enum)] +pub enum LintLevel { + /// A suggestion that doesn't affect whether the document works. + Info, + /// A problem that is likely to cause unexpected behavior. + Warning, + /// A problem that prevents the document from working. + Error, +} + +impl Display for LintLevel { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let text = match self { + LintLevel::Info => "info", + LintLevel::Warning => "warning", + LintLevel::Error => "error", + }; + write!(f, "{text}") + } +} + +/// The area of a document that a rule inspects. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Deserialize, Serialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +#[schemars(inline, transform = idiomaticize_string_enum)] +pub enum LintCategory { + /// The structure of the document and its resources. + Document, + /// Expressions and the functions they call. + Expression, + /// Parameter and variable declarations and their values. + Parameter, + /// Dependencies between resources. + Dependency, + /// Features that DSC has deprecated or doesn't support. + Deprecation, + /// The environment the document targets. + Environment, + /// Checks that need the installed resources. + Resource, +} + +impl Display for LintCategory { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let text = match self { + LintCategory::Document => "document", + LintCategory::Expression => "expression", + LintCategory::Parameter => "parameter", + LintCategory::Dependency => "dependency", + LintCategory::Deprecation => "deprecation", + LintCategory::Environment => "environment", + LintCategory::Resource => "resource", + }; + write!(f, "{text}") + } +} + +/// A position in the source text. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, JsonSchema)] +#[serde(deny_unknown_fields)] +#[schemars(inline)] +pub struct SourceLocation { + /// The line number, starting at 1. + pub line: usize, + /// The column number, starting at 1. + pub column: usize, +} + +/// A problem found in a document. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, JsonSchema)] +#[serde(deny_unknown_fields)] +#[schemars(inline)] +pub struct LintDiagnostic { + /// The severity of the problem. + pub level: LintLevel, + /// The identifier of the rule that reported the problem. + pub rule: String, + /// The category of the rule that reported the problem. + pub category: LintCategory, + /// A description of the problem. + pub message: String, + /// JSON pointer (RFC 6901) to the location of the problem in the document. An empty string + /// identifies the document root. + pub path: String, + /// The file the document was read from, when known. + #[serde(skip_serializing_if = "Option::is_none")] + pub file: Option, + /// The position of the problem in the source text, when known. + #[serde(skip_serializing_if = "Option::is_none")] + pub location: Option, +} + +/// Counts of diagnostics by level. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize, JsonSchema)] +#[serde(deny_unknown_fields)] +#[schemars(inline)] +pub struct LintSummary { + /// The number of error-level diagnostics. + pub errors: usize, + /// The number of warning-level diagnostics. + pub warnings: usize, + /// The number of info-level diagnostics. + pub info: usize, +} + +/// The result of linting one or more documents. +#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize, JsonSchema, DscRepoSchema)] +#[serde(deny_unknown_fields)] +#[dsc_repo_schema(base_name = "result", folder_path = "outputs/lint")] +#[schemars( + transform = LintResult::transform_export_schema_uris, + transform = LintResult::transform_schema_docs +)] +pub struct LintResult { + /// The problems found, ordered by path and rule. + pub diagnostics: Vec, + /// Counts of the problems found by level. + pub summary: LintSummary, + /// Whether no error-level problems were found. + pub valid: bool, + /// Whether resource discovery ran, which enables the rules that inspect installed resources. + pub discovery: bool, +} + +impl LintResult { + /// Build a result from a set of diagnostics, sorting them and computing the summary. + /// + /// # Arguments + /// + /// * `diagnostics` - The diagnostics found. + /// * `discovery` - Whether resource discovery ran. + #[must_use] + pub fn from_diagnostics(mut diagnostics: Vec, discovery: bool) -> Self { + diagnostics.sort_by(|a, b| a.file.cmp(&b.file).then_with(|| a.path.cmp(&b.path)).then_with(|| a.rule.cmp(&b.rule))); + let mut summary = LintSummary::default(); + for diagnostic in &diagnostics { + match diagnostic.level { + LintLevel::Error => summary.errors += 1, + LintLevel::Warning => summary.warnings += 1, + LintLevel::Info => summary.info += 1, + } + } + Self { + valid: summary.errors == 0, + discovery, + summary, + diagnostics, + } + } + + /// Combine another result into this one, for example when linting several files. + /// + /// # Arguments + /// + /// * `other` - The result to merge; its diagnostics are appended and re-sorted. + pub fn merge(&mut self, other: LintResult) { + let discovery = self.discovery && other.discovery; + let mut diagnostics = std::mem::take(&mut self.diagnostics); + diagnostics.extend(other.diagnostics); + *self = Self::from_diagnostics(diagnostics, discovery); + } + + /// Whether any diagnostic is at or above the given level. + /// + /// # Arguments + /// + /// * `level` - The threshold. + #[must_use] + pub fn has_diagnostic_at_least(&self, level: LintLevel) -> bool { + self.diagnostics.iter().any(|diagnostic| diagnostic.level >= level) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn diagnostic(level: LintLevel, rule: &str, path: &str) -> LintDiagnostic { + LintDiagnostic { + level, + rule: rule.to_string(), + category: LintCategory::Document, + message: String::new(), + path: path.to_string(), + file: None, + location: None, + } + } + + #[test] + fn from_diagnostics_sorts_and_summarizes() { + let result = LintResult::from_diagnostics(vec![ + diagnostic(LintLevel::Warning, "b", "/resources/1"), + diagnostic(LintLevel::Error, "a", "/resources/0"), + diagnostic(LintLevel::Info, "a", "/resources/1"), + ], true); + assert_eq!(result.summary, LintSummary { errors: 1, warnings: 1, info: 1 }); + assert!(!result.valid); + assert!(result.discovery); + let order: Vec<(&str, &str)> = result.diagnostics.iter().map(|d| (d.path.as_str(), d.rule.as_str())).collect(); + assert_eq!(order, vec![("/resources/0", "a"), ("/resources/1", "a"), ("/resources/1", "b")]); + } + + #[test] + fn merge_combines_results() { + let mut first = LintResult::from_diagnostics(vec![diagnostic(LintLevel::Warning, "a", "")], true); + let second = LintResult::from_diagnostics(vec![diagnostic(LintLevel::Error, "b", "")], false); + first.merge(second); + assert_eq!(first.diagnostics.len(), 2); + assert!(!first.valid); + assert!(!first.discovery); + assert!(first.has_diagnostic_at_least(LintLevel::Error)); + } + + #[test] + fn levels_are_ordered() { + assert!(LintLevel::Error > LintLevel::Warning); + assert!(LintLevel::Warning > LintLevel::Info); + } +} diff --git a/lib/dsc-lib/src/lint/expression_index.rs b/lib/dsc-lib/src/lint/expression_index.rs new file mode 100644 index 000000000..864c1b3ec --- /dev/null +++ b/lib/dsc-lib/src/lint/expression_index.rs @@ -0,0 +1,366 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use std::collections::HashMap; + +use serde_json::Value; + +use crate::{ + dscerror::DscError, + parser::{expressions::{Accessor, Expression}, functions::{Function, FunctionArg}, ParsedStatement, Statement}, +}; + +use super::{context::ResourceSite, pointer::JsonPointer}; + +/// Where in the document an expression appears, which decides how its functions are resolved. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum ExpressionScope { + /// Inside the resource with the given index in the context's resource list. + Resource(usize), + /// Inside the default value of the named parameter. + Parameter(String), + /// Inside the named variable. + Variable(String), + /// Inside the named output. + Output(String), + /// Inside the body of a user-defined function, where `parameters()` refers to the + /// function's own parameters. + UserFunction, +} + +/// One expression found in the document. +pub struct ExpressionSite { + /// The pointer to the string that holds the expression. + pub pointer: JsonPointer, + /// The expression text as written. + pub text: String, + /// Where the expression appears. + pub scope: ExpressionScope, + /// The parsed expression, or the parse error. + pub parsed: Result, +} + +/// A function call found inside an expression. +pub struct FunctionCall<'a> { + /// The expression the call appears in. + pub site: &'a ExpressionSite, + /// The call. + pub function: &'a Function, +} + +/// Every expression in a document, parsed once so rules can inspect them without re-parsing. +pub struct ExpressionIndex { + sites: Vec, + by_pointer: HashMap, +} + +impl ExpressionIndex { + /// Find and parse every expression in a document. + /// + /// Strings are walked in the places DSC evaluates expressions: parameter default values, + /// variables, outputs, user-defined function bodies, and for each resource its name, + /// condition, `dependsOn` entries, copy counts, and properties. + /// + /// # Arguments + /// + /// * `raw` - The document. + /// * `resources` - The resource sites of the document, in the order the context lists them. + /// * `parser` - The parser to parse expressions with. + #[must_use] + pub fn build(raw: &Value, resources: &[ResourceSite<'_>], parser: &mut Statement) -> Self { + let mut index = Self { + sites: Vec::new(), + by_pointer: HashMap::new(), + }; + let root = JsonPointer::root(); + + if let Some(parameters) = raw.get("parameters").and_then(Value::as_object) { + let base = root.push("parameters"); + for (name, parameter) in parameters { + if let Some(default_value) = parameter.get("defaultValue") { + index.walk(default_value, &base.push(name).push("defaultValue"), &ExpressionScope::Parameter(name.clone()), parser); + } + } + } + + if let Some(variables) = raw.get("variables").and_then(Value::as_object) { + let base = root.push("variables"); + for (name, variable) in variables { + index.walk(variable, &base.push(name), &ExpressionScope::Variable(name.clone()), parser); + } + } + + if let Some(outputs) = raw.get("outputs").and_then(Value::as_object) { + let base = root.push("outputs"); + for (name, output) in outputs { + let scope = ExpressionScope::Output(name.clone()); + for key in ["value", "condition"] { + if let Some(member) = output.get(key) { + index.walk(member, &base.push(name).push(key), &scope, parser); + } + } + } + } + + if let Some(functions) = raw.get("functions").and_then(Value::as_array) { + let base = root.push("functions"); + for (position, function) in functions.iter().enumerate() { + let Some(members) = function.get("members").and_then(Value::as_object) else { + continue; + }; + for (member_name, member) in members { + if let Some(value) = member.get("output").and_then(|output| output.get("value")) { + let pointer = base.push_index(position).push("members").push(member_name).push("output").push("value"); + index.walk(value, &pointer, &ExpressionScope::UserFunction, parser); + } + } + } + } + + for (position, site) in resources.iter().enumerate() { + let Some(resource) = site.pointer.resolve(raw) else { + continue; + }; + let scope = ExpressionScope::Resource(position); + for key in ["name", "condition", "dependsOn", "properties"] { + if let Some(member) = resource.get(key) { + index.walk(member, &site.pointer.push(key), &scope, parser); + } + } + if let Some(copy) = resource.get("copy") { + for key in ["count", "batchSize"] { + if let Some(member) = copy.get(key) { + index.walk(member, &site.pointer.push("copy").push(key), &scope, parser); + } + } + } + } + + index + } + + /// Walk a value, parsing every string that starts with `[`. + /// + /// # Arguments + /// + /// * `value` - The value to walk. + /// * `pointer` - The pointer to `value`. + /// * `scope` - The scope every expression found belongs to. + /// * `parser` - The parser to parse expressions with. + fn walk(&mut self, value: &Value, pointer: &JsonPointer, scope: &ExpressionScope, parser: &mut Statement) { + match value { + Value::String(text) if text.starts_with('[') => { + self.by_pointer.insert(pointer.to_string(), self.sites.len()); + self.sites.push(ExpressionSite { + pointer: pointer.clone(), + text: text.clone(), + scope: scope.clone(), + parsed: parser.parse(text), + }); + }, + Value::Array(items) => { + for (position, item) in items.iter().enumerate() { + self.walk(item, &pointer.push_index(position), scope, parser); + } + }, + Value::Object(members) => { + for (key, member) in members { + self.walk(member, &pointer.push(key), scope, parser); + } + }, + _ => {}, + } + } + + /// Every expression site in document order. + #[must_use] + pub fn sites(&self) -> &[ExpressionSite] { + &self.sites + } + + /// The expression site at a pointer, if the string there is an expression. + /// + /// # Arguments + /// + /// * `pointer` - The pointer to the string. + #[must_use] + pub fn site_at(&self, pointer: &JsonPointer) -> Option<&ExpressionSite> { + self.by_pointer.get(pointer.as_str()).map(|position| &self.sites[*position]) + } + + /// Every function call in every successfully parsed expression, outermost first. + #[must_use] + pub fn calls(&self) -> Vec> { + let mut calls = Vec::new(); + for site in &self.sites { + if let Ok(ParsedStatement::Expression(expression)) = &site.parsed { + visit_expression(expression, &mut |function| calls.push(FunctionCall { site, function })); + } + } + calls + } + + /// Every call of the named function; names are case-sensitive, as at runtime. + /// + /// # Arguments + /// + /// * `name` - The function name. + #[must_use] + pub fn calls_named(&self, name: &str) -> Vec> { + self.calls().into_iter().filter(|call| call.function.name() == name).collect() + } +} + +/// Visit a function and, depth first, every function nested in its arguments and accessors. +/// +/// # Arguments +/// +/// * `expression` - The expression to visit. +/// * `visit` - Called with each function found, outermost first. +pub fn visit_expression<'a>(expression: &'a Expression, visit: &mut dyn FnMut(&'a Function)) { + let function = expression.function(); + visit(function); + if let Some(args) = function.args() { + for arg in args { + match arg { + FunctionArg::Expression(nested) => visit_expression(nested, visit), + FunctionArg::Lambda(lambda) => visit_expression(&lambda.body, visit), + FunctionArg::Value(_) => {}, + } + } + } + for accessor in expression.accessors() { + if let Accessor::IndexExpression(nested) = accessor { + visit_expression(nested, visit); + } + } +} + +/// The literal string argument at a position of a call, if that argument is a literal string. +/// +/// # Arguments +/// +/// * `function` - The call. +/// * `position` - The zero-based argument position. +#[must_use] +pub fn literal_string_arg(function: &Function, position: usize) -> Option<&str> { + match function.args()?.get(position)? { + FunctionArg::Value(Value::String(text)) => Some(text), + _ => None, + } +} + +/// The literal values of a call's arguments, with `None` for arguments that are nested +/// expressions. +/// +/// # Arguments +/// +/// * `function` - The call. +#[must_use] +pub fn literal_args(function: &Function) -> Vec> { + function.args().map_or_else(Vec::new, |args| { + args.iter().map(|arg| match arg { + FunctionArg::Value(value) => Some(value), + _ => None, + }).collect() + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{configure::config_doc::Configuration, lint::context::collect_resource_sites}; + + fn index_for(yaml: &str) -> (Value, ExpressionIndex) { + let raw: Value = serde_yaml::from_str(yaml).unwrap(); + let config: Configuration = serde_json::from_value(raw.clone()).unwrap(); + let sites = collect_resource_sites(&config); + let mut parser = Statement::new().unwrap(); + let index = ExpressionIndex::build(&raw, &sites, &mut parser); + (raw, index) + } + + #[test] + fn indexes_expressions_in_every_scope() { + let (_, index) = index_for(r#" +$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json +parameters: + p: + type: string + defaultValue: "[concat('a', 'b')]" +variables: + v: "[parameters('p')]" +functions: +- namespace: my + members: + twice: + output: + type: string + value: "[concat(parameters('x'), parameters('x'))]" +outputs: + o: + type: string + value: "[variables('v')]" + condition: "[true()]" +resources: +- name: "[concat('n', '1')]" + type: Microsoft.DSC.Debug/Echo + condition: "[true()]" + dependsOn: + - "[resourceId('Microsoft.DSC.Debug/Echo', 'other')]" + properties: + output: + nested: "[parameters('p')]" + literal: "[[escaped]" +- name: other + type: Microsoft.DSC.Debug/Echo +"#); + let scopes: Vec<(&str, &ExpressionScope)> = index.sites().iter().map(|site| (site.pointer.as_str(), &site.scope)).collect(); + assert!(scopes.contains(&("/parameters/p/defaultValue", &ExpressionScope::Parameter("p".to_string())))); + assert!(scopes.contains(&("/variables/v", &ExpressionScope::Variable("v".to_string())))); + assert!(scopes.contains(&("/functions/0/members/twice/output/value", &ExpressionScope::UserFunction))); + assert!(scopes.contains(&("/outputs/o/value", &ExpressionScope::Output("o".to_string())))); + assert!(scopes.contains(&("/outputs/o/condition", &ExpressionScope::Output("o".to_string())))); + assert!(scopes.contains(&("/resources/0/name", &ExpressionScope::Resource(0)))); + assert!(scopes.contains(&("/resources/0/condition", &ExpressionScope::Resource(0)))); + assert!(scopes.contains(&("/resources/0/dependsOn/0", &ExpressionScope::Resource(0)))); + assert!(scopes.contains(&("/resources/0/properties/output/nested", &ExpressionScope::Resource(0)))); + // escaped strings are indexed so rules can tell them apart from literals + let escaped = index.site_at(&JsonPointer::from_pointer("/resources/0/properties/output/literal")).unwrap(); + assert!(matches!(escaped.parsed, Ok(ParsedStatement::Escaped(_)))); + assert!(index.sites().iter().all(|site| site.parsed.is_ok())); + } + + #[test] + fn collects_nested_calls() { + let (_, index) = index_for(r#" +$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json +resources: +- name: a + type: Microsoft.DSC.Debug/Echo + properties: + output: "[concat(parameters('p'), variables('v')[int('0')])]" +"#); + let names: Vec<&str> = index.calls().iter().map(|call| call.function.name()).collect(); + assert_eq!(names, vec!["concat", "parameters", "variables", "int"]); + let parameters = index.calls_named("parameters"); + assert_eq!(parameters.len(), 1); + assert_eq!(literal_string_arg(parameters[0].function, 0), Some("p")); + assert_eq!(literal_args(index.calls_named("concat")[0].function), vec![None, None]); + } + + #[test] + fn records_parse_failures() { + let (_, index) = index_for(r#" +$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json +resources: +- name: a + type: Microsoft.DSC.Debug/Echo + properties: + output: "[concat('unterminated)]" +"#); + assert_eq!(index.sites().len(), 1); + assert!(index.sites()[0].parsed.is_err()); + assert!(index.calls().is_empty()); + } +} diff --git a/lib/dsc-lib/src/lint/mod.rs b/lib/dsc-lib/src/lint/mod.rs new file mode 100644 index 000000000..ca48fcc32 --- /dev/null +++ b/lib/dsc-lib/src/lint/mod.rs @@ -0,0 +1,818 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Static analysis for DSC configuration documents. +//! +//! The linter parses a document, validates it against the configuration document schema, and +//! runs a set of rules that find problems before the document is applied. Rules never invoke +//! resource operations; with discovery enabled they only read the manifests of installed +//! resources. +//! +//! ```rust,ignore +//! use dsc_lib::lint::{Linter, LintOptions}; +//! +//! let mut linter = Linter::new(LintOptions::default())?; +//! let result = linter.lint(document_text)?; +//! for diagnostic in &result.diagnostics { +//! println!("{} {} {}: {}", diagnostic.level, diagnostic.rule, diagnostic.path, diagnostic.message); +//! } +//! ``` + +use std::collections::HashMap; + +use miette::Diagnostic; +use rust_i18n::t; +use serde_json::Value; +use thiserror::Error; +use tracing::{debug, warn}; + +use crate::{ + DscManager, + configure::{ + config_doc::{Configuration, Resource, ResourceDiscoveryMode}, + get_require_adapter_from_directive, + parameters::import_parameters, + }, + discovery::discovery_trait::DiscoveryFilter, + dscerror::DscError, + dscresources::{dscresource::validate_json_all, resource_manifest::ResourceManifest}, + extensions::extension_manifest::ExtensionManifest, + parser::Statement, + progress::ProgressFormat, + schemas::dsc_repo::{DscRepoSchema, RecognizedSchemaVersion, SchemaForm, SchemaUriPrefix}, + util::{get_setting, parse_input_to_value}, +}; + +pub mod context; +pub mod diagnostic; +pub mod expression_index; +pub mod options; +pub mod pointer; +pub mod rule; +pub mod rules; + +pub use diagnostic::{LintCategory, LintDiagnostic, LintLevel, LintResult, LintSummary, SourceLocation}; +pub use options::{DocumentKind, LintOptions, LintSettings, RuleLevel, RuleLevelOverride, RuleSetting}; +pub use pointer::JsonPointer; +pub use rule::{CheckFn, DiagnosticSink, LintRuleInfo, Rule, RuleTier}; + +use context::{collect_resource_sites, LintContext, ResolvedResource}; +use expression_index::ExpressionIndex; + +/// The fixed rule that reports a document that isn't valid JSON or YAML. +pub const PARSE_ERROR_RULE: &str = "parse-error"; +/// The fixed rule that reports violations of the configuration document schema. +pub const SCHEMA_INVALID_RULE: &str = "schema-invalid"; +/// The fixed rule that reports a document kind the linter doesn't support yet. +pub const UNSUPPORTED_DOCUMENT_TYPE_RULE: &str = "unsupported-document-type"; +/// The fixed rule that reports a resource discovery failure. +pub const DISCOVERY_FAILED_RULE: &str = "discovery-failed"; + +/// The rules whose level can't be changed. +const FIXED_RULES: [&str; 4] = [PARSE_ERROR_RULE, SCHEMA_INVALID_RULE, UNSUPPORTED_DOCUMENT_TYPE_RULE, DISCOVERY_FAILED_RULE]; + +/// Errors raised by the linter itself, as opposed to problems found in a document. +#[derive(Debug, Error, Diagnostic)] +pub enum LintError { + /// A rule level override names a rule that doesn't exist. + #[error("{t}: '{0}'", t = t!("lint.mod.unknownRule"))] + UnknownRule(String), + /// A rule level override isn't written as `=`. + #[error("{t}: '{0}'", t = t!("lint.mod.invalidRuleOverride"))] + InvalidRuleOverride(String), + /// A rule level isn't `off`, `info`, `warning`, or `error`. + #[error("{t}: '{0}'", t = t!("lint.mod.invalidRuleLevel"))] + InvalidRuleLevel(String), + /// The `lint` section of the settings file can't be read. + #[error("{t}: {0}", t = t!("lint.mod.invalidSettings"))] + InvalidSettings(String), + /// The supplied parameters aren't in a format DSC accepts. + #[error("{t}: {0}", t = t!("lint.mod.invalidParameters"))] + InvalidParameters(String), +} + +/// A rule together with the level it reports at in this run. +struct ActiveRule { + rule: Rule, + level: LintLevel, +} + +/// Lints configuration documents. +/// +/// Create one with [`Linter::new`] and reuse it for several documents; it holds the compiled +/// expression parser and the effective rule levels. +pub struct Linter { + options: LintOptions, + rules: Vec, + levels: HashMap<&'static str, RuleLevel>, + parser: Statement, + config_schema: Value, +} + +impl Linter { + /// Create a linter. + /// + /// # Arguments + /// + /// * `options` - The options of the run. + /// + /// # Errors + /// + /// Returns [`LintError::UnknownRule`] when an override names an unknown rule, + /// [`LintError::InvalidSettings`] when the settings file's `lint` section is malformed, or + /// another `DscError` when the expression parser or the document schema can't be prepared. + pub fn new(options: LintOptions) -> Result { + let levels = effective_levels(&options)?; + let rules = rules::all_rules().into_iter().filter_map(|rule| { + let level = levels.get(rule.id).copied().unwrap_or(rule.default_level).as_lint_level()?; + Some(ActiveRule { rule, level }) + }).collect(); + let config_schema = serde_json::to_value(Configuration::generate_schema( + RecognizedSchemaVersion::default(), + SchemaForm::Bundled, + SchemaUriPrefix::AkaDotMs, + ))?; + Ok(Self { + options, + rules, + levels, + parser: Statement::new()?, + config_schema, + }) + } + + /// The options of the run. + #[must_use] + pub fn options(&self) -> &LintOptions { + &self.options + } + + /// Set the file name recorded on the diagnostics of the documents linted from now on. + /// + /// # Arguments + /// + /// * `file` - The file the next documents are read from, or `None` for inline input. + pub fn set_file(&mut self, file: Option) { + self.options.file = file; + } + + /// Describe every rule, fixed and configurable, with the level it reports at in this run. + #[must_use] + pub fn rules(&self) -> Vec { + let mut list = fixed_rule_infos(); + for rule in rules::all_rules() { + let level = self.levels.get(rule.id).copied().unwrap_or(rule.default_level); + list.push(LintRuleInfo::from_rule(&rule, level)); + } + list.sort_by(|a, b| a.id.cmp(&b.id)); + list + } + + /// Lint a document given as JSON or YAML text. + /// + /// Problems in the document are reported as diagnostics, including a document that doesn't + /// parse. + /// + /// # Arguments + /// + /// * `document` - The document text. + /// + /// # Errors + /// + /// Returns [`LintError::InvalidParameters`] when the supplied parameters can't be imported, + /// a `DscError::Validation` when the configuration document schema can't be compiled, or + /// another `DscError` when discovery itself fails in a way that isn't reported as a + /// diagnostic. + pub fn lint(&mut self, document: &str) -> Result { + match parse_document(document) { + Ok(raw) => self.lint_parsed(&raw, Vec::new()), + Err(diagnostic) => Ok(LintResult::from_diagnostics(stamp_file(vec![*diagnostic], &self.options.file), false)), + } + } + + /// Lint a document that is already parsed. + /// + /// # Arguments + /// + /// * `document` - The document. + /// + /// # Errors + /// + /// See [`lint`](Self::lint). + pub fn lint_value(&mut self, document: &Value) -> Result { + self.lint_parsed(document, Vec::new()) + } + + /// Run the pipeline on a parsed document. + /// + /// # Arguments + /// + /// * `raw` - The document. + /// * `diagnostics` - Diagnostics already collected for the document. + fn lint_parsed(&mut self, raw: &Value, mut diagnostics: Vec) -> Result { + if let Some(diagnostic) = self.check_document_kind(raw) { + diagnostics.push(diagnostic); + return Ok(LintResult::from_diagnostics(stamp_file(diagnostics, &self.options.file), false)); + } + + for violation in validate_json_all("Configuration", &self.config_schema, raw)? { + diagnostics.push(fixed_diagnostic( + SCHEMA_INVALID_RULE, + LintCategory::Document, + LintLevel::Error, + &JsonPointer::from_pointer(&violation.instance_path), + None, + violation.message, + )); + } + + let config: Configuration = match serde_json::from_value(raw.clone()) { + Ok(config) => config, + Err(err) => { + if diagnostics.is_empty() { + diagnostics.push(fixed_diagnostic( + SCHEMA_INVALID_RULE, + LintCategory::Document, + LintLevel::Error, + &JsonPointer::root(), + None, + t!("lint.mod.documentInvalid", error = err).to_string(), + )); + } + return Ok(LintResult::from_diagnostics(stamp_file(diagnostics, &self.options.file), false)); + }, + }; + + let supplied_parameters = match &self.options.parameters { + Some(parameters) => Some(import_parameters(parameters).map_err(|err| LintError::InvalidParameters(err.to_string()))?), + None => None, + }; + + let Self { options, rules, parser, .. } = self; + let sites = collect_resource_sites(&config); + let expressions = ExpressionIndex::build(raw, &sites, parser); + let mut context = LintContext { + raw, + config: &config, + resources: sites, + expressions, + options, + functions: parser.function_dispatcher(), + supplied_parameters, + discovered: None, + }; + + for active in rules.iter().filter(|active| active.rule.tier == RuleTier::Static) { + run_rule(active, &context, &mut diagnostics); + } + + let mut discovery_ran = false; + if options.discovery && !skips_discovery(&config) { + match discover(&context, options.progress_format) { + Ok(discovered) => { + context.discovered = Some(discovered); + discovery_ran = true; + for active in rules.iter().filter(|active| active.rule.tier == RuleTier::Resource) { + run_rule(active, &context, &mut diagnostics); + } + }, + Err(err) => { + debug!("{}", t!("lint.mod.discoveryFailed", error = err)); + diagnostics.push(fixed_diagnostic( + DISCOVERY_FAILED_RULE, + LintCategory::Resource, + LintLevel::Warning, + &JsonPointer::root(), + None, + t!("lint.mod.discoveryFailed", error = err).to_string(), + )); + }, + } + } + + Ok(LintResult::from_diagnostics(stamp_file(diagnostics, &options.file), discovery_ran)) + } + + /// Report a document whose kind the linter doesn't support. + /// + /// # Arguments + /// + /// * `raw` - The document. + fn check_document_kind(&self, raw: &Value) -> Option { + let kind = self.options.document_kind.or_else(|| detect_document_kind(raw))?; + if kind == DocumentKind::Configuration { + return None; + } + Some(fixed_diagnostic( + UNSUPPORTED_DOCUMENT_TYPE_RULE, + LintCategory::Document, + LintLevel::Error, + &JsonPointer::root().push("$schema"), + None, + t!("lint.mod.unsupportedDocumentType", kind = kind).to_string(), + )) + } +} + +/// Parse a document as JSON or YAML, reporting a failure as a diagnostic. +/// +/// Input that starts with `{` is reported with the JSON parser's message and position so the +/// message matches what the author wrote; everything else uses the YAML parser's. +/// +/// # Arguments +/// +/// * `document` - The document text. +/// +/// # Errors +/// +/// Returns the `parse-error` diagnostic when the text is neither valid JSON nor valid YAML. +fn parse_document(document: &str) -> Result> { + let yaml_error = match parse_input_to_value(document) { + Ok(value) => return Ok(value), + Err(err) => err, + }; + let (message, location) = match (document.trim_start().starts_with('{'), serde_json::from_str::(document), &yaml_error) { + (true, Err(err), _) => (err.to_string(), Some(SourceLocation { line: err.line(), column: err.column() })), + (_, _, DscError::Yaml(err)) => (err.to_string(), err.location().map(|location| SourceLocation { line: location.line(), column: location.column() })), + (_, _, other) => (other.to_string(), None), + }; + Err(Box::new(fixed_diagnostic( + PARSE_ERROR_RULE, + LintCategory::Document, + LintLevel::Error, + &JsonPointer::root(), + location, + t!("lint.mod.parseError", error = message).to_string(), + ))) +} + +/// Detect the kind of document from its `$schema` URI. +/// +/// # Arguments +/// +/// * `raw` - The document. +/// +/// # Returns +/// +/// The kind, or `None` when there is no recognized `$schema`. +fn detect_document_kind(raw: &Value) -> Option { + let uri = raw.get("$schema")?.as_str()?.to_string(); + if Configuration::is_recognized_schema_uri(&uri) { + Some(DocumentKind::Configuration) + } else if ResourceManifest::is_recognized_schema_uri(&uri) { + Some(DocumentKind::ResourceManifest) + } else if ExtensionManifest::is_recognized_schema_uri(&uri) { + Some(DocumentKind::ExtensionManifest) + } else { + None + } +} + +/// Whether the document asks DSC to discover resources during deployment instead of up front. +/// +/// # Arguments +/// +/// * `config` - The configuration. +fn skips_discovery(config: &Configuration) -> bool { + config.directives.as_ref().and_then(|directives| directives.resource_discovery.as_ref()) == Some(&ResourceDiscoveryMode::DuringDeployment) +} + +/// The discovery filter for a resource declaration, matching what `dsc config` uses. +/// +/// # Arguments +/// +/// * `resource` - The resource declaration. +fn filter_for(resource: &Resource) -> DiscoveryFilter { + DiscoveryFilter::new( + &resource.resource_type, + resource.require_version.clone(), + get_require_adapter_from_directive(&resource.directives), + ) +} + +/// Discover the resource types the document uses. +/// +/// # Arguments +/// +/// * `context` - The lint context, for its resource declarations. +/// * `progress_format` - How to report discovery progress. +/// +/// # Returns +/// +/// One entry per resource declaration, in the context's order. +/// +/// # Errors +/// +/// Returns the discovery error when resources can't be discovered at all. +fn discover(context: &LintContext, progress_format: ProgressFormat) -> Result, DscError> { + let mut manager = DscManager::new(); + let mut filters: Vec = Vec::new(); + for site in &context.resources { + let filter = filter_for(site.resource); + if !filters.contains(&filter) { + filters.push(filter); + } + } + manager.find_resources(&filters, progress_format)?; + + let mut resolved = Vec::with_capacity(context.resources.len()); + for site in &context.resources { + let found = manager.find_resource(&filter_for(site.resource))?.cloned(); + let type_exists = if found.is_some() { + true + } else if site.resource.require_version.is_some() { + let any_version = DiscoveryFilter::new(&site.resource.resource_type, None, get_require_adapter_from_directive(&site.resource.directives)); + manager.find_resource(&any_version)?.is_some() + } else { + false + }; + resolved.push(ResolvedResource { found, type_exists }); + } + Ok(resolved) +} + +/// Run one rule, collecting what it reports. +/// +/// # Arguments +/// +/// * `active` - The rule and its level. +/// * `context` - The lint context. +/// * `diagnostics` - Where reported diagnostics are appended. +fn run_rule(active: &ActiveRule, context: &LintContext, diagnostics: &mut Vec) { + let mut sink = DiagnosticSink::new(active.rule.id, active.rule.category, active.level, diagnostics); + (active.rule.check)(context, &mut sink); +} + +/// Build a diagnostic for one of the fixed rules. +/// +/// # Arguments +/// +/// * `rule` - The rule identifier. +/// * `category` - The rule category. +/// * `level` - The level to report at. +/// * `path` - The location of the problem. +/// * `location` - The position in the source text, when known. +/// * `message` - A description of the problem. +fn fixed_diagnostic(rule: &str, category: LintCategory, level: LintLevel, path: &JsonPointer, location: Option, message: String) -> LintDiagnostic { + LintDiagnostic { + level, + rule: rule.to_string(), + category, + message, + path: path.to_string(), + file: None, + location, + } +} + +/// Record the source file on diagnostics that don't have one yet. +/// +/// # Arguments +/// +/// * `diagnostics` - The diagnostics. +/// * `file` - The file the document was read from, if known. +fn stamp_file(mut diagnostics: Vec, file: &Option) -> Vec { + for diagnostic in &mut diagnostics { + if diagnostic.file.is_none() { + diagnostic.file.clone_from(file); + } + } + diagnostics +} + +/// Whether a rule's level is fixed. +/// +/// # Arguments +/// +/// * `id` - The rule identifier. +fn is_fixed_rule(id: &str) -> bool { + FIXED_RULES.contains(&id) +} + +/// Describe the fixed rules. +fn fixed_rule_infos() -> Vec { + let info = |id: &str, category: LintCategory, level: LintLevel, description: String| LintRuleInfo { + id: id.to_string(), + category, + tier: if category == LintCategory::Resource { RuleTier::Resource } else { RuleTier::Static }, + default_level: level.into(), + level: level.into(), + fixed: true, + description, + }; + vec![ + info(PARSE_ERROR_RULE, LintCategory::Document, LintLevel::Error, t!("lint.mod.parseErrorDescription").to_string()), + info(SCHEMA_INVALID_RULE, LintCategory::Document, LintLevel::Error, t!("lint.mod.schemaInvalidDescription").to_string()), + info(UNSUPPORTED_DOCUMENT_TYPE_RULE, LintCategory::Document, LintLevel::Error, t!("lint.mod.unsupportedDocumentTypeDescription").to_string()), + info(DISCOVERY_FAILED_RULE, LintCategory::Resource, LintLevel::Warning, t!("lint.mod.discoveryFailedDescription").to_string()), + ] +} + +/// Compute the level of every configurable rule for a run. +/// +/// Precedence, lowest first: the rule's default, the settings file, the overrides in the +/// options, and the policy file. +/// +/// # Arguments +/// +/// * `options` - The options of the run. +/// +/// # Errors +/// +/// Returns [`LintError::UnknownRule`] when an override names an unknown rule, or +/// [`LintError::InvalidSettings`] when the settings can't be read. +fn effective_levels(options: &LintOptions) -> Result, LintError> { + let mut levels: HashMap<&'static str, RuleLevel> = rules::all_rules().iter().map(|rule| (rule.id, rule.default_level)).collect(); + let (settings, policy) = if options.use_settings { load_settings()? } else { (None, None) }; + if let Some(settings) = settings { + apply_settings(&mut levels, &settings); + } + for override_ in &options.rule_levels { + if is_fixed_rule(&override_.id) { + warn!("{}", t!("lint.mod.fixedRuleOverrideIgnored", rule = override_.id)); + continue; + } + let Some(level) = levels.get_mut(override_.id.as_str()) else { + return Err(LintError::UnknownRule(override_.id.clone())); + }; + *level = override_.level; + } + if let Some(policy) = policy { + apply_settings(&mut levels, &policy); + } + Ok(levels) +} + +/// Read the `lint` section of the settings and policy files. +/// +/// # Returns +/// +/// The settings-file section and the policy-file section; either is `None` when not configured. +/// +/// # Errors +/// +/// Returns [`LintError::InvalidSettings`] when a section isn't a valid `lint` setting. +fn load_settings() -> Result<(Option, Option), LintError> { + let Ok(value) = get_setting("lint") else { + return Ok((None, None)); + }; + Ok((parse_settings(value.setting)?, parse_settings(value.policy)?)) +} + +/// Parse one `lint` settings value. +/// +/// # Arguments +/// +/// * `value` - The value, or `Null` when the file doesn't configure linting. +/// +/// # Errors +/// +/// Returns [`LintError::InvalidSettings`] when the value isn't a valid `lint` setting. +fn parse_settings(value: Value) -> Result, LintError> { + if value.is_null() { + return Ok(None); + } + serde_json::from_value(value).map(Some).map_err(|err| LintError::InvalidSettings(err.to_string())) +} + +/// Apply the rule levels from a settings section, ignoring fixed and unknown rules. +/// +/// # Arguments +/// +/// * `levels` - The levels to update. +/// * `settings` - The settings section. +fn apply_settings(levels: &mut HashMap<&'static str, RuleLevel>, settings: &LintSettings) { + for (id, setting) in &settings.rules { + let Some(level) = setting.level else { + continue; + }; + if is_fixed_rule(id) { + warn!("{}", t!("lint.mod.fixedRuleOverrideIgnored", rule = id)); + continue; + } + match levels.get_mut(id.as_str()) { + Some(current) => *current = level, + None => warn!("{}", t!("lint.mod.unknownRuleInSettings", rule = id)), + } + } +} + +#[cfg(test)] +pub(crate) mod test_util { + use super::{LintOptions, LintResult, Linter}; + + /// Lint a document with discovery and the settings file disabled. + pub(crate) fn lint_yaml(document: &str) -> LintResult { + lint_with(document, LintOptions { + discovery: false, + use_settings: false, + ..Default::default() + }) + } + + /// Lint a document with the given options. + pub(crate) fn lint_with(document: &str, options: LintOptions) -> LintResult { + Linter::new(options).unwrap().lint(document).unwrap() + } + + /// The paths of the diagnostics a rule reported, in result order. + pub(crate) fn rule_paths(result: &LintResult, rule: &str) -> Vec { + result.diagnostics.iter().filter(|diagnostic| diagnostic.rule == rule).map(|diagnostic| diagnostic.path.clone()).collect() + } + + /// The messages of the diagnostics a rule reported, in result order. + pub(crate) fn rule_messages(result: &LintResult, rule: &str) -> Vec { + result.diagnostics.iter().filter(|diagnostic| diagnostic.rule == rule).map(|diagnostic| diagnostic.message.clone()).collect() + } +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::{test_util::{lint_with, lint_yaml, rule_paths}, *}; + + const CLEAN: &str = r#" +$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json +parameters: + greeting: + type: string + defaultValue: hello +resources: +- name: a + type: Microsoft.DSC.Debug/Echo + properties: + output: "[parameters('greeting')]" +"#; + + #[test] + fn clean_document_is_valid() { + let result = lint_yaml(CLEAN); + assert!(result.valid, "{:?}", result.diagnostics); + assert!(result.diagnostics.is_empty(), "{:?}", result.diagnostics); + assert!(!result.discovery); + } + + #[test] + fn reports_yaml_parse_errors_with_location() { + let result = lint_yaml("resources:\n- name: a\n type: [unclosed\n"); + assert_eq!(rule_paths(&result, PARSE_ERROR_RULE), vec![""]); + assert!(result.diagnostics[0].location.is_some()); + assert!(!result.valid); + } + + #[test] + fn reports_json_parse_errors_with_json_message() { + let result = lint_yaml("{\"resources\": [}"); + assert_eq!(rule_paths(&result, PARSE_ERROR_RULE), vec![""]); + let location = result.diagnostics[0].location.unwrap(); + assert_eq!(location.line, 1); + assert!(location.column > 1); + } + + #[test] + fn reports_schema_violations_with_paths() { + let result = lint_yaml(r#" +$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json +resources: +- name: a + type: Microsoft.DSC.Debug/Echo + unknownKey: true +"#); + let paths = rule_paths(&result, SCHEMA_INVALID_RULE); + assert!(paths.iter().any(|path| path == "/resources/0"), "{:?}", result.diagnostics); + assert!(!result.valid); + } + + #[test] + fn reports_missing_schema_uri() { + let result = lint_yaml("resources: []\n"); + assert!(!rule_paths(&result, SCHEMA_INVALID_RULE).is_empty(), "{:?}", result.diagnostics); + } + + #[test] + fn rejects_manifests() { + let result = lint_yaml(r#" +$schema: https://aka.ms/dsc/schemas/v3/bundled/resource/manifest.json +type: Test/Example +version: 1.0.0 +"#); + assert_eq!(rule_paths(&result, UNSUPPORTED_DOCUMENT_TYPE_RULE), vec!["/$schema"]); + assert_eq!(result.diagnostics.len(), 1); + } + + #[test] + fn overrides_change_levels_and_disable_rules() { + let document = r#" +$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json +parameters: + unused: + type: string + defaultValue: x +resources: +- name: a + type: Microsoft.DSC.Debug/Echo +"#; + let warned = lint_yaml(document); + assert_eq!(rule_paths(&warned, "no-unused-parameters"), vec!["/parameters/unused"]); + assert_eq!(warned.diagnostics[0].level, LintLevel::Warning); + + let raised = lint_with(document, LintOptions { + discovery: false, + use_settings: false, + rule_levels: vec!["no-unused-parameters=error".parse().unwrap()], + ..Default::default() + }); + assert_eq!(raised.diagnostics[0].level, LintLevel::Error); + assert!(!raised.valid); + + let silenced = lint_with(document, LintOptions { + discovery: false, + use_settings: false, + rule_levels: vec!["no-unused-parameters=off".parse().unwrap()], + ..Default::default() + }); + assert!(rule_paths(&silenced, "no-unused-parameters").is_empty()); + } + + #[test] + fn unknown_rule_override_is_an_error() { + let result = Linter::new(LintOptions { + use_settings: false, + rule_levels: vec!["no-such-rule=off".parse().unwrap()], + ..Default::default() + }); + assert!(matches!(result, Err(DscError::Lint(LintError::UnknownRule(id))) if id == "no-such-rule")); + } + + #[test] + fn fixed_rule_override_is_ignored() { + let linter = Linter::new(LintOptions { + use_settings: false, + rule_levels: vec!["parse-error=off".parse().unwrap()], + ..Default::default() + }).unwrap(); + let result = linter.rules(); + let parse_error = result.iter().find(|rule| rule.id == PARSE_ERROR_RULE).unwrap(); + assert!(parse_error.fixed); + assert_eq!(parse_error.level, RuleLevel::Error); + } + + #[test] + fn lists_rules_sorted_with_effective_levels() { + let linter = Linter::new(LintOptions { + use_settings: false, + rule_levels: vec!["duplicate-resource=info".parse().unwrap()], + ..Default::default() + }).unwrap(); + let rules = linter.rules(); + let ids: Vec<&str> = rules.iter().map(|rule| rule.id.as_str()).collect(); + let mut sorted = ids.clone(); + sorted.sort_unstable(); + assert_eq!(ids, sorted); + let duplicate = rules.iter().find(|rule| rule.id == "duplicate-resource").unwrap(); + assert_eq!(duplicate.default_level, RuleLevel::Error); + assert_eq!(duplicate.level, RuleLevel::Info); + assert!(!duplicate.fixed); + assert!(rules.iter().any(|rule| rule.tier == RuleTier::Resource)); + } + + #[test] + fn invalid_supplied_parameters_are_an_error() { + let result = Linter::new(LintOptions { + discovery: false, + use_settings: false, + parameters: Some(json!({"not": "parameters"})), + ..Default::default() + }).unwrap().lint(CLEAN); + assert!(matches!(result, Err(DscError::Lint(LintError::InvalidParameters(_))))); + } + + #[test] + fn records_the_file_on_diagnostics() { + let result = lint_with("resources: []\n", LintOptions { + discovery: false, + use_settings: false, + file: Some("config.yaml".to_string()), + ..Default::default() + }); + assert!(result.diagnostics.iter().all(|diagnostic| diagnostic.file.as_deref() == Some("config.yaml"))); + } + + #[test] + fn during_deployment_discovery_skips_the_resource_tier() { + let result = lint_with(r#" +$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json +directives: + resourceDiscovery: duringDeployment +resources: +- name: a + type: Not.Installed/Anywhere +"#, LintOptions { + use_settings: false, + ..Default::default() + }); + assert!(!result.discovery); + assert!(rule_paths(&result, "unknown-resource-type").is_empty()); + } +} diff --git a/lib/dsc-lib/src/lint/options.rs b/lib/dsc-lib/src/lint/options.rs new file mode 100644 index 000000000..6acaae222 --- /dev/null +++ b/lib/dsc-lib/src/lint/options.rs @@ -0,0 +1,220 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use std::{collections::HashMap, fmt::Display, path::PathBuf, str::FromStr}; + +use clap::ValueEnum; +use rust_i18n::t; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use crate::{progress::ProgressFormat, schemas::transforms::idiomaticize_string_enum, types::SemanticVersion}; + +use super::{diagnostic::LintLevel, LintError}; + +/// The kind of DSC document being linted. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DocumentKind { + /// A configuration document. + Configuration, + /// A resource manifest. + ResourceManifest, + /// An extension manifest. + ExtensionManifest, +} + +impl Display for DocumentKind { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let text = match self { + DocumentKind::Configuration => t!("lint.options.documentKindConfiguration"), + DocumentKind::ResourceManifest => t!("lint.options.documentKindResourceManifest"), + DocumentKind::ExtensionManifest => t!("lint.options.documentKindExtensionManifest"), + }; + write!(f, "{text}") + } +} + +/// The level a rule reports at, or `off` to disable the rule. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Deserialize, Serialize, JsonSchema, ValueEnum)] +#[serde(rename_all = "camelCase")] +#[schemars(inline, transform = idiomaticize_string_enum)] +pub enum RuleLevel { + /// The rule doesn't run. + Off, + /// The rule reports at the info level. + Info, + /// The rule reports at the warning level. + Warning, + /// The rule reports at the error level. + Error, +} + +impl RuleLevel { + /// The diagnostic level for this rule level, or `None` when the rule is off. + #[must_use] + pub fn as_lint_level(self) -> Option { + match self { + RuleLevel::Off => None, + RuleLevel::Info => Some(LintLevel::Info), + RuleLevel::Warning => Some(LintLevel::Warning), + RuleLevel::Error => Some(LintLevel::Error), + } + } +} + +impl From for RuleLevel { + fn from(level: LintLevel) -> Self { + match level { + LintLevel::Info => RuleLevel::Info, + LintLevel::Warning => RuleLevel::Warning, + LintLevel::Error => RuleLevel::Error, + } + } +} + +impl Display for RuleLevel { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let text = match self { + RuleLevel::Off => "off", + RuleLevel::Info => "info", + RuleLevel::Warning => "warning", + RuleLevel::Error => "error", + }; + write!(f, "{text}") + } +} + +impl FromStr for RuleLevel { + type Err = LintError; + + fn from_str(text: &str) -> Result { + match text.trim().to_ascii_lowercase().as_str() { + "off" => Ok(RuleLevel::Off), + "info" => Ok(RuleLevel::Info), + "warning" => Ok(RuleLevel::Warning), + "error" => Ok(RuleLevel::Error), + _ => Err(LintError::InvalidRuleLevel(text.to_string())), + } + } +} + +/// Overrides the level of one rule, written as `=` on the command line. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RuleLevelOverride { + /// The identifier of the rule. + pub id: String, + /// The level the rule reports at. + pub level: RuleLevel, +} + +impl FromStr for RuleLevelOverride { + type Err = LintError; + + fn from_str(text: &str) -> Result { + let Some((id, level)) = text.split_once('=') else { + return Err(LintError::InvalidRuleOverride(text.to_string())); + }; + let id = id.trim(); + if id.is_empty() { + return Err(LintError::InvalidRuleOverride(text.to_string())); + } + Ok(Self { + id: id.to_string(), + level: level.parse()?, + }) + } +} + +impl Display for RuleLevelOverride { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}={}", self.id, self.level) + } +} + +/// The `lint` section of the DSC settings file. +#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize, JsonSchema)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct LintSettings { + /// Per-rule settings keyed by rule identifier. + #[serde(default)] + pub rules: HashMap, +} + +/// The settings for one rule in the DSC settings file. +#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct RuleSetting { + /// The level the rule reports at. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub level: Option, +} + +/// Options that control a lint run. +#[derive(Debug, Clone)] +pub struct LintOptions { + /// The kind of document; detected from `$schema` when `None`. + pub document_kind: Option, + /// Whether to discover installed resources and run the rules that inspect them. + pub discovery: bool, + /// Whether to read rule levels from the DSC settings files. + pub use_settings: bool, + /// Rule level overrides, applied after the settings file and before the policy file. + pub rule_levels: Vec, + /// Parameter values that would be passed to the configuration, in either input format that + /// `dsc config` accepts. + pub parameters: Option, + /// The system root the configuration would be applied with, if not the running OS. + pub system_root: Option, + /// The DSC version to check `directives.version` against. + pub dsc_version: Option, + /// How to report progress during resource discovery. + pub progress_format: ProgressFormat, + /// The file the document was read from, recorded on each diagnostic. + pub file: Option, +} + +impl Default for LintOptions { + fn default() -> Self { + Self { + document_kind: None, + discovery: true, + use_settings: true, + rule_levels: Vec::new(), + parameters: None, + system_root: None, + dsc_version: None, + progress_format: ProgressFormat::None, + file: None, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use test_case::test_case; + + #[test_case("no-unused-parameters=off", "no-unused-parameters", RuleLevel::Off; "off")] + #[test_case("duplicate-resource = Warning", "duplicate-resource", RuleLevel::Warning; "spaces and case")] + #[test_case("x=ERROR", "x", RuleLevel::Error; "error")] + fn parses_rule_overrides(text: &str, id: &str, level: RuleLevel) { + let parsed: RuleLevelOverride = text.parse().unwrap(); + assert_eq!(parsed, RuleLevelOverride { id: id.to_string(), level }); + } + + #[test_case("no-equals"; "missing separator")] + #[test_case("=off"; "missing id")] + #[test_case("rule=loud"; "unknown level")] + fn rejects_invalid_rule_overrides(text: &str) { + assert!(text.parse::().is_err()); + } + + #[test] + fn settings_deserialize() { + let settings: LintSettings = serde_json::from_value(serde_json::json!({ + "rules": { "no-unused-variables": { "level": "info" } } + })).unwrap(); + assert_eq!(settings.rules["no-unused-variables"].level, Some(RuleLevel::Info)); + } +} diff --git a/lib/dsc-lib/src/lint/pointer.rs b/lib/dsc-lib/src/lint/pointer.rs new file mode 100644 index 000000000..2724f7df9 --- /dev/null +++ b/lib/dsc-lib/src/lint/pointer.rs @@ -0,0 +1,165 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use std::fmt::Display; + +use serde_json::Value; + +/// A JSON pointer (RFC 6901) that identifies a location in a document. +/// +/// The empty pointer identifies the document root. +#[derive(Clone, Debug, Default, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct JsonPointer(String); + +impl JsonPointer { + /// The pointer to the document root. + #[must_use] + pub fn root() -> Self { + Self(String::new()) + } + + /// Create a pointer from an existing pointer string, such as one produced by a JSON Schema + /// validator. + /// + /// # Arguments + /// + /// * `pointer` - The pointer text, which must be empty or start with `/`. + #[must_use] + pub fn from_pointer(pointer: &str) -> Self { + Self(pointer.to_string()) + } + + /// Return a pointer to the member `key` of the object at this pointer. + /// + /// # Arguments + /// + /// * `key` - The member name; `~` and `/` are escaped as the RFC requires. + #[must_use] + pub fn push(&self, key: &str) -> Self { + let escaped = key.replace('~', "~0").replace('/', "~1"); + Self(format!("{}/{escaped}", self.0)) + } + + /// Return a pointer to the element `index` of the array at this pointer. + /// + /// # Arguments + /// + /// * `index` - The zero-based element index. + #[must_use] + pub fn push_index(&self, index: usize) -> Self { + Self(format!("{}/{index}", self.0)) + } + + /// Return a pointer that appends another pointer to this one. + /// + /// # Arguments + /// + /// * `other` - The pointer text to append, which must be empty or start with `/`. + #[must_use] + pub fn join(&self, other: &str) -> Self { + Self(format!("{}{other}", self.0)) + } + + /// The pointer text. + #[must_use] + pub fn as_str(&self) -> &str { + &self.0 + } + + /// Whether this pointer identifies `other` or a location below it. + /// + /// # Arguments + /// + /// * `other` - The pointer to compare against. + #[must_use] + pub fn starts_with(&self, other: &JsonPointer) -> bool { + self.0 == other.0 || (self.0.starts_with(&other.0) && self.0[other.0.len()..].starts_with('/')) + } + + /// Resolve the pointer against a document. + /// + /// # Arguments + /// + /// * `document` - The document to look in. + /// + /// # Returns + /// + /// The value at this pointer, or `None` when the document has no such location. + #[must_use] + pub fn resolve<'a>(&self, document: &'a Value) -> Option<&'a Value> { + document.pointer(&self.0) + } +} + +impl Display for JsonPointer { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + +/// Call `visit` for every string value in a document. +/// +/// # Arguments +/// +/// * `value` - The value to walk. +/// * `pointer` - The pointer to `value`. +/// * `visit` - Called with the pointer and text of each string found. +pub fn for_each_string(value: &Value, pointer: &JsonPointer, visit: &mut dyn FnMut(&JsonPointer, &str)) { + match value { + Value::String(text) => visit(pointer, text), + Value::Array(items) => { + for (index, item) in items.iter().enumerate() { + for_each_string(item, &pointer.push_index(index), visit); + } + }, + Value::Object(members) => { + for (key, member) in members { + for_each_string(member, &pointer.push(key), visit); + } + }, + _ => {}, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn push_escapes_special_characters() { + let pointer = JsonPointer::root().push("a/b").push("c~d"); + assert_eq!(pointer.as_str(), "/a~1b/c~0d"); + } + + #[test] + fn push_index_and_join() { + let pointer = JsonPointer::root().push("resources").push_index(2).join("/properties/path"); + assert_eq!(pointer.to_string(), "/resources/2/properties/path"); + } + + #[test] + fn starts_with_matches_self_and_descendants_only() { + let base = JsonPointer::root().push("resources").push_index(1); + assert!(base.starts_with(&base)); + assert!(base.push("name").starts_with(&base)); + assert!(base.starts_with(&JsonPointer::root())); + assert!(!JsonPointer::root().push("resources").push_index(10).starts_with(&base)); + } + + #[test] + fn resolve_finds_values() { + let document = json!({"resources": [{"name": "first"}]}); + let pointer = JsonPointer::root().push("resources").push_index(0).push("name"); + assert_eq!(pointer.resolve(&document), Some(&json!("first"))); + assert_eq!(JsonPointer::root().push("missing").resolve(&document), None); + } + + #[test] + fn for_each_string_visits_nested_strings() { + let document = json!({"a": ["x", {"b": "y"}], "c": 1}); + let mut found = Vec::new(); + for_each_string(&document, &JsonPointer::root(), &mut |pointer, text| found.push((pointer.to_string(), text.to_string()))); + assert_eq!(found, vec![("/a/0".to_string(), "x".to_string()), ("/a/1/b".to_string(), "y".to_string())]); + } +} diff --git a/lib/dsc-lib/src/lint/rule.rs b/lib/dsc-lib/src/lint/rule.rs new file mode 100644 index 000000000..da6f06882 --- /dev/null +++ b/lib/dsc-lib/src/lint/rule.rs @@ -0,0 +1,158 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use crate::schemas::{dsc_repo::DscRepoSchema, transforms::idiomaticize_string_enum}; + +use super::{ + context::LintContext, + diagnostic::{LintCategory, LintDiagnostic, LintLevel, SourceLocation}, + options::RuleLevel, + pointer::JsonPointer, +}; + +/// When a rule can run. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +#[schemars(inline, transform = idiomaticize_string_enum)] +pub enum RuleTier { + /// The rule only needs the document. + Static, + /// The rule needs the installed resources, so it only runs when discovery is enabled. + Resource, +} + +impl std::fmt::Display for RuleTier { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let text = match self { + RuleTier::Static => "static", + RuleTier::Resource => "resource", + }; + write!(f, "{text}") + } +} + +/// The signature of a rule's check function. +pub type CheckFn = fn(&LintContext<'_>, &mut DiagnosticSink<'_>); + +/// A lint rule. +pub struct Rule { + /// The kebab-case identifier of the rule, such as `duplicate-resource`. + pub id: &'static str, + /// The area of the document the rule inspects. + pub category: LintCategory, + /// When the rule can run. + pub tier: RuleTier, + /// The level the rule reports at unless overridden. + pub default_level: RuleLevel, + /// Returns the localized description of what the rule checks. + pub description: fn() -> String, + /// Runs the rule against a document, reporting problems to the sink. + pub check: CheckFn, +} + +/// Describes a rule and the level it is configured to report at. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, JsonSchema, DscRepoSchema)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +#[dsc_repo_schema(base_name = "rules", folder_path = "outputs/lint")] +#[schemars( + transform = LintRuleInfo::transform_export_schema_uris, + transform = LintRuleInfo::transform_schema_docs +)] +pub struct LintRuleInfo { + /// The identifier of the rule. + pub id: String, + /// The area of the document the rule inspects. + pub category: LintCategory, + /// When the rule can run. + pub tier: RuleTier, + /// The level the rule reports at unless overridden. + pub default_level: RuleLevel, + /// The level the rule is configured to report at. + pub level: RuleLevel, + /// Whether the level of the rule can't be changed. + pub fixed: bool, + /// What the rule checks. + pub description: String, +} + +impl LintRuleInfo { + /// Describe a rule at its configured level. + /// + /// # Arguments + /// + /// * `rule` - The rule. + /// * `level` - The level the rule is configured to report at. + #[must_use] + pub fn from_rule(rule: &Rule, level: RuleLevel) -> Self { + Self { + id: rule.id.to_string(), + category: rule.category, + tier: rule.tier, + default_level: rule.default_level, + level, + fixed: false, + description: (rule.description)(), + } + } +} + +/// Collects the diagnostics one rule reports, stamping each with the rule's identity and level +/// so a rule can't report under another name. +pub struct DiagnosticSink<'a> { + rule: &'static str, + category: LintCategory, + level: LintLevel, + diagnostics: &'a mut Vec, +} + +impl<'a> DiagnosticSink<'a> { + /// Create a sink for one rule. + /// + /// # Arguments + /// + /// * `rule` - The identifier of the rule that reports through this sink. + /// * `category` - The category of the rule. + /// * `level` - The level the rule reports at. + /// * `diagnostics` - Where reported diagnostics are appended. + #[must_use] + pub fn new(rule: &'static str, category: LintCategory, level: LintLevel, diagnostics: &'a mut Vec) -> Self { + Self { + rule, + category, + level, + diagnostics, + } + } + + /// Report a problem at a location in the document. + /// + /// # Arguments + /// + /// * `path` - The location of the problem. + /// * `message` - A description of the problem. + pub fn report(&mut self, path: &JsonPointer, message: String) { + self.report_at(path, None, message); + } + + /// Report a problem at a location in the document with a position in the source text. + /// + /// # Arguments + /// + /// * `path` - The location of the problem. + /// * `location` - The position in the source text, when known. + /// * `message` - A description of the problem. + pub fn report_at(&mut self, path: &JsonPointer, location: Option, message: String) { + self.diagnostics.push(LintDiagnostic { + level: self.level, + rule: self.rule.to_string(), + category: self.category, + message, + path: path.to_string(), + file: None, + location, + }); + } +} diff --git a/lib/dsc-lib/src/lint/rules/dependency.rs b/lib/dsc-lib/src/lint/rules/dependency.rs new file mode 100644 index 000000000..678a95b77 --- /dev/null +++ b/lib/dsc-lib/src/lint/rules/dependency.rs @@ -0,0 +1,438 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Rules about the dependencies between resources. + +use std::collections::{HashMap, HashSet}; + +use rust_i18n::t; +use serde_json::Value; + +use crate::{ + lint::{ + context::LintContext, + diagnostic::LintCategory, + expression_index::ExpressionScope, + options::RuleLevel, + pointer::JsonPointer, + rule::{DiagnosticSink, Rule, RuleTier}, + }, + parser::{expressions::Expression, functions::FunctionArg, ParsedStatement}, +}; + +/// The rules in this module. +#[must_use] +pub fn rules() -> Vec { + vec![ + Rule { + id: "unresolved-dependency", + category: LintCategory::Dependency, + tier: RuleTier::Static, + default_level: RuleLevel::Error, + description: || t!("lint.rules.dependency.unresolvedDependencyDescription").to_string(), + check: unresolved_dependency, + }, + Rule { + id: "circular-dependency", + category: LintCategory::Dependency, + tier: RuleTier::Static, + default_level: RuleLevel::Error, + description: || t!("lint.rules.dependency.circularDependencyDescription").to_string(), + check: circular_dependency, + }, + Rule { + id: "reference-without-depends-on", + category: LintCategory::Dependency, + tier: RuleTier::Static, + default_level: RuleLevel::Warning, + description: || t!("lint.rules.dependency.referenceWithoutDependsOnDescription").to_string(), + check: reference_without_depends_on, + }, + Rule { + id: "duplicate-depends-on", + category: LintCategory::Dependency, + tier: RuleTier::Static, + default_level: RuleLevel::Info, + description: || t!("lint.rules.dependency.duplicateDependsOnDescription").to_string(), + check: duplicate_depends_on, + }, + ] +} + +/// Identifies a resource by type and name, as `resourceId()` does. +#[derive(Clone, Debug)] +struct ResourceKey { + /// The resource type as written; compared case-insensitively. + type_name: String, + /// The resource name; compared exactly. + name: String, +} + +impl ResourceKey { + /// Whether this key identifies the same resource as another. + /// + /// # Arguments + /// + /// * `other` - The key to compare with. + fn same_as(&self, other: &ResourceKey) -> bool { + self.name == other.name && self.type_name.eq_ignore_ascii_case(&other.type_name) + } + + /// The key as `type/name`, for messages. + fn display(&self) -> String { + format!("{} named '{}'", self.type_name, self.name) + } +} + +/// How a dependency key resolved against the sibling resources. +enum Resolution { + /// The resource at the given index matches by name and type. + Found(usize), + /// A resource at the given index has the name but a different type. + TypeMismatch(usize), + /// No sibling has the name. + NotFound, +} + +/// The key of a `resourceId('type', 'name')` expression with literal arguments and no accessors. +/// +/// # Arguments +/// +/// * `expression` - The expression. +fn resource_id_key(expression: &Expression) -> Option { + if !expression.accessors().is_empty() || expression.function().name() != "resourceId" { + return None; + } + let args = expression.function().args()?; + match args { + [FunctionArg::Value(Value::String(type_name)), FunctionArg::Value(Value::String(name))] => Some(ResourceKey { + type_name: type_name.clone(), + name: name.clone(), + }), + _ => None, + } +} + +/// The literal `dependsOn` entries of a resource with their pointers. +/// +/// Entries that aren't a literal `resourceId()` call are skipped; the schema already rejects +/// entries that aren't `resourceId()` expressions. +/// +/// # Arguments +/// +/// * `context` - The lint context. +/// * `index` - The index of the resource in the context's resource list. +fn literal_dependencies(context: &LintContext, index: usize) -> Vec<(JsonPointer, ResourceKey)> { + let site = &context.resources[index]; + let Some(depends_on) = &site.resource.depends_on else { + return Vec::new(); + }; + let base = site.pointer.push("dependsOn"); + (0..depends_on.len()).filter_map(|position| { + let pointer = base.push_index(position); + let parsed = context.expressions.site_at(&pointer)?.parsed.as_ref().ok()?; + let ParsedStatement::Expression(expression) = parsed else { + return None; + }; + resource_id_key(expression).map(|key| (pointer, key)) + }).collect() +} + +/// The resources a resource references through `reference(resourceId('type', 'name'))`, with +/// the pointer of each referencing expression. +/// +/// # Arguments +/// +/// * `context` - The lint context. +/// * `index` - The index of the resource in the context's resource list. +fn reference_targets(context: &LintContext, index: usize) -> Vec<(JsonPointer, ResourceKey)> { + context.expressions.calls_named("reference").into_iter() + .filter(|call| call.site.scope == ExpressionScope::Resource(index)) + .filter_map(|call| { + let Some([FunctionArg::Expression(argument), ..]) = call.function.args() else { + return None; + }; + resource_id_key(argument).map(|key| (call.site.pointer.clone(), key)) + }) + .collect() +} + +/// Resolve a dependency key against a group of sibling resources. +/// +/// # Arguments +/// +/// * `context` - The lint context. +/// * `group` - The indexes of the sibling resources. +/// * `key` - The dependency key. +fn resolve(context: &LintContext, group: &[usize], key: &ResourceKey) -> Resolution { + let by_name: Vec = group.iter().copied().filter(|&index| context.resources[index].resource.name == key.name).collect(); + if let Some(&index) = by_name.iter().find(|&&index| context.resources[index].resource.resource_type.as_ref().eq_ignore_ascii_case(&key.type_name)) { + return Resolution::Found(index); + } + match by_name.first() { + Some(&index) => Resolution::TypeMismatch(index), + None => Resolution::NotFound, + } +} + +/// Report `dependsOn` entries and `reference()` calls whose target isn't a sibling resource. +/// +/// Resources in copy loops are skipped, and a missing target isn't reported when a sibling is +/// a copy loop, because copy loops produce names that are only known at runtime. +fn unresolved_dependency(context: &LintContext, sink: &mut DiagnosticSink) { + for group in context.sibling_groups() { + let has_copy = group.iter().any(|&index| context.resources[index].resource.copy.is_some()); + for &index in &group { + let site = &context.resources[index]; + if site.resource.copy.is_some() { + continue; + } + for (pointer, key) in literal_dependencies(context, index).into_iter().chain(reference_targets(context, index)) { + match resolve(context, &group, &key) { + Resolution::Found(_) => {}, + Resolution::TypeMismatch(actual) => sink.report( + &pointer, + t!( + "lint.rules.dependency.dependencyTypeMismatch", + name = site.resource.name, + dependency_type = key.type_name, + dependency_name = key.name, + actual_type = context.resources[actual].resource.resource_type + ).to_string(), + ), + Resolution::NotFound => { + if !has_copy { + sink.report( + &pointer, + t!( + "lint.rules.dependency.unresolvedDependency", + name = site.resource.name, + dependency_type = key.type_name, + dependency_name = key.name + ).to_string(), + ); + } + }, + } + } + } + } +} + +/// Report resources whose `dependsOn` entries form a cycle. +fn circular_dependency(context: &LintContext, sink: &mut DiagnosticSink) { + for group in context.sibling_groups() { + let mut edges: HashMap> = HashMap::new(); + for &index in &group { + if context.resources[index].resource.copy.is_some() { + continue; + } + let targets = literal_dependencies(context, index).into_iter().filter_map(|(_, key)| match resolve(context, &group, &key) { + Resolution::Found(target) => Some(target), + _ => None, + }).collect(); + edges.insert(index, targets); + } + + let mut reported: HashSet> = HashSet::new(); + for &start in &group { + let Some(cycle) = find_cycle(start, &edges) else { + continue; + }; + let mut members = cycle.clone(); + members.sort_unstable(); + if !reported.insert(members) { + continue; + } + let site = &context.resources[start]; + let path: Vec<&str> = cycle.iter().chain(std::iter::once(&start)).map(|&index| context.resources[index].resource.name.as_str()).collect(); + sink.report( + &site.pointer.push("dependsOn"), + t!("lint.rules.dependency.circularDependency", name = site.resource.name, cycle = path.join(" -> ")).to_string(), + ); + } + } +} + +/// Find a dependency path that leads from a resource back to itself. +/// +/// # Arguments +/// +/// * `start` - The resource to start from. +/// * `edges` - The resolved dependencies of each resource. +/// +/// # Returns +/// +/// The resources on the cycle, starting with `start`, or `None` when there is no cycle. +fn find_cycle(start: usize, edges: &HashMap>) -> Option> { + let mut path = Vec::new(); + let mut visited = HashSet::new(); + if walk(start, start, edges, &mut path, &mut visited) { Some(path) } else { None } +} + +/// Depth-first search for a path from `node` back to `start`. +/// +/// # Arguments +/// +/// * `node` - The current resource. +/// * `start` - The resource the search is looking for. +/// * `edges` - The resolved dependencies of each resource. +/// * `path` - The resources on the current path; holds the cycle when the search succeeds. +/// * `visited` - The resources already explored. +fn walk(node: usize, start: usize, edges: &HashMap>, path: &mut Vec, visited: &mut HashSet) -> bool { + path.push(node); + visited.insert(node); + for &next in edges.get(&node).map(Vec::as_slice).unwrap_or(&[]) { + if next == start { + return true; + } + if !visited.contains(&next) && walk(next, start, edges, path, visited) { + return true; + } + } + path.pop(); + false +} + +/// Report `reference()` calls to resources that aren't listed in `dependsOn`. +fn reference_without_depends_on(context: &LintContext, sink: &mut DiagnosticSink) { + for index in 0..context.resources.len() { + let site = &context.resources[index]; + let dependencies = literal_dependencies(context, index); + for (pointer, target) in reference_targets(context, index) { + if !dependencies.iter().any(|(_, dependency)| dependency.same_as(&target)) { + sink.report( + &pointer, + t!("lint.rules.dependency.referenceWithoutDependsOn", name = site.resource.name, target = target.display()).to_string(), + ); + } + } + } +} + +/// Report `dependsOn` entries that repeat an earlier entry. +fn duplicate_depends_on(context: &LintContext, sink: &mut DiagnosticSink) { + for index in 0..context.resources.len() { + let site = &context.resources[index]; + let mut seen: Vec = Vec::new(); + for (pointer, key) in literal_dependencies(context, index) { + if seen.iter().any(|earlier| earlier.same_as(&key)) { + sink.report( + &pointer, + t!("lint.rules.dependency.duplicateDependsOn", name = site.resource.name, target = key.display()).to_string(), + ); + } else { + seen.push(key); + } + } + } +} + +#[cfg(test)] +mod tests { + use crate::lint::test_util::{lint_yaml, rule_messages, rule_paths}; + + #[test] + fn reports_unresolved_and_mismatched_dependencies() { + let result = lint_yaml(r#" +$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json +resources: +- name: first + type: Microsoft.DSC.Debug/Echo +- name: second + type: Microsoft.DSC.Debug/Echo + dependsOn: + - "[resourceId('microsoft.dsc.debug/echo', 'first')]" + - "[resourceId('Microsoft.DSC.Debug/Echo', 'missing')]" + - "[resourceId('Microsoft/OSInfo', 'first')]" + properties: + output: "[reference(resourceId('Microsoft.DSC.Debug/Echo', 'nobody'))]" +"#); + assert_eq!( + rule_paths(&result, "unresolved-dependency"), + vec!["/resources/1/dependsOn/1", "/resources/1/dependsOn/2", "/resources/1/properties/output"] + ); + assert!(rule_messages(&result, "unresolved-dependency")[1].contains("Microsoft.DSC.Debug/Echo")); + } + + #[test] + fn copy_loops_suppress_unresolved_dependencies() { + let result = lint_yaml(r#" +$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json +resources: +- name: "[concat('item', copyIndex())]" + type: Microsoft.DSC.Debug/Echo + copy: + name: items + count: 2 +- name: consumer + type: Microsoft.DSC.Debug/Echo + dependsOn: + - "[resourceId('Microsoft.DSC.Debug/Echo', 'item0')]" +"#); + assert!(rule_paths(&result, "unresolved-dependency").is_empty()); + } + + #[test] + fn reports_cycles_once() { + let result = lint_yaml(r#" +$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json +resources: +- name: a + type: Microsoft.DSC.Debug/Echo + dependsOn: + - "[resourceId('Microsoft.DSC.Debug/Echo', 'b')]" +- name: b + type: Microsoft.DSC.Debug/Echo + dependsOn: + - "[resourceId('Microsoft.DSC.Debug/Echo', 'a')]" +- name: self + type: Microsoft.DSC.Debug/Echo + dependsOn: + - "[resourceId('Microsoft.DSC.Debug/Echo', 'self')]" +- name: fine + type: Microsoft.DSC.Debug/Echo + dependsOn: + - "[resourceId('Microsoft.DSC.Debug/Echo', 'a')]" +"#); + assert_eq!(rule_paths(&result, "circular-dependency"), vec!["/resources/0/dependsOn", "/resources/2/dependsOn"]); + assert!(rule_messages(&result, "circular-dependency")[0].contains("a -> b -> a")); + } + + #[test] + fn reports_references_without_depends_on() { + let result = lint_yaml(r#" +$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json +resources: +- name: source + type: Microsoft.DSC.Debug/Echo +- name: listed + type: Microsoft.DSC.Debug/Echo + dependsOn: + - "[resourceId('Microsoft.DSC.Debug/Echo', 'source')]" + properties: + output: "[reference(resourceId('Microsoft.DSC.Debug/Echo', 'source')).output]" +- name: unlisted + type: Microsoft.DSC.Debug/Echo + properties: + output: "[reference(resourceId('Microsoft.DSC.Debug/Echo', 'source')).output]" +"#); + assert_eq!(rule_paths(&result, "reference-without-depends-on"), vec!["/resources/2/properties/output"]); + } + + #[test] + fn reports_duplicate_depends_on_entries() { + let result = lint_yaml(r#" +$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json +resources: +- name: source + type: Microsoft.DSC.Debug/Echo +- name: twice + type: Microsoft.DSC.Debug/Echo + dependsOn: + - "[resourceId('Microsoft.DSC.Debug/Echo', 'source')]" + - "[resourceId('Microsoft.DSC.Debug/Echo', 'source')]" +"#); + assert_eq!(rule_paths(&result, "duplicate-depends-on"), vec!["/resources/1/dependsOn/1"]); + } +} diff --git a/lib/dsc-lib/src/lint/rules/deprecation.rs b/lib/dsc-lib/src/lint/rules/deprecation.rs new file mode 100644 index 000000000..070bdec7b --- /dev/null +++ b/lib/dsc-lib/src/lint/rules/deprecation.rs @@ -0,0 +1,206 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Rules about features DSC has deprecated or doesn't support. + +use rust_i18n::t; + +use crate::{ + configure::config_doc::ValueOrCopy, + lint::{ + context::LintContext, + diagnostic::LintCategory, + options::RuleLevel, + pointer::JsonPointer, + rule::{DiagnosticSink, Rule, RuleTier}, + }, +}; + +/// The rules in this module. +#[must_use] +pub fn rules() -> Vec { + vec![ + Rule { + id: "copy-loop-deprecated", + category: LintCategory::Deprecation, + tier: RuleTier::Static, + default_level: RuleLevel::Warning, + description: || t!("lint.rules.deprecation.copyLoopDeprecatedDescription").to_string(), + check: copy_loop_deprecated, + }, + Rule { + id: "copy-options-unsupported", + category: LintCategory::Deprecation, + tier: RuleTier::Static, + default_level: RuleLevel::Error, + description: || t!("lint.rules.deprecation.copyOptionsUnsupportedDescription").to_string(), + check: copy_options_unsupported, + }, + Rule { + id: "security-context-in-metadata", + category: LintCategory::Deprecation, + tier: RuleTier::Static, + default_level: RuleLevel::Warning, + description: || t!("lint.rules.deprecation.securityContextInMetadataDescription").to_string(), + check: security_context_in_metadata, + }, + Rule { + id: "security-context-conflict", + category: LintCategory::Deprecation, + tier: RuleTier::Static, + default_level: RuleLevel::Error, + description: || t!("lint.rules.deprecation.securityContextConflictDescription").to_string(), + check: security_context_conflict, + }, + Rule { + id: "dsc-version-unsatisfied", + category: LintCategory::Deprecation, + tier: RuleTier::Static, + default_level: RuleLevel::Warning, + description: || t!("lint.rules.deprecation.dscVersionUnsatisfiedDescription").to_string(), + check: dsc_version_unsatisfied, + }, + ] +} + +/// Report copy loops on resources and outputs. +fn copy_loop_deprecated(context: &LintContext, sink: &mut DiagnosticSink) { + for site in &context.resources { + if let Some(copy) = &site.resource.copy { + sink.report(&site.pointer.push("copy"), t!("lint.rules.deprecation.copyLoopDeprecated", name = copy.name).to_string()); + } + } + if let Some(outputs) = &context.config.outputs { + for (name, output) in outputs { + if let ValueOrCopy::Copy(copy) = &output.value_or_copy { + sink.report( + &JsonPointer::root().push("outputs").push(name).push("copy"), + t!("lint.rules.deprecation.copyLoopDeprecated", name = copy.name).to_string(), + ); + } + } + } +} + +/// Report copy loop options that DSC rejects. +fn copy_options_unsupported(context: &LintContext, sink: &mut DiagnosticSink) { + for site in &context.resources { + let Some(copy) = &site.resource.copy else { + continue; + }; + let base = site.pointer.push("copy"); + if copy.mode.is_some() { + sink.report(&base.push("mode"), t!("lint.rules.deprecation.copyOptionsUnsupported", option = "mode").to_string()); + } + if copy.batch_size.is_some() { + sink.report(&base.push("batchSize"), t!("lint.rules.deprecation.copyOptionsUnsupported", option = "batchSize").to_string()); + } + } +} + +/// Report a security context set through `Microsoft.DSC` metadata. +fn security_context_in_metadata(context: &LintContext, sink: &mut DiagnosticSink) { + if metadata_security_context(context).is_some() { + sink.report( + &JsonPointer::root().push("metadata").push("Microsoft.DSC").push("securityContext"), + t!("lint.rules.deprecation.securityContextInMetadata").to_string(), + ); + } +} + +/// Report a security context in metadata that contradicts the directive. +fn security_context_conflict(context: &LintContext, sink: &mut DiagnosticSink) { + let directive = context.config.directives.as_ref().and_then(|directives| directives.security_context.as_ref()); + if let (Some(metadata), Some(directive)) = (metadata_security_context(context), directive) + && metadata != directive { + sink.report( + &JsonPointer::root().push("directives").push("securityContext"), + t!("lint.rules.deprecation.securityContextConflict", metadata = metadata, directive = directive).to_string(), + ); + } +} + +/// Report a `directives.version` requirement the linting DSC doesn't satisfy. +fn dsc_version_unsatisfied(context: &LintContext, sink: &mut DiagnosticSink) { + let Some(current) = &context.options.dsc_version else { + return; + }; + let Some(required) = context.config.directives.as_ref().and_then(|directives| directives.version.as_ref()) else { + return; + }; + if !required.matches(current) { + sink.report( + &JsonPointer::root().push("directives").push("version"), + t!("lint.rules.deprecation.dscVersionUnsatisfied", required = required, current = current).to_string(), + ); + } +} + +/// The security context set through `Microsoft.DSC` metadata, if any. +/// +/// # Arguments +/// +/// * `context` - The lint context. +fn metadata_security_context<'a>(context: &'a LintContext<'_>) -> Option<&'a crate::configure::config_doc::SecurityContextKind> { + context.config.metadata.as_ref()?.microsoft.as_ref()?.security_context.as_ref() +} + +#[cfg(test)] +mod tests { + use crate::{lint::{options::LintOptions, test_util::{lint_with, lint_yaml, rule_paths}}, types::SemanticVersion}; + + #[test] + fn reports_copy_loops_and_their_options() { + let result = lint_yaml(r#" +$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json +outputs: + items: + type: array + copy: + name: outer + count: 2 +resources: +- name: "[concat('a', copyIndex())]" + type: Microsoft.DSC.Debug/Echo + copy: + name: loop + count: 2 + mode: serial + batchSize: 1 +"#); + assert_eq!(rule_paths(&result, "copy-loop-deprecated"), vec!["/outputs/items/copy", "/resources/0/copy"]); + assert_eq!(rule_paths(&result, "copy-options-unsupported"), vec!["/resources/0/copy/batchSize", "/resources/0/copy/mode"]); + } + + #[test] + fn reports_security_context_in_metadata_and_conflicts() { + let result = lint_yaml(r#" +$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json +metadata: + Microsoft.DSC: + securityContext: elevated +directives: + securityContext: restricted +resources: [] +"#); + assert_eq!(rule_paths(&result, "security-context-in-metadata"), vec!["/metadata/Microsoft.DSC/securityContext"]); + assert_eq!(rule_paths(&result, "security-context-conflict"), vec!["/directives/securityContext"]); + } + + #[test] + fn reports_unsatisfied_dsc_version() { + let options = LintOptions { + discovery: false, + use_settings: false, + dsc_version: Some(SemanticVersion::parse("3.1.0").unwrap()), + ..Default::default() + }; + let result = lint_with(r#" +$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json +directives: + version: ">=4.0.0" +resources: [] +"#, options); + assert_eq!(rule_paths(&result, "dsc-version-unsatisfied"), vec!["/directives/version"]); + } +} diff --git a/lib/dsc-lib/src/lint/rules/document.rs b/lib/dsc-lib/src/lint/rules/document.rs new file mode 100644 index 000000000..05820ecbf --- /dev/null +++ b/lib/dsc-lib/src/lint/rules/document.rs @@ -0,0 +1,364 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Rules about the structure of a configuration document and its resource declarations. + +use rust_i18n::t; +use serde_json::Value; + +use crate::{ + lint::{ + context::LintContext, + diagnostic::LintCategory, + options::RuleLevel, + pointer::JsonPointer, + rule::{DiagnosticSink, Rule, RuleTier}, + }, + types::ResourceVersionReq, +}; + +/// The keys DSC recognizes under `Microsoft.DSC` metadata. +const MICROSOFT_DSC_METADATA_KEYS: [&str; 9] = [ + "duration", + "endDatetime", + "executionType", + "operation", + "restartRequired", + "copyLoops", + "securityContext", + "startDatetime", + "version", +]; + +/// The rules in this module. +#[must_use] +pub fn rules() -> Vec { + vec![ + Rule { + id: "duplicate-resource", + category: LintCategory::Document, + tier: RuleTier::Static, + default_level: RuleLevel::Error, + description: || t!("lint.rules.document.duplicateResourceDescription").to_string(), + check: duplicate_resource, + }, + Rule { + id: "duplicate-resource-name", + category: LintCategory::Document, + tier: RuleTier::Static, + default_level: RuleLevel::Warning, + description: || t!("lint.rules.document.duplicateResourceNameDescription").to_string(), + check: duplicate_resource_name, + }, + Rule { + id: "empty-resources", + category: LintCategory::Document, + tier: RuleTier::Static, + default_level: RuleLevel::Warning, + description: || t!("lint.rules.document.emptyResourcesDescription").to_string(), + check: empty_resources, + }, + Rule { + id: "unknown-microsoft-dsc-metadata", + category: LintCategory::Document, + tier: RuleTier::Static, + default_level: RuleLevel::Warning, + description: || t!("lint.rules.document.unknownMicrosoftDscMetadataDescription").to_string(), + check: unknown_microsoft_dsc_metadata, + }, + Rule { + id: "api-version-alias", + category: LintCategory::Document, + tier: RuleTier::Static, + default_level: RuleLevel::Info, + description: || t!("lint.rules.document.apiVersionAliasDescription").to_string(), + check: api_version_alias, + }, + Rule { + id: "date-version", + category: LintCategory::Document, + tier: RuleTier::Static, + default_level: RuleLevel::Warning, + description: || t!("lint.rules.document.dateVersionDescription").to_string(), + check: date_version, + }, + Rule { + id: "missing-require-version", + category: LintCategory::Document, + tier: RuleTier::Static, + default_level: RuleLevel::Off, + description: || t!("lint.rules.document.missingRequireVersionDescription").to_string(), + check: missing_require_version, + }, + ] +} + +/// Report resources that share a name and a type with an earlier sibling. +/// +/// Resources in copy loops are skipped because their names are only known at runtime. +fn duplicate_resource(context: &LintContext, sink: &mut DiagnosticSink) { + for group in context.sibling_groups() { + for (position, &later) in group.iter().enumerate() { + let later_site = &context.resources[later]; + if later_site.resource.copy.is_some() { + continue; + } + let duplicate = group[..position].iter().any(|&earlier| { + let earlier_site = &context.resources[earlier]; + earlier_site.resource.copy.is_none() + && earlier_site.resource.name == later_site.resource.name + && earlier_site.resource.resource_type == later_site.resource.resource_type + }); + if duplicate { + sink.report( + &later_site.pointer, + t!( + "lint.rules.document.duplicateResource", + name = later_site.resource.name, + type_name = later_site.resource.resource_type + ).to_string(), + ); + } + } + } +} + +/// Report resources that share a name with an earlier sibling of a different type. +fn duplicate_resource_name(context: &LintContext, sink: &mut DiagnosticSink) { + for group in context.sibling_groups() { + for (position, &later) in group.iter().enumerate() { + let later_site = &context.resources[later]; + if later_site.resource.copy.is_some() { + continue; + } + let same_name = group[..position].iter().map(|&earlier| &context.resources[earlier]).find(|earlier_site| { + earlier_site.resource.copy.is_none() + && earlier_site.resource.name == later_site.resource.name + && earlier_site.resource.resource_type != later_site.resource.resource_type + }); + if let Some(earlier_site) = same_name { + sink.report( + &later_site.pointer, + t!( + "lint.rules.document.duplicateResourceName", + name = later_site.resource.name, + first_type = earlier_site.resource.resource_type, + second_type = later_site.resource.resource_type + ).to_string(), + ); + } + } + } +} + +/// Report a configuration without resources. +fn empty_resources(context: &LintContext, sink: &mut DiagnosticSink) { + if context.config.resources.is_empty() { + sink.report(&JsonPointer::root().push("resources"), t!("lint.rules.document.emptyResources").to_string()); + } +} + +/// Report unrecognized keys under `Microsoft.DSC` metadata on the document and on each resource. +fn unknown_microsoft_dsc_metadata(context: &LintContext, sink: &mut DiagnosticSink) { + check_microsoft_dsc_metadata(context.raw, &JsonPointer::root(), sink); + for (index, site) in context.resources.iter().enumerate() { + if let Some(resource) = context.raw_resource(index) { + check_microsoft_dsc_metadata(resource, &site.pointer, sink); + } + } +} + +/// Report the unrecognized keys under the `Microsoft.DSC` metadata of one node. +/// +/// # Arguments +/// +/// * `node` - The document or resource node. +/// * `base` - The pointer to `node`. +/// * `sink` - Where to report. +fn check_microsoft_dsc_metadata(node: &Value, base: &JsonPointer, sink: &mut DiagnosticSink) { + let Some(metadata) = node.get("metadata").and_then(|metadata| metadata.get("Microsoft.DSC")).and_then(Value::as_object) else { + return; + }; + let pointer = base.push("metadata").push("Microsoft.DSC"); + for key in metadata.keys() { + if !MICROSOFT_DSC_METADATA_KEYS.contains(&key.as_str()) { + sink.report(&pointer.push(key), t!("lint.rules.document.unknownMicrosoftDscMetadata", key = key).to_string()); + } + } +} + +/// Report resources written with `apiVersion` instead of `requireVersion`. +fn api_version_alias(context: &LintContext, sink: &mut DiagnosticSink) { + for (index, site) in context.resources.iter().enumerate() { + if context.raw_resource(index).is_some_and(|resource| resource.get("apiVersion").is_some()) { + sink.report(&site.pointer.push("apiVersion"), t!("lint.rules.document.apiVersionAlias").to_string()); + } + } +} + +/// Report resources that require a date-based version. +fn date_version(context: &LintContext, sink: &mut DiagnosticSink) { + for (index, site) in context.resources.iter().enumerate() { + if let Some(version @ ResourceVersionReq::Date(_)) = &site.resource.require_version { + sink.report(&version_pointer(context, index), t!("lint.rules.document.dateVersion", version = version).to_string()); + } + } +} + +/// Report resources that don't pin a version. +fn missing_require_version(context: &LintContext, sink: &mut DiagnosticSink) { + for site in &context.resources { + if site.resource.require_version.is_none() { + sink.report( + &site.pointer, + t!( + "lint.rules.document.missingRequireVersion", + name = site.resource.name, + type_name = site.resource.resource_type + ).to_string(), + ); + } + } +} + +/// The pointer to the version requirement of a resource, whichever key the author used. +/// +/// # Arguments +/// +/// * `context` - The lint context. +/// * `index` - The index of the resource in the context's resource list. +fn version_pointer(context: &LintContext, index: usize) -> JsonPointer { + let site = &context.resources[index]; + if context.raw_resource(index).is_some_and(|resource| resource.get("apiVersion").is_some()) { + site.pointer.push("apiVersion") + } else { + site.pointer.push("requireVersion") + } +} + +#[cfg(test)] +mod tests { + use crate::lint::test_util::{lint_yaml, rule_paths}; + + #[test] + fn reports_duplicate_resources_case_insensitively() { + let result = lint_yaml(r#" +$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json +resources: +- name: Same + type: Microsoft.DSC.Debug/Echo +- name: Same + type: microsoft.dsc.debug/echo +- name: Other + type: Microsoft.DSC.Debug/Echo +"#); + assert_eq!(rule_paths(&result, "duplicate-resource"), vec!["/resources/1"]); + assert!(rule_paths(&result, "duplicate-resource-name").is_empty()); + } + + #[test] + fn skips_copy_loops_for_duplicates() { + let result = lint_yaml(r#" +$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json +resources: +- name: "[concat('a', copyIndex())]" + type: Microsoft.DSC.Debug/Echo + copy: + name: loop + count: 2 +- name: "[concat('a', copyIndex())]" + type: Microsoft.DSC.Debug/Echo + copy: + name: loop + count: 2 +"#); + assert!(rule_paths(&result, "duplicate-resource").is_empty()); + } + + #[test] + fn reports_duplicate_names_across_types() { + let result = lint_yaml(r#" +$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json +resources: +- name: Same + type: Microsoft.DSC.Debug/Echo +- name: Same + type: Microsoft/OSInfo +"#); + assert_eq!(rule_paths(&result, "duplicate-resource-name"), vec!["/resources/1"]); + assert!(rule_paths(&result, "duplicate-resource").is_empty()); + } + + #[test] + fn nested_resources_are_separate_groups() { + let result = lint_yaml(r#" +$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json +resources: +- name: Same + type: Microsoft.DSC/Group + resources: + - name: Same + type: Microsoft.DSC/Group +"#); + assert!(rule_paths(&result, "duplicate-resource").is_empty()); + } + + #[test] + fn reports_empty_resources() { + let result = lint_yaml(r#" +$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json +resources: [] +"#); + assert_eq!(rule_paths(&result, "empty-resources"), vec!["/resources"]); + } + + #[test] + fn reports_unknown_microsoft_dsc_metadata_keys() { + let result = lint_yaml(r#" +$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json +metadata: + Microsoft.DSC: + securityContext: elevated + customKey: 1 + Other: + anything: true +resources: +- name: a + type: Microsoft.DSC.Debug/Echo + metadata: + Microsoft.DSC: + nope: 2 +"#); + assert_eq!( + rule_paths(&result, "unknown-microsoft-dsc-metadata"), + vec!["/metadata/Microsoft.DSC/customKey", "/resources/0/metadata/Microsoft.DSC/nope"] + ); + } + + #[test] + fn reports_api_version_alias_and_date_versions() { + let result = lint_yaml(r#" +$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json +resources: +- name: a + type: Microsoft.DSC.Debug/Echo + apiVersion: "2025-01-01" +- name: b + type: Microsoft.DSC.Debug/Echo + requireVersion: "^1.0" +"#); + assert_eq!(rule_paths(&result, "api-version-alias"), vec!["/resources/0/apiVersion"]); + assert_eq!(rule_paths(&result, "date-version"), vec!["/resources/0/apiVersion"]); + } + + #[test] + fn missing_require_version_is_off_by_default() { + let result = lint_yaml(r#" +$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json +resources: +- name: a + type: Microsoft.DSC.Debug/Echo +"#); + assert!(rule_paths(&result, "missing-require-version").is_empty()); + } +} diff --git a/lib/dsc-lib/src/lint/rules/environment.rs b/lib/dsc-lib/src/lint/rules/environment.rs new file mode 100644 index 000000000..ae77eb3b5 --- /dev/null +++ b/lib/dsc-lib/src/lint/rules/environment.rs @@ -0,0 +1,124 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Rules about the environment a configuration document targets. + +use std::sync::LazyLock; + +use regex::Regex; +use rust_i18n::t; + +use crate::lint::{ + context::LintContext, + diagnostic::LintCategory, + options::RuleLevel, + pointer::{for_each_string, JsonPointer}, + rule::{DiagnosticSink, Rule, RuleTier}, +}; + +/// Absolute paths rooted in an operating system drive or system directory. +static SYSTEM_PATH_PATTERN: LazyLock = LazyLock::new(|| { + Regex::new(r"^(?:[A-Za-z]:[\\/]|/(?:etc|usr|var|opt|bin|sbin|lib|home)/)") + .expect("the system path pattern is a valid regex") +}); + +/// The rules in this module. +#[must_use] +pub fn rules() -> Vec { + vec![ + Rule { + id: "system-root-unused", + category: LintCategory::Environment, + tier: RuleTier::Static, + default_level: RuleLevel::Warning, + description: || t!("lint.rules.environment.systemRootUnusedDescription").to_string(), + check: system_root_unused, + }, + Rule { + id: "hardcoded-system-path", + category: LintCategory::Environment, + tier: RuleTier::Static, + default_level: RuleLevel::Off, + description: || t!("lint.rules.environment.hardcodedSystemPathDescription").to_string(), + check: hardcoded_system_path, + }, + ] +} + +/// Report a document that doesn't call `systemRoot()` although a system root was specified. +fn system_root_unused(context: &LintContext, sink: &mut DiagnosticSink) { + let Some(system_root) = &context.options.system_root else { + return; + }; + if context.expressions.calls_named("systemRoot").is_empty() { + sink.report( + &JsonPointer::root(), + t!("lint.rules.environment.systemRootUnused", path = system_root.display()).to_string(), + ); + } +} + +/// Report literal absolute operating system paths in resource properties. +fn hardcoded_system_path(context: &LintContext, sink: &mut DiagnosticSink) { + for (index, site) in context.resources.iter().enumerate() { + let Some(properties) = context.raw_resource(index).and_then(|resource| resource.get("properties")) else { + continue; + }; + for_each_string(properties, &site.pointer.push("properties"), &mut |pointer, text| { + if !text.starts_with('[') && SYSTEM_PATH_PATTERN.is_match(text) { + sink.report(pointer, t!("lint.rules.environment.hardcodedSystemPath", value = text).to_string()); + } + }); + } +} + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + + use crate::lint::{options::{LintOptions, RuleLevel, RuleLevelOverride}, test_util::{lint_with, rule_paths}}; + + fn options_with_root() -> LintOptions { + LintOptions { + discovery: false, + use_settings: false, + system_root: Some(PathBuf::from("/mnt/image")), + rule_levels: vec![RuleLevelOverride { id: "hardcoded-system-path".to_string(), level: RuleLevel::Info }], + ..Default::default() + } + } + + #[test] + fn reports_unused_system_root_and_hardcoded_paths() { + let result = lint_with(r#" +$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json +resources: +- name: a + type: Microsoft.DSC.Debug/Echo + properties: + output: + windows: 'C:\Windows\System32' + unix: /etc/ssh/sshd_config + relative: config/sshd_config + escaped: "[[C:\\not-an-expression]" +"#, options_with_root()); + assert_eq!(rule_paths(&result, "system-root-unused"), vec![""]); + assert_eq!( + rule_paths(&result, "hardcoded-system-path"), + vec!["/resources/0/properties/output/unix", "/resources/0/properties/output/windows"] + ); + } + + #[test] + fn system_root_call_satisfies_the_rule() { + let result = lint_with(r#" +$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json +resources: +- name: a + type: Microsoft.DSC.Debug/Echo + properties: + output: "[concat(systemRoot(), 'etc')]" +"#, options_with_root()); + assert!(rule_paths(&result, "system-root-unused").is_empty()); + } +} diff --git a/lib/dsc-lib/src/lint/rules/expression.rs b/lib/dsc-lib/src/lint/rules/expression.rs new file mode 100644 index 000000000..d3f961484 --- /dev/null +++ b/lib/dsc-lib/src/lint/rules/expression.rs @@ -0,0 +1,331 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Rules about expressions and the functions they call. + +use rust_i18n::t; + +use crate::lint::{ + context::LintContext, + diagnostic::LintCategory, + expression_index::{literal_args, literal_string_arg, ExpressionScope, FunctionCall}, + options::RuleLevel, + pointer::JsonPointer, + rule::{DiagnosticSink, Rule, RuleTier}, +}; + +/// The rules in this module. +#[must_use] +pub fn rules() -> Vec { + vec![ + Rule { + id: "invalid-expression", + category: LintCategory::Expression, + tier: RuleTier::Static, + default_level: RuleLevel::Error, + description: || t!("lint.rules.expression.invalidExpressionDescription").to_string(), + check: invalid_expression, + }, + Rule { + id: "unknown-function", + category: LintCategory::Expression, + tier: RuleTier::Static, + default_level: RuleLevel::Error, + description: || t!("lint.rules.expression.unknownFunctionDescription").to_string(), + check: unknown_function, + }, + Rule { + id: "invalid-argument-count", + category: LintCategory::Expression, + tier: RuleTier::Static, + default_level: RuleLevel::Error, + description: || t!("lint.rules.expression.invalidArgumentCountDescription").to_string(), + check: invalid_argument_count, + }, + Rule { + id: "invalid-argument-type", + category: LintCategory::Expression, + tier: RuleTier::Static, + default_level: RuleLevel::Error, + description: || t!("lint.rules.expression.invalidArgumentTypeDescription").to_string(), + check: invalid_argument_type, + }, + Rule { + id: "undefined-parameter", + category: LintCategory::Expression, + tier: RuleTier::Static, + default_level: RuleLevel::Error, + description: || t!("lint.rules.expression.undefinedParameterDescription").to_string(), + check: undefined_parameter, + }, + Rule { + id: "undefined-variable", + category: LintCategory::Expression, + tier: RuleTier::Static, + default_level: RuleLevel::Error, + description: || t!("lint.rules.expression.undefinedVariableDescription").to_string(), + check: undefined_variable, + }, + Rule { + id: "condition-not-expression", + category: LintCategory::Expression, + tier: RuleTier::Static, + default_level: RuleLevel::Warning, + description: || t!("lint.rules.expression.conditionNotExpressionDescription").to_string(), + check: condition_not_expression, + }, + ] +} + +/// Whether a call is to `lambda()`, which the runtime dispatches specially with no arguments. +/// +/// # Arguments +/// +/// * `call` - The call. +fn is_lambda(call: &FunctionCall) -> bool { + call.function.name().eq_ignore_ascii_case("lambda") +} + +/// Whether a call can be checked against built-in function metadata. +/// +/// User-defined functions (`namespace.member`) and `lambda()` are excluded. +/// +/// # Arguments +/// +/// * `context` - The lint context. +/// * `call` - The call. +fn is_checkable_builtin(context: &LintContext, call: &FunctionCall) -> bool { + let name = call.function.name(); + !name.contains('.') && !is_lambda(call) && context.functions.get_metadata(name).is_some() +} + +/// Report expressions that fail to parse. +fn invalid_expression(context: &LintContext, sink: &mut DiagnosticSink) { + for site in context.expressions.sites() { + if let Err(err) = &site.parsed { + sink.report( + &site.pointer, + t!( + "lint.rules.expression.invalidExpression", + expression = site.text, + error = LintContext::error_message(err) + ).to_string(), + ); + } + } +} + +/// Report calls to functions that are neither built in nor declared in `functions`. +fn unknown_function(context: &LintContext, sink: &mut DiagnosticSink) { + let user_functions = context.user_function_names(); + for call in context.expressions.calls() { + let name = call.function.name(); + if name.contains('.') { + if !user_functions.contains(name) { + sink.report(&call.site.pointer, t!("lint.rules.expression.unknownUserFunction", name = name).to_string()); + } + continue; + } + if is_lambda(&call) || context.functions.get_metadata(name).is_some() { + continue; + } + let suggestion = context.functions.list().into_iter().map(|function| function.name).find(|known| known.eq_ignore_ascii_case(name)); + let message = match suggestion { + Some(suggestion) => t!("lint.rules.expression.unknownFunctionSuggestion", name = name, suggestion = suggestion), + None => t!("lint.rules.expression.unknownFunction", name = name), + }; + sink.report(&call.site.pointer, message.to_string()); + } +} + +/// Report calls that pass a number of arguments the function doesn't accept. +fn invalid_argument_count(context: &LintContext, sink: &mut DiagnosticSink) { + for call in context.expressions.calls() { + if !is_checkable_builtin(context, &call) { + continue; + } + let count = call.function.args().map_or(0, <[_]>::len); + if let Err(err) = context.functions.validate_arg_count(call.function.name(), count) { + sink.report(&call.site.pointer, LintContext::error_message(&err)); + } + } +} + +/// Report calls whose literal arguments have a kind the function doesn't accept. +fn invalid_argument_type(context: &LintContext, sink: &mut DiagnosticSink) { + for call in context.expressions.calls() { + if !is_checkable_builtin(context, &call) { + continue; + } + let args = literal_args(call.function); + if let Err(err) = context.functions.validate_literal_args(call.function.name(), &args) { + sink.report(&call.site.pointer, LintContext::error_message(&err)); + } + } +} + +/// Report `parameters()` calls that name an undeclared parameter. +/// +/// Calls inside user-defined function bodies refer to the function's own parameters and are +/// skipped. +fn undefined_parameter(context: &LintContext, sink: &mut DiagnosticSink) { + for call in context.expressions.calls_named("parameters") { + if call.site.scope == ExpressionScope::UserFunction { + continue; + } + let Some(name) = literal_string_arg(call.function, 0) else { + continue; + }; + if context.declared_parameter(name).is_none() { + sink.report(&call.site.pointer, t!("lint.rules.expression.undefinedParameter", name = name).to_string()); + } + } +} + +/// Report `variables()` calls that name an undeclared variable. +fn undefined_variable(context: &LintContext, sink: &mut DiagnosticSink) { + for call in context.expressions.calls_named("variables") { + let Some(name) = literal_string_arg(call.function, 0) else { + continue; + }; + let declared = context.config.variables.as_ref().is_some_and(|variables| variables.contains_key(name)); + if !declared { + sink.report(&call.site.pointer, t!("lint.rules.expression.undefinedVariable", name = name).to_string()); + } + } +} + +/// Report resource and output conditions that are plain strings rather than expressions. +fn condition_not_expression(context: &LintContext, sink: &mut DiagnosticSink) { + for site in &context.resources { + if let Some(condition) = &site.resource.condition + && !LintContext::is_expression(condition) { + sink.report( + &site.pointer.push("condition"), + t!("lint.rules.expression.conditionNotExpression", condition = condition).to_string(), + ); + } + } + if let Some(outputs) = &context.config.outputs { + for (name, output) in outputs { + if let Some(condition) = &output.condition + && !LintContext::is_expression(condition) { + sink.report( + &JsonPointer::root().push("outputs").push(name).push("condition"), + t!("lint.rules.expression.conditionNotExpression", condition = condition).to_string(), + ); + } + } + } +} + +#[cfg(test)] +mod tests { + use crate::lint::test_util::{lint_yaml, rule_messages, rule_paths}; + + #[test] + fn reports_invalid_expressions() { + let result = lint_yaml(r#" +$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json +resources: +- name: a + type: Microsoft.DSC.Debug/Echo + properties: + output: "[concat('unterminated)]" + fine: "[[not an expression]" +"#); + assert_eq!(rule_paths(&result, "invalid-expression"), vec!["/resources/0/properties/output"]); + } + + #[test] + fn reports_unknown_functions_with_case_suggestion() { + let result = lint_yaml(r#" +$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json +functions: +- namespace: my + members: + hello: + output: + type: string + value: "[concat('hi', parameters('who'))]" +resources: +- name: a + type: Microsoft.DSC.Debug/Echo + properties: + output: + a: "[Concat('a', 'b')]" + b: "[nosuchfunction()]" + c: "[my.hello('x')]" + d: "[my.missing('x')]" + e: "[map(createArray(1), lambda('item', lambdaVariables('item')))]" +"#); + assert_eq!( + rule_paths(&result, "unknown-function"), + vec!["/resources/0/properties/output/a", "/resources/0/properties/output/b", "/resources/0/properties/output/d"] + ); + let messages = rule_messages(&result, "unknown-function"); + assert!(messages[0].contains("'concat'"), "expected a case suggestion, got {}", messages[0]); + assert!(rule_paths(&result, "undefined-parameter").is_empty(), "user function parameters must not be checked"); + } + + #[test] + fn reports_argument_count_and_type() { + let result = lint_yaml(r#" +$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json +resources: +- name: a + type: Microsoft.DSC.Debug/Echo + properties: + output: + count: "[int('1', '2')]" + kind: "[int(true)]" + nested: "[int(concat('1', '2'))]" +"#); + assert_eq!(rule_paths(&result, "invalid-argument-count"), vec!["/resources/0/properties/output/count"]); + assert_eq!(rule_paths(&result, "invalid-argument-type"), vec!["/resources/0/properties/output/kind"]); + } + + #[test] + fn reports_undefined_parameters_and_variables() { + let result = lint_yaml(r#" +$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json +parameters: + known: + type: string + defaultValue: x +variables: + v: 1 +resources: +- name: a + type: Microsoft.DSC.Debug/Echo + properties: + output: + p1: "[parameters('known')]" + p2: "[parameters('unknown')]" + v1: "[variables('v')]" + v2: "[variables('w')]" +"#); + assert_eq!(rule_paths(&result, "undefined-parameter"), vec!["/resources/0/properties/output/p2"]); + assert_eq!(rule_paths(&result, "undefined-variable"), vec!["/resources/0/properties/output/v2"]); + } + + #[test] + fn reports_plain_string_conditions() { + let result = lint_yaml(r#" +$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json +outputs: + o: + type: string + value: x + condition: "true" +resources: +- name: a + type: Microsoft.DSC.Debug/Echo + condition: "true" +- name: b + type: Microsoft.DSC.Debug/Echo + condition: "[true()]" +"#); + assert_eq!(rule_paths(&result, "condition-not-expression"), vec!["/outputs/o/condition", "/resources/0/condition"]); + } +} diff --git a/lib/dsc-lib/src/lint/rules/mod.rs b/lib/dsc-lib/src/lint/rules/mod.rs new file mode 100644 index 000000000..d6bb0645b --- /dev/null +++ b/lib/dsc-lib/src/lint/rules/mod.rs @@ -0,0 +1,49 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! The lint rules, grouped by the area of the document they inspect. + +use super::rule::Rule; + +pub mod dependency; +pub mod deprecation; +pub mod document; +pub mod environment; +pub mod expression; +pub mod parameter; +pub mod resource; + +/// Every rule, grouped by category. +#[must_use] +pub fn all_rules() -> Vec { + let mut rules = Vec::new(); + rules.extend(document::rules()); + rules.extend(expression::rules()); + rules.extend(parameter::rules()); + rules.extend(dependency::rules()); + rules.extend(deprecation::rules()); + rules.extend(environment::rules()); + rules.extend(resource::rules()); + rules +} + +#[cfg(test)] +mod tests { + use std::collections::HashSet; + + use super::*; + + #[test] + fn rule_ids_are_unique_and_kebab_case() { + let mut seen = HashSet::new(); + for rule in all_rules() { + assert!(seen.insert(rule.id), "duplicate rule id {}", rule.id); + assert!( + rule.id.chars().all(|c| c.is_ascii_lowercase() || c == '-'), + "rule id {} must be kebab-case", + rule.id + ); + assert!(!(rule.description)().is_empty(), "rule {} has no description", rule.id); + } + } +} diff --git a/lib/dsc-lib/src/lint/rules/parameter.rs b/lib/dsc-lib/src/lint/rules/parameter.rs new file mode 100644 index 000000000..3471a0624 --- /dev/null +++ b/lib/dsc-lib/src/lint/rules/parameter.rs @@ -0,0 +1,648 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Rules about parameter and variable declarations and the values supplied for them. + +use std::{collections::HashSet, sync::LazyLock}; + +use regex::Regex; +use rust_i18n::t; +use serde_json::Value; + +use crate::{ + configure::{ + config_doc::{DataType, Parameter, ValueOrCopy}, + constraints::{check_allowed_values, check_length, check_number_limits}, + }, + lint::{ + context::LintContext, + diagnostic::LintCategory, + expression_index::{literal_string_arg, ExpressionScope}, + options::RuleLevel, + pointer::JsonPointer, + rule::{DiagnosticSink, Rule, RuleTier}, + }, +}; + +/// Parameter names that suggest the value is a secret. +static SECRET_NAME_PATTERN: LazyLock = LazyLock::new(|| { + Regex::new(r"(?i)(password|passwd|secret|token|apikey|api[-_]?key|credential|connectionstring)") + .expect("the secret name pattern is a valid regex") +}); + +/// The rules in this module. +#[must_use] +pub fn rules() -> Vec { + vec![ + Rule { + id: "no-unused-parameters", + category: LintCategory::Parameter, + tier: RuleTier::Static, + default_level: RuleLevel::Warning, + description: || t!("lint.rules.parameter.noUnusedParametersDescription").to_string(), + check: no_unused_parameters, + }, + Rule { + id: "no-unused-variables", + category: LintCategory::Parameter, + tier: RuleTier::Static, + default_level: RuleLevel::Warning, + description: || t!("lint.rules.parameter.noUnusedVariablesDescription").to_string(), + check: no_unused_variables, + }, + Rule { + id: "parameter-default-type", + category: LintCategory::Parameter, + tier: RuleTier::Static, + default_level: RuleLevel::Error, + description: || t!("lint.rules.parameter.parameterDefaultTypeDescription").to_string(), + check: parameter_default_type, + }, + Rule { + id: "invalid-parameter-constraints", + category: LintCategory::Parameter, + tier: RuleTier::Static, + default_level: RuleLevel::Error, + description: || t!("lint.rules.parameter.invalidParameterConstraintsDescription").to_string(), + check: invalid_parameter_constraints, + }, + Rule { + id: "secure-parameter-default", + category: LintCategory::Parameter, + tier: RuleTier::Static, + default_level: RuleLevel::Warning, + description: || t!("lint.rules.parameter.secureParameterDefaultDescription").to_string(), + check: secure_parameter_default, + }, + Rule { + id: "prefer-secure-type", + category: LintCategory::Parameter, + tier: RuleTier::Static, + default_level: RuleLevel::Warning, + description: || t!("lint.rules.parameter.preferSecureTypeDescription").to_string(), + check: prefer_secure_type, + }, + Rule { + id: "secure-value-in-output", + category: LintCategory::Parameter, + tier: RuleTier::Static, + default_level: RuleLevel::Warning, + description: || t!("lint.rules.parameter.secureValueInOutputDescription").to_string(), + check: secure_value_in_output, + }, + Rule { + id: "use-parameter-description", + category: LintCategory::Parameter, + tier: RuleTier::Static, + default_level: RuleLevel::Off, + description: || t!("lint.rules.parameter.useParameterDescriptionDescription").to_string(), + check: use_parameter_description, + }, + Rule { + id: "undeclared-parameter-value", + category: LintCategory::Parameter, + tier: RuleTier::Static, + default_level: RuleLevel::Error, + description: || t!("lint.rules.parameter.undeclaredParameterValueDescription").to_string(), + check: undeclared_parameter_value, + }, + Rule { + id: "missing-parameter-value", + category: LintCategory::Parameter, + tier: RuleTier::Static, + default_level: RuleLevel::Error, + description: || t!("lint.rules.parameter.missingParameterValueDescription").to_string(), + check: missing_parameter_value, + }, + Rule { + id: "parameter-value-type", + category: LintCategory::Parameter, + tier: RuleTier::Static, + default_level: RuleLevel::Error, + description: || t!("lint.rules.parameter.parameterValueTypeDescription").to_string(), + check: parameter_value_type, + }, + ] +} + +/// The names passed as the literal first argument of every call of a function, outside +/// user-defined function bodies. +/// +/// # Arguments +/// +/// * `context` - The lint context. +/// * `function_name` - The function, such as `parameters` or `variables`. +fn referenced_names(context: &LintContext, function_name: &str) -> HashSet { + context.expressions.calls_named(function_name).into_iter() + .filter(|call| call.site.scope != ExpressionScope::UserFunction) + .filter_map(|call| literal_string_arg(call.function, 0).map(String::from)) + .collect() +} + +/// The pointer to a declared parameter. +/// +/// # Arguments +/// +/// * `name` - The parameter name. +fn parameter_pointer(name: &str) -> JsonPointer { + JsonPointer::root().push("parameters").push(name) +} + +/// Whether a value is acceptable for a parameter type. +/// +/// Secure types accept the plain value as well as the wrapped form (`{"secureString": ...}`), +/// because DSC wraps plain values when it imports parameters. +/// +/// # Arguments +/// +/// * `value` - The value. +/// * `data_type` - The parameter type. +fn value_matches_type(value: &Value, data_type: &DataType) -> bool { + match data_type { + DataType::String => value.is_string(), + DataType::SecureString => value.is_string() || wrapped_secure_value(value, "secureString").is_some_and(Value::is_string), + DataType::Int => value.is_i64(), + DataType::Bool => value.is_boolean(), + DataType::Object => value.is_object(), + DataType::SecureObject => value.is_object(), + DataType::Array => value.is_array(), + } +} + +/// The inner value of a wrapped secure value such as `{"secureString": "..."}`. +/// +/// # Arguments +/// +/// * `value` - The value to unwrap. +/// * `key` - The wrapper key, `secureString` or `secureObject`. +fn wrapped_secure_value<'a>(value: &'a Value, key: &str) -> Option<&'a Value> { + let object = value.as_object()?; + if object.len() == 1 { object.get(key) } else { None } +} + +/// Check a literal value against a parameter's type and constraints. +/// +/// # Arguments +/// +/// * `name` - The parameter name. +/// * `value` - The value. +/// * `parameter` - The parameter declaration. +/// +/// # Errors +/// +/// Returns the reason the value is invalid. +fn check_value(name: &str, value: &Value, parameter: &Parameter) -> Result<(), String> { + if !value_matches_type(value, ¶meter.parameter_type) { + return Err(t!("lint.rules.parameter.valueNotOfType", type_name = parameter.parameter_type).to_string()); + } + check_allowed_values(name, value, parameter).map_err(|err| LintContext::error_message(&err))?; + if parameter.parameter_type == DataType::Int { + check_number_limits(name, value, parameter).map_err(|err| LintContext::error_message(&err))?; + } + if (value.is_string() || value.is_array()) && has_length_type(parameter) { + check_length(name, value, parameter).map_err(|err| LintContext::error_message(&err))?; + } + Ok(()) +} + +/// Whether length constraints apply to the parameter's type. +/// +/// # Arguments +/// +/// * `parameter` - The parameter declaration. +fn has_length_type(parameter: &Parameter) -> bool { + matches!(parameter.parameter_type, DataType::String | DataType::SecureString | DataType::Array) +} + +/// Whether a parameter has a secure type. +/// +/// # Arguments +/// +/// * `parameter` - The parameter declaration. +fn is_secure(parameter: &Parameter) -> bool { + matches!(parameter.parameter_type, DataType::SecureString | DataType::SecureObject) +} + +/// Report declared parameters that no expression references. +fn no_unused_parameters(context: &LintContext, sink: &mut DiagnosticSink) { + let Some(parameters) = &context.config.parameters else { + return; + }; + let referenced = referenced_names(context, "parameters"); + for name in parameters.keys() { + if !referenced.contains(name) { + sink.report(¶meter_pointer(name), t!("lint.rules.parameter.noUnusedParameters", name = name).to_string()); + } + } +} + +/// Report declared variables that no expression references. +fn no_unused_variables(context: &LintContext, sink: &mut DiagnosticSink) { + let Some(variables) = &context.config.variables else { + return; + }; + let referenced = referenced_names(context, "variables"); + for name in variables.keys() { + if !referenced.contains(name) { + sink.report(&JsonPointer::root().push("variables").push(name), t!("lint.rules.parameter.noUnusedVariables", name = name).to_string()); + } + } +} + +/// Report literal default values that don't satisfy the parameter's type or constraints. +fn parameter_default_type(context: &LintContext, sink: &mut DiagnosticSink) { + let Some(parameters) = &context.config.parameters else { + return; + }; + for (name, parameter) in parameters { + let Some(value) = ¶meter.default_value else { + continue; + }; + if value.as_str().is_some_and(LintContext::is_expression) { + continue; + } + if let Err(reason) = check_value(name, value, parameter) { + sink.report( + ¶meter_pointer(name).push("defaultValue"), + t!("lint.rules.parameter.parameterDefaultType", name = name, reason = reason).to_string(), + ); + } + } +} + +/// Report constraints that contradict each other or don't apply to the parameter's type. +fn invalid_parameter_constraints(context: &LintContext, sink: &mut DiagnosticSink) { + let Some(parameters) = &context.config.parameters else { + return; + }; + for (name, parameter) in parameters { + let base = parameter_pointer(name); + let type_name = ¶meter.parameter_type; + if let (Some(min), Some(max)) = (parameter.min_value, parameter.max_value) + && min > max { + sink.report(&base.push("minValue"), t!("lint.rules.parameter.minValueExceedsMaxValue", name = name, min = min, max = max).to_string()); + } + if let (Some(min), Some(max)) = (parameter.min_length, parameter.max_length) + && min > max { + sink.report(&base.push("minLength"), t!("lint.rules.parameter.minLengthExceedsMaxLength", name = name, min = min, max = max).to_string()); + } + if *type_name != DataType::Int { + for (constraint, limit) in [("minValue", parameter.min_value), ("maxValue", parameter.max_value)] { + if limit.is_some() { + sink.report(&base.push(constraint), t!("lint.rules.parameter.valueLimitOnNonInt", name = name, constraint = constraint, type_name = type_name).to_string()); + } + } + } + for (constraint, limit) in [("minLength", parameter.min_length), ("maxLength", parameter.max_length)] { + let Some(limit) = limit else { + continue; + }; + if !has_length_type(parameter) { + sink.report(&base.push(constraint), t!("lint.rules.parameter.lengthLimitOnOtherType", name = name, constraint = constraint, type_name = type_name).to_string()); + } else if limit < 0 { + sink.report(&base.push(constraint), t!("lint.rules.parameter.negativeLength", name = name, constraint = constraint).to_string()); + } + } + if let Some(allowed_values) = ¶meter.allowed_values { + if allowed_values.is_empty() { + sink.report(&base.push("allowedValues"), t!("lint.rules.parameter.emptyAllowedValues", name = name).to_string()); + } + for (position, allowed) in allowed_values.iter().enumerate() { + if !value_matches_type(allowed, type_name) { + sink.report( + &base.push("allowedValues").push_index(position), + t!("lint.rules.parameter.allowedValueTypeMismatch", name = name, type_name = type_name).to_string(), + ); + } + } + } + } +} + +/// Report secure parameters with a hard-coded default value. +fn secure_parameter_default(context: &LintContext, sink: &mut DiagnosticSink) { + let Some(parameters) = &context.config.parameters else { + return; + }; + for (name, parameter) in parameters { + if !is_secure(parameter) { + continue; + } + let Some(value) = ¶meter.default_value else { + continue; + }; + let harmless = value.as_str().is_some_and(|text| text.is_empty() || LintContext::is_expression(text)); + if !harmless { + sink.report(¶meter_pointer(name).push("defaultValue"), t!("lint.rules.parameter.secureParameterDefault", name = name).to_string()); + } + } +} + +/// Report parameters whose names suggest a secret but whose type isn't secure. +fn prefer_secure_type(context: &LintContext, sink: &mut DiagnosticSink) { + let Some(parameters) = &context.config.parameters else { + return; + }; + for (name, parameter) in parameters { + let secure_type = match parameter.parameter_type { + DataType::String => DataType::SecureString, + DataType::Object => DataType::SecureObject, + _ => continue, + }; + if SECRET_NAME_PATTERN.is_match(name) { + sink.report( + ¶meter_pointer(name), + t!("lint.rules.parameter.preferSecureType", name = name, secure_type = secure_type, type_name = parameter.parameter_type).to_string(), + ); + } + } +} + +/// Report outputs whose value references a secure parameter or a secret. +fn secure_value_in_output(context: &LintContext, sink: &mut DiagnosticSink) { + let Some(outputs) = &context.config.outputs else { + return; + }; + for (name, output) in outputs { + if !matches!(output.value_or_copy, ValueOrCopy::Value(_)) { + continue; + } + let pointer = JsonPointer::root().push("outputs").push(name).push("value"); + let calls = context.expressions.calls().into_iter().filter(|call| call.site.pointer == pointer); + for call in calls { + let source = match call.function.name() { + "parameters" => { + let Some(parameter_name) = literal_string_arg(call.function, 0) else { + continue; + }; + if !context.declared_parameter(parameter_name).is_some_and(is_secure) { + continue; + } + format!("parameters('{parameter_name}')") + }, + "secret" => "secret()".to_string(), + _ => continue, + }; + sink.report(&pointer, t!("lint.rules.parameter.secureValueInOutput", name = name, source = source).to_string()); + break; + } + } +} + +/// Report parameters without a description. +fn use_parameter_description(context: &LintContext, sink: &mut DiagnosticSink) { + let Some(parameters) = &context.config.parameters else { + return; + }; + for (name, parameter) in parameters { + if parameter.description.as_ref().is_none_or(|description| description.trim().is_empty()) { + sink.report(¶meter_pointer(name), t!("lint.rules.parameter.useParameterDescription", name = name).to_string()); + } + } +} + +/// Report supplied parameter values that don't match a declared parameter. +fn undeclared_parameter_value(context: &LintContext, sink: &mut DiagnosticSink) { + let Some(supplied) = &context.supplied_parameters else { + return; + }; + for name in supplied.keys() { + if context.declared_parameter(name).is_none() { + sink.report(&JsonPointer::root().push("parameters"), t!("lint.rules.parameter.undeclaredParameterValue", name = name).to_string()); + } + } +} + +/// Report declared parameters without a default that weren't supplied a value. +fn missing_parameter_value(context: &LintContext, sink: &mut DiagnosticSink) { + let Some(supplied) = &context.supplied_parameters else { + return; + }; + let Some(parameters) = &context.config.parameters else { + return; + }; + for (name, parameter) in parameters { + if parameter.default_value.is_none() && !supplied.contains_key(name) { + sink.report(¶meter_pointer(name), t!("lint.rules.parameter.missingParameterValue", name = name).to_string()); + } + } +} + +/// Report supplied parameter values that don't satisfy the parameter's type or constraints. +fn parameter_value_type(context: &LintContext, sink: &mut DiagnosticSink) { + let Some(supplied) = &context.supplied_parameters else { + return; + }; + for (name, value) in supplied { + let Some(parameter) = context.declared_parameter(name) else { + continue; + }; + if let Err(reason) = check_value(name, value, parameter) { + sink.report(¶meter_pointer(name), t!("lint.rules.parameter.parameterValueType", name = name, reason = reason).to_string()); + } + } +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use crate::lint::{options::LintOptions, test_util::{lint_with, lint_yaml, rule_paths}}; + + #[test] + fn reports_unused_parameters_and_variables() { + let result = lint_yaml(r#" +$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json +parameters: + used: + type: string + defaultValue: a + unused: + type: string + defaultValue: b +variables: + used: 1 + unused: 2 +resources: +- name: a + type: Microsoft.DSC.Debug/Echo + properties: + output: "[concat(parameters('used'), string(variables('used')))]" +"#); + assert_eq!(rule_paths(&result, "no-unused-parameters"), vec!["/parameters/unused"]); + assert_eq!(rule_paths(&result, "no-unused-variables"), vec!["/variables/unused"]); + } + + #[test] + fn reports_invalid_default_values() { + let result = lint_yaml(r#" +$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json +parameters: + wrongType: + type: int + defaultValue: ten + notAllowed: + type: string + defaultValue: c + allowedValues: [a, b] + tooSmall: + type: int + defaultValue: 1 + minValue: 5 + tooShort: + type: string + defaultValue: ab + minLength: 3 + expression: + type: int + defaultValue: "[int('1')]" + secureOk: + type: secureString + defaultValue: "" + fine: + type: array + defaultValue: [1] +resources: [] +"#); + assert_eq!( + rule_paths(&result, "parameter-default-type"), + vec![ + "/parameters/notAllowed/defaultValue", + "/parameters/tooShort/defaultValue", + "/parameters/tooSmall/defaultValue", + "/parameters/wrongType/defaultValue", + ] + ); + } + + #[test] + fn reports_invalid_constraints() { + let result = lint_yaml(r#" +$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json +parameters: + flipped: + type: int + minValue: 10 + maxValue: 1 + lengthOnInt: + type: int + minLength: 1 + valueOnString: + type: string + maxValue: 1 + negative: + type: string + maxLength: -1 + empty: + type: string + allowedValues: [] + mixed: + type: int + allowedValues: [1, two] +resources: [] +"#); + assert_eq!( + rule_paths(&result, "invalid-parameter-constraints"), + vec![ + "/parameters/empty/allowedValues", + "/parameters/flipped/minValue", + "/parameters/lengthOnInt/minLength", + "/parameters/mixed/allowedValues/1", + "/parameters/negative/maxLength", + "/parameters/valueOnString/maxValue", + ] + ); + } + + #[test] + fn reports_secure_defaults_and_secret_like_names() { + let result = lint_yaml(r#" +$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json +parameters: + adminPassword: + type: string + defaultValue: "" + token: + type: secureString + defaultValue: hardcoded + apiKey: + type: secureString + config: + type: object + defaultValue: {} +resources: [] +"#); + assert_eq!(rule_paths(&result, "secure-parameter-default"), vec!["/parameters/token/defaultValue"]); + assert_eq!(rule_paths(&result, "prefer-secure-type"), vec!["/parameters/adminPassword"]); + } + + #[test] + fn reports_secure_values_in_outputs() { + let result = lint_yaml(r#" +$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json +parameters: + password: + type: secureString + plain: + type: string + defaultValue: x +outputs: + leaked: + type: string + value: "[parameters('password')]" + viaSecret: + type: string + value: "[secret('vault')]" + fine: + type: string + value: "[parameters('plain')]" +resources: [] +"#); + assert_eq!(rule_paths(&result, "secure-value-in-output"), vec!["/outputs/leaked/value", "/outputs/viaSecret/value"]); + } + + #[test] + fn checks_supplied_parameter_values() { + let options = LintOptions { + discovery: false, + use_settings: false, + parameters: Some(json!({"parameters": {"count": "five", "extra": 1}})), + ..Default::default() + }; + let result = lint_with(r#" +$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json +parameters: + count: + type: int + required: + type: string + optional: + type: string + defaultValue: x +resources: +- name: a + type: Microsoft.DSC.Debug/Echo + properties: + output: "[concat(string(parameters('count')), parameters('required'), parameters('optional'))]" +"#, options); + assert_eq!(rule_paths(&result, "undeclared-parameter-value"), vec!["/parameters"]); + assert_eq!(rule_paths(&result, "missing-parameter-value"), vec!["/parameters/required"]); + assert_eq!(rule_paths(&result, "parameter-value-type"), vec!["/parameters/count"]); + } + + #[test] + fn parameter_value_rules_need_supplied_parameters() { + let result = lint_yaml(r#" +$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json +parameters: + required: + type: string +resources: +- name: a + type: Microsoft.DSC.Debug/Echo + properties: + output: "[parameters('required')]" +"#); + assert!(rule_paths(&result, "missing-parameter-value").is_empty()); + } +} diff --git a/lib/dsc-lib/src/lint/rules/resource.rs b/lib/dsc-lib/src/lint/rules/resource.rs new file mode 100644 index 000000000..6f14563af --- /dev/null +++ b/lib/dsc-lib/src/lint/rules/resource.rs @@ -0,0 +1,427 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Rules that inspect the installed resources a configuration document uses. They only run +//! when discovery is enabled and never invoke a resource operation. + +use rust_i18n::t; +use serde_json::Value; + +use crate::{ + dscresources::{ + dscresource::{validate_json_all, Capability, DscResource, ImplementedAs}, + resource_manifest::{Kind, SchemaKind}, + }, + lint::{ + context::{LintContext, ResolvedResource}, + diagnostic::LintCategory, + expression_index::ExpressionScope, + options::RuleLevel, + pointer::JsonPointer, + rule::{DiagnosticSink, Rule, RuleTier}, + }, +}; + +/// The rules in this module. +#[must_use] +pub fn rules() -> Vec { + vec![ + Rule { + id: "unknown-resource-type", + category: LintCategory::Resource, + tier: RuleTier::Resource, + default_level: RuleLevel::Warning, + description: || t!("lint.rules.resource.unknownResourceTypeDescription").to_string(), + check: unknown_resource_type, + }, + Rule { + id: "unsatisfied-require-version", + category: LintCategory::Resource, + tier: RuleTier::Resource, + default_level: RuleLevel::Warning, + description: || t!("lint.rules.resource.unsatisfiedRequireVersionDescription").to_string(), + check: unsatisfied_require_version, + }, + Rule { + id: "deprecated-resource", + category: LintCategory::Resource, + tier: RuleTier::Resource, + default_level: RuleLevel::Warning, + description: || t!("lint.rules.resource.deprecatedResourceDescription").to_string(), + check: deprecated_resource, + }, + Rule { + id: "property-schema-mismatch", + category: LintCategory::Resource, + tier: RuleTier::Resource, + default_level: RuleLevel::Error, + description: || t!("lint.rules.resource.propertySchemaMismatchDescription").to_string(), + check: property_schema_mismatch, + }, + Rule { + id: "nested-resources-unsupported", + category: LintCategory::Resource, + tier: RuleTier::Resource, + default_level: RuleLevel::Warning, + description: || t!("lint.rules.resource.nestedResourcesUnsupportedDescription").to_string(), + check: nested_resources_unsupported, + }, + Rule { + id: "exist-false-unsupported", + category: LintCategory::Resource, + tier: RuleTier::Resource, + default_level: RuleLevel::Error, + description: || t!("lint.rules.resource.existFalseUnsupportedDescription").to_string(), + check: exist_false_unsupported, + }, + Rule { + id: "canonical-type-name", + category: LintCategory::Resource, + tier: RuleTier::Resource, + default_level: RuleLevel::Info, + description: || t!("lint.rules.resource.canonicalTypeNameDescription").to_string(), + check: canonical_type_name, + }, + ] +} + +/// The resource declarations paired with what discovery found for them. +/// +/// # Arguments +/// +/// * `context` - The lint context. +/// +/// # Returns +/// +/// One entry per resource declaration, or an empty list when discovery didn't run. +fn resolved<'a>(context: &'a LintContext<'_>) -> Vec<(usize, &'a ResolvedResource)> { + context.discovered.as_ref().map_or_else(Vec::new, |discovered| discovered.iter().enumerate().collect()) +} + +/// The pointer to the version requirement of a resource, whichever key the author used. +/// +/// # Arguments +/// +/// * `context` - The lint context. +/// * `index` - The index of the resource in the context's resource list. +fn version_pointer(context: &LintContext, index: usize) -> JsonPointer { + let site = &context.resources[index]; + if context.raw_resource(index).is_some_and(|resource| resource.get("apiVersion").is_some()) { + site.pointer.push("apiVersion") + } else { + site.pointer.push("requireVersion") + } +} + +/// The schema for a resource's properties without invoking the resource: the schema discovery +/// loaded, or the schema embedded in the manifest. +/// +/// # Arguments +/// +/// * `resource` - The discovered resource. +fn embedded_schema(resource: &DscResource) -> Option { + if let Some(schema) = &resource.schema { + return Some(Value::Object(schema.clone())); + } + if let Some(SchemaKind::Embedded(schema)) = resource.manifest.as_ref().and_then(|manifest| manifest.schema.as_ref()) { + return Some(schema.clone()); + } + None +} + +/// Report resource types that aren't installed. +fn unknown_resource_type(context: &LintContext, sink: &mut DiagnosticSink) { + for (index, resolved) in resolved(context) { + if resolved.found.is_none() && !resolved.type_exists { + let site = &context.resources[index]; + sink.report(&site.pointer.push("type"), t!("lint.rules.resource.unknownResourceType", type_name = site.resource.resource_type).to_string()); + } + } +} + +/// Report version requirements that no installed version of the type satisfies. +fn unsatisfied_require_version(context: &LintContext, sink: &mut DiagnosticSink) { + for (index, resolved) in resolved(context) { + if resolved.found.is_none() && resolved.type_exists + && let Some(version) = &context.resources[index].resource.require_version { + sink.report( + &version_pointer(context, index), + t!( + "lint.rules.resource.unsatisfiedRequireVersion", + type_name = context.resources[index].resource.resource_type, + version = version + ).to_string(), + ); + } + } +} + +/// Report resources whose type is deprecated. +fn deprecated_resource(context: &LintContext, sink: &mut DiagnosticSink) { + for (index, resolved) in resolved(context) { + if let Some(message) = resolved.found.as_ref().and_then(|found| found.deprecation_message.as_ref()) { + let site = &context.resources[index]; + sink.report( + &site.pointer.push("type"), + t!("lint.rules.resource.deprecatedResource", type_name = site.resource.resource_type, message = message).to_string(), + ); + } + } +} + +/// Report property values that don't validate against the resource's embedded schema. +/// +/// Violations at or below a property that holds an expression are dropped, because the value +/// is only known at runtime. +fn property_schema_mismatch(context: &LintContext, sink: &mut DiagnosticSink) { + for (index, resolved) in resolved(context) { + let Some(found) = &resolved.found else { + continue; + }; + if found.implemented_as != Some(ImplementedAs::Command) { + continue; + } + let Some(schema) = embedded_schema(found) else { + continue; + }; + let site = &context.resources[index]; + let base = site.pointer.push("properties"); + let Some(properties) = base.resolve(context.raw) else { + continue; + }; + let violations = match validate_json_all(found.type_name.as_ref(), &schema, properties) { + Ok(violations) => violations, + Err(err) => { + sink.report( + &base, + t!("lint.rules.resource.propertySchemaCompileFailed", type_name = site.resource.resource_type, error = LintContext::error_message(&err)).to_string(), + ); + continue; + }, + }; + for violation in violations { + let pointer = base.join(&violation.instance_path); + let in_expression = context.expressions.sites().iter().any(|expression| { + expression.scope == ExpressionScope::Resource(index) && pointer.starts_with(&expression.pointer) + }); + if in_expression { + continue; + } + sink.report( + &pointer, + t!("lint.rules.resource.propertySchemaMismatch", type_name = site.resource.resource_type, message = violation.message).to_string(), + ); + } + } +} + +/// Report nested resources under a type that isn't a group or adapter. +fn nested_resources_unsupported(context: &LintContext, sink: &mut DiagnosticSink) { + for (index, resolved) in resolved(context) { + let site = &context.resources[index]; + if site.resource.resources.is_none() { + continue; + } + let Some(found) = &resolved.found else { + continue; + }; + if !matches!(found.kind, Kind::Group | Kind::Adapter) { + sink.report( + &site.pointer.push("resources"), + t!( + "lint.rules.resource.nestedResourcesUnsupported", + name = site.resource.name, + type_name = site.resource.resource_type, + kind = format!("{:?}", found.kind).to_lowercase() + ).to_string(), + ); + } + } +} + +/// Report `_exist: false` on a type that can't delete instances. +fn exist_false_unsupported(context: &LintContext, sink: &mut DiagnosticSink) { + for (index, resolved) in resolved(context) { + let site = &context.resources[index]; + let exists = site.resource.properties.as_ref().and_then(|properties| properties.get("_exist")); + if exists != Some(&Value::Bool(false)) { + continue; + } + let Some(found) = &resolved.found else { + continue; + }; + let can_delete = found.capabilities.contains(&Capability::Delete) || found.capabilities.contains(&Capability::SetHandlesExist); + if !can_delete { + sink.report( + &site.pointer.push("properties").push("_exist"), + t!("lint.rules.resource.existFalseUnsupported", type_name = site.resource.resource_type).to_string(), + ); + } + } +} + +/// Report resource types written with different casing than their manifest. +fn canonical_type_name(context: &LintContext, sink: &mut DiagnosticSink) { + for (index, resolved) in resolved(context) { + let Some(found) = &resolved.found else { + continue; + }; + let site = &context.resources[index]; + let written: &str = site.resource.resource_type.as_ref(); + let canonical: &str = found.type_name.as_ref(); + if written != canonical { + sink.report( + &site.pointer.push("type"), + t!("lint.rules.resource.canonicalTypeName", type_name = written, canonical = canonical).to_string(), + ); + } + } +} + +#[cfg(test)] +mod tests { + use serde_json::{json, Value}; + + use crate::{ + configure::config_doc::Configuration, + dscresources::dscresource::{Capability, DscResource, ImplementedAs}, + dscresources::resource_manifest::Kind, + lint::{ + context::{collect_resource_sites, LintContext, ResolvedResource}, + diagnostic::{LintDiagnostic, LintLevel}, + expression_index::ExpressionIndex, + options::LintOptions, + rule::DiagnosticSink, + }, + parser::Statement, + types::FullyQualifiedTypeName, + }; + + use super::rules; + + fn echo(kind: Kind, capabilities: Vec, schema: Option) -> DscResource { + DscResource { + type_name: FullyQualifiedTypeName::parse("Microsoft.DSC.Debug/Echo").unwrap(), + kind, + capabilities, + implemented_as: Some(ImplementedAs::Command), + schema: schema.and_then(|schema| schema.as_object().cloned()), + ..DscResource::new() + } + } + + fn run(document: &str, discovered: Vec, rule_id: &str) -> Vec { + let raw: Value = serde_yaml::from_str(document).unwrap(); + let config: Configuration = serde_json::from_value(raw.clone()).unwrap(); + let options = LintOptions::default(); + let mut parser = Statement::new().unwrap(); + let sites = collect_resource_sites(&config); + let expressions = ExpressionIndex::build(&raw, &sites, &mut parser); + let context = LintContext { + raw: &raw, + config: &config, + resources: sites, + expressions, + options: &options, + functions: parser.function_dispatcher(), + supplied_parameters: None, + discovered: Some(discovered), + }; + let rule = rules().into_iter().find(|rule| rule.id == rule_id).unwrap(); + let mut diagnostics = Vec::new(); + let mut sink = DiagnosticSink::new(rule.id, rule.category, LintLevel::Error, &mut diagnostics); + (rule.check)(&context, &mut sink); + diagnostics + } + + fn paths(diagnostics: &[LintDiagnostic]) -> Vec<&str> { + diagnostics.iter().map(|diagnostic| diagnostic.path.as_str()).collect() + } + + const DOCUMENT: &str = r#" +$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json +resources: +- name: a + type: microsoft.dsc.debug/echo + requireVersion: "^9.0" + properties: + output: literal + dynamic: "[parameters('x')]" + _exist: false + resources: + - name: nested + type: Microsoft.DSC.Debug/Echo +"#; + + #[test] + fn reports_unknown_types_and_unsatisfied_versions() { + let discovered = vec![ + ResolvedResource { found: None, type_exists: true }, + ResolvedResource { found: None, type_exists: false }, + ]; + assert_eq!(paths(&run(DOCUMENT, discovered, "unknown-resource-type")), vec!["/resources/0/resources/0/type"]); + let discovered = vec![ + ResolvedResource { found: None, type_exists: true }, + ResolvedResource { found: None, type_exists: false }, + ]; + assert_eq!(paths(&run(DOCUMENT, discovered, "unsatisfied-require-version")), vec!["/resources/0/requireVersion"]); + } + + #[test] + fn reports_deprecated_resources_and_casing() { + let mut deprecated = echo(Kind::Resource, vec![Capability::Get], None); + deprecated.deprecation_message = Some("use something else".to_string()); + let discovered = vec![ + ResolvedResource { found: Some(deprecated), type_exists: true }, + ResolvedResource { found: Some(echo(Kind::Resource, vec![Capability::Get], None)), type_exists: true }, + ]; + assert_eq!(paths(&run(DOCUMENT, discovered, "deprecated-resource")), vec!["/resources/0/type"]); + let discovered = vec![ + ResolvedResource { found: Some(echo(Kind::Resource, vec![Capability::Get], None)), type_exists: true }, + ResolvedResource { found: Some(echo(Kind::Resource, vec![Capability::Get], None)), type_exists: true }, + ]; + assert_eq!(paths(&run(DOCUMENT, discovered, "canonical-type-name")), vec!["/resources/0/type"]); + } + + #[test] + fn reports_schema_violations_outside_expressions() { + let schema = json!({ + "type": "object", + "additionalProperties": false, + "properties": { + "output": { "type": "integer" }, + "dynamic": { "type": "integer" }, + "_exist": { "type": "boolean" } + } + }); + let discovered = vec![ + ResolvedResource { found: Some(echo(Kind::Resource, vec![Capability::Get], Some(schema))), type_exists: true }, + ResolvedResource { found: Some(echo(Kind::Resource, vec![Capability::Get], None)), type_exists: true }, + ]; + assert_eq!(paths(&run(DOCUMENT, discovered, "property-schema-mismatch")), vec!["/resources/0/properties/output"]); + } + + #[test] + fn reports_nested_resources_and_exist_false_on_unsupported_types() { + let discovered = vec![ + ResolvedResource { found: Some(echo(Kind::Resource, vec![Capability::Get, Capability::Set], None)), type_exists: true }, + ResolvedResource { found: Some(echo(Kind::Resource, vec![Capability::Get], None)), type_exists: true }, + ]; + assert_eq!(paths(&run(DOCUMENT, discovered, "nested-resources-unsupported")), vec!["/resources/0/resources"]); + let discovered = vec![ + ResolvedResource { found: Some(echo(Kind::Resource, vec![Capability::Get, Capability::Set], None)), type_exists: true }, + ResolvedResource { found: Some(echo(Kind::Resource, vec![Capability::Get], None)), type_exists: true }, + ]; + assert_eq!(paths(&run(DOCUMENT, discovered, "exist-false-unsupported")), vec!["/resources/0/properties/_exist"]); + let discovered = vec![ + ResolvedResource { found: Some(echo(Kind::Group, vec![Capability::Get, Capability::Delete], None)), type_exists: true }, + ResolvedResource { found: Some(echo(Kind::Resource, vec![Capability::Get], None)), type_exists: true }, + ]; + assert!(run(DOCUMENT, discovered, "nested-resources-unsupported").is_empty()); + let discovered = vec![ + ResolvedResource { found: Some(echo(Kind::Group, vec![Capability::Get, Capability::Delete], None)), type_exists: true }, + ResolvedResource { found: Some(echo(Kind::Resource, vec![Capability::Get], None)), type_exists: true }, + ]; + assert!(run(DOCUMENT, discovered, "exist-false-unsupported").is_empty()); + } +} diff --git a/lib/dsc-lib/src/parser/expressions.rs b/lib/dsc-lib/src/parser/expressions.rs index 5071da666..514b8abee 100644 --- a/lib/dsc-lib/src/parser/expressions.rs +++ b/lib/dsc-lib/src/parser/expressions.rs @@ -204,4 +204,16 @@ impl Expression { Ok(value) } } + + /// Get the function the expression invokes. + #[must_use] + pub fn function(&self) -> &Function { + &self.function + } + + /// Get the accessors applied to the function result, in the order they are applied. + #[must_use] + pub fn accessors(&self) -> &[Accessor] { + &self.accessors + } } diff --git a/lib/dsc-lib/src/parser/functions.rs b/lib/dsc-lib/src/parser/functions.rs index 68c68c74b..194cc599b 100644 --- a/lib/dsc-lib/src/parser/functions.rs +++ b/lib/dsc-lib/src/parser/functions.rs @@ -147,6 +147,12 @@ impl Function { pub fn name(&self) -> &str { &self.name } + + /// Get the arguments passed to the function, if any were written. + #[must_use] + pub fn args(&self) -> Option<&[FunctionArg]> { + self.args.as_deref() + } } fn convert_args_node(statement_bytes: &[u8], args: Option<&Node>) -> Result>, DscError> { diff --git a/lib/dsc-lib/src/parser/mod.rs b/lib/dsc-lib/src/parser/mod.rs index eb67e3dce..790df9177 100644 --- a/lib/dsc-lib/src/parser/mod.rs +++ b/lib/dsc-lib/src/parser/mod.rs @@ -19,6 +19,17 @@ pub struct Statement { function_dispatcher: FunctionDispatcher, } +/// The result of parsing a statement without evaluating it. +#[derive(Clone, Debug)] +pub enum ParsedStatement { + /// The statement is a plain string that doesn't contain an expression. + Literal(String), + /// The statement started with `[[`; the value has the escape character removed. + Escaped(String), + /// The statement is an expression that can be invoked. + Expression(Expression), +} + impl Statement { /// Create a new `StatementParser` instance. /// @@ -56,6 +67,29 @@ impl Statement { return Ok(Value::String(statement.to_string())); } + match self.parse(statement)? { + ParsedStatement::Literal(value) | ParsedStatement::Escaped(value) => Ok(Value::String(value)), + ParsedStatement::Expression(expression) => expression.invoke(&self.function_dispatcher, context), + } + } + + /// Parse a statement without evaluating it. + /// + /// Use this to inspect the expression tree of a statement, for example to find which + /// functions it calls, without needing a [`Context`] and without side effects. + /// + /// # Arguments + /// + /// * `statement` - The statement to parse. + /// + /// # Returns + /// + /// The parsed statement: a literal, an escaped literal, or an expression tree. + /// + /// # Errors + /// + /// This function will return an error if the statement fails to parse. + pub fn parse(&mut self, statement: &str) -> Result { let Some(tree) = &mut self.parser.parse(statement, None) else { return Err(DscError::Parser(t!("parser.failedToParse", statement = statement).to_string())); }; @@ -68,19 +102,19 @@ impl Statement { } let statement_bytes = statement.as_bytes(); let mut cursor = root_node.walk(); - let mut return_value = Value::Null; + let mut parsed = ParsedStatement::Literal(statement.to_string()); for child_node in root_node.named_children(&mut cursor) { if child_node.is_error() { return Err(DscError::Parser(t!("parser.failedToParse", statement = statement).to_string())); } - match child_node.kind() { + parsed = match child_node.kind() { "stringLiteral" => { let Ok(value) = child_node.utf8_text(statement_bytes) else { return Err(DscError::Parser(t!("parser.failedToParseStringLiteral").to_string())); }; debug!("{}", t!("parser.parsingStringLiteral", value = value.to_string())); - return_value = Value::String(value.to_string()); + ParsedStatement::Literal(value.to_string()) }, "escapedStringLiteral" => { // need to remove the first character: [[ => [ @@ -88,20 +122,25 @@ impl Statement { return Err(DscError::Parser(t!("parser.failedToParseEscapedStringLiteral").to_string())); }; debug!("{}", t!("parser.parsingEscapedStringLiteral", value = value[1..].to_string())); - return_value = Value::String(value[1..].to_string()); + ParsedStatement::Escaped(value[1..].to_string()) }, "expression" => { debug!("{}", t!("parser.parsingExpression")); - let expression = Expression::new(statement_bytes, &child_node)?; - return_value = expression.invoke(&self.function_dispatcher, context)?; + ParsedStatement::Expression(Expression::new(statement_bytes, &child_node)?) }, _ => { return Err(DscError::Parser(t!("parser.unknownExpressionType", kind = child_node.kind()).to_string())); } - } + }; } - Ok(return_value) + Ok(parsed) + } + + /// Get the function dispatcher used to evaluate expressions. + #[must_use] + pub fn function_dispatcher(&self) -> &FunctionDispatcher { + &self.function_dispatcher } } @@ -141,6 +180,39 @@ mod tests { assert!(result.is_err()); } + #[test] + fn parse_literal_without_evaluating() { + let mut parser = Statement::new().unwrap(); + let result = parser.parse("plain text").unwrap(); + assert!(matches!(result, ParsedStatement::Literal(value) if value == "plain text")); + } + + #[test] + fn parse_escaped_literal_without_evaluating() { + let mut parser = Statement::new().unwrap(); + let result = parser.parse("[[not an expression]").unwrap(); + assert!(matches!(result, ParsedStatement::Escaped(value) if value == "[not an expression]")); + } + + #[test] + fn parse_expression_without_evaluating() { + let mut parser = Statement::new().unwrap(); + // `parameters('x')` would fail to evaluate without a context, but parsing must succeed + let result = parser.parse("[parameters('x').member]").unwrap(); + let ParsedStatement::Expression(expression) = result else { + panic!("expected an expression"); + }; + assert_eq!(expression.function().name(), "parameters"); + assert_eq!(expression.function().args().map(<[_]>::len), Some(1)); + assert_eq!(expression.accessors().len(), 1); + } + + #[test] + fn parse_invalid_expression_fails() { + let mut parser = Statement::new().unwrap(); + assert!(parser.parse("[concat('abc)]").is_err()); + } + #[test] fn invalid_function() { let mut parser = Statement::new().unwrap(); diff --git a/lib/dsc-lib/src/util.rs b/lib/dsc-lib/src/util.rs index a291f8d55..e0e3c1d4b 100644 --- a/lib/dsc-lib/src/util.rs +++ b/lib/dsc-lib/src/util.rs @@ -30,6 +30,34 @@ impl Default for DscSettingValue { } } +/// Parse a JSON or YAML string into a JSON value. +/// +/// JSON is tried first; if that fails the input is parsed as YAML. +/// +/// # Arguments +/// +/// * `value` - A string slice that holds the input value +/// +/// # Returns +/// +/// The parsed value +/// +/// # Errors +/// +/// Returns `DscError::Yaml` with the YAML parser error when the input is neither valid JSON nor +/// valid YAML. +pub fn parse_input_to_value(value: &str) -> Result { + match serde_json::from_str::(value) { + Ok(json) => Ok(json), + Err(_) => { + match serde_yaml::from_str::(value) { + Ok(yaml) => Ok(yaml), + Err(err) => Err(DscError::Yaml(err)), + } + } + } +} + /// Return JSON string whether the input is JSON or YAML /// /// # Arguments diff --git a/lib/dsc-lib/tests/integration/schemas/schema_for.rs b/lib/dsc-lib/tests/integration/schemas/schema_for.rs index 4bcff527c..c228eca7e 100644 --- a/lib/dsc-lib/tests/integration/schemas/schema_for.rs +++ b/lib/dsc-lib/tests/integration/schemas/schema_for.rs @@ -132,6 +132,17 @@ macro_rules! test_schema_for { } } + #[cfg(test)] mod lint { + #[allow(unused_must_use)] + #[cfg(test)] mod diagnostic { + test_schema_for!(dsc_lib::lint::diagnostic::LintResult); + } + #[allow(unused_must_use)] + #[cfg(test)] mod rule { + test_schema_for!(dsc_lib::lint::rule::LintRuleInfo); + } + } + #[cfg(test)] mod extensions { #[allow(unused_must_use)] #[cfg(test)] mod discover { diff --git a/xtask/src/schemas/export.rs b/xtask/src/schemas/export.rs index fdfb4a8b8..f90a64608 100644 --- a/xtask/src/schemas/export.rs +++ b/xtask/src/schemas/export.rs @@ -149,6 +149,8 @@ pub(crate) fn export_schemas( dsc_lib::functions::FunctionArgKind, dsc_lib::functions::FunctionCategory, dsc_lib::functions::FunctionDefinition, + dsc_lib::lint::diagnostic::LintResult, + dsc_lib::lint::rule::LintRuleInfo, dsc_lib::types::DateVersion, dsc_lib::types::ExitCodesMap, dsc_lib::types::FullyQualifiedTypeName, From 5b64b44dfc9ee7db7c4060064795f47fb9d20749 Mon Sep 17 00:00:00 2001 From: "G.Reijn" <26114636+Gijsreyn@users.noreply.github.com> Date: Thu, 22 Oct 2026 00:49:57 +0200 Subject: [PATCH 2/6] Resolve Copilot remarks --- CHANGELOG.md | 6 +- docs/reference/cli/lint/rules.md | 37 ++++-- dsc/locales/en-us.toml | 5 + dsc/src/server/lint_dsc_config.rs | 118 +++++++++++++++++ dsc/src/server/mcp_server.rs | 1 + dsc/src/server/mod.rs | 1 + dsc/tests/dsc_server.tests.ps1 | 60 +++++++++ lib/dsc-lib/locales/en-us.toml | 6 +- lib/dsc-lib/src/dscresources/dscresource.rs | 41 ++++-- lib/dsc-lib/src/lint/context.rs | 24 ++++ lib/dsc-lib/src/lint/expression_index.rs | 96 ++++++++++++-- lib/dsc-lib/src/lint/rules/dependency.rs | 115 +++++++++++++++- lib/dsc-lib/src/lint/rules/expression.rs | 71 ++++++++-- lib/dsc-lib/src/lint/rules/parameter.rs | 97 ++++++++++++-- lib/dsc-lib/src/lint/rules/resource.rs | 137 ++++++++++++++++++-- 15 files changed, 750 insertions(+), 65 deletions(-) create mode 100644 dsc/src/server/lint_dsc_config.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index cab2b748d..45f8f17f3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -85,14 +85,15 @@ changes since the last release, see the [diff on GitHub][unreleased]. resource types that aren't installed or literal properties that don't match a resource's schema. You can change the level of a rule for a run with the `--rule` option or for every run in the new `lint` section of the settings file, and control which level makes the command fail with - `--fail-on`. The `dsc schema` command exposes the new `lint-result` and `lint-rules` schemas. + `--fail-on`. The `dsc schema` command exposes the new `lint-result` and `lint-rules` schemas, + and the MCP server exposes the linter as the `lint_dsc_config` tool.
Related work items - Issues: - [#591][#591] - [#842][#842] - - PRs: _None_ + - PRs: [#1768][#1768]
@@ -2021,4 +2022,5 @@ Version `3.0.0` is the first generally available release of DSC. [#1558]: https://github.com/PowerShell/DSC/issues/1558 [#1562]: https://github.com/PowerShell/DSC/issues/1562 [#1729]: https://github.com/PowerShell/DSC/issues/1729 +[#1768]: https://github.com/PowerShell/DSC/issues/1768 diff --git a/docs/reference/cli/lint/rules.md b/docs/reference/cli/lint/rules.md index 79b0cf21a..ae0a60c75 100644 --- a/docs/reference/cli/lint/rules.md +++ b/docs/reference/cli/lint/rules.md @@ -130,10 +130,20 @@ enable it to require reproducible documents. ## Expression rules +The expression rules check strings in the places DSC evaluates them: parameter default values, +variables, output values and conditions, user-defined function bodies, and the name, condition, +`dependsOn` entries, copy settings, and properties of each resource. A parameter default value or +variable is only an expression when the whole value is a string; DSC uses object and array values +as written, so strings nested in them aren't checked. The properties of a group resource, such as +`Microsoft.DSC/Group`, hold an embedded configuration document that DSC evaluates separately, so +they aren't checked against the outer document. + ### invalid-expression Every string that starts with `[` is evaluated as an expression and must parse. To use a literal -string that starts with a bracket, escape it with `[[`. +string that starts with a bracket, escape it with `[[`. When the expression is the default value of +a `secureString` or `secureObject` parameter, the diagnostic omits the expression and the parser +error, because they can contain a secret. ```yaml properties: @@ -184,7 +194,8 @@ Declared variables should be referenced by at least one expression. ### parameter-default-type A literal default value must satisfy the parameter's type, `allowedValues`, value limits, and -length limits. Default values that are expressions aren't checked. +length limits. Default values that are expressions aren't checked. An escaped string such as +`"[[x]"` is checked as the value DSC uses, `"[x]"`. ### invalid-parameter-constraints @@ -205,8 +216,11 @@ A `string` or `object` parameter whose name suggests a secret, such as `password ### secure-value-in-output -Outputs shouldn't expose a secure parameter or a `secret()` value. DSC skips such outputs at -runtime. +Outputs shouldn't use a secure parameter or a `secret()` value. DSC only withholds outputs whose +declared type is `secureString` or `secureObject`; it doesn't track where a value came from. An +output of any other type that extracts a plain value, such as +`[parameters('password').secureString]`, returns that value in the result. Outputs declared as +`secureString` or `secureObject` aren't reported, because DSC never returns them. ### use-parameter-description @@ -226,6 +240,8 @@ parameters on purpose. ### parameter-value-type When you pass parameters, every supplied value must satisfy the parameter's type and constraints. +A `secureString` value must be supplied as a plain string, not in the wrapped +`{"secureString": "..."}` form that a default value can use. ## Dependency rules @@ -234,7 +250,9 @@ When you pass parameters, every supplied value must satisfy the parameter's type Every `dependsOn` entry and every `reference(resourceId(...))` call must point at a resource declared at the same level. The diagnostic distinguishes a missing resource from a resource whose type doesn't match. Resources in copy loops aren't checked, because their names are only known at -runtime. +runtime. `dependsOn` entries match resource names as written, the way DSC resolves them. A +`reference()` call matches the name DSC evaluates, so it isn't reported when a resource at the same +level has a name that is an expression. ```yaml resources: @@ -326,9 +344,12 @@ includes the deprecation message from the manifest. ### property-schema-mismatch -Literal property values must validate against the resource's embedded schema. Properties that hold -expressions aren't checked, because their value is only known at runtime. The rule never invokes -the resource to retrieve its schema. +Literal property values must validate against the resource's embedded schema. Escaped strings such +as `"[[x]"` are validated as the value DSC passes to the resource, `"[x]"`. Properties that hold +expressions aren't checked, because their value is only known at runtime. When an expression sits +inside an `anyOf` or `oneOf`, the rule only reports the failure if every alternative fails for a +reason the expression can't change, such as a literal sibling with the wrong type. The rule never +invokes the resource to retrieve its schema. ### nested-resources-unsupported diff --git a/dsc/locales/en-us.toml b/dsc/locales/en-us.toml index 0a0c9640f..ad52059ee 100644 --- a/dsc/locales/en-us.toml +++ b/dsc/locales/en-us.toml @@ -123,6 +123,11 @@ functionInvocationFailed = "Function '%{function}' invocation failed: %{error}" resourceNotFound = "Resource type '%{resource}' does not exist" whatIfNotSupported = "what_if is only supported for the 'set' and 'delete' operations" +[server.lint_dsc_config] +invalidParameters = "Invalid parameters" +failedConvertJson = "Failed to convert to JSON" +lintFailed = "Failed to lint the configuration" + [server.list_dsc_functions] invalidNamePattern = "Invalid function name pattern '%{pattern}'" invalidDescriptionPattern = "Invalid function description pattern '%{pattern}'" diff --git a/dsc/src/server/lint_dsc_config.rs b/dsc/src/server/lint_dsc_config.rs new file mode 100644 index 000000000..a4e7e28e8 --- /dev/null +++ b/dsc/src/server/lint_dsc_config.rs @@ -0,0 +1,118 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use std::collections::HashMap; + +use crate::server::mcp_server::McpServer; +use crate::util::current_dsc_version; +use dsc_lib::{ + dscerror::DscError, + lint::{LintError, LintOptions, LintResult, Linter, RuleLevel, RuleLevelOverride}, + progress::ProgressFormat, +}; +use rmcp::{handler::server::wrapper::Parameters, tool, tool_router, ErrorData as McpError, Json}; +use rust_i18n::t; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use tokio::task; + +#[derive(Serialize, JsonSchema)] +pub struct LintDscConfigResponse { + pub result: LintResult, +} + +#[derive(Deserialize, JsonSchema)] +pub struct LintDscConfigRequest { + #[schemars(description = "The DSC configuration document as a JSON or YAML string")] + pub configuration: String, + #[schemars( + description = "Optional parameters to check against the configuration as a JSON or YAML string, with or without a top-level 'parameters' wrapper" + )] + pub parameters: Option, + #[schemars( + description = "When false, skip resource discovery and the rules that inspect installed resources. Defaults to true." + )] + #[serde(default)] + pub discovery: Option, + #[schemars(description = "Optional rule levels for this run, keyed by rule identifier")] + #[serde(default)] + pub rules: Option>, +} + +/// Parse the parameters of a lint request into the input format `dsc config` accepts. +/// +/// # Arguments +/// +/// * `parameters` - The parameters as JSON or YAML, with or without a top-level `parameters` wrapper. +/// +/// # Errors +/// +/// Returns an invalid-request error when the text isn't valid JSON or YAML. +fn parse_parameters(parameters: &str) -> Result { + let yaml = serde_yaml::from_str::(parameters) + .map_err(|e| McpError::invalid_request(format!("{}: {e}", t!("server.lint_dsc_config.invalidParameters")), None))?; + let json = serde_json::to_value(yaml) + .map_err(|e| McpError::invalid_request(format!("{}: {e}", t!("server.lint_dsc_config.failedConvertJson")), None))?; + if json.get("parameters").is_some_and(Value::is_object) { + Ok(json) + } else { + Ok(serde_json::json!({ "parameters": json })) + } +} + +/// Convert a linter error into the MCP error for it. +/// +/// # Arguments +/// +/// * `error` - The error from the linter. +fn lint_error(error: DscError) -> McpError { + let message = format!("{}: {error}", t!("server.lint_dsc_config.lintFailed")); + match error { + DscError::Lint(LintError::UnknownRule(_)) => McpError::invalid_params(message, None), + DscError::Lint(LintError::InvalidParameters(_)) => McpError::invalid_request(message, None), + _ => McpError::internal_error(message, None), + } +} + +#[tool_router(router = lint_dsc_config_router, vis = "pub")] +impl McpServer { + #[tool( + description = "Lint a DSC configuration document without applying it. Returns diagnostics for problems such as invalid expressions, undeclared parameters, unresolved dependencies, and, with discovery, property values that don't match the resource schema.", + annotations( + title = "Lint a DSC configuration document", + read_only_hint = true, + destructive_hint = false, + idempotent_hint = true, + open_world_hint = false, + ) + )] + pub async fn lint_dsc_config( + &self, + Parameters(LintDscConfigRequest { + configuration, + parameters, + discovery, + rules, + }): Parameters, + ) -> Result, McpError> { + let result = task::spawn_blocking(move || { + let parameters = parameters.as_deref().map(parse_parameters).transpose()?; + let rule_levels = rules.unwrap_or_default().into_iter().map(|(id, level)| RuleLevelOverride { id, level }).collect(); + let options = LintOptions { + discovery: discovery.unwrap_or(true), + rule_levels, + parameters, + dsc_version: Some(current_dsc_version()), + progress_format: ProgressFormat::None, + ..LintOptions::default() + }; + let mut linter = Linter::new(options).map_err(lint_error)?; + linter.lint(&configuration).map_err(lint_error) + }) + .await + .map_err(|e| McpError::internal_error(e.to_string(), None))??; + + Ok(Json(LintDscConfigResponse { result })) + } +} diff --git a/dsc/src/server/mcp_server.rs b/dsc/src/server/mcp_server.rs index 951b5cd14..a76351cf1 100644 --- a/dsc/src/server/mcp_server.rs +++ b/dsc/src/server/mcp_server.rs @@ -25,6 +25,7 @@ impl McpServer { + Self::invoke_dsc_expression_router() + Self::invoke_dsc_function_router() + Self::invoke_dsc_resource_router() + + Self::lint_dsc_config_router() + Self::list_dsc_functions_router() + Self::list_dsc_resources_router() + Self::show_dsc_resource_router() diff --git a/dsc/src/server/mod.rs b/dsc/src/server/mod.rs index b7c844b4d..e8e8509bd 100644 --- a/dsc/src/server/mod.rs +++ b/dsc/src/server/mod.rs @@ -13,6 +13,7 @@ pub mod invoke_dsc_config; pub mod invoke_dsc_expression; pub mod invoke_dsc_function; pub mod invoke_dsc_resource; +pub mod lint_dsc_config; pub mod list_dsc_functions; pub mod list_dsc_resources; pub mod mcp_server; diff --git a/dsc/tests/dsc_server.tests.ps1 b/dsc/tests/dsc_server.tests.ps1 index 3382a7a2a..bb4052096 100644 --- a/dsc/tests/dsc_server.tests.ps1 +++ b/dsc/tests/dsc_server.tests.ps1 @@ -75,6 +75,7 @@ Describe 'Tests for DSC server' { 'invoke_dsc_expression' = $false 'invoke_dsc_function' = $false 'invoke_dsc_resource' = $false + 'lint_dsc_config' = $false 'list_dsc_functions' = $false 'list_dsc_resources' = $false 'show_dsc_resource' = $false @@ -1108,4 +1109,63 @@ resources: $response.error.code | Should -Be -32600 $response.error.message | Should -BeExactly "Function 'add' invocation failed: Parser: Function 'add' does not accept string arguments, accepted types are: Number" } + + It 'Calling lint_dsc_config returns diagnostics' { + $configuration = @' +$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json +parameters: + unused: + type: string + defaultValue: x +resources: +- name: a + type: Microsoft.DSC.Debug/Echo + properties: + output: "[parameters('missing')]" +'@ + $mcpRequest = @{ + jsonrpc = "2.0" + id = 39 + method = "tools/call" + params = @{ + name = "lint_dsc_config" + arguments = @{ + configuration = $configuration + discovery = $false + rules = @{ 'no-unused-parameters' = 'info' } + } + } + } + + $response = Send-McpRequest -request $mcpRequest + $response.id | Should -Be 39 + $result = $response.result.structuredContent.result + $result.valid | Should -BeFalse -Because ($response | ConvertTo-Json -Depth 20 | Out-String) + $result.discovery | Should -BeFalse + $undefined = $result.diagnostics | Where-Object rule -EQ 'undefined-parameter' + $undefined.path | Should -BeExactly '/resources/0/properties/output' + $unused = $result.diagnostics | Where-Object rule -EQ 'no-unused-parameters' + $unused.level | Should -BeExactly 'info' + } + + It 'Calling lint_dsc_config with an unknown rule returns error' { + $mcpRequest = @{ + jsonrpc = "2.0" + id = 40 + method = "tools/call" + params = @{ + name = "lint_dsc_config" + arguments = @{ + configuration = '{"$schema": "https://aka.ms/dsc/schemas/v3/bundled/config/document.json", "resources": []}' + discovery = $false + rules = @{ 'no-such-rule' = 'off' } + } + } + } + + $response = Send-McpRequest -request $mcpRequest + $response.id | Should -Be 40 + $response.error.code | Should -Be -32602 + $response.error.message | Should -Match 'no-such-rule' + } } diff --git a/lib/dsc-lib/locales/en-us.toml b/lib/dsc-lib/locales/en-us.toml index 380b729db..bc3d57520 100644 --- a/lib/dsc-lib/locales/en-us.toml +++ b/lib/dsc-lib/locales/en-us.toml @@ -1141,6 +1141,7 @@ missingRequireVersion = "Resource '%{name}' doesn't pin the version of '%{type_n [lint.rules.expression] invalidExpressionDescription = "Strings that start with '[' must be valid expressions; escape a literal bracket with '[['." invalidExpression = "Expression '%{expression}' failed to parse: %{error}" +invalidExpressionInSecureParameter = "The default value of secure parameter '%{name}' isn't a valid expression; the expression isn't shown because it can hold a secret" unknownFunctionDescription = "Expressions may only call built-in functions or user-defined functions declared in 'functions'." unknownFunction = "Unknown function '%{name}'" unknownFunctionSuggestion = "Unknown function '%{name}'; function names are case-sensitive, did you mean '%{suggestion}'?" @@ -1162,6 +1163,7 @@ noUnusedVariables = "Variable '%{name}' is declared but never used" parameterDefaultTypeDescription = "A literal default value must satisfy the parameter's type and constraints." parameterDefaultType = "The default value of parameter '%{name}' is invalid: %{reason}" valueNotOfType = "the value isn't of type '%{type_name}'" +secureStringNotPlain = "a 'secureString' value must be supplied as a plain string" invalidParameterConstraintsDescription = "Parameter constraints must be consistent with each other and with the parameter type." minValueExceedsMaxValue = "Parameter '%{name}' has a minValue of %{min} that is greater than its maxValue of %{max}" minLengthExceedsMaxLength = "Parameter '%{name}' has a minLength of %{min} that is greater than its maxLength of %{max}" @@ -1174,8 +1176,8 @@ secureParameterDefaultDescription = "Secure parameters shouldn't have a hard-cod secureParameterDefault = "Secure parameter '%{name}' has a hard-coded default value" preferSecureTypeDescription = "Parameters whose names suggest a secret should use the 'secureString' or 'secureObject' type." preferSecureType = "Parameter '%{name}' looks like a secret; use type '%{secure_type}' instead of '%{type_name}'" -secureValueInOutputDescription = "Outputs shouldn't expose secure values; DSC skips such outputs at runtime." -secureValueInOutput = "Output '%{name}' exposes the secure value from %{source}, so DSC skips it at runtime" +secureValueInOutputDescription = "Outputs shouldn't use secure values; DSC only withholds outputs declared as 'secureString' or 'secureObject', so a plain value extracted into any other output is returned in the result." +secureValueInOutput = "Output '%{name}' uses the secure value from %{source}; because the output isn't declared as 'secureString' or 'secureObject', a plain value extracted from it is returned in the result" useParameterDescriptionDescription = "Parameters should have a description." useParameterDescription = "Parameter '%{name}' has no description" undeclaredParameterValueDescription = "Supplied parameter values must match a declared parameter." diff --git a/lib/dsc-lib/src/dscresources/dscresource.rs b/lib/dsc-lib/src/dscresources/dscresource.rs index 4b5f8f677..785e1a0f0 100644 --- a/lib/dsc-lib/src/dscresources/dscresource.rs +++ b/lib/dsc-lib/src/dscresources/dscresource.rs @@ -6,7 +6,7 @@ use crate::discovery::discovery_trait::DiscoveryFilter; use crate::dscresources::invoke_result::{ResourceGetResponse, ResourceSetResponse}; use crate::schemas::transforms::idiomaticize_string_enum; use dscerror::DscError; -use jsonschema::Validator; +use jsonschema::{error::ValidationErrorKind, ValidationError, Validator}; use rust_i18n::t; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; @@ -945,14 +945,44 @@ pub struct SchemaViolation { pub instance_path: String, /// JSON pointer to the keyword in the schema that the instance violated. pub schema_path: String, + /// The keyword that failed, such as `type` or `anyOf`. + pub keyword: String, /// The validation error message. pub message: String, + /// For a failed `anyOf`, or a `oneOf` that no subschema matched, the violations of each + /// subschema in order; empty for every other keyword. + pub alternatives: Vec>, +} + +impl SchemaViolation { + /// Convert a validation error, including the violations of each `anyOf` or `oneOf` + /// subschema. + /// + /// # Arguments + /// + /// * `error` - The validation error. + fn from_error(error: &ValidationError<'_>) -> Self { + let alternatives = match error.kind() { + ValidationErrorKind::AnyOf { context } | ValidationErrorKind::OneOfNotValid { context } => { + context.iter().map(|branch| branch.iter().map(Self::from_error).collect()).collect() + }, + _ => Vec::new(), + }; + Self { + instance_path: error.instance_path().to_string(), + schema_path: error.schema_path().to_string(), + keyword: error.kind().keyword().to_string(), + message: error.to_string(), + alternatives, + } + } } /// Validate the JSON against the schema and return every violation. /// /// Unlike [`validate_json`], which stops at the first error, this function collects all -/// violations so callers can report each one with its location. +/// violations so callers can report each one with its location. The JSON isn't traced because +/// it can hold secure values. /// /// # Arguments /// @@ -969,7 +999,6 @@ pub struct SchemaViolation { /// * `DscError::Validation` - The schema failed to compile pub fn validate_json_all(source: &str, schema: &Value, json: &Value) -> Result, DscError> { debug!("{}: {source}", t!("dscresources.dscresource.validatingSchema")); - trace!("{}", t!("dscresources.dscresource.json", json = json)); trace!("{}", t!("dscresources.dscresource.schema", schema = schema)); let compiled_schema = match Validator::new(schema) { Ok(compiled_schema) => compiled_schema, @@ -978,11 +1007,7 @@ pub fn validate_json_all(source: &str, schema: &Value, json: &Value) -> Result { pub parent: Option, } +/// The built-in group resources. They receive an embedded configuration document as their +/// properties and evaluate its expressions themselves. +const GROUP_RESOURCE_TYPES: [&str; 2] = ["Microsoft.DSC/Group", "Microsoft.DSC/Assertion"]; + +impl ResourceSite<'_> { + /// Whether the resource's properties are an embedded configuration document, which DSC + /// passes to the resource without evaluating its expressions. + /// + /// Without discovery the resource kind isn't known, so this recognizes the built-in group + /// resources and any properties that declare a configuration document `$schema`. + #[must_use] + pub fn has_embedded_configuration(&self) -> bool { + let type_name: &str = self.resource.resource_type.as_ref(); + if GROUP_RESOURCE_TYPES.iter().any(|group| group.eq_ignore_ascii_case(type_name)) { + return true; + } + self.resource.properties.as_ref() + .and_then(|properties| properties.get("$schema")) + .and_then(Value::as_str) + .is_some_and(|uri| Configuration::is_recognized_schema_uri(&uri.to_string())) + } +} + /// The outcome of discovering the resource type a declaration uses. pub struct ResolvedResource { /// The installed resource that satisfies the declaration, if one was found. diff --git a/lib/dsc-lib/src/lint/expression_index.rs b/lib/dsc-lib/src/lint/expression_index.rs index 864c1b3ec..66fcf2715 100644 --- a/lib/dsc-lib/src/lint/expression_index.rs +++ b/lib/dsc-lib/src/lint/expression_index.rs @@ -59,7 +59,10 @@ impl ExpressionIndex { /// /// Strings are walked in the places DSC evaluates expressions: parameter default values, /// variables, outputs, user-defined function bodies, and for each resource its name, - /// condition, `dependsOn` entries, copy counts, and properties. + /// condition, `dependsOn` entries, copy counts, and properties. Parameter default values and + /// variables are only expressions when the whole value is a string; DSC uses object and + /// array values as written. The properties of a resource that holds an embedded + /// configuration are skipped, because that configuration is evaluated on its own. /// /// # Arguments /// @@ -78,7 +81,7 @@ impl ExpressionIndex { let base = root.push("parameters"); for (name, parameter) in parameters { if let Some(default_value) = parameter.get("defaultValue") { - index.walk(default_value, &base.push(name).push("defaultValue"), &ExpressionScope::Parameter(name.clone()), parser); + index.add_string(default_value, &base.push(name).push("defaultValue"), &ExpressionScope::Parameter(name.clone()), parser); } } } @@ -86,7 +89,7 @@ impl ExpressionIndex { if let Some(variables) = raw.get("variables").and_then(Value::as_object) { let base = root.push("variables"); for (name, variable) in variables { - index.walk(variable, &base.push(name), &ExpressionScope::Variable(name.clone()), parser); + index.add_string(variable, &base.push(name), &ExpressionScope::Variable(name.clone()), parser); } } @@ -123,6 +126,9 @@ impl ExpressionIndex { }; let scope = ExpressionScope::Resource(position); for key in ["name", "condition", "dependsOn", "properties"] { + if key == "properties" && site.has_embedded_configuration() { + continue; + } if let Some(member) = resource.get(key) { index.walk(member, &site.pointer.push(key), &scope, parser); } @@ -139,6 +145,27 @@ impl ExpressionIndex { index } + /// Parse a value when it is a string that starts with `[`, without looking inside objects + /// or arrays. + /// + /// # Arguments + /// + /// * `value` - The value. + /// * `pointer` - The pointer to `value`. + /// * `scope` - The scope the expression belongs to. + /// * `parser` - The parser to parse expressions with. + fn add_string(&mut self, value: &Value, pointer: &JsonPointer, scope: &ExpressionScope, parser: &mut Statement) { + if let Value::String(text) = value && text.starts_with('[') { + self.by_pointer.insert(pointer.to_string(), self.sites.len()); + self.sites.push(ExpressionSite { + pointer: pointer.clone(), + text: text.clone(), + scope: scope.clone(), + parsed: parser.parse(text), + }); + } + } + /// Walk a value, parsing every string that starts with `[`. /// /// # Arguments @@ -149,15 +176,7 @@ impl ExpressionIndex { /// * `parser` - The parser to parse expressions with. fn walk(&mut self, value: &Value, pointer: &JsonPointer, scope: &ExpressionScope, parser: &mut Statement) { match value { - Value::String(text) if text.starts_with('[') => { - self.by_pointer.insert(pointer.to_string(), self.sites.len()); - self.sites.push(ExpressionSite { - pointer: pointer.clone(), - text: text.clone(), - scope: scope.clone(), - parsed: parser.parse(text), - }); - }, + Value::String(_) => self.add_string(value, pointer, scope, parser), Value::Array(items) => { for (position, item) in items.iter().enumerate() { self.walk(item, &pointer.push_index(position), scope, parser); @@ -363,4 +382,57 @@ resources: assert!(index.sites()[0].parsed.is_err()); assert!(index.calls().is_empty()); } + + #[test] + fn indexes_only_string_defaults_and_variables() { + let (_, index) = index_for(r#" +$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json +parameters: + p: + type: object + defaultValue: + nested: "[parameters('missing')]" +variables: + literal: + nested: "[parameters('missing')]" + list: + - "[parameters('missing')]" + evaluated: "[parameters('p')]" +resources: [] +"#); + let pointers: Vec<&str> = index.sites().iter().map(|site| site.pointer.as_str()).collect(); + assert_eq!(pointers, vec!["/variables/evaluated"]); + } + + #[test] + fn skips_embedded_configurations() { + let (_, index) = index_for(r#" +$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json +resources: +- name: "[concat('group', '1')]" + type: Microsoft.DSC/Group + properties: + $schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json + resources: + - name: inner + type: Microsoft.DSC.Debug/Echo + properties: + output: "[parameters('inner')]" +- name: other + type: Third.Party/Group + properties: + $schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json + resources: [] + parameters: + x: + type: string + defaultValue: "[parameters('inner')]" +- name: plain + type: Microsoft.DSC.Debug/Echo + properties: + output: "[parameters('outer')]" +"#); + let pointers: Vec<&str> = index.sites().iter().map(|site| site.pointer.as_str()).collect(); + assert_eq!(pointers, vec!["/resources/0/name", "/resources/2/properties/output"]); + } } diff --git a/lib/dsc-lib/src/lint/rules/dependency.rs b/lib/dsc-lib/src/lint/rules/dependency.rs index 678a95b77..b5adaa2d1 100644 --- a/lib/dsc-lib/src/lint/rules/dependency.rs +++ b/lib/dsc-lib/src/lint/rules/dependency.rs @@ -157,7 +157,8 @@ fn reference_targets(context: &LintContext, index: usize) -> Vec<(JsonPointer, R .collect() } -/// Resolve a dependency key against a group of sibling resources. +/// Resolve a dependency key against a group of sibling resources by their names as written, +/// which is how DSC resolves `dependsOn`. /// /// # Arguments /// @@ -165,7 +166,64 @@ fn reference_targets(context: &LintContext, index: usize) -> Vec<(JsonPointer, R /// * `group` - The indexes of the sibling resources. /// * `key` - The dependency key. fn resolve(context: &LintContext, group: &[usize], key: &ResourceKey) -> Resolution { - let by_name: Vec = group.iter().copied().filter(|&index| context.resources[index].resource.name == key.name).collect(); + resolve_by(context, group, key, |index| Some(context.resources[index].resource.name.clone())) +} + +/// Resolve a `reference()` key against a group of sibling resources by their evaluated names, +/// which is how DSC resolves references. +/// +/// # Arguments +/// +/// * `context` - The lint context. +/// * `group` - The indexes of the sibling resources. +/// * `key` - The referenced key. +/// +/// # Returns +/// +/// The resolution, or `None` when the key isn't found and a sibling's name is an expression +/// that could evaluate to it. +fn resolve_reference(context: &LintContext, group: &[usize], key: &ResourceKey) -> Option { + let resolution = resolve_by(context, group, key, |index| evaluated_name(context, index)); + let has_dynamic_name = group.iter().any(|&index| evaluated_name(context, index).is_none()); + match resolution { + Resolution::Found(_) => Some(resolution), + _ if has_dynamic_name => None, + _ => Some(resolution), + } +} + +/// The name a resource has at runtime, when it is known without evaluating an expression. +/// +/// # Arguments +/// +/// * `context` - The lint context. +/// * `index` - The index of the resource in the context's resource list. +/// +/// # Returns +/// +/// The name as written, the name without the escape character when it starts with `[[`, or +/// `None` when the name is an expression. +fn evaluated_name(context: &LintContext, index: usize) -> Option { + let name = &context.resources[index].resource.name; + if LintContext::is_expression(name) { + None + } else if let Some(unescaped) = name.strip_prefix('[') { + Some(unescaped.to_string()) + } else { + Some(name.clone()) + } +} + +/// Resolve a dependency key against a group of sibling resources. +/// +/// # Arguments +/// +/// * `context` - The lint context. +/// * `group` - The indexes of the sibling resources. +/// * `key` - The dependency key. +/// * `name_of` - The name to match for a sibling, or `None` when it isn't known. +fn resolve_by(context: &LintContext, group: &[usize], key: &ResourceKey, name_of: impl Fn(usize) -> Option) -> Resolution { + let by_name: Vec = group.iter().copied().filter(|&index| name_of(index).is_some_and(|name| name == key.name)).collect(); if let Some(&index) = by_name.iter().find(|&&index| context.resources[index].resource.resource_type.as_ref().eq_ignore_ascii_case(&key.type_name)) { return Resolution::Found(index); } @@ -178,7 +236,8 @@ fn resolve(context: &LintContext, group: &[usize], key: &ResourceKey) -> Resolut /// Report `dependsOn` entries and `reference()` calls whose target isn't a sibling resource. /// /// Resources in copy loops are skipped, and a missing target isn't reported when a sibling is -/// a copy loop, because copy loops produce names that are only known at runtime. +/// a copy loop, because copy loops produce names that are only known at runtime. For the same +/// reason a `reference()` call isn't reported when a sibling's name is an expression. fn unresolved_dependency(context: &LintContext, sink: &mut DiagnosticSink) { for group in context.sibling_groups() { let has_copy = group.iter().any(|&index| context.resources[index].resource.copy.is_some()); @@ -187,8 +246,19 @@ fn unresolved_dependency(context: &LintContext, sink: &mut DiagnosticSink) { if site.resource.copy.is_some() { continue; } - for (pointer, key) in literal_dependencies(context, index).into_iter().chain(reference_targets(context, index)) { - match resolve(context, &group, &key) { + let dependencies = literal_dependencies(context, index).into_iter().map(|(pointer, key)| { + let resolution = resolve(context, &group, &key); + (pointer, key, Some(resolution)) + }); + let references = reference_targets(context, index).into_iter().map(|(pointer, key)| { + let resolution = resolve_reference(context, &group, &key); + (pointer, key, resolution) + }); + for (pointer, key, resolution) in dependencies.chain(references) { + let Some(resolution) = resolution else { + continue; + }; + match resolution { Resolution::Found(_) => {}, Resolution::TypeMismatch(actual) => sink.report( &pointer, @@ -355,6 +425,41 @@ resources: assert!(rule_messages(&result, "unresolved-dependency")[1].contains("Microsoft.DSC.Debug/Echo")); } + #[test] + fn references_resolve_against_evaluated_names() { + let result = lint_yaml(r#" +$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json +parameters: + name: + type: string + defaultValue: source +resources: +- name: "[parameters('name')]" + type: Microsoft.DSC.Debug/Echo +- name: consumer + type: Microsoft.DSC.Debug/Echo + dependsOn: + - "[resourceId('Microsoft.DSC.Debug/Echo', 'source')]" + properties: + output: "[reference(resourceId('Microsoft.DSC.Debug/Echo', 'source'))]" +"#); + // dependsOn matches names as written, so only it is reported + assert_eq!(rule_paths(&result, "unresolved-dependency"), vec!["/resources/1/dependsOn/0"]); + + let result = lint_yaml(r#" +$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json +resources: +- name: "[[bracketed]" + type: Microsoft.DSC.Debug/Echo +- name: consumer + type: Microsoft.DSC.Debug/Echo + properties: + found: "[reference(resourceId('Microsoft.DSC.Debug/Echo', '[bracketed]'))]" + missing: "[reference(resourceId('Microsoft.DSC.Debug/Echo', '[[bracketed]'))]" +"#); + assert_eq!(rule_paths(&result, "unresolved-dependency"), vec!["/resources/1/properties/missing"]); + } + #[test] fn copy_loops_suppress_unresolved_dependencies() { let result = lint_yaml(r#" diff --git a/lib/dsc-lib/src/lint/rules/expression.rs b/lib/dsc-lib/src/lint/rules/expression.rs index d3f961484..70ff62bff 100644 --- a/lib/dsc-lib/src/lint/rules/expression.rs +++ b/lib/dsc-lib/src/lint/rules/expression.rs @@ -5,6 +5,7 @@ use rust_i18n::t; +use crate::configure::config_doc::DataType; use crate::lint::{ context::LintContext, diagnostic::LintCategory, @@ -99,19 +100,39 @@ fn is_checkable_builtin(context: &LintContext, call: &FunctionCall) -> bool { !name.contains('.') && !is_lambda(call) && context.functions.get_metadata(name).is_some() } +/// The name of the secure parameter whose default value holds an expression, if any. +/// +/// # Arguments +/// +/// * `context` - The lint context. +/// * `scope` - Where the expression appears. +fn secure_parameter_scope<'a>(context: &LintContext, scope: &'a ExpressionScope) -> Option<&'a str> { + let ExpressionScope::Parameter(name) = scope else { + return None; + }; + let secure = context.declared_parameter(name) + .is_some_and(|parameter| matches!(parameter.parameter_type, DataType::SecureString | DataType::SecureObject)); + secure.then_some(name.as_str()) +} + /// Report expressions that fail to parse. +/// +/// The expression and the parser error, which repeats it, are left out of the message when the +/// expression is the default value of a secure parameter, because it can hold a secret. fn invalid_expression(context: &LintContext, sink: &mut DiagnosticSink) { for site in context.expressions.sites() { - if let Err(err) = &site.parsed { - sink.report( - &site.pointer, - t!( - "lint.rules.expression.invalidExpression", - expression = site.text, - error = LintContext::error_message(err) - ).to_string(), - ); - } + let Err(err) = &site.parsed else { + continue; + }; + let message = match secure_parameter_scope(context, &site.scope) { + Some(name) => t!("lint.rules.expression.invalidExpressionInSecureParameter", name = name), + None => t!( + "lint.rules.expression.invalidExpression", + expression = site.text, + error = LintContext::error_message(err) + ), + }; + sink.report(&site.pointer, message.to_string()); } } @@ -237,6 +258,36 @@ resources: assert_eq!(rule_paths(&result, "invalid-expression"), vec!["/resources/0/properties/output"]); } + #[test] + fn redacts_invalid_expressions_in_secure_defaults() { + let result = lint_yaml(r#" +$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json +parameters: + password: + type: secureString + defaultValue: "[concat('hunter2', 'unterminated)]" + blob: + type: secureObject + defaultValue: "[createObject('key', 'hunter2'))]" + plain: + type: string + defaultValue: "[concat('visible', 'unterminated)]" +resources: +- name: a + type: Microsoft.DSC.Debug/Echo + properties: + output: "[concat(parameters('password'), parameters('blob'), parameters('plain'))]" +"#); + assert_eq!( + rule_paths(&result, "invalid-expression"), + vec!["/parameters/blob/defaultValue", "/parameters/password/defaultValue", "/parameters/plain/defaultValue"] + ); + let messages = rule_messages(&result, "invalid-expression"); + assert!(messages[0].contains("'blob'") && !messages[0].contains("hunter2"), "{}", messages[0]); + assert!(messages[1].contains("'password'") && !messages[1].contains("hunter2"), "{}", messages[1]); + assert!(messages[2].contains("visible"), "{}", messages[2]); + } + #[test] fn reports_unknown_functions_with_case_suggestion() { let result = lint_yaml(r#" diff --git a/lib/dsc-lib/src/lint/rules/parameter.rs b/lib/dsc-lib/src/lint/rules/parameter.rs index 3471a0624..bd81201d6 100644 --- a/lib/dsc-lib/src/lint/rules/parameter.rs +++ b/lib/dsc-lib/src/lint/rules/parameter.rs @@ -22,6 +22,7 @@ use crate::{ pointer::JsonPointer, rule::{DiagnosticSink, Rule, RuleTier}, }, + parser::ParsedStatement, }; /// Parameter names that suggest the value is a secret. @@ -250,6 +251,9 @@ fn no_unused_variables(context: &LintContext, sink: &mut DiagnosticSink) { } /// Report literal default values that don't satisfy the parameter's type or constraints. +/// +/// An escaped string default (`[[...`) is checked with the escape character removed, which is +/// the value DSC uses. fn parameter_default_type(context: &LintContext, sink: &mut DiagnosticSink) { let Some(parameters) = &context.config.parameters else { return; @@ -261,11 +265,13 @@ fn parameter_default_type(context: &LintContext, sink: &mut DiagnosticSink) { if value.as_str().is_some_and(LintContext::is_expression) { continue; } - if let Err(reason) = check_value(name, value, parameter) { - sink.report( - ¶meter_pointer(name).push("defaultValue"), - t!("lint.rules.parameter.parameterDefaultType", name = name, reason = reason).to_string(), - ); + let pointer = parameter_pointer(name).push("defaultValue"); + let unescaped = match context.expressions.site_at(&pointer).map(|site| &site.parsed) { + Some(Ok(ParsedStatement::Escaped(text))) => Value::String(text.clone()), + _ => value.clone(), + }; + if let Err(reason) = check_value(name, &unescaped, parameter) { + sink.report(&pointer, t!("lint.rules.parameter.parameterDefaultType", name = name, reason = reason).to_string()); } } } @@ -359,12 +365,15 @@ fn prefer_secure_type(context: &LintContext, sink: &mut DiagnosticSink) { } /// Report outputs whose value references a secure parameter or a secret. +/// +/// Outputs declared as `secureString` or `secureObject` are skipped, because DSC withholds them +/// from the result. fn secure_value_in_output(context: &LintContext, sink: &mut DiagnosticSink) { let Some(outputs) = &context.config.outputs else { return; }; for (name, output) in outputs { - if !matches!(output.value_or_copy, ValueOrCopy::Value(_)) { + if !matches!(output.value_or_copy, ValueOrCopy::Value(_)) || matches!(output.r#type, DataType::SecureString | DataType::SecureObject) { continue; } let pointer = JsonPointer::root().push("outputs").push(name).push("value"); @@ -428,6 +437,27 @@ fn missing_parameter_value(context: &LintContext, sink: &mut DiagnosticSink) { } } +/// Check a supplied value against a parameter's type and constraints. +/// +/// Unlike a default value, a supplied `secureString` value must be a plain string; DSC wraps it +/// itself and rejects the wrapped form. +/// +/// # Arguments +/// +/// * `name` - The parameter name. +/// * `value` - The supplied value. +/// * `parameter` - The parameter declaration. +/// +/// # Errors +/// +/// Returns the reason the value is invalid. +fn check_supplied_value(name: &str, value: &Value, parameter: &Parameter) -> Result<(), String> { + if parameter.parameter_type == DataType::SecureString && !value.is_string() { + return Err(t!("lint.rules.parameter.secureStringNotPlain").to_string()); + } + check_value(name, value, parameter) +} + /// Report supplied parameter values that don't satisfy the parameter's type or constraints. fn parameter_value_type(context: &LintContext, sink: &mut DiagnosticSink) { let Some(supplied) = &context.supplied_parameters else { @@ -437,7 +467,7 @@ fn parameter_value_type(context: &LintContext, sink: &mut DiagnosticSink) { let Some(parameter) = context.declared_parameter(name) else { continue; }; - if let Err(reason) = check_value(name, value, parameter) { + if let Err(reason) = check_supplied_value(name, value, parameter) { sink.report(¶meter_pointer(name), t!("lint.rules.parameter.parameterValueType", name = name, reason = reason).to_string()); } } @@ -515,6 +545,54 @@ resources: [] ); } + #[test] + fn checks_escaped_defaults_without_the_escape_character() { + let result = lint_yaml(r#" +$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json +parameters: + fits: + type: string + defaultValue: "[[x]" + maxLength: 3 + allowedValues: ["[x]"] + tooLong: + type: string + defaultValue: "[[abc]" + maxLength: 4 +resources: [] +"#); + assert_eq!(rule_paths(&result, "parameter-default-type"), vec!["/parameters/tooLong/defaultValue"]); + } + + #[test] + fn reports_wrapped_supplied_secure_strings() { + let options = LintOptions { + discovery: false, + use_settings: false, + parameters: Some(json!({"parameters": {"wrapped": {"secureString": "x"}, "plain": "x"}})), + ..Default::default() + }; + let result = lint_with(r#" +$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json +parameters: + wrapped: + type: secureString + plain: + type: secureString + defaulted: + type: secureString + defaultValue: + secureString: "[[not a secret]" +resources: +- name: a + type: Microsoft.DSC.Debug/Echo + properties: + output: "[concat(parameters('wrapped'), parameters('plain'), parameters('defaulted'))]" +"#, options); + assert_eq!(rule_paths(&result, "parameter-value-type"), vec!["/parameters/wrapped"]); + assert!(rule_paths(&result, "parameter-default-type").is_empty()); + } + #[test] fn reports_invalid_constraints() { let result = lint_yaml(r#" @@ -589,13 +667,16 @@ parameters: outputs: leaked: type: string - value: "[parameters('password')]" + value: "[parameters('password').secureString]" viaSecret: type: string value: "[secret('vault')]" fine: type: string value: "[parameters('plain')]" + withheld: + type: secureString + value: "[parameters('password')]" resources: [] "#); assert_eq!(rule_paths(&result, "secure-value-in-output"), vec!["/outputs/leaked/value", "/outputs/viaSecret/value"]); diff --git a/lib/dsc-lib/src/lint/rules/resource.rs b/lib/dsc-lib/src/lint/rules/resource.rs index 6f14563af..ef1653f4f 100644 --- a/lib/dsc-lib/src/lint/rules/resource.rs +++ b/lib/dsc-lib/src/lint/rules/resource.rs @@ -9,7 +9,7 @@ use serde_json::Value; use crate::{ dscresources::{ - dscresource::{validate_json_all, Capability, DscResource, ImplementedAs}, + dscresource::{validate_json_all, Capability, DscResource, ImplementedAs, SchemaViolation}, resource_manifest::{Kind, SchemaKind}, }, lint::{ @@ -20,6 +20,7 @@ use crate::{ pointer::JsonPointer, rule::{DiagnosticSink, Rule, RuleTier}, }, + parser::ParsedStatement, }; /// The rules in this module. @@ -169,10 +170,85 @@ fn deprecated_resource(context: &LintContext, sink: &mut DiagnosticSink) { } } +/// Keywords whose outcome depends only on the shape of a value (its type, member names, or +/// length) and not on the values nested in it, so an expression nested in the value can't +/// change it. +const STRUCTURAL_KEYWORDS: [&str; 9] = [ + "type", + "required", + "additionalProperties", + "propertyNames", + "minProperties", + "maxProperties", + "dependentRequired", + "minItems", + "maxItems", +]; + +/// The properties of a resource as DSC passes them to the resource, as far as that is known +/// statically. +/// +/// # Arguments +/// +/// * `context` - The lint context. +/// * `index` - The index of the resource in the context's resource list. +/// * `properties` - The properties as written. +/// +/// # Returns +/// +/// The properties with escaped strings (`[[...`) replaced by their value, and the pointers of +/// the expressions, whose values are only known at runtime. +fn runtime_properties(context: &LintContext, index: usize, properties: &Value) -> (Value, Vec) { + let base = context.resources[index].pointer.push("properties"); + let mut value = properties.clone(); + let mut unknown = Vec::new(); + for site in context.expressions.sites() { + if site.scope != ExpressionScope::Resource(index) || !site.pointer.starts_with(&base) { + continue; + } + match &site.parsed { + Ok(ParsedStatement::Escaped(text)) => { + let relative = &site.pointer.as_str()[base.as_str().len()..]; + if let Some(slot) = value.pointer_mut(relative) { + *slot = Value::String(text.clone()); + } + }, + Ok(ParsedStatement::Literal(_)) => {}, + Ok(ParsedStatement::Expression(_)) | Err(_) => unknown.push(site.pointer.clone()), + } + } + (value, unknown) +} + +/// Whether a violation holds whatever the expressions in the properties evaluate to. +/// +/// A violation at or below an expression is unknown until runtime. Above an expression, only +/// violations of a structural keyword are definite. A failed `anyOf` or `oneOf` is definite only +/// when every subschema has a definite violation, because otherwise the runtime values could +/// satisfy one of them. +/// +/// # Arguments +/// +/// * `violation` - The violation. +/// * `base` - The pointer to the properties the violation paths are relative to. +/// * `unknown` - The pointers of the expressions in the properties. +fn is_definite(violation: &SchemaViolation, base: &JsonPointer, unknown: &[JsonPointer]) -> bool { + if !violation.alternatives.is_empty() { + return violation.alternatives.iter().all(|alternative| alternative.iter().any(|nested| is_definite(nested, base, unknown))); + } + let pointer = base.join(&violation.instance_path); + if unknown.iter().any(|expression| pointer.starts_with(expression)) { + return false; + } + let contains_expression = unknown.iter().any(|expression| expression.starts_with(&pointer)); + !contains_expression || STRUCTURAL_KEYWORDS.contains(&violation.keyword.as_str()) +} + /// Report property values that don't validate against the resource's embedded schema. /// -/// Violations at or below a property that holds an expression are dropped, because the value -/// is only known at runtime. +/// The properties are validated as DSC passes them to the resource, with escaped strings +/// unescaped. Violations that an expression's runtime value could resolve are dropped; see +/// [`is_definite`]. fn property_schema_mismatch(context: &LintContext, sink: &mut DiagnosticSink) { for (index, resolved) in resolved(context) { let Some(found) = &resolved.found else { @@ -189,7 +265,8 @@ fn property_schema_mismatch(context: &LintContext, sink: &mut DiagnosticSink) { let Some(properties) = base.resolve(context.raw) else { continue; }; - let violations = match validate_json_all(found.type_name.as_ref(), &schema, properties) { + let (properties, unknown) = runtime_properties(context, index, properties); + let violations = match validate_json_all(found.type_name.as_ref(), &schema, &properties) { Ok(violations) => violations, Err(err) => { sink.report( @@ -200,15 +277,11 @@ fn property_schema_mismatch(context: &LintContext, sink: &mut DiagnosticSink) { }, }; for violation in violations { - let pointer = base.join(&violation.instance_path); - let in_expression = context.expressions.sites().iter().any(|expression| { - expression.scope == ExpressionScope::Resource(index) && pointer.starts_with(&expression.pointer) - }); - if in_expression { + if !is_definite(&violation, &base, &unknown) { continue; } sink.report( - &pointer, + &base.join(&violation.instance_path), t!("lint.rules.resource.propertySchemaMismatch", type_name = site.resource.resource_type, message = violation.message).to_string(), ); } @@ -401,6 +474,50 @@ resources: assert_eq!(paths(&run(DOCUMENT, discovered, "property-schema-mismatch")), vec!["/resources/0/properties/output"]); } + #[test] + fn combinators_allow_expressions_but_keep_definite_errors() { + let document = r#" +$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json +resources: +- name: a + type: Microsoft.DSC.Debug/Echo + properties: + items: + - name: dynamic + scope: "[parameters('scope')]" + - name: literal + scope: nowhere + - name: brokenSibling + scope: "[parameters('scope')]" + count: notAnInt + count: "[[5]" + mode: "[[x]" +"#; + let branch = |scope: &str| json!({ + "type": "object", + "properties": { + "name": { "type": "string" }, + "scope": { "const": scope }, + "count": { "type": "integer" } + } + }); + let schema = json!({ + "type": "object", + "properties": { + "items": { "type": "array", "items": { "anyOf": [branch("currentUser"), branch("machine")] } }, + "count": { "type": "integer" }, + "mode": { "enum": ["[x]"] } + } + }); + let discovered = vec![ + ResolvedResource { found: Some(echo(Kind::Resource, vec![Capability::Get], Some(schema))), type_exists: true }, + ]; + let diagnostics = run(document, discovered, "property-schema-mismatch"); + let mut reported = paths(&diagnostics); + reported.sort_unstable(); + assert_eq!(reported, vec!["/resources/0/properties/count", "/resources/0/properties/items/1", "/resources/0/properties/items/2"]); + } + #[test] fn reports_nested_resources_and_exist_false_on_unsupported_types() { let discovered = vec![ From 15b8005a2704e5ad4aadb6499ac4dd4c06eb6497 Mon Sep 17 00:00:00 2001 From: "G.Reijn" <26114636+Gijsreyn@users.noreply.github.com> Date: Fri, 23 Oct 2026 08:48:06 +0200 Subject: [PATCH 3/6] Add miette --- CHANGELOG.md | 21 +- Cargo.lock | 187 +++++- Cargo.toml | 4 +- docs/reference/cli/lint/index.md | 46 +- docs/reference/schemas/outputs/lint/result.md | 5 +- dsc/Cargo.toml | 1 + dsc/locales/en-us.toml | 4 + dsc/src/main.rs | 1 + dsc/src/report.rs | 165 ++++++ dsc/src/resource_command.rs | 21 +- dsc/src/subcommand.rs | 90 ++- dsc/src/util.rs | 39 +- dsc/tests/dsc_diagnostics.tests.ps1 | 84 +++ dsc/tests/dsc_lint.tests.ps1 | 11 + lib/dsc-lib/Cargo.toml | 2 +- lib/dsc-lib/locales/en-us.toml | 14 + lib/dsc-lib/src/configure/mod.rs | 63 +- lib/dsc-lib/src/diagnostics/mod.rs | 43 ++ lib/dsc-lib/src/diagnostics/schema.rs | 387 +++++++++++++ lib/dsc-lib/src/diagnostics/source_map.rs | 537 ++++++++++++++++++ lib/dsc-lib/src/dscerror.rs | 4 + .../src/dscresources/command_resource.rs | 104 ++-- lib/dsc-lib/src/dscresources/dscresource.rs | 47 +- lib/dsc-lib/src/lib.rs | 1 + lib/dsc-lib/src/lint/mod.rs | 32 +- lib/dsc-lib/src/lint/report.rs | 139 +++++ rfc.md | 462 +++++++++++++++ 27 files changed, 2368 insertions(+), 146 deletions(-) create mode 100644 dsc/src/report.rs create mode 100644 dsc/tests/dsc_diagnostics.tests.ps1 create mode 100644 lib/dsc-lib/src/diagnostics/mod.rs create mode 100644 lib/dsc-lib/src/diagnostics/schema.rs create mode 100644 lib/dsc-lib/src/diagnostics/source_map.rs create mode 100644 lib/dsc-lib/src/lint/report.rs create mode 100644 rfc.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 45f8f17f3..8ae61a517 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -86,7 +86,10 @@ changes since the last release, see the [diff on GitHub][unreleased]. You can change the level of a rule for a run with the `--rule` option or for every run in the new `lint` section of the settings file, and control which level makes the command fail with `--fail-on`. The `dsc schema` command exposes the new `lint-result` and `lint-rules` schemas, - and the MCP server exposes the linter as the `lint_dsc_config` tool. + and the MCP server exposes the linter as the `lint_dsc_config` tool. Every diagnostic records + the line and column of the problem, and when the output isn't redirected the command renders + each diagnostic as a report that shows the problem in the document text with the rule's + identifier and what the rule requires.
Related work items @@ -106,6 +109,22 @@ changes since the last release, see the [diff on GitHub][unreleased]. ### Changed +- Schema validation errors now show the problem in place. When the input to a resource or a + resource's properties in a configuration document don't match the resource's schema, DSC reports + every violation at once with its JSON pointer, renders a snippet of the input or the document + that highlights each offending property or value, and suggests the closest known property name + for a property it doesn't recognize. For a configuration document the snippet points at the + resource's declaration in your document rather than at its evaluated properties. The snippet + isn't rendered for the `json` trace format, which keeps one line per error, and is never + rendered for values the schema identifies as secure. + +
Related work items + + - Issues: _None_ + - PRs: [#1768][#1768] + +
+ - Extensions that implement the `secret` capability must now define the `args` property for the `secret` command with exactly one secret name input argument (`nameArg`) and at most one vault input argument (`vaultArg`). Previously, an extension could omit the secret name input argument, diff --git a/Cargo.lock b/Cargo.lock index 46df2b052..ddf924905 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -808,6 +808,7 @@ dependencies = [ "dsc-lib-telemetry", "indicatif", "jsonschema", + "miette", "path-absolutize", "regex", "rmcp", @@ -874,6 +875,7 @@ dependencies = [ "registry", "rt-format", "rust-i18n", + "saphyr-parser", "schemars", "semver", "serde", @@ -1647,9 +1649,9 @@ dependencies = [ [[package]] name = "icu_collections" -version = "2.3.0" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +checksum = "fe090a42a0d3140fed8c63263ed1fecc02422cb2f4bfe55cd8de2de1b8e105dd" dependencies = [ "displaydoc", "potential_utf", @@ -1661,22 +1663,43 @@ dependencies = [ [[package]] name = "icu_locale_core" -version = "2.3.0" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +checksum = "25d29b57c17c6528d0dc4c2141cfa2d08200465acb537a3909c8b9b8a4a984c0" dependencies = [ "displaydoc", "litemap", + "serde", "tinystr", "writeable", "zerovec", ] +[[package]] +name = "icu_locale_fallback" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "406e9ae5b95d8aa37db6293405931b766a6172d1848daee22d70eb44aa78a43c" +dependencies = [ + "icu_locale_core", + "icu_locale_fallback_data", + "icu_provider", + "potential_utf", + "tinystr", + "zerovec", +] + +[[package]] +name = "icu_locale_fallback_data" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3632ebbd0c5c3637ba1a40bdf8288f2cb9d2268e73441a69d5fc73d7cfbaa87c" + [[package]] name = "icu_normalizer" -version = "2.3.0" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +checksum = "404a3b8c486f205c87fe7f23ab6894d84a4ad76d6e4671ae1981ba7def87c599" dependencies = [ "icu_collections", "icu_normalizer_data", @@ -1688,15 +1711,15 @@ dependencies = [ [[package]] name = "icu_normalizer_data" -version = "2.3.0" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" +checksum = "20f0088bfb7441f7a37fa0c9e20cb1e9cba1cac6b9ce8cd2a6dc44294f8e8cbc" [[package]] name = "icu_properties" -version = "2.3.0" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +checksum = "2ea83d991c56158f7cf898bf86436c1dfe7c07ee82fe88951f2695c9f37a379f" dependencies = [ "displaydoc", "icu_collections", @@ -1709,18 +1732,20 @@ dependencies = [ [[package]] name = "icu_properties_data" -version = "2.3.0" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" +checksum = "b98f29af82d02b163fe50161388350e743bb3107cc8e8c2f7314cb505faddfcf" [[package]] name = "icu_provider" -version = "2.3.1" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" +checksum = "413099315636c588ee8f011190b329b971a09a8fae9d157e01dcae8379468514" dependencies = [ "displaydoc", "icu_locale_core", + "serde", + "stable_deref_trait", "writeable", "yoke", "zerofrom", @@ -1728,6 +1753,29 @@ dependencies = [ "zerovec", ] +[[package]] +name = "icu_segmenter" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61b195dc605f81aa181bec9b3bd14e26cc71b903e4644f6af578ca9772c7a710" +dependencies = [ + "icu_collections", + "icu_locale_fallback", + "icu_provider", + "icu_segmenter_data", + "potential_utf", + "smallvec", + "utf8_iter", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_segmenter_data" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "913a7d294ad056474749152107309fdee3ec17d2482d1ab70fdc61a81c64b9a3" + [[package]] name = "ident_case" version = "1.0.1" @@ -1809,6 +1857,12 @@ version = "0.21.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf370abdafd54d13e54a620e8c3e1145f28e46cc9d704bc6d94414559df41763" +[[package]] +name = "is_ci" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7655c9839580ee829dfacba1d1278c2b7883e50a277ff7541299489d6bdfdc45" + [[package]] name = "is_elevated" version = "0.1.2" @@ -1998,6 +2052,12 @@ checksum = "5f98efec8807c63c752b5bd61f862c165c115b0a35685bdcfd9238c7aeb592b7" dependencies = [ "cfg-if", "miette-derive", + "owo-colors", + "supports-color", + "supports-hyperlinks", + "supports-unicode", + "terminal_size", + "textwrap", "unicode-width 0.1.14", ] @@ -2445,6 +2505,12 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e" +[[package]] +name = "owo-colors" +version = "4.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c45bb4a6ae1280ec0803b1ef9d3455eb50f01efbbe1447ab020f1d54fba9d8" + [[package]] name = "parking_lot" version = "0.12.5" @@ -2557,6 +2623,8 @@ version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" dependencies = [ + "serde_core", + "writeable", "zerovec", ] @@ -3056,6 +3124,16 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "saphyr-parser" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c6220586c3dacac54bfddec87af86ddb31862d29666e7e35cf4baf70a25d387" +dependencies = [ + "arraydeque", + "thiserror 2.0.21", +] + [[package]] name = "schemars" version = "1.2.2" @@ -3375,6 +3453,27 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "supports-color" +version = "3.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c64fc7232dd8d2e4ac5ce4ef302b1d81e0b80d055b9d77c7c4f51f6aa4c867d6" +dependencies = [ + "is_ci", +] + +[[package]] +name = "supports-hyperlinks" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e396b6523b11ccb83120b115a0b7366de372751aa6edf19844dfb13a6af97e91" + +[[package]] +name = "supports-unicode" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7401a30af6cb5818bb64852270bb722533397edcfc7344954a38f420819ece2" + [[package]] name = "syn" version = "2.0.119" @@ -3417,6 +3516,17 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "synstructure" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + [[package]] name = "syntect" version = "5.3.0" @@ -3472,6 +3582,16 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "terminal_size" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "230a1b821ccbd75b185820a1f1ff7b14d21da1e442e22c0863ea5f08771a8874" +dependencies = [ + "rustix", + "windows-sys", +] + [[package]] name = "test-case" version = "3.4.0" @@ -3516,6 +3636,16 @@ dependencies = [ "serde_json", ] +[[package]] +name = "textwrap" +version = "0.16.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ecfad6c3abc80a577f2b91c1e412ee57e7a060d430b553c1b0c940974ebcd49" +dependencies = [ + "icu_segmenter", + "unicode-width 0.2.2", +] + [[package]] name = "thiserror" version = "1.0.69" @@ -3597,11 +3727,12 @@ dependencies = [ [[package]] name = "tinystr" -version = "0.8.4" +version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +checksum = "abc840c238154d58f9e86b2792261edfde565a17696e1cda1a449de27979801a" dependencies = [ "displaydoc", + "serde_core", "zerovec", ] @@ -4629,9 +4760,9 @@ checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" [[package]] name = "yoke" -version = "0.8.3" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +checksum = "417ef3fcf6c300b7dbd25c85531a746931c02ad536f3cf4786ed1e88223340b3" dependencies = [ "stable_deref_trait", "yoke-derive", @@ -4640,14 +4771,14 @@ dependencies = [ [[package]] name = "yoke-derive" -version = "0.8.2" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", - "synstructure", + "syn 3.0.6", + "synstructure 0.14.0", ] [[package]] @@ -4688,7 +4819,7 @@ dependencies = [ "proc-macro2", "quote", "syn 2.0.119", - "synstructure", + "synstructure 0.13.2", ] [[package]] @@ -4700,14 +4831,16 @@ dependencies = [ "displaydoc", "yoke", "zerofrom", + "zerovec", ] [[package]] name = "zerovec" -version = "0.11.8" +version = "0.11.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +checksum = "f58a2799cfe9a1670c7ea8edbd52ef8edb93cb37a7f83bf31ba9e35d9dae6a40" dependencies = [ + "serde", "yoke", "zerofrom", "zerovec-derive", @@ -4715,9 +4848,9 @@ dependencies = [ [[package]] name = "zerovec-derive" -version = "0.11.6" +version = "0.11.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" +checksum = "c3743ddbf47738d929cd1ae2e199d40d0162979dfe31aef8ae646d2bf00bf97a" dependencies = [ "proc-macro2", "quote", diff --git a/Cargo.toml b/Cargo.toml index ea14b0632..d7c730833 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -196,7 +196,7 @@ jsonschema = { version = "0.58.6", default-features = false } referencing = { version = "0.58.6" } # dsc-lib linked-hash-map = { version = "0.5" } -# dsc-lib +# dsc, dsc-lib miette = { version = "7.6.0" } # dsc-lib murmurhash64 = { version = "0.3" } @@ -230,6 +230,8 @@ roxmltree = { version = "0.21.1" } rt-format = { version = "0.3" } # dsc, dsc-lib, dsc-bicep-ext, dscecho, registry, dsc-lib-registry, runcommandonset, sshdconfig rust-i18n = { version = "4.2.4" } +# dsc-lib +saphyr-parser = { version = "0.2.2" } # dsc, dsc-lib, dscecho, registry, dsc-lib-registry, sshdconfig, dsctest, test_group_resource schemars = { version = "1.2.2", features = ["preserve_order"] } # dsc, dsc-lib diff --git a/docs/reference/cli/lint/index.md b/docs/reference/cli/lint/index.md index 487cc412c..957ab2076 100644 --- a/docs/reference/cli/lint/index.md +++ b/docs/reference/cli/lint/index.md @@ -76,7 +76,8 @@ For the list of rules and what each one checks, see [dsc lint rules][01]. -The command reports a duplicate resource name and exits with code `5`. +The command reports a duplicate resource name and exits with code `5`. In the console, each +diagnostic is rendered as a report that shows the problem in the document. ```yaml # example.dsc.config.yaml @@ -96,6 +97,30 @@ resources: dsc lint --file ./example.dsc.config.yaml ``` +```text +duplicate-resource + + × Resource 'EnsureTest1Value' of type 'Microsoft.DSC.Debug/Echo' is declared more than once + ╭─[./example.dsc.config.yaml:7:3] + 5 │ properties: + 6 │ output: one + 7 │ - name: EnsureTest1Value + · ────────────────────── + 8 │ type: Microsoft.DSC.Debug/Echo + 9 │ properties: + ╰──── + help: Resource names must be unique among resources of the same type at the same level. + +errors: 1, warnings: 0, info: 0 +``` + +When the output is redirected, or when you specify [--output-format](#--output-format), the +command emits the result as data instead: + +```sh +dsc lint --file ./example.dsc.config.yaml --output-format yaml +``` + ```yaml diagnostics: - level: error @@ -104,6 +129,9 @@ diagnostics: message: Resource 'EnsureTest1Value' of type 'Microsoft.DSC.Debug/Echo' is declared more than once path: /resources/1 file: ./example.dsc.config.yaml + location: + line: 7 + column: 3 summary: errors: 1 warnings: 0 @@ -315,14 +343,17 @@ available formats are: - `pretty-json` to emit the data as JSON with newlines, indentation, and spaces for readability. - `yaml` to emit the data as YAML. -The default output format depends on whether DSC detects that the output is being redirected or -captured as a variable: +The default output depends on whether DSC detects that the output is being redirected or captured +as a variable: -- If the command isn't being redirected or captured, DSC displays the output as the `yaml` format - in the console. +- If the command isn't being redirected or captured, DSC renders each diagnostic in the console as + a report that shows the problem in the document text, with the rule identifier as the code and + what the rule requires as the help, followed by the counts of diagnostics by level. - If the command output is redirected or captured, DSC emits the data as the `json` format to stdout. +Specify a format to get the data in the console too. + ```yaml Type : string Mandatory : false @@ -350,8 +381,9 @@ ShortSyntax : -h This command returns the diagnostics for the document, counts of the diagnostics by level, whether the document has no errors, and whether resource discovery ran. The command always writes the -result to stdout, including when the document doesn't parse. For more information, see -[dsc lint result schema][04]. +result to stdout, including when the document doesn't parse. In the console it renders the +diagnostics as reports; otherwise, or when you specify [--output-format](#--output-format), it +emits the result as data. For more information, see [dsc lint result schema][04]. ## Exit codes diff --git a/docs/reference/schemas/outputs/lint/result.md b/docs/reference/schemas/outputs/lint/result.md index 736dae6ea..5f10ddda1 100644 --- a/docs/reference/schemas/outputs/lint/result.md +++ b/docs/reference/schemas/outputs/lint/result.md @@ -52,8 +52,9 @@ following properties: - `file` is the file the document was read from. The property is omitted for documents passed with `--input` or from stdin. - `location` is the position of the problem in the source text as an object with `line` and - `column` properties, both starting at `1`. The property is only present for problems that - prevented the document from parsing. + `column` properties, both starting at `1`. For a `path` the document doesn't spell out, like a + property that is missing, the location is that of the closest enclosing value. The property is + omitted when the linter is given an already parsed document, as the MCP server does. ```yaml Type: array diff --git a/dsc/Cargo.toml b/dsc/Cargo.toml index 010d1c420..8b8de73b1 100644 --- a/dsc/Cargo.toml +++ b/dsc/Cargo.toml @@ -11,6 +11,7 @@ crossterm = { workspace = true } ctrlc = { workspace = true } indicatif = { workspace = true } jsonschema = { workspace = true } +miette = { workspace = true, features = ["fancy-no-backtrace"] } path-absolutize = { workspace = true } regex = { workspace = true } rmcp = { workspace = true, features = [ diff --git a/dsc/locales/en-us.toml b/dsc/locales/en-us.toml index ad52059ee..72f20ba3d 100644 --- a/dsc/locales/en-us.toml +++ b/dsc/locales/en-us.toml @@ -139,6 +139,9 @@ adapterNotFound = "Adapter '%{adapter}' does not exist" [server.show_dsc_resource] resourceNotFound = "Resource type '%{type_name}' does not exist" +[report] +lintSummary = "errors: %{errors}, warnings: %{warnings}, info: %{info}" + [subcommand] actualStateNotObject = "actual_state is not an object" unexpectedTestResult = "Unexpected Group TestResult" @@ -197,6 +200,7 @@ failedToReadFile = "Failed to read input file" noInput = "No input provided" emptyInput = "Empty input provided" failedToParseInput = "Invalid JSON or YAML" +inputSourceName = "input" failedToAbsolutizePath = "Error making config path absolute" failedToGetParentPath = "Error reading config path parent" dscConfigRootAlreadySet = "The current value of DSC_CONFIG_ROOT env var will be overridden" diff --git a/dsc/src/main.rs b/dsc/src/main.rs index ef65bef25..9972b69f9 100644 --- a/dsc/src/main.rs +++ b/dsc/src/main.rs @@ -19,6 +19,7 @@ use crossterm::event; use std::env; pub mod args; +pub mod report; pub mod resolve; pub mod resource_command; pub mod server; diff --git a/dsc/src/report.rs b/dsc/src/report.rs new file mode 100644 index 000000000..8d8e015f5 --- /dev/null +++ b/dsc/src/report.rs @@ -0,0 +1,165 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Render errors for the terminal. +//! +//! Errors from `dsc-lib` are [`miette::Diagnostic`]s. Most carry only a message, but the ones +//! found while validating a document or resource input also carry the text they apply to, with a +//! label on each problem and sometimes a suggestion. [`render`] turns such an error into a report +//! that shows the problem in place, in the style the trace format asks for. + +use std::collections::HashMap; +use std::io::{IsTerminal, stderr}; +use std::sync::OnceLock; + +use dsc_lib::diagnostics::SourceMap; +use dsc_lib::lint::{LintDiagnosticReport, LintResult}; +use dsc_lib_telemetry::StderrFormat; +use miette::{Diagnostic, GraphicalReportHandler, GraphicalTheme}; +use rust_i18n::t; + +/// The lines shown around each labeled line of a snippet. +const CONTEXT_LINES: usize = 2; + +static STDERR_FORMAT: OnceLock = OnceLock::new(); + +/// Record the trace format so that reports match the rest of the trace output. +/// +/// Only the first call takes effect. +/// +/// # Arguments +/// +/// * `format` - The format trace output is written in. +pub fn set_stderr_format(format: StderrFormat) { + let _ = STDERR_FORMAT.set(format); +} + +/// Render an error for the error log. +/// +/// An error that carries source text or help is rendered as a report with the text, the labels +/// on it, and the help; any other error is rendered as its message. When the trace format is +/// JSON the message alone is used, so that consumers get one line per error. +/// +/// # Arguments +/// +/// * `error` - The error to render. +#[must_use] +pub fn render(error: &dyn Diagnostic) -> String { + let format = STDERR_FORMAT.get().copied().unwrap_or(StderrFormat::Default); + let has_detail = error.source_code().is_some() || error.help().is_some(); + if !has_detail || format == StderrFormat::Json { + return error.to_string(); + } + + let handler = handler(format == StderrFormat::Default && stderr().is_terminal()); + let mut report = String::new(); + if handler.render_report(&mut report, error).is_err() { + return error.to_string(); + } + report.trim_end().to_string() +} + +/// Render the diagnostics of a lint run as reports that show each problem in its document, +/// followed by the counts by level. +/// +/// # Arguments +/// +/// * `result` - The lint result. +/// * `documents` - The text of each linted document, keyed by the file name recorded on its +/// diagnostics; `None` for inline input. +/// * `descriptions` - What each rule requires, by rule identifier, shown as the help text. +/// * `colored` - Whether to style the reports with ANSI colors. +#[must_use] +pub fn render_lint_result(result: &LintResult, documents: &[(Option, String)], descriptions: &HashMap, colored: bool) -> String { + let maps: HashMap<&Option, SourceMap> = documents.iter() + .filter_map(|(file, text)| SourceMap::parse(text).map(|map| (file, map))) + .collect(); + let handler = handler(colored); + let mut output = String::new(); + for diagnostic in &result.diagnostics { + let report = LintDiagnosticReport::new(diagnostic, maps.get(&diagnostic.file), descriptions.get(&diagnostic.rule).cloned()); + if handler.render_report(&mut output, &report).is_err() { + output.push_str(&format!("{} {}: {}\n", diagnostic.level, diagnostic.rule, diagnostic.message)); + } + output.push('\n'); + } + output.push_str(&t!( + "report.lintSummary", + errors = result.summary.errors, + warnings = result.summary.warnings, + info = result.summary.info + )); + output.push('\n'); + output +} + +/// A report handler for the terminal, drawing with unicode and, unless `NO_COLOR` asks +/// otherwise, ANSI colors. +/// +/// # Arguments +/// +/// * `colored` - Whether colors are wanted. +fn handler(colored: bool) -> GraphicalReportHandler { + let no_color = std::env::var("NO_COLOR").is_ok_and(|value| value != "0"); + let theme = if colored && !no_color { + GraphicalTheme::unicode() + } else { + GraphicalTheme::unicode_nocolor() + }; + GraphicalReportHandler::new_themed(theme).with_context_lines(CONTEXT_LINES) +} + +#[cfg(test)] +mod tests { + use super::*; + use dsc_lib::{diagnostics::SchemaValidationError, dscerror::DscError, dscresources::dscresource::validate_json_all, lint::{LintCategory, LintDiagnostic, LintLevel}}; + use serde_json::json; + + fn schema_error() -> DscError { + let schema = json!({"type": "object", "additionalProperties": false, "properties": {"output": {"type": "string"}}}); + let text = "{\n \"otput\": \"hi\"\n}"; + let violations = validate_json_all("test", &schema, &serde_json::from_str(text).unwrap()).unwrap(); + let error = SchemaValidationError::new("input for 'Test/Echo'", violations) + .with_schema(&schema) + .with_source(text, "input", ""); + DscError::SchemaValidation(Box::new(error)) + } + + #[test] + fn plain_errors_render_as_their_message() { + let error = DscError::Operation("failed".to_string()); + assert_eq!(render(&error), error.to_string()); + } + + #[test] + fn errors_with_source_render_a_snippet_and_help() { + let report = render(&schema_error()); + assert!(report.contains("input for 'Test/Echo'"), "{report}"); + assert!(report.contains("\"otput\""), "{report}"); + assert!(report.contains("'otput' isn't a known property"), "{report}"); + assert!(report.contains("Did you mean 'output'"), "{report}"); + assert!(report.contains("[input:2:3]"), "{report}"); + } + + #[test] + fn lint_results_render_each_diagnostic_in_its_document() { + let text = "resources:\n- name: a\n type: T/E\n- name: a\n type: T/E\n".to_string(); + let diagnostic = LintDiagnostic { + level: LintLevel::Warning, + rule: "duplicate-resource".to_string(), + category: LintCategory::Document, + message: "declared twice".to_string(), + path: "/resources/1".to_string(), + file: Some("demo.dsc.yaml".to_string()), + location: None, + }; + let result = LintResult::from_diagnostics(vec![diagnostic], false); + let descriptions = HashMap::from([("duplicate-resource".to_string(), "Names must be unique.".to_string())]); + let output = render_lint_result(&result, &[(Some("demo.dsc.yaml".to_string()), text)], &descriptions, false); + assert!(output.contains("duplicate-resource"), "{output}"); + assert!(output.contains("declared twice"), "{output}"); + assert!(output.contains("[demo.dsc.yaml:4:3]"), "{output}"); + assert!(output.contains("Names must be unique."), "{output}"); + assert!(output.contains("errors: 0, warnings: 1, info: 0"), "{output}"); + } +} diff --git a/dsc/src/resource_command.rs b/dsc/src/resource_command.rs index 322057799..84500efa4 100644 --- a/dsc/src/resource_command.rs +++ b/dsc/src/resource_command.rs @@ -2,6 +2,7 @@ // Licensed under the MIT License. use crate::args::{GetOutputFormat, OutputFormat}; +use crate::report::render; use crate::util::{EXIT_DSC_ERROR, EXIT_INVALID_ARGS, EXIT_JSON_ERROR, EXIT_DSC_RESOURCE_NOT_FOUND, write_object}; use dsc_lib::configure::config_doc::{Configuration, ExecutionKind}; use dsc_lib::configure::add_resource_export_results_to_configuration; @@ -64,7 +65,7 @@ pub fn get(dsc: &mut DscManager, resource_type: &FullyQualifiedTypeName, version write_object(&json, format, false)?; } Err(err) => { - error!("{err}"); + error!("{}", render(&err)); return Err(ExitCode::from(EXIT_DSC_ERROR)); } } @@ -87,7 +88,7 @@ pub fn get_all(dsc: &mut DscManager, resource_type: &FullyQualifiedTypeName, ver let export_result = match resource.export(&input) { Ok(export) => { export } Err(err) => { - error!("{err}"); + error!("{}", render(&err)); return Err(ExitCode::from(EXIT_DSC_ERROR)); } }; @@ -167,13 +168,13 @@ pub fn set(dsc: &mut DscManager, resource_type: &FullyQualifiedTypeName, version Ok(GetResult::Resource(response)) => response.actual_state, Ok(_) => unreachable!(), Err(err) => { - error!("{err}"); + error!("{}", render(&err)); return Err(ExitCode::from(EXIT_DSC_ERROR)); } }; if let Err(err) = resource.delete(input, &ExecutionKind::Actual) { - error!("{err}"); + error!("{}", render(&err)); return Err(ExitCode::from(EXIT_DSC_ERROR)); } @@ -181,7 +182,7 @@ pub fn set(dsc: &mut DscManager, resource_type: &FullyQualifiedTypeName, version Ok(GetResult::Resource(response)) => response.actual_state, Ok(_) => unreachable!(), Err(err) => { - error!("{err}"); + error!("{}", render(&err)); return Err(ExitCode::from(EXIT_DSC_ERROR)); } }; @@ -218,7 +219,7 @@ pub fn set(dsc: &mut DscManager, resource_type: &FullyQualifiedTypeName, version write_object(&json, format, false)?; } Err(err) => { - error!("{err}"); + error!("{}", render(&err)); return Err(ExitCode::from(EXIT_DSC_ERROR)); } } @@ -255,7 +256,7 @@ pub fn test(dsc: &mut DscManager, resource_type: &FullyQualifiedTypeName, versio write_object(&json, format, false)?; } Err(err) => { - error!("{err}"); + error!("{}", render(&err)); return Err(ExitCode::from(EXIT_DSC_ERROR)); } } @@ -296,7 +297,7 @@ pub fn delete(dsc: &mut DscManager, resource_type: &FullyQualifiedTypeName, vers } }, Err(err) => { - error!("{err}"); + error!("{}", render(&err)); return Err(ExitCode::from(EXIT_DSC_ERROR)); } } @@ -326,7 +327,7 @@ pub fn schema(dsc: &mut DscManager, resource_type: &FullyQualifiedTypeName, vers write_object(&json, format, false)?; } Err(err) => { - error!("{err}"); + error!("{}", render(&err)); return Err(ExitCode::from(EXIT_DSC_ERROR)); } } @@ -346,7 +347,7 @@ pub fn export(dsc: &mut DscManager, resource_type: &FullyQualifiedTypeName, vers let mut conf = Configuration::new(); if let Err(err) = add_resource_export_results_to_configuration(dsc_resource, &mut conf, input) { - error!("{err}"); + error!("{}", render(&err)); return Err(ExitCode::from(EXIT_DSC_ERROR)); } diff --git a/dsc/src/subcommand.rs b/dsc/src/subcommand.rs index 7e09ecdba..9af8fdb28 100644 --- a/dsc/src/subcommand.rs +++ b/dsc/src/subcommand.rs @@ -2,10 +2,12 @@ // Licensed under the MIT License. use crate::args::{ConfigSubCommand, SchemaType, ExtensionSubCommand, FunctionSubCommand, GetOutputFormat, ListOutputFormat, OutputFormat, ResourceSubCommand}; +use crate::report::{render, render_lint_result}; use crate::resolve::{get_contents, Include}; use crate::resource_command::{get_resource, self}; use crate::tablewriter::Table; -use crate::util::{current_dsc_version, get_input, get_schema, in_desired_state, read_input, set_dscconfigroot, write_object, DSC_CONFIG_ROOT, EXIT_DSC_ASSERTION_FAILED, EXIT_DSC_ERROR, EXIT_INVALID_ARGS, EXIT_INVALID_INPUT, EXIT_JSON_ERROR, EXIT_VALIDATION_FAILED}; +use crate::util::{current_dsc_version, get_input, get_input_with_source, get_schema, in_desired_state, read_input, set_dscconfigroot, write_object, DSC_CONFIG_ROOT, EXIT_DSC_ASSERTION_FAILED, EXIT_DSC_ERROR, EXIT_INVALID_ARGS, EXIT_INVALID_INPUT, EXIT_JSON_ERROR, EXIT_VALIDATION_FAILED}; +use dsc_lib::diagnostics::DocumentSource; use dsc_lib::lint::{LintError, LintLevel, LintOptions, LintResult, Linter, RuleLevelOverride}; use dsc_lib::types::{FullyQualifiedTypeName, ResourceVersionReq, TypeNameFilter}; use dsc_lib::{ @@ -71,7 +73,7 @@ pub fn config_get(configurator: &mut Configurator, format: Option<&OutputFormat> } }, Err(err) => { - error!("{err}"); + error!("{}", render(&err)); return Err(ExitCode::from(EXIT_DSC_ERROR)); } } @@ -106,7 +108,7 @@ pub fn config_set(configurator: &mut Configurator, format: Option<&OutputFormat> } }, Err(err) => { - error!("Error: {err}"); + error!("Error: {}", render(&err)); return Err(ExitCode::from(EXIT_DSC_ERROR)); } } @@ -207,7 +209,7 @@ pub fn config_test(configurator: &mut Configurator, format: Option<&OutputFormat } }, Err(err) => { - error!("{err}"); + error!("{}", render(&err)); let exit_code = config_test_exit_code(&err); return Err(ExitCode::from(exit_code)); } @@ -217,7 +219,7 @@ pub fn config_test(configurator: &mut Configurator, format: Option<&OutputFormat fn config_test_exit_code(error: &DscError) -> u8 { match error { - DscError::Schema(_) => EXIT_VALIDATION_FAILED, + DscError::Schema(_) | DscError::SchemaValidation(_) => EXIT_VALIDATION_FAILED, _ => EXIT_DSC_ERROR, } } @@ -225,10 +227,13 @@ fn config_test_exit_code(error: &DscError) -> u8 { #[cfg(test)] mod tests { use super::{config_test_exit_code, DscError, EXIT_DSC_ERROR, EXIT_VALIDATION_FAILED}; + use dsc_lib::diagnostics::SchemaValidationError; #[test] fn schema_errors_use_validation_failed_exit_code() { assert_eq!(config_test_exit_code(&DscError::Schema("invalid value".to_string())), EXIT_VALIDATION_FAILED); + let violations = DscError::SchemaValidation(Box::new(SchemaValidationError::new("input for 'Test/Echo'", Vec::new()))); + assert_eq!(config_test_exit_code(&violations), EXIT_VALIDATION_FAILED); } #[test] @@ -259,7 +264,7 @@ pub fn config_export(configurator: &mut Configurator, format: Option<&OutputForm } }, Err(err) => { - error!("{err}"); + error!("{}", render(&err)); return Err(ExitCode::from(EXIT_DSC_ERROR)); } } @@ -299,14 +304,14 @@ fn initialize_config_root(path: Option<&String>) -> Result, ExitC #[allow(clippy::too_many_lines)] #[allow(clippy::too_many_arguments)] pub fn config(subcommand: &ConfigSubCommand, parameters: &Option, mounted_path: Option<&String>, as_group: &bool, as_assert: &bool, as_include: &bool, progress_format: ProgressFormat) -> Result<(), ExitCode> { - let (new_parameters, json_string) = match subcommand { + let (new_parameters, json_string, source) = match subcommand { ConfigSubCommand::Get { input, file, .. } | ConfigSubCommand::Set { input, file, .. } | ConfigSubCommand::Test { input, file, .. } | ConfigSubCommand::Validate { input, file, .. } | ConfigSubCommand::Export { input, file, .. } => { let new_path = initialize_config_root(file.as_ref())?; - let document = get_input(input.as_ref(), new_path.as_ref())?; + let (document, source) = get_input_with_source(input.as_ref(), new_path.as_ref())?; if *as_include { let (new_parameters, config_json) = match get_contents(&document) { Ok((parameters, config_json)) => (parameters, config_json), @@ -315,9 +320,9 @@ pub fn config(subcommand: &ConfigSubCommand, parameters: &Option, mounte return Err(ExitCode::from(EXIT_DSC_ERROR)); } }; - (new_parameters, config_json) + (new_parameters, config_json, None) } else { - (None, document) + (None, document, source) } }, ConfigSubCommand::Resolve { input, file, .. } => { @@ -330,17 +335,20 @@ pub fn config(subcommand: &ConfigSubCommand, parameters: &Option, mounte return Err(ExitCode::from(EXIT_DSC_ERROR)); } }; - (new_parameters, config_json) + (new_parameters, config_json, None) } }; let mut configurator = match Configurator::new_with_dsc_version(&json_string, progress_format, current_dsc_version()) { Ok(configurator) => configurator, Err(err) => { - error!("Error: {err}"); + error!("Error: {}", render(&err)); return Err(ExitCode::from(EXIT_DSC_ERROR)); } }; + if let Some(source) = source { + configurator.set_source(source); + } if let ConfigSubCommand::Set { what_if , .. } = subcommand && *what_if { configurator.context.execution_type = ExecutionKind::WhatIf; @@ -408,12 +416,12 @@ pub fn config(subcommand: &ConfigSubCommand, parameters: &Option, mounte } } } else { - match validate_config(configurator.get_config(), progress_format) { + match validate_config(configurator.get_config(), configurator.source(), progress_format) { Ok(()) => { // valid, so do nothing }, Err(err) => { - error!("{err}"); + error!("{}", render(&err)); result.valid = false; } } @@ -577,9 +585,9 @@ pub fn lint(args: &LintArgs, progress_format: ProgressFormat) -> Result<(), Exit } let mut result = LintResult::from_diagnostics(Vec::new(), true); - for (file, document) in documents { - linter.set_file(file); - let document_result = match linter.lint(&document) { + for (file, document) in &documents { + linter.set_file(file.clone()); + let document_result = match linter.lint(document) { Ok(document_result) => document_result, Err(DscError::Lint(err @ LintError::InvalidParameters(_))) => { error!("{}: {err}", t!("subcommand.lintFailed")); @@ -593,14 +601,19 @@ pub fn lint(args: &LintArgs, progress_format: ProgressFormat) -> Result<(), Exit result.merge(document_result); } - let json = match serde_json::to_string(&result) { - Ok(json) => json, - Err(err) => { - error!("JSON: {err}"); - return Err(ExitCode::from(EXIT_JSON_ERROR)); - } - }; - write_object(&json, args.output_format, false)?; + if args.output_format.is_none() && io::stdout().is_terminal() { + let descriptions = linter.rules().into_iter().map(|rule| (rule.id, rule.description)).collect(); + print!("{}", render_lint_result(&result, &documents, &descriptions, true)); + } else { + let json = match serde_json::to_string(&result) { + Ok(json) => json, + Err(err) => { + error!("JSON: {err}"); + return Err(ExitCode::from(EXIT_JSON_ERROR)); + } + }; + write_object(&json, args.output_format, false)?; + } let threshold = args.fail_on.unwrap_or(LintLevel::Error); if result.has_diagnostic_at_least(threshold) { @@ -657,6 +670,8 @@ fn list_lint_rules(linter: &Linter, format: Option<&OutputFormat>) -> Result<(), /// # Arguments /// /// * `config` - The configuration to validate. +/// * `source` - The text the configuration was read from, so that errors point into it. +/// * `progress_format` - How to report progress during resource discovery. /// /// # Returns /// @@ -665,12 +680,12 @@ fn list_lint_rules(linter: &Linter, format: Option<&OutputFormat>) -> Result<(), /// # Errors /// /// * `DscError` - The error that occurred. -pub fn validate_config(config: &Configuration, progress_format: ProgressFormat) -> Result<(), DscError> { +pub fn validate_config(config: &Configuration, source: Option<&DocumentSource>, progress_format: ProgressFormat) -> Result<(), DscError> { // first validate against the config schema debug!("{}", t!("subcommand.validatingConfiguration")); let schema = serde_json::to_value(get_schema(SchemaType::Configuration))?; let config_value = serde_json::to_value(config)?; - validate_json("Configuration", &schema, &config_value)?; + validate_json("Configuration", &schema, &config_value).map_err(|err| anchor_error(err, source, ""))?; let mut dsc = DscManager::new(); // then validate each resource @@ -693,7 +708,7 @@ pub fn validate_config(config: &Configuration, progress_format: ProgressFormat) } dsc.find_resources(&resource_types, progress_format)?; - for resource_block in resources { + for (index, resource_block) in resources.iter().enumerate() { let Some(type_name) = resource_block["type"].as_str() else { return Err(DscError::Validation(t!("subcommand.resourceTypeNotSpecified").to_string())); }; @@ -712,13 +727,30 @@ pub fn validate_config(config: &Configuration, progress_format: ProgressFormat) // see if the resource is command based if resource.implemented_as == Some(ImplementedAs::Command) { - validate_properties(resource, &resource_block["properties"])?; + validate_properties(resource, &resource_block["properties"]) + .map_err(|err| anchor_error(err, source, &format!("/resources/{index}/properties")))?; } } Ok(()) } +/// Point a schema validation error at the document it was found in. +/// +/// # Arguments +/// +/// * `error` - The error. +/// * `source` - The document text, when known. +/// * `base` - The pointer in the document that the error's paths are relative to. +fn anchor_error(error: DscError, source: Option<&DocumentSource>, base: &str) -> DscError { + match (error, source) { + (DscError::SchemaValidation(schema_error), Some(source)) => { + DscError::SchemaValidation(Box::new(schema_error.with_source(&source.text, &source.name, base))) + }, + (error, _) => error, + } +} + pub fn extension(subcommand: &ExtensionSubCommand, progress_format: ProgressFormat) -> Result<(), ExitCode> { let mut dsc = DscManager::new(); diff --git a/dsc/src/util.rs b/dsc/src/util.rs index c10b101a3..498cca203 100644 --- a/dsc/src/util.rs +++ b/dsc/src/util.rs @@ -19,6 +19,7 @@ use dsc_lib::{ ResourceTestResult, }, }, + diagnostics::DocumentSource, discovery::{ command_discovery::ManifestList, Discovery, @@ -449,6 +450,7 @@ pub fn enable_tracing(trace_level_arg: Option<&TraceLevel>, trace_format_arg: Op tracing_level, stderr_format }.init_subscriber(); + crate::report::set_stderr_format(stderr_format); drop(default_guard); if tracing::subscriber::set_global_default(subscriber).is_err() { @@ -478,13 +480,40 @@ pub fn enable_tracing(trace_level_arg: Option<&TraceLevel>, trace_format_arg: Op /// Returns `EXIT_INVALID_INPUT` when the document can't be read (see [`read_input`]) or isn't /// valid JSON or YAML. pub fn get_input(input: Option<&String>, file: Option<&String>) -> Result { - let value = read_input(input, file)?; - if value.is_empty() { - return Ok(value); + get_input_with_source(input, file).map(|(json, _)| json) +} + +/// Read an input document, converting it to a JSON string and keeping the text it was read from. +/// +/// # Arguments +/// +/// * `input` - The inline document, if provided. +/// * `file` - The path to the document, or `-` to read it from STDIN. +/// +/// # Returns +/// +/// The document as JSON and, when there is a document, its text named after the file it was +/// read from, or `input` for inline text and STDIN. Both are empty when neither argument is +/// provided. +/// +/// # Errors +/// +/// Returns `EXIT_INVALID_INPUT` when the document can't be read (see [`read_input`]) or isn't +/// valid JSON or YAML. +pub fn get_input_with_source(input: Option<&String>, file: Option<&String>) -> Result<(String, Option), ExitCode> { + let text = read_input(input, file)?; + if text.is_empty() { + return Ok((text, None)); } - match parse_input_to_json(&value) { - Ok(json) => Ok(json), + match parse_input_to_json(&text) { + Ok(json) => { + let name = match file { + Some(path) if path != "-" => path.clone(), + _ => t!("util.inputSourceName").to_string(), + }; + Ok((json, Some(DocumentSource::new(name, text)))) + }, Err(err) => { error!("{}: {err}", t!("util.failedToParseInput")); Err(ExitCode::from(EXIT_INVALID_INPUT)) diff --git a/dsc/tests/dsc_diagnostics.tests.ps1 b/dsc/tests/dsc_diagnostics.tests.ps1 new file mode 100644 index 000000000..d1f73fb36 --- /dev/null +++ b/dsc/tests/dsc_diagnostics.tests.ps1 @@ -0,0 +1,84 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +Describe 'Schema validation diagnostics' { + BeforeAll { + $schemaUri = 'https://aka.ms/dsc/schemas/v3/bundled/config/document.json' + $configYaml = @" +`$schema: $schemaUri +resources: +- name: first + type: Microsoft.DSC.Debug/Echo + properties: + output: fine +- name: second + type: Microsoft.DSC.Debug/Echo + properties: + otput: hi +"@ + $configPath = Join-Path $TestDrive 'typo.dsc.yaml' + Set-Content -Path $configPath -Value $configYaml + } + + It 'shows the invalid property of resource input in a snippet with a suggestion' { + $resourceInput = @' +{ + "otput": "hi" +} +'@ + $null = dsc resource get --resource Microsoft.DSC.Debug/Echo --input $resourceInput 2> $TestDrive/error.log + $LASTEXITCODE | Should -Be 2 + $log = Get-Content -Path $TestDrive/error.log -Raw + $log | Should -Match "Schema: input for 'Microsoft.DSC.Debug/Echo'" -Because $log + $log | Should -Match "'otput' was unexpected" -Because $log + # the snippet opens at the first label, which marks the object missing its required property + $log | Should -Match '\[input:1:1\]' -Because $log + $log | Should -Match "'otput' isn't a known property" -Because $log + $log | Should -Match "missing required property 'output'" -Because $log + $log | Should -Match "Did you mean 'output' instead of 'otput'" -Because $log + } + + It 'points at the resource declaration in the configuration file for: ' -TestCases @( + @{ operation = 'get'; exitCode = 2 } + @{ operation = 'test'; exitCode = 5 } + ) { + param($operation, $exitCode) + + $null = dsc config $operation --file $configPath 2> $TestDrive/error.log + $LASTEXITCODE | Should -Be $exitCode + $log = Get-Content -Path $TestDrive/error.log -Raw + $log | Should -Match "Schema: input for 'Microsoft.DSC.Debug/Echo'" -Because $log + # the snippet is the author's YAML, not the evaluated properties + $log | Should -Match ([regex]::Escape($configPath) + ':9:3') -Because $log + $log | Should -Match '- name: second' -Because $log + $log | Should -Match 'otput: hi' -Because $log + $log | Should -Match "'otput' isn't a known property" -Because $log + $log | Should -Match "Did you mean 'output' instead of 'otput'" -Because $log + $log | Should -Not -Match 'name: first' -Because 'only the failing resource is shown' + } + + It 'names inline input as input in the snippet' { + $null = dsc config get --input $configYaml 2> $TestDrive/error.log + $LASTEXITCODE | Should -Be 2 + $log = Get-Content -Path $TestDrive/error.log -Raw + $log | Should -Match '\[input:9:3\]' -Because $log + } + + It 'keeps the message on one line for the json trace format' { + $null = dsc --trace-format json config get --file $configPath 2> $TestDrive/error.log + $LASTEXITCODE | Should -Be 2 + $errors = @(Get-Content -Path $TestDrive/error.log | Where-Object { $_ -like '*"level":"ERROR"*' }) + $errors.Count | Should -Be 1 -Because (Get-Content -Path $TestDrive/error.log -Raw) + $entry = $errors[0] | ConvertFrom-Json + $entry.fields.message | Should -Match "^Schema: input for 'Microsoft.DSC.Debug/Echo': Additional properties are not allowed \('otput' was unexpected\)" -Because $entry.fields.message + $entry.fields.message | Should -Not -Match 'otput: hi' -Because 'the snippet is only rendered for the default and plaintext formats' + } + + It 'renders the snippet without colors for the plaintext trace format' { + $null = dsc --trace-format plaintext config get --file $configPath 2> $TestDrive/error.log + $LASTEXITCODE | Should -Be 2 + $log = Get-Content -Path $TestDrive/error.log -Raw + $log | Should -Match "'otput' isn't a known property" -Because $log + $log | Should -Not -Match "`e\[" -Because 'plaintext output has no ANSI escape sequences' + } +} diff --git a/dsc/tests/dsc_lint.tests.ps1 b/dsc/tests/dsc_lint.tests.ps1 index 9f1c3989c..f17c53d4f 100644 --- a/dsc/tests/dsc_lint.tests.ps1 +++ b/dsc/tests/dsc_lint.tests.ps1 @@ -69,6 +69,17 @@ resources: $result.diagnostics[0].category | Should -BeExactly 'document' $result.diagnostics[0].path | Should -BeExactly '/resources/2' $result.diagnostics[0].message | Should -BeLike "*EnsureTest1Value*" + # the third resource starts on line 11 of the document, after the leading `- ` + $result.diagnostics[0].location.line | Should -Be 11 + $result.diagnostics[0].location.column | Should -Be 3 + } + + It 'records the line and column of a diagnostic inside a value' { + $result = $unusedParameterConfig | dsc lint --file - --no-discovery 2>$TestDrive/error.log | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 -Because (Get-Content $TestDrive/error.log -Raw) + $result.diagnostics[0].path | Should -BeExactly '/parameters/unused' + $result.diagnostics[0].location.line | Should -Be 3 + $result.diagnostics[0].location.column | Should -Be 3 } It 'accepts the document with --input' { diff --git a/lib/dsc-lib/Cargo.toml b/lib/dsc-lib/Cargo.toml index ea1de3f57..c47986b35 100644 --- a/lib/dsc-lib/Cargo.toml +++ b/lib/dsc-lib/Cargo.toml @@ -24,7 +24,7 @@ path-absolutize = { workspace = true } regex = { workspace = true } rt-format = { workspace = true } rust-i18n = { workspace = true } -# reqwest = { version = "0.12.8", features = ["native-tls"], default-features = false } +saphyr-parser = { workspace = true } schemars = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } diff --git a/lib/dsc-lib/locales/en-us.toml b/lib/dsc-lib/locales/en-us.toml index bc3d57520..cfb6e5b3b 100644 --- a/lib/dsc-lib/locales/en-us.toml +++ b/lib/dsc-lib/locales/en-us.toml @@ -116,6 +116,13 @@ importingParametersFromComplexInput = "Importing parameters from complex input" importingParametersFromInput = "Importing parameters from simple input" invalidParamsFormat = "Invalid parameters format: %{error}" +[diagnostics.schema] +unexpectedProperty = "'%{property}' isn't a known property" +missingProperty = "missing required property '%{property}'" +didYouMean = "Did you mean '%{suggestion}' instead of '%{property}'?" +knownProperties = "Known properties: %{properties}" +at = "at '%{path}'" + [discovery.commandDiscovery] couldNotReadSetting = "Could not read 'resourcePath' setting" appendingEnvPath = "Appending PATH to resourcePath" @@ -233,6 +240,10 @@ writingStdin = "Writing to command STDIN: %{input}" processTrace = "PID %{process_id}: %{message}" processTraceWithTarget = "PID %{process_id}: %{target}: %{line_number}: %{message}" redactedSchemaErrorMessage = "'secureString' and 'secureObject' values are not compatible with the schema, use '.secureString' or '.secureObject' in an expression to convert to string or object" +schemaSubjectInput = "input for '%{resource}'" +schemaSubjectOutput = "output from '%{resource}'" +schemaSourceInput = "input" +schemaSourceOutput = "output" [dscresources.dscresource] invokeGet = "Invoking get for '%{resource}'" @@ -1122,6 +1133,9 @@ documentKindConfiguration = "configuration document" documentKindResourceManifest = "resource manifest" documentKindExtensionManifest = "extension manifest" +[lint.report] +inputSourceName = "input" + [lint.rules.document] duplicateResourceDescription = "Resource names must be unique among resources of the same type at the same level." duplicateResource = "Resource '%{name}' of type '%{type_name}' is declared more than once" diff --git a/lib/dsc-lib/src/configure/mod.rs b/lib/dsc-lib/src/configure/mod.rs index a612970fc..0e87dd9d3 100644 --- a/lib/dsc-lib/src/configure/mod.rs +++ b/lib/dsc-lib/src/configure/mod.rs @@ -6,6 +6,7 @@ use crate::configure::context::{Context, ProcessMode}; use crate::configure::parameters::{SecureObject, SecureString, import_parameters, is_secure_value}; use crate::configure::{config_doc::{ExecutionKind, IntOrExpression, Metadata, Parameter, Resource, ResourceDiscoveryMode, RestartRequired, ValueOrCopy}}; use crate::discovery::discovery_trait::DiscoveryFilter; +use crate::diagnostics::DocumentSource; use crate::dscerror::DscError; use crate::dscresources::{ {dscresource::{Capability, Invoke, get_diff, validate_properties, get_adapter_input_kind}, @@ -45,6 +46,7 @@ pub struct Configurator { discovery: Discovery, statement_parser: Statement, progress_format: ProgressFormat, + source: Option, } /// Invokes the [`Discovery::find_resource`] method to retrieve a specific resource or raise a @@ -557,6 +559,7 @@ impl Configurator { discovery: discovery.clone(), statement_parser: Statement::new()?, progress_format, + source: None, }; config.validate_config()?; for extension in discovery.extensions.values() { @@ -575,6 +578,52 @@ impl Configurator { &self.config } + /// Record the text the configuration was read from, so that errors found in a resource's + /// properties point at the author's document rather than at the evaluated properties. + /// + /// # Arguments + /// + /// * `source` - The document text and how to name it. + pub fn set_source(&mut self, source: DocumentSource) { + self.source = Some(source); + } + + /// The text the configuration was read from, when the host recorded it. + #[must_use] + pub fn source(&self) -> Option<&DocumentSource> { + self.source.as_ref() + } + + /// Point a schema validation error from a resource at the resource's declaration in the + /// document. + /// + /// Resources are validated after their expressions are evaluated, so the error arrives + /// pointing at the evaluated properties. When the document text is known and the resource + /// is declared in it directly, the error is re-anchored to the resource's `properties` in the + /// document. Resources that a copy loop expands keep the evaluated view. Other errors pass + /// through unchanged. + /// + /// # Arguments + /// + /// * `error` - The error the resource operation returned. + /// * `resource` - The resource that was being invoked. + fn anchor_error(&self, error: DscError, resource: &Resource) -> DscError { + let DscError::SchemaValidation(schema_error) = error else { + return error; + }; + let Some(source) = &self.source else { + return DscError::SchemaValidation(schema_error); + }; + let declared = self.config.resources.iter().position(|declared| { + declared.name == resource.name && declared.resource_type == resource.resource_type + }); + let Some(index) = declared else { + return DscError::SchemaValidation(schema_error); + }; + let base = format!("/resources/{index}/properties"); + DscError::SchemaValidation(Box::new(schema_error.with_source(&source.text, &source.name, &base))) + } + /// Get the discovery. /// /// # Returns @@ -650,7 +699,7 @@ impl Configurator { Err(e) => { progress.set_failure(get_failure_from_error(&e)); progress.write_increment(1); - return Err(e); + return Err(self.anchor_error(e, &resource)); }, }; let end_datetime = chrono::Local::now(); @@ -764,7 +813,7 @@ impl Configurator { Err(e) => { progress.set_failure(get_failure_from_error(&e)); progress.write_increment(1); - return Err(e); + return Err(self.anchor_error(e, &resource)); }, }; end_datetime = chrono::Local::now(); @@ -776,7 +825,7 @@ impl Configurator { Err(e) => { progress.set_failure(get_failure_from_error(&e)); progress.write_increment(1); - return Err(e); + return Err(self.anchor_error(e, &resource)); }, }; @@ -786,7 +835,7 @@ impl Configurator { Err(e) => { progress.set_failure(get_failure_from_error(&e)); progress.write_increment(1); - return Err(e); + return Err(self.anchor_error(e, &resource)); }, }; @@ -805,7 +854,7 @@ impl Configurator { Err(e) => { progress.set_failure(get_failure_from_error(&e)); progress.write_increment(1); - return Err(e); + return Err(self.anchor_error(e, &resource)); }, }; end_datetime = chrono::Local::now(); @@ -932,7 +981,7 @@ impl Configurator { Err(e) => { progress.set_failure(get_failure_from_error(&e)); progress.write_increment(1); - return Err(e); + return Err(self.anchor_error(e, &resource)); }, }; let end_datetime = chrono::Local::now(); @@ -1025,7 +1074,7 @@ impl Configurator { Err(e) => { progress.set_failure(get_failure_from_error(&e)); progress.write_increment(1); - return Err(e); + return Err(self.anchor_error(e, resource)); }, }; self.context.references.insert(resource_id(&resource.resource_type, &evaluated_name), serde_json::to_value(&export_result.actual_state)?); diff --git a/lib/dsc-lib/src/diagnostics/mod.rs b/lib/dsc-lib/src/diagnostics/mod.rs new file mode 100644 index 000000000..a78e3cb2b --- /dev/null +++ b/lib/dsc-lib/src/diagnostics/mod.rs @@ -0,0 +1,43 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Diagnostics that point at the text they apply to. +//! +//! DSC reads documents and resource input as JSON or YAML text and validates the parsed value. +//! The types here keep the connection between a problem found in the value and the text it came +//! from, so a host can show the problem in place rather than only describe it: +//! +//! - [`SourceMap`] finds the byte span and line/column of any JSON pointer in JSON or YAML text. +//! - [`SchemaValidationError`] is a [`miette::Diagnostic`] for JSON schema violations, with a +//! label on each violation when its source text is attached. +//! - [`DocumentSource`] carries the text and name of a document a host read, so library code +//! can anchor the problems it finds to the author's text. + +pub mod schema; +pub mod source_map; + +pub use schema::SchemaValidationError; +pub use source_map::SourceMap; + +/// The text a document was read from, for diagnostics that point into it. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct DocumentSource { + /// How to name the text in a report: the file it was read from when known, otherwise a + /// description like `input`. + pub name: String, + /// The document text as the author wrote it, as JSON or YAML. + pub text: String, +} + +impl DocumentSource { + /// Create a document source. + /// + /// # Arguments + /// + /// * `name` - How to name the text in a report. + /// * `text` - The document text. + #[must_use] + pub fn new(name: impl Into, text: impl Into) -> Self { + Self { name: name.into(), text: text.into() } + } +} diff --git a/lib/dsc-lib/src/diagnostics/schema.rs b/lib/dsc-lib/src/diagnostics/schema.rs new file mode 100644 index 000000000..137ed452f --- /dev/null +++ b/lib/dsc-lib/src/diagnostics/schema.rs @@ -0,0 +1,387 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Schema validation failures that point at the text they were found in. + +// TODO: Remove when updating to rustc 1.94; false positive from thiserror + rust-i18n t!() macro +#![allow(unused_assignments)] + +use std::fmt::{self, Display}; + +use miette::{Diagnostic, LabeledSpan, NamedSource, SourceCode}; +use rust_i18n::t; +use serde_json::Value; + +use crate::dscresources::dscresource::{SchemaViolation, SchemaViolationDetail}; + +use super::source_map::SourceMap; + +/// Property names within this edit distance of an unknown property are suggested as corrections. +const MAX_SUGGESTION_DISTANCE: usize = 2; +/// The most property names to list when there is no close match. +const MAX_LISTED_PROPERTIES: usize = 12; + +/// Every violation found when validating a value against a JSON schema. +/// +/// The error is a [`miette::Diagnostic`]: when source text is attached with +/// [`with_source`](Self::with_source), each violation becomes a label on the span of the text it +/// applies to, and unknown properties get a suggestion in the help text when the schema is +/// attached with [`with_schema`](Self::with_schema). Without source text the error still lists +/// every violation and its JSON pointer in its message. +#[derive(Debug)] +pub struct SchemaValidationError { + subject: String, + violations: Vec, + source: Option>, + labels: Vec, + help: Option, +} + +impl SchemaValidationError { + /// Create an error for a set of violations. + /// + /// # Arguments + /// + /// * `subject` - What was validated, as it should read in the message, like + /// `input for 'Microsoft.DSC.Debug/Echo'` or `Configuration`. + /// * `violations` - The violations found; must not be empty. + #[must_use] + pub fn new(subject: impl Into, violations: Vec) -> Self { + Self { + subject: subject.into(), + violations, + source: None, + labels: Vec::new(), + help: None, + } + } + + /// Use the schema to suggest corrections for unknown properties in the help text. + /// + /// # Arguments + /// + /// * `schema` - The schema the value was validated against. + #[must_use] + pub fn with_schema(mut self, schema: &Value) -> Self { + let mut suggestions = Vec::new(); + let mut listed = Vec::new(); + for violation in &self.violations { + let SchemaViolationDetail::UnexpectedProperties(unexpected) = &violation.detail else { + continue; + }; + let Some(known) = known_properties(schema, &violation.schema_path) else { + continue; + }; + for property in unexpected { + match closest(property, &known) { + Some(suggestion) => suggestions.push(t!("diagnostics.schema.didYouMean", property = property, suggestion = suggestion).to_string()), + None if listed.is_empty() && !known.is_empty() && known.len() <= MAX_LISTED_PROPERTIES => { + listed.push(t!("diagnostics.schema.knownProperties", properties = known.join(", ")).to_string()); + }, + None => {}, + } + } + } + suggestions.extend(listed); + if !suggestions.is_empty() { + self.help = Some(suggestions.join("\n")); + } + self + } + + /// Attach the text the validated value came from, so the report can show each violation in + /// place. + /// + /// Replaces any text attached before, so a host that knows the document a value was taken + /// from can point at the document instead of the value. + /// + /// # Arguments + /// + /// * `text` - The document text, as JSON or YAML. + /// * `name` - How to name the text in the report, like `input` or a file path. + /// * `base` - The JSON pointer in `text` that the violations' instance paths are relative + /// to; empty when the text is the validated value itself. + /// + /// # Returns + /// + /// The error with the text attached, or unchanged when the text can't be parsed. + #[must_use] + pub fn with_source(mut self, text: &str, name: &str, base: &str) -> Self { + let Some(map) = SourceMap::parse(text) else { + return self; + }; + let mut labels = Vec::new(); + for violation in &self.violations { + labels.extend(labels_for(violation, &map, base)); + } + if labels.is_empty() { + return self; + } + self.labels = labels; + self.source = Some(NamedSource::new(name, map.text().to_string())); + self + } + + /// Replace every violation message and drop the source text, for a value that holds secure + /// data the report must not echo. + /// + /// # Arguments + /// + /// * `message` - The message to show in place of each violation's. + #[must_use] + pub fn redacted(mut self, message: &str) -> Self { + for violation in &mut self.violations { + violation.message = message.to_string(); + violation.alternatives.clear(); + } + self.source = None; + self.labels.clear(); + self.help = None; + self + } + + /// What was validated. + #[must_use] + pub fn subject(&self) -> &str { + &self.subject + } + + /// The violations found. + #[must_use] + pub fn violations(&self) -> &[SchemaViolation] { + &self.violations + } + + /// Whether the report can show the violations in their source text. + #[must_use] + pub fn has_source(&self) -> bool { + self.source.is_some() + } +} + +impl Display for SchemaValidationError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}: {}", t!("dscerror.schema"), self.subject)?; + for (index, violation) in self.violations.iter().enumerate() { + write!(f, "{} {}", if index == 0 { ":" } else { ";" }, violation.message)?; + if !violation.instance_path.is_empty() { + write!(f, " {}", t!("diagnostics.schema.at", path = violation.instance_path))?; + } + } + Ok(()) + } +} + +impl std::error::Error for SchemaValidationError {} + +impl Diagnostic for SchemaValidationError { + fn help<'a>(&'a self) -> Option> { + self.help.as_ref().map(|help| Box::new(help) as Box) + } + + fn source_code(&self) -> Option<&dyn SourceCode> { + self.source.as_ref().map(|source| source as &dyn SourceCode) + } + + fn labels(&self) -> Option + '_>> { + if self.labels.is_empty() { + None + } else { + Some(Box::new(self.labels.iter().cloned())) + } + } +} + +/// The labels that show a violation in the source text. +/// +/// # Arguments +/// +/// * `violation` - The violation. +/// * `map` - The source map of the text. +/// * `base` - The pointer the violation's instance path is relative to. +fn labels_for(violation: &SchemaViolation, map: &SourceMap, base: &str) -> Vec { + let pointer = format!("{base}{}", violation.instance_path); + match &violation.detail { + SchemaViolationDetail::UnexpectedProperties(unexpected) => { + unexpected.iter().filter_map(|property| { + let member = format!("{pointer}/{}", escape(property)); + let span = map.key_span(&member).or_else(|| map.span(&member))?; + Some(label(map, span, t!("diagnostics.schema.unexpectedProperty", property = property).to_string())) + }).collect() + }, + SchemaViolationDetail::MissingProperty(property) => { + let Some((_, span)) = map.nearest_span(&pointer) else { + return Vec::new(); + }; + vec![label(map, span, t!("diagnostics.schema.missingProperty", property = property).to_string())] + }, + SchemaViolationDetail::None => { + if let Some(span) = map.value_span(&pointer) { + return vec![label(map, span, violation.message.clone())]; + } + // The text doesn't spell the value out, so point at the closest enclosing value. + let Some((_, span)) = map.nearest_span(&pointer) else { + return Vec::new(); + }; + let text = if violation.instance_path.is_empty() { + violation.message.clone() + } else { + format!("{} {}", violation.message, t!("diagnostics.schema.at", path = violation.instance_path)) + }; + vec![label(map, span, text)] + }, + } +} + +/// A label over the first line of a span. +fn label(map: &SourceMap, span: std::ops::Range, text: String) -> LabeledSpan { + let first_line_end = map.text()[span.clone()].find('\n').map_or(span.end, |offset| span.start + offset); + let span = span.start..first_line_end.max(span.start); + LabeledSpan::new(Some(text), span.start, span.len()) +} + +/// Escape a member name as a JSON pointer reference token. +fn escape(key: &str) -> String { + key.replace('~', "~0").replace('/', "~1") +} + +/// The property names the schema declares at the object schema a keyword belongs to. +/// +/// # Arguments +/// +/// * `schema` - The root schema. +/// * `schema_path` - The pointer to the keyword that failed, like `/additionalProperties`. +fn known_properties(schema: &Value, schema_path: &str) -> Option> { + let parent = &schema_path[..schema_path.rfind('/')?]; + let properties = schema.pointer(&format!("{parent}/properties"))?.as_object()?; + Some(properties.keys().cloned().collect()) +} + +/// The known property that most resembles an unknown one, if any does. +/// +/// # Arguments +/// +/// * `property` - The unknown property. +/// * `known` - The known property names. +fn closest<'a>(property: &str, known: &'a [String]) -> Option<&'a str> { + if let Some(same) = known.iter().find(|candidate| candidate.eq_ignore_ascii_case(property)) { + return Some(same); + } + known.iter() + .map(|candidate| (edit_distance(&property.to_lowercase(), &candidate.to_lowercase()), candidate)) + .filter(|(distance, _)| *distance <= MAX_SUGGESTION_DISTANCE) + .min_by_key(|(distance, _)| *distance) + .map(|(_, candidate)| candidate.as_str()) +} + +/// The Levenshtein distance between two strings. +fn edit_distance(a: &str, b: &str) -> usize { + let a: Vec = a.chars().collect(); + let b: Vec = b.chars().collect(); + let mut previous: Vec = (0..=b.len()).collect(); + let mut current = vec![0; b.len() + 1]; + for (i, &ca) in a.iter().enumerate() { + current[0] = i + 1; + for (j, &cb) in b.iter().enumerate() { + let cost = usize::from(ca != cb); + current[j + 1] = (previous[j + 1] + 1).min(current[j] + 1).min(previous[j] + cost); + } + std::mem::swap(&mut previous, &mut current); + } + previous[b.len()] +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::dscresources::dscresource::validate_json_all; + use serde_json::json; + + fn echo_schema() -> Value { + json!({ + "type": "object", + "additionalProperties": false, + "required": ["output"], + "properties": { + "output": { "type": ["string", "object"] }, + "verbose": { "type": "boolean" } + } + }) + } + + fn error_for(text: &str) -> SchemaValidationError { + let schema = echo_schema(); + let value: Value = serde_yaml::from_str(text).unwrap(); + let violations = validate_json_all("test", &schema, &value).unwrap(); + SchemaValidationError::new("input for 'Test/Echo'", violations) + .with_schema(&schema) + .with_source(text, "input", "") + } + + #[test] + fn unknown_property_is_labeled_at_its_key_with_a_suggestion() { + let text = "{\n \"otput\": \"hi\",\n \"output\": \"x\"\n}"; + let error = error_for(text); + assert!(error.has_source()); + let labels: Vec = error.labels().unwrap().collect(); + assert_eq!(labels.len(), 1); + assert_eq!(&text[labels[0].offset()..labels[0].offset() + labels[0].len()], "\"otput\""); + assert_eq!(labels[0].label().unwrap(), "'otput' isn't a known property"); + assert_eq!(error.help().unwrap().to_string(), "Did you mean 'output' instead of 'otput'?"); + assert_eq!(error.to_string(), "Schema: input for 'Test/Echo': Additional properties are not allowed ('otput' was unexpected)"); + } + + #[test] + fn missing_property_is_labeled_at_the_enclosing_object() { + let text = "verbose: true\n"; + let error = error_for(text); + let labels: Vec = error.labels().unwrap().collect(); + assert_eq!(labels[0].label().unwrap(), "missing required property 'output'"); + assert_eq!(labels[0].offset(), 0); + assert!(error.help().is_none()); + } + + #[test] + fn wrong_type_is_labeled_at_the_value_and_named_in_the_message() { + let text = "output: hi\nverbose: nope\n"; + let error = error_for(text); + let labels: Vec = error.labels().unwrap().collect(); + assert_eq!(&text[labels[0].offset()..labels[0].offset() + labels[0].len()], "nope"); + assert!(error.to_string().ends_with("is not of type \"boolean\" at '/verbose'")); + } + + #[test] + fn base_pointer_anchors_violations_inside_a_document() { + let document = "resources:\n- name: a\n type: Test/Echo\n properties:\n otput: hi\n"; + let schema = echo_schema(); + let properties = json!({"otput": "hi"}); + let violations = validate_json_all("test", &schema, &properties).unwrap(); + let error = SchemaValidationError::new("input for 'Test/Echo'", violations) + .with_source(document, "demo.dsc.yaml", "/resources/0/properties"); + let labels: Vec = error.labels().unwrap().collect(); + let unknown = labels.iter().find(|label| label.label() == Some("'otput' isn't a known property")).unwrap(); + assert_eq!(&document[unknown.offset()..unknown.offset() + unknown.len()], "otput"); + } + + #[test] + fn redaction_drops_source_and_messages() { + let error = error_for("output: hi\nverbose: secret\n").redacted("redacted"); + assert!(!error.has_source()); + assert!(error.labels().is_none()); + assert_eq!(error.to_string(), "Schema: input for 'Test/Echo': redacted at '/verbose'"); + } + + #[test] + fn lists_known_properties_without_a_close_match() { + let error = error_for("output: hi\ncompletelyDifferent: 1\n"); + assert_eq!(error.help().unwrap().to_string(), "Known properties: output, verbose"); + } + + #[test] + fn edit_distance_counts_edits() { + assert_eq!(edit_distance("output", "otput"), 1); + assert_eq!(edit_distance("keyPath", "keypath"), 1); + assert_eq!(edit_distance("", "abc"), 3); + assert_eq!(edit_distance("same", "same"), 0); + } +} diff --git a/lib/dsc-lib/src/diagnostics/source_map.rs b/lib/dsc-lib/src/diagnostics/source_map.rs new file mode 100644 index 000000000..7b1c6baca --- /dev/null +++ b/lib/dsc-lib/src/diagnostics/source_map.rs @@ -0,0 +1,537 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Locate JSON pointers in the text of a JSON or YAML document. + +use std::{collections::HashMap, ops::Range}; + +/// The spans of one location in a document. +#[derive(Clone, Debug, PartialEq, Eq)] +struct Entry { + /// The span of the member name, when the value is a member of an object. + key: Option>, + /// The span of the value. + value: Range, +} + +/// The text of a document together with the byte span of every location in it, indexed by JSON +/// pointer (RFC 6901). +/// +/// A source map lets a diagnostic point at the exact place in a document that a pointer +/// identifies, both as a byte range for rendering a snippet and as a line and column. +#[derive(Clone, Debug)] +pub struct SourceMap { + text: String, + entries: HashMap, + line_starts: Vec, +} + +impl SourceMap { + /// Build a source map from document text. + /// + /// Text that is valid JSON is mapped with the JSON grammar; anything else is mapped as YAML. + /// + /// # Arguments + /// + /// * `text` - The document text. + /// + /// # Returns + /// + /// The source map, or `None` when the text is neither valid JSON nor valid YAML. + #[must_use] + pub fn parse(text: &str) -> Option { + let entries = if serde_json::from_str::(text).is_ok() { + json::scan(text)? + } else { + yaml::scan(text)? + }; + Some(Self { + text: text.to_string(), + entries, + line_starts: line_starts(text), + }) + } + + /// The document text. + #[must_use] + pub fn text(&self) -> &str { + &self.text + } + + /// Whether the document has a value at the pointer. + /// + /// # Arguments + /// + /// * `pointer` - The JSON pointer; the empty pointer is the document root. + #[must_use] + pub fn contains(&self, pointer: &str) -> bool { + self.entries.contains_key(pointer) + } + + /// The span of the value at the pointer. + /// + /// # Arguments + /// + /// * `pointer` - The JSON pointer; the empty pointer is the document root. + #[must_use] + pub fn value_span(&self, pointer: &str) -> Option> { + self.entries.get(pointer).map(|entry| entry.value.clone()) + } + + /// The span of the member name of the value at the pointer, when it is a member of an object. + /// + /// # Arguments + /// + /// * `pointer` - The JSON pointer. + #[must_use] + pub fn key_span(&self, pointer: &str) -> Option> { + self.entries.get(pointer).and_then(|entry| entry.key.clone()) + } + + /// The span that best identifies the pointer: the member name when the value is a member of + /// an object, otherwise the value. + /// + /// # Arguments + /// + /// * `pointer` - The JSON pointer. + #[must_use] + pub fn span(&self, pointer: &str) -> Option> { + self.entries.get(pointer).map(|entry| entry.key.clone().unwrap_or_else(|| entry.value.clone())) + } + + /// The span of the nearest location that exists in the document, walking up from the pointer + /// to its ancestors. + /// + /// Useful for a pointer into a value the document doesn't spell out, like a property that is + /// missing or a character offset inside a string. + /// + /// # Arguments + /// + /// * `pointer` - The JSON pointer. + /// + /// # Returns + /// + /// The pointer that was found and its span. + #[must_use] + pub fn nearest_span(&self, pointer: &str) -> Option<(String, Range)> { + let mut current = pointer; + loop { + if let Some(span) = self.span(current) { + return Some((current.to_string(), span)); + } + let parent_end = current.rfind('/')?; + current = ¤t[..parent_end]; + } + } + + /// The line and column of a byte offset, both starting at 1. Columns count characters. + /// + /// # Arguments + /// + /// * `offset` - The byte offset into the text. + #[must_use] + pub fn location(&self, offset: usize) -> (usize, usize) { + let offset = offset.min(self.text.len()); + let line_index = match self.line_starts.binary_search(&offset) { + Ok(index) => index, + Err(index) => index.saturating_sub(1), + }; + let line_start = self.line_starts[line_index]; + let column = self.text[line_start..offset].chars().count() + 1; + (line_index + 1, column) + } +} + +/// The byte offset where each line starts. +fn line_starts(text: &str) -> Vec { + let mut starts = vec![0]; + for (index, byte) in text.bytes().enumerate() { + if byte == b'\n' { + starts.push(index + 1); + } + } + starts +} + +/// Escape a member name as a JSON pointer reference token. +fn escape(key: &str) -> String { + key.replace('~', "~0").replace('/', "~1") +} + +mod json { + use super::{Entry, escape}; + use std::{collections::HashMap, ops::Range}; + + /// Record the span of every value in valid JSON text. + pub(super) fn scan(text: &str) -> Option> { + let mut scanner = Scanner { text, bytes: text.as_bytes(), pos: 0, entries: HashMap::new() }; + scanner.value("", None)?; + Some(scanner.entries) + } + + struct Scanner<'a> { + text: &'a str, + bytes: &'a [u8], + pos: usize, + entries: HashMap, + } + + impl Scanner<'_> { + fn skip_whitespace(&mut self) { + while self.pos < self.bytes.len() && matches!(self.bytes[self.pos], b' ' | b'\t' | b'\n' | b'\r') { + self.pos += 1; + } + } + + fn expect(&mut self, byte: u8) -> Option<()> { + self.skip_whitespace(); + if self.bytes.get(self.pos) == Some(&byte) { + self.pos += 1; + Some(()) + } else { + None + } + } + + fn value(&mut self, pointer: &str, key: Option>) -> Option> { + self.skip_whitespace(); + let start = self.pos; + let span = match self.bytes.get(self.pos)? { + b'{' => self.object(pointer)?, + b'[' => self.array(pointer)?, + b'"' => self.string()?, + _ => { + while self.pos < self.bytes.len() && !matches!(self.bytes[self.pos], b',' | b'}' | b']' | b' ' | b'\t' | b'\n' | b'\r') { + self.pos += 1; + } + start..self.pos + }, + }; + self.entries.insert(pointer.to_string(), Entry { key, value: span.clone() }); + Some(span) + } + + fn object(&mut self, pointer: &str) -> Option> { + let start = self.pos; + self.pos += 1; + loop { + self.skip_whitespace(); + match self.bytes.get(self.pos)? { + b'}' => { + self.pos += 1; + return Some(start..self.pos); + }, + b',' => { + self.pos += 1; + }, + b'"' => { + let key_span = self.string()?; + let key: String = serde_json::from_str(&self.text[key_span.clone()]).ok()?; + self.expect(b':')?; + self.value(&format!("{pointer}/{}", escape(&key)), Some(key_span))?; + }, + _ => return None, + } + } + } + + fn array(&mut self, pointer: &str) -> Option> { + let start = self.pos; + self.pos += 1; + let mut index = 0; + loop { + self.skip_whitespace(); + match self.bytes.get(self.pos)? { + b']' => { + self.pos += 1; + return Some(start..self.pos); + }, + b',' => { + self.pos += 1; + }, + _ => { + self.value(&format!("{pointer}/{index}"), None)?; + index += 1; + }, + } + } + } + + fn string(&mut self) -> Option> { + let start = self.pos; + self.pos += 1; + while let Some(byte) = self.bytes.get(self.pos) { + match byte { + b'\\' => self.pos += 2, + b'"' => { + self.pos += 1; + return Some(start..self.pos); + }, + _ => self.pos += 1, + } + } + None + } + } +} + +mod yaml { + use super::{Entry, escape, line_starts}; + use saphyr_parser::{Event, Marker, Parser, Span}; + use std::{collections::HashMap, ops::Range}; + + /// A container whose members are being read. + enum Frame { + Mapping { + pointer: String, + key: Option>, + /// The member name the next value belongs to: `None` while expecting a key, and + /// `Some(None)` for a key that isn't a scalar and so can't be addressed by a pointer. + pending: Option)>>, + start: usize, + end: usize, + }, + Sequence { + pointer: String, + key: Option>, + index: usize, + start: usize, + end: usize, + }, + } + + /// Record the span of every node in the first document of YAML text. + pub(super) fn scan(text: &str) -> Option> { + let offsets = Offsets::new(text); + let mut entries = HashMap::new(); + let mut frames: Vec = Vec::new(); + // Nesting depth inside a key that is a collection, whose events are skipped. + let mut skipping = 0usize; + let mut root_done = false; + + for item in Parser::new_from_str(text) { + let (event, span) = item.ok()?; + let range = offsets.range(&span); + match event { + Event::StreamStart | Event::StreamEnd | Event::Nothing => {}, + Event::DocumentStart(_) => { + if root_done { + break; + } + }, + Event::DocumentEnd => root_done = true, + Event::Scalar(..) | Event::Alias(..) => { + if skipping > 0 { + continue; + } + let key_text = match &event { + Event::Scalar(value, ..) => Some(value.to_string()), + _ => None, + }; + match frames.last_mut() { + Some(Frame::Mapping { pending: pending @ None, .. }) => { + *pending = Some(key_text.map(|value| (value, range))); + }, + Some(frame) => { + let (pointer, key) = child(frame, range.end); + if let Some(pointer) = pointer { + entries.insert(pointer, Entry { key, value: range }); + } + }, + None => { + entries.insert(String::new(), Entry { key: None, value: range }); + }, + } + }, + Event::SequenceStart(..) | Event::MappingStart(..) => { + if skipping > 0 { + skipping += 1; + continue; + } + if let Some(Frame::Mapping { pending: pending @ None, .. }) = frames.last_mut() { + *pending = Some(None); + skipping = 1; + continue; + } + let (pointer, key) = match frames.last_mut() { + Some(frame) => child(frame, range.end), + None => (Some(String::new()), None), + }; + let Some(pointer) = pointer else { + skipping = 1; + continue; + }; + frames.push(if matches!(event, Event::SequenceStart(..)) { + Frame::Sequence { pointer, key, index: 0, start: range.start, end: range.end } + } else { + Frame::Mapping { pointer, key, pending: None, start: range.start, end: range.end } + }); + }, + Event::SequenceEnd | Event::MappingEnd => { + if skipping > 0 { + skipping -= 1; + continue; + } + let frame = frames.pop()?; + let (pointer, key, start, end) = match frame { + Frame::Mapping { pointer, key, start, end, .. } | Frame::Sequence { pointer, key, start, end, .. } => (pointer, key, start, end), + }; + // A flow collection's closing bracket has a span of its own; a block + // collection ends with its last member. + let end = if range.end > range.start { range.end.max(end) } else { end.max(start) }; + if let Some(parent) = frames.last_mut() { + extend(parent, end); + } + entries.insert(pointer, Entry { key, value: start..end }); + }, + } + } + Some(entries) + } + + /// Resolve the pointer and member-name span of the next value in a container, and advance + /// the container past it. + fn child(frame: &mut Frame, end: usize) -> (Option, Option>) { + match frame { + Frame::Mapping { pointer, pending, end: frame_end, .. } => { + *frame_end = (*frame_end).max(end); + match pending.take().flatten() { + Some((key, span)) => (Some(format!("{pointer}/{}", escape(&key))), Some(span)), + None => (None, None), + } + }, + Frame::Sequence { pointer, index, end: frame_end, .. } => { + *frame_end = (*frame_end).max(end); + let child = format!("{pointer}/{index}"); + *index += 1; + (Some(child), None) + }, + } + } + + fn extend(frame: &mut Frame, end: usize) { + match frame { + Frame::Mapping { end: frame_end, .. } | Frame::Sequence { end: frame_end, .. } => { + *frame_end = (*frame_end).max(end); + }, + } + } + + /// Converts the parser's line and column markers into byte offsets. + struct Offsets<'a> { + text: &'a str, + line_starts: Vec, + } + + impl<'a> Offsets<'a> { + fn new(text: &'a str) -> Self { + Self { text, line_starts: line_starts(text) } + } + + fn range(&self, span: &Span) -> Range { + let start = self.offset(&span.start); + let end = self.offset(&span.end).max(start); + start..end + } + + /// The byte offset of a marker, whose line starts at 1 and whose column starts at 0 and + /// counts characters. + fn offset(&self, marker: &Marker) -> usize { + let Some(&line_start) = self.line_starts.get(marker.line().saturating_sub(1)) else { + return self.text.len(); + }; + let line = &self.text[line_start..]; + let line = &line[..line.find('\n').unwrap_or(line.len())]; + let column_offset = line.char_indices().nth(marker.col()).map_or(line.len(), |(offset, _)| offset); + line_start + column_offset + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn text_at(map: &SourceMap, span: Range) -> &str { + &map.text()[span] + } + + #[test] + fn json_locates_keys_values_and_nested_items() { + let map = SourceMap::parse(r#"{ + "a": 1, + "b": [true, {"c~/d": "x"}], + "e": {"f": null} +}"#).unwrap(); + assert_eq!(text_at(&map, map.key_span("/a").unwrap()), "\"a\""); + assert_eq!(text_at(&map, map.value_span("/a").unwrap()), "1"); + assert_eq!(text_at(&map, map.value_span("/b/0").unwrap()), "true"); + assert_eq!(text_at(&map, map.key_span("/b/1/c~0~1d").unwrap()), "\"c~/d\""); + assert_eq!(text_at(&map, map.value_span("/b/1/c~0~1d").unwrap()), "\"x\""); + assert_eq!(text_at(&map, map.value_span("/e").unwrap()), "{\"f\": null}"); + assert_eq!(text_at(&map, map.value_span("/b").unwrap()), "[true, {\"c~/d\": \"x\"}]"); + assert_eq!(map.value_span("").unwrap().start, 0); + assert!(map.value_span("/missing").is_none()); + assert_eq!(map.location(map.key_span("/b").unwrap().start), (3, 3)); + } + + #[test] + fn json_handles_escaped_strings() { + let map = SourceMap::parse(r#"{"k\"ey": "va\\lue", "n": -1.5e3}"#).unwrap(); + assert_eq!(text_at(&map, map.value_span("/k\"ey").unwrap()), r#""va\\lue""#); + assert_eq!(text_at(&map, map.value_span("/n").unwrap()), "-1.5e3"); + } + + #[test] + fn yaml_locates_block_mappings_and_sequences() { + let text = "$schema: https://example\nresources:\n- name: first\n type: Test/Echo\n properties:\n output: hi\n- name: second\n type: Test/Echo\n"; + let map = SourceMap::parse(text).unwrap(); + assert_eq!(text_at(&map, map.key_span("/resources/0/properties/output").unwrap()), "output"); + assert_eq!(text_at(&map, map.value_span("/resources/0/properties/output").unwrap()), "hi"); + assert_eq!(text_at(&map, map.value_span("/resources/1/name").unwrap()), "second"); + assert_eq!(text_at(&map, map.key_span("/resources").unwrap()), "resources"); + let item = map.value_span("/resources/0").unwrap(); + assert!(text_at(&map, item.clone()).starts_with("name: first")); + assert!(text_at(&map, item).ends_with("output: hi")); + assert_eq!(map.location(map.key_span("/resources/1/type").unwrap().start), (8, 3)); + } + + #[test] + fn yaml_locates_flow_collections_and_quoted_scalars() { + let text = "a: {b: [1, 2], 'c d': \"q\"}\n"; + let map = SourceMap::parse(text).unwrap(); + assert_eq!(text_at(&map, map.value_span("/a/b/1").unwrap()), "2"); + assert_eq!(text_at(&map, map.key_span("/a/c d").unwrap()), "'c d'"); + assert_eq!(text_at(&map, map.value_span("/a/c d").unwrap()), "\"q\""); + assert_eq!(text_at(&map, map.value_span("/a").unwrap()), "{b: [1, 2], 'c d': \"q\"}"); + } + + #[test] + fn yaml_counts_columns_in_characters() { + let text = "héllo: wörld\nnext: 1\n"; + let map = SourceMap::parse(text).unwrap(); + assert_eq!(text_at(&map, map.value_span("/héllo").unwrap()), "wörld"); + assert_eq!(text_at(&map, map.value_span("/next").unwrap()), "1"); + } + + #[test] + fn yaml_skips_complex_keys() { + let text = "? [a, b]\n: value\nplain: 1\n"; + let map = SourceMap::parse(text).unwrap(); + assert_eq!(text_at(&map, map.value_span("/plain").unwrap()), "1"); + } + + #[test] + fn nearest_span_walks_up_to_an_ancestor() { + let map = SourceMap::parse("resources:\n- name: a\n").unwrap(); + let (pointer, span) = map.nearest_span("/resources/0/properties/output").unwrap(); + assert_eq!(pointer, "/resources/0"); + assert_eq!(text_at(&map, span), "name: a"); + assert!(map.nearest_span("/resources/5").is_some()); + } + + #[test] + fn invalid_text_has_no_map() { + assert!(SourceMap::parse("a: [b").is_none()); + } +} diff --git a/lib/dsc-lib/src/dscerror.rs b/lib/dsc-lib/src/dscerror.rs index 9a430b8ff..57dc04f05 100644 --- a/lib/dsc-lib/src/dscerror.rs +++ b/lib/dsc-lib/src/dscerror.rs @@ -152,6 +152,10 @@ pub enum DscError { #[error("{t}: {0}", t = t!("dscerror.schema"))] Schema(String), + #[error(transparent)] + #[diagnostic(transparent)] + SchemaValidation(Box), + #[error("{t}: {0}", t = t!("dscerror.schemaNotAvailable"))] SchemaNotAvailable(String), diff --git a/lib/dsc-lib/src/dscresources/command_resource.rs b/lib/dsc-lib/src/dscresources/command_resource.rs index c024ee6ae..29f86affe 100644 --- a/lib/dsc-lib/src/dscresources/command_resource.rs +++ b/lib/dsc-lib/src/dscresources/command_resource.rs @@ -3,16 +3,15 @@ use clap::ValueEnum; use dsc_lib_security_context::{SecurityContext, get_security_context}; -use jsonschema::Validator; use rust_i18n::t; use serde::Deserialize; use serde_json::{Map, Value}; use std::{collections::HashMap, env, path::Path, process::Stdio}; -use crate::{configure::{config_doc::{ExecutionKind, SecurityContextKind}, config_result::{ResourceGetResult, ResourceTestResult}, schema_cache::{RESOURCE_SCHEMAS, get_resource_schema}}, dscresources::{dscresource::Operation, resource_manifest::{ExportSchemaKind, ExportSchemaOrFiltering, SchemaArgKind}}, types::ExitCodesMap, util::canonicalize_which}; +use crate::{configure::{config_doc::{ExecutionKind, SecurityContextKind}, config_result::{ResourceGetResult, ResourceTestResult}, schema_cache::{RESOURCE_SCHEMAS, get_resource_schema}}, diagnostics::SchemaValidationError, dscresources::{dscresource::Operation, resource_manifest::{ExportSchemaKind, ExportSchemaOrFiltering, SchemaArgKind}}, types::ExitCodesMap, util::canonicalize_which}; use crate::dscerror::DscError; use crate::locked_insert; use super::{ - dscresource::{get_diff, get_diff_with_schema, redact, DscResource}, + dscresource::{get_diff, get_diff_with_schema, redact, validate_json_all, DscResource}, invoke_result::{ DeleteResult, DeleteResultKind, ExportResult, GetResult, ResolveResult, SetResult, TestResult, ValidateResult, @@ -54,7 +53,7 @@ pub fn invoke_get(resource: &DscResource, filter: &str, target_resource: Option< validate_security_context(target_resource, &get.require_security_context, &command_resource.type_name, &Operation::Get)?; let args = process_get_args(get.args.as_ref(), filter, command_resource); if !filter.is_empty() { - verify_json_from_manifest(resource, filter, target_resource)?; + verify_json_from_manifest(resource, filter, target_resource, JsonOrigin::Input)?; command_input = get_command_input(get.input.as_ref(), filter)?; } @@ -62,7 +61,7 @@ pub fn invoke_get(resource: &DscResource, filter: &str, target_resource: Option< let (_exit_code, stdout, stderr) = invoke_command(&get.executable, args, command_input.stdin.as_deref(), Some(&resource.directory), command_input.env, manifest.exit_codes.as_ref())?; if resource.kind == Kind::Resource { debug!("{}", t!("dscresources.commandResource.verifyOutputUsing", resource = &resource.type_name, executable = &get.executable)); - verify_json_from_manifest(resource, &stdout, target_resource)?; + verify_json_from_manifest(resource, &stdout, target_resource, JsonOrigin::Output)?; } let result: GetResult = if let Ok(group_response) = serde_json::from_str::>(&stdout) { @@ -141,7 +140,7 @@ pub fn invoke_set(resource: &DscResource, desired: &str, skip_test: bool, execut return Err(DscError::NotImplemented("set".to_string())); }; validate_security_context(target_resource, &set.require_security_context, &command_resource.type_name, &Operation::Set)?; - verify_json_from_manifest(resource, desired, target_resource)?; + verify_json_from_manifest(resource, desired, target_resource, JsonOrigin::Input)?; // if resource doesn't implement a pre-test, we execute test first to see if a set is needed if !skip_test && set.pre_test != Some(true) { @@ -194,7 +193,7 @@ pub fn invoke_set(resource: &DscResource, desired: &str, skip_test: bool, execut if resource.kind == Kind::Resource { debug!("{}", t!("dscresources.commandResource.setVerifyGet", resource = &resource.type_name, executable = &get.executable)); - verify_json_from_manifest(resource, &stdout, target_resource)?; + verify_json_from_manifest(resource, &stdout, target_resource, JsonOrigin::Output)?; } let pre_state_value: Value = if exit_code == 0 { @@ -244,7 +243,7 @@ pub fn invoke_set(resource: &DscResource, desired: &str, skip_test: bool, execut if resource.kind == Kind::Resource { debug!("{}", t!("dscresources.commandResource.setVerifyOutput", operation = operation_type, resource = &command_resource.type_name, executable = &set.executable)); - verify_json_from_manifest(resource, &stdout, target_resource)?; + verify_json_from_manifest(resource, &stdout, target_resource, JsonOrigin::Output)?; } let actual_value: Value = match serde_json::from_str(&stdout){ @@ -272,7 +271,7 @@ pub fn invoke_set(resource: &DscResource, desired: &str, skip_test: bool, execut if resource.kind == Kind::Resource { debug!("{}", t!("dscresources.commandResource.setVerifyOutput", operation = operation_type, resource = &command_resource.type_name, executable = &set.executable)); - verify_json_from_manifest(resource, actual_line, target_resource)?; + verify_json_from_manifest(resource, actual_line, target_resource, JsonOrigin::Output)?; } let actual_value: Value = serde_json::from_str(actual_line)?; @@ -335,7 +334,7 @@ pub fn invoke_test(resource: &DscResource, expected: &str, target_resource: Opti return invoke_synthetic_test(resource, expected, target_resource); }; - verify_json_from_manifest(resource, expected, target_resource)?; + verify_json_from_manifest(resource, expected, target_resource, JsonOrigin::Input)?; let command_resource = match target_resource { Some(r) => r, @@ -359,7 +358,7 @@ pub fn invoke_test(resource: &DscResource, expected: &str, target_resource: Opti Some(ReturnKind::State) => { if command_resource.kind == Kind::Resource { debug!("{}", t!("dscresources.commandResource.testVerifyOutput", resource = &command_resource.type_name, executable = &test.executable)); - verify_json_from_manifest(resource, &stdout, target_resource)?; + verify_json_from_manifest(resource, &stdout, target_resource, JsonOrigin::Output)?; } let actual_value: Value = match serde_json::from_str(&stdout){ @@ -387,7 +386,7 @@ pub fn invoke_test(resource: &DscResource, expected: &str, target_resource: Opti if resource.kind == Kind::Resource { debug!("{}", t!("dscresources.commandResource.testVerifyOutput", resource = &resource.type_name, executable = &test.executable)); - verify_json_from_manifest(resource, actual_value, target_resource)?; + verify_json_from_manifest(resource, actual_value, target_resource, JsonOrigin::Output)?; } let actual_value: Value = serde_json::from_str(actual_value)?; @@ -505,7 +504,7 @@ pub fn invoke_delete(resource: &DscResource, filter: &str, target_resource: Opti return Err(DscError::NotImplemented("delete".to_string())); }; - verify_json_from_manifest(resource, filter, target_resource)?; + verify_json_from_manifest(resource, filter, target_resource, JsonOrigin::Input)?; let command_resource = match target_resource { Some(r) => r, @@ -666,17 +665,8 @@ fn verify_with_export_schema(input: &str, resource: &DscResource, target_resourc } }; let schema = serde_json::from_str(&schema)?; - let compiled_schema = match Validator::new(&schema) { - Ok(schema) => schema, - Err(e) => { - return Err(DscError::Schema(e.to_string())); - }, - }; let json: Value = serde_json::from_str(input)?; - if let Err(err) = compiled_schema.validate(&json) { - return Err(DscError::Schema(err.to_string())); - } - Ok(()) + validate_against_schema(resource, &schema, &json, JsonOrigin::Input) } /// Invoke the export operation on a resource @@ -759,7 +749,7 @@ pub fn invoke_export(resource: &DscResource, input: Option<&str>, target_resourc }; if resource.kind == Kind::Resource { debug!("{}", t!("dscresources.commandResource.exportVerifyOutput", resource = &resource.type_name, executable = &export.executable)); - verify_json_from_manifest(resource, line, target_resource)?; + verify_json_from_manifest(resource, line, target_resource, JsonOrigin::Output)?; } instances.push(instance); } @@ -1180,7 +1170,16 @@ fn get_command_input(input_kind: Option<&InputKind>, input: &str) -> Result) -> Result<(), DscError> { +/// Where the JSON that is validated against a resource's schema came from. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum JsonOrigin { + /// The desired state or filter a caller passed to the resource. + Input, + /// The state the resource returned. + Output, +} + +fn verify_json_from_manifest(resource: &DscResource, json: &str, target_resource: Option<&DscResource>, origin: JsonOrigin) -> Result<(), DscError> { trace!("{}", t!("dscresources.commandResource.verifyJson", resource = resource.type_name, json = json)); let Some(manifest) = &resource.manifest else { return Err(DscError::MissingManifest(resource.type_name.to_string())); @@ -1205,23 +1204,50 @@ fn verify_json_from_manifest(resource: &DscResource, json: &str, target_resource // otherwise, use schema validation let schema = get_schema(resource, target_resource)?; let schema: Value = serde_json::from_str(&schema)?; - let compiled_schema = match Validator::new(&schema) { - Ok(schema) => schema, - Err(e) => { - return Err(DscError::Schema(e.to_string())); - }, - }; let json: Value = serde_json::from_str(json)?; - if let Err(err) = compiled_schema.validate(&json) { - let mut error_message = err.to_string(); - // check if message contains a "secureString" or "secureObject" and redact it - if error_message.contains("\"secureString\"") || error_message.contains("\"secureObject\"") { - error_message = t!("dscresources.commandResource.redactedSchemaErrorMessage").to_string(); - } - return Err(DscError::Schema(error_message)); + validate_against_schema(resource, &schema, &json, origin) +} + +/// Validate JSON against a resource's schema, reporting every violation where it occurs. +/// +/// # Arguments +/// +/// * `resource` - The resource whose schema it is +/// * `schema` - The schema +/// * `json` - The JSON to validate +/// * `origin` - Where the JSON came from, which the report names +/// +/// # Errors +/// +/// * `DscError::Validation` - The schema failed to compile +/// * `DscError::SchemaValidation` - The JSON violates the schema +fn validate_against_schema(resource: &DscResource, schema: &Value, json: &Value, origin: JsonOrigin) -> Result<(), DscError> { + let violations = validate_json_all(resource.type_name.as_ref(), schema, json)?; + if violations.is_empty() { + return Ok(()); } - Ok(()) + let (subject, name) = match origin { + JsonOrigin::Input => ( + t!("dscresources.commandResource.schemaSubjectInput", resource = resource.type_name).to_string(), + t!("dscresources.commandResource.schemaSourceInput").to_string(), + ), + JsonOrigin::Output => ( + t!("dscresources.commandResource.schemaSubjectOutput", resource = resource.type_name).to_string(), + t!("dscresources.commandResource.schemaSourceOutput").to_string(), + ), + }; + let mut error = SchemaValidationError::new(subject, violations).with_schema(schema); + // a message that quotes a "secureString" or "secureObject" value would expose it, as would the text + let holds_secure_value = error.violations().iter().any(|violation| { + violation.message.contains("\"secureString\"") || violation.message.contains("\"secureObject\"") + }); + if holds_secure_value { + error = error.redacted(&t!("dscresources.commandResource.redactedSchemaErrorMessage")); + } else if let Ok(text) = serde_json::to_string_pretty(json) { + error = error.with_source(&text, &name, ""); + } + Err(DscError::SchemaValidation(Box::new(error))) } fn json_to_hashmap(json: &str) -> Result, DscError> { diff --git a/lib/dsc-lib/src/dscresources/dscresource.rs b/lib/dsc-lib/src/dscresources/dscresource.rs index 785e1a0f0..e3c3357fe 100644 --- a/lib/dsc-lib/src/dscresources/dscresource.rs +++ b/lib/dsc-lib/src/dscresources/dscresource.rs @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -use crate::{configure::{Configurator, config_doc::{Configuration, ExecutionKind, Resource}, context::ProcessMode, parameters::{SECURE_VALUE_REDACTED, is_secure_value}, schema_cache::get_resource_schema}, dscresources::{adapted_resource_manifest::AdaptedDscResourceManifest, resource_manifest::{AdapterInputKind, Kind}}, types::{FullyQualifiedTypeName, ResourceVersion}}; +use crate::{configure::{Configurator, config_doc::{Configuration, ExecutionKind, Resource}, context::ProcessMode, parameters::{SECURE_VALUE_REDACTED, is_secure_value}, schema_cache::get_resource_schema}, diagnostics::SchemaValidationError, dscresources::{adapted_resource_manifest::AdaptedDscResourceManifest, resource_manifest::{AdapterInputKind, Kind}}, types::{FullyQualifiedTypeName, ResourceVersion}}; use crate::discovery::discovery_trait::DiscoveryFilter; use crate::dscresources::invoke_result::{ResourceGetResponse, ResourceSetResponse}; use crate::schemas::transforms::idiomaticize_string_enum; @@ -909,7 +909,7 @@ pub fn validate_properties(resource: &DscResource, properties: &Value) -> Result /// /// # Arguments /// -/// * `source` - The source of the JSON +/// * `source` - The source of the JSON, as it should read in the error message /// * `schema` - The schema to validate against /// * `json` - The JSON to validate /// @@ -919,23 +919,28 @@ pub fn validate_properties(resource: &DscResource, properties: &Value) -> Result /// /// # Errors /// -/// * `DscError` - The JSON is invalid +/// * `DscError::Validation` - The schema failed to compile +/// * `DscError::SchemaValidation` - The JSON is invalid; the error lists every violation pub fn validate_json(source: &str, schema: &Value, json: &Value) -> Result<(), DscError> { - debug!("{}: {source}", t!("dscresources.dscresource.validatingSchema")); trace!("{}", t!("dscresources.dscresource.json", json = json)); - trace!("{}", t!("dscresources.dscresource.schema", schema = schema)); - let compiled_schema = match Validator::new(schema) { - Ok(compiled_schema) => compiled_schema, - Err(err) => { - return Err(DscError::Validation(format!("{}: {err}", t!("dscresources.dscresource.failedToCompileSchema")))); - } - }; - - if let Err(err) = compiled_schema.validate(json) { - return Err(DscError::Validation(format!("{}: '{source}' {err}", t!("dscresources.dscresource.validationFailed")))); + let violations = validate_json_all(source, schema, json)?; + if violations.is_empty() { + return Ok(()); } + Err(DscError::SchemaValidation(Box::new(SchemaValidationError::new(source, violations).with_schema(schema)))) +} - Ok(()) +/// Extra information for the keywords whose message alone doesn't locate the problem. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub enum SchemaViolationDetail { + /// The message describes the problem fully. + #[default] + None, + /// For `additionalProperties` and `unevaluatedProperties`, the property names that aren't + /// allowed. + UnexpectedProperties(Vec), + /// For `required`, the property that is missing. + MissingProperty(String), } /// A single violation found when validating JSON against a schema. @@ -949,6 +954,8 @@ pub struct SchemaViolation { pub keyword: String, /// The validation error message. pub message: String, + /// What the keyword found, when the message alone doesn't locate the problem. + pub detail: SchemaViolationDetail, /// For a failed `anyOf`, or a `oneOf` that no subschema matched, the violations of each /// subschema in order; empty for every other keyword. pub alternatives: Vec>, @@ -968,11 +975,21 @@ impl SchemaViolation { }, _ => Vec::new(), }; + let detail = match error.kind() { + ValidationErrorKind::AdditionalProperties { unexpected } | ValidationErrorKind::UnevaluatedProperties { unexpected } => { + SchemaViolationDetail::UnexpectedProperties(unexpected.clone()) + }, + ValidationErrorKind::Required { property } => { + SchemaViolationDetail::MissingProperty(property.as_str().map_or_else(|| property.to_string(), str::to_string)) + }, + _ => SchemaViolationDetail::None, + }; Self { instance_path: error.instance_path().to_string(), schema_path: error.schema_path().to_string(), keyword: error.kind().keyword().to_string(), message: error.to_string(), + detail, alternatives, } } diff --git a/lib/dsc-lib/src/lib.rs b/lib/dsc-lib/src/lib.rs index b4c88957f..621380793 100644 --- a/lib/dsc-lib/src/lib.rs +++ b/lib/dsc-lib/src/lib.rs @@ -12,6 +12,7 @@ use dscresources::{dscresource::{DscResource, Invoke}, invoke_result::{GetResult use rust_i18n::i18n; pub mod configure; +pub mod diagnostics; pub mod discovery; pub mod dscerror; pub mod dscresources; diff --git a/lib/dsc-lib/src/lint/mod.rs b/lib/dsc-lib/src/lint/mod.rs index ca48fcc32..a7d74a412 100644 --- a/lib/dsc-lib/src/lint/mod.rs +++ b/lib/dsc-lib/src/lint/mod.rs @@ -33,6 +33,7 @@ use crate::{ get_require_adapter_from_directive, parameters::import_parameters, }, + diagnostics::SourceMap, discovery::discovery_trait::DiscoveryFilter, dscerror::DscError, dscresources::{dscresource::validate_json_all, resource_manifest::ResourceManifest}, @@ -48,12 +49,14 @@ pub mod diagnostic; pub mod expression_index; pub mod options; pub mod pointer; +pub mod report; pub mod rule; pub mod rules; pub use diagnostic::{LintCategory, LintDiagnostic, LintLevel, LintResult, LintSummary, SourceLocation}; pub use options::{DocumentKind, LintOptions, LintSettings, RuleLevel, RuleLevelOverride, RuleSetting}; pub use pointer::JsonPointer; +pub use report::LintDiagnosticReport; pub use rule::{CheckFn, DiagnosticSink, LintRuleInfo, Rule, RuleTier}; use context::{collect_resource_sites, LintContext, ResolvedResource}; @@ -171,7 +174,7 @@ impl Linter { /// Lint a document given as JSON or YAML text. /// /// Problems in the document are reported as diagnostics, including a document that doesn't - /// parse. + /// parse. Every diagnostic gets the line and column of its location in the text. /// /// # Arguments /// @@ -185,7 +188,13 @@ impl Linter { /// diagnostic. pub fn lint(&mut self, document: &str) -> Result { match parse_document(document) { - Ok(raw) => self.lint_parsed(&raw, Vec::new()), + Ok(raw) => { + let mut result = self.lint_parsed(&raw, Vec::new())?; + if let Some(map) = SourceMap::parse(document) { + fill_locations(&mut result.diagnostics, &map); + } + Ok(result) + }, Err(diagnostic) => Ok(LintResult::from_diagnostics(stamp_file(vec![*diagnostic], &self.options.file), false)), } } @@ -480,6 +489,25 @@ fn stamp_file(mut diagnostics: Vec, file: &Option) -> Ve diagnostics } +/// Record the line and column of each diagnostic's path on diagnostics that don't have a +/// location yet. +/// +/// A path the document doesn't spell out, like a property that is missing, gets the location of +/// its closest enclosing value. +/// +/// # Arguments +/// +/// * `diagnostics` - The diagnostics. +/// * `map` - The source map of the document text. +fn fill_locations(diagnostics: &mut [LintDiagnostic], map: &SourceMap) { + for diagnostic in diagnostics.iter_mut().filter(|diagnostic| diagnostic.location.is_none()) { + if let Some((_, span)) = map.nearest_span(&diagnostic.path) { + let (line, column) = map.location(span.start); + diagnostic.location = Some(SourceLocation { line, column }); + } + } +} + /// Whether a rule's level is fixed. /// /// # Arguments diff --git a/lib/dsc-lib/src/lint/report.rs b/lib/dsc-lib/src/lint/report.rs new file mode 100644 index 000000000..968c137d7 --- /dev/null +++ b/lib/dsc-lib/src/lint/report.rs @@ -0,0 +1,139 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Present lint diagnostics as [`miette::Diagnostic`]s, so a host can show each problem in the +//! document text it was found in. + +use std::fmt::{self, Display}; + +use miette::{Diagnostic, LabeledSpan, NamedSource, Severity, SourceCode}; +use rust_i18n::t; + +use crate::diagnostics::SourceMap; + +use super::diagnostic::{LintDiagnostic, LintLevel}; + +/// A lint diagnostic together with the text it points into. +/// +/// The rule identifier is the diagnostic code, the level is the severity, and the description of +/// the rule is the help text. When the document text is available, the problem's location is +/// labeled in it. +#[derive(Debug)] +pub struct LintDiagnosticReport { + message: String, + rule: String, + severity: Severity, + help: Option, + source: Option>, + label: Option, +} + +impl LintDiagnosticReport { + /// Create a report for a diagnostic. + /// + /// # Arguments + /// + /// * `diagnostic` - The diagnostic. + /// * `source` - The source map of the document the diagnostic was found in, when the text is + /// available. + /// * `help` - What the rule requires, shown as the help text. + #[must_use] + pub fn new(diagnostic: &LintDiagnostic, source: Option<&SourceMap>, help: Option) -> Self { + let severity = match diagnostic.level { + LintLevel::Error => Severity::Error, + LintLevel::Warning => Severity::Warning, + LintLevel::Info => Severity::Advice, + }; + let located = source.and_then(|map| { + let (_, span) = map.nearest_span(&diagnostic.path)?; + let first_line_end = map.text()[span.clone()].find('\n').map_or(span.end, |offset| span.start + offset); + let span = span.start..first_line_end.max(span.start); + let name = diagnostic.file.clone().unwrap_or_else(|| t!("lint.report.inputSourceName").to_string()); + Some((NamedSource::new(name, map.text().to_string()), LabeledSpan::new(None, span.start, span.len()))) + }); + let (source, label) = match located { + Some((source, label)) => (Some(source), Some(label)), + None => (None, None), + }; + Self { + message: diagnostic.message.clone(), + rule: diagnostic.rule.clone(), + severity, + help, + source, + label, + } + } +} + +impl Display for LintDiagnosticReport { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.message) + } +} + +impl std::error::Error for LintDiagnosticReport {} + +impl Diagnostic for LintDiagnosticReport { + fn code<'a>(&'a self) -> Option> { + Some(Box::new(&self.rule)) + } + + fn severity(&self) -> Option { + Some(self.severity) + } + + fn help<'a>(&'a self) -> Option> { + self.help.as_ref().map(|help| Box::new(help) as Box) + } + + fn source_code(&self) -> Option<&dyn SourceCode> { + self.source.as_ref().map(|source| source as &dyn SourceCode) + } + + fn labels(&self) -> Option + '_>> { + self.label.as_ref().map(|label| Box::new(std::iter::once(label.clone())) as Box + '_>) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::lint::diagnostic::LintCategory; + + fn diagnostic(path: &str, level: LintLevel) -> LintDiagnostic { + LintDiagnostic { + level, + rule: "duplicate-resource".to_string(), + category: LintCategory::Document, + message: "declared twice".to_string(), + path: path.to_string(), + file: Some("demo.dsc.yaml".to_string()), + location: None, + } + } + + #[test] + fn report_labels_the_path_in_the_document() { + let text = "resources:\n- name: a\n type: T/E\n- name: a\n type: T/E\n"; + let map = SourceMap::parse(text).unwrap(); + let report = LintDiagnosticReport::new(&diagnostic("/resources/1", LintLevel::Error), Some(&map), Some("Names must be unique.".to_string())); + assert_eq!(report.code().unwrap().to_string(), "duplicate-resource"); + assert_eq!(report.severity(), Some(Severity::Error)); + assert_eq!(report.help().unwrap().to_string(), "Names must be unique."); + assert_eq!(report.to_string(), "declared twice"); + let label = report.labels().unwrap().next().unwrap(); + assert_eq!(&text[label.offset()..label.offset() + label.len()], "name: a"); + assert_eq!(label.offset(), text.rfind("name: a").unwrap()); + assert!(report.source_code().is_some()); + } + + #[test] + fn report_without_text_has_no_snippet() { + let report = LintDiagnosticReport::new(&diagnostic("/resources/0", LintLevel::Info), None, None); + assert_eq!(report.severity(), Some(Severity::Advice)); + assert!(report.labels().is_none()); + assert!(report.source_code().is_none()); + assert!(report.help().is_none()); + } +} diff --git a/rfc.md b/rfc.md new file mode 100644 index 000000000..d5d4ff473 --- /dev/null +++ b/rfc.md @@ -0,0 +1,462 @@ +--- +RFC: RFCNNNN +Author: @Gijsreyn +Sponsor: @Gijsreyn +Status: Draft +SupercededBy: null +Version: 1.0 +Area: CLI +CommentsDue: 2026-11-20 +--- + +# Static linting for configuration documents with `dsc lint` + +This RFC proposes a `dsc lint` command that checks DSC configuration documents written in YAML or +JSON for problems without applying them. The command parses the document, validates it against +the configuration document schema, and runs a catalog of rules that find mistakes DSC only reports +at runtime today, or doesn't report at all. It reports each problem as a diagnostic with a level, +a rule identifier, a message, and a JSON pointer to the location in the document, so authors, +editors, and CI pipelines can act on the result. + +The linting engine lives in `dsc-lib` so other hosts can reuse it. The first phase covers +configuration documents. A second phase extends the same command to resource and extension +manifests, as suggested in the discussion of [#591][01]. + +## Motivation + +> As a configuration author, +> I want DSC to tell me about mistakes in my document before I apply it, +> so that I don't discover a typo in a function name or a missing dependency halfway through a +> `dsc config set` run. + +> As a platform engineer who keeps configuration documents in source control, +> I want a command that fails a pull request on errors and emits machine-readable results, +> so that broken documents never reach the machines they target. + +> As a resource author, +> I want the same command to check my resource manifest, +> so that I find an inconsistent manifest before publishing it. + +Today DSC only validates a document when it runs it. `dsc config validate` exists but is hidden, +is only used by the Group, Assertion, and Include resources, reports a single `valid` boolean +without a reason, exits with code `0` even when the document is invalid, and always discovers +resources before it validates anything. Problems such as a `dependsOn` entry that names a +resource that doesn't exist, a `reference()` to a resource that hasn't run yet, a parameter that +is declared but never used, a secure parameter with a hard-coded default, or a condition written +as a plain string are either reported late, with no location, or not at all. + +> [!NOTE] +> I think earlier discussions revealed that eventually `dsc config validate` will be deprecated (question mark). + +[#591][01] proposed a `dsc lint` command with one rule: when the operating system root is +overridden (`--system-root`, formerly `--target-path`), the document should use the `systemRoot()` +function. The discussion asked whether linting belongs in an extension or in the core `dsc` +executable, noted that a linter for both configurations and manifests would be valuable, and +observed that a YAML and JSON authoring community needs good linting regardless of whether Bicep +gains DSC-specific rules. [#842][02] asked `dsc config validate` to reject duplicate resource names +and suggested growing that command into static analysis. + +This proposal answers those points as follows: + +- Linting is part of the core `dsc` executable and of `dsc-lib`. The rules need the document + schema, the expression parser, the function registry, resource discovery, and the dependency + resolution logic, all of which live in `dsc-lib`. Putting the engine in the library lets the + VS Code extension, the MCP server, and other hosts reuse it without spawning a process. + Extensions can't contribute rules in the first version; a `lint` extension capability is a + possible later addition. +- One command covers every document kind. `dsc lint` detects the kind of document from its + `$schema` property. The first phase implements configuration documents and rejects manifests + with a clear diagnostic; the second phase adds manifests. +- Bicep users benefit too. Bicep compiles to the same configuration document JSON, so `dsc lint` + can check the compiled output, and the rule identifiers follow the Bicep linter's naming where + the concept is shared. + +## Proposed experience + +Linting the document from [#842][02] reports the duplicate resource and exits with code `5`: + +```yaml +# demo.dsc.yaml +$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json +resources: +- name: EnsureTest1Value + type: Microsoft.Windows/Registry + properties: + keyPath: HKEY_LOCAL_MACHINE\SOFTWARE\TEST + valueName: Test1 + valueData: { DWord: 1 } +- name: EnsureTest2Value + type: Microsoft.Windows/Registry + properties: + keyPath: HKEY_LOCAL_MACHINE\SOFTWARE\TEST + valueName: Test2 + valueData: { String: Hello World! } +- name: EnsureTest1Value + type: Microsoft.Windows/Registry + properties: + keyPath: HKEY_LOCAL_MACHINE\SOFTWARE\TEST + valueName: Test + _exist: false +``` + +```sh +dsc lint --file ./demo.dsc.yaml +``` + +```yaml +diagnostics: +- level: error + rule: duplicate-resource + category: document + message: Resource 'EnsureTest1Value' of type 'Microsoft.Windows/Registry' is declared more than once + path: /resources/2 + file: ./demo.dsc.yaml +summary: + errors: 1 + warnings: 0 + info: 0 +valid: false +discovery: true +``` + +A pipeline that lints on a build agent without the resources installed skips discovery and fails +on warnings as well as errors: + +```sh +dsc lint --file ./demo.dsc.yaml --no-discovery --fail-on warning +``` + +The rule from [#591][01] fires when `--system-root` is given and no expression calls +`systemRoot()`: + +```sh +dsc lint --file ./demo.dsc.yaml --system-root D:\ +``` + +```yaml +diagnostics: +- level: warning + rule: system-root-unused + category: environment + message: A system root of 'D:\' was specified, but no expression calls systemRoot(), so the document doesn't target it + path: '' + file: ./demo.dsc.yaml +``` + +Rule levels can be changed per run or in the settings file, and the rules can be listed: + +```sh +dsc lint --file ./demo.dsc.yaml --rule no-unused-parameters=off --rule missing-require-version=warning +dsc lint --list-rules +``` + +```json +{ + "lint": { + "rules": { + "no-unused-parameters": { "level": "off" } + } + } +} +``` + +## Specification + +### Command + +```text +dsc lint [OPTIONS] (--file ... | --input ) +dsc lint --list-rules [--output-format ] +``` + +| Option | Description | +| :---------------------------- | :--------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `-f, --file ` | A document to lint. `-` reads stdin. The option can be repeated; the result then carries the file name on each diagnostic. | +| `-i, --input ` | The document as a string. Mutually exclusive with `--file`. | +| `-p, --parameters ` | Parameter values as JSON or YAML, in either format `dsc config` accepts. Enables the rules that check supplied values. | +| `--parameters-file ` | Parameter values from a file; `--parameters` takes precedence over it. | +| `-r, --system-root ` | The system root the document would be applied with. Enables `system-root-unused`. The path isn't required to exist. | +| `--no-discovery` | Skip resource discovery and the rules that need it. | +| `--fail-on ` | The lowest level that makes the command exit with code `5`: `error` (default), `warning`, or `info`. | +| `--rule =` | Override the level of a rule for this run (`off`, `info`, `warning`, `error`). Repeatable. An unknown rule is an argument error. | +| `--list-rules` | List the rules with the level each reports at in this run instead of linting. | +| `-o, --output-format `| `json`, `pretty-json`, or `yaml`, with the same defaults as the other commands. | + +The `-p` short option is bound to `--parameters` as it is for `dsc config`; the global +`--progress-format` option keeps its `-p` short form because it is only accepted before the +subcommand. + +### Document kind + +The command reads the `$schema` property and matches it against the recognized URIs of the +configuration document, resource manifest, and extension manifest schemas. In the first phase a +manifest is reported with the fixed `unsupported-document-type` rule. A document without a +recognized `$schema` is treated as a configuration document, so the schema rule reports the +missing or unrecognized URI. + +### Pipeline + +1. Parse the text as JSON, then as YAML. A failure is the fixed `parse-error` diagnostic at the + document root, with the parser's message and, when available, the line and column. Input that + starts with `{` is reported with the JSON parser's message so the message matches what the + author wrote. +1. Validate the parsed value against the bundled configuration document schema and report every + violation with the fixed `schema-invalid` rule at the violation's instance path. This is where + unknown properties, a missing `$schema`, and malformed `dependsOn` entries are reported. +1. Deserialize the value into the typed configuration. When that fails and no schema violation + explains why, report the deserialization message under `schema-invalid`. Stop when the typed + document isn't available. +1. Index every expression once. Strings that start with `[` in parameter defaults, variables, + outputs, user-defined function bodies, resource names, conditions, `dependsOn` entries, copy + counts, and properties are parsed with the expression grammar without being evaluated. Each + indexed expression records its JSON pointer and its scope (resource, parameter, variable, + output, or user-defined function). +1. Run the static rules. +1. Unless discovery is disabled, or the document sets `directives.resourceDiscovery` to + `duringDeployment`, discover the resource types the document uses with the same filters + `dsc config` builds (type, `requireVersion`, and `directives.requireAdapter`) and run the + resource rules. A discovery failure is the fixed `discovery-failed` warning and the resource + rules are skipped. +1. Sort the diagnostics by file, path, and rule, count them by level, and set `valid` to whether + there are no errors. + +The linter never calls a resource's `get`, `set`, `test`, `export`, `validate`, or `schema` +operation. Property validation only uses the schema embedded in the manifest or loaded by +discovery. + +### Levels and precedence + +Every rule has a default level: `error`, `warning`, `info`, or `off`. The effective level for a +run is resolved in this order, lowest precedence first: + +1. The rule's default. +1. The `lint.rules..level` setting in `dsc_default.settings.json` and `dsc.settings.json`. +1. The `--rule` options. +1. The `lint.rules..level` setting in the policy settings file, which wins so that an + administrator can enforce a rule. + +Unknown rules in the settings files are logged and ignored so a stale setting can't break every +run. The fixed rules `parse-error`, `schema-invalid`, `unsupported-document-type`, and +`discovery-failed` ignore overrides with a logged warning. + +### Exit codes + +| Code | Meaning | +| :--: | :-------------------------------------------------------------------------------------- | +| `0` | No diagnostic at or above the `--fail-on` level. | +| `1` | Invalid arguments, such as an unknown rule, a malformed `--rule` value, or no input. | +| `2` | Another failure, such as an unreadable `lint` settings section. | +| `4` | A document file or the parameters couldn't be read, or the parameters are malformed. | +| `5` | At least one diagnostic at or above the `--fail-on` level. | + +A document that doesn't parse or doesn't validate against the schema still produces the full +result object on stdout and exits with code `5`, so a pipeline always has structured output to +show. + +### Output + +The result object is published as the `lint-result` schema (`outputs/lint/result.json`): + +```json +{ + "diagnostics": [ + { + "level": "error", + "rule": "duplicate-resource", + "category": "document", + "message": "Resource 'EnsureTest1Value' of type 'Microsoft.Windows/Registry' is declared more than once", + "path": "/resources/2", + "file": "./demo.dsc.yaml", + "location": { "line": 14, "column": 3 } + } + ], + "summary": { "errors": 1, "warnings": 0, "info": 0 }, + "valid": false, + "discovery": true +} +``` + +- `path` is an [RFC 6901][03] JSON pointer into the parsed document. The empty string is the + document root. +- `file` is present when the document was read from a file. +- `location` is present when the position in the source text is known. In the first phase only + `parse-error` carries it, because the YAML and JSON parsers DSC uses don't keep positions for + values. The field is part of the schema from the start so that adding positions later isn't a + breaking change. +- `valid` is independent of `--fail-on`. +- `discovery` records whether the resource rules ran. + +`dsc lint --list-rules` emits an array of objects published as the `lint-rules` schema +(`outputs/lint/rules.json`) with the properties `id`, `category`, `tier` (`static` or +`resource`), `defaultLevel`, `level`, `fixed`, and `description`. + +### Rules for configuration documents + +Identifiers are kebab-case and follow the Bicep linter's vocabulary where the concept is shared, +so that `no-unused-parameters` and `secure-parameter-default` mean the same thing to a Bicep user. + +#### Document + +| Rule | Default | Checks | +| :------------------------------- | :------ | :------------------------------------------------------------------------------------------------------------------------------------------------ | +| `parse-error` | error | Fixed. The text is valid JSON or YAML. | +| `schema-invalid` | error | Fixed. Every violation of the configuration document schema, with its path. | +| `unsupported-document-type` | error | Fixed. The `$schema` isn't a manifest schema (first phase). | +| `duplicate-resource` | error | No two sibling resources share a name and a type (case-insensitive). DSC refuses such documents at runtime ([#842][02]). | +| `duplicate-resource-name` | warning | No two sibling resources of different types share a name; dependency resolution looks resources up by name first. | +| `empty-resources` | warning | The document declares at least one resource. | +| `unknown-microsoft-dsc-metadata` | warning | Keys under `Microsoft.DSC` metadata are ones DSC recognizes; unknown keys are silently dropped today. | +| `api-version-alias` | info | `requireVersion` is used rather than its alias `apiVersion`. | +| `date-version` | warning | `requireVersion` isn't a deprecated date-based version. | +| `missing-require-version` | off | Every resource pins a version with `requireVersion`. | + +#### Expression + +| Rule | Default | Checks | +| :------------------------- | :------ | :------------------------------------------------------------------------------------------------------------------------------------------- | +| `invalid-expression` | error | Every string that starts with `[` (but not `[[`) parses with the expression grammar. | +| `unknown-function` | error | Every call names a built-in function (case-sensitive, with a casing suggestion) or a user-defined function declared in `functions`. | +| `invalid-argument-count` | error | Every call passes a number of arguments within the function's accepted range, using the function registry's metadata and runtime messages. | +| `invalid-argument-type` | error | Every literal argument is of a kind the function accepts at that position; nested expressions aren't checked. | +| `undefined-parameter` | error | `parameters('x')` names a declared parameter, except inside user-defined function bodies. | +| `undefined-variable` | error | `variables('x')` names a declared variable. | +| `condition-not-expression` | warning | A `condition` is an expression; a plain string never evaluates to `true`, so the item is always skipped. | + +#### Parameter + +| Rule | Default | Checks | +| :------------------------------ | :------ | :------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `no-unused-parameters` | warning | Every declared parameter is referenced. | +| `no-unused-variables` | warning | Every declared variable is referenced. | +| `parameter-default-type` | error | A literal default satisfies the type, `allowedValues`, value limits, and length limits, reusing the runtime constraint checks. | +| `invalid-parameter-constraints` | error | Constraints are consistent: `minValue <= maxValue`, `minLength <= maxLength`, value limits only on `int`, length limits only on string and array types, non-empty `allowedValues` of the right type. | +| `secure-parameter-default` | warning | Secure parameters have no hard-coded default (an empty string or an expression is allowed). | +| `prefer-secure-type` | warning | A `string` or `object` parameter whose name suggests a secret uses a secure type. | +| `secure-value-in-output` | warning | Outputs don't expose a secure parameter or `secret()`; DSC skips such outputs at runtime. | +| `use-parameter-description` | off | Every parameter has a description. | +| `undeclared-parameter-value` | error | With supplied parameters: every supplied value matches a declared parameter. | +| `missing-parameter-value` | error | With supplied parameters: every parameter without a default has a value. | +| `parameter-value-type` | error | With supplied parameters: every value satisfies the type and constraints. | + +#### Dependency + +| Rule | Default | Checks | +| :----------------------------- | :------ | :----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `unresolved-dependency` | error | Every literal `dependsOn` entry and `reference(resourceId(...))` call points at a sibling resource; a name with the wrong type gets its own message. Copy loops are skipped. | +| `circular-dependency` | error | The `dependsOn` graph of each sibling group has no cycle, including self-dependencies; the message shows the cycle. | +| `reference-without-depends-on` | warning | A resource that calls `reference()` lists the referenced resource in `dependsOn`, because `reference()` only sees resources that already ran. | +| `duplicate-depends-on` | info | No dependency is listed twice. | + +#### Deprecation + +| Rule | Default | Checks | +| :----------------------------- | :------ | :-------------------------------------------------------------------------------------------------- | +| `copy-loop-deprecated` | warning | No resource or output uses a copy loop ([#1429][04]). | +| `copy-options-unsupported` | error | No copy loop sets `mode` or `batchSize`, which DSC rejects. | +| `security-context-in-metadata` | warning | The security context is set with `directives.securityContext`, not `Microsoft.DSC` metadata. | +| `security-context-conflict` | error | The metadata and directive security contexts don't contradict each other. | +| `dsc-version-unsatisfied` | warning | `directives.version` is satisfied by the version of `dsc` running the linter. | + +#### Environment + +| Rule | Default | Checks | +| :---------------------- | :------ | :------------------------------------------------------------------------------------------------ | +| `system-root-unused` | warning | With `--system-root`: some expression calls `systemRoot()` ([#591][01]). | +| `hardcoded-system-path` | off | Property values aren't absolute operating system paths that `systemRoot()` should build instead. | + +#### Resource (needs discovery) + +| Rule | Default | Checks | +| :----------------------------- | :------ | :------------------------------------------------------------------------------------------------------------------------------------------ | +| `discovery-failed` | warning | Fixed. Discovery succeeded; otherwise the resource rules are skipped. | +| `unknown-resource-type` | warning | Every resource type is installed. A warning, because the document may be valid on the machine it targets. | +| `unsatisfied-require-version` | warning | An installed version satisfies `requireVersion`. | +| `deprecated-resource` | warning | No resource type carries a `deprecationMessage`. | +| `property-schema-mismatch` | error | Literal properties validate against the embedded resource schema; violations under properties that hold expressions are dropped. | +| `nested-resources-unsupported` | warning | Only group and adapter resources declare nested `resources`. | +| `exist-false-unsupported` | error | A resource with `_exist: false` has the `delete` or `setHandlesExist` capability; otherwise `dsc config set` fails. | +| `canonical-type-name` | info | Resource types are written with the casing of their manifest. | + +### Rules for manifests (second phase) + +The same command, engine, output, and settings apply. The document kind is detected from +`$schema`, and `dsc lint --type resource-manifest` can force it. Proposed rules: + +| Rule | Default | Checks | +| :------------------------ | :------ | :------------------------------------------------------------------------------------------------------- | +| `manifest-schema-invalid` | error | Fixed. Every violation of the manifest schema. | +| `kind-method-mismatch` | error | `adapter` kinds define `adapter`, `importer` kinds define `resolve`, `exporter` kinds define `export`. | +| `missing-schema` | warning | A non-adapter resource defines an embedded schema or a schema command. | +| `embedded-schema-invalid` | error | The embedded schema compiles as a JSON schema. | +| `deprecated-whatif-method`| warning | The deprecated top-level `whatIf` method isn't used. | +| `exit-code-zero-success` | error | Exit code `0` isn't described as a failure. | +| `missing-description` | info | The manifest has a description. | +| `executable-not-found` | warning | Discovery tier: every executable the manifest names resolves on the path. | + +### Library design + +The engine is the `dsc_lib::lint` module: + +- `Linter::new(LintOptions)` resolves the effective rule levels and prepares the expression parser + and the compiled document schema; `Linter::lint(&str)` and `Linter::lint_value(&Value)` run the + pipeline and return a `LintResult`. Problems in the document never surface as `Err`; `Err` is + reserved for engine faults such as an unknown rule or malformed parameters. +- Each rule is a `Rule` value with an identifier, category, tier, default level, localized + description, and a check function that receives a `LintContext` (the raw value, the typed + configuration, the flattened resource sites, the expression index, the options, the function + registry, the supplied parameters, and the discovery results) and reports through a + `DiagnosticSink` that stamps the rule identity and level on every diagnostic. +- The expression parser gains a parse-only entry point (`Statement::parse`) and read access to + the parsed tree, and the function dispatcher gains `validate_arg_count` and + `validate_literal_args`, so the rules use the same metadata and messages as the runtime without + evaluating anything. Schema validation gains `validate_json_all`, which returns every violation + with its instance path instead of only the first. +- The CLI reads the raw document text instead of normalizing it to JSON first, so syntax errors + become diagnostics rather than an early exit. + +### Relationship to `dsc config validate` + +The hidden `validate` subcommand keeps its current contract for the Group, Assertion, and Include +resources. Once `dsc lint` is in place, `validate` can delegate to the engine's fixed rules and +the resource tier, which gives it the duplicate-resource check from [#842][02] and real reasons, +without changing its output shape. That change is out of scope for this RFC. + +## Alternate Proposals and Considerations + +- **`dsc config lint` instead of `dsc lint`.** A verb under `config` matches the noun-verb grammar + of the other commands and inherits the group's `--parameters` and `--system-root` options. + Manifests would then need `dsc resource lint` and `dsc extension lint`. A single top-level + command was chosen because it mirrors `bicep lint`, matches the original proposal in + [#591][01], gives one entry point for every document kind, and accepts several files in one run. +- **Growing `dsc config validate`.** [#842][02] suggested a `--schema-only` flag for the internal + callers and static analysis in `validate`. That mixes a pass-or-fail contract used by three + resources with a diagnostics contract meant for people and pipelines. Keeping `validate` as is + and building `lint` next to it avoids a breaking change for the resources. +- **Linting as an extension.** Extensions today provide discovery, import, and secrets, and run as + separate processes. The rules need the parser, function registry, schema, and discovery, which + are internal to `dsc-lib`, and editors want to lint in process. Extension-contributed rules + remain possible later through a new capability. +- **Relying on Bicep's linter.** Bicep can add DSC-specific rules for Bicep authors, but it + doesn't help the YAML and JSON authoring community, and the compiled JSON can still be checked + with `dsc lint`. +- **Discovery off by default.** A purely static default is faster and environment-independent, + but `bicep lint` restores modules by default and the hidden `validate` discovers today. Discovery + is on by default with `--no-discovery` as the escape hatch, and the discovery-dependent rules + report warnings rather than errors so a build agent without resources still passes. +- **Source positions.** The YAML and JSON parsers DSC uses don't keep positions for values, so + diagnostics carry JSON pointers and only parse errors carry a line and column. A later pass with + a marker-emitting YAML parser can fill `location` for every diagnostic without a schema change. +- **Future output formats.** SARIF output for code-scanning integrations and inline suppression in + the document are natural follow-ups once the rule catalog settles. + +## Related work items + +- [#591][01] - Proposes `dsc lint` and the `systemRoot()` rule; discusses extension versus core + and linting manifests. +- [#842][02] - Asks `dsc config validate` to reject duplicate resource names. +- [#1429][04] - Deprecates copy loops, which the `copy-loop-deprecated` rule reports. + +[01]: https://github.com/PowerShell/DSC/issues/591 +[02]: https://github.com/PowerShell/DSC/issues/842 +[03]: https://www.rfc-editor.org/rfc/rfc6901 +[04]: https://github.com/PowerShell/DSC/issues/1429 From 4202dee904450f655e5ce5a6a68508df25e3e288 Mon Sep 17 00:00:00 2001 From: "G.Reijn" <26114636+Gijsreyn@users.noreply.github.com> Date: Fri, 23 Oct 2026 08:55:30 +0200 Subject: [PATCH 4/6] Remove RFC from commit --- rfc.md | 462 --------------------------------------------------------- 1 file changed, 462 deletions(-) delete mode 100644 rfc.md diff --git a/rfc.md b/rfc.md deleted file mode 100644 index d5d4ff473..000000000 --- a/rfc.md +++ /dev/null @@ -1,462 +0,0 @@ ---- -RFC: RFCNNNN -Author: @Gijsreyn -Sponsor: @Gijsreyn -Status: Draft -SupercededBy: null -Version: 1.0 -Area: CLI -CommentsDue: 2026-11-20 ---- - -# Static linting for configuration documents with `dsc lint` - -This RFC proposes a `dsc lint` command that checks DSC configuration documents written in YAML or -JSON for problems without applying them. The command parses the document, validates it against -the configuration document schema, and runs a catalog of rules that find mistakes DSC only reports -at runtime today, or doesn't report at all. It reports each problem as a diagnostic with a level, -a rule identifier, a message, and a JSON pointer to the location in the document, so authors, -editors, and CI pipelines can act on the result. - -The linting engine lives in `dsc-lib` so other hosts can reuse it. The first phase covers -configuration documents. A second phase extends the same command to resource and extension -manifests, as suggested in the discussion of [#591][01]. - -## Motivation - -> As a configuration author, -> I want DSC to tell me about mistakes in my document before I apply it, -> so that I don't discover a typo in a function name or a missing dependency halfway through a -> `dsc config set` run. - -> As a platform engineer who keeps configuration documents in source control, -> I want a command that fails a pull request on errors and emits machine-readable results, -> so that broken documents never reach the machines they target. - -> As a resource author, -> I want the same command to check my resource manifest, -> so that I find an inconsistent manifest before publishing it. - -Today DSC only validates a document when it runs it. `dsc config validate` exists but is hidden, -is only used by the Group, Assertion, and Include resources, reports a single `valid` boolean -without a reason, exits with code `0` even when the document is invalid, and always discovers -resources before it validates anything. Problems such as a `dependsOn` entry that names a -resource that doesn't exist, a `reference()` to a resource that hasn't run yet, a parameter that -is declared but never used, a secure parameter with a hard-coded default, or a condition written -as a plain string are either reported late, with no location, or not at all. - -> [!NOTE] -> I think earlier discussions revealed that eventually `dsc config validate` will be deprecated (question mark). - -[#591][01] proposed a `dsc lint` command with one rule: when the operating system root is -overridden (`--system-root`, formerly `--target-path`), the document should use the `systemRoot()` -function. The discussion asked whether linting belongs in an extension or in the core `dsc` -executable, noted that a linter for both configurations and manifests would be valuable, and -observed that a YAML and JSON authoring community needs good linting regardless of whether Bicep -gains DSC-specific rules. [#842][02] asked `dsc config validate` to reject duplicate resource names -and suggested growing that command into static analysis. - -This proposal answers those points as follows: - -- Linting is part of the core `dsc` executable and of `dsc-lib`. The rules need the document - schema, the expression parser, the function registry, resource discovery, and the dependency - resolution logic, all of which live in `dsc-lib`. Putting the engine in the library lets the - VS Code extension, the MCP server, and other hosts reuse it without spawning a process. - Extensions can't contribute rules in the first version; a `lint` extension capability is a - possible later addition. -- One command covers every document kind. `dsc lint` detects the kind of document from its - `$schema` property. The first phase implements configuration documents and rejects manifests - with a clear diagnostic; the second phase adds manifests. -- Bicep users benefit too. Bicep compiles to the same configuration document JSON, so `dsc lint` - can check the compiled output, and the rule identifiers follow the Bicep linter's naming where - the concept is shared. - -## Proposed experience - -Linting the document from [#842][02] reports the duplicate resource and exits with code `5`: - -```yaml -# demo.dsc.yaml -$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json -resources: -- name: EnsureTest1Value - type: Microsoft.Windows/Registry - properties: - keyPath: HKEY_LOCAL_MACHINE\SOFTWARE\TEST - valueName: Test1 - valueData: { DWord: 1 } -- name: EnsureTest2Value - type: Microsoft.Windows/Registry - properties: - keyPath: HKEY_LOCAL_MACHINE\SOFTWARE\TEST - valueName: Test2 - valueData: { String: Hello World! } -- name: EnsureTest1Value - type: Microsoft.Windows/Registry - properties: - keyPath: HKEY_LOCAL_MACHINE\SOFTWARE\TEST - valueName: Test - _exist: false -``` - -```sh -dsc lint --file ./demo.dsc.yaml -``` - -```yaml -diagnostics: -- level: error - rule: duplicate-resource - category: document - message: Resource 'EnsureTest1Value' of type 'Microsoft.Windows/Registry' is declared more than once - path: /resources/2 - file: ./demo.dsc.yaml -summary: - errors: 1 - warnings: 0 - info: 0 -valid: false -discovery: true -``` - -A pipeline that lints on a build agent without the resources installed skips discovery and fails -on warnings as well as errors: - -```sh -dsc lint --file ./demo.dsc.yaml --no-discovery --fail-on warning -``` - -The rule from [#591][01] fires when `--system-root` is given and no expression calls -`systemRoot()`: - -```sh -dsc lint --file ./demo.dsc.yaml --system-root D:\ -``` - -```yaml -diagnostics: -- level: warning - rule: system-root-unused - category: environment - message: A system root of 'D:\' was specified, but no expression calls systemRoot(), so the document doesn't target it - path: '' - file: ./demo.dsc.yaml -``` - -Rule levels can be changed per run or in the settings file, and the rules can be listed: - -```sh -dsc lint --file ./demo.dsc.yaml --rule no-unused-parameters=off --rule missing-require-version=warning -dsc lint --list-rules -``` - -```json -{ - "lint": { - "rules": { - "no-unused-parameters": { "level": "off" } - } - } -} -``` - -## Specification - -### Command - -```text -dsc lint [OPTIONS] (--file ... | --input ) -dsc lint --list-rules [--output-format ] -``` - -| Option | Description | -| :---------------------------- | :--------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `-f, --file ` | A document to lint. `-` reads stdin. The option can be repeated; the result then carries the file name on each diagnostic. | -| `-i, --input ` | The document as a string. Mutually exclusive with `--file`. | -| `-p, --parameters ` | Parameter values as JSON or YAML, in either format `dsc config` accepts. Enables the rules that check supplied values. | -| `--parameters-file ` | Parameter values from a file; `--parameters` takes precedence over it. | -| `-r, --system-root ` | The system root the document would be applied with. Enables `system-root-unused`. The path isn't required to exist. | -| `--no-discovery` | Skip resource discovery and the rules that need it. | -| `--fail-on ` | The lowest level that makes the command exit with code `5`: `error` (default), `warning`, or `info`. | -| `--rule =` | Override the level of a rule for this run (`off`, `info`, `warning`, `error`). Repeatable. An unknown rule is an argument error. | -| `--list-rules` | List the rules with the level each reports at in this run instead of linting. | -| `-o, --output-format `| `json`, `pretty-json`, or `yaml`, with the same defaults as the other commands. | - -The `-p` short option is bound to `--parameters` as it is for `dsc config`; the global -`--progress-format` option keeps its `-p` short form because it is only accepted before the -subcommand. - -### Document kind - -The command reads the `$schema` property and matches it against the recognized URIs of the -configuration document, resource manifest, and extension manifest schemas. In the first phase a -manifest is reported with the fixed `unsupported-document-type` rule. A document without a -recognized `$schema` is treated as a configuration document, so the schema rule reports the -missing or unrecognized URI. - -### Pipeline - -1. Parse the text as JSON, then as YAML. A failure is the fixed `parse-error` diagnostic at the - document root, with the parser's message and, when available, the line and column. Input that - starts with `{` is reported with the JSON parser's message so the message matches what the - author wrote. -1. Validate the parsed value against the bundled configuration document schema and report every - violation with the fixed `schema-invalid` rule at the violation's instance path. This is where - unknown properties, a missing `$schema`, and malformed `dependsOn` entries are reported. -1. Deserialize the value into the typed configuration. When that fails and no schema violation - explains why, report the deserialization message under `schema-invalid`. Stop when the typed - document isn't available. -1. Index every expression once. Strings that start with `[` in parameter defaults, variables, - outputs, user-defined function bodies, resource names, conditions, `dependsOn` entries, copy - counts, and properties are parsed with the expression grammar without being evaluated. Each - indexed expression records its JSON pointer and its scope (resource, parameter, variable, - output, or user-defined function). -1. Run the static rules. -1. Unless discovery is disabled, or the document sets `directives.resourceDiscovery` to - `duringDeployment`, discover the resource types the document uses with the same filters - `dsc config` builds (type, `requireVersion`, and `directives.requireAdapter`) and run the - resource rules. A discovery failure is the fixed `discovery-failed` warning and the resource - rules are skipped. -1. Sort the diagnostics by file, path, and rule, count them by level, and set `valid` to whether - there are no errors. - -The linter never calls a resource's `get`, `set`, `test`, `export`, `validate`, or `schema` -operation. Property validation only uses the schema embedded in the manifest or loaded by -discovery. - -### Levels and precedence - -Every rule has a default level: `error`, `warning`, `info`, or `off`. The effective level for a -run is resolved in this order, lowest precedence first: - -1. The rule's default. -1. The `lint.rules..level` setting in `dsc_default.settings.json` and `dsc.settings.json`. -1. The `--rule` options. -1. The `lint.rules..level` setting in the policy settings file, which wins so that an - administrator can enforce a rule. - -Unknown rules in the settings files are logged and ignored so a stale setting can't break every -run. The fixed rules `parse-error`, `schema-invalid`, `unsupported-document-type`, and -`discovery-failed` ignore overrides with a logged warning. - -### Exit codes - -| Code | Meaning | -| :--: | :-------------------------------------------------------------------------------------- | -| `0` | No diagnostic at or above the `--fail-on` level. | -| `1` | Invalid arguments, such as an unknown rule, a malformed `--rule` value, or no input. | -| `2` | Another failure, such as an unreadable `lint` settings section. | -| `4` | A document file or the parameters couldn't be read, or the parameters are malformed. | -| `5` | At least one diagnostic at or above the `--fail-on` level. | - -A document that doesn't parse or doesn't validate against the schema still produces the full -result object on stdout and exits with code `5`, so a pipeline always has structured output to -show. - -### Output - -The result object is published as the `lint-result` schema (`outputs/lint/result.json`): - -```json -{ - "diagnostics": [ - { - "level": "error", - "rule": "duplicate-resource", - "category": "document", - "message": "Resource 'EnsureTest1Value' of type 'Microsoft.Windows/Registry' is declared more than once", - "path": "/resources/2", - "file": "./demo.dsc.yaml", - "location": { "line": 14, "column": 3 } - } - ], - "summary": { "errors": 1, "warnings": 0, "info": 0 }, - "valid": false, - "discovery": true -} -``` - -- `path` is an [RFC 6901][03] JSON pointer into the parsed document. The empty string is the - document root. -- `file` is present when the document was read from a file. -- `location` is present when the position in the source text is known. In the first phase only - `parse-error` carries it, because the YAML and JSON parsers DSC uses don't keep positions for - values. The field is part of the schema from the start so that adding positions later isn't a - breaking change. -- `valid` is independent of `--fail-on`. -- `discovery` records whether the resource rules ran. - -`dsc lint --list-rules` emits an array of objects published as the `lint-rules` schema -(`outputs/lint/rules.json`) with the properties `id`, `category`, `tier` (`static` or -`resource`), `defaultLevel`, `level`, `fixed`, and `description`. - -### Rules for configuration documents - -Identifiers are kebab-case and follow the Bicep linter's vocabulary where the concept is shared, -so that `no-unused-parameters` and `secure-parameter-default` mean the same thing to a Bicep user. - -#### Document - -| Rule | Default | Checks | -| :------------------------------- | :------ | :------------------------------------------------------------------------------------------------------------------------------------------------ | -| `parse-error` | error | Fixed. The text is valid JSON or YAML. | -| `schema-invalid` | error | Fixed. Every violation of the configuration document schema, with its path. | -| `unsupported-document-type` | error | Fixed. The `$schema` isn't a manifest schema (first phase). | -| `duplicate-resource` | error | No two sibling resources share a name and a type (case-insensitive). DSC refuses such documents at runtime ([#842][02]). | -| `duplicate-resource-name` | warning | No two sibling resources of different types share a name; dependency resolution looks resources up by name first. | -| `empty-resources` | warning | The document declares at least one resource. | -| `unknown-microsoft-dsc-metadata` | warning | Keys under `Microsoft.DSC` metadata are ones DSC recognizes; unknown keys are silently dropped today. | -| `api-version-alias` | info | `requireVersion` is used rather than its alias `apiVersion`. | -| `date-version` | warning | `requireVersion` isn't a deprecated date-based version. | -| `missing-require-version` | off | Every resource pins a version with `requireVersion`. | - -#### Expression - -| Rule | Default | Checks | -| :------------------------- | :------ | :------------------------------------------------------------------------------------------------------------------------------------------- | -| `invalid-expression` | error | Every string that starts with `[` (but not `[[`) parses with the expression grammar. | -| `unknown-function` | error | Every call names a built-in function (case-sensitive, with a casing suggestion) or a user-defined function declared in `functions`. | -| `invalid-argument-count` | error | Every call passes a number of arguments within the function's accepted range, using the function registry's metadata and runtime messages. | -| `invalid-argument-type` | error | Every literal argument is of a kind the function accepts at that position; nested expressions aren't checked. | -| `undefined-parameter` | error | `parameters('x')` names a declared parameter, except inside user-defined function bodies. | -| `undefined-variable` | error | `variables('x')` names a declared variable. | -| `condition-not-expression` | warning | A `condition` is an expression; a plain string never evaluates to `true`, so the item is always skipped. | - -#### Parameter - -| Rule | Default | Checks | -| :------------------------------ | :------ | :------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `no-unused-parameters` | warning | Every declared parameter is referenced. | -| `no-unused-variables` | warning | Every declared variable is referenced. | -| `parameter-default-type` | error | A literal default satisfies the type, `allowedValues`, value limits, and length limits, reusing the runtime constraint checks. | -| `invalid-parameter-constraints` | error | Constraints are consistent: `minValue <= maxValue`, `minLength <= maxLength`, value limits only on `int`, length limits only on string and array types, non-empty `allowedValues` of the right type. | -| `secure-parameter-default` | warning | Secure parameters have no hard-coded default (an empty string or an expression is allowed). | -| `prefer-secure-type` | warning | A `string` or `object` parameter whose name suggests a secret uses a secure type. | -| `secure-value-in-output` | warning | Outputs don't expose a secure parameter or `secret()`; DSC skips such outputs at runtime. | -| `use-parameter-description` | off | Every parameter has a description. | -| `undeclared-parameter-value` | error | With supplied parameters: every supplied value matches a declared parameter. | -| `missing-parameter-value` | error | With supplied parameters: every parameter without a default has a value. | -| `parameter-value-type` | error | With supplied parameters: every value satisfies the type and constraints. | - -#### Dependency - -| Rule | Default | Checks | -| :----------------------------- | :------ | :----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `unresolved-dependency` | error | Every literal `dependsOn` entry and `reference(resourceId(...))` call points at a sibling resource; a name with the wrong type gets its own message. Copy loops are skipped. | -| `circular-dependency` | error | The `dependsOn` graph of each sibling group has no cycle, including self-dependencies; the message shows the cycle. | -| `reference-without-depends-on` | warning | A resource that calls `reference()` lists the referenced resource in `dependsOn`, because `reference()` only sees resources that already ran. | -| `duplicate-depends-on` | info | No dependency is listed twice. | - -#### Deprecation - -| Rule | Default | Checks | -| :----------------------------- | :------ | :-------------------------------------------------------------------------------------------------- | -| `copy-loop-deprecated` | warning | No resource or output uses a copy loop ([#1429][04]). | -| `copy-options-unsupported` | error | No copy loop sets `mode` or `batchSize`, which DSC rejects. | -| `security-context-in-metadata` | warning | The security context is set with `directives.securityContext`, not `Microsoft.DSC` metadata. | -| `security-context-conflict` | error | The metadata and directive security contexts don't contradict each other. | -| `dsc-version-unsatisfied` | warning | `directives.version` is satisfied by the version of `dsc` running the linter. | - -#### Environment - -| Rule | Default | Checks | -| :---------------------- | :------ | :------------------------------------------------------------------------------------------------ | -| `system-root-unused` | warning | With `--system-root`: some expression calls `systemRoot()` ([#591][01]). | -| `hardcoded-system-path` | off | Property values aren't absolute operating system paths that `systemRoot()` should build instead. | - -#### Resource (needs discovery) - -| Rule | Default | Checks | -| :----------------------------- | :------ | :------------------------------------------------------------------------------------------------------------------------------------------ | -| `discovery-failed` | warning | Fixed. Discovery succeeded; otherwise the resource rules are skipped. | -| `unknown-resource-type` | warning | Every resource type is installed. A warning, because the document may be valid on the machine it targets. | -| `unsatisfied-require-version` | warning | An installed version satisfies `requireVersion`. | -| `deprecated-resource` | warning | No resource type carries a `deprecationMessage`. | -| `property-schema-mismatch` | error | Literal properties validate against the embedded resource schema; violations under properties that hold expressions are dropped. | -| `nested-resources-unsupported` | warning | Only group and adapter resources declare nested `resources`. | -| `exist-false-unsupported` | error | A resource with `_exist: false` has the `delete` or `setHandlesExist` capability; otherwise `dsc config set` fails. | -| `canonical-type-name` | info | Resource types are written with the casing of their manifest. | - -### Rules for manifests (second phase) - -The same command, engine, output, and settings apply. The document kind is detected from -`$schema`, and `dsc lint --type resource-manifest` can force it. Proposed rules: - -| Rule | Default | Checks | -| :------------------------ | :------ | :------------------------------------------------------------------------------------------------------- | -| `manifest-schema-invalid` | error | Fixed. Every violation of the manifest schema. | -| `kind-method-mismatch` | error | `adapter` kinds define `adapter`, `importer` kinds define `resolve`, `exporter` kinds define `export`. | -| `missing-schema` | warning | A non-adapter resource defines an embedded schema or a schema command. | -| `embedded-schema-invalid` | error | The embedded schema compiles as a JSON schema. | -| `deprecated-whatif-method`| warning | The deprecated top-level `whatIf` method isn't used. | -| `exit-code-zero-success` | error | Exit code `0` isn't described as a failure. | -| `missing-description` | info | The manifest has a description. | -| `executable-not-found` | warning | Discovery tier: every executable the manifest names resolves on the path. | - -### Library design - -The engine is the `dsc_lib::lint` module: - -- `Linter::new(LintOptions)` resolves the effective rule levels and prepares the expression parser - and the compiled document schema; `Linter::lint(&str)` and `Linter::lint_value(&Value)` run the - pipeline and return a `LintResult`. Problems in the document never surface as `Err`; `Err` is - reserved for engine faults such as an unknown rule or malformed parameters. -- Each rule is a `Rule` value with an identifier, category, tier, default level, localized - description, and a check function that receives a `LintContext` (the raw value, the typed - configuration, the flattened resource sites, the expression index, the options, the function - registry, the supplied parameters, and the discovery results) and reports through a - `DiagnosticSink` that stamps the rule identity and level on every diagnostic. -- The expression parser gains a parse-only entry point (`Statement::parse`) and read access to - the parsed tree, and the function dispatcher gains `validate_arg_count` and - `validate_literal_args`, so the rules use the same metadata and messages as the runtime without - evaluating anything. Schema validation gains `validate_json_all`, which returns every violation - with its instance path instead of only the first. -- The CLI reads the raw document text instead of normalizing it to JSON first, so syntax errors - become diagnostics rather than an early exit. - -### Relationship to `dsc config validate` - -The hidden `validate` subcommand keeps its current contract for the Group, Assertion, and Include -resources. Once `dsc lint` is in place, `validate` can delegate to the engine's fixed rules and -the resource tier, which gives it the duplicate-resource check from [#842][02] and real reasons, -without changing its output shape. That change is out of scope for this RFC. - -## Alternate Proposals and Considerations - -- **`dsc config lint` instead of `dsc lint`.** A verb under `config` matches the noun-verb grammar - of the other commands and inherits the group's `--parameters` and `--system-root` options. - Manifests would then need `dsc resource lint` and `dsc extension lint`. A single top-level - command was chosen because it mirrors `bicep lint`, matches the original proposal in - [#591][01], gives one entry point for every document kind, and accepts several files in one run. -- **Growing `dsc config validate`.** [#842][02] suggested a `--schema-only` flag for the internal - callers and static analysis in `validate`. That mixes a pass-or-fail contract used by three - resources with a diagnostics contract meant for people and pipelines. Keeping `validate` as is - and building `lint` next to it avoids a breaking change for the resources. -- **Linting as an extension.** Extensions today provide discovery, import, and secrets, and run as - separate processes. The rules need the parser, function registry, schema, and discovery, which - are internal to `dsc-lib`, and editors want to lint in process. Extension-contributed rules - remain possible later through a new capability. -- **Relying on Bicep's linter.** Bicep can add DSC-specific rules for Bicep authors, but it - doesn't help the YAML and JSON authoring community, and the compiled JSON can still be checked - with `dsc lint`. -- **Discovery off by default.** A purely static default is faster and environment-independent, - but `bicep lint` restores modules by default and the hidden `validate` discovers today. Discovery - is on by default with `--no-discovery` as the escape hatch, and the discovery-dependent rules - report warnings rather than errors so a build agent without resources still passes. -- **Source positions.** The YAML and JSON parsers DSC uses don't keep positions for values, so - diagnostics carry JSON pointers and only parse errors carry a line and column. A later pass with - a marker-emitting YAML parser can fill `location` for every diagnostic without a schema change. -- **Future output formats.** SARIF output for code-scanning integrations and inline suppression in - the document are natural follow-ups once the rule catalog settles. - -## Related work items - -- [#591][01] - Proposes `dsc lint` and the `systemRoot()` rule; discusses extension versus core - and linting manifests. -- [#842][02] - Asks `dsc config validate` to reject duplicate resource names. -- [#1429][04] - Deprecates copy loops, which the `copy-loop-deprecated` rule reports. - -[01]: https://github.com/PowerShell/DSC/issues/591 -[02]: https://github.com/PowerShell/DSC/issues/842 -[03]: https://www.rfc-editor.org/rfc/rfc6901 -[04]: https://github.com/PowerShell/DSC/issues/1429 From a7757ed01a73313fe2f55e2d2e51e6d2afbe36cb Mon Sep 17 00:00:00 2001 From: "G.Reijn" <26114636+Gijsreyn@users.noreply.github.com> Date: Fri, 23 Oct 2026 12:08:36 +0200 Subject: [PATCH 5/6] Rewrite in dsclint command --- .github/instructions/instructions.md | 5 +- .github/workflows/rust.yml | 6 +- CHANGELOG.md | 26 +- Cargo.lock | 17 + Cargo.toml | 23 +- build.ps1 | 2 +- data.build.json | 12 + docs/reference/cli/index.md | 8 - docs/reference/schemas/outputs/lint/result.md | 14 +- docs/reference/schemas/outputs/lint/rules.md | 15 +- docs/reference/tools/builtin.md | 21 +- .../{cli/lint => tools/dsclint}/index.md | 166 ++++-- .../{cli/lint => tools/dsclint}/rules.md | 12 +- dsc/locales/en-us.toml | 17 - dsc/src/args.rs | 24 - dsc/src/main.rs | 15 - dsc/src/report.rs | 62 +-- dsc/src/subcommand.rs | 167 +------ dsc/tests/dsc_schema.tests.ps1 | 11 + dsclint/.project.data.json | 8 + dsclint/Cargo.toml | 24 + dsclint/build.rs | 7 + dsclint/locales/en-us.toml | 59 +++ dsclint/src/args.rs | 59 +++ dsclint/src/lint.rs | 196 ++++++++ dsclint/src/main.rs | 69 +++ dsclint/src/report.rs | 95 ++++ dsclint/src/tablewriter.rs | 98 ++++ dsclint/src/util.rs | 471 ++++++++++++++++++ .../tests/dsclint.tests.ps1 | 61 +-- helpers.build.psm1 | 5 +- lib/dsc-lib/locales/schemas.outputs.yaml | 12 +- packaging/msix/AppxManifest.xml | 11 + packaging/rpm/dsc.spec | 2 + 34 files changed, 1384 insertions(+), 416 deletions(-) rename docs/reference/{cli/lint => tools/dsclint}/index.md (65%) rename docs/reference/{cli/lint => tools/dsclint}/rules.md (97%) create mode 100644 dsclint/.project.data.json create mode 100644 dsclint/Cargo.toml create mode 100644 dsclint/build.rs create mode 100644 dsclint/locales/en-us.toml create mode 100644 dsclint/src/args.rs create mode 100644 dsclint/src/lint.rs create mode 100644 dsclint/src/main.rs create mode 100644 dsclint/src/report.rs create mode 100644 dsclint/src/tablewriter.rs create mode 100644 dsclint/src/util.rs rename dsc/tests/dsc_lint.tests.ps1 => dsclint/tests/dsclint.tests.ps1 (77%) diff --git a/.github/instructions/instructions.md b/.github/instructions/instructions.md index 071e813b9..689d368bc 100644 --- a/.github/instructions/instructions.md +++ b/.github/instructions/instructions.md @@ -199,6 +199,9 @@ Pester tests are organized into groups: # Run tests for DSC CLI ./build.ps1 -SkipBuild -Test -ExcludeRustTests -PesterTestGroup dsc +# Run tests for the dsclint CLI +./build.ps1 -SkipBuild -Test -ExcludeRustTests -PesterTestGroup dsclint + # Run tests for adapters ./build.ps1 -SkipBuild -Test -ExcludeRustTests -PesterTestGroup adapters @@ -413,7 +416,7 @@ The build script automatically installs these. If issues persist: | `-Project` | String[] | Build specific projects only | | `-ExcludeRustTests` | Switch | Skip Rust tests (use with -Test) | | `-ExcludePesterTests` | Switch | Skip Pester tests (use with -Test) | -| `-PesterTestGroup` | String[] | Run specific Pester test groups (dsc, adapters, extensions, grammars, resources) | +| `-PesterTestGroup` | String[] | Run specific Pester test groups (dsc, dsclint, adapters, extensions, grammars, resources) | | `-SkipLinkCheck` | Switch | Skip Windows C++ link tools check | | `-UseX64MakeAppx` | Switch | Use x64 version of MakeAppx.exe | | `-UseCFS` | Switch | Use Component Framework Service | diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index 1bfe045df..2a71e5973 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -73,7 +73,7 @@ jobs: strategy: fail-fast: false matrix: - group: [dsc, adapters, extensions, resources] + group: [dsc, dsclint, adapters, extensions, resources] runs-on: ubuntu-latest steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -157,7 +157,7 @@ jobs: strategy: fail-fast: false matrix: - group: [dsc, adapters, extensions, resources] + group: [dsc, dsclint, adapters, extensions, resources] runs-on: macos-latest steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -247,7 +247,7 @@ jobs: strategy: fail-fast: false matrix: - group: [dsc, adapters, extensions, resources] + group: [dsc, dsclint, adapters, extensions, resources] runs-on: windows-latest steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 diff --git a/CHANGELOG.md b/CHANGELOG.md index 8ae61a517..470db14fb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -77,19 +77,19 @@ changes since the last release, see the [diff on GitHub][unreleased]. ### Added -- Added the `dsc lint` command, which checks a configuration document for problems without - applying it. The command validates the document against the configuration document schema and - runs a set of rules that report duplicate resource names, unresolved and circular dependencies, - expressions that don't parse or call unknown functions, undeclared or unused parameters and - variables, invalid parameter constraints, deprecated features, and, with resource discovery, - resource types that aren't installed or literal properties that don't match a resource's schema. - You can change the level of a rule for a run with the `--rule` option or for every run in the - new `lint` section of the settings file, and control which level makes the command fail with - `--fail-on`. The `dsc schema` command exposes the new `lint-result` and `lint-rules` schemas, - and the MCP server exposes the linter as the `lint_dsc_config` tool. Every diagnostic records - the line and column of the problem, and when the output isn't redirected the command renders - each diagnostic as a report that shows the problem in the document text with the rule's - identifier and what the rule requires. +- Added the `dsclint` command, a standalone executable that ships with DSC and checks a + configuration document for problems without applying it. The command validates the document + against the configuration document schema and runs a set of rules that report duplicate resource + names, unresolved and circular dependencies, expressions that don't parse or call unknown + functions, undeclared or unused parameters and variables, invalid parameter constraints, + deprecated features, and, with resource discovery, resource types that aren't installed or + literal properties that don't match a resource's schema. You can change the level of a rule for + a run with the `--rule` option or for every run in the new `lint` section of the settings file, + and control which level makes the command fail with `--fail-on`. The `dsc schema` command + exposes the new `lint-result` and `lint-rules` schemas, and the MCP server exposes the linter as + the `lint_dsc_config` tool. Every diagnostic records the line and column of the problem, and + when the output isn't redirected the command renders each diagnostic as a report that shows the + problem in the document text with the rule's identifier and what the rule requires.
Related work items diff --git a/Cargo.lock b/Cargo.lock index ddf924905..0ac368933 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1069,6 +1069,23 @@ dependencies = [ "windows 0.62.2", ] +[[package]] +name = "dsclint" +version = "3.4.0-preview.2" +dependencies = [ + "clap", + "crossterm", + "dsc-lib", + "dsc-lib-telemetry", + "miette", + "rust-i18n", + "serde", + "serde_json", + "serde_yaml", + "syntect", + "tracing", +] + [[package]] name = "dsctest" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index d7c730833..6ea40d2ad 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,6 +5,7 @@ resolver = "2" members = [ "dsc", "dsc-bicep-ext", + "dsclint", "grammars/tree-sitter-dscexpression", "grammars/tree-sitter-ssh-server-config", "lib/dsc-lib-jsonschema-macros", @@ -40,6 +41,7 @@ members = [ default-members = [ "dsc", "dsc-bicep-ext", + "dsclint", "grammars/tree-sitter-dscexpression", "grammars/tree-sitter-ssh-server-config", "lib/dsc-lib-jsonschema-macros", @@ -76,6 +78,7 @@ default-members = [ Windows = [ "dsc", "dsc-bicep-ext", + "dsclint", "grammars/tree-sitter-dscexpression", "grammars/tree-sitter-ssh-server-config", "lib/dsc-lib-jsonschema-macros", @@ -107,6 +110,7 @@ Windows = [ macOS = [ "dsc", "dsc-bicep-ext", + "dsclint", "grammars/tree-sitter-dscexpression", "grammars/tree-sitter-ssh-server-config", "lib/dsc-lib-jsonschema-macros", @@ -129,6 +133,7 @@ macOS = [ Linux = [ "dsc", "dsc-bicep-ext", + "dsclint", "grammars/tree-sitter-dscexpression", "grammars/tree-sitter-ssh-server-config", "lib/dsc-lib-jsonschema-macros", @@ -170,13 +175,13 @@ base64 = { version = "0.23.1" } bytes = { version = "1.12.1" } # dsc-lib, sshdconfig chrono = { version = "0.4.45" } -# dsc, dsc-lib, dsc-bicep-ext, dscecho, registry, runcommandonset, sshdconfig, dsctest, test_group_resource +# dsc, dsc-lib, dsc-bicep-ext, dsclint, dscecho, registry, runcommandonset, sshdconfig, dsctest, test_group_resource clap = { version = "4.6.7", features = ["derive"] } # dsc clap_complete = { version = "4.6.11" } # dsc-lib const-str = {version = "1.1" } -# dsc, registry, dsc-lib-registry, sshdconfig +# dsc, dsclint, registry, dsc-lib-registry, sshdconfig crossterm = { version = "0.29" } # dsc ctrlc = { version = "3.5.2" } @@ -196,7 +201,7 @@ jsonschema = { version = "0.58.6", default-features = false } referencing = { version = "0.58.6" } # dsc-lib linked-hash-map = { version = "0.5" } -# dsc, dsc-lib +# dsc, dsc-lib, dsclint miette = { version = "7.6.0" } # dsc-lib murmurhash64 = { version = "0.3" } @@ -228,7 +233,7 @@ rmcp = { version = "3.5.1" } roxmltree = { version = "0.21.1" } # dsc_lib rt-format = { version = "0.3" } -# dsc, dsc-lib, dsc-bicep-ext, dscecho, registry, dsc-lib-registry, runcommandonset, sshdconfig +# dsc, dsc-lib, dsc-bicep-ext, dsclint, dscecho, registry, dsc-lib-registry, runcommandonset, sshdconfig rust-i18n = { version = "4.2.4" } # dsc-lib saphyr-parser = { version = "0.2.2" } @@ -238,17 +243,17 @@ schemars = { version = "1.2.2", features = ["preserve_order"] } semver = { version = "1.0.28" } # dsc-lib serial_test = { version = "4.0.1" } -# dsc, dsc-lib, dscecho, dsc-lib-osinfo, process, registry, dsc-lib-registry, runcommandonset, sshdconfig, dsctest, test_group_resource +# dsc, dsc-lib, dsclint, dscecho, dsc-lib-osinfo, process, registry, dsc-lib-registry, runcommandonset, sshdconfig, dsctest, test_group_resource serde = { version = "1.0.229", features = ["derive"] } -# dsc, dsc-lib, dscecho, dsc-lib-osinfo, osinfo, process, registry, dsc-lib-registry, runcommandonset, sshdconfig, dsctest, test_group_resource, y2j +# dsc, dsc-lib, dsclint, dscecho, dsc-lib-osinfo, osinfo, process, registry, dsc-lib-registry, runcommandonset, sshdconfig, dsctest, test_group_resource, y2j serde_json = { version = "1.0.151", features = ["preserve_order"] } # file_dsc sha2 = { version = "0.11.0" } -# dsc, dsc-lib, y2j +# dsc, dsc-lib, dsclint, y2j serde_yaml = { version = "0.9" } # dsc-lib-jsonschema-macros syn = { version = "3.0.6" } -# dsc, y2j +# dsc, dsclint, y2j syntect = { version = "5.3", features = ["default-fancy"], default-features = false } # dsc, process sysinfo = { version = "0.39.6" } @@ -270,7 +275,7 @@ tonic = { version = "0.14.6" } tonic-prost = { version = "0.14.6" } # dsc-bicep-ext tonic-reflection = { version = "0.14.6" } -# dsc, dsc-lib, dsc-bicep-ext, registry, dsc-lib-registry, runcommandonset, sshdconfig +# dsc, dsc-lib, dsc-bicep-ext, dsclint, registry, dsc-lib-registry, runcommandonset, sshdconfig tracing = { version = "0.1.44" } # dsc, dsc-lib tracing-indicatif = { version = "0.3.14" } diff --git a/build.ps1 b/build.ps1 index 2ee850d14..5825c97d6 100755 --- a/build.ps1 +++ b/build.ps1 @@ -141,7 +141,7 @@ param( [switch]$ExcludeRustTests, [string]$RustTestFilter, [switch]$ExcludePesterTests, - [ValidateSet("dsc", "adapters", "extensions", "grammars", "resources")] + [ValidateSet("dsc", "dsclint", "adapters", "extensions", "grammars", "resources")] [string[]]$PesterTestGroup, [switch]$GetPackageVersion, [switch]$SkipLinkCheck, diff --git a/data.build.json b/data.build.json index a91dc0240..2f6a51a9a 100644 --- a/data.build.json +++ b/data.build.json @@ -9,6 +9,7 @@ "dsc_default.settings.json", "dsc.settings.json", "dsc-bicep-ext", + "dsclint", "dscecho", "echo.dsc.resource.json", "file_dsc", @@ -41,6 +42,7 @@ "dsc_default.settings.json", "dsc.settings.json", "dsc-bicep-ext", + "dsclint", "dscecho", "echo.dsc.resource.json", "file_dsc", @@ -76,6 +78,7 @@ "dsc_default.settings.json", "dsc.settings.json", "dsc-bicep-ext.exe", + "dsclint.exe", "dscecho.exe", "echo.dsc.resource.json", "file_dsc.exe", @@ -219,6 +222,15 @@ "dsc-bicep-ext" ] }, + { + "Name": "dsclint", + "Kind": "CLI", + "RelativePath": "dsclint", + "IsRust": true, + "Binaries": [ + "dsclint" + ] + }, { "Name": "extensions/appx", "Kind": "Extension", diff --git a/docs/reference/cli/index.md b/docs/reference/cli/index.md index ca243e89c..f0823aa37 100644 --- a/docs/reference/cli/index.md +++ b/docs/reference/cli/index.md @@ -42,13 +42,6 @@ The `function` command manages DSC functions. You can use it to: For more information, see [dsc function][03] -### lint - -The `lint` command checks a configuration document for problems without applying it. It reports -duplicate resources, invalid expressions, unresolved dependencies, invalid parameters, deprecated -features, and, with resource discovery, resource types that aren't installed. For more -information, see [dsc lint][07]. - ### mcp The `mcp` command starts DSC as a Model Context Protocol (MCP) server. For more information, see @@ -259,4 +252,3 @@ file, ensure that the input is encoded as UTF-8. [04]: server/index.md [05]: resource/index.md [06]: schema/index.md -[07]: lint/index.md diff --git a/docs/reference/schemas/outputs/lint/result.md b/docs/reference/schemas/outputs/lint/result.md index 5f10ddda1..d886f65af 100644 --- a/docs/reference/schemas/outputs/lint/result.md +++ b/docs/reference/schemas/outputs/lint/result.md @@ -1,15 +1,15 @@ --- -description: JSON schema reference for the data returned by the 'dsc lint' command. -ms.date: 10/20/2026 +description: JSON schema reference for the data returned by the 'dsclint' command and the 'lint_dsc_config' MCP tool. +ms.date: 10/23/2026 ms.topic: reference -title: dsc lint result schema reference +title: dsclint result schema reference --- -# dsc lint result schema reference +# dsclint result schema reference ## Synopsis -The result output from the `dsc lint` command. +The result output from the `dsclint` command and the `lint_dsc_config` tool of the DSC MCP server. ## Metadata @@ -21,10 +21,10 @@ Type: object ## Description -The output from the `dsc lint` command includes every diagnostic the rules reported, counts of the +The output from the `dsclint` command includes every diagnostic the rules reported, counts of the diagnostics by level, whether the document is free of error-level diagnostics, and whether resource discovery ran. The command always emits this object, including when the document doesn't -parse. +parse. The `dsc schema --type lint-result` command returns this schema. ## Required properties diff --git a/docs/reference/schemas/outputs/lint/rules.md b/docs/reference/schemas/outputs/lint/rules.md index 649e1c819..72e5a4e6b 100644 --- a/docs/reference/schemas/outputs/lint/rules.md +++ b/docs/reference/schemas/outputs/lint/rules.md @@ -1,15 +1,15 @@ --- -description: JSON schema reference for the data returned by the 'dsc lint --list-rules' command. -ms.date: 10/20/2026 +description: JSON schema reference for the data returned by the 'dsclint --list-rules' command. +ms.date: 10/23/2026 ms.topic: reference -title: dsc lint rules schema reference +title: dsclint rules schema reference --- -# dsc lint rules schema reference +# dsclint rules schema reference ## Synopsis -The output from the `dsc lint --list-rules` command. +The output from the `dsclint --list-rules` command. ## Metadata @@ -21,9 +21,10 @@ Type: object ## Description -The `dsc lint --list-rules` command returns an array of objects with this schema, one per rule, +The `dsclint --list-rules` command returns an array of objects with this schema, one per rule, sorted by rule identifier. Each object describes the rule and the level it reports at after the -settings file and any `--rule` overrides are applied. +settings file and any `--rule` overrides are applied. The `dsc schema --type lint-rules` command +returns this schema. ## Required properties diff --git a/docs/reference/tools/builtin.md b/docs/reference/tools/builtin.md index 0612dad88..4d1b57619 100644 --- a/docs/reference/tools/builtin.md +++ b/docs/reference/tools/builtin.md @@ -2,7 +2,7 @@ description: >- Lists the tools that ship with DSC, their purpose, and links to the reference documentation for each tool. -ms.date: 03/25/2025 +ms.date: 10/23/2026 ms.topic: reference title: osinfo --- @@ -15,10 +15,20 @@ feedback and functionality. The following table describes the tools included in current releases of DSC and the platforms those tools are available for: -| Executable | Platforms | Description | -|:-----------|:----------------------|:--------------------------------------------------------| -| `osinfo` | Linux, macOS, Windows | Returns information about the operating system as JSON. | -| `registry` | Windows | Manages registry keys and values. | +| Executable | Platforms | Description | +|:-----------|:----------------------|:-------------------------------------------------------------------| +| `dsclint` | Linux, macOS, Windows | Checks configuration documents for problems without applying them. | +| `osinfo` | Linux, macOS, Windows | Returns information about the operating system as JSON. | +| `registry` | Windows | Manages registry keys and values. | + +## dsclint + +The `dsclint` command checks a DSC configuration document for problems without applying it. It +reports duplicate resources, invalid expressions, unresolved dependencies, invalid parameters, +deprecated features, and, with resource discovery, resource types that aren't installed. `dsclint` +is available in the DSC release for all supported platforms. + +For more information about the command, see [dsclint command reference][05]. ## osinfo @@ -43,3 +53,4 @@ more information about the resource, see [Microsoft.Windows/Registry][04]. [02]: ../resources/Microsoft/OSInfo/index.md [03]: ./registry/index.md [04]: ../resources/Microsoft/Windows/Registry/index.md +[05]: ./dsclint/index.md diff --git a/docs/reference/cli/lint/index.md b/docs/reference/tools/dsclint/index.md similarity index 65% rename from docs/reference/cli/lint/index.md rename to docs/reference/tools/dsclint/index.md index 957ab2076..78d8673a8 100644 --- a/docs/reference/cli/lint/index.md +++ b/docs/reference/tools/dsclint/index.md @@ -1,11 +1,11 @@ --- -description: Command line reference for the 'dsc lint' command -ms.date: 10/20/2026 +description: Command line reference for the 'dsclint' command +ms.date: 10/23/2026 ms.topic: reference -title: dsc lint +title: dsclint command reference --- -# dsc lint +# dsclint command reference ## Synopsis @@ -16,37 +16,39 @@ Checks a configuration document for problems without applying it. ### Configuration document from file ```sh -dsc lint [Options] --file +dsclint [Options] --file ``` ### Configuration document from option string ```sh -dsc lint [Options] --input +dsclint [Options] --input ``` ### Configuration document from stdin ```sh -cat | dsc lint [Options] --file - +cat | dsclint [Options] --file - ``` ### Several configuration documents ```sh -dsc lint [Options] --file --file +dsclint [Options] --file --file ``` ### List the rules ```sh -dsc lint --list-rules [--output-format ] +dsclint --list-rules [--output-format ] ``` ## Description -The `lint` command analyzes a configuration document and reports problems as diagnostics. It never -invokes a resource operation, so it's safe to run on any machine, including CI agents. +The `dsclint` command analyzes a configuration document and reports problems as diagnostics. It +never invokes a resource operation, so it's safe to run on any machine, including CI agents. +`dsclint` ships with DSC and is installed alongside the `dsc` command. The same linter is available +to agents as the `lint_dsc_config` tool of the [DSC MCP server][05]. The command works in stages: @@ -68,7 +70,7 @@ informational diagnostics instead. Use [--rule](#--rule) or the `lint` section o file to change the level of a rule or turn it off. A few rules have a fixed level that can't be changed: `parse-error`, `schema-invalid`, `unsupported-document-type`, and `discovery-failed`. -For the list of rules and what each one checks, see [dsc lint rules][01]. +For the list of rules and what each one checks, see [dsclint rules][01]. ## Examples @@ -94,7 +96,7 @@ resources: ``` ```sh -dsc lint --file ./example.dsc.config.yaml +dsclint --file ./example.dsc.config.yaml ``` ```text @@ -118,7 +120,7 @@ When the output is redirected, or when you specify [--output-format](#--output-f command emits the result as data instead: ```sh -dsc lint --file ./example.dsc.config.yaml --output-format yaml +dsclint --file ./example.dsc.config.yaml --output-format yaml ``` ```yaml @@ -149,7 +151,7 @@ an unused parameter as well as on an error. Discovery is skipped because the bui have the resources installed. ```sh -dsc lint --file ./example.dsc.config.yaml --no-discovery --fail-on warning +dsclint --file ./example.dsc.config.yaml --no-discovery --fail-on warning ``` ### Example 3 - Change the level of a rule @@ -160,7 +162,7 @@ The command turns off the `no-unused-parameters` rule and raises `missing-requir is off by default, to a warning. ```sh -dsc lint --file ./example.dsc.config.yaml --rule no-unused-parameters=off --rule missing-require-version=warning +dsclint --file ./example.dsc.config.yaml --rule no-unused-parameters=off --rule missing-require-version=warning ``` ### Example 4 - Check a document that targets another system root @@ -171,7 +173,7 @@ When you pass `--system-root`, the `system-root-unused` rule warns if no express calls the `systemRoot()` function, because the document wouldn't target the specified root. ```sh -dsc lint --file ./example.dsc.config.yaml --system-root D:\ +dsclint --file ./example.dsc.config.yaml --system-root D:\ ``` ### Example 5 - List the rules @@ -182,7 +184,7 @@ The command lists every rule with its category, the level it reports at, whether discovery, and a description. ```sh -dsc lint --list-rules +dsclint --list-rules ``` ## Options @@ -321,7 +323,7 @@ LongSyntax : --rule Lists the rules with the level each reports at in this run instead of linting a document. When the output isn't redirected, the command shows a table; otherwise it emits the list in the format -specified by `--output-format`. For the schema of the list, see [dsc lint rules schema][03]. +specified by `--output-format`. For the schema of the list, see [dsclint rules schema][03]. This option is mutually exclusive with the `--input` and `--file` options. @@ -336,20 +338,20 @@ LongSyntax : --list-rules -The `--output-format` option controls which format DSC uses for the data the command returns. The -available formats are: +The `--output-format` option controls which format `dsclint` uses for the data the command returns. +The available formats are: - `json` to emit the data as a [JSON Line][02]. - `pretty-json` to emit the data as JSON with newlines, indentation, and spaces for readability. - `yaml` to emit the data as YAML. -The default output depends on whether DSC detects that the output is being redirected or captured -as a variable: +The default output depends on whether `dsclint` detects that the output is being redirected or +captured as a variable: -- If the command isn't being redirected or captured, DSC renders each diagnostic in the console as - a report that shows the problem in the document text, with the rule identifier as the code and - what the rule requires as the help, followed by the counts of diagnostics by level. -- If the command output is redirected or captured, DSC emits the data as the `json` format to +- If the command isn't being redirected or captured, `dsclint` renders each diagnostic in the + console as a report that shows the problem in the document text, with the rule identifier as the + code and what the rule requires as the help, followed by the counts of diagnostics by level. +- If the command output is redirected or captured, `dsclint` emits the data as the `json` format to stdout. Specify a format to get the data in the console too. @@ -362,13 +364,107 @@ LongSyntax : --output-format ShortSyntax : -o ``` +### -l, --trace-level + + + + +Defines the minimum message level `dsclint` should emit on stderr during a run. The levels are the +same as for the `dsc` command, from highest to lowest: `error`, `warn` (default), `info`, `debug`, +and `trace`. + +You can also set the trace level with the `DSC_TRACE_LEVEL` environment variable or the `tracing` +section of the DSC settings file. The option takes precedence over both unless the policy settings +file defines the tracing setting. + +```yaml +Type : string +Mandatory : false +DefaultValue : warn +ValidValues : [error, warn, info, debug, trace] +LongSyntax : --trace-level +ShortSyntax : -l +``` + +### -t, --trace-format + + + + +Defines the output format to use when emitting trace messages on stderr: + +- `default` - Emits the message with ANSI console coloring for the timestamp, message level, and + line number. +- `plaintext` - As `default` but without any console colors. +- `json` - Emits each message as a compressed JSON object with the timestamp, level, message, and + line number as properties. + +```yaml +Type : string +Mandatory : false +DefaultValue : default +ValidValues : [default, plaintext, json] +LongSyntax : --trace-format +ShortSyntax : -t +``` + +### --progress-format + + + +Defines the progress format to use when emitting progress messages on stderr during resource +discovery. This option has no short form because `-p` is used by [--parameters](#--parameters). + +- `default` - Shows a progress bar if `dsclint` detects that it's being called interactively. + Otherwise, no progress is shown. +- `none` - Doesn't show any progress. +- `json` - Emits progress as compressed JSON objects with the timestamp, level, message, and line + number as properties. + +```yaml +Type : string +Mandatory : false +DefaultValue : default +ValidValues : [default, none, json] +LongSyntax : --progress-format +``` + +### --ignore-settings-file + + + +Ignores the DSC settings file for this run. The `lint` and `tracing` sections of the settings file +aren't applied, so every rule reports at its default level unless you override it with +[--rule](#--rule). + +```yaml +Type : boolean +Mandatory : false +LongSyntax : --ignore-settings-file +``` + +### -V, --version + + + + +Displays the version of the application. When you specify this option, the application ignores all +options and arguments except for [--help](#--help), which overrides this option. + +```yaml +Type : boolean +Mandatory : false +LongSyntax : --version +ShortSyntax : -V +``` + ### -h, --help -Displays the help for the current command or subcommand. When you specify this option, the -application ignores all other options and arguments. +Displays the help for the command. When you specify this option, the application ignores all other +options and arguments. ```yaml Type : boolean @@ -383,7 +479,10 @@ This command returns the diagnostics for the document, counts of the diagnostics the document has no errors, and whether resource discovery ran. The command always writes the result to stdout, including when the document doesn't parse. In the console it renders the diagnostics as reports; otherwise, or when you specify [--output-format](#--output-format), it -emits the result as data. For more information, see [dsc lint result schema][04]. +emits the result as data. For more information, see [dsclint result schema][04]. + +The `dsc schema` command returns the JSON schemas for the output as the `lint-result` and +`lint-rules` schema types. ## Exit codes @@ -397,8 +496,10 @@ emits the result as data. For more information, see [dsc lint result schema][04] ## Settings -The `lint` section of the DSC settings file sets the level of rules for every run. The same -section in the policy settings file takes precedence over the `--rule` option. +`dsclint` reads the DSC settings file (`dsc.settings.json`) and the policy settings file from the +folder that contains the `dsclint` executable, which is the folder `dsc` is installed in. The +`lint` section of the settings file sets the level of rules for every run. The same section in the +policy settings file takes precedence over the `--rule` option. ```json { @@ -416,3 +517,4 @@ section in the policy settings file takes precedence over the `--rule` option. [02]: https://jsonlines.org/ [03]: ../../schemas/outputs/lint/rules.md [04]: ../../schemas/outputs/lint/result.md +[05]: ../../cli/server/index.md diff --git a/docs/reference/cli/lint/rules.md b/docs/reference/tools/dsclint/rules.md similarity index 97% rename from docs/reference/cli/lint/rules.md rename to docs/reference/tools/dsclint/rules.md index ae0a60c75..3ace6fa94 100644 --- a/docs/reference/cli/lint/rules.md +++ b/docs/reference/tools/dsclint/rules.md @@ -1,13 +1,13 @@ --- -description: Reference for the rules the 'dsc lint' command checks configuration documents against -ms.date: 10/20/2026 +description: Reference for the rules the 'dsclint' command checks configuration documents against +ms.date: 10/23/2026 ms.topic: reference -title: dsc lint rules +title: dsclint rules --- -# dsc lint rules +# dsclint rules -The `dsc lint` command checks a configuration document against the rules on this page. Each rule +The `dsclint` command checks a configuration document against the rules on this page. Each rule has an identifier you can use with the `--rule` option or in the `lint` section of the settings file, a category, a default level, and a tier: @@ -304,7 +304,7 @@ run the document otherwise. ### dsc-version-unsatisfied The `directives.version` requirement should be satisfied by the version of DSC in use. The rule -checks the version of the `dsc` command that runs the linter. +checks the version of `dsclint`, which is the version of the `dsc` command it ships with. ## Environment rules diff --git a/dsc/locales/en-us.toml b/dsc/locales/en-us.toml index 72f20ba3d..bacf2638a 100644 --- a/dsc/locales/en-us.toml +++ b/dsc/locales/en-us.toml @@ -40,13 +40,6 @@ version = "The version requirement for the resource, like '^1.2' or '>1.2.3, <1. serverAbout = "Use DSC as a server over JSON-RPC (useful as MCP server)" bicepAbout = "Use DSC as a Bicep server over gRPC" ignoreSettingsFile = "Ignore the settings file when running the command" -lintAbout = "Check a configuration document for problems without applying it" -lintInput = "The configuration document as JSON or YAML to lint" -lintFile = "The path to a configuration document to lint; specify multiple times to lint several documents. Use '-' to read from STDIN." -noDiscovery = "Skip resource discovery and the rules that inspect installed resources" -failOn = "The lowest diagnostic level that makes the command fail (default: error)" -rule = "Override the level of a rule, like 'no-unused-parameters=off'; specify multiple times for several rules" -listRules = "List the lint rules and the level each reports at instead of linting" [main] failedToSpawnMain = "Failed to spawn dsc main thread: %{error}" @@ -139,9 +132,6 @@ adapterNotFound = "Adapter '%{adapter}' does not exist" [server.show_dsc_resource] resourceNotFound = "Resource type '%{type_name}' does not exist" -[report] -lintSummary = "errors: %{errors}, warnings: %{warnings}, info: %{info}" - [subcommand] actualStateNotObject = "actual_state is not an object" unexpectedTestResult = "Unexpected Group TestResult" @@ -177,13 +167,6 @@ invalidFunctionDescriptionFilter = "Invalid function description filter" maxInt = "maxInt" invalidManifest = "Error in manifest for" jsonArrayNotSupported = "JSON array output format is only supported for `--all'" -lintNoInput = "No configuration document specified; use '--file' or '--input'" -lintFailed = "Linting failed" -invalidLintRule = "Invalid lint rule option" -tableHeader_lintRule = "Rule" -tableHeader_lintCategory = "Category" -tableHeader_lintLevel = "Level" -tableHeader_lintTier = "Tier" [util] failedToConvertJsonToString = "Failed to convert JSON to string" diff --git a/dsc/src/args.rs b/dsc/src/args.rs index 096d72c7e..effe13cef 100644 --- a/dsc/src/args.rs +++ b/dsc/src/args.rs @@ -5,7 +5,6 @@ use clap::{Parser, Subcommand, ValueEnum}; use clap_complete::Shell; use dsc_lib::dscresources::command_resource::TraceLevel; use dsc_lib::functions::FunctionCategory; -use dsc_lib::lint::{LintLevel, RuleLevelOverride}; use dsc_lib::progress::ProgressFormat; use dsc_lib::types::{FullyQualifiedTypeName, ResourceVersionReq, TypeNameFilter}; use rust_i18n::t; @@ -97,29 +96,6 @@ pub enum SubCommand { #[clap(subcommand)] subcommand: FunctionSubCommand, }, - #[clap(name = "lint", about = t!("args.lintAbout").to_string())] - Lint { - #[clap(short = 'i', long, help = t!("args.lintInput").to_string(), conflicts_with = "file")] - input: Option, - #[clap(short = 'f', long, help = t!("args.lintFile").to_string(), conflicts_with = "input")] - file: Vec, - #[clap(short = 'p', long, help = t!("args.parameters").to_string())] - parameters: Option, - #[clap(long, help = t!("args.parametersFile").to_string())] - parameters_file: Option, - #[clap(short = 'r', long, help = t!("args.systemRoot").to_string())] - system_root: Option, - #[clap(long, help = t!("args.noDiscovery").to_string())] - no_discovery: bool, - #[clap(long, help = t!("args.failOn").to_string(), value_enum)] - fail_on: Option, - #[clap(long = "rule", value_name = "RULE=LEVEL", help = t!("args.rule").to_string())] - rules: Vec, - #[clap(long, help = t!("args.listRules").to_string(), conflicts_with_all = ["input", "file"])] - list_rules: bool, - #[clap(short = 'o', long, help = t!("args.outputFormat").to_string())] - output_format: Option, - }, #[clap(name = "server", alias = "mcp", about = t!("args.serverAbout").to_string())] Server, #[clap(name = "resource", about = t!("args.resourceAbout").to_string())] diff --git a/dsc/src/main.rs b/dsc/src/main.rs index 9972b69f9..36a0fcef6 100644 --- a/dsc/src/main.rs +++ b/dsc/src/main.rs @@ -102,21 +102,6 @@ fn dsc_main() -> Result<(), ExitCode> { let merged_parameters = merge_parameter_inputs(parameters, parameters_file.as_ref())?; subcommand::config(&subcommand, &merged_parameters, system_root.as_ref(), &as_group, &as_assert, &as_include, progress_format)?; }, - SubCommand::Lint { input, file, parameters, parameters_file, system_root, no_discovery, fail_on, rules, list_rules, output_format } => { - let merged_parameters = merge_parameter_inputs(parameters, parameters_file.as_ref())?; - let args = subcommand::LintArgs { - input: input.as_ref(), - files: &file, - parameters: merged_parameters.as_ref(), - system_root: system_root.as_ref(), - no_discovery, - fail_on, - rules: &rules, - list_rules, - output_format: output_format.as_ref(), - }; - subcommand::lint(&args, progress_format)?; - }, SubCommand::Extension { subcommand } => { subcommand::extension(&subcommand, progress_format)?; }, diff --git a/dsc/src/report.rs b/dsc/src/report.rs index 8d8e015f5..704054175 100644 --- a/dsc/src/report.rs +++ b/dsc/src/report.rs @@ -8,15 +8,11 @@ //! label on each problem and sometimes a suggestion. [`render`] turns such an error into a report //! that shows the problem in place, in the style the trace format asks for. -use std::collections::HashMap; use std::io::{IsTerminal, stderr}; use std::sync::OnceLock; -use dsc_lib::diagnostics::SourceMap; -use dsc_lib::lint::{LintDiagnosticReport, LintResult}; use dsc_lib_telemetry::StderrFormat; use miette::{Diagnostic, GraphicalReportHandler, GraphicalTheme}; -use rust_i18n::t; /// The lines shown around each labeled line of a snippet. const CONTEXT_LINES: usize = 2; @@ -59,40 +55,6 @@ pub fn render(error: &dyn Diagnostic) -> String { report.trim_end().to_string() } -/// Render the diagnostics of a lint run as reports that show each problem in its document, -/// followed by the counts by level. -/// -/// # Arguments -/// -/// * `result` - The lint result. -/// * `documents` - The text of each linted document, keyed by the file name recorded on its -/// diagnostics; `None` for inline input. -/// * `descriptions` - What each rule requires, by rule identifier, shown as the help text. -/// * `colored` - Whether to style the reports with ANSI colors. -#[must_use] -pub fn render_lint_result(result: &LintResult, documents: &[(Option, String)], descriptions: &HashMap, colored: bool) -> String { - let maps: HashMap<&Option, SourceMap> = documents.iter() - .filter_map(|(file, text)| SourceMap::parse(text).map(|map| (file, map))) - .collect(); - let handler = handler(colored); - let mut output = String::new(); - for diagnostic in &result.diagnostics { - let report = LintDiagnosticReport::new(diagnostic, maps.get(&diagnostic.file), descriptions.get(&diagnostic.rule).cloned()); - if handler.render_report(&mut output, &report).is_err() { - output.push_str(&format!("{} {}: {}\n", diagnostic.level, diagnostic.rule, diagnostic.message)); - } - output.push('\n'); - } - output.push_str(&t!( - "report.lintSummary", - errors = result.summary.errors, - warnings = result.summary.warnings, - info = result.summary.info - )); - output.push('\n'); - output -} - /// A report handler for the terminal, drawing with unicode and, unless `NO_COLOR` asks /// otherwise, ANSI colors. /// @@ -112,7 +74,7 @@ fn handler(colored: bool) -> GraphicalReportHandler { #[cfg(test)] mod tests { use super::*; - use dsc_lib::{diagnostics::SchemaValidationError, dscerror::DscError, dscresources::dscresource::validate_json_all, lint::{LintCategory, LintDiagnostic, LintLevel}}; + use dsc_lib::{diagnostics::SchemaValidationError, dscerror::DscError, dscresources::dscresource::validate_json_all}; use serde_json::json; fn schema_error() -> DscError { @@ -140,26 +102,4 @@ mod tests { assert!(report.contains("Did you mean 'output'"), "{report}"); assert!(report.contains("[input:2:3]"), "{report}"); } - - #[test] - fn lint_results_render_each_diagnostic_in_its_document() { - let text = "resources:\n- name: a\n type: T/E\n- name: a\n type: T/E\n".to_string(); - let diagnostic = LintDiagnostic { - level: LintLevel::Warning, - rule: "duplicate-resource".to_string(), - category: LintCategory::Document, - message: "declared twice".to_string(), - path: "/resources/1".to_string(), - file: Some("demo.dsc.yaml".to_string()), - location: None, - }; - let result = LintResult::from_diagnostics(vec![diagnostic], false); - let descriptions = HashMap::from([("duplicate-resource".to_string(), "Names must be unique.".to_string())]); - let output = render_lint_result(&result, &[(Some("demo.dsc.yaml".to_string()), text)], &descriptions, false); - assert!(output.contains("duplicate-resource"), "{output}"); - assert!(output.contains("declared twice"), "{output}"); - assert!(output.contains("[demo.dsc.yaml:4:3]"), "{output}"); - assert!(output.contains("Names must be unique."), "{output}"); - assert!(output.contains("errors: 0, warnings: 1, info: 0"), "{output}"); - } } diff --git a/dsc/src/subcommand.rs b/dsc/src/subcommand.rs index 9af8fdb28..4ceec8d3f 100644 --- a/dsc/src/subcommand.rs +++ b/dsc/src/subcommand.rs @@ -2,13 +2,12 @@ // Licensed under the MIT License. use crate::args::{ConfigSubCommand, SchemaType, ExtensionSubCommand, FunctionSubCommand, GetOutputFormat, ListOutputFormat, OutputFormat, ResourceSubCommand}; -use crate::report::{render, render_lint_result}; +use crate::report::render; use crate::resolve::{get_contents, Include}; use crate::resource_command::{get_resource, self}; use crate::tablewriter::Table; -use crate::util::{current_dsc_version, get_input, get_input_with_source, get_schema, in_desired_state, read_input, set_dscconfigroot, write_object, DSC_CONFIG_ROOT, EXIT_DSC_ASSERTION_FAILED, EXIT_DSC_ERROR, EXIT_INVALID_ARGS, EXIT_INVALID_INPUT, EXIT_JSON_ERROR, EXIT_VALIDATION_FAILED}; +use crate::util::{current_dsc_version, get_input, get_input_with_source, get_schema, in_desired_state, set_dscconfigroot, write_object, DSC_CONFIG_ROOT, EXIT_DSC_ASSERTION_FAILED, EXIT_DSC_ERROR, EXIT_INVALID_ARGS, EXIT_INVALID_INPUT, EXIT_JSON_ERROR, EXIT_VALIDATION_FAILED}; use dsc_lib::diagnostics::DocumentSource; -use dsc_lib::lint::{LintError, LintLevel, LintOptions, LintResult, Linter, RuleLevelOverride}; use dsc_lib::types::{FullyQualifiedTypeName, ResourceVersionReq, TypeNameFilter}; use dsc_lib::{ configure::{ @@ -41,7 +40,7 @@ use std::process::ExitCode; use std::{ collections::HashMap, io::{self, IsTerminal}, - path::{Path, PathBuf} + path::Path }; use tracing::{debug, error, trace}; @@ -505,166 +504,6 @@ fn parse_parameters(parameters: &str) -> Result { } } -/// The arguments of the `dsc lint` command. -pub struct LintArgs<'a> { - /// The inline document, if given. - pub input: Option<&'a String>, - /// The documents to read from files; `-` reads STDIN. - pub files: &'a [String], - /// The merged parameters, if any were given. - pub parameters: Option<&'a String>, - /// The system root the configuration targets, if not the running OS. - pub system_root: Option<&'a String>, - /// Whether to skip resource discovery and the rules that need it. - pub no_discovery: bool, - /// The lowest diagnostic level that makes the command fail; `error` when not given. - pub fail_on: Option, - /// Rule level overrides for this run. - pub rules: &'a [RuleLevelOverride], - /// Whether to list the rules instead of linting. - pub list_rules: bool, - /// The output format. - pub output_format: Option<&'a OutputFormat>, -} - -/// Lint one or more configuration documents. -/// -/// # Arguments -/// -/// * `args` - The command arguments. -/// * `progress_format` - How to report progress during resource discovery. -/// -/// # Errors -/// -/// Returns `EXIT_INVALID_ARGS` for an unknown rule or missing input, `EXIT_INVALID_INPUT` when a -/// document or the parameters can't be read, `EXIT_VALIDATION_FAILED` when a diagnostic at or -/// above the `--fail-on` level was reported, `EXIT_JSON_ERROR` when the result can't be -/// serialized, and `EXIT_DSC_ERROR` for other failures. -pub fn lint(args: &LintArgs, progress_format: ProgressFormat) -> Result<(), ExitCode> { - let parameters = match args.parameters { - Some(parameters) => Some(parse_parameters(parameters)?), - None => None, - }; - let options = LintOptions { - discovery: !args.no_discovery, - rule_levels: args.rules.to_vec(), - parameters, - system_root: args.system_root.map(PathBuf::from), - dsc_version: Some(current_dsc_version()), - progress_format, - ..LintOptions::default() - }; - let mut linter = match Linter::new(options) { - Ok(linter) => linter, - Err(DscError::Lint(err @ (LintError::UnknownRule(_) | LintError::InvalidRuleOverride(_) | LintError::InvalidRuleLevel(_)))) => { - error!("{}: {err}", t!("subcommand.invalidLintRule")); - return Err(ExitCode::from(EXIT_INVALID_ARGS)); - }, - Err(err) => { - error!("{}: {err}", t!("subcommand.lintFailed")); - return Err(ExitCode::from(EXIT_DSC_ERROR)); - }, - }; - - if args.list_rules { - return list_lint_rules(&linter, args.output_format); - } - - let mut documents: Vec<(Option, String)> = Vec::new(); - if let Some(input) = args.input { - documents.push((None, read_input(Some(input), None)?)); - } - for file in args.files { - let document = read_input(None, Some(file))?; - let name = if file == "-" { None } else { Some(file.clone()) }; - documents.push((name, document)); - } - if documents.is_empty() { - error!("{}", t!("subcommand.lintNoInput")); - return Err(ExitCode::from(EXIT_INVALID_ARGS)); - } - - let mut result = LintResult::from_diagnostics(Vec::new(), true); - for (file, document) in &documents { - linter.set_file(file.clone()); - let document_result = match linter.lint(document) { - Ok(document_result) => document_result, - Err(DscError::Lint(err @ LintError::InvalidParameters(_))) => { - error!("{}: {err}", t!("subcommand.lintFailed")); - return Err(ExitCode::from(EXIT_INVALID_INPUT)); - }, - Err(err) => { - error!("{}: {err}", t!("subcommand.lintFailed")); - return Err(ExitCode::from(EXIT_DSC_ERROR)); - }, - }; - result.merge(document_result); - } - - if args.output_format.is_none() && io::stdout().is_terminal() { - let descriptions = linter.rules().into_iter().map(|rule| (rule.id, rule.description)).collect(); - print!("{}", render_lint_result(&result, &documents, &descriptions, true)); - } else { - let json = match serde_json::to_string(&result) { - Ok(json) => json, - Err(err) => { - error!("JSON: {err}"); - return Err(ExitCode::from(EXIT_JSON_ERROR)); - } - }; - write_object(&json, args.output_format, false)?; - } - - let threshold = args.fail_on.unwrap_or(LintLevel::Error); - if result.has_diagnostic_at_least(threshold) { - return Err(ExitCode::from(EXIT_VALIDATION_FAILED)); - } - Ok(()) -} - -/// Print the lint rules with the level each reports at in this run. -/// -/// # Arguments -/// -/// * `linter` - The linter whose rule levels to show. -/// * `format` - The output format; a table when not given and stdout is a terminal. -/// -/// # Errors -/// -/// Returns `EXIT_JSON_ERROR` when the rules can't be serialized. -fn list_lint_rules(linter: &Linter, format: Option<&OutputFormat>) -> Result<(), ExitCode> { - let rules = linter.rules(); - if format.is_none() && io::stdout().is_terminal() { - let mut table = Table::new(&[ - t!("subcommand.tableHeader_lintRule").to_string().as_ref(), - t!("subcommand.tableHeader_lintCategory").to_string().as_ref(), - t!("subcommand.tableHeader_lintLevel").to_string().as_ref(), - t!("subcommand.tableHeader_lintTier").to_string().as_ref(), - t!("subcommand.tableHeader_description").to_string().as_ref(), - ]); - for rule in rules { - table.add_row(vec![ - rule.id, - rule.category.to_string(), - rule.level.to_string(), - rule.tier.to_string(), - rule.description, - ]); - } - table.print(true); - return Ok(()); - } - - let json = match serde_json::to_string(&rules) { - Ok(json) => json, - Err(err) => { - error!("JSON: {err}"); - return Err(ExitCode::from(EXIT_JSON_ERROR)); - } - }; - write_object(&json, format, false) -} - /// Validate configuration. /// /// # Arguments diff --git a/dsc/tests/dsc_schema.tests.ps1 b/dsc/tests/dsc_schema.tests.ps1 index d01bf13fa..9e66be0f7 100644 --- a/dsc/tests/dsc_schema.tests.ps1 +++ b/dsc/tests/dsc_schema.tests.ps1 @@ -48,6 +48,17 @@ Describe 'config schema tests' { $schema.'$schema' | Should -BeExactly 'https://json-schema.org/draft/2020-12/schema' } + It 'exposes the lint output schemas used by dsclint and the MCP server' { + $schema = dsc schema --type lint-result 2>$TestDrive/error.log | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 -Because (Get-Content $TestDrive/error.log -Raw) + $schema.properties.diagnostics | Should -Not -BeNullOrEmpty + $schema.properties.summary | Should -Not -BeNullOrEmpty + + $schema = dsc schema --type lint-rules 2>$TestDrive/error.log | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 -Because (Get-Content $TestDrive/error.log -Raw) + $schema.properties.defaultLevel | Should -Not -BeNullOrEmpty + } + It 'schema uses camelCase for ' -TestCases $schemaTestCases { param($type) diff --git a/dsclint/.project.data.json b/dsclint/.project.data.json new file mode 100644 index 000000000..51df832ed --- /dev/null +++ b/dsclint/.project.data.json @@ -0,0 +1,8 @@ +{ + "Name": "dsclint", + "Kind": "CLI", + "IsRust": true, + "Binaries": [ + "dsclint" + ] +} diff --git a/dsclint/Cargo.toml b/dsclint/Cargo.toml new file mode 100644 index 000000000..251666666 --- /dev/null +++ b/dsclint/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "dsclint" +# Keep in lockstep with the `dsc` crate: the linter checks `directives.version` against this value. +version = "3.4.0-preview.2" +edition = "2024" + +[[bin]] +name = "dsclint" +path = "src/main.rs" + +[dependencies] +# external dependencies +clap = { workspace = true } +crossterm = { workspace = true } +miette = { workspace = true, features = ["fancy-no-backtrace"] } +rust-i18n = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +serde_yaml = { workspace = true } +syntect = { workspace = true } +tracing = { workspace = true } +# workspace crate dependencies +dsc-lib = { workspace = true } +dsc-lib-telemetry = { workspace = true } diff --git a/dsclint/build.rs b/dsclint/build.rs new file mode 100644 index 000000000..315b846a1 --- /dev/null +++ b/dsclint/build.rs @@ -0,0 +1,7 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +fn main() { + // Always rebuild if translations are updated. + println!("cargo::rerun-if-changed=locales"); +} diff --git a/dsclint/locales/en-us.toml b/dsclint/locales/en-us.toml new file mode 100644 index 000000000..e2f9fb8c1 --- /dev/null +++ b/dsclint/locales/en-us.toml @@ -0,0 +1,59 @@ +_version = 1 + +[args] +about = "Check a DSC configuration document for problems without applying it" +input = "The configuration document as JSON or YAML to lint" +file = "The path to a configuration document to lint; specify multiple times to lint several documents. Use '-' to read from STDIN." +parameters = "Parameters to pass to the configuration as JSON or YAML" +parametersFile = "Parameters to pass to the configuration as a JSON or YAML file" +systemRoot = "Specify the operating system root path if not targeting the current running OS" +noDiscovery = "Skip resource discovery and the rules that inspect installed resources" +failOn = "The lowest diagnostic level that makes the command fail (default: error)" +rule = "Override the level of a rule, like 'no-unused-parameters=off'; specify multiple times for several rules" +listRules = "List the lint rules and the level each reports at instead of linting" +outputFormat = "The output format to use" +traceFormat = "Trace format to use" +traceLevel = "Trace level to use" +progressFormat = "Progress format to use during resource discovery" +ignoreSettingsFile = "Ignore the settings file when running the command" + +[main] +failedToSpawnMain = "Failed to spawn dsclint main thread: %{error}" +failedToJoinMain = "Failed to join dsclint main thread: %{error}" +usingVersion = "Running dsclint version" + +[lint] +mergingParameters = "Merging inline parameters with parameters file (inline takes precedence)" +failedMergingParameters = "Failed to merge parameters" +invalidParameters = "Invalid parameters" +failedConvertJson = "Failed to convert YAML to JSON" +noInput = "No configuration document specified; use '--file' or '--input'" +lintFailed = "Linting failed" +invalidLintRule = "Invalid lint rule option" +tableHeader_rule = "Rule" +tableHeader_category = "Category" +tableHeader_level = "Level" +tableHeader_tier = "Tier" +tableHeader_description = "Description" + +[report] +lintSummary = "errors: %{errors}, warnings: %{warnings}, info: %{info}" + +[util] +failedToReadTracingSetting = "Could not read 'tracing' setting" +invalidTraceLevel = "Default to 'warn', invalid DSC_TRACE_LEVEL value" +failedToSetTracing = "Unable to set global default tracing subscriber. Tracing is disabled." +readingInput = "Reading input from command line parameter" +inputIsFile = "Document provided is a file path, use '--file' instead" +readingInputFromFile = "Reading input from file" +readingInputFromStdin = "Reading input from STDIN" +invalidUtf8 = "Invalid utf-8 input" +failedToReadStdin = "Failed to read input from STDIN" +failedToReadFile = "Failed to read input file" +noInput = "No input provided" +emptyInput = "Empty input provided" +removingUtf8Bom = "Removing UTF-8 BOM from input" +failedToParseFileParameters = "Failed to parse parameters file: %{error}" +failedToParseInlineParameters = "Failed to parse inline parameters: %{error}" +parametersNotObject = "Parameters must be an object" +invalidVersion = "Unable to parse the dsclint version from the cargo manifest: %{error}" diff --git a/dsclint/src/args.rs b/dsclint/src/args.rs new file mode 100644 index 000000000..ebf5bf39e --- /dev/null +++ b/dsclint/src/args.rs @@ -0,0 +1,59 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use clap::{Parser, ValueEnum}; +use dsc_lib::dscresources::command_resource::TraceLevel; +use dsc_lib::lint::{LintLevel, RuleLevelOverride}; +use dsc_lib::progress::ProgressFormat; +use rust_i18n::t; +use serde::Deserialize; + +#[derive(Debug, Clone, PartialEq, Eq, ValueEnum)] +pub enum OutputFormat { + Json, + PrettyJson, + Yaml, +} + +#[derive(Debug, Clone, PartialEq, Eq, ValueEnum, Deserialize)] +pub enum TraceFormat { + Default, + Plaintext, + Json, + #[clap(hide = true)] + PassThrough, +} + +#[derive(Debug, Parser)] +#[clap(name = "dsclint", version = env!("CARGO_PKG_VERSION"), about = t!("args.about").to_string(), long_about = None)] +pub struct Args { + #[clap(short = 'i', long, help = t!("args.input").to_string(), conflicts_with = "file")] + pub input: Option, + #[clap(short = 'f', long, help = t!("args.file").to_string(), conflicts_with = "input")] + pub file: Vec, + #[clap(short = 'p', long, help = t!("args.parameters").to_string())] + pub parameters: Option, + #[clap(long, help = t!("args.parametersFile").to_string())] + pub parameters_file: Option, + #[clap(short = 'r', long, help = t!("args.systemRoot").to_string())] + pub system_root: Option, + #[clap(long, help = t!("args.noDiscovery").to_string())] + pub no_discovery: bool, + #[clap(long, help = t!("args.failOn").to_string(), value_enum)] + pub fail_on: Option, + #[clap(long = "rule", value_name = "RULE=LEVEL", help = t!("args.rule").to_string())] + pub rules: Vec, + #[clap(long, help = t!("args.listRules").to_string(), conflicts_with_all = ["input", "file"])] + pub list_rules: bool, + #[clap(short = 'o', long, help = t!("args.outputFormat").to_string(), value_enum)] + pub output_format: Option, + #[clap(short = 'l', long, help = t!("args.traceLevel").to_string(), value_enum)] + pub trace_level: Option, + #[clap(short = 't', long, help = t!("args.traceFormat").to_string(), value_enum)] + pub trace_format: Option, + // `-p` is taken by `--parameters`, as in `dsc config`, so this option has no short form. + #[clap(long, help = t!("args.progressFormat").to_string(), value_enum)] + pub progress_format: Option, + #[clap(long, help = t!("args.ignoreSettingsFile").to_string())] + pub ignore_settings_file: bool, +} diff --git a/dsclint/src/lint.rs b/dsclint/src/lint.rs new file mode 100644 index 000000000..78dc53f6c --- /dev/null +++ b/dsclint/src/lint.rs @@ -0,0 +1,196 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! The lint command: read the documents, run the linter, and write the result. + +use crate::args::{Args, OutputFormat}; +use crate::report::render_lint_result; +use crate::tablewriter::Table; +use crate::util::{ + EXIT_DSC_ERROR, EXIT_INVALID_ARGS, EXIT_INVALID_INPUT, EXIT_JSON_ERROR, EXIT_VALIDATION_FAILED, + current_version, merge_parameters, parse_parameters, read_input, write_object, +}; +use dsc_lib::dscerror::DscError; +use dsc_lib::lint::{LintError, LintLevel, LintOptions, LintResult, Linter}; +use dsc_lib::progress::ProgressFormat; +use rust_i18n::t; +use serde_json::Value; +use std::io::{self, IsTerminal}; +use std::path::PathBuf; +use std::process::ExitCode; +use tracing::{error, info}; + +/// Lint the configuration documents named by the arguments, or list the rules. +/// +/// # Arguments +/// +/// * `args` - The command-line arguments. +/// +/// # Errors +/// +/// Returns `EXIT_INVALID_ARGS` for an unknown rule or missing input, `EXIT_INVALID_INPUT` when a +/// document or the parameters can't be read, `EXIT_VALIDATION_FAILED` when a diagnostic at or +/// above the `--fail-on` level was reported, `EXIT_JSON_ERROR` when the result can't be +/// serialized, and `EXIT_DSC_ERROR` for other failures. +pub fn run(args: &Args) -> Result<(), ExitCode> { + let parameters = resolve_parameters(args.parameters.as_ref(), args.parameters_file.as_ref())?; + let options = LintOptions { + discovery: !args.no_discovery, + rule_levels: args.rules.clone(), + parameters, + system_root: args.system_root.as_ref().map(PathBuf::from), + dsc_version: Some(current_version()), + progress_format: args.progress_format.unwrap_or(ProgressFormat::Default), + ..LintOptions::default() + }; + let mut linter = match Linter::new(options) { + Ok(linter) => linter, + Err(DscError::Lint(err @ (LintError::UnknownRule(_) | LintError::InvalidRuleOverride(_) | LintError::InvalidRuleLevel(_)))) => { + error!("{}: {err}", t!("lint.invalidLintRule")); + return Err(ExitCode::from(EXIT_INVALID_ARGS)); + }, + Err(err) => { + error!("{}: {err}", t!("lint.lintFailed")); + return Err(ExitCode::from(EXIT_DSC_ERROR)); + }, + }; + + if args.list_rules { + return list_rules(&linter, args.output_format.as_ref()); + } + + let mut documents: Vec<(Option, String)> = Vec::new(); + if let Some(input) = &args.input { + documents.push((None, read_input(Some(input), None)?)); + } + for file in &args.file { + let document = read_input(None, Some(file))?; + let name = if file == "-" { None } else { Some(file.clone()) }; + documents.push((name, document)); + } + if documents.is_empty() { + error!("{}", t!("lint.noInput")); + return Err(ExitCode::from(EXIT_INVALID_ARGS)); + } + + let mut result = LintResult::from_diagnostics(Vec::new(), true); + for (file, document) in &documents { + linter.set_file(file.clone()); + let document_result = match linter.lint(document) { + Ok(document_result) => document_result, + Err(DscError::Lint(err @ LintError::InvalidParameters(_))) => { + error!("{}: {err}", t!("lint.lintFailed")); + return Err(ExitCode::from(EXIT_INVALID_INPUT)); + }, + Err(err) => { + error!("{}: {err}", t!("lint.lintFailed")); + return Err(ExitCode::from(EXIT_DSC_ERROR)); + }, + }; + result.merge(document_result); + } + + if args.output_format.is_none() && io::stdout().is_terminal() { + let descriptions = linter.rules().into_iter().map(|rule| (rule.id, rule.description)).collect(); + print!("{}", render_lint_result(&result, &documents, &descriptions, true)); + } else { + let json = match serde_json::to_string(&result) { + Ok(json) => json, + Err(err) => { + error!("JSON: {err}"); + return Err(ExitCode::from(EXIT_JSON_ERROR)); + } + }; + write_object(&json, args.output_format.as_ref())?; + } + + let threshold = args.fail_on.unwrap_or(LintLevel::Error); + if result.has_diagnostic_at_least(threshold) { + return Err(ExitCode::from(EXIT_VALIDATION_FAILED)); + } + Ok(()) +} + +/// Combine the inline parameters with the parameters file, if either was given. +/// +/// # Arguments +/// +/// * `parameters` - The inline parameters, as JSON or YAML. +/// * `parameters_file` - The path to a parameters file, or `-` to read it from STDIN. +/// +/// # Returns +/// +/// The parameters, or `None` when neither was given. Inline values take precedence over values +/// from the file. +/// +/// # Errors +/// +/// Returns `EXIT_INVALID_INPUT` when the file can't be read or either input isn't valid JSON or +/// YAML. +fn resolve_parameters(parameters: Option<&String>, parameters_file: Option<&String>) -> Result, ExitCode> { + let file_parameters = match parameters_file { + Some(path) => Some(read_input(None, Some(path))?), + None => None, + }; + + let parsed = match (file_parameters, parameters) { + (Some(file_content), Some(inline_content)) => { + info!("{}", t!("lint.mergingParameters")); + merge_parameters(&file_content, inline_content) + }, + (Some(file_content), None) => parse_parameters(&file_content, "FileParameters"), + (None, Some(inline_content)) => parse_parameters(inline_content, "InlineParameters"), + (None, None) => return Ok(None), + }; + + match parsed { + Ok(value) => Ok(Some(value)), + Err(err) => { + error!("{}: {err}", t!("lint.invalidParameters")); + Err(ExitCode::from(EXIT_INVALID_INPUT)) + } + } +} + +/// Print the lint rules with the level each reports at in this run. +/// +/// # Arguments +/// +/// * `linter` - The linter whose rule levels to show. +/// * `format` - The output format; a table when not given and stdout is a terminal. +/// +/// # Errors +/// +/// Returns `EXIT_JSON_ERROR` when the rules can't be serialized. +fn list_rules(linter: &Linter, format: Option<&OutputFormat>) -> Result<(), ExitCode> { + let rules = linter.rules(); + if format.is_none() && io::stdout().is_terminal() { + let mut table = Table::new(&[ + t!("lint.tableHeader_rule").to_string().as_ref(), + t!("lint.tableHeader_category").to_string().as_ref(), + t!("lint.tableHeader_level").to_string().as_ref(), + t!("lint.tableHeader_tier").to_string().as_ref(), + t!("lint.tableHeader_description").to_string().as_ref(), + ]); + for rule in rules { + table.add_row(vec![ + rule.id, + rule.category.to_string(), + rule.level.to_string(), + rule.tier.to_string(), + rule.description, + ]); + } + table.print(true); + return Ok(()); + } + + let json = match serde_json::to_string(&rules) { + Ok(json) => json, + Err(err) => { + error!("JSON: {err}"); + return Err(ExitCode::from(EXIT_JSON_ERROR)); + } + }; + write_object(&json, format) +} diff --git a/dsclint/src/main.rs b/dsclint/src/main.rs new file mode 100644 index 000000000..44c286039 --- /dev/null +++ b/dsclint/src/main.rs @@ -0,0 +1,69 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! `dsclint` checks DSC configuration documents for problems without applying them. + +use args::Args; +use clap::Parser; +use dsc_lib::util::DSC_IGNORE_SETTINGS_FILE; +use rust_i18n::{i18n, t}; +use std::{env::set_var, process::ExitCode}; +use tracing::debug; + +mod args; +mod lint; +mod report; +mod tablewriter; +mod util; + +i18n!("locales", fallback = "en-us"); + +fn main() -> ExitCode { + #[cfg(windows)] + { + let handle = match std::thread::Builder::new() + .name("dsclint-main".to_string()) + .stack_size(2 * 1024 * 1024) // Default stack is too small on Windows causing overflow + .spawn(lint_main) + { + Ok(handle) => handle, + Err(err) => { + eprintln!("{}", t!("main.failedToSpawnMain", error = err)); + return ExitCode::from(util::EXIT_DSC_ERROR); + } + }; + + match handle.join() { + Ok(Ok(())) => ExitCode::from(util::EXIT_SUCCESS), + Ok(Err(code)) => code, + Err(err) => { + eprintln!("{}", t!("main.failedToJoinMain", error = err : {:?})); + ExitCode::from(util::EXIT_DSC_ERROR) + } + } + } + #[cfg(not(windows))] + { + match lint_main() { + Ok(()) => ExitCode::from(util::EXIT_SUCCESS), + Err(code) => code, + } + } +} + +fn lint_main() -> Result<(), ExitCode> { + let args = Args::parse(); + + // the settings file is read by `dsc-lib`, which honors this environment variable + if args.ignore_settings_file { + unsafe { + set_var(DSC_IGNORE_SETTINGS_FILE, "1"); + } + } + + util::enable_tracing(args.trace_level.as_ref(), args.trace_format.as_ref()); + + debug!("{}: {}", t!("main.usingVersion"), env!("CARGO_PKG_VERSION")); + + lint::run(&args) +} diff --git a/dsclint/src/report.rs b/dsclint/src/report.rs new file mode 100644 index 000000000..6f5d32413 --- /dev/null +++ b/dsclint/src/report.rs @@ -0,0 +1,95 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Render lint results for the terminal. +//! +//! Each [`dsc_lib::lint::LintDiagnostic`] is rendered as a [`miette`] report that shows the problem +//! in place in the document it was found in, followed by the counts by level. + +use std::collections::HashMap; + +use dsc_lib::diagnostics::SourceMap; +use dsc_lib::lint::{LintDiagnosticReport, LintResult}; +use miette::{GraphicalReportHandler, GraphicalTheme}; +use rust_i18n::t; + +/// The lines shown around each labeled line of a snippet. +const CONTEXT_LINES: usize = 2; + +/// Render the diagnostics of a lint run as reports that show each problem in its document, +/// followed by the counts by level. +/// +/// # Arguments +/// +/// * `result` - The lint result. +/// * `documents` - The text of each linted document, keyed by the file name recorded on its +/// diagnostics; `None` for inline input. +/// * `descriptions` - What each rule requires, by rule identifier, shown as the help text. +/// * `colored` - Whether to style the reports with ANSI colors. +#[must_use] +pub fn render_lint_result(result: &LintResult, documents: &[(Option, String)], descriptions: &HashMap, colored: bool) -> String { + let maps: HashMap<&Option, SourceMap> = documents.iter() + .filter_map(|(file, text)| SourceMap::parse(text).map(|map| (file, map))) + .collect(); + let handler = handler(colored); + let mut output = String::new(); + for diagnostic in &result.diagnostics { + let report = LintDiagnosticReport::new(diagnostic, maps.get(&diagnostic.file), descriptions.get(&diagnostic.rule).cloned()); + if handler.render_report(&mut output, &report).is_err() { + output.push_str(&format!("{} {}: {}\n", diagnostic.level, diagnostic.rule, diagnostic.message)); + } + output.push('\n'); + } + output.push_str(&t!( + "report.lintSummary", + errors = result.summary.errors, + warnings = result.summary.warnings, + info = result.summary.info + )); + output.push('\n'); + output +} + +/// A report handler for the terminal, drawing with unicode and, unless `NO_COLOR` asks +/// otherwise, ANSI colors. +/// +/// # Arguments +/// +/// * `colored` - Whether colors are wanted. +fn handler(colored: bool) -> GraphicalReportHandler { + let no_color = std::env::var("NO_COLOR").is_ok_and(|value| value != "0"); + let theme = if colored && !no_color { + GraphicalTheme::unicode() + } else { + GraphicalTheme::unicode_nocolor() + }; + GraphicalReportHandler::new_themed(theme).with_context_lines(CONTEXT_LINES) +} + +#[cfg(test)] +mod tests { + use super::*; + use dsc_lib::lint::{LintCategory, LintDiagnostic, LintLevel}; + + #[test] + fn lint_results_render_each_diagnostic_in_its_document() { + let text = "resources:\n- name: a\n type: T/E\n- name: a\n type: T/E\n".to_string(); + let diagnostic = LintDiagnostic { + level: LintLevel::Warning, + rule: "duplicate-resource".to_string(), + category: LintCategory::Document, + message: "declared twice".to_string(), + path: "/resources/1".to_string(), + file: Some("demo.dsc.yaml".to_string()), + location: None, + }; + let result = LintResult::from_diagnostics(vec![diagnostic], false); + let descriptions = HashMap::from([("duplicate-resource".to_string(), "Names must be unique.".to_string())]); + let output = render_lint_result(&result, &[(Some("demo.dsc.yaml".to_string()), text)], &descriptions, false); + assert!(output.contains("duplicate-resource"), "{output}"); + assert!(output.contains("declared twice"), "{output}"); + assert!(output.contains("[demo.dsc.yaml:4:3]"), "{output}"); + assert!(output.contains("Names must be unique."), "{output}"); + assert!(output.contains("errors: 0, warnings: 1, info: 0"), "{output}"); + } +} diff --git a/dsclint/src/tablewriter.rs b/dsclint/src/tablewriter.rs new file mode 100644 index 000000000..a9c6ce662 --- /dev/null +++ b/dsclint/src/tablewriter.rs @@ -0,0 +1,98 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use crossterm::terminal::size; + +pub struct Table { + header: Vec, + rows: Vec>, + column_widths: Vec, +} + +impl Table { + /// Create a new table. + /// + /// # Arguments + /// + /// * `header` - The header row + /// + /// # Returns + /// + /// * `Table` - The new table + #[must_use] + pub fn new(header: &[&str]) -> Table { + let mut column_widths = Vec::new(); + for header_text in header { + column_widths.push(header_text.len()); + } + let header = header.iter().map(|s| (*s).to_string()).collect::>(); + Table { + header, + rows: Vec::new(), + column_widths, + } + } + + /// Add a row to the table. + /// + /// # Arguments + /// + /// * `row` - The row to add + pub fn add_row(&mut self, row: Vec) { + for (i, column) in row.iter().enumerate() { + if column.len() > self.column_widths[i] { + self.column_widths[i] = column.len(); + } + } + self.rows.push(row); + } + + /// Print the table to the console. + /// + /// # Arguments + /// + /// * `truncate` - Whether to truncate the table if it is too wide for the console + #[allow(clippy::format_push_string)] + pub fn print(&self, truncate: bool) { + let (width, _) = size().unwrap_or((80, 25)); + // make header bright green + println!("\x1b[1;32m"); + let mut header_row = String::with_capacity(width as usize); + let last_column = self.header.len() - 1; + for (i, column) in self.header.iter().enumerate() { + header_row.push_str(&format!("{: width as usize { + header_row.truncate(width as usize); + } + + println!("{header_row}"); + println!("{}\x1b[0m", "-".repeat(header_row.len())); + + // TODO: make this smarter by splitting long text to multiple lines + for row in &self.rows { + let mut row_str = String::with_capacity(header_row.len()); + for (i, column) in row.iter().enumerate() { + row_str.push_str(&format!("{: width as usize { + row_str.truncate(width as usize); + if !row_str.ends_with(' ') { + row_str.pop(); + row_str.push('…'); + } + } + + println!("{row_str}"); + } + } +} diff --git a/dsclint/src/util.rs b/dsclint/src/util.rs new file mode 100644 index 000000000..6087bbcb3 --- /dev/null +++ b/dsclint/src/util.rs @@ -0,0 +1,471 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Helpers shared by the command: exit codes, tracing setup, input reading, and output writing. +//! +//! These mirror the equivalents in the `dsc` crate so that `dsclint` behaves the same way as +//! `dsc` for settings, tracing, input, and output. + +use crate::args::{OutputFormat, TraceFormat}; +use dsc_lib::{ + discovery::Discovery, + dscerror::DscError, + dscresources::command_resource::TraceLevel, + extensions::dscextension::Capability, + types::SemanticVersion, + util::get_setting, +}; +use dsc_lib_telemetry::{StderrFormat, basic::BasicTracingOptions}; +use rust_i18n::t; +use serde::Deserialize; +use serde_json::{Map, Value}; +use std::env; +use std::io::{IsTerminal, Read, Write, stdout}; +use std::path::Path; +use std::process::ExitCode; +use syntect::{ + easy::HighlightLines, + highlighting::ThemeSet, + parsing::SyntaxSet, + util::{LinesWithEndings, as_24_bit_terminal_escaped}, +}; +use tracing::{Level, debug, error, info, trace, warn}; + +// The exit codes match the ones `dsc` uses for the same conditions. +pub const EXIT_SUCCESS: u8 = 0; +pub const EXIT_INVALID_ARGS: u8 = 1; +pub const EXIT_DSC_ERROR: u8 = 2; +pub const EXIT_JSON_ERROR: u8 = 3; +pub const EXIT_INVALID_INPUT: u8 = 4; +pub const EXIT_VALIDATION_FAILED: u8 = 5; + +pub const DSC_TRACE_LEVEL: &str = "DSC_TRACE_LEVEL"; + +#[derive(Deserialize)] +pub struct TracingSetting { + /// Trace level to use - see pub enum `TraceLevel` in `dsc_lib\src\dscresources\command_resource.rs` + level: TraceLevel, + /// Trace format to use - see pub enum `TraceFormat` in `dsclint\src\args.rs` + format: TraceFormat, + /// Whether the 'level' can be overridden by `DSC_TRACE_LEVEL` environment variable + #[serde(rename = "allowOverride")] + allow_override: bool, +} + +impl Default for TracingSetting { + fn default() -> TracingSetting { + TracingSetting { + level: TraceLevel::Warn, + format: TraceFormat::Default, + allow_override: true, + } + } +} + +/// Returns the version of `dsclint` as a semantic version. +/// +/// The crate version is kept in lockstep with `dsc`, so this is the DSC version that +/// `directives.version` requirements are checked against. +/// +/// # Panics +/// +/// Panics if the version defined in the cargo manifest isn't a valid semantic version. Cargo +/// rejects such a manifest at build time, so this can't happen for a built binary. +#[must_use] +pub fn current_version() -> SemanticVersion { + match SemanticVersion::parse(env!("CARGO_PKG_VERSION")) { + Ok(version) => version, + Err(err) => unreachable!("{}", t!("util.invalidVersion", error = err)), + } +} + +/// Write the JSON object to the console +/// +/// # Arguments +/// +/// * `json` - The JSON to write +/// * `format` - The format to use +/// +/// # Errors +/// +/// Returns `EXIT_JSON_ERROR` when the JSON can't be parsed or converted to the requested format. +pub fn write_object(json: &str, format: Option<&OutputFormat>) -> Result<(), ExitCode> { + let mut is_json = true; + let mut output_format = format; + let mut syntax_color = false; + if stdout().is_terminal() { + syntax_color = true; + if output_format.is_none() { + output_format = Some(&OutputFormat::Yaml); + } + } else if output_format.is_none() { + output_format = Some(&OutputFormat::Json); + } + + let output = match output_format { + Some(OutputFormat::Json) => json.to_string(), + Some(OutputFormat::PrettyJson) => { + let value: Value = match serde_json::from_str(json) { + Ok(value) => value, + Err(err) => { + error!("JSON: {err}"); + return Err(ExitCode::from(EXIT_JSON_ERROR)); + } + }; + match serde_json::to_string_pretty(&value) { + Ok(json) => json, + Err(err) => { + error!("JSON: {err}"); + return Err(ExitCode::from(EXIT_JSON_ERROR)); + } + } + }, + Some(OutputFormat::Yaml) | None => { + is_json = false; + let value: Value = match serde_json::from_str(json) { + Ok(value) => value, + Err(err) => { + error!("JSON: {err}"); + return Err(ExitCode::from(EXIT_JSON_ERROR)); + } + }; + match serde_yaml::to_string(&value) { + Ok(yaml) => yaml, + Err(err) => { + error!("YAML: {err}"); + return Err(ExitCode::from(EXIT_JSON_ERROR)); + } + } + } + }; + + if syntax_color { + let ps = SyntaxSet::load_defaults_newlines(); + let ts = ThemeSet::load_defaults(); + let Some(syntax) = (if is_json { + ps.find_syntax_by_extension("json") + } else { + ps.find_syntax_by_extension("yaml") + }) else { + println!("{json}"); + return Ok(()); + }; + + let mut h = HighlightLines::new(syntax, &ts.themes["base16-ocean.dark"]); + + for line in LinesWithEndings::from(&output) { + let Ok(ranges) = h.highlight_line(line, &ps) else { + continue; + }; + let escaped = as_24_bit_terminal_escaped(&ranges[..], false); + print!("{escaped}"); + } + } else { + let mut stdout_lock = stdout().lock(); + if writeln!(stdout_lock, "{output}").is_err() { + // likely caused by a broken pipe (e.g. 'head' command closed early) + return Ok(()); + } + } + + Ok(()) +} + +/// Enable tracing the same way `dsc` does: the `tracing` setting or policy from the settings +/// file next to the executable, overridden by `DSC_TRACE_LEVEL` when allowed, then by the +/// command-line arguments when no policy is in force. +/// +/// # Arguments +/// +/// * `trace_level_arg` - The trace level from the command line, if given. +/// * `trace_format_arg` - The trace format from the command line, if given. +pub fn enable_tracing(trace_level_arg: Option<&TraceLevel>, trace_format_arg: Option<&TraceFormat>) { + let mut policy_is_used = false; + let mut tracing_setting = TracingSetting::default(); + + let default_guard = BasicTracingOptions::init_default_guard(); + + // read setting/policy from files + if let Ok(v) = get_setting("tracing") { + if v.policy != Value::Null { + match serde_json::from_value::(v.policy) { + Ok(v) => { + tracing_setting = v; + policy_is_used = true; + }, + Err(e) => { error!("{e}"); } + } + } else if v.setting != Value::Null { + match serde_json::from_value::(v.setting) { + Ok(v) => { + tracing_setting = v; + }, + Err(e) => { error!("{e}"); } + } + } + } else { + error!("{}", t!("util.failedToReadTracingSetting")); + } + + // override with DSC_TRACE_LEVEL env var if permitted + if tracing_setting.allow_override && let Ok(level) = env::var(DSC_TRACE_LEVEL) { + tracing_setting.level = match level.to_ascii_uppercase().as_str() { + "ERROR" => TraceLevel::Error, + "WARN" => TraceLevel::Warn, + "INFO" => TraceLevel::Info, + "DEBUG" => TraceLevel::Debug, + "TRACE" => TraceLevel::Trace, + _ => { + warn!("{}: '{level}'", t!("util.invalidTraceLevel")); + TraceLevel::Warn + } + } + } + + // command-line args override setting value, but not policy + if !policy_is_used { + if let Some(v) = trace_level_arg { + tracing_setting.level = v.clone(); + } + if let Some(v) = trace_format_arg { + tracing_setting.format = v.clone(); + } + } + + // convert to 'tracing' crate type + let tracing_level = match tracing_setting.level { + TraceLevel::Error => Level::ERROR, + TraceLevel::Warn => Level::WARN, + TraceLevel::Info => Level::INFO, + TraceLevel::Debug => Level::DEBUG, + TraceLevel::Trace => Level::TRACE, + }; + // convert to 'dsc-lib-telemetry' crate type + let stderr_format = match tracing_setting.format { + TraceFormat::Default => StderrFormat::Default, + TraceFormat::Plaintext => StderrFormat::Plaintext, + TraceFormat::Json | TraceFormat::PassThrough => StderrFormat::Json, + }; + + // enable tracing + let subscriber = BasicTracingOptions { + tracing_level, + stderr_format + }.init_subscriber(); + + drop(default_guard); + if tracing::subscriber::set_global_default(subscriber).is_err() { + eprintln!("{}", t!("util.failedToSetTracing")); + } + + // set DSC_TRACE_LEVEL for child processes, like the resources run during discovery + unsafe { + env::set_var(DSC_TRACE_LEVEL, tracing_level.to_string().to_ascii_lowercase()); + } + info!("Trace-level is {:?}", tracing_setting.level); +} + +/// Read an input document as text without parsing it. +/// +/// # Arguments +/// +/// * `input` - The inline document, if provided. +/// * `file` - The path to the document, or `-` to read it from STDIN. Extensions with the +/// `import` capability get the first chance to handle a file path. +/// +/// # Returns +/// +/// The document text, or an empty string when neither argument is provided. +/// +/// # Errors +/// +/// Returns `EXIT_INVALID_INPUT` when the inline input is actually a path to an existing file, the +/// file can't be read, STDIN isn't valid UTF-8, or the document is empty. +pub fn read_input(input: Option<&String>, file: Option<&String>) -> Result { + trace!("Input: {input:?}, File: {file:?}"); + let value = if let Some(input) = input { + debug!("{}", t!("util.readingInput")); + + // see if user accidentally passed in a file path + if Path::new(input).exists() { + error!("{}", t!("util.inputIsFile")); + return Err(ExitCode::from(EXIT_INVALID_INPUT)); + } + input.clone() + } else if let Some(path) = file { + debug!("{} {path}", t!("util.readingInputFromFile")); + // check if need to read from STDIN + if path == "-" { + info!("{}", t!("util.readingInputFromStdin")); + let mut stdin = Vec::::new(); + match std::io::stdin().read_to_end(&mut stdin) { + Ok(_) => { + match String::from_utf8(stdin) { + Ok(input) => { + input + }, + Err(err) => { + error!("{}: {err}", t!("util.invalidUtf8")); + return Err(ExitCode::from(EXIT_INVALID_INPUT)); + } + } + }, + Err(err) => { + error!("{}: {err}", t!("util.failedToReadStdin")); + return Err(ExitCode::from(EXIT_INVALID_INPUT)); + } + } + } else { + // see if an extension should handle this file + let mut discovery = Discovery::new(); + let path_buf = Path::new(path); + for extension in discovery.get_extensions(&Capability::Import) { + if let Ok(content) = extension.import(path_buf) { + return Ok(content); + } + } + match std::fs::read_to_string(path) { + Ok(input) => { + // check if it contains UTF-8 BOM and remove it + if input.as_bytes().starts_with(&[0xEF, 0xBB, 0xBF]) { + info!("{}", t!("util.removingUtf8Bom")); + input[3..].to_string() + } else { + input + } + }, + Err(err) => { + error!("{}: {err}", t!("util.failedToReadFile")); + return Err(ExitCode::from(EXIT_INVALID_INPUT)); + } + } + } + } else { + debug!("{}", t!("util.noInput")); + return Ok(String::new()); + }; + + if value.trim().is_empty() { + error!("{}", t!("util.emptyInput")); + return Err(ExitCode::from(EXIT_INVALID_INPUT)); + } + + Ok(value) +} + +/// Parse parameter input as JSON, falling back to YAML. +/// +/// # Arguments +/// +/// * `input` - The parameters as JSON or YAML text. +/// * `context` - Which input is being parsed, for the error message: `FileParameters` or +/// `InlineParameters`. +/// +/// # Errors +/// +/// Returns an error if the input cannot be parsed as valid JSON or YAML. +pub fn parse_parameters(input: &str, context: &str) -> Result { + match serde_json::from_str(input) { + Ok(json) => Ok(json), + Err(_) => { + match serde_yaml::from_str::(input) { + Ok(yaml) => Ok(serde_json::to_value(yaml)?), + Err(err) => { + Err(DscError::Parser(t!(&format!("util.failedToParse{context}"), error = err.to_string()).to_string())) + } + } + } + } +} + +/// Convert parameter input to a map. +/// +/// # Arguments +/// +/// * `params` - Parameter string to convert (JSON or YAML format) +/// * `context` - Context string for error messages +/// +/// # Errors +/// +/// Returns an error if the input cannot be parsed or is not an object. +fn params_to_map(params: &str, context: &str) -> Result, DscError> { + let value = parse_parameters(params, context)?; + + let Some(map) = value.as_object().cloned() else { + return Err(DscError::Parser(t!("util.parametersNotObject").to_string())); + }; + + Ok(map) +} + +/// Merge two parameter sets, with inline parameters taking precedence over file parameters. +/// Top-level keys (like "parameters") are merged recursively, but parameter values themselves +/// are replaced (not merged) when specified inline. +/// +/// # Arguments +/// +/// * `file_params` - Parameters from file (JSON or YAML format) +/// * `inline_params` - Inline parameters (JSON or YAML format) that take precedence +/// +/// # Returns +/// +/// The merged parameters. +/// +/// # Errors +/// +/// Returns an error if either parameter set cannot be parsed as valid JSON or YAML or isn't an +/// object. +pub fn merge_parameters(file_params: &str, inline_params: &str) -> Result { + let mut file_map = params_to_map(file_params, "FileParameters")?; + let inline_map = params_to_map(inline_params, "InlineParameters")?; + + for (key, inline_value) in &inline_map { + if key == "parameters" { + // Special handling for "parameters" key - merge at parameter name level only. + // Within each parameter name, inline replaces (not merges). + if let Some(file_params_value) = file_map.get_mut("parameters") { + if let (Some(file_params_obj), Some(inline_params_obj)) = (file_params_value.as_object_mut(), inline_value.as_object()) { + for (param_name, param_value) in inline_params_obj { + file_params_obj.insert(param_name.clone(), param_value.clone()); + } + } else { + // If one is not an object, inline replaces completely + file_map.insert(key.clone(), inline_value.clone()); + } + } else { + file_map.insert(key.clone(), inline_value.clone()); + } + } else { + // For other top-level keys, inline value replaces file value + file_map.insert(key.clone(), inline_value.clone()); + } + } + + Ok(Value::Object(file_map)) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn inline_parameters_replace_file_parameters_by_name() { + let file = r#"{"parameters": {"a": 1, "b": {"x": 1}}, "other": true}"#; + let inline = "parameters:\n b:\n y: 2\n c: 3\n"; + let merged = merge_parameters(file, inline).unwrap(); + assert_eq!(merged, json!({"parameters": {"a": 1, "b": {"y": 2}, "c": 3}, "other": true})); + } + + #[test] + fn parameters_must_be_an_object() { + let err = merge_parameters("[1, 2]", "{}").unwrap_err(); + assert!(err.to_string().contains("must be an object"), "{err}"); + } + + #[test] + fn invalid_parameters_name_the_input() { + let err = parse_parameters("{not: valid: yaml", "InlineParameters").unwrap_err(); + assert!(err.to_string().contains("inline parameters"), "{err}"); + } +} diff --git a/dsc/tests/dsc_lint.tests.ps1 b/dsclint/tests/dsclint.tests.ps1 similarity index 77% rename from dsc/tests/dsc_lint.tests.ps1 rename to dsclint/tests/dsclint.tests.ps1 index f17c53d4f..ea9087cdc 100644 --- a/dsc/tests/dsc_lint.tests.ps1 +++ b/dsclint/tests/dsclint.tests.ps1 @@ -1,7 +1,7 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. -Describe 'dsc lint tests' { +Describe 'dsclint tests' { BeforeAll { $schemaUri = 'https://aka.ms/dsc/schemas/v3/bundled/config/document.json' $cleanConfig = @" @@ -48,7 +48,7 @@ resources: } It 'reports no diagnostics for a clean document' { - $result = $cleanConfig | dsc lint --file - 2>$TestDrive/error.log | ConvertFrom-Json + $result = $cleanConfig | dsclint --file - 2>$TestDrive/error.log | ConvertFrom-Json $LASTEXITCODE | Should -Be 0 -Because (Get-Content $TestDrive/error.log -Raw) $result.valid | Should -BeTrue $result.discovery | Should -BeTrue @@ -58,7 +58,7 @@ resources: } It 'reports a duplicate resource and exits with code 5' { - $result = $duplicateConfig | dsc lint --file - --no-discovery 2>$TestDrive/error.log | ConvertFrom-Json + $result = $duplicateConfig | dsclint --file - --no-discovery 2>$TestDrive/error.log | ConvertFrom-Json $LASTEXITCODE | Should -Be 5 -Because (Get-Content $TestDrive/error.log -Raw) $result.valid | Should -BeFalse $result.discovery | Should -BeFalse @@ -75,7 +75,7 @@ resources: } It 'records the line and column of a diagnostic inside a value' { - $result = $unusedParameterConfig | dsc lint --file - --no-discovery 2>$TestDrive/error.log | ConvertFrom-Json + $result = $unusedParameterConfig | dsclint --file - --no-discovery 2>$TestDrive/error.log | ConvertFrom-Json $LASTEXITCODE | Should -Be 0 -Because (Get-Content $TestDrive/error.log -Raw) $result.diagnostics[0].path | Should -BeExactly '/parameters/unused' $result.diagnostics[0].location.line | Should -Be 3 @@ -83,13 +83,13 @@ resources: } It 'accepts the document with --input' { - $result = dsc lint --input $duplicateConfig --no-discovery 2>$TestDrive/error.log | ConvertFrom-Json + $result = dsclint --input $duplicateConfig --no-discovery 2>$TestDrive/error.log | ConvertFrom-Json $LASTEXITCODE | Should -Be 5 -Because (Get-Content $TestDrive/error.log -Raw) $result.diagnostics[0].rule | Should -BeExactly 'duplicate-resource' } It 'treats warnings as non-fatal unless --fail-on warning is set' { - $result = $unusedParameterConfig | dsc lint --file - --no-discovery 2>$TestDrive/error.log | ConvertFrom-Json + $result = $unusedParameterConfig | dsclint --file - --no-discovery 2>$TestDrive/error.log | ConvertFrom-Json $LASTEXITCODE | Should -Be 0 -Because (Get-Content $TestDrive/error.log -Raw) $result.valid | Should -BeTrue $result.summary.warnings | Should -Be 1 @@ -97,29 +97,29 @@ resources: $result.diagnostics[0].level | Should -BeExactly 'warning' $result.diagnostics[0].path | Should -BeExactly '/parameters/unused' - $null = $unusedParameterConfig | dsc lint --file - --no-discovery --fail-on warning 2>$TestDrive/error.log + $null = $unusedParameterConfig | dsclint --file - --no-discovery --fail-on warning 2>$TestDrive/error.log $LASTEXITCODE | Should -Be 5 -Because (Get-Content $TestDrive/error.log -Raw) } It 'can change the level of a rule with --rule' { - $result = $unusedParameterConfig | dsc lint --file - --no-discovery --rule no-unused-parameters=off 2>$TestDrive/error.log | ConvertFrom-Json + $result = $unusedParameterConfig | dsclint --file - --no-discovery --rule no-unused-parameters=off 2>$TestDrive/error.log | ConvertFrom-Json $LASTEXITCODE | Should -Be 0 -Because (Get-Content $TestDrive/error.log -Raw) $result.diagnostics.Count | Should -Be 0 - $result = $unusedParameterConfig | dsc lint --file - --no-discovery --rule no-unused-parameters=error 2>$TestDrive/error.log | ConvertFrom-Json + $result = $unusedParameterConfig | dsclint --file - --no-discovery --rule no-unused-parameters=error 2>$TestDrive/error.log | ConvertFrom-Json $LASTEXITCODE | Should -Be 5 -Because (Get-Content $TestDrive/error.log -Raw) $result.valid | Should -BeFalse $result.diagnostics[0].level | Should -BeExactly 'error' } It 'rejects an unknown rule with exit code 1' { - $null = $cleanConfig | dsc lint --file - --no-discovery --rule no-such-rule=off 2>$TestDrive/error.log + $null = $cleanConfig | dsclint --file - --no-discovery --rule no-such-rule=off 2>$TestDrive/error.log $LASTEXITCODE | Should -Be 1 (Get-Content $TestDrive/error.log -Raw) | Should -BeLike '*no-such-rule*' } It 'reports a document that is not valid YAML as a diagnostic' { - $result = "resources:`n- name: a`n type: [unclosed" | dsc lint --file - --no-discovery 2>$TestDrive/error.log | ConvertFrom-Json + $result = "resources:`n- name: a`n type: [unclosed" | dsclint --file - --no-discovery 2>$TestDrive/error.log | ConvertFrom-Json $LASTEXITCODE | Should -Be 5 -Because (Get-Content $TestDrive/error.log -Raw) $result.valid | Should -BeFalse $result.diagnostics.Count | Should -Be 1 @@ -136,7 +136,7 @@ resources: type: Microsoft.DSC.Debug/Echo unknownKey: true "@ - $result = $config | dsc lint --file - --no-discovery 2>$TestDrive/error.log | ConvertFrom-Json + $result = $config | dsclint --file - --no-discovery 2>$TestDrive/error.log | ConvertFrom-Json $LASTEXITCODE | Should -Be 5 -Because (Get-Content $TestDrive/error.log -Raw) $violations = @($result.diagnostics | Where-Object rule -eq 'schema-invalid') $violations.Count | Should -BeGreaterThan 0 @@ -161,7 +161,7 @@ resources: properties: output: "$property" "@ - $result = $config | dsc lint --file - --no-discovery 2>$TestDrive/error.log | ConvertFrom-Json + $result = $config | dsclint --file - --no-discovery 2>$TestDrive/error.log | ConvertFrom-Json $LASTEXITCODE | Should -Be 5 -Because (Get-Content $TestDrive/error.log -Raw) $diagnostic = $result.diagnostics | Where-Object rule -eq $rule $diagnostic | Should -Not -BeNullOrEmpty -Because ($result.diagnostics | ConvertTo-Json -Depth 5) @@ -182,7 +182,7 @@ resources: dependsOn: - "[resourceId('Microsoft.DSC.Debug/Echo', 'a')]" "@ - $result = $config | dsc lint --file - --no-discovery 2>$TestDrive/error.log | ConvertFrom-Json + $result = $config | dsclint --file - --no-discovery 2>$TestDrive/error.log | ConvertFrom-Json $LASTEXITCODE | Should -Be 5 -Because (Get-Content $TestDrive/error.log -Raw) $diagnostic = $result.diagnostics | Where-Object rule -eq 'circular-dependency' $diagnostic.path | Should -BeExactly '/resources/0/dependsOn' @@ -190,7 +190,7 @@ resources: } It 'warns when --system-root is given but systemRoot() is not used' { - $result = $cleanConfig | dsc lint --file - --no-discovery --system-root $TestDrive 2>$TestDrive/error.log | ConvertFrom-Json + $result = $cleanConfig | dsclint --file - --no-discovery --system-root $TestDrive 2>$TestDrive/error.log | ConvertFrom-Json $LASTEXITCODE | Should -Be 0 -Because (Get-Content $TestDrive/error.log -Raw) $diagnostic = $result.diagnostics | Where-Object rule -eq 'system-root-unused' $diagnostic | Should -Not -BeNullOrEmpty @@ -205,7 +205,7 @@ resources: properties: output: "[systemRoot()]" "@ - $result = $config | dsc lint --file - --no-discovery --system-root $TestDrive 2>$TestDrive/error.log | ConvertFrom-Json + $result = $config | dsclint --file - --no-discovery --system-root $TestDrive 2>$TestDrive/error.log | ConvertFrom-Json $LASTEXITCODE | Should -Be 0 -Because (Get-Content $TestDrive/error.log -Raw) $result.diagnostics | Where-Object rule -eq 'system-root-unused' | Should -BeNullOrEmpty } @@ -224,7 +224,7 @@ resources: properties: output: "[concat(string(parameters('count')), parameters('required'))]" "@ - $result = $config | dsc lint --file - --no-discovery --parameters '{"parameters": {"count": "five", "extra": 1}}' 2>$TestDrive/error.log | ConvertFrom-Json + $result = $config | dsclint --file - --no-discovery --parameters '{"parameters": {"count": "five", "extra": 1}}' 2>$TestDrive/error.log | ConvertFrom-Json $LASTEXITCODE | Should -Be 5 -Because (Get-Content $TestDrive/error.log -Raw) $result.diagnostics.rule | Should -Contain 'parameter-value-type' $result.diagnostics.rule | Should -Contain 'missing-parameter-value' @@ -238,14 +238,14 @@ resources: - name: a type: Not.Installed/Anywhere "@ - $result = $config | dsc lint --file - 2>$TestDrive/error.log | ConvertFrom-Json + $result = $config | dsclint --file - 2>$TestDrive/error.log | ConvertFrom-Json $LASTEXITCODE | Should -Be 0 -Because (Get-Content $TestDrive/error.log -Raw) $result.discovery | Should -BeTrue $diagnostic = $result.diagnostics | Where-Object rule -eq 'unknown-resource-type' $diagnostic.level | Should -BeExactly 'warning' $diagnostic.path | Should -BeExactly '/resources/0/type' - $result = $config | dsc lint --file - --no-discovery 2>$TestDrive/error.log | ConvertFrom-Json + $result = $config | dsclint --file - --no-discovery 2>$TestDrive/error.log | ConvertFrom-Json $LASTEXITCODE | Should -Be 0 -Because (Get-Content $TestDrive/error.log -Raw) $result.discovery | Should -BeFalse $result.diagnostics | Where-Object rule -eq 'unknown-resource-type' | Should -BeNullOrEmpty @@ -254,7 +254,7 @@ resources: It 'lints several files in one run and records the file on each diagnostic' { Set-Content -Path "$TestDrive/clean.dsc.yaml" -Value $cleanConfig Set-Content -Path "$TestDrive/duplicate.dsc.yaml" -Value $duplicateConfig - $result = dsc lint --file "$TestDrive/clean.dsc.yaml" --file "$TestDrive/duplicate.dsc.yaml" --no-discovery 2>$TestDrive/error.log | ConvertFrom-Json + $result = dsclint --file "$TestDrive/clean.dsc.yaml" --file "$TestDrive/duplicate.dsc.yaml" --no-discovery 2>$TestDrive/error.log | ConvertFrom-Json $LASTEXITCODE | Should -Be 5 -Because (Get-Content $TestDrive/error.log -Raw) $result.diagnostics.Count | Should -Be 1 $result.diagnostics[0].file | Should -BeLike '*duplicate.dsc.yaml' @@ -266,14 +266,14 @@ resources: type: Test/Example version: 1.0.0 "@ - $result = $manifest | dsc lint --file - --no-discovery 2>$TestDrive/error.log | ConvertFrom-Json + $result = $manifest | dsclint --file - --no-discovery 2>$TestDrive/error.log | ConvertFrom-Json $LASTEXITCODE | Should -Be 5 -Because (Get-Content $TestDrive/error.log -Raw) $result.diagnostics.Count | Should -Be 1 $result.diagnostics[0].rule | Should -BeExactly 'unsupported-document-type' } It 'lists the rules with their levels' { - $rules = dsc lint --list-rules --output-format json 2>$TestDrive/error.log | ConvertFrom-Json + $rules = dsclint --list-rules --output-format json 2>$TestDrive/error.log | ConvertFrom-Json $LASTEXITCODE | Should -Be 0 -Because (Get-Content $TestDrive/error.log -Raw) $rules.id | Should -Contain 'duplicate-resource' $rules.id | Should -Contain 'parse-error' @@ -284,28 +284,17 @@ version: 1.0.0 $duplicate.tier | Should -BeExactly 'static' ($rules | Where-Object id -eq 'unknown-resource-type').tier | Should -BeExactly 'resource' - $rules = dsc lint --list-rules --rule duplicate-resource=warning --output-format json 2>$TestDrive/error.log | ConvertFrom-Json + $rules = dsclint --list-rules --rule duplicate-resource=warning --output-format json 2>$TestDrive/error.log | ConvertFrom-Json ($rules | Where-Object id -eq 'duplicate-resource').level | Should -BeExactly 'warning' } - It 'exposes the output schemas' { - $schema = dsc schema --type lint-result 2>$TestDrive/error.log | ConvertFrom-Json - $LASTEXITCODE | Should -Be 0 -Because (Get-Content $TestDrive/error.log -Raw) - $schema.properties.diagnostics | Should -Not -BeNullOrEmpty - $schema.properties.summary | Should -Not -BeNullOrEmpty - - $schema = dsc schema --type lint-rules 2>$TestDrive/error.log | ConvertFrom-Json - $LASTEXITCODE | Should -Be 0 -Because (Get-Content $TestDrive/error.log -Raw) - $schema.properties.defaultLevel | Should -Not -BeNullOrEmpty - } - It 'lints the shipped example without errors: ' -TestCases ( - Get-ChildItem (Join-Path $PSScriptRoot '../examples') -Filter '*.dsc.*' | + Get-ChildItem (Join-Path $PSScriptRoot '../../dsc/examples') -Filter '*.dsc.*' | Where-Object { $_.Extension -in @('.yaml', '.json') -and $_.Name -ne 'invalid_schema.dsc.yaml' } | ForEach-Object { @{ name = $_.Name; path = $_.FullName } } ) { param($name, $path) - $result = dsc lint --file $path --no-discovery 2>$TestDrive/error.log | ConvertFrom-Json + $result = dsclint --file $path --no-discovery 2>$TestDrive/error.log | ConvertFrom-Json $LASTEXITCODE | Should -Be 0 -Because ($result.diagnostics | ConvertTo-Json -Depth 5) $result.summary.errors | Should -Be 0 } diff --git a/helpers.build.psm1 b/helpers.build.psm1 index b1f8bdc55..7c9c44658 100644 --- a/helpers.build.psm1 +++ b/helpers.build.psm1 @@ -2856,7 +2856,7 @@ function Test-ProjectWithPester { [cmdletbinding()] param( [DscProjectDefinition[]]$Project, - [ValidateSet("dsc", "adapters", "extensions", "grammars", "resources")] + [ValidateSet("dsc", "dsclint", "adapters", "extensions", "grammars", "resources")] [string[]]$Group, [switch]$UsingADO ) @@ -3034,6 +3034,9 @@ function Build-DscDebPackage{ $symlinkPath = Join-Path $debBuildRoot 'usr' 'bin' 'dsc-bicep-ext' New-Item -ItemType SymbolicLink -Path $symlinkPath -Target '/opt/dsc/dsc-bicep-ext' -Force > $null + $symlinkPath = Join-Path $debBuildRoot 'usr' 'bin' 'dsclint' + New-Item -ItemType SymbolicLink -Path $symlinkPath -Target '/opt/dsc/dsclint' -Force > $null + # Read the control template and replace placeholders $controlTemplate = Get-Content "$PSScriptRoot/packaging/deb/control" -Raw $controlContent = $controlTemplate.Replace('VERSION_PLACEHOLDER', $productVersion).Replace('ARCH_PLACEHOLDER', $debArch) diff --git a/lib/dsc-lib/locales/schemas.outputs.yaml b/lib/dsc-lib/locales/schemas.outputs.yaml index 6220b1efd..13118c2af 100644 --- a/lib/dsc-lib/locales/schemas.outputs.yaml +++ b/lib/dsc-lib/locales/schemas.outputs.yaml @@ -4,24 +4,24 @@ schemas: lint: result: title: - en-us: dsc lint result + en-us: dsclint result description: en-us: >- - Describes the output of the `dsc lint` command. + Describes the output of the `dsclint` command. markdownDescription: en-us: |- - Describes the output of the `dsc lint` command: the diagnostics the lint rules + Describes the output of the `dsclint` command: the diagnostics the lint rules reported for the document, counts of the diagnostics by level, whether the document is free of error-level diagnostics, and whether resource discovery ran. rules: title: - en-us: dsc lint rule + en-us: dsclint rule description: en-us: >- - Describes a lint rule and the level it reports at, as listed by `dsc lint --list-rules`. + Describes a lint rule and the level it reports at, as listed by `dsclint --list-rules`. markdownDescription: en-us: |- Describes a lint rule and the level it reports at, as listed by - `dsc lint --list-rules`. The `defaultLevel` is the level the rule reports at unless + `dsclint --list-rules`. The `defaultLevel` is the level the rule reports at unless it is overridden; `level` is the level in effect after the settings file and any `--rule` overrides are applied. diff --git a/packaging/msix/AppxManifest.xml b/packaging/msix/AppxManifest.xml index 6b33119e6..050fc2ac2 100644 --- a/packaging/msix/AppxManifest.xml +++ b/packaging/msix/AppxManifest.xml @@ -50,6 +50,17 @@ + + + + + + + + + + + diff --git a/packaging/rpm/dsc.spec b/packaging/rpm/dsc.spec index 76267d725..980614116 100644 --- a/packaging/rpm/dsc.spec +++ b/packaging/rpm/dsc.spec @@ -29,11 +29,13 @@ cp -r $RPM_SOURCE_DIR/dsc_files/* $RPM_BUILD_ROOT/opt/dsc/ # Create symlink to make dsc available in PATH ln -s /opt/dsc/dsc $RPM_BUILD_ROOT/usr/bin/dsc ln -s /opt/dsc/dsc-bicep-ext $RPM_BUILD_ROOT/usr/bin/dsc-bicep-ext +ln -s /opt/dsc/dsclint $RPM_BUILD_ROOT/usr/bin/dsclint %files /opt/dsc/* /usr/bin/dsc /usr/bin/dsc-bicep-ext +/usr/bin/dsclint %changelog * Wed Oct 22 2025 Microsoft Corporation From c3cdca8565192bb266f9f0e66b13097822862b8b Mon Sep 17 00:00:00 2001 From: "G.Reijn" <26114636+Gijsreyn@users.noreply.github.com> Date: Fri, 23 Oct 2026 18:01:34 +0200 Subject: [PATCH 6/6] Fix test and resolve Copilot --- docs/reference/tools/dsclint/index.md | 3 +- docs/reference/tools/dsclint/rules.md | 3 +- dsclint/src/lint.rs | 9 +- dsclint/src/report.rs | 37 ++++++- dsclint/src/util.rs | 37 +++++-- dsclint/tests/dsclint.tests.ps1 | 15 +++ lib/dsc-lib/locales/en-us.toml | 2 +- lib/dsc-lib/src/configure/mod.rs | 72 +++++++++++- lib/dsc-lib/src/diagnostics/mod.rs | 5 +- lib/dsc-lib/src/diagnostics/schema.rs | 76 ++++++++++++- lib/dsc-lib/src/diagnostics/source_map.rs | 79 ++++++++++++++ .../src/dscresources/command_resource.rs | 82 +++++++++++--- lib/dsc-lib/src/lint/mod.rs | 29 ++++- lib/dsc-lib/src/lint/report.rs | 103 +++++++++++++++++- 14 files changed, 509 insertions(+), 43 deletions(-) diff --git a/docs/reference/tools/dsclint/index.md b/docs/reference/tools/dsclint/index.md index 78d8673a8..b3ae54159 100644 --- a/docs/reference/tools/dsclint/index.md +++ b/docs/reference/tools/dsclint/index.md @@ -79,7 +79,8 @@ For the list of rules and what each one checks, see [dsclint rules][01]. The command reports a duplicate resource name and exits with code `5`. In the console, each -diagnostic is rendered as a report that shows the problem in the document. +diagnostic is rendered as a report that shows the problem in the document. Secure values in the +document, like the default value of a `secureString` parameter, are shown as ``. ```yaml # example.dsc.config.yaml diff --git a/docs/reference/tools/dsclint/rules.md b/docs/reference/tools/dsclint/rules.md index 3ace6fa94..e918b37bb 100644 --- a/docs/reference/tools/dsclint/rules.md +++ b/docs/reference/tools/dsclint/rules.md @@ -73,7 +73,8 @@ Rules with a fixed level can't be changed or turned off. ### parse-error The document must be valid JSON or YAML. The diagnostic includes the parser's message and, when -known, the line and column of the problem. This rule has a fixed level. +known, the line and column of the problem. An empty document is reported by this rule too. This +rule has a fixed level. ### schema-invalid diff --git a/dsclint/src/lint.rs b/dsclint/src/lint.rs index 78dc53f6c..5fced59e3 100644 --- a/dsclint/src/lint.rs +++ b/dsclint/src/lint.rs @@ -8,7 +8,7 @@ use crate::report::render_lint_result; use crate::tablewriter::Table; use crate::util::{ EXIT_DSC_ERROR, EXIT_INVALID_ARGS, EXIT_INVALID_INPUT, EXIT_JSON_ERROR, EXIT_VALIDATION_FAILED, - current_version, merge_parameters, parse_parameters, read_input, write_object, + current_version, merge_parameters, parse_parameters, read_document, read_input, write_object, }; use dsc_lib::dscerror::DscError; use dsc_lib::lint::{LintError, LintLevel, LintOptions, LintResult, Linter}; @@ -31,7 +31,8 @@ use tracing::{error, info}; /// Returns `EXIT_INVALID_ARGS` for an unknown rule or missing input, `EXIT_INVALID_INPUT` when a /// document or the parameters can't be read, `EXIT_VALIDATION_FAILED` when a diagnostic at or /// above the `--fail-on` level was reported, `EXIT_JSON_ERROR` when the result can't be -/// serialized, and `EXIT_DSC_ERROR` for other failures. +/// serialized, and `EXIT_DSC_ERROR` for other failures. A document that is empty or invalid is +/// read successfully and reported in the result, not as an error. pub fn run(args: &Args) -> Result<(), ExitCode> { let parameters = resolve_parameters(args.parameters.as_ref(), args.parameters_file.as_ref())?; let options = LintOptions { @@ -61,10 +62,10 @@ pub fn run(args: &Args) -> Result<(), ExitCode> { let mut documents: Vec<(Option, String)> = Vec::new(); if let Some(input) = &args.input { - documents.push((None, read_input(Some(input), None)?)); + documents.push((None, read_document(Some(input), None)?)); } for file in &args.file { - let document = read_input(None, Some(file))?; + let document = read_document(None, Some(file))?; let name = if file == "-" { None } else { Some(file.clone()) }; documents.push((name, document)); } diff --git a/dsclint/src/report.rs b/dsclint/src/report.rs index 6f5d32413..6be3f3665 100644 --- a/dsclint/src/report.rs +++ b/dsclint/src/report.rs @@ -4,12 +4,13 @@ //! Render lint results for the terminal. //! //! Each [`dsc_lib::lint::LintDiagnostic`] is rendered as a [`miette`] report that shows the problem -//! in place in the document it was found in, followed by the counts by level. +//! in place in the document it was found in, followed by the counts by level. Secure values in +//! the document are replaced before it is shown, so a snippet never prints a secret. use std::collections::HashMap; use dsc_lib::diagnostics::SourceMap; -use dsc_lib::lint::{LintDiagnosticReport, LintResult}; +use dsc_lib::lint::{LintDiagnosticReport, LintResult, redacted_source_map}; use miette::{GraphicalReportHandler, GraphicalTheme}; use rust_i18n::t; @@ -23,13 +24,15 @@ const CONTEXT_LINES: usize = 2; /// /// * `result` - The lint result. /// * `documents` - The text of each linted document, keyed by the file name recorded on its -/// diagnostics; `None` for inline input. +/// diagnostics; `None` for inline input. Secure values in the text are shown as +/// ``; a document whose secure values can't be located is shown without a +/// snippet. /// * `descriptions` - What each rule requires, by rule identifier, shown as the help text. /// * `colored` - Whether to style the reports with ANSI colors. #[must_use] pub fn render_lint_result(result: &LintResult, documents: &[(Option, String)], descriptions: &HashMap, colored: bool) -> String { let maps: HashMap<&Option, SourceMap> = documents.iter() - .filter_map(|(file, text)| SourceMap::parse(text).map(|map| (file, map))) + .filter_map(|(file, text)| redacted_source_map(text).map(|map| (file, map))) .collect(); let handler = handler(colored); let mut output = String::new(); @@ -92,4 +95,30 @@ mod tests { assert!(output.contains("Names must be unique."), "{output}"); assert!(output.contains("errors: 0, warnings: 1, info: 0"), "{output}"); } + + #[test] + fn rendered_snippets_never_show_secure_values() { + let text = "parameters:\n password:\n type: secureString\n defaultValue: hunter2\n token:\n type: secureString\n defaultValue: \"[concat('letmein', 'unterminated)]\"\nresources:\n- name: a\n type: T/E\n properties:\n secret:\n secureString: topsecret\n output: \"[parameters('missing')]\"\n".to_string(); + let diagnostic = |rule: &str, level: LintLevel, path: &str| LintDiagnostic { + level, + rule: rule.to_string(), + category: LintCategory::Parameter, + message: format!("problem reported by {rule}"), + path: path.to_string(), + file: None, + location: None, + }; + let result = LintResult::from_diagnostics(vec![ + diagnostic("secure-parameter-default", LintLevel::Warning, "/parameters/password/defaultValue"), + diagnostic("invalid-expression", LintLevel::Error, "/parameters/token/defaultValue"), + diagnostic("undefined-parameter", LintLevel::Error, "/resources/0/properties/output"), + ], false); + let output = render_lint_result(&result, &[(None, text)], &HashMap::new(), false); + for secret in ["hunter2", "letmein", "topsecret"] { + assert!(!output.contains(secret), "{output}"); + } + assert!(output.contains(""), "{output}"); + assert!(output.contains("[input:4:5]"), "{output}"); + assert!(output.contains("parameters('missing')"), "{output}"); + } } diff --git a/dsclint/src/util.rs b/dsclint/src/util.rs index 6087bbcb3..f36327b0a 100644 --- a/dsclint/src/util.rs +++ b/dsclint/src/util.rs @@ -265,7 +265,7 @@ pub fn enable_tracing(trace_level_arg: Option<&TraceLevel>, trace_format_arg: Op info!("Trace-level is {:?}", tracing_setting.level); } -/// Read an input document as text without parsing it. +/// Read an input document as text without parsing it, rejecting an empty document. /// /// # Arguments /// @@ -282,6 +282,34 @@ pub fn enable_tracing(trace_level_arg: Option<&TraceLevel>, trace_format_arg: Op /// Returns `EXIT_INVALID_INPUT` when the inline input is actually a path to an existing file, the /// file can't be read, STDIN isn't valid UTF-8, or the document is empty. pub fn read_input(input: Option<&String>, file: Option<&String>) -> Result { + let value = read_document(input, file)?; + if (input.is_some() || file.is_some()) && value.trim().is_empty() { + error!("{}", t!("util.emptyInput")); + return Err(ExitCode::from(EXIT_INVALID_INPUT)); + } + Ok(value) +} + +/// Read an input document as text without parsing it, including an empty document. +/// +/// The linter reports an empty document as a diagnostic in its result, so the document is +/// returned as read. Use [`read_input`] for input that must not be empty, like parameters. +/// +/// # Arguments +/// +/// * `input` - The inline document, if provided. +/// * `file` - The path to the document, or `-` to read it from STDIN. Extensions with the +/// `import` capability get the first chance to handle a file path. +/// +/// # Returns +/// +/// The document text, or an empty string when neither argument is provided. +/// +/// # Errors +/// +/// Returns `EXIT_INVALID_INPUT` when the inline input is actually a path to an existing file, the +/// file can't be read, or STDIN isn't valid UTF-8. +pub fn read_document(input: Option<&String>, file: Option<&String>) -> Result { trace!("Input: {input:?}, File: {file:?}"); let value = if let Some(input) = input { debug!("{}", t!("util.readingInput")); @@ -342,14 +370,9 @@ pub fn read_input(input: Option<&String>, file: Option<&String>) -> Result$TestDrive/error.log | ConvertFrom-Json + $LASTEXITCODE | Should -Be 5 -Because (Get-Content $TestDrive/error.log -Raw) + $result.valid | Should -BeFalse + $result.diagnostics.Count | Should -Be 1 + $result.diagnostics[0].rule | Should -BeExactly 'parse-error' + + [IO.File]::WriteAllText("$TestDrive/empty.dsc.yaml", '') + $result = dsclint --file "$TestDrive/empty.dsc.yaml" --no-discovery 2>$TestDrive/error.log | ConvertFrom-Json + $LASTEXITCODE | Should -Be 5 -Because (Get-Content $TestDrive/error.log -Raw) + $result.diagnostics.Count | Should -Be 1 + $result.diagnostics[0].rule | Should -BeExactly 'parse-error' + $result.diagnostics[0].file | Should -BeLike '*empty.dsc.yaml' + } + It 'reports schema violations with the path of the problem' { $config = @" `$schema: $schemaUri diff --git a/lib/dsc-lib/locales/en-us.toml b/lib/dsc-lib/locales/en-us.toml index cfb6e5b3b..1c6b6b04d 100644 --- a/lib/dsc-lib/locales/en-us.toml +++ b/lib/dsc-lib/locales/en-us.toml @@ -277,7 +277,6 @@ resourceDoesNotImplementValidate = "Resource does not implement validation, usin resourceImplementsValidate = "Resource implements validation" resourceValidationFailed = "Resource failed validation" validatingSchema = "Validating against schema" -validationFailed = "Failed validation" adapterResourceNotFound = "Adapter resource '%{adapter}' not found" adapterManifestNotFound = "Adapter manifest for '%{adapter}' not found" adapterDoesNotSupportDelete = "Adapter '%{adapter}' does not support delete operation" @@ -1120,6 +1119,7 @@ invalidParameters = "The supplied parameters are invalid" fixedRuleOverrideIgnored = "The level of lint rule '%{rule}' is fixed and can't be changed; the override was ignored" unknownRuleInSettings = "The settings file configures unknown lint rule '%{rule}'; the entry was ignored" parseError = "The document isn't valid JSON or YAML: %{error}" +emptyDocument = "The document is empty" parseErrorDescription = "The document must be valid JSON or YAML." schemaInvalidDescription = "The document must validate against the configuration document JSON schema." documentInvalid = "The document couldn't be loaded as a configuration: %{error}" diff --git a/lib/dsc-lib/src/configure/mod.rs b/lib/dsc-lib/src/configure/mod.rs index 0e87dd9d3..59092a931 100644 --- a/lib/dsc-lib/src/configure/mod.rs +++ b/lib/dsc-lib/src/configure/mod.rs @@ -6,7 +6,7 @@ use crate::configure::context::{Context, ProcessMode}; use crate::configure::parameters::{SecureObject, SecureString, import_parameters, is_secure_value}; use crate::configure::{config_doc::{ExecutionKind, IntOrExpression, Metadata, Parameter, Resource, ResourceDiscoveryMode, RestartRequired, ValueOrCopy}}; use crate::discovery::discovery_trait::DiscoveryFilter; -use crate::diagnostics::DocumentSource; +use crate::diagnostics::{DocumentSource, JsonOrigin}; use crate::dscerror::DscError; use crate::dscresources::{ {dscresource::{Capability, Invoke, get_diff, validate_properties, get_adapter_input_kind}, @@ -594,13 +594,15 @@ impl Configurator { self.source.as_ref() } - /// Point a schema validation error from a resource at the resource's declaration in the - /// document. + /// Point a schema validation error about a resource's input at the resource's declaration in + /// the document. /// /// Resources are validated after their expressions are evaluated, so the error arrives /// pointing at the evaluated properties. When the document text is known and the resource /// is declared in it directly, the error is re-anchored to the resource's `properties` in the - /// document. Resources that a copy loop expands keep the evaluated view. Other errors pass + /// document. Resources that a copy loop expands keep the evaluated view. An error about what + /// the resource returned keeps pointing at that output, because the document doesn't hold the + /// offending value, and a redacted error stays without source text. Other errors pass /// through unchanged. /// /// # Arguments @@ -611,6 +613,9 @@ impl Configurator { let DscError::SchemaValidation(schema_error) = error else { return error; }; + if schema_error.origin() == JsonOrigin::Output || schema_error.is_redacted() { + return DscError::SchemaValidation(schema_error); + } let Some(source) = &self.source else { return DscError::SchemaValidation(schema_error); }; @@ -1620,6 +1625,9 @@ fn get_failure_from_error(err: &DscError) -> Option { #[cfg(test)] mod tests { use super::*; + use crate::diagnostics::SchemaValidationError; + use crate::dscresources::dscresource::validate_json_all; + use miette::Diagnostic; use serde_json::json; fn resource_with_export(export: Value) -> DscResource { @@ -1697,4 +1705,60 @@ mod tests { fn filter_input_invalid_json_is_error() { assert!(parse_export_filter_input("not json").is_err()); } + + /// A document whose resources aren't discovered, so a configurator can be created in a test. + const ANCHOR_DOCUMENT: &str = "$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json\ndirectives:\n resourceDiscovery: duringDeployment\nresources:\n- name: a\n type: Test/Echo\n properties:\n output: hi\n verbose: nope\n"; + /// The evaluated properties of the resource in `ANCHOR_DOCUMENT`, as a resource sees them. + const EVALUATED_PROPERTIES: &str = "{\n \"output\": \"hi\",\n \"verbose\": \"nope\"\n}"; + + fn anchored_configurator() -> Configurator { + let json = serde_json::to_string(&serde_yaml::from_str::(ANCHOR_DOCUMENT).unwrap()).unwrap(); + let mut configurator = Configurator::new(&json, ProgressFormat::None).unwrap(); + configurator.set_source(DocumentSource::new("demo.dsc.yaml", ANCHOR_DOCUMENT)); + configurator + } + + /// The error a resource reports for `EVALUATED_PROPERTIES`, before any text is attached. + fn verbose_type_error() -> SchemaValidationError { + let schema = json!({ "type": "object", "properties": { "output": { "type": "string" }, "verbose": { "type": "boolean" } } }); + let violations = validate_json_all("Test/Echo", &schema, &serde_json::from_str(EVALUATED_PROPERTIES).unwrap()).unwrap(); + SchemaValidationError::new("input for 'Test/Echo'", violations) + } + + /// Run an error through `anchor_error` for the resource declared in `ANCHOR_DOCUMENT`. + fn anchored_schema_error(error: SchemaValidationError) -> SchemaValidationError { + let configurator = anchored_configurator(); + let resource = configurator.get_config().resources[0].clone(); + match configurator.anchor_error(DscError::SchemaValidation(Box::new(error)), &resource) { + DscError::SchemaValidation(error) => *error, + other => panic!("expected a schema validation error, got {other}"), + } + } + + fn first_label_offset(error: &SchemaValidationError) -> usize { + error.labels().unwrap().next().unwrap().offset() + } + + #[test] + fn anchor_error_points_input_errors_at_the_document() { + let error = anchored_schema_error(verbose_type_error().with_source(EVALUATED_PROPERTIES, "input", "")); + assert_eq!(error.origin(), JsonOrigin::Input); + assert_eq!(first_label_offset(&error), ANCHOR_DOCUMENT.find("nope").unwrap()); + } + + #[test] + fn anchor_error_keeps_output_errors_on_the_output() { + let error = anchored_schema_error(verbose_type_error().with_origin(JsonOrigin::Output).with_source(EVALUATED_PROPERTIES, "output", "")); + assert_eq!(error.origin(), JsonOrigin::Output); + assert_eq!(first_label_offset(&error), EVALUATED_PROPERTIES.find("\"nope\"").unwrap()); + } + + #[test] + fn anchor_error_keeps_redacted_errors_without_source() { + let error = anchored_schema_error(verbose_type_error().redacted("redacted")); + assert!(error.is_redacted()); + assert!(!error.has_source()); + assert!(error.labels().is_none()); + assert!(!format!("{error:?}").contains("nope")); + } } diff --git a/lib/dsc-lib/src/diagnostics/mod.rs b/lib/dsc-lib/src/diagnostics/mod.rs index a78e3cb2b..ec687a38e 100644 --- a/lib/dsc-lib/src/diagnostics/mod.rs +++ b/lib/dsc-lib/src/diagnostics/mod.rs @@ -9,14 +9,15 @@ //! //! - [`SourceMap`] finds the byte span and line/column of any JSON pointer in JSON or YAML text. //! - [`SchemaValidationError`] is a [`miette::Diagnostic`] for JSON schema violations, with a -//! label on each violation when its source text is attached. +//! label on each violation when its source text is attached, and a [`JsonOrigin`] that says +//! whether the value was input to a resource or output from one. //! - [`DocumentSource`] carries the text and name of a document a host read, so library code //! can anchor the problems it finds to the author's text. pub mod schema; pub mod source_map; -pub use schema::SchemaValidationError; +pub use schema::{JsonOrigin, SchemaValidationError}; pub use source_map::SourceMap; /// The text a document was read from, for diagnostics that point into it. diff --git a/lib/dsc-lib/src/diagnostics/schema.rs b/lib/dsc-lib/src/diagnostics/schema.rs index 137ed452f..06075644e 100644 --- a/lib/dsc-lib/src/diagnostics/schema.rs +++ b/lib/dsc-lib/src/diagnostics/schema.rs @@ -21,6 +21,19 @@ const MAX_SUGGESTION_DISTANCE: usize = 2; /// The most property names to list when there is no close match. const MAX_LISTED_PROPERTIES: usize = 12; +/// Where the value that was validated against a schema came from. +/// +/// A host that knows the document a value was taken from can re-anchor an error about input to +/// the document; an error about what a resource returned has no place in the document. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum JsonOrigin { + /// The desired state, filter, or properties a caller passed to the resource. + #[default] + Input, + /// The state the resource returned. + Output, +} + /// Every violation found when validating a value against a JSON schema. /// /// The error is a [`miette::Diagnostic`]: when source text is attached with @@ -28,10 +41,15 @@ const MAX_LISTED_PROPERTIES: usize = 12; /// applies to, and unknown properties get a suggestion in the help text when the schema is /// attached with [`with_schema`](Self::with_schema). Without source text the error still lists /// every violation and its JSON pointer in its message. +/// +/// An error that was [`redacted`](Self::redacted) because the value holds secure data never +/// accepts source text again, so a host that re-anchors errors can't reintroduce the value. #[derive(Debug)] pub struct SchemaValidationError { subject: String, violations: Vec, + origin: JsonOrigin, + redacted: bool, source: Option>, labels: Vec, help: Option, @@ -40,6 +58,9 @@ pub struct SchemaValidationError { impl SchemaValidationError { /// Create an error for a set of violations. /// + /// The origin is [`JsonOrigin::Input`] until [`with_origin`](Self::with_origin) says + /// otherwise. + /// /// # Arguments /// /// * `subject` - What was validated, as it should read in the message, like @@ -50,12 +71,25 @@ impl SchemaValidationError { Self { subject: subject.into(), violations, + origin: JsonOrigin::default(), + redacted: false, source: None, labels: Vec::new(), help: None, } } + /// Record where the validated value came from. + /// + /// # Arguments + /// + /// * `origin` - Whether the value was input to a resource or output from one. + #[must_use] + pub fn with_origin(mut self, origin: JsonOrigin) -> Self { + self.origin = origin; + self + } + /// Use the schema to suggest corrections for unknown properties in the help text. /// /// # Arguments @@ -104,9 +138,13 @@ impl SchemaValidationError { /// /// # Returns /// - /// The error with the text attached, or unchanged when the text can't be parsed. + /// The error with the text attached, or unchanged when the text can't be parsed or the error + /// was [`redacted`](Self::redacted). #[must_use] pub fn with_source(mut self, text: &str, name: &str, base: &str) -> Self { + if self.redacted { + return self; + } let Some(map) = SourceMap::parse(text) else { return self; }; @@ -125,6 +163,8 @@ impl SchemaValidationError { /// Replace every violation message and drop the source text, for a value that holds secure /// data the report must not echo. /// + /// The error stays redacted: a later [`with_source`](Self::with_source) is ignored. + /// /// # Arguments /// /// * `message` - The message to show in place of each violation's. @@ -134,6 +174,7 @@ impl SchemaValidationError { violation.message = message.to_string(); violation.alternatives.clear(); } + self.redacted = true; self.source = None; self.labels.clear(); self.help = None; @@ -146,6 +187,18 @@ impl SchemaValidationError { &self.subject } + /// Where the validated value came from. + #[must_use] + pub fn origin(&self) -> JsonOrigin { + self.origin + } + + /// Whether the error was [`redacted`](Self::redacted) because the value holds secure data. + #[must_use] + pub fn is_redacted(&self) -> bool { + self.redacted + } + /// The violations found. #[must_use] pub fn violations(&self) -> &[SchemaViolation] { @@ -366,11 +419,32 @@ mod tests { #[test] fn redaction_drops_source_and_messages() { let error = error_for("output: hi\nverbose: secret\n").redacted("redacted"); + assert!(error.is_redacted()); assert!(!error.has_source()); assert!(error.labels().is_none()); + assert!(error.help().is_none()); assert_eq!(error.to_string(), "Schema: input for 'Test/Echo': redacted at '/verbose'"); } + #[test] + fn redaction_survives_attaching_source_again() { + let document = "resources:\n- name: a\n type: Test/Echo\n properties:\n output: hi\n verbose: secret\n"; + let error = error_for("output: hi\nverbose: secret\n") + .redacted("redacted") + .with_source(document, "demo.dsc.yaml", "/resources/0/properties"); + assert!(error.is_redacted()); + assert!(!error.has_source()); + assert!(error.labels().is_none()); + assert!(!format!("{error:?}").contains("secret")); + } + + #[test] + fn origin_defaults_to_input() { + let error = error_for("verbose: true\n"); + assert_eq!(error.origin(), JsonOrigin::Input); + assert_eq!(error.with_origin(JsonOrigin::Output).origin(), JsonOrigin::Output); + } + #[test] fn lists_known_properties_without_a_close_match() { let error = error_for("output: hi\ncompletelyDifferent: 1\n"); diff --git a/lib/dsc-lib/src/diagnostics/source_map.rs b/lib/dsc-lib/src/diagnostics/source_map.rs index 7b1c6baca..ac7e27929 100644 --- a/lib/dsc-lib/src/diagnostics/source_map.rs +++ b/lib/dsc-lib/src/diagnostics/source_map.rs @@ -124,6 +124,48 @@ impl SourceMap { } } + /// A map of the same document with the values at the given pointers replaced by a + /// placeholder, for text that is shown to a reader but holds values it must not show. + /// + /// The placeholder is written as a JSON string literal, which both grammars accept in place + /// of any value, so every other pointer in the document keeps its location. A pointer below + /// another listed pointer is covered by it. + /// + /// # Arguments + /// + /// * `pointers` - The JSON pointers of the values to replace. + /// * `placeholder` - The text shown in place of each value. + /// + /// # Returns + /// + /// The redacted map, or `None` when a pointer has no value in the document or the redacted + /// text can't be mapped, so the caller knows the text can't be shown safely. + #[must_use] + pub fn redacted(&self, pointers: &[String], placeholder: &str) -> Option { + let mut sorted: Vec<&str> = pointers.iter().map(String::as_str).collect(); + sorted.sort_unstable(); + sorted.dedup(); + let mut spans: Vec> = Vec::new(); + for pointer in &sorted { + let covered = sorted.iter().any(|other| { + other != pointer && pointer.starts_with(other) && pointer[other.len()..].starts_with('/') + }); + if !covered { + spans.push(self.value_span(pointer)?); + } + } + if spans.is_empty() { + return Some(self.clone()); + } + let replacement = serde_json::to_string(placeholder).ok()?; + spans.sort_by_key(|span| std::cmp::Reverse(span.start)); + let mut text = self.text.clone(); + for span in spans { + text.replace_range(span, &replacement); + } + Self::parse(&text) + } + /// The line and column of a byte offset, both starting at 1. Columns count characters. /// /// # Arguments @@ -534,4 +576,41 @@ mod tests { fn invalid_text_has_no_map() { assert!(SourceMap::parse("a: [b").is_none()); } + + #[test] + fn redacted_replaces_values_and_keeps_other_locations() { + let text = "parameters:\n password:\n type: secureString\n defaultValue: hunter2\nresources:\n- name: a\n properties:\n token:\n secureString: letmein\n output: hi\n"; + let map = SourceMap::parse(text).unwrap(); + let pointers = [ + "/parameters/password/defaultValue".to_string(), + "/resources/0/properties/token".to_string(), + "/resources/0/properties/token/secureString".to_string(), + "/resources/0/properties/token".to_string(), + ]; + let redacted = map.redacted(&pointers, "").unwrap(); + assert!(!redacted.text().contains("hunter2") && !redacted.text().contains("letmein"), "{}", redacted.text()); + assert_eq!(text_at(&redacted, redacted.value_span("/parameters/password/defaultValue").unwrap()), "\"\""); + assert_eq!(text_at(&redacted, redacted.value_span("/resources/0/properties/token").unwrap()), "\"\""); + assert_eq!(text_at(&redacted, redacted.value_span("/resources/0/properties/output").unwrap()), "hi"); + assert_eq!(redacted.location(redacted.key_span("/resources/0/properties/output").unwrap().start), (10, 5)); + assert_eq!(text_at(&map, map.value_span("/parameters/password/defaultValue").unwrap()), "hunter2"); + } + + #[test] + fn redacted_json_stays_json() { + let text = r#"{"parameters": {"password": {"type": "secureString", "defaultValue": {"secureString": "hunter2"}}}, "resources": []}"#; + let map = SourceMap::parse(text).unwrap(); + let redacted = map.redacted(&["/parameters/password/defaultValue".to_string()], "").unwrap(); + assert!(!redacted.text().contains("hunter2")); + assert!(serde_json::from_str::(redacted.text()).is_ok()); + assert_eq!(text_at(&redacted, redacted.value_span("/parameters/password/defaultValue").unwrap()), "\"\""); + assert!(redacted.value_span("/resources").is_some()); + } + + #[test] + fn redacted_needs_every_pointer_in_the_document() { + let map = SourceMap::parse("a: 1\n").unwrap(); + assert!(map.redacted(&["/missing".to_string()], "x").is_none()); + assert_eq!(map.redacted(&[], "x").unwrap().text(), "a: 1\n"); + } } diff --git a/lib/dsc-lib/src/dscresources/command_resource.rs b/lib/dsc-lib/src/dscresources/command_resource.rs index 29f86affe..778812a1f 100644 --- a/lib/dsc-lib/src/dscresources/command_resource.rs +++ b/lib/dsc-lib/src/dscresources/command_resource.rs @@ -7,7 +7,7 @@ use rust_i18n::t; use serde::Deserialize; use serde_json::{Map, Value}; use std::{collections::HashMap, env, path::Path, process::Stdio}; -use crate::{configure::{config_doc::{ExecutionKind, SecurityContextKind}, config_result::{ResourceGetResult, ResourceTestResult}, schema_cache::{RESOURCE_SCHEMAS, get_resource_schema}}, diagnostics::SchemaValidationError, dscresources::{dscresource::Operation, resource_manifest::{ExportSchemaKind, ExportSchemaOrFiltering, SchemaArgKind}}, types::ExitCodesMap, util::canonicalize_which}; +use crate::{configure::{config_doc::{ExecutionKind, SecurityContextKind}, config_result::{ResourceGetResult, ResourceTestResult}, schema_cache::{RESOURCE_SCHEMAS, get_resource_schema}}, diagnostics::{JsonOrigin, SchemaValidationError}, dscresources::{dscresource::Operation, resource_manifest::{ExportSchemaKind, ExportSchemaOrFiltering, SchemaArgKind}}, types::ExitCodesMap, util::canonicalize_which}; use crate::dscerror::DscError; use crate::locked_insert; use super::{ @@ -1170,15 +1170,6 @@ fn get_command_input(input_kind: Option<&InputKind>, input: &str) -> Result, origin: JsonOrigin) -> Result<(), DscError> { trace!("{}", t!("dscresources.commandResource.verifyJson", resource = resource.type_name, json = json)); let Some(manifest) = &resource.manifest else { @@ -1210,6 +1201,11 @@ fn verify_json_from_manifest(resource: &DscResource, json: &str, target_resource /// Validate JSON against a resource's schema, reporting every violation where it occurs. /// +/// The error carries the origin, so a host can tell an error about its own input from one about +/// what the resource returned. The text shown with the error has every `secureString` and +/// `secureObject` value replaced, so a violation next to a secure value can't expose it; a +/// violation that quotes a secure value in its message is redacted entirely. +/// /// # Arguments /// /// * `resource` - The resource whose schema it is @@ -1237,14 +1233,14 @@ fn validate_against_schema(resource: &DscResource, schema: &Value, json: &Value, t!("dscresources.commandResource.schemaSourceOutput").to_string(), ), }; - let mut error = SchemaValidationError::new(subject, violations).with_schema(schema); - // a message that quotes a "secureString" or "secureObject" value would expose it, as would the text + let mut error = SchemaValidationError::new(subject, violations).with_schema(schema).with_origin(origin); + // a message that quotes a "secureString" or "secureObject" value would expose it let holds_secure_value = error.violations().iter().any(|violation| { violation.message.contains("\"secureString\"") || violation.message.contains("\"secureObject\"") }); if holds_secure_value { error = error.redacted(&t!("dscresources.commandResource.redactedSchemaErrorMessage")); - } else if let Ok(text) = serde_json::to_string_pretty(json) { + } else if let Ok(text) = serde_json::to_string_pretty(&redact(json)) { error = error.with_source(&text, &name, ""); } Err(DscError::SchemaValidation(Box::new(error))) @@ -1475,3 +1471,63 @@ struct Trace { #[serde(rename = "spans")] _spans: Option>>, } + +#[cfg(test)] +mod tests { + use super::*; + use crate::configure::parameters::SECURE_VALUE_REDACTED; + use miette::Diagnostic; + use serde_json::json; + + fn echo() -> DscResource { + let mut resource = DscResource::new(); + resource.type_name = "Test/Echo".parse().unwrap(); + resource + } + + /// The schema validation error inside a `DscError`. + fn schema_error(error: DscError) -> SchemaValidationError { + match error { + DscError::SchemaValidation(error) => *error, + other => panic!("expected a schema validation error, got {other}"), + } + } + + /// The whole text attached to an error. + fn attached_text(error: &SchemaValidationError) -> String { + let source = error.source_code().expect("the error has source text"); + let contents = source.read_span(&(0..1).into(), 0, 1000).unwrap(); + String::from_utf8_lossy(contents.data()).to_string() + } + + #[test] + fn attached_input_has_secure_values_redacted_even_when_they_are_valid() { + let schema = json!({ + "type": "object", + "additionalProperties": false, + "properties": { + "password": { "type": ["string", "object"] }, + "name": { "type": "string" } + } + }); + let input = json!({ "password": { "secureString": "hunter2" }, "nmae": "x" }); + let error = schema_error(validate_against_schema(&echo(), &schema, &input, JsonOrigin::Input).unwrap_err()); + assert_eq!(error.origin(), JsonOrigin::Input); + assert!(!error.is_redacted()); + let text = attached_text(&error); + assert!(!text.contains("hunter2"), "{text}"); + assert!(text.contains(SECURE_VALUE_REDACTED), "{text}"); + assert!(text.contains("nmae"), "{text}"); + } + + #[test] + fn secure_value_quoted_in_a_message_redacts_the_whole_error() { + let schema = json!({ "type": "object", "properties": { "password": { "type": "string" } } }); + let output = json!({ "password": { "secureString": "hunter2" } }); + let error = schema_error(validate_against_schema(&echo(), &schema, &output, JsonOrigin::Output).unwrap_err()); + assert_eq!(error.origin(), JsonOrigin::Output); + assert!(error.is_redacted()); + assert!(!error.has_source()); + assert!(!error.to_string().contains("hunter2"), "{error}"); + } +} diff --git a/lib/dsc-lib/src/lint/mod.rs b/lib/dsc-lib/src/lint/mod.rs index a7d74a412..2355bea89 100644 --- a/lib/dsc-lib/src/lint/mod.rs +++ b/lib/dsc-lib/src/lint/mod.rs @@ -56,7 +56,7 @@ pub mod rules; pub use diagnostic::{LintCategory, LintDiagnostic, LintLevel, LintResult, LintSummary, SourceLocation}; pub use options::{DocumentKind, LintOptions, LintSettings, RuleLevel, RuleLevelOverride, RuleSetting}; pub use pointer::JsonPointer; -pub use report::LintDiagnosticReport; +pub use report::{LintDiagnosticReport, redacted_source_map}; pub use rule::{CheckFn, DiagnosticSink, LintRuleInfo, Rule, RuleTier}; use context::{collect_resource_sites, LintContext, ResolvedResource}; @@ -326,7 +326,9 @@ impl Linter { /// Parse a document as JSON or YAML, reporting a failure as a diagnostic. /// /// Input that starts with `{` is reported with the JSON parser's message and position so the -/// message matches what the author wrote; everything else uses the YAML parser's. +/// message matches what the author wrote; everything else uses the YAML parser's. Text that is +/// empty or only whitespace is reported as empty rather than as whatever the YAML parser makes +/// of it. /// /// # Arguments /// @@ -334,8 +336,19 @@ impl Linter { /// /// # Errors /// -/// Returns the `parse-error` diagnostic when the text is neither valid JSON nor valid YAML. +/// Returns the `parse-error` diagnostic when the text is empty or neither valid JSON nor valid +/// YAML. fn parse_document(document: &str) -> Result> { + if document.trim().is_empty() { + return Err(Box::new(fixed_diagnostic( + PARSE_ERROR_RULE, + LintCategory::Document, + LintLevel::Error, + &JsonPointer::root(), + None, + t!("lint.mod.emptyDocument").to_string(), + ))); + } let yaml_error = match parse_input_to_value(document) { Ok(value) => return Ok(value), Err(err) => err, @@ -689,6 +702,16 @@ resources: assert!(!result.valid); } + #[test] + fn reports_an_empty_document_as_a_parse_error() { + for document in ["", " \n\t\n"] { + let result = lint_yaml(document); + assert_eq!(rule_paths(&result, PARSE_ERROR_RULE), vec![""], "{document:?}"); + assert_eq!(result.diagnostics.len(), 1, "{document:?}"); + assert!(!result.valid); + } + } + #[test] fn reports_json_parse_errors_with_json_message() { let result = lint_yaml("{\"resources\": [}"); diff --git a/lib/dsc-lib/src/lint/report.rs b/lib/dsc-lib/src/lint/report.rs index 968c137d7..b0627ec5e 100644 --- a/lib/dsc-lib/src/lint/report.rs +++ b/lib/dsc-lib/src/lint/report.rs @@ -8,10 +8,76 @@ use std::fmt::{self, Display}; use miette::{Diagnostic, LabeledSpan, NamedSource, Severity, SourceCode}; use rust_i18n::t; +use serde_json::Value; -use crate::diagnostics::SourceMap; +use crate::{ + configure::parameters::{SECURE_VALUE_REDACTED, is_secure_value}, + diagnostics::SourceMap, + util::parse_input_to_value, +}; -use super::diagnostic::{LintDiagnostic, LintLevel}; +use super::{diagnostic::{LintDiagnostic, LintLevel}, pointer::JsonPointer}; + +/// Parse document text for showing diagnostics in it, with every secure value replaced. +/// +/// The default value of each `secureString` or `secureObject` parameter and every wrapped +/// `{"secureString": ...}` or `{"secureObject": ...}` value is replaced in the text by +/// ``, so a snippet, including the lines of context around the labeled line, never +/// shows a secret the author hard-coded. Diagnostics keep their paths, because the structure of +/// the document is unchanged. +/// +/// # Arguments +/// +/// * `text` - The document text, as JSON or YAML. +/// +/// # Returns +/// +/// The source map of the redacted text, or `None` when the text can't be mapped or a secure +/// value can't be located in it; a host then reports the diagnostics without a snippet. +#[must_use] +pub fn redacted_source_map(text: &str) -> Option { + let map = SourceMap::parse(text)?; + let raw = parse_input_to_value(text).ok()?; + let mut pointers = Vec::new(); + if let Some(parameters) = raw.get("parameters").and_then(Value::as_object) { + for (name, parameter) in parameters { + let secure_type = parameter.get("type").and_then(Value::as_str) + .is_some_and(|kind| kind == "secureString" || kind == "secureObject"); + if secure_type && parameter.get("defaultValue").is_some() { + pointers.push(JsonPointer::root().push("parameters").push(name).push("defaultValue").to_string()); + } + } + } + collect_secure_values(&raw, &JsonPointer::root(), &mut pointers); + map.redacted(&pointers, SECURE_VALUE_REDACTED) +} + +/// Record the pointer of every wrapped secure value at or below a value. +/// +/// # Arguments +/// +/// * `value` - The value to search. +/// * `pointer` - The pointer of `value` in the document. +/// * `pointers` - Where the pointers found are appended. +fn collect_secure_values(value: &Value, pointer: &JsonPointer, pointers: &mut Vec) { + if is_secure_value(value) { + pointers.push(pointer.to_string()); + return; + } + match value { + Value::Object(members) => { + for (key, member) in members { + collect_secure_values(member, &pointer.push(key), pointers); + } + }, + Value::Array(items) => { + for (index, item) in items.iter().enumerate() { + collect_secure_values(item, &pointer.push_index(index), pointers); + } + }, + _ => {}, + } +} /// A lint diagnostic together with the text it points into. /// @@ -136,4 +202,37 @@ mod tests { assert!(report.source_code().is_none()); assert!(report.help().is_none()); } + + #[test] + fn redacted_source_hides_secure_values_and_keeps_locations() { + let text = "parameters:\n password:\n type: secureString\n defaultValue: hunter2\n blob:\n type: secureObject\n defaultValue:\n key: letmein\n plain:\n type: string\n defaultValue: visible\nresources:\n- name: a\n type: T/E\n properties:\n token:\n secureString: topsecret\n output: hi\n"; + let map = redacted_source_map(text).unwrap(); + for secret in ["hunter2", "letmein", "topsecret"] { + assert!(!map.text().contains(secret), "{}", map.text()); + } + assert!(map.text().contains("visible")); + assert_eq!(&map.text()[map.value_span("/parameters/password/defaultValue").unwrap()], "\"\""); + assert_eq!(&map.text()[map.value_span("/parameters/blob/defaultValue").unwrap()], "\"\""); + assert_eq!(&map.text()[map.value_span("/resources/0/properties/token").unwrap()], "\"\""); + let report = LintDiagnosticReport::new(&diagnostic("/parameters/password/defaultValue", LintLevel::Warning), Some(&map), None); + let label = report.labels().unwrap().next().unwrap(); + assert_eq!(&map.text()[label.offset()..label.offset() + label.len()], "defaultValue"); + assert_eq!(map.location(label.offset()), (4, 5)); + } + + #[test] + fn redacted_source_handles_json_documents() { + let text = r#"{"parameters": {"password": {"type": "secureString", "defaultValue": "hunter2"}}, "resources": [{"name": "a", "type": "T/E", "properties": {"token": {"secureObject": {"k": "letmein"}}}}]}"#; + let map = redacted_source_map(text).unwrap(); + assert!(!map.text().contains("hunter2") && !map.text().contains("letmein"), "{}", map.text()); + assert!(map.value_span("/resources/0/properties/token").is_some()); + assert!(map.value_span("/resources/0/name").is_some()); + } + + #[test] + fn text_without_secure_values_is_unchanged_and_unmappable_text_has_no_map() { + let text = "resources:\n- name: a\n type: T/E\n"; + assert_eq!(redacted_source_map(text).unwrap().text(), text); + assert!(redacted_source_map("a: [b").is_none()); + } }