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 bc5d85e86..470db14fb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -77,6 +77,29 @@ changes since the last release, see the [diff on GitHub][unreleased]. ### Added +- 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 + + - Issues: + - [#591][#591] + - [#842][#842] + - PRs: [#1768][#1768] + +
+ - 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`, @@ -86,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, @@ -1764,6 +1803,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 +1868,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 @@ -2000,4 +2041,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/Cargo.lock b/Cargo.lock index 46df2b052..0ac368933 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", @@ -1067,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" @@ -1647,9 +1666,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 +1680,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 +1728,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 +1749,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 +1770,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 +1874,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 +2069,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 +2522,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 +2640,8 @@ version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" dependencies = [ + "serde_core", + "writeable", "zerovec", ] @@ -3056,6 +3141,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 +3470,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 +3533,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 +3599,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 +3653,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 +3744,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 +4777,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 +4788,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 +4836,7 @@ dependencies = [ "proc-macro2", "quote", "syn 2.0.119", - "synstructure", + "synstructure 0.13.2", ] [[package]] @@ -4700,14 +4848,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 +4865,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..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-lib +# dsc, dsc-lib, dsclint miette = { version = "7.6.0" } # dsc-lib murmurhash64 = { version = "0.3" } @@ -228,25 +233,27 @@ 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" } # dsc, dsc-lib, dscecho, registry, dsc-lib-registry, sshdconfig, dsctest, test_group_resource schemars = { version = "1.2.2", features = ["preserve_order"] } # dsc, dsc-lib 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" } @@ -268,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/schemas/outputs/lint/result.md b/docs/reference/schemas/outputs/lint/result.md new file mode 100644 index 000000000..d886f65af --- /dev/null +++ b/docs/reference/schemas/outputs/lint/result.md @@ -0,0 +1,96 @@ +--- +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: dsclint result schema reference +--- + +# dsclint result schema reference + +## Synopsis + +The result output from the `dsclint` command and the `lint_dsc_config` tool of the DSC MCP server. + +## 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 `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. The `dsc schema --type lint-result` command returns this schema. + +## 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`. 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 +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..72e5a4e6b --- /dev/null +++ b/docs/reference/schemas/outputs/lint/rules.md @@ -0,0 +1,110 @@ +--- +description: JSON schema reference for the data returned by the 'dsclint --list-rules' command. +ms.date: 10/23/2026 +ms.topic: reference +title: dsclint rules schema reference +--- + +# dsclint rules schema reference + +## Synopsis + +The output from the `dsclint --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 `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. The `dsc schema --type lint-rules` command +returns this schema. + +## 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/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/tools/dsclint/index.md b/docs/reference/tools/dsclint/index.md new file mode 100644 index 000000000..b3ae54159 --- /dev/null +++ b/docs/reference/tools/dsclint/index.md @@ -0,0 +1,521 @@ +--- +description: Command line reference for the 'dsclint' command +ms.date: 10/23/2026 +ms.topic: reference +title: dsclint command reference +--- + +# dsclint command reference + +## Synopsis + +Checks a configuration document for problems without applying it. + +## Syntax + +### Configuration document from file + +```sh +dsclint [Options] --file +``` + +### Configuration document from option string + +```sh +dsclint [Options] --input +``` + +### Configuration document from stdin + +```sh +cat | dsclint [Options] --file - +``` + +### Several configuration documents + +```sh +dsclint [Options] --file --file +``` + +### List the rules + +```sh +dsclint --list-rules [--output-format ] +``` + +## Description + +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: + +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 [dsclint rules][01]. + +## Examples + +### Example 1 - Lint a configuration document + + + +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. Secure values in the +document, like the default value of a `secureString` parameter, are shown as ``. + +```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 +dsclint --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 +dsclint --file ./example.dsc.config.yaml --output-format 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 + location: + line: 7 + column: 3 +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 +dsclint --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 +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 + + + +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 +dsclint --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 +dsclint --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 [dsclint 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 `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 `dsclint` detects that the output is being redirected or +captured as a variable: + +- 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. + +```yaml +Type : string +Mandatory : false +ValidValues : [json, pretty-json, yaml] +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 command. 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. 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 [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 + +| 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 + +`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 +{ + "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 +[05]: ../../cli/server/index.md diff --git a/docs/reference/tools/dsclint/rules.md b/docs/reference/tools/dsclint/rules.md new file mode 100644 index 000000000..e918b37bb --- /dev/null +++ b/docs/reference/tools/dsclint/rules.md @@ -0,0 +1,369 @@ +--- +description: Reference for the rules the 'dsclint' command checks configuration documents against +ms.date: 10/23/2026 +ms.topic: reference +title: dsclint rules +--- + +# dsclint rules + +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: + +- `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. An empty document is reported by this rule too. 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 + +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 `[[`. 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: + 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. An escaped string such as +`"[[x]"` is checked as the value DSC uses, `"[x]"`. + +### 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 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 + +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. +A `secureString` value must be supplied as a plain string, not in the wrapped +`{"secureString": "..."}` form that a default value can use. + +## 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. `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: +- 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 `dsclint`, which is the version of the `dsc` command it ships with. + +## 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. 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 + +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/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/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..bacf2638a 100644 --- a/dsc/locales/en-us.toml +++ b/dsc/locales/en-us.toml @@ -116,6 +116,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}'" @@ -178,6 +183,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/args.rs b/dsc/src/args.rs index 69e6dba72..effe13cef 100644 --- a/dsc/src/args.rs +++ b/dsc/src/args.rs @@ -318,6 +318,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..36a0fcef6 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; @@ -98,31 +99,7 @@ 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::Extension { subcommand } => { @@ -157,6 +134,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/report.rs b/dsc/src/report.rs new file mode 100644 index 000000000..704054175 --- /dev/null +++ b/dsc/src/report.rs @@ -0,0 +1,105 @@ +// 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::io::{IsTerminal, stderr}; +use std::sync::OnceLock; + +use dsc_lib_telemetry::StderrFormat; +use miette::{Diagnostic, GraphicalReportHandler, GraphicalTheme}; + +/// 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() +} + +/// 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}; + 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}"); + } +} 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/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/src/subcommand.rs b/dsc/src/subcommand.rs index fabd3fda3..4ceec8d3f 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; 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, 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::types::{FullyQualifiedTypeName, ResourceVersionReq, TypeNameFilter}; use dsc_lib::{ configure::{ @@ -70,7 +72,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)); } } @@ -105,7 +107,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)); } } @@ -206,7 +208,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)); } @@ -216,7 +218,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, } } @@ -224,10 +226,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] @@ -258,7 +263,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)); } } @@ -298,14 +303,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), @@ -314,9 +319,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, .. } => { @@ -329,17 +334,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; @@ -356,26 +364,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)?) } }; @@ -426,12 +415,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; } } @@ -482,11 +471,46 @@ 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)) + } + } + } + } +} + /// Validate configuration. /// /// # 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 /// @@ -495,12 +519,12 @@ pub fn config(subcommand: &ConfigSubCommand, parameters: &Option, mounte /// # 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 @@ -523,7 +547,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())); }; @@ -542,13 +566,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 7323cce51..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, @@ -41,6 +42,7 @@ use dsc_lib::{ extension_manifest::ExtensionManifest, }, functions::FunctionDefinition, + lint::{LintResult, LintRuleInfo}, schemas::dsc_repo::{DscRepoSchema, RecognizedSchemaVersion, SchemaForm, SchemaUriPrefix}, types::SemanticVersion, util::{ @@ -227,6 +229,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) }, @@ -442,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() { @@ -455,7 +464,80 @@ 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 { + 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(&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)) + } + } +} + +/// 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 +606,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_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_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/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/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..5fced59e3 --- /dev/null +++ b/dsclint/src/lint.rs @@ -0,0 +1,197 @@ +// 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_document, 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. 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 { + 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_document(Some(input), None)?)); + } + for file in &args.file { + let document = read_document(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..6be3f3665 --- /dev/null +++ b/dsclint/src/report.rs @@ -0,0 +1,124 @@ +// 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. 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, redacted_source_map}; +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. 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)| redacted_source_map(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}"); + } + + #[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/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..f36327b0a --- /dev/null +++ b/dsclint/src/util.rs @@ -0,0 +1,494 @@ +// 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, rejecting an empty document. +/// +/// # 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 { + 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")); + + // 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")); + String::new() + }; + + 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/dsclint/tests/dsclint.tests.ps1 b/dsclint/tests/dsclint.tests.ps1 new file mode 100644 index 000000000..69bdc9072 --- /dev/null +++ b/dsclint/tests/dsclint.tests.ps1 @@ -0,0 +1,316 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +Describe 'dsclint 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 | 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 + $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 | 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 + $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*" + # 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 | 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 + $result.diagnostics[0].location.column | Should -Be 3 + } + + It 'accepts the document with --input' { + $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 | 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 + $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 | 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 | 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 | 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 | 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" | 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 + $result.diagnostics[0].rule | Should -BeExactly 'parse-error' + $result.diagnostics[0].path | Should -BeExactly '' + $result.diagnostics[0].location.line | Should -BeGreaterThan 0 + } + + It 'reports an empty document as a diagnostic instead of failing to read it' { + $result = "`n" | 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 + $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 +resources: +- name: a + type: Microsoft.DSC.Debug/Echo + unknownKey: true +"@ + $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 + $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 | 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) + $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 | 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' + $diagnostic.message | Should -BeLike '*a -> b -> a*' + } + + It 'warns when --system-root is given but systemRoot() is not used' { + $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 + $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 | 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 + } + + 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 | 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' + $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 | 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 | 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 + } + + 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 = 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' + } + + 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 | 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 = 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' + ($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 = 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 'lints the shipped example without errors: ' -TestCases ( + 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 = 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/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 6acaf9653..1c6b6b04d 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}'" @@ -266,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" @@ -1099,3 +1109,140 @@ 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}" +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}" +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.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" +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}" +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}'?" +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}'" +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}" +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 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." +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..13118c2af --- /dev/null +++ b/lib/dsc-lib/locales/schemas.outputs.yaml @@ -0,0 +1,27 @@ +_version: 2 +schemas: + outputs: + lint: + result: + title: + en-us: dsclint result + description: + en-us: >- + Describes the output of the `dsclint` command. + markdownDescription: + en-us: |- + 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: dsclint rule + description: + en-us: >- + 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 + `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/lib/dsc-lib/src/configure/mod.rs b/lib/dsc-lib/src/configure/mod.rs index 3ed7fd523..59092a931 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, JsonOrigin}; 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 @@ -331,7 +333,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()); @@ -548,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() { @@ -566,6 +578,57 @@ 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 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. 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 + /// + /// * `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; + }; + 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); + }; + 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 @@ -641,7 +704,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(); @@ -755,7 +818,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(); @@ -767,7 +830,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)); }, }; @@ -777,7 +840,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)); }, }; @@ -796,7 +859,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(); @@ -923,7 +986,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(); @@ -1016,7 +1079,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)?); @@ -1562,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 { @@ -1639,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 new file mode 100644 index 000000000..ec687a38e --- /dev/null +++ b/lib/dsc-lib/src/diagnostics/mod.rs @@ -0,0 +1,44 @@ +// 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, 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::{JsonOrigin, 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..06075644e --- /dev/null +++ b/lib/dsc-lib/src/diagnostics/schema.rs @@ -0,0 +1,461 @@ +// 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; + +/// 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 +/// [`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. +/// +/// 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, +} + +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 + /// `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, + 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 + /// + /// * `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 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; + }; + 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. + /// + /// 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. + #[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.redacted = true; + self.source = None; + self.labels.clear(); + self.help = None; + self + } + + /// What was validated. + #[must_use] + pub fn subject(&self) -> &str { + &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] { + &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.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"); + 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..ac7e27929 --- /dev/null +++ b/lib/dsc-lib/src/diagnostics/source_map.rs @@ -0,0 +1,616 @@ +// 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]; + } + } + + /// 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 + /// + /// * `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()); + } + + #[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/dscerror.rs b/lib/dsc-lib/src/dscerror.rs index 70d75140f..57dc04f05 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), @@ -149,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..778812a1f 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::{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::{ - 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,7 @@ fn get_command_input(input_kind: Option<&InputKind>, input: &str) -> Result) -> Result<(), DscError> { +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 +1195,55 @@ 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. +/// +/// 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 +/// * `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).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(&redact(json)) { + error = error.with_source(&text, &name, ""); + } + Err(DscError::SchemaValidation(Box::new(error))) } fn json_to_hashmap(json: &str) -> Result, DscError> { @@ -1449,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/dscresources/dscresource.rs b/lib/dsc-lib/src/dscresources/dscresource.rs index 735dbf634..e3c3357fe 100644 --- a/lib/dsc-lib/src/dscresources/dscresource.rs +++ b/lib/dsc-lib/src/dscresources/dscresource.rs @@ -1,12 +1,12 @@ // 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; use dscerror::DscError; -use jsonschema::Validator; +use jsonschema::{error::ValidationErrorKind, ValidationError, Validator}; use rust_i18n::t; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; @@ -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,10 +919,103 @@ 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)); + 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)))) +} + +/// 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. +#[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 keyword that failed, such as `type` or `anyOf`. + 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>, +} + +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(), + }; + 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, + } + } +} + +/// 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. The JSON isn't traced because +/// it can hold secure values. +/// +/// # 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.schema", schema = schema)); let compiled_schema = match Validator::new(schema) { Ok(compiled_schema) => compiled_schema, @@ -931,11 +1024,7 @@ pub fn validate_json(source: &str, schema: &Value, json: &Value) -> Result<(), D } }; - if let Err(err) = compiled_schema.validate(json) { - return Err(DscError::Validation(format!("{}: '{source}' {err}", t!("dscresources.dscresource.validationFailed")))); - } - - Ok(()) + Ok(compiled_schema.iter_errors(json).map(|error| SchemaViolation::from_error(&error)).collect()) } /// Compares two arrays independent of order 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 715420a23..621380793 100644 --- a/lib/dsc-lib/src/lib.rs +++ b/lib/dsc-lib/src/lib.rs @@ -12,11 +12,13 @@ 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; 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..a018aea28 --- /dev/null +++ b/lib/dsc-lib/src/lint/context.rs @@ -0,0 +1,226 @@ +// 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, + schemas::dsc_repo::DscRepoSchema, +}; + +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 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. + 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..66fcf2715 --- /dev/null +++ b/lib/dsc-lib/src/lint/expression_index.rs @@ -0,0 +1,438 @@ +// 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. 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 + /// + /// * `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.add_string(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.add_string(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 key == "properties" && site.has_embedded_configuration() { + continue; + } + 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 + } + + /// 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 + /// + /// * `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(_) => 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); + } + }, + 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()); + } + + #[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/mod.rs b/lib/dsc-lib/src/lint/mod.rs new file mode 100644 index 000000000..2355bea89 --- /dev/null +++ b/lib/dsc-lib/src/lint/mod.rs @@ -0,0 +1,869 @@ +// 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, + }, + diagnostics::SourceMap, + 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 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, redacted_source_map}; +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. Every diagnostic gets the line and column of its location in the text. + /// + /// # 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) => { + 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)), + } + } + + /// 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. Text that is +/// empty or only whitespace is reported as empty rather than as whatever the YAML parser makes +/// of it. +/// +/// # Arguments +/// +/// * `document` - The document text. +/// +/// # Errors +/// +/// 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, + }; + 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 +} + +/// 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 +/// +/// * `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_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\": [}"); + 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/report.rs b/lib/dsc-lib/src/lint/report.rs new file mode 100644 index 000000000..b0627ec5e --- /dev/null +++ b/lib/dsc-lib/src/lint/report.rs @@ -0,0 +1,238 @@ +// 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 serde_json::Value; + +use crate::{ + configure::parameters::{SECURE_VALUE_REDACTED, is_secure_value}, + diagnostics::SourceMap, + util::parse_input_to_value, +}; + +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. +/// +/// 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()); + } + + #[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()); + } +} 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..b5adaa2d1 --- /dev/null +++ b/lib/dsc-lib/src/lint/rules/dependency.rs @@ -0,0 +1,543 @@ +// 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 by their names as written, +/// which is how DSC resolves `dependsOn`. +/// +/// # 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 { + 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); + } + 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. 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()); + for &index in &group { + let site = &context.resources[index]; + if site.resource.copy.is_some() { + continue; + } + 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, + 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 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#" +$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..70ff62bff --- /dev/null +++ b/lib/dsc-lib/src/lint/rules/expression.rs @@ -0,0 +1,382 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Rules about expressions and the functions they call. + +use rust_i18n::t; + +use crate::configure::config_doc::DataType; +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() +} + +/// 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() { + 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()); + } +} + +/// 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 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#" +$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..bd81201d6 --- /dev/null +++ b/lib/dsc-lib/src/lint/rules/parameter.rs @@ -0,0 +1,729 @@ +// 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}, + }, + parser::ParsedStatement, +}; + +/// 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. +/// +/// 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; + }; + for (name, parameter) in parameters { + let Some(value) = ¶meter.default_value else { + continue; + }; + if value.as_str().is_some_and(LintContext::is_expression) { + continue; + } + 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()); + } + } +} + +/// 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. +/// +/// 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(_)) || matches!(output.r#type, DataType::SecureString | DataType::SecureObject) { + 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()); + } + } +} + +/// 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 { + return; + }; + for (name, value) in supplied { + let Some(parameter) = context.declared_parameter(name) else { + continue; + }; + 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()); + } + } +} + +#[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 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#" +$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').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"]); + } + + #[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..ef1653f4f --- /dev/null +++ b/lib/dsc-lib/src/lint/rules/resource.rs @@ -0,0 +1,544 @@ +// 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, SchemaViolation}, + resource_manifest::{Kind, SchemaKind}, + }, + lint::{ + context::{LintContext, ResolvedResource}, + diagnostic::LintCategory, + expression_index::ExpressionScope, + options::RuleLevel, + pointer::JsonPointer, + rule::{DiagnosticSink, Rule, RuleTier}, + }, + parser::ParsedStatement, +}; + +/// 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(), + ); + } + } +} + +/// 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. +/// +/// 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 { + 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 (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( + &base, + t!("lint.rules.resource.propertySchemaCompileFailed", type_name = site.resource.resource_type, error = LintContext::error_message(&err)).to_string(), + ); + continue; + }, + }; + for violation in violations { + if !is_definite(&violation, &base, &unknown) { + continue; + } + sink.report( + &base.join(&violation.instance_path), + 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 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![ + 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 d39abe74e..93444ffc0 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/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 diff --git a/xtask/src/schemas/export.rs b/xtask/src/schemas/export.rs index 211efc91c..002abd4d0 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::settings::fields::ForbidIgnoreSettingsFileField, dsc_lib::settings::fields::IgnoreSettingsFileField, dsc_lib::settings::fields::ResourcePathFileData,