diff --git a/lib/dsc-lib/locales/schemas.settings.yaml b/lib/dsc-lib/locales/schemas.settings.yaml new file mode 100644 index 000000000..e69de29bb diff --git a/lib/dsc-lib/src/lib.rs b/lib/dsc-lib/src/lib.rs index aa7299a15..9c6a5443a 100644 --- a/lib/dsc-lib/src/lib.rs +++ b/lib/dsc-lib/src/lib.rs @@ -19,10 +19,12 @@ pub mod extensions; pub mod functions; pub mod parser; pub mod progress; +pub mod settings; pub mod types; pub mod util; // Re-export the dependency crate to minimize dependency management. +#[doc(inline)] pub use dsc_lib_jsonschema as schemas; i18n!("locales", fallback = "en-us"); diff --git a/lib/dsc-lib/src/settings/constants_and_statics.rs b/lib/dsc-lib/src/settings/constants_and_statics.rs new file mode 100644 index 000000000..b27fc965c --- /dev/null +++ b/lib/dsc-lib/src/settings/constants_and_statics.rs @@ -0,0 +1,119 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Defines static lazy-initialized paths to the various settings files used by DSC. These paths +//! are determined at runtime based on the operating system and environment variables, and they +//! provide a consistent way to access the settings files across different platforms. + +use std::{path::PathBuf, sync::{LazyLock}}; + +/// Name of the settings file used for the machine, user, and workspace scopes. +pub const SETTINGS_PREFERENCE_FILE_NAME: &str = "dsc.settings.json"; +/// Name of the policy file used for the policy scope. +pub const SETTINGS_POLICY_FILE_NAME: &str = "dsc.policy.json"; + +/// Defines the full path to the policy settings file, which is located in a platform-specific +/// folder. +/// +/// The pseudo-path for this file depends on the platform: +/// +/// - On Windows: `{ProgramData}\dsc\dsc.policy.json` +/// - On macOS: `/Library/Application Support/dsc/dsc.policy.json` +/// - On Linux and other Unix-like systems: `/etc/dsc/dsc.policy.json` +pub static POLICY_SETTINGS_FILE_PATH: LazyLock = LazyLock::new(|| { + #[cfg(target_os = "windows")] + { + let program_data = std::env::var_os("ProgramData") + .expect("Couldn't retrieve the ProgramData environment variable"); + std::path::Path::new(&program_data).join("dsc").join(SETTINGS_POLICY_FILE_NAME) + } + #[cfg(target_os = "macos")] + { + std::path::Path::new("/Library").join("Application Support").join("dsc").join(SETTINGS_POLICY_FILE_NAME) + } + #[cfg(not(any(target_os = "windows", target_os = "macos")))] + { + std::path::Path::new("/etc").join("dsc").join(SETTINGS_POLICY_FILE_NAME) + } +}); + +/// Defines the full path to the machine settings file, which is located in a platform-specific +/// folder. +/// +/// The pseudo-path for this file depends on the platform: +/// +/// - On Windows: `{ProgramData}\dsc\dsc.settings.json` +/// - On macOS: `/Library/Application Support/dsc/dsc.settings.json` +/// - On Linux and other Unix-like systems: `/etc/dsc/dsc.settings.json` +pub static MACHINE_SETTINGS_FILE_PATH: LazyLock = LazyLock::new(|| { + #[cfg(target_os = "windows")] + { + let program_data = std::env::var_os("ProgramData") + .expect("Couldn't retrieve the ProgramData environment variable"); + std::path::Path::new(&program_data).join("dsc").join(SETTINGS_PREFERENCE_FILE_NAME) + } + #[cfg(target_os = "macos")] + { + std::path::Path::new("/Library").join("Application Support").join("dsc").join(SETTINGS_PREFERENCE_FILE_NAME) + } + #[cfg(not(any(target_os = "windows", target_os = "macos")))] + { + std::path::Path::new("/etc").join("dsc").join(SETTINGS_PREFERENCE_FILE_NAME) + } +}); + +/// Defines the full path to the user settings file, which is located in a platform-specific folder +/// based on the user's home directory or environment variables. +/// +/// The pseudo-path for this file depends on the platform and whether the `XDG_CONFIG_HOME` +/// environment variable is set. When `XDG_CONFIG_HOME` is set, the path is always +/// `{XDG_CONFIG_HOME}/dsc/dsc.settings.json` (using `\` instead of `/` on Windows). Otherwise, +/// the path varies by platform: +/// +/// - On Windows: `{APPDATA}\dsc\dsc.settings.json` +/// - On macOS: `{HOME}/Library/Application Support/dsc/dsc.settings.json` +/// - On Linux and other Unix-like systems: `{HOME}/.config/dsc/dsc.settings.json` +pub static USER_SETTINGS_FILE_PATH: LazyLock = LazyLock::new(|| { + if let Some(xdg_config_home) = std::env::var_os("XDG_CONFIG_HOME") { + return std::path::Path::new(&xdg_config_home) + .join("dsc") + .join(SETTINGS_PREFERENCE_FILE_NAME); + } + #[cfg(target_os = "windows")] + { + let app_data = std::env::var_os("APPDATA") + .expect("Couldn't retrieve the APPDATA environment variable"); + return std::path::Path::new(&app_data) + .join("dsc") + .join(SETTINGS_PREFERENCE_FILE_NAME); + } + #[cfg(target_os = "macos")] + { + let home = std::env::var_os("HOME") + .expect("Couldn't retrieve the HOME environment variable"); + return std::path::Path::new(&home) + .join("Library") + .join("Application Support") + .join("dsc") + .join(SETTINGS_PREFERENCE_FILE_NAME); + } + #[cfg(not(any(target_os = "windows", target_os = "macos")))] + { + let home = std::env::var_os("HOME") + .expect("Couldn't retrieve the HOME environment variable"); + return std::path::Path::new(&home) + .join(".config") + .join("dsc") + .join(SETTINGS_PREFERENCE_FILE_NAME); + } +}); + +/// Defines the full path to the workspace settings file, which is located in the current working +/// directory. +/// +/// The pseudo-path for this file is `{CWD}/dsc.settings.json`. +pub static WORKSPACE_SETTINGS_FILE_PATH: LazyLock = LazyLock::new(|| { + std::env::current_dir() + .expect("Couldn't retrieve the current working directory") + .join(SETTINGS_PREFERENCE_FILE_NAME) +}); diff --git a/lib/dsc-lib/src/settings/dsc_settings_scope.rs b/lib/dsc-lib/src/settings/dsc_settings_scope.rs new file mode 100644 index 000000000..988ce9bee --- /dev/null +++ b/lib/dsc-lib/src/settings/dsc_settings_scope.rs @@ -0,0 +1,78 @@ +use std::fmt::Display; + +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +/// Defines the source of a setting value. +/// +/// DSC supports multiple sources for settings. This enum represents the source of a setting value. +/// The sources are ordered by precedence, with the highest precedence source being the one that +/// DSC uses. +/// +/// The highest precedence source is [`Policy`], which is defined in the machine policy file. Fields +/// defined as policy cannot be overridden by any other source, including environment variables or +/// command line arguments. +/// +/// [`Policy`]: Self::Policy +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub enum DscSettingsScope { + /// The default settings staticalally defined in the DSC codebase. + Default, + /// The settings defined for all users on the machine in a [preference settings file]. + /// + /// The location for the settings file in this scope depends on the operating system: + /// + /// - On Windows, it is typically located at `{PROGRAM_DATA}\DSC\machine_settings.json`. + /// - On Unix-like systems, it is typically located at `/etc/dsc/machine_settings.json`. + /// + /// [preference settings file]: crate::settings::DscPreferenceFileData + Machine, + /// The settings defined for the current user in a [preference settings file]. + /// + /// [preference settings file]: crate::settings::DscPreferenceFileData + User, + /// The settings defined for the current workspace in a [preference settings file]. + /// + /// [preference settings file]: crate::settings::DscPreferenceFileData + Workspace, + /// Settings defined as environment variables. + Environment, + /// Settings defined as command line arguments. + #[serde(rename = "cli")] + CommandLine, + /// The system policy file. Fields defined as policy cannot be overridden. + Policy, +} + +impl DscSettingsScope { + pub const ALL: [DscSettingsScope; 7] = [ + DscSettingsScope::Default, + DscSettingsScope::Machine, + DscSettingsScope::User, + DscSettingsScope::Workspace, + DscSettingsScope::Environment, + DscSettingsScope::CommandLine, + DscSettingsScope::Policy, + ]; + pub const FILE_BASED: [DscSettingsScope; 3] = [ + DscSettingsScope::Machine, + DscSettingsScope::User, + DscSettingsScope::Workspace, + ]; +} + +impl Display for DscSettingsScope { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let source_str = match self { + DscSettingsScope::Default => "default", + DscSettingsScope::Machine => "machine", + DscSettingsScope::User => "user", + DscSettingsScope::Workspace => "workspace", + DscSettingsScope::Environment => "environment", + DscSettingsScope::CommandLine => "cli", + DscSettingsScope::Policy => "policy", + }; + write!(f, "{}", source_str) + } +} diff --git a/lib/dsc-lib/src/settings/errors.rs b/lib/dsc-lib/src/settings/errors.rs new file mode 100644 index 000000000..dfc325d98 --- /dev/null +++ b/lib/dsc-lib/src/settings/errors.rs @@ -0,0 +1,67 @@ +use miette::Diagnostic; +use thiserror::Error; +use rust_i18n::t; + +#[derive(Error, Debug, Diagnostic)] +pub enum DscSettingsError { + #[error("{t}", t = t!( + "settings.errors.invalidDataFileMultipleErrors", + path = file_path, + err = errors.iter().map(|e| e.to_string()).collect::>().join(", ") + ))] + InvalidDataFileMultipleErrors { + file_path: String, + #[related] + errors: Vec, + }, + #[error("{t}: {0}", t = t!("settings.errors.invalidIgnoreSettingsFileEnvVar"))] + InvalidIgnoreSettingsFileEnvVar(String), + #[error("{t}: {0}", t = t!("settings.errors.invalidTraceLevel"))] + InvalidTraceLevel(String), + #[error("{t}: {0}", t = t!("settings.errors.invalidTraceFormat"))] + InvalidTraceFormat(String), + /// The settings file could not be read. + #[error("{t}", t = t!("settings.errors.fileReadError", file_path = file_path))] + FileReadError { + file_path: String, + #[source] + source: std::io::Error, + }, + /// The settings file could not be written. + #[error("{t}", t = t!("settings.errors.fileWriteError", file_path = file_path))] + FileWriteError{ + file_path: String, + #[source] + source: std::io::Error, + }, + /// The settings file could not be found. + #[error("{t}", t = t!("settings.errors.fileNotFound", path = file_path))] + FileNotFound{ + file_path: String, + }, + /// The settings file could not be parsed. + #[error("{t}", t = t!("settings.errors.parseDataFileError", path = file_path, err = source))] + ParseDataFileError{ + file_path: String, + #[source] + source: serde_json::Error, + }, + /// Indicates an error when parsing a boolean environment variable. + #[error("{t}", t = t!("settings.errors.parseBooleanEnvVarError", value = value))] + ParseBooleanEnvVarError{ + value: String, + }, + /// Multiple errors occurred while loading settings. + #[error("{t}: {0:?}", t = t!("settings.errors.loadMultipleErrors"))] + LoadMultipleErrors(Vec), + /// Indicates an error when loading an environment variable. + #[error("{t}", t = t!("settings.errors.loadEnvironmentError"))] + LoadEnvironmentError{ + env_var: &'static str, + #[source] + source: Box, + }, + /// Multiple errors occurred while loading settings from environment variables. + #[error("{t}: {0:?}", t = t!("settings.errors.loadEnvironmentMultipleErrors"))] + LoadEnvironmentMultipleErrors(Vec), +} \ No newline at end of file diff --git a/lib/dsc-lib/src/settings/fields/forbid_ignore_settings_file.rs b/lib/dsc-lib/src/settings/fields/forbid_ignore_settings_file.rs new file mode 100644 index 000000000..e684b2e91 --- /dev/null +++ b/lib/dsc-lib/src/settings/fields/forbid_ignore_settings_file.rs @@ -0,0 +1,2 @@ +/// Defines the default value for the `forbid_ignore_settings_file` field in DSC settings. +pub const CODE_DEFAULT_FORBID_IGNORE_SETTINGS_FILE: bool = false; \ No newline at end of file diff --git a/lib/dsc-lib/src/settings/fields/ignore_settings_file.rs b/lib/dsc-lib/src/settings/fields/ignore_settings_file.rs new file mode 100644 index 000000000..ee3ed3a1f --- /dev/null +++ b/lib/dsc-lib/src/settings/fields/ignore_settings_file.rs @@ -0,0 +1 @@ +pub const CODE_DEFAULT_IGNORE_SETTINGS_FILE: bool = false; diff --git a/lib/dsc-lib/src/settings/fields/mod.rs b/lib/dsc-lib/src/settings/fields/mod.rs new file mode 100644 index 000000000..f8603b018 --- /dev/null +++ b/lib/dsc-lib/src/settings/fields/mod.rs @@ -0,0 +1,136 @@ +//! Defines the fields used in DSC settings. +//! +//! This documentation describes the structure and provides guidance for implementing new fields in +//! DSC settings. +//! +//! Follow this guidance when defining new top-level fields in DSC settings: +//! +//! 1. Define a private submodule for the setting field in this module. The submodule should have +//! the same name as the field. +//! 1. Re-export all public items from the submodule in this module. +//! +//! For example, if the field is `new_area`, you would add the following lines to this file: +//! +//! ```ignore +//! mod new_area; +//! pub use new_area::*; +//! ``` +//! +//! Additional guidance is provided in the following sections for defining leaf fields and +//! container fields. For the purposes of this guidance, a container field is a field that has one +//! or more subfields (like [`resource_path`]), while a leaf field is a field that doesn't have any +//! subfields (like [`forbid_ignore_settings_file`]). +//! +//! # Leaf fields +//! +//! In the submodule for a leaf field, follow this guidance to define the types and constants +//! needed to represent the field in DSC settings: +//! +//! 1. If the field requires a new type to represent its value, define the type in the submodule: +//! +//! - Use the naming convention `Field`, like `NewAreaField`. +//! - Implement (or derive) [`Clone`], [`Debug`], [`PartialEq`], [`Eq`], [`Serialize`], +//! [`Deserialize`], and [`JsonSchema`] for the type. +//! 1. Define a constant for the code default value of the field: +//! +//! - Use the naming convention `CODE_DEFAULT_`, like `CODE_DEFAULT_NEW_AREA`. +//! 1. Ensure that the appropriate field is defined in the following structs for the setting: +//! +//! - [`DscPolicyFileData`] +//! - [`DscPreferenceFileData`] +//! 1. Ensure that the [`DscCodeDefaults`] struct defines the field with the appropriate value type +//! and update the [`CODE_DEFAULT_SETTINGS`] constant by setting the field to the code +//! default constant for the field. +//! 1. Ensure that the [`DscSettingsResolved`] struct defines the field as a +//! [`DscSettingsResolvedField`] with the appropriate value type and update the [`Default`] +//! implementation for the struct to initialize the field with the code default constant. +//! 1. Every top-level leaf field in DSC settings must be definable as an environment variable. +//! Follow the guidance in [`environment`] to define the appropriate field in that struct. +//! 1. If the field is definable in the command line arguments, follow the guidance in [`cli`] to +//! define the command line argument for the field. +//! +//! # Container fields +//! +//! In the submodule for a container field, follow this guidance to define the types and constants +//! needed to represent the field in DSC settings: +//! +//! 1. Define a struct to represent the container setting in settings files: +//! +//! - If the setting is identical in both the preference file and the policy file, define a +//! single struct for the setting container using the naming convention +//! `FileData`, like `NewAreaFileData`. +//! - If the setting is different between the preference file and the policy file, define +//! separate structs for each using the following naming conventions: +//! +//! - `PreferenceFileData`, like `NewAreaPreferenceFileData` +//! - `PolicyFileData`, like `NewAreaPolicyFileData +//! - When defining both file data structs, ensure that the `*PolicyFileData` struct is _always_ +//! a superset of the `*PreferenceFileData` struct. This ensures that any field defined in the +//! preference file can also be defined in the policy file. Policy must _always_ be able to +//! override preferences. +//! - Define _every_ field in file data structs as an `Option`. No field in a data file must +//! be required. If a field is not defined in a data file, it will be `None` in the +//! corresponding struct. +//! - As needed, define types for leaf fields in the file data structs. Use the naming +//! convention `Field`, like `NewAreaFooField`. +//! - As needed, define structs for nested container fields in the file data structs. Use the +//! naming convention `FileData`, like `NewAreaFooFileData`. +//! 1. Define a struct to represent the code defaults for the setting container: +//! +//! - Use the naming convention `CodeDefaults`, like `NewAreaCodeDefaults`. +//! - Define every leaf field in the code defaults struct as the appropriate value type. Don't +//! define any fields as `Option` in the code defaults struct. +//! - If you defined any structs for nested container fields in the file data structs, define a +//! corresponding struct for the code defaults. Use the naming convention +//! `CodeDefaults`, like `NewAreaFooCodeDefaults`. +//! 1. Define a constant for the code defaults: +//! - Use the naming convention `CODE_DEFAULT_`, like +//! `CODE_DEFAULT_NEW_AREA`. +//! - Define the constant with the appropriate values for every field. +//! 1. Define a struct to represent the resolved settings for the setting container: +//! +//! - Use the naming convention `ResolvedSettings`, like +//! `NewAreaResolvedSettings`. +//! - Define every leaf field in the resolved settings struct as a +//! [`DscSettingsResolvedField`] with the appropriate value type. +//! - If you defined any structs for nested container fields in the file data structs, define a +//! corresponding struct for the resolved settings. Use the naming convention +//! `ResolvedSettings`, like `NewAreaFooResolvedSettings`. +//! 1. Ensure that the following traits are implemented for every type defined in this module: +//! +//! - Always implement (or derive) [`Clone`], [`Debug`], [`PartialEq`], [`Eq`], [`Serialize`], +//! [`Deserialize`], and [`JsonSchema`]. +//! - Implement [`Default`] for every `*FileData` and `*ResolvedSettings` struct. You can derive +//! the implementation for `*FileData` structs, but you must implement it manually for +//! `*ResolvedSettings` structs. The implementation for `*ResolvedSettings` structs must +//! initialize every field with the appropriate code default value and a scope of +//! [`DscSettingsScope::Default`]. +//! 1. Ensure that the appropriate field is defined in the following structs for the settings +//! container: +//! +//! - [`DscPolicyFileData`] - define the field with the `*PolicyFileData` struct type or the +//! `*FileData` struct type. +//! - [`DscPreferenceFileData`] - define the field with the `*PreferenceFileData` struct type or +//! the `*FileData` struct type. +//! - [`DscCodeDefaults`] - define the field with the `*CodeDefaults` struct type. +//! - [`DscSettingsResolved`] - define the field with the `*ResolvedSettings` struct type. +//! 1. If any settings for the container are definable in the environment, follow the guidance in +//! [`environment`] to define the appropriate field in that struct. +//! 1. If any settings for the container are definable in the command line arguments, follow the +//! guidance in [`cli`] to define the appropriate field in that struct. +//! +//! [`DscPolicyFileData`]: crate::settings::DscPolicyFileData +//! [`DscPreferenceFileData`]: crate::settings::DscPreferenceFileData +//! [`DscCodeDefaults`]: crate::settings::DscCodeDefaults +//! [`DscSettingsResolved`]: crate::settings::DscSettingsResolved +//! [`environment`]: crate::settings::sources::environment +//! [`cli`]: crate::settings::sources::cli + +mod forbid_ignore_settings_file; +pub use forbid_ignore_settings_file::*; +mod ignore_settings_file; +pub use ignore_settings_file::*; +mod resource_path; +pub use resource_path::*; +mod tracing; +pub use tracing::*; diff --git a/lib/dsc-lib/src/settings/fields/resource_path.rs b/lib/dsc-lib/src/settings/fields/resource_path.rs new file mode 100644 index 000000000..b0763a0fd --- /dev/null +++ b/lib/dsc-lib/src/settings/fields/resource_path.rs @@ -0,0 +1,133 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use std::path::PathBuf; + +use crate::settings::{DscSettingsResolvedField, DscSettingsScope}; +use crate::schemas::{dsc_repo::DscRepoSchema, schema_i18n}; + +use schemars::{JsonSchema, json_schema}; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, DscRepoSchema)] +#[serde(rename_all = "camelCase")] +#[dsc_repo_schema(base_name = "resourcePath", folder_path = "settings/fields")] +pub struct ResourcePathFileData { + /// Directories that DSC should search for executables and manifests. + pub directories: Option>, + /// Whether to append the `PATH` environment variable to the list of directories. + pub append_env_path: Option, + /// Whether DSC should allow invoking binaries outside of those listed in [`directories`]. + /// + /// [`directories`]: Self::directories + pub restricted: Option, +} + +impl JsonSchema for ResourcePathFileData { + fn schema_name() -> std::borrow::Cow<'static, str> { + Self::default_schema_id_uri().into() + } + fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema { + json_schema!({ + "title": schema_i18n!("title"), + "description": schema_i18n!("description"), + "markdownDescription": schema_i18n!("markdownDescription"), + "type": "object", + "properties": { + "appendEnvPath": { + "type": "boolean", + "title": schema_i18n!("appendEnvPath.title"), + "description": schema_i18n!("appendEnvPath.description"), + "markdownDescription": schema_i18n!("appendEnvPath.markdownDescription") + }, + "directories": { + "type": "array", + "items": { + "type": "string" + }, + "title": schema_i18n!("directories.title"), + "description": schema_i18n!("directories.description"), + "markdownDescription": schema_i18n!("directories.markdownDescription") + }, + "restrictPath": { + "type": "boolean", + "title": schema_i18n!("restrictPath.title"), + "description": schema_i18n!("restrictPath.description"), + "markdownDescription": schema_i18n!("restrictPath.markdownDescription") + } + }, + "anyOf": [ + { + "if": { + "properties": { "restrictPath": { "const": true } } + }, + "then": { + "anyOf": [ + { "not": { "required": ["appendEnvPath"] } }, + { "properties": { "appendEnvPath": { "const": false } } } + ] + } + }, + { + "if": { + "properties": { "appendEnvPath": { "const": true } } + }, + "then": { + "anyOf": [ + { "not": { "required": ["restrictPath"] } }, + { "properties": { "restrictPath": { "const": false } } } + ] + } + } + ] + }) + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub struct ResourcePathCodeDefaults { + pub directories: Vec, + pub append_env_path: bool, + pub restricted: bool, +} + +impl Default for ResourcePathCodeDefaults { + fn default() -> Self { + CODE_DEFAULT_RESOURCE_PATH + } +} + +/// Defines the default values for the resource path configuration in DSC. +/// +/// The following snippet shows the effective code defaults as YAML data: +/// +/// ```yaml +/// resource_path: +/// directories: [] +/// append_env_path: true +/// restricted: false +/// ``` +pub const CODE_DEFAULT_RESOURCE_PATH: ResourcePathCodeDefaults = ResourcePathCodeDefaults { + directories: vec![], + append_env_path: true, + restricted: false, +}; + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct ResourcePathResolvedSettings { + pub directories: DscSettingsResolvedField>, + pub append_env_path: DscSettingsResolvedField, + pub restricted: DscSettingsResolvedField, +} + +impl Default for ResourcePathResolvedSettings { + fn default() -> Self { + let scope = DscSettingsScope::Default; + Self { + directories: DscSettingsResolvedField::new(CODE_DEFAULT_RESOURCE_PATH.directories.clone(), scope), + append_env_path: DscSettingsResolvedField::new(CODE_DEFAULT_RESOURCE_PATH.append_env_path, scope), + restricted: DscSettingsResolvedField::new(CODE_DEFAULT_RESOURCE_PATH.restricted, scope), + } + } +} diff --git a/lib/dsc-lib/src/settings/fields/tracing/fields.rs b/lib/dsc-lib/src/settings/fields/tracing/fields.rs new file mode 100644 index 000000000..eb50839fe --- /dev/null +++ b/lib/dsc-lib/src/settings/fields/tracing/fields.rs @@ -0,0 +1,128 @@ +use std::{fmt::Display, str::FromStr}; + +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use crate::settings::DscSettingsError; + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase", try_from = "String", into = "String")] +pub enum TraceLevelField { + Error, + Warn, + Info, + Debug, + Trace, +} + +impl FromStr for TraceLevelField { + type Err = DscSettingsError; + + fn from_str(s: &str) -> Result { + match s.to_lowercase().as_str() { + "error" => Ok(TraceLevelField::Error), + "warn" => Ok(TraceLevelField::Warn), + "info" => Ok(TraceLevelField::Info), + "debug" => Ok(TraceLevelField::Debug), + "trace" => Ok(TraceLevelField::Trace), + _ => Err(DscSettingsError::InvalidTraceLevel(s.to_string())), + } + } +} + +impl TryFrom for TraceLevelField { + type Error = DscSettingsError; + + fn try_from(value: String) -> Result>::Error> { + TraceLevelField::from_str(&value) + } +} + +impl From for String { + fn from(level: TraceLevelField) -> Self { + level.to_string() + } +} + +impl Display for TraceLevelField { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let format_str = match self { + TraceLevelField::Error => "error", + TraceLevelField::Warn => "warn", + TraceLevelField::Info => "info", + TraceLevelField::Debug => "debug", + TraceLevelField::Trace => "trace", + }; + write!(f, "{}", format_str) + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase", try_from = "String", into = "String")] +pub enum TraceFormatField { + Default, + Plaintext, + Json, +} + +impl FromStr for TraceFormatField { + type Err = DscSettingsError; + + fn from_str(s: &str) -> Result { + match s.to_lowercase().as_str() { + "default" => Ok(TraceFormatField::Default), + "plaintext" => Ok(TraceFormatField::Plaintext), + "json" => Ok(TraceFormatField::Json), + _ => Err(DscSettingsError::InvalidTraceFormat(s.to_string())), + } + } +} + +impl Display for TraceFormatField { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let format_str = match self { + TraceFormatField::Default => "default", + TraceFormatField::Plaintext => "plaintext", + TraceFormatField::Json => "json", + }; + write!(f, "{}", format_str) + } +} + +impl From for String { + fn from(format: TraceFormatField) -> Self { + format.to_string() + } +} + +impl TryFrom for TraceFormatField { + type Error = DscSettingsError; + + fn try_from(value: String) -> Result>::Error> { + TraceFormatField::from_str(&value) + } +} + +impl From for tracing::Level { + fn from(level: TraceLevelField) -> Self { + match level { + TraceLevelField::Error => tracing::Level::ERROR, + TraceLevelField::Warn => tracing::Level::WARN, + TraceLevelField::Info => tracing::Level::INFO, + TraceLevelField::Debug => tracing::Level::DEBUG, + TraceLevelField::Trace => tracing::Level::TRACE, + } + } +} + +impl From for TraceLevelField { + fn from(level: tracing::Level) -> Self { + match level { + tracing::Level::ERROR => TraceLevelField::Error, + tracing::Level::WARN => TraceLevelField::Warn, + tracing::Level::INFO => TraceLevelField::Info, + tracing::Level::DEBUG => TraceLevelField::Debug, + tracing::Level::TRACE => TraceLevelField::Trace, + } + } +} diff --git a/lib/dsc-lib/src/settings/fields/tracing/mod.rs b/lib/dsc-lib/src/settings/fields/tracing/mod.rs new file mode 100644 index 000000000..fd8e0d9b6 --- /dev/null +++ b/lib/dsc-lib/src/settings/fields/tracing/mod.rs @@ -0,0 +1,126 @@ +use schemars::{JsonSchema, json_schema}; +use serde::{Deserialize, Serialize}; + +use crate::schemas::{dsc_repo::DscRepoSchema, schema_i18n}; +use crate::settings::{DscSettingsResolvedField, DscSettingsScope}; + +mod fields; +pub use fields::*; + +/// Defines the available settings for the tracing configuration in DSC. +/// +/// This struct is used to represent the settings as they are defined in both [`DscPolicyFileData`] +/// and [`DscPreferenceFileData`]. +/// +/// [`DscPolicyFileData`]: crate::settings::DscPolicyFileData +/// [`DscPreferenceFileData`]: crate::settings::DscPreferenceFileData +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, DscRepoSchema)] +#[serde(rename_all = "camelCase")] +#[dsc_repo_schema(base_name = "tracing", folder_path = "settings/fields")] +pub struct TracingFileData { + /// Specifies the trace level to use for logging and diagnostics. + /// + /// This field controls the verbosity of the logs generated by DSC. Messages with a lower + /// level than the specified trace level will be filtered out and not emitted. + /// + /// The available trace levels, in order of increasing verbosity, are: + /// + /// - `error` + /// - `warn` + /// - `info` + /// - `debug` + /// - `trace` + pub level: Option, + /// Specifies the trace format to use for logging and diagnostics. + /// + /// This field controls the text that DSC emits to stderr for trace messages. + /// + /// The available trace formats are: + /// + /// - `default` - Colorized human-readable text with timestamps and log levels. + /// - `plaintext` - Human-readable text without colorization. + /// - `json` - Structured output as compressed JSON objects suitable for machine parsing. + pub format: Option, +} + +impl JsonSchema for TracingFileData { + fn schema_name() -> std::borrow::Cow<'static, str> { + Self::default_schema_id_uri().into() + } + fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema { + json_schema!({ + "title": schema_i18n!("title"), + "description": schema_i18n!("description"), + "markdownDescription": schema_i18n!("markdownDescription"), + "type": "object", + "properties": { + "level": { + "type": "string", + "enum": [ + "error", + "warn", + "info", + "debug", + "trace" + ], + "title": schema_i18n!("level.title"), + "description": schema_i18n!("level.description"), + "markdownDescription": schema_i18n!("level.markdownDescription") + }, + "format": { + "type": "string", + "enum": [ + "default", + "plaintext", + "json" + ], + "title": schema_i18n!("format.title"), + "description": schema_i18n!("format.description"), + "markdownDescription": schema_i18n!("format.markdownDescription") + } + } + }) + } +} + +/// Defines the default values for the tracing configuration in DSC. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub struct TracingCodeDefaults { + /// Indicates the default trace level to use for logging and diagnostics. + pub level: TraceLevelField, + /// Indicates the default trace format to use for logging and diagnostics. + pub format: TraceFormatField, +} + +/// Defines the default values for the tracing configuration in DSC. +/// +/// The following snippet shows the effective code defaults as YAML data: +/// +/// ```yaml +/// tracing: +/// level: warn +/// format: default +/// ``` +pub const CODE_DEFAULT_TRACING: TracingCodeDefaults = TracingCodeDefaults { + level: TraceLevelField::Warn, + format: TraceFormatField::Default, +}; + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct TracingResolvedSettings { + /// Indicates the minimum level of traces to emit. + pub level: DscSettingsResolvedField, + /// Indicates the format to use when emitting traces. + pub format: DscSettingsResolvedField, +} + +impl Default for TracingResolvedSettings { + fn default() -> Self { + let scope = DscSettingsScope::Default; + Self { + level: DscSettingsResolvedField::new(CODE_DEFAULT_TRACING.level, scope), + format: DscSettingsResolvedField::new(CODE_DEFAULT_TRACING.format, scope), + } + } +} diff --git a/lib/dsc-lib/src/settings/mod.rs b/lib/dsc-lib/src/settings/mod.rs new file mode 100644 index 000000000..c072efae6 --- /dev/null +++ b/lib/dsc-lib/src/settings/mod.rs @@ -0,0 +1,946 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Defines how to load and resolve settings for DSC. +//! +//! DSC uses a layered approach to settings, composing a set of resolved settings from multiple +//! sources. The [`DscSettings`] struct represents the complete model of settings, including all +//! sources and the resolved effective settings. +//! +//! +//! When DSC starts, it loads settings from various [scopes]. Every scope maps to a specific source. +//! After loading the settings from all sources, DSC resolves the effective settings, starting with +//! the default settings defined in the source code and then ensuring that the resolved settings +//! reflect the precedence scope that defined each setting. +//! +//! # Scopes and sources +//! +//! The following table defines the scopes and their corresponding sources, in order of precedence +//! from lowest to highest, where later scopes override settings defined in earlier scopes: +//! +//! | Scope | Source | Description | +//! |:---------------:|:------------------------------:|-------------| +//! | [`Default`] | [`DSC_SETTINGS_CODE_DEFAULTS`] | The hardcoded default settings defined in the source code. | +//! | [`User`] | [`DscPreferenceFileData`] | A settings file defining settings for the current user. | +//! | [`Machine`] | [`DscPreferenceFileData`] | A settings file defining settings for every user on the machine. | +//! | [`Workspace`] | [`DscPreferenceFileData`] | The settings loaded from the workspace settings file, if it exists. | +//! | [`Environment`] | [`DscSettingsEnvironmentData`] | The settings loaded from the environment variables, if they are set. | +//! | [`CommandLine`] | [`DscSettingsCliData`] | The settings loaded from the command line arguments, if they are provided. | +//! | [`Policy`] | [`DscPolicyFileData`] | The settings loaded from the policy settings file, if it exists. | +//! +//! Every available setting for DSC can be defined in the [`Policy`] scope, which has the highest +//! precedence and can't be overridden by any other scope. Users can define a policy file to enforce +//! specific settings for all users on a machine. +//! +//! The [`Machine`], [`User`], and [`Workspace`] scopes all support users defining a preference file +//! that contains settings for DSC. Preference files use the same data structure as policy files, +//! but not every field in a policy file is supported in a preference file. For example, the +//! [`forbid_ignore_settings_file`] field is only supported in a policy file because it controls +//! whether DSC should load and resolve settings from the preference files. Defining this field in +//! a preference file wouldn't make sense. +//! +//! The [`Environment`] scope allows users to define settings for DSC as environment variables. Not +//! every setting is supported as an environment variable,but every environment variable setting +//! maps to either a specific field in the policy file or a combination of fields in the policy +//! file. For example, users can define either the [`DSC_RESOURCE_PATH`] or the +//! [`DSC_RESTRICTED_PATH`] environment variable as a collection of directories separated by the +//! platform specific path separator for `PATH`-like environment variables. When DSC processes these +//! environment variables, DSC splits the values using the platform specific path separator and uses +//! the resulting collection to populate the [`resource_path.directories`] field in the resolved +//! settings. Additionally, the [`DSC_RESTRICTED_PATH`] environment variable populates the +//! [`resource_path.restricted`] field in the resolved settings. +//! +//! The [`CommandLine`] scope allows users to define settings for DSC as global command line +//! arguments. Only a few settings are supported as command line arguments to minimize the clutter +//! and cognitive load when invoking DSC commands. +//! +//! ## Loading settings +//! +//! With the exception of the [`Default`] and [`CommandLine`] scopes, DSC automatically attempts to +//! load settings from all other scopes during initialization. The [`Default`] scope is statically +//! defined in the source code and the [`CommandLine`] scope must be passed to the +//! [`new_with_command_line()`] constructor. +//! +//! The other scopes are loaded automatically by calling either the [`load()`] or [`try_load()`] +//! methods. The [`load()`] method attempts to load all sources and converts any errors into +//! warnings, while the [`try_load()`] method attempts to load all sources and collects any errors +//! into a single error value. +//! +//! When loading settings for the [`Policy`], [`Machine`], [`User`], and [`Workspace`] scopes, DSC +//! checks whether the corresponding settings file exists. If the file exists, DSC tries to read +//! and parse the file into the appropriate data structure ([`DscPolicyFileData`] for [`Policy`] +//! and [`DscPreferenceFileData`] for the others). +//! +//! When loading settings for the [`Environment`] scope, DSC checks whether the relevant environment +//! variables are defined and reads their values if they exist, parsing them into the appropriate +//! data type for the backing field in [`DscSettingsEnvironmentData`]. +//! +//! # Resolving settings +//! +//! DSC represents the resolved effective settings in an instance of [`DscSettingsResolved`]. Every +//! field in this struct is either a _leaf_ field where the type is [`DscSettingsResolvedField`], or +//! a _container_ field where the type is another struct with its own leaf and container fields. The +//! leaf fields contain the final value for that setting and the scope that defined it, so users +//! can understand which settings were defined in which scope with what value. +//! +//! When resolving settings, DSC supports overriding any _leaf_ field in the settings data structure +//! when that field is defined in a higher-precedence scope. It doesn't replace the entire container +//! with the value from the higher-precedence scope. This ensures that users can define only the +//! specific settings they want to override in a higher-precedence scope without needing to redefine +//! the entire collection of settings in that scope. +//! +//! For example, if the machine settings file defines both [`tracing.level`] and [`tracing.format`], +//! and the user settings file defines only [`tracing.format`], the final resolved settings will +//! match the following YAML snippet: +//! +//! ```yaml +//! tracing: +//! level: +//! scope: machine +//! value: +//! format: +//! scope: user +//! value: +//! ``` +//! +//! ## Resolution steps +//! +//! After loading settings from all sources, DSC follows these steps to resolve the effective +//! settings: +//! +//! 1. Initialize the resolved settings with the default settings defined in the source code. +//! 1. Any settings defined in the [`Policy`] scope override the code defaults. These settings are +//! applied first because they have the highest precedence and can't be overridden by any other +//! scope. +//! 1. Process the settings defined in the remaining scopes in precedence order, overriding any +//! settings from the code defaults or prior scopes unless they were defined in the [`Policy`] +//! scope. The order of precedence for these scopes is as follows: +//! +//! - [`Machine`] +//! - [`User`] +//! - [`Workspace`] +//! - [`Environment`] +//! - [`CommandLine`] +//! +//! After the initial resolution, DSC caches the resolved settings in a private field of the +//! [`DscSettings`] instance. Repeated access to the resolved settings with [`resolved()`] will use +//! the cached values, ensuring efficient retrieval without reprocessing every loaded source. +//! +//! # Available settings +//! +//! The following sections provide an overview of the available settings, how they affect DSC, and +//! how to define them in the various scopes. +//! +//! ## Ignoring settings files +//! +//! By default, DSC automatically laods and resolves settings from the [`Machine`], [`User`], and +//! [`Workspace`] settings files if they exist. +//! +//! DSC defines two opposing settings that control whether DSC should load and resolve settings +//! from the [`Machine`], [`User`], and [`Workspace`] settings files. By default, DSC loads and +//! resolves settings from these files if they exist. +//! +//! A user can control whether DSC should ignore the preference settings files by: +//! +//! 1. Defining the [`ignore_settings_file`] field in the [`Policy`] settings file. +//! 1. Defining the [`DSC_IGNORE_SETTINGS_FILE`] environment variable. +//! 1. Specifying the [`--ignore-settings-file`] global command line argument. +//! +//! When the resolved value for the field is `true`, DSC will ignore the preference settings files +//! when loading and resolving settings. +//! +//! Additionally, a user can forbid ignoring the preference settings files by defining the +//! [`forbid_ignore_settings_file`] field in the [`Policy`] settings file. When this field is +//! defined as `true`, DSC will always load and resolve settings from the preference files if +//! they exist. +//! +//! ## Tracing settings +//! +//! DSC emits messages to `stderr` to provide information about the execution lifecycle. Every +//! message is emitted with a different severity level. DSC supports emitting trace messages in +//! multiple formats. +//! +//! ### Trace level +//! +//! By default, DSC emits only [`Warn`] and [`Error`] messages to stderr. +//! +//! Users can define what level of messages to emit by: +//! +//! 1. Defining the [`tracing.level`] field in the [`Policy`], [`Machine`], [`User`], or +//! [`Workspace`] settings files. +//! 1. Defining the [`DSC_TRACE_LEVEL`] environment variable. +//! 1. Specifying the [`--trace-level`] global command line argument. +//! +//! When the trace level is set, DSC will emit messages of the specified severity and higher to +//! stderr. For example, if the trace level is set to [`Info`], DSC will emit messages with +//! [`Info`], [`Warn`], and [`Error`] severity levels. DSC won't emit messages with [`Debug`] +//! or [`Trace`] severity levels. +//! +//! ### Trace format +//! +//! By default, DSC emits messages to stderr as colorized human-readable text. +//! +//! Users can override the default trace format by: +//! +//! 1. Defining the [`tracing.format`] field in the [`Policy`], [`Machine`], [`User`], or +//! [`Workspace`] settings files. +//! 1. Defining the [`DSC_TRACE_FORMAT`] environment variable. +//! 1. Specifying the [`--trace-format`] global command line argument. +//! +//! The available trace formats include: +//! +//! - `default`: DSC emits messages to stderr in the default colorized human-readable text format. +//! - `plaintext`: DSC emits messages to stderr as human readable text without colorization. +//! - `json`: DSC emits messages to stderr as compressed JSON objects. +//! +//! ## Resource path settings +//! +//! DSC discovers manifest files by searching a collection of directories for files with known +//! extensions. By default, DSC only searches the `PATH` environment variable and the directory +//! that DSC is installed in. +//! +//! When invoking commands for a manifest, DSC supports invoking commands that exist in `PATH` or +//! that can be resolved relative to the manifest file. +//! +//! Unlike the tracing settings, which are independent of each other, the resource path settings +//! define how DSC discovers manifest files and invokes commands: +//! +//! - When [`directories`] is resolved, DSC always searches the specified directories for manifests +//! and can invoke commands from those directories. +//! - When [`append_env_path`] is resolved as `true`, DSC appends the `PATH` environment variable +//! to the resolved value for [`directories`] when searching for manifests. +//! - When [`restricted`] is resolved as `true`, DSC restricts the paths from which it can invoke +//! binaries to the resolved value for [`directories`]. When this setting is `true` it effectively +//! ignores the `append_env_path` setting and doesn't append the `PATH` environment variable to +//! set of directories. DSC will only discover and invoke commands from resolved value of +//! [`directories`]. +//! +//!
+//!
Restricted path behavior +//! +//! When you set [`restricted`] to `true`, DSC will _only_ discover and invoke commands from the +//! resolved value of [`directories`]. This explicitly _does not_ consider the `PATH` environment +//! variable or the DSC installation directory. +//! +//! To use built-in resources and extensions, you need to ensure that [`directories`] includes the +//! DSC installation directory. +//! +//! To use resources and extensions that require invoking commands that aren't adjacent to the +//! manifest file, you need to ensure that [`directories`] includes the directories containing +//! those commands. +//! +//!
+//!
+//! +//! To configure how DSC discovers manifests and invokes commands, users can: +//! +//! 1. Define the [`resourcePath.directories`], [`resourcePath.appendEnvPath`], and +//! [`resourcePath.restricted`] fields in the [`Policy`], [`Machine`], [`User`], or [`Workspace`] +//! settings files. +//! 1. Define the [`DSC_RESOURCE_PATH`] environment variable as a string containing a collection of +//! directories separated by the platform specific path separator for `PATH`-like environment +//! variables. This effectively populates the [`directories`] field in the resolved settings. +//! 1. Define the [`DSC_RESTRICTED_PATH`] environment variable as a string containing a collection +//! of directories separated by the platform specific path separator for `PATH`-like environment +//! variables. This effectively populates the [`directories`] field in the resolved settings and +//! sets the [`restricted`] field in the resolved settings to `true`. +//! +//! ### Examples +//! +//! The following examples clarify the effective behavior of DSC depending on the resource path +//! settings. Each scenario shows the effective resolved settings for the resource path as a YAML +//! snippet before explaining how DSC behaves when discovering manifests and invoking commands. +//! +//! 1. ```yaml +//! resourcePath: +//! directories: [] +//! append_env_path: true +//! restricted: false +//! ``` +//! +//! DSC searches the directories in the `PATH` environment variable and the directory that DSC +//! is installed in for manifests. When invoking commands for a manifest, DSC can invoke +//! commands that exist in `PATH`, the DSC installation directory, and relative to any +//! discovered manifest file. +//! 1. ```yaml +//! resourcePath: +//! directories: ["D:\infra\resources", "D:\infra\tools"] +//! append_env_path: true +//! restricted: false +//! ``` +//! +//! DSC searches the speciied directories, the directories in the `PATH` environment variable, +//! and the directory that DSC is installed in for manifest files. When invoking commands for a +//! manifest, DSC can invoke commands that exist those same directories and relative to any +//! discovered manifest file. +//! 1. ```yaml +//! resourcePath: +//! directories: ["D:\infra\resources", "D:\infra\tools"] +//! append_env_path: true +//! restricted: true +//! ``` +//! +//! DSC _only_ searches the specified directories for manifest files and does not consider the +//! `PATH` environment variable or the DSC installation directory. When invoking commands for a +//! manifest, DSC can only invoke commands that exist in the specified directories. Attempting +//! to invoke commands outside of the specified directories raises an error. +//! +//! [scopes]: DscSettingsScope +//! [`Default`]: DscSettingsScope::Default +//! [`Machine`]: DscSettingsScope::Machine +//! [`User`]: DscSettingsScope::User +//! [`Workspace`]: DscSettingsScope::Workspace +//! [`Environment`]: DscSettingsScope::Environment +//! [`CommandLine`]: DscSettingsScope::CommandLine +//! [`Policy`]: DscSettingsScope::Policy +//! [`forbid_ignore_settings_file`]: DscPolicyFileData::forbid_ignore_settings_file +//! [`DSC_RESOURCE_PATH`]: DscSettingsEnvironmentData::dsc_resource_path +//! [`DSC_RESTRICTED_PATH`]: DscSettingsEnvironmentData::dsc_restricted_path +//! [`--ignore-settings-file`]: DscSettingsCliData::ignore_settings_file +//! [`ignore_settings_file`]: DscPolicyFileData::ignore_settings_file +//! [`DSC_IGNORE_SETTINGS_FILE`]: DscSettingsEnvironmentData::dsc_ignore_settings_file +//! [`forbid_ignore_settings_file`]: DscPolicyFileData::forbid_ignore_settings_file +//! [`Error`]: TraceLevelField::Error +//! [`Warn`]: TraceLevelField::Warn +//! [`Info`]: TraceLevelField::Info +//! [`Debug`]: TraceLevelField::Debug +//! [`Trace`]: TraceLevelField::Trace +//! [`tracing.level`]: TracingFileData::level +//! [`tracing.format`]: TracingFileData::format +//! [`DSC_TRACE_LEVEL`]: DscSettingsEnvironmentData::dsc_trace_level +//! [`DSC_TRACE_FORMAT`]: DscSettingsEnvironmentData::dsc_trace_format +//! [`--trace-level`]: DscSettingsCliData::trace_level +//! [`--trace-format`]: DscSettingsCliData::trace_format +//! [`resourcePath.directories`]: ResourcePathFileData::directories +//! [`resourcePath.appendEnvPath`]: ResourcePathFileData::append_env_path +//! [`resourcePath.restricted`]: ResourcePathFileData::restricted +//! [`directories`]: ResourcePathResolvedSettings::directories +//! [`restricted`]: ResourcePathResolvedSettings::restricted +//! [`append_env_path`]: ResourcePathResolvedSettings::append_env_path +//! [`resource_path.directories`]: ResourcePathResolvedSettings::directories +//! [`resource_path.restricted`]: ResourcePathResolvedSettings::restricted +//! [`new_with_command_line()`]: DscSettings::new_with_command_line +//! [`load()`]: DscSettings::load +//! [`try_load()`]: DscSettings::try_load +//! [`resolved()`]: DscSettings::resolved + +use tracing::{debug, warn}; + +mod fields; +pub use fields::*; +mod sources; +pub use sources::*; +mod resolved; +pub use resolved::*; +mod constants_and_statics; +pub use constants_and_statics::*; +mod dsc_settings_scope; +pub use dsc_settings_scope::DscSettingsScope; + +mod errors; +pub use errors::DscSettingsError; + + +/// Represents the complete set of DSC settings, including all sources and the resolved effective +/// settings. +/// +/// During initialization, DSC loads settings from various sources. The following list defines the +/// sources for settings in order of precedence, from lowest to highest where later sources override +/// earlier sources: +/// +/// - The hardcoded defaults defined in the source code. +/// - The machine settings file, if it exists. +/// - The user settings file, if it exists. +/// - The workspace settings file, if it exists. +/// - The environment variables, if they are set. +/// - The command line arguments, if they are provided. +/// - The policy settings file, if it exists. +/// +/// You can use the [`load()`] or [`try_load()`] methods to load settings from all sources except +/// for the command line, which must be manually provided with the [`new_with_command_line()`] +/// constructor. +/// +/// You can access the resolved effective settings by calling the [`resolved()`] method, which +/// returns an instance of [`DscSettingsResolved`] containing the final values for each setting +/// after considering all sources. +/// +/// [`load()`]: Self::load +/// [`try_load()`]: Self::try_load +/// [`new_with_command_line()`]: Self::new_with_command_line +/// [`resolved()`]: Self::resolved +#[allow(dead_code)] +pub struct DscSettings { + /// The hardcoded default settings defined in the source code. + /// + /// These defaults have the lowest precedence and will be overridden by any other settings + /// source if a value is provided. + /// + /// The following snippet shows the effective code defaults as YAML data: + /// + /// ```yaml + /// forbid_ignore_settings_file: false + /// ignore_settings_file: false + /// tracing: + /// level: warn + /// format: default + /// resource_path: + /// directories: [] + /// append_env_path: true + /// restricted: false + /// ``` + /// + default: DscSettingsCodeDefaults, + /// The settings loaded from the machine settings file, if it exists. + /// + /// This field is set to [`None`] if the machine settings file does not exist or if it couldn't + /// be loaded. + /// + /// The machine settings file has the lowest precedence for settings after the code defaults. + /// Any settings defined in a different scope will override the values defined in the machine + /// settings file. + /// + /// The location for the machine settings file depends on the operating system: + /// + /// - On Windows, it's located at `%PROGRAMDATA%\DSC\settings.json`. + /// - On macOS, it's located at `/Library/Application Support/DSC/settings.json`. + /// - On Linux, it's located at `/etc/dsc/settings.json`. + pub machine: Option, + /// The settings loaded from the user settings file, if it exists. + /// + /// This field is set to [`None`] if the user settings file does not exist or if it couldn't + /// be loaded. + /// + /// The user settings file has higher precedence than the machine settings file, but lower + /// precedence than the workspace settings file, environment variables, and command line + /// arguments. Any settings defined in those scopes will override the values defined in the + /// user settings file. + pub user: Option, + /// The settings loaded from the workspace settings file, if it exists. + /// + /// This field is set to [`None`] if the workspace settings file does not exist or if it + /// couldn't be loaded. + /// + /// The workspace settings file has higher precedence than the machine and user settings files + /// but lower precedence than environment variables and command line arguments. Any settings + /// defined in those scopes will override the values defined in the workspace settings file. + pub workspace: Option, + /// The settings loaded from the environment variables, if they exist. + /// + /// This field is set to [`None`] if none of the relevant environment variables are set. DSC + /// uses the following environment variables for settings: + /// + /// - `DSC_TRACE_LEVEL`: Defines the trace level to use. + /// - `DSC_TRACE_FORMAT`: Defines the trace format to use. + /// - `DSC_RESOURCE_PATH`: Defines the resource directories to use. + /// - `DSC_RESTRICTED_PATH`: Defines the resource directories to use and restricts all DSC + /// invocations to those directories exclusively. + /// - `DSC_IGNORE_SETTINGS_FILE`: Defines whether to ignore settings files. + /// + /// The environment variables have higher precedence than the machine, user, and workspace + /// settings files but lower precedence than command line arguments. Any settings defined in + /// the command line arguments will override the values defined in the environment variables. + pub environment: Option, + /// The settings loaded from the command line arguments, if they were specified. + /// + /// This field is set to [`None`] if no command line arguments were provided relating to DSC + /// settings. DSC uses the following command line arguments for settings: + /// + /// - `--trace-level`: Defines the trace level to use. + /// - `--trace-format`: Defines the trace format to use. + /// - `--ignore-settings-file`: Defines whether to ignore settings files. + /// + /// The command line arguments have the highest precedence for settings, overriding any values + /// defined in the machine, user, and workspace settings files, as well as any values defined + /// in the environment variables. Only policy settings have higher precedence than command + /// line arguments, and they cannot be overridden. + pub command_line: Option, + /// The settings loaded from the policy settings file, if it exists. + /// + /// This field is set to [`None`] if the policy settings file doesn't exist or if it couldn't + /// be loaded. + /// + /// The policy settings file has the highest precedence for settings, overriding any values + /// defined in other sources, including command line arguments. Policy settings cannot be + /// overridden. + pub policy: Option, + resolved: Option, +} + + +// Public API +impl DscSettings { + /// Creates a new instance of `DscSettings` with all fields except `default` initialized to `None`. + /// + /// The `default` field is initialized with the hardcoded defaults defined in + /// [`DSC_SETTINGS_CODE_DEFAULTS`]. + pub fn new() -> Self { + Self { + default: DSC_SETTINGS_CODE_DEFAULTS, + machine: None, + user: None, + workspace: None, + environment: None, + command_line: None, + policy: None, + resolved: None, + } + } + + /// Creates a new instance of `DscSettings` and loads the provided command line data into it. + /// + /// The only fields populated in the returned instance are `default` and `command_line`. All + /// other fields are defined as [`None`]. + /// + /// # Arguments + /// + /// - `cli_data`: The command line data to load into the new instance. + pub fn new_with_command_line(cli_data: DscSettingsCliData) -> Self { + let mut settings = Self::new(); + settings.command_line = Some(cli_data); + + settings + } + + pub fn resolved(&mut self) -> &DscSettingsResolved { + if self.resolved.is_none() { + self.resolve_all(); + } + + self.resolved.as_ref().unwrap() + } + + /// Indicates whether the policy settings forbid ignoring settings files. + /// + /// # Returns + /// + /// This method returns `true` if the policy settings forbid ignoring settings files, and + /// `false` otherwise. If the policy settings haven't been loaded, this method returns `false`. + pub fn policy_forbids_ignoring_settings_files(&self) -> bool { + self.policy.as_ref().is_some_and(|p| { + p.forbid_ignore_settings_file.is_some_and(|v| v == true) + }) + } + + /// Indicates whether settings files should be ignored based on the current settings sources. + /// + /// # Returns + /// + /// This return value for this method depends on the loaded policy, environment, and command + /// line settings. The precedence rules are as follows: + /// + /// 1. If `policy.forbid_ignore_settings_file` is set to `true`, this method always returns + /// `false`, regardless of the other sources. + /// 1. If `command_line.ignore_settings_file` is set, this method returns its value. + /// 1. If `environment.dsc_ignore_settings_file` is set, this method returns its value. + /// 1. If none of the above conditions are met, this method returns `false`. + pub fn ignoring_settings_files(&self) -> bool { + // If the policy forbids ignoring settings files, always return false. + if self.policy_forbids_ignoring_settings_files() { + return false; + } + if let Some(policy_ignore) = self.policy.as_ref().and_then(|p| p.ignore_settings_file) { + return policy_ignore; + } + if let Some(cli_ignore) = self.command_line.as_ref().and_then(|cli| cli.ignore_settings_file) { + return cli_ignore; + } + if let Some(env_ignore) = self.environment.as_ref().and_then(|env| env.dsc_ignore_settings_file) { + return env_ignore; + } + + false + } + + /// Resolves the effective settings by applying the precedence rules to all loaded sources. + /// + /// The resolution steps are: + /// + /// 1. Initialize with default settings from source code. + /// 1. If policy file settings are loaded, apply them next, as they have the highest precedence. + /// 1. Apply settings file sources in order of precedence ([`Machine`], then [`User`], then + /// [`Workspace`]). + /// 1. If environment settings are loaded, apply them next, overriding any non-policy settings. + /// 1. If command line settings are loaded, apply them last, overriding any non-policy settings. + /// + /// Resolution is performed for every setting, _not_ by container. If the machine settings file + /// defines both `tracing.level` and `tracing.format`, and the user settings file defines only + /// `tracing.format`, the final resolved settings will have `tracing.level` from the machine + /// settings file and `tracing.format` from the user settings file. + /// + /// Settings must be explicitly overridden by a higher-precedence source to be applied. DSC + /// doesn't support effectively "undefining" a setting in a higher precedence source. + /// + /// [`Machine`]: DscSettingsScope::Machine + /// [`User`]: DscSettingsScope::User + /// [`Workspace`]: DscSettingsScope::Workspace + /// [`Environment`]: DscSettingsScope::Environment + /// [`Cli`]: DscSettingsScope::CommandLine + /// [`Policy`]: DscSettingsScope::Policy + pub fn resolve_all(&mut self) { + let mut resolving = DscSettingsResolved::default(); + // First, resolve policy settings, as they have the highest precedence. + self.resolve_policy(&mut resolving); + // Only resolve file-based settings if ignoring settings files is not forbidden by policy + // and not specified in the policy, command line, or environment. + if !self.ignoring_settings_files() { + // Resolve file-based settings in order of precedence (Machine, User, Workspace). + for source in DscSettingsScope::FILE_BASED.iter().cloned() { + self.resolve_file_based_settings(source, &mut resolving); + } + } + // Resolve environment settings, which have higher precedence than file-based settings. + self.resolve_environment(&mut resolving); + // Finally, resolve command line settings, which have the highest precedence (except for policy). + self.resolve_command_line(&mut resolving); + + self.resolved = Some(resolving); + } + + /// Attempts to load settings from all non-CLI sources and converts loading errors into warnings. + /// + /// When loading a source raises an error, this method logs the error as a warning and continues + /// loading the remaining sources. Only sources that are successfully loaded are stored in the + /// instance. All other sources remain set to [`None`]. + pub fn load(&mut self) { + if let Err(e) = self.load_policy() { + warn!("failed to load policy settings: {}", e); + } + if let Err(e) = self.load_machine() { + warn!("failed to load machine settings: {}", e); + } + if let Err(e) = self.load_user() { + warn!("failed to load user settings: {}", e); + } + if let Err(e) = self.load_workspace() { + warn!("failed to load workspace settings: {}", e); + } + self.load_environment(); + } + + /// Attempts to load settings from all non-CLI sources and collects any errors when loading + /// each source. + /// + /// To load settings and ignore sources with errors, use the [`load()`] method instead. + /// + /// # Errors + /// + /// If loading any source raises an error, this method returns an instance of + /// [`LoadMultipleErrors`] containing a vector of all errors encountered. + /// + /// [`load()`]: Self::load + /// [`LoadMultipleErrors`]: DscSettingsError::LoadMultipleErrors + pub fn try_load(&mut self) -> Result<(), DscSettingsError> { + let mut errors : Vec = Vec::new(); + if let Err(e) = self.load_policy() { + errors.push(e); + } + if let Err(e) = self.load_machine() { + errors.push(e); + } + if let Err(e) = self.load_user() { + errors.push(e); + } + if let Err(e) = self.load_workspace() { + errors.push(e); + } + self.load_environment(); + + if errors.is_empty() { + Ok(()) + } else { + Err(DscSettingsError::LoadMultipleErrors(errors)) + } + } +} + +// Private API +impl DscSettings { + /// Loads the environment variables into the instance. + fn load_environment(&mut self) { + self.environment = Some(DscSettingsEnvironmentData::from_env()); + } + + /// Attempts to load the machine settings file into the instance + /// + /// If the machine settings file doesn't exist, this method does nothing and returns + /// `Ok(())`. If the file exists and is validly defined, this method loads the settings into + /// the instance. + /// + /// # Errors + /// + /// If the file exists with invalid data, this method returns an error indicating the failure + /// to load the machine settings file. + fn load_machine(&mut self) -> Result<(), DscSettingsError> { + let machine_settings_path = MACHINE_SETTINGS_FILE_PATH.as_path(); + if machine_settings_path.exists() { + let data = DscPreferenceFileData::from_file(&machine_settings_path)?; + debug!("Loaded machine settings from '{}'", machine_settings_path.to_string_lossy()); + self.machine = Some(data); + } else { + debug!("Machine settings file '{}' does not exist, skipping", machine_settings_path.to_string_lossy()); + } + + Ok(()) + } + + fn load_user(&mut self) -> Result<(), DscSettingsError> { + let user_settings_path = USER_SETTINGS_FILE_PATH.as_path(); + if user_settings_path.exists() { + let data = DscPreferenceFileData::from_file(&user_settings_path)?; + debug!("Loaded user settings from '{}'", user_settings_path.to_string_lossy()); + self.user = Some(data); + } else { + debug!("User settings file '{}' does not exist, skipping", user_settings_path.to_string_lossy()); + } + + Ok(()) + } + fn load_workspace(&mut self) -> Result<(), DscSettingsError> { + let workspace_settings_path = WORKSPACE_SETTINGS_FILE_PATH.as_path(); + if workspace_settings_path.exists() { + let data = DscPreferenceFileData::from_file(&workspace_settings_path)?; + debug!("Loaded workspace settings from '{}'", workspace_settings_path.to_string_lossy()); + self.workspace = Some(data); + } else { + debug!("Workspace settings file '{}' does not exist, skipping", workspace_settings_path.to_string_lossy()); + } + + Ok(()) + } + + fn load_policy(&mut self) -> Result<(), DscSettingsError> { + let policy_settings_path = POLICY_SETTINGS_FILE_PATH.as_path(); + if policy_settings_path.exists() { + let data = DscPolicyFileData::from_file(&policy_settings_path)?; + debug!("Loaded policy settings from '{}'", policy_settings_path.to_string_lossy()); + self.policy = Some(data); + } else { + debug!("Policy settings file '{}' does not exist, skipping", policy_settings_path.to_string_lossy()); + } + + Ok(()) + } + + /// Applies the settings from the [`Cli`] scope to the resolved settings. + /// + /// If the command line settings aren't loaded, this method returns immediately without + /// modifying the resolved settings. If the command line settings are loaded, this method + /// applies them to the resolved settings, overriding any values from lower-precedence sources. + /// + /// # Arguments + /// + /// - `resolving` - A mutable reference to the instance of [`DscSettingsResolved`] that + /// represents the intermediate state of the resolved settings. + /// + /// [`Cli`]: DscSettingsScope::CommandLine + fn resolve_command_line(&mut self, resolving: &mut DscSettingsResolved) { + let Some(cli_data) = self.command_line.as_ref() else { + return; + }; + let scope = DscSettingsScope::CommandLine; + + if let Some(level) = cli_data.trace_level.as_ref() { + if resolving.tracing.level.scope < scope { + resolving.tracing.level = DscSettingsResolvedField::new(level.clone(), scope); + } + } + if let Some(format) = cli_data.trace_format.as_ref() { + if resolving.tracing.format.scope < scope { + resolving.tracing.format = DscSettingsResolvedField::new(format.clone(), scope); + } + } + + if let Some(ignore_settings_file) = cli_data.ignore_settings_file.as_ref() { + // check if this option is forbidden by policy + if self.policy_forbids_ignoring_settings_files() { + warn!("Ignoring the --ignore-settings-file option because it's forbidden by policy."); + } else { + if resolving.ignore_settings_file.scope < scope { + resolving.ignore_settings_file = DscSettingsResolvedField::new(*ignore_settings_file, scope); + } + } + } + } + + /// Applies the settings from the [`Environment`] scope to the resolved settings. + /// + /// If the environment settings aren't loaded, this method returns immediately without modifying + /// the resolved settings. If the environment settings are loaded, this method applies them to + /// the resolved settings, overriding any values from lower-precedence sources. + /// + /// # Arguments + /// + /// - `resolving` - A mutable reference to the instance of [`DscSettingsResolved`] that + /// represents the intermediate state of the resolved settings. + /// + /// [`Environment`]: DscSettingsScope::Environment + fn resolve_environment(&mut self, resolving: &mut DscSettingsResolved) { + let Some(env_data) = self.environment.as_ref() else { + return; + }; + let scope = DscSettingsScope::Environment; + + + if let Some(level) = env_data.dsc_trace_level.as_ref() { + if resolving.tracing.level.scope < scope { + resolving.tracing.level = DscSettingsResolvedField::new(level.clone(), scope); + } + } + if let Some(format) = env_data.dsc_trace_format.as_ref() { + if resolving.tracing.format.scope < scope { + resolving.tracing.format = DscSettingsResolvedField::new(format.clone(), scope); + } + } + + if let Some(restricted_path) = env_data.dsc_restricted_path.as_ref() { + if resolving.resource_path.restricted.scope < scope { + let directories = restricted_path.clone(); + resolving.resource_path.directories = DscSettingsResolvedField::new(directories, scope); + resolving.resource_path.restricted = DscSettingsResolvedField::new(true, scope); + } + } else if let Some(resource_path) = env_data.dsc_resource_path.as_ref() { + if resolving.resource_path.directories.scope < scope { + let directories = resource_path.clone(); + resolving.resource_path.directories = DscSettingsResolvedField::new(directories, scope); + } + } + + if let Some(ignore_settings_file) = env_data.dsc_ignore_settings_file.as_ref() { + // Only override the ignore_settings_file setting if it's not forbidden by policy + if self.policy_forbids_ignoring_settings_files() { + warn!("Ignoring the DSC_IGNORE_SETTINGS_FILE environment variable because it's forbidden by policy."); + } else { + if resolving.ignore_settings_file.scope < scope { + resolving.ignore_settings_file = DscSettingsResolvedField::new(*ignore_settings_file, scope); + } + } + } + } + + /// Resolves the settings from file-based sources and applies them to the resolved settings. + /// + /// # Arguments + /// + /// - `source` - The source scope from which to resolve the settings. This scope must be one of + /// the file-based sources: [`Machine`], [`User`], or [`Workspace`]. Specifying any other + /// scope returns early from the method without modifying the resolved settings or raising any + /// errors. + /// - `resolving` - A mutable reference to the instance of [`DscSettingsResolved`] that + /// represents the intermediate state of the resolved settings. + fn resolve_file_based_settings(&mut self, source: DscSettingsScope, resolving: &mut DscSettingsResolved) { + let file_data = match source { + DscSettingsScope::Machine => self.machine.as_ref(), + DscSettingsScope::User => self.user.as_ref(), + DscSettingsScope::Workspace => self.workspace.as_ref(), + _ => None, + }; + let Some(file_data) = file_data else { + return; + }; + + if let Some(tracing) = &file_data.tracing { + if let Some(level) = tracing.level.as_ref() { + if resolving.tracing.level.scope < source { + resolving.tracing.level = DscSettingsResolvedField::new(level.clone(), source); + } + } + if let Some(format) = tracing.format.as_ref() { + if resolving.tracing.format.scope < source { + resolving.tracing.format = DscSettingsResolvedField::new(format.clone(), source); + } + } + } + + if let Some(resource_path) = &file_data.resource_path { + if let Some(append_env_path) = resource_path.append_env_path.as_ref() { + if resolving.resource_path.append_env_path.scope < source { + resolving.resource_path.append_env_path = DscSettingsResolvedField::new(append_env_path.clone(), source); + } + } + if let Some(directories) = resource_path.directories.as_ref() { + if resolving.resource_path.directories.scope < source { + resolving.resource_path.directories = DscSettingsResolvedField::new(directories.clone(), source); + } + } + if let Some(restrict_path) = resource_path.restricted.as_ref() { + if resolving.resource_path.restricted.scope < source { + resolving.resource_path.restricted = DscSettingsResolvedField::new(restrict_path.clone(), source); + } + } + } + } + + /// Resolves the policy settings and applies them to the resolved settings. + /// + /// If the policy settings aren't loaded, this method returns immediately without modifying the + /// resolved settings. If the policy settings are loaded, this method applies them to the + /// resolved settings, overriding any values from lower-precedence sources. + fn resolve_policy(&mut self, resolving: &mut DscSettingsResolved) { + let Some(policy) = self.policy.as_ref() else { + return; + }; + + let scope = DscSettingsScope::Policy; + + if let Some(forbid_ignore) = policy.forbid_ignore_settings_file { + resolving.forbid_ignore_settings_file = DscSettingsResolvedField::new( + forbid_ignore, + scope + ); + } + if let Some(ignore) = policy.ignore_settings_file { + if let Some(forbid_ignore) = policy.forbid_ignore_settings_file { + // If the settings are incompatible, prefer forbidding and don't ignore the + // settings files. + if forbid_ignore && ignore{ + warn!("Ignoring the policy setting 'ignore_settings_file' because 'forbid_ignore_settings_file' is set to true."); + } else { + resolving.ignore_settings_file = DscSettingsResolvedField::new( + ignore, + scope + ); + } + } else { + resolving.ignore_settings_file = DscSettingsResolvedField::new( + ignore, + scope + ); + } + } + + if let Some(tracing) = &policy.tracing { + if let Some(level) = tracing.level.as_ref() { + resolving.tracing.level = DscSettingsResolvedField::new( + level.clone(), + scope + ); + } + if let Some(format) = tracing.format.as_ref() { + resolving.tracing.format = DscSettingsResolvedField::new( + format.clone(), + scope + ); + } + } + if let Some(resource_path) = &policy.resource_path { + if let Some(append_env_path) = resource_path.append_env_path.as_ref() { + resolving.resource_path.append_env_path = DscSettingsResolvedField::new( + append_env_path.clone(), + scope + ); + } + if let Some(directories) = resource_path.directories.as_ref() { + resolving.resource_path.directories = DscSettingsResolvedField::new( + directories.clone(), + scope + ); + } + if let Some(restrict_path) = resource_path.restricted.as_ref() { + resolving.resource_path.restricted = DscSettingsResolvedField::new( + restrict_path.clone(), + scope + ); + } + } + } +} diff --git a/lib/dsc-lib/src/settings/resolved/field.rs b/lib/dsc-lib/src/settings/resolved/field.rs new file mode 100644 index 000000000..05e7fc75a --- /dev/null +++ b/lib/dsc-lib/src/settings/resolved/field.rs @@ -0,0 +1,27 @@ +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use crate::settings::DscSettingsScope; + +/// A resolved setting field value with the scope it was defined in. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct DscSettingsResolvedField { + /// The resolved value for the field. + pub value: T, + /// The scope the value was defined in. + pub scope: DscSettingsScope, +} + +impl DscSettingsResolvedField { + /// Creates a new resolved field with the given value and scope. + pub fn new(value: T, scope: DscSettingsScope) -> Self { + Self { value, scope } + } + /// Returns true if the field is enforced by policy and must not be overridden + /// by environment variables or CLI options. + #[must_use] + pub fn is_policy(&self) -> bool { + self.scope == DscSettingsScope::Policy + } +} diff --git a/lib/dsc-lib/src/settings/resolved/mod.rs b/lib/dsc-lib/src/settings/resolved/mod.rs new file mode 100644 index 000000000..00f70bcfc --- /dev/null +++ b/lib/dsc-lib/src/settings/resolved/mod.rs @@ -0,0 +1,17 @@ +//! Defines the types for the resolved settings. +//! +//! This module defines two types: +//! +//! - [`DscSettingsResolvedField`] is a generic struct that colocates a resolved setting value with +//! the highest precedence scope it was defined in. +//! - [`DscSettingsResolved`] is a struct that contains all the resolved settings for DSC. Every +//! leaf field in this struct is a [`DscSettingsResolvedField`] and every container field is +//! a struct that contains other container fields and/or leaf fields. +//! +//! Generally, only the [`DscSettingsResolved`] type should require any modification when updating +//! settings definitions. + +mod field; +pub use field::*; +mod settings; +pub use settings::*; diff --git a/lib/dsc-lib/src/settings/resolved/settings.rs b/lib/dsc-lib/src/settings/resolved/settings.rs new file mode 100644 index 000000000..784b9f6bd --- /dev/null +++ b/lib/dsc-lib/src/settings/resolved/settings.rs @@ -0,0 +1,116 @@ +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use crate::settings::{DscSettingsResolvedField, DscSettingsScope, CODE_DEFAULT_FORBID_IGNORE_SETTINGS_FILE, CODE_DEFAULT_IGNORE_SETTINGS_FILE, ResourcePathResolvedSettings, TracingResolvedSettings}; + +/// Defines the effective settings for DSC after resolving all the settings sources. +/// +/// +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub struct DscSettingsResolved { + /// Indicates whether to allow users to ignore settings files. + /// + /// This setting is only available in the [`Policy`] scope. The default setting is `false`. + /// + /// When this setting is `false`, users can indicate that DSC should not load and resolve + /// settings files by specifying the [`--ignore-settings-file`] CLI option or defining the + /// [`DSC_IGNORE_SETTINGS_FILE`] environment variable. Doing so effectively skips processing + /// the [`Machine`], [`User`], and [`Workspace`] settings scopes. The [`Policy`] scope is + /// always processed regardless of this setting. + /// + /// When the policy scope defines this setting as `true`, users aren't allowed to ignore + /// settings files. If a user attempts to ignore settings files, DSC raises a warning and + /// indicates that the command is processing settings files as normal. + /// + /// [`Policy`]: DscSettingsScope::Policy + /// [`Machine`]: DscSettingsScope::Machine + /// [`User`]: DscSettingsScope::User + /// [`Workspace`]: DscSettingsScope::Workspace + /// [`--ignore-settings-file`]: crate::settings::DscSettingsCliData::ignore_settings_file + /// [`DSC_IGNORE_SETTINGS_FILE`]: crate::settings::DscSettingsEnvironmentData::dsc_ignore_settings_file + pub forbid_ignore_settings_file: DscSettingsResolvedField, + /// Indicates whether to ignore settings files. + /// + /// This setting is available in the [`Environment`] and [`Cli`] scopes. The default setting is + /// `false`. + /// + /// When this setting is `true`, DSC ignores the [`Machine`], [`User`], and [`Workspace`] + /// settings scopes. DSC won't automatically load those settings files and will ignore them + /// during settings resolution even if they were manually loaded. The [`Policy`] scope is + /// always processed regardless of this setting. + /// + /// If the [`forbid_ignore_settings_file`] setting is defined as `true` in the [`Policy`] scope, + /// this setting is effectively ignored and DSC will always load and resolve settings files if + /// they exist. + /// + /// [`Environment`]: DscSettingsScope::Environment + /// [`Cli`]: DscSettingsScope::CommandLine + /// [`Machine`]: DscSettingsScope::Machine + /// [`User`]: DscSettingsScope::User + /// [`Workspace`]: DscSettingsScope::Workspace + /// [`Policy`]: DscSettingsScope::Policy + /// [`forbid_ignore_settings_file`]: Self::forbid_ignore_settings_file + pub ignore_settings_file: DscSettingsResolvedField, + /// Indicates how DSC should emit messages during command execution. + /// + /// These settings control which messages DSC emits to stderr and the format it emits them in. + /// + /// The following snippet shows the effective code defaults as YAML data: + /// + /// ```yaml + /// tracing: + /// level: info + /// format: default + /// ``` + /// + /// For more information on defining these settings, see: + /// + /// - [`TracingFileData`] for defining them in the [policy settings file] or [preference settings files]. + /// - [`DscSettingsEnvironmentData`] for defining them as environment variables. + /// - [`DscSettingsCliData`] for defining them as CLI options. + /// + /// [`TracingFileData`]: crate::settings::TracingFileData + /// [`DscSettingsEnvironmentData`]: crate::settings::DscSettingsEnvironmentData + /// [`DscSettingsCliData`]: crate::settings::DscSettingsCliData + /// [policy settings file]: crate::settings::TracingFileData + /// [preference settings files]: crate::settings::TracingFileData + pub tracing: TracingResolvedSettings, + /// Indicates how DSC should discover manifests and binaries during command execution. + /// + /// These settings control which directories DSC searches for manifests and binaries, whether to include the system + /// `PATH` environment variable in the search, and whether to restrict the search to only the specified directories. + /// + /// The following snippet shows the effective code defaults as YAML data: + /// + /// ```yaml + /// resource_path: + /// include_system_path: true + /// restrict_to_specified_dirs: false + /// ``` + /// + /// + /// For more information on defining these settings, see: + /// + /// - [`ResourcePathFileData`] for defining them in the [policy settings file] or [preference settings files]. + /// - [`DscSettingsEnvironmentData`] for defining them as environment variables. + /// - [`DscSettingsCliData`] for defining them as CLI options. + /// + /// [`ResourcePathFileData`]: crate::settings::ResourcePathFileData + /// [`DscSettingsEnvironmentData`]: crate::settings::DscSettingsEnvironmentData + /// [`DscSettingsCliData`]: crate::settings::DscSettingsCliData + /// [policy settings file]: crate::settings::ResourcePathFileData + /// [preference settings files]: crate::settings::ResourcePathFileData + pub resource_path: ResourcePathResolvedSettings, +} + +impl Default for DscSettingsResolved { + fn default() -> Self { + let scope = DscSettingsScope::Default; + Self { + forbid_ignore_settings_file: DscSettingsResolvedField::new(CODE_DEFAULT_FORBID_IGNORE_SETTINGS_FILE, scope), + ignore_settings_file: DscSettingsResolvedField::new(CODE_DEFAULT_IGNORE_SETTINGS_FILE, scope), + tracing: TracingResolvedSettings::default(), + resource_path: ResourcePathResolvedSettings::default(), + } + } +} diff --git a/lib/dsc-lib/src/settings/sources/cli.rs b/lib/dsc-lib/src/settings/sources/cli.rs new file mode 100644 index 000000000..8451ebbbc --- /dev/null +++ b/lib/dsc-lib/src/settings/sources/cli.rs @@ -0,0 +1,118 @@ + +//! This module defines the `DscSettingsCliData` struct, which represents the command line +//! arguments related to DSC settings. +//! +//! This documentation provides guidance for defining new command line arguments related to DSC +//! settings. When adding a new command line argument, follow this guidance: +//! +//! 1. Ensure that the field is defined in the [`fields`] module following that module guidance. +//! 1. Add the field to the [`DscSettingsCliData`] struct in this module: +//! +//! - Name the field the same as the command line argument's long name, using snake case. For +//! example, the `--trace-level` command line argument would correspond to a field named +//! `trace_level`. +//! - Define the field's type as `Option`, where `T` is the type of the field defined in the +//! [`fields`] module (or the externally defined type if the field doesn't require a new type). +//! 1. Update the [`DscSettings::resolve_cli_data`] method to appropriately resolve the field. +//! +//! For example, when defining a setting for a top-level field named `new_area`, you would add +//! the following snippet to the `resolve_cli_data` method: +//! +//! ```ignore +//! if let Some(value) = cli_data.new_area.as_ref() { +//! if resolving.new_area.scope < DscSettingsScope::CommandLine { +//! resolving.new_area = DscSettingsResolvedField::new( +//! value.clone(), +//! DscSettingsScope::CommandLine +//! ); +//! } +//! } +//! ``` +//! +//! When defining a setting for a nested leaf field, you would add a similar snippet, but with +//! the appropriate dot notation to access the nested field. For example, if the field is +//! `new_area.foo.bar`, you would add the following snippet: +//! +//! ```ignore +//! if let Some(value) = cli_data.new_area.foo.bar.as_ref() { +//! if resolving.new_area.foo.bar.scope < DscSettingsScope::CommandLine { +//! resolving.new_area.foo.bar = DscSettingsResolvedField::new( +//! value.clone(), +//! DscSettingsScope::CommandLine +//! ); +//! } +//! } +//! ``` +//! 1. Ensure that the argument in DSC is defined in the CLI argument parser. +//! +//! - If the argument is a boolean flag, ensure that the Clap attribute defines the following +//! fields: +//! +//! - `num_args=0..=1` - Makes the argument accept zero or one value. This allows the argument +//! to be specified as a flag without an explicit value, or with an explicit value of `true` +//! or `false`. +//! - `default_missing_value="true"` - Ensures that if the argument is specified without a +//! value, it will be treated as `true`. +//! - `require_equals = true` - Ensures that if the argument is specified with a value, it +//! must be specified using an equals sign, like `--ignore-settings-file=true`. +//! +//! This is necessary to distinguish between the argument not being specified and being +//! specified with a value of `false`. Otherwise, the CLI argument will _always_ supercede +//! lower precedence sources. For example, consider the `--ignore-settings-file` argument: +//! +//! ```sh +//! DSC_IGNORE_SETTINGS_FILE=true dsc config get -f ./example.dsc.config.yaml +//! ``` +//! +//! In this case, even though the user specified the environment variable to ignore settings +//! files, the argument parser interprets the `--ignore-settings-file` argument as `false` and +//! DSC will load settings files during resolution. +//! +//! When the argument is defined with the above attributes, the parser can indicate that the +//! argument wasn't specified, and DSC will correctly resolve the setting to `true` based on +//! the environment variable. +//! +//! This also enables the user to effectively override the environment variable with the +//! `--ignore-settings-file=false` argument. +//! - If the argument is for a defined type, ensure that _either_: +//! +//! 1. The CLI code defines the `From` trait to convert between the CLI argument type and the +//! type defined in the [`fields`] module, or +//! 1. The CLI code uses the type defined in the [`fields`] module for the argument. +//! 1. Ensure that the CLI call to initialize the settings includes the new argument in the `DscSettingsCliData` +//! struct. + +use serde::{Deserialize, Serialize}; +use schemars::JsonSchema; + +use crate::settings::{TraceFormatField, TraceLevelField}; + +/// Represents the command line arguments related to DSC settings. +/// +/// DSC defines several global command line arguments that can be used to override settings +/// defined in preference files or environment variables. This struct captures the values of those +/// command line arguments. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub struct DscSettingsCliData { + /// Defines the trace level to use. + /// + /// Retrieved from the global `--trace-level` command line argument. + pub trace_level: Option, + /// Defines the trace format to use. + /// + /// Retrieved from the global `--trace-format` command line argument. + pub trace_format: Option, + /// Whether to ignore settings files. + /// + /// Retrieved from the global `--ignore-settings-file` command line argument. + /// + /// When this is set to `true`, DSC will ignore all settings files, including machine, user, + /// and workspace settings files. When resolving settings, DSC will ignore the settings files + /// and only consider the following sources, in order of precedence: + /// + /// 1. The system policy file, if it exists. + /// 2. The command line arguments, if they're provided. + /// 3. The environment variables, if they're set. + /// 4. The code defaults, which are the built-in default values for each setting. + pub ignore_settings_file: Option, +} diff --git a/lib/dsc-lib/src/settings/sources/code_defaults.rs b/lib/dsc-lib/src/settings/sources/code_defaults.rs new file mode 100644 index 000000000..49d734f16 --- /dev/null +++ b/lib/dsc-lib/src/settings/sources/code_defaults.rs @@ -0,0 +1,94 @@ +//! Defines the default values for DSC settings fields. +//! +//! The [`DscSettingsCodeDefaults`] struct should mirror the structure of [`DscSettingsResolved`], +//! except that instead of using [`DscSettingsResolvedField`] for each field, it should use the +//! underlying value type for each field. +//! +//! The [`DSC_SETTINGS_CODE_DEFAULTS`] constant is a static representation of the code defaults and +//! every field should be initialized with the appropriate constant from the [`fields`] module. +//! +//! [`DscSettingsResolved`]: crate::settings::DscSettingsResolved +//! [`DscSettingsResolvedField`]: crate::settings::DscSettingsResolvedField +//! [`fields`]: crate::settings::fields +use schemars::JsonSchema; +use serde::Serialize; + +use crate::settings::{ + CODE_DEFAULT_FORBID_IGNORE_SETTINGS_FILE, + CODE_DEFAULT_RESOURCE_PATH, + ResourcePathCodeDefaults, + CODE_DEFAULT_TRACING, + TracingCodeDefaults +}; + +/// Defines the default values for DSC settings fields. +/// +/// DSC uses a layered approach to resolving settings values. The code defaults, which this struct +/// represents, are the lowest precedence in the settings hierarchy. They are defined in the +/// [`DSC_SETTINGS_CODE_DEFAULTS`] constant. +/// +/// These defaults are used when no other sources define a value for a setting. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, JsonSchema)] +pub struct DscSettingsCodeDefaults { + /// Indicates whether to allow users to ignore settings files. + /// + /// The code default for this setting is `false`, which means that users are allowed to ignore + /// settings files. This setting can only be overridden by the [`Policy`] scope. + /// + /// For more information, see [`forbid_ignore_settings_file`] in the policy file documentation. + /// + /// [`Policy`]: crate::settings::DscSettingsScope::Policy + /// [`forbid_ignore_settings_file`]: crate::settings::DscPolicyFileData::forbid_ignore_settings_file + pub forbid_ignore_settings_file: bool, + /// Indicates whether to ignore settings files. + /// + /// The code default for this setting is `false`, which means that DSC will load and resolve + /// settings files. This setting can be overridden by the [`Environment`] and [`CLI`] scopes. + /// + /// If the [`forbid_ignore_settings_file`] setting is defined as `true` in the [`Policy`] scope, + /// this setting is effectively ignored and DSC will always load and resolve settings files. + /// + /// For more information, see [`DscSettingsResolved::ignore_settings_file`]. + /// + /// [`Environment`]: crate::settings::DscSettingsScope::Environment + /// [`CLI`]: crate::settings::DscSettingsScope::CommandLine + /// [`Policy`]: crate::settings::DscSettingsScope::Policy + /// [`forbid_ignore_settings_file`]: crate::settings::DscPolicyFileData::forbid_ignore_settings_file + /// [`DscSettingsResolved::ignore_settings_file`]: crate::settings::DscSettingsResolved::ignore_settings_file + pub ignore_settings_file: bool, + /// Defines how DSC should emit trace messages for logging and diagnostics. + /// + /// + pub tracing: TracingCodeDefaults, + /// Defines the paths to use when searching for and invoking resources, extensions, and other + /// executables. + pub resource_path: ResourcePathCodeDefaults, +} + +/// Defines the default values for DSC settings fields. +/// +/// The following snippet shows the effective code defaults as YAML data: +/// +/// ```yaml +/// forbid_ignore_settings_file: false +/// ignore_settings_file: false +/// tracing: +/// level: warn +/// format: default +/// resource_path: +/// append_env_path: true +/// directories: [] +/// restricted: false +/// ``` +pub const DSC_SETTINGS_CODE_DEFAULTS: DscSettingsCodeDefaults = DscSettingsCodeDefaults { + forbid_ignore_settings_file: CODE_DEFAULT_FORBID_IGNORE_SETTINGS_FILE, + ignore_settings_file: false, + tracing: CODE_DEFAULT_TRACING, + resource_path: CODE_DEFAULT_RESOURCE_PATH, +}; + +impl Default for DscSettingsCodeDefaults { + fn default() -> Self { + DSC_SETTINGS_CODE_DEFAULTS + } +} diff --git a/lib/dsc-lib/src/settings/sources/environment.rs b/lib/dsc-lib/src/settings/sources/environment.rs new file mode 100644 index 000000000..fbd93e6db --- /dev/null +++ b/lib/dsc-lib/src/settings/sources/environment.rs @@ -0,0 +1,437 @@ +//! Defines the `DscSettingsEnvironmentData` struct, which represents the DSC settings that can be +//! configured via environment variables. +//! +//! When defining a new environment variable for a DSC setting, follow these guidelines: +//! +//! 1. Ensure that the field is defined in the [`fields`] module following that module guidance. +//! 1. If the field is defined as a new type in the [`fields`] module, ensure that the type +//! implements the [`FromStr`] trait to enable parsing from a string. +//! 1. Determine the appropriate name for the environment variable: +//! +//! - Always prefix the name with `DSC_` and use `SCREAMING_SNAKE_CASE`. +//! - If the field is defined as a command line argument, use the long name of the argument, like +//! `DSC_TRACE_LEVEL` for the `--trace-level` argument. +//! - If the field isn't defined as a command line argument, choose a semantically meaningful +//! name that clearly indicates the purpose of the environment variable. If you're not sure, +//! default to using the field name for top-level fields. For nested fields, like +//! `my_new_area.foo.bar`, use the field name with underscores, like `DSC_MY_NEW_AREA_FOO_BAR`. +//! 1. Add a field to the [`DscSettingsEnvironmentData`] struct in this module: +//! +//! - Name the field the same as the environment variable, using snake case. For example, the +//! `DSC_TRACE_LEVEL` environment variable would correspond to a field named `dsc_trace_level`. +//! - Define the field's type as `Option`, where `T` is the type of the field defined in the +//! [`fields`] module (or the externally defined type if the field doesn't require a new type). +//! 1. Implement a method to retrieve the value of the environment variable and parse it into the +//! the appropriate type: +//! - Name the method `get_env_`, like `get_env_trace_level` for the +//! `dsc_trace_level` field. +//! - If the parsing for the field is infallible, define the return type as [`Option`] to +//! handle the case where the environment variable is not set. +//! - If the parsing for the field is fallible, define the return type as +//! [`Result, DscSettingsError>`] to surface parsing errors. +//! 1. Update the [`from_env()`] method to call the new `get_env_` method and set the +//! corresponding field in the [`DscSettingsEnvironmentData`] struct. +//! +//! If the method is infallible, you can just set the field to the return value of the method. +//! For example, if the new variable is `DSC_NEW_FIELD`, you would add the following snippet: +//! +//! ```ignore +//! dsc_new_field: Self::get_env_new_field(), +//! ``` +//! +//! If the method is fallible, set the field to a match statement that handles the [`Ok`] and +//! [`Err`] cases, emitting a warning for the invalid value and setting the field to [`None`] +//! in the [`Err`] case. For example, if the new variable is `DSC_NEW_FIELD`, you would add the +//! following snippet: +//! +//! ```ignore +//! dsc_new_field: match Self::get_env_new_field() { +//! Ok(value) => value, +//! Err(err) => { +//! warn!("ignoring invalid DSC_NEW_FIELD environment variable: {}", err); +//! None +//! } +//! }, +//! ``` +//! +//! 1. Update the [`try_from_env()`] method to call the new `get_env_` method and set +//! the corresponding field in the [`DscSettingsEnvironmentData`] struct. +//! +//! If the method is infallible, you can just set the field to the return value of the method. +//! For example, if the new variable is `DSC_NEW_FIELD`, you would add the following snippet: +//! +//! ```ignore +//! data.dsc_new_field = Self::get_env_new_field(); +//! ``` +//! +//! If the method is fallible, use a match statement to handle the [`Ok`] and [`Err`] cases, +//! pushing any errors to the `errors` vector. For example, if the new variable is +//! `DSC_NEW_FIELD`, you would add the following snippet: +//! +//! ```ignore +//! match Self::get_env_new_field() { +//! Ok(value) => data.dsc_new_field = value, +//! Err(err) => errors.push(err), +//! }; +//! ``` + +use std::{path::PathBuf, str::FromStr}; + +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use tracing::warn; + +use crate::settings::{DscSettingsError, TraceFormatField, TraceLevelField}; + +#[derive(Default, Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub struct DscSettingsEnvironmentData { + /// `DSC_TRACE_LEVEL` - Defines the trace level to use. + /// + /// This environment variable can be set to one of the following values (case insensitive): + /// + /// - [`error`] - Only emit error messages. + /// - [`warn`] - Only emit warning and error messages. + /// - [`info`] - Only emit informational, warning, and error messages. + /// - [`debug`] - Emit all messages except for trace messages. + /// - [`trace`] - Emit all messages, including trace messages. + /// + /// Setting this environment variable to an invalid value will raise an [`InvalidTraceLevel`] + /// error when initializing the settings. DSC will raise a warning and ignore the environment + /// variable when loading the environment settings data. + /// + /// [`error`]: crate::settings::TraceLevelField::Error + /// [`warn`]: crate::settings::TraceLevelField::Warn + /// [`info`]: crate::settings::TraceLevelField::Info + /// [`debug`]: crate::settings::TraceLevelField::Debug + /// [`trace`]: crate::settings::TraceLevelField::Trace + /// [`InvalidTraceLevel`]: crate::settings::DscSettingsError::InvalidTraceLevel + pub dsc_trace_level: Option, + /// `DSC_TRACE_FORMAT` - Defines the trace format to use. + /// + /// This environment variable can be set to one of the following values (case insensitive): + /// + /// - [`default`] - Emit trace messages in the default format, which is a human-readable format + /// that includes the timestamp, log level, and message. + /// - [`json`] - Emit trace messages in JSON format. + /// - [`plaintext`] - Emit trace messages in plain text format. + /// + /// Setting this environment variable to an invalid value will result in a + /// [`DscSettingsError::InvalidTraceFormat`] error when initializing the settings. DSC will + /// raise a warning and ignore the environment variable when loading the environment settings + /// data. + /// + /// [`default`]: crate::settings::TraceFormatField::Default + /// [`json`]: crate::settings::TraceFormatField::Json + /// [`plaintext`]: crate::settings::TraceFormatField::Plaintext + pub dsc_trace_format: Option, + /// `DSC_RESOURCE_PATH` - Defines a list of paths to use when searching for DSC resource, + /// extension, and other manifests. + /// + /// When defined, DSC will search for resources, extensions, and other manifests in the + /// specified paths. Effectively, this environment maps to the following settings in the + /// [`DscPreferenceFileData`] struct: + /// + /// ```ignore + /// directories: dsc_resource_path + /// ``` + /// + /// If either the [`restricted`] or [`append_env_path`] settings are defined in the [`Policy`], + /// [`Machine`], [`User`], or [`Workspace`] scopes, the behavior of this environment variable + /// may be affected. See the documentation for those settings for more information. + /// + /// If this environment variable is defined with the [`DSC_RESTRICTED_PATH`] environment + /// variable, the value of that variable takes precedence. + /// + /// [`restricted`]: crate::settings::ResourcePathFileData::restricted + /// [`append_env_path`]: crate::settings::ResourcePathFileData::append_env_path + /// [`Policy`]: crate::settings::DscSettingsScope::Policy + /// [`Machine`]: crate::settings::DscSettingsScope::Machine + /// [`User`]: crate::settings::DscSettingsScope::User + /// [`Workspace`]: crate::settings::DscSettingsScope::Workspace + /// [`DSC_RESTRICTED_PATH`]: Self::dsc_restricted_path + /// [`DscPreferenceFileData`]: crate::settings::DscPreferenceFileData + pub dsc_resource_path: Option>, + /// `DSC_RESTRICTED_PATH` - Defines a list of paths to use when searching for and invoking + /// resources, extensions, and other executables. + /// + /// When defined, DSC will only search for resources, extensions, and other executables in the + /// specified paths. DSC will _not_ allow invoking any executables outside of the specified + /// paths. Effectively, this environment maps to the following settings in the + /// [`DscPreferenceFileData`] struct: + /// + /// ```ignore + /// directories: dsc_restricted_path + /// restricted: true + /// ``` + /// + /// This environment variable should be defined as a list of paths separated by the + /// platform-specific path separator (`;` on Windows, `:` on Unix-like systems). + /// + /// If this environment variable is defined with the [`DSC_RESOURCE_PATH`] environment + /// variable, the value of this variable takes precedence. + /// + /// [`DSC_RESOURCE_PATH`]: Self::dsc_resource_path + /// [`DscPreferenceFileData`]: crate::settings::DscPreferenceFileData + pub dsc_restricted_path: Option>, + /// `DSC_IGNORE_SETTINGS_FILE` - Indicates whether to ignore settings files. + /// + /// When this environment variable is set to `true` or `1`, DSC will not automatically load + /// settings files. When resolving settings, DSC will ignore the settings files even if they + /// were manually loaded. This effectively skips processing the [`Machine`], [`User`], and + /// [`Workspace`] settings scopes. + /// + /// When this environment variable is set to `false` or `0`, DSC will load and resolve settings + /// files as normal. This is the default behavior when the environment variable is not set. + /// + /// The [`Policy`] scope is always processed regardless of this setting. + /// + /// This environment variable can be overridden by the [`--ignore-settings-file`] CLI argument, + /// which has a higher precedence. + /// + /// [`Machine`]: crate::settings::DscSettingsScope::Machine + /// [`User`]: crate::settings::DscSettingsScope::User + /// [`Workspace`]: crate::settings::DscSettingsScope::Workspace + /// [`Policy`]: crate::settings::DscSettingsScope::Policy + /// [`--ignore-settings-file`]: crate::settings::DscSettingsCliData::ignore_settings_file + pub dsc_ignore_settings_file: Option, +} + +impl DscSettingsEnvironmentData { + pub const DSC_TRACE_LEVEL_ENV_VAR: &str = "DSC_TRACE_LEVEL"; + pub const DSC_TRACE_FORMAT_ENV_VAR: &str = "DSC_TRACE_FORMAT"; + pub const DSC_RESOURCE_PATH_ENV_VAR: &str = "DSC_RESOURCE_PATH"; + pub const DSC_RESTRICTED_PATH_ENV_VAR: &str = "DSC_RESTRICTED_PATH"; + pub const DSC_IGNORE_SETTINGS_FILE_ENV_VAR: &str = "DSC_IGNORE_SETTINGS_FILE"; + /// Retrieves the value of the `DSC_TRACE_LEVEL` environment variable and parses it into a [`TraceLevelField`]. + /// + /// # Returns + /// + /// - [`Some`] [`TraceLevelField`] if the environment variable is set and valid. + /// - [`None`] if the environment variable is not set. + /// + /// # Errors + /// + /// If the environment variable is set but contains an invalid value, this function returns a + /// [`DscSettingsError::InvalidTraceLevel`] error. + pub fn get_env_trace_level() -> Result, DscSettingsError> { + let Some(level) = std::env::var(Self::DSC_TRACE_LEVEL_ENV_VAR).ok() else { + return Ok(None) + }; + + match TraceLevelField::from_str(&level) { + Ok(trace_level) => Ok(Some(trace_level)), + Err(source ) => { + Err(DscSettingsError::LoadEnvironmentError { + env_var: Self::DSC_TRACE_LEVEL_ENV_VAR, + source: Box::new(source), + }) + } + } + } + /// Retrieves the value of the `DSC_TRACE_FORMAT` environment variable and parses it into a [`TraceFormatField`]. + /// + /// # Returns + /// + /// - [`Some`] [`TraceFormatField`] if the environment variable is set and valid. + /// - [`None`] if the environment variable is not set. + /// + /// # Errors + /// + /// If the environment variable is set but contains an invalid value, this function returns a + /// [`DscSettingsError::InvalidTraceFormat`] error. + pub fn get_env_trace_format() -> Result, DscSettingsError> { + let Some(format) = std::env::var(Self::DSC_TRACE_FORMAT_ENV_VAR).ok() else { + return Ok(None) + }; + + match TraceFormatField::from_str(&format) { + Ok(trace_format) => Ok(Some(trace_format)), + Err(source) => Err(DscSettingsError::LoadEnvironmentError { + env_var: Self::DSC_TRACE_FORMAT_ENV_VAR, + source: Box::new(source), + }), + } + } + /// Retrieves the value of the `DSC_RESOURCE_PATH` environment variable and parses it into a + /// vector of [`PathBuf`]. + /// + /// # Returns + /// + /// - [`Some`] [`Vec`] if the environment variable is set. + /// - [`None`] if the environment variable is not set. + pub fn get_env_resource_path() -> Option> { + let Some(path) = std::env::var(Self::DSC_RESOURCE_PATH_ENV_VAR).ok() else { + return None; + }; + + Some(std::env::split_paths(&path).collect::>()) + } + /// Retrieves the value of the `DSC_RESTRICTED_PATH` environment variable and parses it into a + /// vector of [`PathBuf`]. + /// + /// # Returns + /// + /// - [`Some`] [`Vec`] if the environment variable is set. + /// - [`None`] if the environment variable is not set. + pub fn get_env_restricted_path() -> Option> { + let Some(path) = std::env::var(Self::DSC_RESTRICTED_PATH_ENV_VAR).ok() else { + return None; + }; + + Some(std::env::split_paths(&path).collect::>()) + } + + /// Retrieves the value of the `DSC_IGNORE_SETTINGS_FILE` environment variable and parses it + /// into a boolean. + /// + /// # Parsing + /// + /// This function interprets the following values case insensitively: + /// + /// - "true" or "1" as `true` + /// - "false" or "0" as `false` + /// + /// Any other value is invalid. + /// + /// # Returns + /// + /// - [`Some`] `true` if the environment variable is set to "true" or "1". + /// - [`Some`] `false` if the environment variable is set to "false" or "0". + /// - [`None`] if the environment variable is not set. + /// + /// # Errors + /// + /// If the environment variable is set but contains an invalid value, this function returns a + /// [`DscSettingsError::InvalidIgnoreSettingsFileEnvVar`] error. + pub fn get_env_ignore_settings_file() -> Result, DscSettingsError> { + let Some(value) = std::env::var(Self::DSC_IGNORE_SETTINGS_FILE_ENV_VAR).ok() else { + return Ok(None); + }; + + match Self::parse_boolean_env_var(&value) { + Ok(boolean_value) => Ok(Some(boolean_value)), + Err(source) => Err(DscSettingsError::LoadEnvironmentError { + env_var: Self::DSC_IGNORE_SETTINGS_FILE_ENV_VAR, + source: Box::new(source), + }), + } + } + + /// Creates a new instance of [`DscSettingsEnvironmentData`] by reading the relevant environment variables. + /// + /// This function reads the following environment variables: + /// - `DSC_TRACE_LEVEL`: The trace level to use. + /// - `DSC_TRACE_FORMAT`: The trace format to use. + /// - `DSC_RESOURCE_PATH`: A list of paths to use when searching for resources, separated by + /// the platform-specific path separator (`;` on Windows, `:` on Unix-like systems). + /// - `DSC_RESTRICTED_PATH`: A list of paths to use when searching for and invoking resources, + /// extensions, and other executables, separated by the platform-specific path separator. + /// - `DSC_IGNORE_SETTINGS_FILE`: Whether to ignore the settings file. + /// + /// When an environment variable is not set or is invalid, the corresponding field will be `None`. This function + /// emits warnings for any invalid environment variable values, but will not return an error. Use + /// [`try_from_env()`] if you want to handle errors instead of emitting warnings and setting the field to `None`. + /// + /// [`try_from_env()`]: Self::try_from_env + pub fn from_env() -> Self { + Self { + dsc_trace_level: match Self::get_env_trace_level() { + Ok(level) => level, + Err(err) => { + warn!("ignoring invalid {} environment variable: {}", Self::DSC_TRACE_LEVEL_ENV_VAR, err); + None + } + }, + dsc_trace_format: match Self::get_env_trace_format() { + Ok(format) => format, + Err(err) => { + warn!("ignoring invalid {} environment variable: {}", Self::DSC_TRACE_FORMAT_ENV_VAR, err); + None + } + }, + dsc_resource_path: Self::get_env_resource_path(), + dsc_restricted_path: Self::get_env_restricted_path(), + dsc_ignore_settings_file: match Self::get_env_ignore_settings_file() { + Ok(val) => val, + Err(err) => { + warn!("ignoring invalid {} environment variable: {}", Self::DSC_IGNORE_SETTINGS_FILE_ENV_VAR, err); + None + } + }, + } + } + + /// Creates a new instance of [`DscSettingsEnvironmentData`] by reading the relevant + /// environment variables. + /// + /// This function reads the same environment variables as [`from_env()`], but returns an error + /// if any of them are defined with invalid values. + /// + /// [`from_env()`]: Self::from_env + pub fn try_from_env() -> Result { + let mut errors = Vec::new(); + let mut data = Self::default(); + + match Self::get_env_trace_level() { + Ok(level) => data.dsc_trace_level = level, + Err(err) => errors.push(err), + } + match Self::get_env_trace_format() { + Ok(format) => data.dsc_trace_format = format, + Err(err) => errors.push(err), + } + data.dsc_resource_path = Self::get_env_resource_path(); + data.dsc_restricted_path = Self::get_env_restricted_path(); + match Self::get_env_ignore_settings_file() { + Ok(val) => data.dsc_ignore_settings_file = val, + Err(err) => errors.push(err), + } + + if errors.is_empty() { + Ok(data) + } else { + Err(DscSettingsError::LoadEnvironmentMultipleErrors(errors)) + } + } + + /// Parses a boolean environment variable value. + /// + /// This function interprets the following values case insensitively: + /// - `"true"` or `"1"` as `true` + /// - `"false"` or `"0"` as `false` + /// + /// # Errors + /// + /// If the value isn't one of the recognized boolean representations, this function returns a + /// [`ParseBooleanEnvVarError`]. + /// + /// # Examples + /// + /// The following example shows how different string values are parsed into boolean values: + /// + /// ```rust + /// assert_eq!(DscSettingsEnvironmentData::parse_boolean_env_var("true"), Ok(true)); + /// assert_eq!(DscSettingsEnvironmentData::parse_boolean_env_var("True"), Ok(true)); + /// assert_eq!(DscSettingsEnvironmentData::parse_boolean_env_var("TRUE"), Ok(true)); + /// assert_eq!(DscSettingsEnvironmentData::parse_boolean_env_var("tRuE"), Ok(true)); + /// assert_eq!(DscSettingsEnvironmentData::parse_boolean_env_var("1"), Ok(true)); + /// assert_eq!(DscSettingsEnvironmentData::parse_boolean_env_var("false"), Ok(false)); + /// assert_eq!(DscSettingsEnvironmentData::parse_boolean_env_var("False"), Ok(false)); + /// assert_eq!(DscSettingsEnvironmentData::parse_boolean_env_var("FALSE"), Ok(false)); + /// assert_eq!(DscSettingsEnvironmentData::parse_boolean_env_var("FfAlSe"), Ok(false)); + /// assert_eq!(DscSettingsEnvironmentData::parse_boolean_env_var("0"), Ok(false)); + /// assert!(DscSettingsEnvironmentData::parse_boolean_env_var("invalid").is_err()); + /// ``` + /// + /// [`ParseBooleanEnvVarError`]: DscSettingsError::ParseBooleanEnvVarError + pub fn parse_boolean_env_var(value: &str) -> Result { + match value.to_lowercase().as_str() { + "true" | "1" => Ok(true), + "false" | "0" => Ok(false), + _ => Err(DscSettingsError::ParseBooleanEnvVarError { + value: value.to_string(), + }), + } + } +} diff --git a/lib/dsc-lib/src/settings/sources/mod.rs b/lib/dsc-lib/src/settings/sources/mod.rs new file mode 100644 index 000000000..8589a055e --- /dev/null +++ b/lib/dsc-lib/src/settings/sources/mod.rs @@ -0,0 +1,45 @@ +//! Defines sources for DSC settings, including code defaults, policy and preference files, +//! environment variables, and command line arguments. +//! +//! Every setting field _must_ be definable in the [`PolicyFileData`] struct to enable systems +//! administrators to fully control how DSC behaves in production environments. Fields may be +//! defined in other sources using the following guidelines: +//! +//! 1. Define the field in the [`PreferenceFileData`] struct unless the field is strictly +//! applicable as policy. For example, the `forbid_ignore_settings_file` only makes sense in the +//! policy file. +//! 1. If the field controls behavior that a user may want to override on a per-command basis, +//! define the field in the [`DscEnvironmentData`] struct to enable users to override the field +//! using an environment variable. +//! +//! Non-Windows platforms allow users to prepend environment variables to a command to affect +//! behavior for that invocation only. We want to support this idiomatic behavior. For example: +//! +//! ```sh +//! # Uses trace level from defaults/files +//! dsc config --parameter-file ./example.dsc.params.yaml get -f ./example.dsc.config.yaml +//! # Overrides trace level for this invocation only +//! DSC_TRACE_LEVEL=debug dsc config get -f ./example.dsc.config.yaml +//! ``` +//! +//! For guidance on how to define the environment variable for a field, see the [`environment`] +//! module. +//! 1. Only define the field as a command line argument if it improves the user experience +//! _substantially_ and helps with discoverability. Any field represented in the CLI must be +//! defined for the root command. The more options available on the root command, the higher +//! the cognitive load for users. +//! +//! Only surface critical settings and extremely common settings in the CLI. +//! +//! For guidance on how to define the command line argument for a field, see the [`cli`] module. + +mod cli; +pub use cli::*; +mod code_defaults; +pub use code_defaults::*; +mod environment; +pub use environment::*; +mod preference_file; +pub use preference_file::*; +mod policy_file; +pub use policy_file::*; diff --git a/lib/dsc-lib/src/settings/sources/policy_file.rs b/lib/dsc-lib/src/settings/sources/policy_file.rs new file mode 100644 index 000000000..d7b2b43ca --- /dev/null +++ b/lib/dsc-lib/src/settings/sources/policy_file.rs @@ -0,0 +1,51 @@ +//! Defines the [`DscPolicyFileData`] struct, which represents the data structure of the policy +//! file for DSC settings. +//! +//! Every field defined in [`DscSettingsResolved`] struct should also be defined in the +//! [`DscPolicyFileData`] struct. The only exception is the `ignore_settings_file` field, which is +//! superceded by the `forbid_ignore_settings_file` field in the policy file. The +//! `ignore_settings_file` field is only definable in the environment and CLI sources. +//! +//! When adding a new top-level field to the policy file, follow these guidelines: +//! +//! 1. Ensure that the field is defined in the [`fields`] module following that module guidance. +//! 1. Add the field to the [`DscSettingsResolved`] struct: +//! +//! - If the field is a container field, define the field in the struct as the appropriate +//! `*PolicyFileData` or `*FileData` struct type. +//! - If the field is a top-level leaf field, define the field in the struct as an +//! [`Option`] with the appropriate type. +//! 1. If the field is a top-level leaf field, define the field in the [`DscPolicyFileData`] struct +//! as an [`Option`] with the appropriate type. +//! +//! No changes are required for the `from_file()` method, as it deserializes the field from the +//! policy file if it's defined. + +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use crate::settings::{DscSettingsError, ResourcePathFileData, TracingFileData}; + +#[derive(Default, Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct DscPolicyFileData { + pub forbid_ignore_settings_file: Option, + pub ignore_settings_file: Option, + pub tracing: Option, + pub resource_path: Option, +} + +impl DscPolicyFileData { + pub fn from_file(file_path: &std::path::Path) -> Result { + let contents = std::fs::read_to_string(file_path) + .map_err(|err| DscSettingsError::FileReadError{ + file_path: file_path.to_string_lossy().to_string(), + source: err, + })?; + serde_json::from_str::(&contents) + .map_err(|err| DscSettingsError::ParseDataFileError{ + file_path: file_path.to_string_lossy().to_string(), + source: err, + }) + } +} diff --git a/lib/dsc-lib/src/settings/sources/preference_file.rs b/lib/dsc-lib/src/settings/sources/preference_file.rs new file mode 100644 index 000000000..78ff739d5 --- /dev/null +++ b/lib/dsc-lib/src/settings/sources/preference_file.rs @@ -0,0 +1,25 @@ +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use crate::settings::{DscSettingsError, ResourcePathFileData, TracingFileData}; + +#[derive(Default, Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub struct DscPreferenceFileData { + pub tracing: Option, + pub resource_path: Option, +} + +impl DscPreferenceFileData { + pub fn from_file(file_path: &std::path::Path) -> Result { + let contents = std::fs::read_to_string(file_path) + .map_err(|err| DscSettingsError::FileReadError { + file_path: file_path.to_string_lossy().to_string(), + source: err, + })?; + serde_json::from_str::(&contents) + .map_err(|err| DscSettingsError::ParseDataFileError{ + file_path: file_path.to_string_lossy().to_string(), + source: err, + }) + } +} diff --git a/lib/dsc-lib/tests/integration/main.rs b/lib/dsc-lib/tests/integration/main.rs index 3a005a0ac..667784b1c 100644 --- a/lib/dsc-lib/tests/integration/main.rs +++ b/lib/dsc-lib/tests/integration/main.rs @@ -16,3 +16,4 @@ #[cfg(test)] mod command_resource; #[cfg(test)] mod schemas; #[cfg(test)] mod types; +#[cfg(test)] mod settings; \ No newline at end of file diff --git a/lib/dsc-lib/tests/integration/settings/builders.rs b/lib/dsc-lib/tests/integration/settings/builders.rs new file mode 100644 index 000000000..3969b0f41 --- /dev/null +++ b/lib/dsc-lib/tests/integration/settings/builders.rs @@ -0,0 +1,485 @@ +//! Defines builders for constructing settings data structures for testing purposes. +//! +//! The implementation for DSC retrieves settings from both the file system and the environment. However, for testing +//! purposes, it's useful to construct settings data structures directly in memory without relying on external files or +//! environment variables. +//! +//! This makes it easier to validate behavior for settings resolution, precedence, and policy enforcement without +//! needing to set up specific files or environment states. +//! +//! Acceptance tests written in Pester are more suited to testing the file system and environment interactions, while +//! these builders are intended for integration tests that focus on the correctness of the public API. + +#![allow(dead_code)] + +use std::path::PathBuf; + +use dsc_lib::settings::{DscPolicyFileData, DscSettingsResolvedField, DscSettings, DscSettingsCliData, DscSettingsEnvironmentData, DscPreferenceFileData, DscSettingsResolved, DscSettingsScope, ResourcePathFileData, ResourcePathResolvedSettings, TraceFormatField, TraceLevelField, TracingFileData, TracingResolvedSettings}; + +pub struct TracingDataBuilder { + level: Option, + format: Option, +} + +impl TracingDataBuilder { + pub fn new() -> Self { + Self { + level: None, + format: None, + } + } + + pub fn with_level(mut self, level: TraceLevelField) -> Self { + self.level = Some(level); + self + } + + pub fn with_format(mut self, format: TraceFormatField) -> Self { + self.format = Some(format); + self + } + + pub fn build(self) -> TracingFileData { + TracingFileData { + level: self.level, + format: self.format, + } + } +} + +pub struct ResourcePathDataBuilder { + append_env_path: Option, + directories: Option>, + restrict_path: Option, +} + +impl ResourcePathDataBuilder { + pub fn new() -> Self { + Self { + append_env_path: None, + directories: None, + restrict_path: None, + } + } + + pub fn with_append_env_path(mut self, append: bool) -> Self { + self.append_env_path = Some(append); + self + } + + pub fn with_directories(mut self, dirs: Vec) -> Self { + self.directories = Some(dirs); + self + } + + pub fn with_restrict_path(mut self, restrict: bool) -> Self { + self.restrict_path = Some(restrict); + self + } + + pub fn build(self) -> ResourcePathFileData { + ResourcePathFileData { + append_env_path: self.append_env_path, + directories: self.directories, + restricted: self.restrict_path, + } + } +} + +pub struct PolicyDataBuilder { + pub forbid_ignore_settings_file: Option, + pub tracing: Option, + pub resource_path: Option, +} + +impl PolicyDataBuilder { + pub fn new() -> Self { + Self { + forbid_ignore_settings_file: None, + tracing: None, + resource_path: None, + } + } + + pub fn with_forbid_ignore_settings_file(mut self, forbid: bool) -> Self { + self.forbid_ignore_settings_file = Some(forbid); + self + } + + pub fn with_tracing(mut self, tracing: TracingFileData) -> Self { + self.tracing = Some(tracing); + self + } + + pub fn with_resource_path(mut self, resource_path: ResourcePathFileData) -> Self { + self.resource_path = Some(resource_path); + self + } + + pub fn build(self) -> DscPolicyFileData { + DscPolicyFileData { + forbid_ignore_settings_file: self.forbid_ignore_settings_file, + tracing: self.tracing, + resource_path: self.resource_path, + } + } +} + +pub struct PreferenceDataBuilder { + pub tracing: Option, + pub resource_path: Option, +} +impl PreferenceDataBuilder { + pub fn new() -> Self { + Self { + tracing: None, + resource_path: None, + } + } + + pub fn with_tracing(mut self, tracing: TracingFileData) -> Self { + self.tracing = Some(tracing); + self + } + + pub fn with_resource_path(mut self, resource_path: ResourcePathFileData) -> Self { + self.resource_path = Some(resource_path); + self + } + + pub fn build(self) -> DscPreferenceFileData { + DscPreferenceFileData { + tracing: self.tracing, + resource_path: self.resource_path, + } + } +} + +pub struct CommandLineDataBuilder { + pub trace_level: Option, + pub trace_format: Option, + pub ignore_settings_file: Option, +} + +impl CommandLineDataBuilder { + pub fn new() -> Self { + Self { + trace_level: None, + trace_format: None, + ignore_settings_file: None, + } + } + + pub fn with_trace_level(mut self, level: TraceLevelField) -> Self { + self.trace_level = Some(level); + self + } + + pub fn with_trace_format(mut self, format: TraceFormatField) -> Self { + self.trace_format = Some(format); + self + } + + pub fn with_ignore_settings_file(mut self, ignore: bool) -> Self { + self.ignore_settings_file = Some(ignore); + self + } + + pub fn build(self) -> DscSettingsCliData { + DscSettingsCliData { + trace_level: self.trace_level, + trace_format: self.trace_format, + ignore_settings_file: self.ignore_settings_file, + } + } +} + +pub struct EnvironmentDataBuilder { + dsc_trace_level: Option, + dsc_trace_format: Option, + dsc_resource_path: Option>, + dsc_restricted_path: Option>, + dsc_ignore_settings_file: Option, +} + +impl EnvironmentDataBuilder { + pub fn new() -> Self { + Self { + dsc_trace_level: None, + dsc_trace_format: None, + dsc_resource_path: None, + dsc_restricted_path: None, + dsc_ignore_settings_file: None, + } + } + pub fn with_trace_level(mut self, level: TraceLevelField) -> Self { + self.dsc_trace_level = Some(level); + self + } + + pub fn with_trace_format(mut self, format: TraceFormatField) -> Self { + self.dsc_trace_format = Some(format); + self + } + + pub fn with_resource_path(mut self, path: Vec) -> Self { + self.dsc_resource_path = Some(path); + self + } + + pub fn with_restricted_path(mut self, path: Vec) -> Self { + self.dsc_restricted_path = Some(path); + self + } + + pub fn with_ignore_settings_file(mut self, ignore: bool) -> Self { + self.dsc_ignore_settings_file = Some(ignore); + self + } + pub fn build(self) -> DscSettingsEnvironmentData { + DscSettingsEnvironmentData { + dsc_trace_level: self.dsc_trace_level, + dsc_trace_format: self.dsc_trace_format, + dsc_resource_path: self.dsc_resource_path, + dsc_restricted_path: self.dsc_restricted_path, + dsc_ignore_settings_file: self.dsc_ignore_settings_file, + } + } +} + +pub struct CliDataBuilder { + pub trace_level: Option, + pub trace_format: Option, + pub ignore_settings_file: Option, +} + +impl CliDataBuilder { + pub fn new() -> Self { + Self { + trace_level: None, + trace_format: None, + ignore_settings_file: None, + } + } + + pub fn with_trace_level(mut self, level: TraceLevelField) -> Self { + self.trace_level = Some(level); + self + } + + pub fn with_trace_format(mut self, format: TraceFormatField) -> Self { + self.trace_format = Some(format); + self + } + + pub fn with_ignore_settings_file(mut self, ignore: bool) -> Self { + self.ignore_settings_file = Some(ignore); + self + } + + pub fn build(self) -> DscSettingsCliData { + DscSettingsCliData { + trace_level: self.trace_level, + trace_format: self.trace_format, + ignore_settings_file: self.ignore_settings_file, + } + } +} + +pub struct SettingsBuilder { + machine: Option, + user: Option, + workspace: Option, + environment: Option, + command_line: Option, + policy: Option, +} + +impl SettingsBuilder { + pub fn new() -> Self { + Self { + machine: None, + user: None, + workspace: None, + environment: None, + command_line: None, + policy: None, + } + } + + pub fn with_machine(mut self, machine_data: DscPreferenceFileData) -> Self { + self.machine = Some(machine_data); + self + } + + pub fn with_user(mut self, user_data: DscPreferenceFileData) -> Self { + self.user = Some(user_data); + self + } + + pub fn with_workspace(mut self, workspace_data: DscPreferenceFileData) -> Self { + self.workspace = Some(workspace_data); + self + } + pub fn with_environment(mut self, environment_data: DscSettingsEnvironmentData) -> Self { + self.environment = Some(environment_data); + self + } + pub fn with_command_line(mut self, command_line_data: DscSettingsCliData) -> Self { + self.command_line = Some(command_line_data); + self + } + pub fn with_policy(mut self, policy_data: DscPolicyFileData) -> Self { + self.policy = Some(policy_data); + self + } + pub fn build(self) -> DscSettings { + let mut settings = DscSettings::new(); + + settings.machine = self.machine; + settings.user = self.user; + settings.workspace = self.workspace; + settings.environment = self.environment; + settings.command_line = self.command_line; + settings.policy = self.policy; + + settings + } +} + +pub struct ResourcePathResolvedSettingsBuilder { + pub append_env_path: Option>, + pub directories: Option>>, + pub restrict_path: Option>, +} + +impl ResourcePathResolvedSettingsBuilder { + pub fn new() -> Self { + Self { + append_env_path: None, + directories: None, + restrict_path: None, + } + } + + pub fn with_append_env_path(mut self, value: bool, scope: DscSettingsScope) -> Self { + self.append_env_path = Some(DscSettingsResolvedField::new(value, scope)); + self + } + + pub fn with_directories(mut self, value: Vec, scope: DscSettingsScope) -> Self { + self.directories = Some(DscSettingsResolvedField::new(value, scope)); + self + } + + pub fn with_restrict_path(mut self, value: bool, scope: DscSettingsScope) -> Self { + self.restrict_path = Some(DscSettingsResolvedField::new(value, scope)); + self + } + + pub fn build(self) -> ResourcePathResolvedSettings { + let mut resolved = ResourcePathResolvedSettings::code_defaults(); + if let Some(append_env_path) = self.append_env_path { + resolved.append_env_path = append_env_path; + } + if let Some(directories) = self.directories { + resolved.directories = directories; + } + if let Some(restrict_path) = self.restrict_path { + resolved.restrict_path = restrict_path; + } + + resolved + } +} + +pub struct TracingResolvedSettingsBuilder { + pub level: Option>, + pub format: Option>, +} + +impl TracingResolvedSettingsBuilder { + pub fn new() -> Self { + Self { + level: None, + format: None, + } + } + + pub fn with_level(mut self, value: TraceLevelField, scope: DscSettingsScope) -> Self { + self.level = Some(DscSettingsResolvedField::new(value, scope)); + self + } + + pub fn with_format(mut self, value: TraceFormatField, scope: DscSettingsScope) -> Self { + self.format = Some(DscSettingsResolvedField::new(value, scope)); + self + } + + pub fn build(self) -> TracingResolvedSettings { + let mut resolved = TracingResolvedSettings::code_defaults(); + if let Some(level) = self.level { + resolved.level = level; + } + if let Some(format) = self.format { + resolved.format = format; + } + + resolved + } +} + +pub struct ResolvedSettingsBuilder { + pub forbid_ignore_settings_file: Option>, + pub ignore_settings_file: Option>, + pub tracing: Option, + pub resource_path: Option, +} + +impl ResolvedSettingsBuilder { + pub fn new() -> Self { + Self { + forbid_ignore_settings_file: None, + ignore_settings_file: None, + tracing: None, + resource_path: None, + } + } + + pub fn with_forbid_ignore_settings_file(mut self, value: bool, scope: DscSettingsScope) -> Self { + self.forbid_ignore_settings_file = Some(DscSettingsResolvedField::new(value, scope)); + self + } + + pub fn with_ignore_settings_file(mut self, value: bool, scope: DscSettingsScope) -> Self { + self.ignore_settings_file = Some(DscSettingsResolvedField::new(value, scope)); + self + } + + pub fn with_tracing(mut self, tracing: TracingResolvedSettings) -> Self { + self.tracing = Some(tracing); + self + } + + pub fn with_resource_path(mut self, resource_path: ResourcePathResolvedSettings) -> Self { + self.resource_path = Some(resource_path); + self + } + pub fn build(self) -> DscSettingsResolved { + let mut settings = DscSettingsResolved::default(); + if let Some(forbid_ignore_settings_file) = self.forbid_ignore_settings_file { + settings.forbid_ignore_settings_file = forbid_ignore_settings_file; + } + if let Some(ignore_settings_file) = self.ignore_settings_file { + settings.ignore_settings_file = ignore_settings_file; + } + if let Some(tracing) = self.tracing { + settings.tracing = tracing; + } + if let Some(resource_path) = self.resource_path { + settings.resource_path = resource_path; + } + + settings + } +} diff --git a/lib/dsc-lib/tests/integration/settings/mod.rs b/lib/dsc-lib/tests/integration/settings/mod.rs new file mode 100644 index 000000000..d289cd2a5 --- /dev/null +++ b/lib/dsc-lib/tests/integration/settings/mod.rs @@ -0,0 +1,394 @@ + +use dsc_lib::settings::*; + +#[cfg(test)] mod builders; +use builders::*; + +#[cfg(test)] mod dsc_settings { + use std::{path::PathBuf, sync::LazyLock}; + use test_case::test_case; + use super::*; + + /// Defines policy data with the following settings: + /// + /// - `forbid_ignore_settings_file`: `true` + /// - `resource_path.append_env_path`: `false` + /// - `resource_path.directories`: `["/etc/dsc"]` + /// - `resource_path.restrict_path`: `true` + /// + /// It doesn't define any values for `tracing`. + static POLICY_DATA: LazyLock = LazyLock::new(|| { + PolicyDataBuilder::new() + .with_forbid_ignore_settings_file(true) + .with_resource_path( + ResourcePathDataBuilder::new() + .with_append_env_path(false) + .with_directories(vec!["/etc/dsc".to_string()]) + .with_restrict_path(true) + .build() + ) + .build() + }); + + /// Defines machine data with the following settings: + /// + /// - `tracing.level`: `warn` + /// - `resource_path.append_env_path`: `true` + /// - `resource_path.directories`: `["/usr/local/bin"]` + static MACHINE_DATA: LazyLock = LazyLock::new(|| { + PreferenceDataBuilder::new() + .with_tracing( + TracingDataBuilder::new() + .with_level(TraceLevelField::Warn) + .build() + ) + .with_resource_path( + ResourcePathDataBuilder::new() + .with_append_env_path(true) + .with_directories(vec!["/usr/local/bin".to_string()]) + .build() + ) + .build() + }); + + /// Defines workspace data with the following settings: + /// + /// - `tracing.level`: `warn` + /// - `resource_path.directories`: `["~/infra/dsc/resources", "~/infra/dsc/extensions"]` + static WORKSPACE_DATA: LazyLock = LazyLock::new(|| { + PreferenceDataBuilder::new() + .with_tracing( + TracingDataBuilder::new() + .with_level(TraceLevelField::Warn) + .build() + ) + .with_resource_path( + ResourcePathDataBuilder::new() + .with_directories(vec![ + "~/infra/dsc/resources".to_string(), + "~/infra/dsc/extensions".to_string() + ]) + .build() + ) + .build() + }); + + /// Defines user data with the following settings: + /// + /// - `tracing.level`: `debug` + static USER_DATA: LazyLock = LazyLock::new(|| { + PreferenceDataBuilder::new() + .with_tracing( + TracingDataBuilder::new() + .with_level(TraceLevelField::Debug) + .build() + ) + .build() + }); + + /// Defines environment data with the following settings: + /// + /// - `dsc_resource_path`: `["/usr/bin"]` + /// - `dsc_ignore_settings_file`: `true` + /// - `dsc_trace_level`: `info` + static ENV_DATA: LazyLock = LazyLock::new(|| { + EnvironmentDataBuilder::new() + .with_resource_path(vec![PathBuf::from("/usr/bin")]) + .with_ignore_settings_file(true) + .with_trace_level(TraceLevelField::Info) + .build() + }); + + static CLI_DATA: LazyLock = LazyLock::new(|| { + CliDataBuilder::new() + .with_ignore_settings_file(false) + .with_trace_level(TraceLevelField::Debug) + .build() + }); + + fn assert_pretty_resolved_eq(expected: DscSettingsResolved) -> impl Fn(DscSettingsResolved) { + move |actual: DscSettingsResolved| { pretty_assertions::assert_eq!(actual, expected) } + } + + #[test_case( + &mut SettingsBuilder::new().build() => + using assert_pretty_resolved_eq( + ResolvedSettingsBuilder::new().build() + ); + "with_code_defaults_only" + )] + #[test_case( + &mut SettingsBuilder::new() + .with_machine(MACHINE_DATA.clone()) + .build() => + using assert_pretty_resolved_eq( + ResolvedSettingsBuilder::new() + .with_resource_path( + ResourcePathResolvedSettingsBuilder::new() + .with_append_env_path(true, DscSettingsScope::Machine) + .with_directories(vec!["/usr/local/bin".to_string()], DscSettingsScope::Machine) + .build() + ) + .with_tracing( + TracingResolvedSettingsBuilder::new() + .with_level(TraceLevelField::Warn, DscSettingsScope::Machine) + .build() + ) + .build() + ); + "machine_overrides_code_defaults" + )] + #[test_case( + &mut SettingsBuilder::new() + .with_machine(MACHINE_DATA.clone()) + .with_user(USER_DATA.clone()) + .build() => + using assert_pretty_resolved_eq( + ResolvedSettingsBuilder::new() + .with_resource_path( + ResourcePathResolvedSettingsBuilder::new() + .with_append_env_path(true, DscSettingsScope::Machine) + .with_directories(vec!["/usr/local/bin".to_string()], DscSettingsScope::Machine) + .build() + ) + .with_tracing( + TracingResolvedSettingsBuilder::new() + .with_level(TraceLevelField::Debug, DscSettingsScope::User) + .build() + ) + .build() + ); + "user_overrides_machine" + )] + #[test_case( + &mut SettingsBuilder::new() + .with_machine(MACHINE_DATA.clone()) + .with_user(USER_DATA.clone()) + .with_workspace(WORKSPACE_DATA.clone()) + .build() => + using assert_pretty_resolved_eq( + ResolvedSettingsBuilder::new() + .with_resource_path( + ResourcePathResolvedSettingsBuilder::new() + .with_append_env_path(true, DscSettingsScope::Machine) + .with_directories(vec![ + "~/infra/dsc/resources".to_string(), + "~/infra/dsc/extensions".to_string() + ], DscSettingsScope::Workspace) + .build() + ) + .with_tracing( + TracingResolvedSettingsBuilder::new() + .with_level(TraceLevelField::Warn, DscSettingsScope::Workspace) + .build() + ) + .build() + ); + "workspace_overrides_user" + )] + #[test_case( + &mut SettingsBuilder::new() + .with_machine(MACHINE_DATA.clone()) + .with_user(USER_DATA.clone()) + .with_workspace(WORKSPACE_DATA.clone()) + .with_environment(ENV_DATA.clone()) + .build() => + using assert_pretty_resolved_eq( + ResolvedSettingsBuilder::new() + .with_ignore_settings_file(true, DscSettingsScope::Environment) + .with_resource_path( + ResourcePathResolvedSettingsBuilder::new() + .with_directories( + vec!["/usr/bin".to_string()], + DscSettingsScope::Environment + ) + .build() + ) + .with_tracing( + TracingResolvedSettingsBuilder::new() + .with_level(TraceLevelField::Info, DscSettingsScope::Environment) + .build() + ) + .build() + ); + "environment_overrides_workspace" + )] + #[test_case( + &mut SettingsBuilder::new() + .with_machine(MACHINE_DATA.clone()) + .with_user(USER_DATA.clone()) + .with_workspace(WORKSPACE_DATA.clone()) + .with_environment(ENV_DATA.clone()) + .with_command_line(CLI_DATA.clone()) + .build() => + using assert_pretty_resolved_eq( + ResolvedSettingsBuilder::new() + .with_ignore_settings_file(false, DscSettingsScope::CommandLine) + .with_resource_path( + ResourcePathResolvedSettingsBuilder::new() + .with_append_env_path(true, DscSettingsScope::Machine) + .with_directories( + vec!["/usr/bin".to_string()], + DscSettingsScope::Environment + ) + .build() + ) + .with_tracing( + TracingResolvedSettingsBuilder::new() + .with_level(TraceLevelField::Debug, DscSettingsScope::CommandLine) + .build() + ) + .build() + ); + "command_line_overrides_environment" + )] + #[test_case( + &mut SettingsBuilder::new() + .with_policy(POLICY_DATA.clone()) + .with_machine(MACHINE_DATA.clone()) + .with_user(USER_DATA.clone()) + .with_workspace(WORKSPACE_DATA.clone()) + .with_environment(ENV_DATA.clone()) + .with_command_line(CLI_DATA.clone()) + .build() => + using assert_pretty_resolved_eq(ResolvedSettingsBuilder::new() + .with_forbid_ignore_settings_file(true, DscSettingsScope::Policy) + .with_ignore_settings_file(false, DscSettingsScope::Default) + .with_resource_path( + ResourcePathResolvedSettingsBuilder::new() + .with_append_env_path(false, DscSettingsScope::Policy) + .with_directories(vec!["/etc/dsc".to_string()], DscSettingsScope::Policy) + .with_restrict_path(true, DscSettingsScope::Policy) + .build() + ) + .with_tracing( + TracingResolvedSettingsBuilder::new() + .with_level(TraceLevelField::Debug, DscSettingsScope::CommandLine) + .with_format(TraceFormatField::Default, DscSettingsScope::Default) + .build() + ) + .build() + ); + "policy_overrides_all" + )] + fn resolved(settings: &mut DscSettings) -> DscSettingsResolved { + settings.resolved().clone() + } + + #[test_case( + &mut SettingsBuilder::new().build() => + false; + "without_policy_returns_false" + )] + #[test_case( + &mut SettingsBuilder::new() + .with_policy( + PolicyDataBuilder::new() + .with_resource_path( + ResourcePathDataBuilder::new() + .with_append_env_path(false) + .with_directories(vec!["/etc/dsc".to_string()]) + .with_restrict_path(true) + .build() + ) + .build() + ) + .build() => + false; + "with_policy_field_undefined_returns_false" + )] + #[test_case( + &mut SettingsBuilder::new() + .with_policy( + PolicyDataBuilder::new() + .with_forbid_ignore_settings_file(false) + .build() + ) + .build() => + false; + "with_policy_field_false_returns_false" + )] + #[test_case( + &mut SettingsBuilder::new() + .with_policy( + PolicyDataBuilder::new() + .with_forbid_ignore_settings_file(true) + .build() + ) + .build() => + true; + "with_policy_field_true_returns_true" + )] + fn policy_forbids_ignoring_settings_files(settings: &mut DscSettings) -> bool { + settings.policy_forbids_ignoring_settings_files() + } + + #[test_case( + &mut SettingsBuilder::new().build() => + false; + "code_defaults_only_returns_false" + )] + #[test_case( + &mut SettingsBuilder::new() + .with_environment( + EnvironmentDataBuilder::new() + .with_ignore_settings_file(true) + .build() + ) + .build() => + true; + "with_env_var_true_without_policy_or_cli_returns_true" + )] + #[test_case( + &mut SettingsBuilder::new() + .with_environment( + EnvironmentDataBuilder::new() + .with_ignore_settings_file(false) + .build() + ) + .build() => + false; + "with_env_var_false_without_policy_or_cli_returns_false" + )] + #[test_case( + &mut SettingsBuilder::new() + .with_command_line( + CliDataBuilder::new() + .with_ignore_settings_file(true) + .build() + ) + .build() => + true; + "with_cli_arg_true_without_policy_or_env_var_returns_true" + )] + #[test_case( + &mut SettingsBuilder::new() + .with_policy( + PolicyDataBuilder::new() + .with_forbid_ignore_settings_file(true) + .build() + ) + .with_environment( + EnvironmentDataBuilder::new() + .with_ignore_settings_file(true) + .build() + ) + .build() => + false; + "with_policy_forbidding_and_env_var_true_returns_false" + )] + #[test_case( + &mut SettingsBuilder::new() + .with_policy( + PolicyDataBuilder::new() + .with_forbid_ignore_settings_file(true) + .build() + ) + .build() => + true; + "with_policy_field_true_returns_true" + )] + fn ignoring_settings_files(settings: &mut DscSettings) -> bool { + settings.ignoring_settings_files() + } +} \ No newline at end of file