Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions dsc/tests/dsc_sshdconfig.tests.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,7 @@ resources:
}

Context 'Set Commands' {
It 'Set works with _clobber: true' {
It 'Set works with _purge: true' {
$set_yaml = @"
`$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json
metadata:
Expand All @@ -151,7 +151,7 @@ resources:
metadata:
filepath: $filepath
properties:
_clobber: true
_purge: true
port: 1234
allowUsers:
- user1
Expand Down
14 changes: 9 additions & 5 deletions resources/sshdconfig/locales/en-us.toml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ setInput = "input to set in sshd_config"
[error]
command = "Command"
envVar = "Environment Variable"
fileNotFound = "File not found: %{path}"
invalidInput = "Invalid Input"
fmt = "Format"
io = "IO"
Expand All @@ -19,6 +20,12 @@ persist = "Persist"
registry = "Registry"
stringUtf8 = "String UTF-8"

[formatter]
deserializeFailed = "Failed to deserialize match input: %{error}"
invalidArrayItem = "Array item '%{item}' for key '%{key}' is not valid"
invalidValue = "Key: '%{key}' cannot have empty value"
matchBlockMissingCriteria = "Match block must contain 'criteria' field"

[get]
debugSetting = "Get setting:"
defaultShellCmdOptionMustBeString = "cmdOption must be a string"
Expand Down Expand Up @@ -58,10 +65,11 @@ unknownNodeType = "unknown node type: '%{node}'"
backingUpConfig = "Backing up existing sshd_config file"
backupCreated = "Backup created at: %{path}"
cleanupFailed = "Failed to clean up temporary file %{path}: %{error}"
clobberFalseUnsupported = "clobber=false is not yet supported for sshd_config resource"
configDoesNotExist = "sshd_config file does not exist, no backup created"
defaultShellDebug = "default_shell: %{shell}"
failedToParseDefaultShell = "failed to parse input for DefaultShell with error: '%{error}'"
purgeFalseRequiresExistingFile = "_purge=false requires an existing sshd_config file. Use _purge=true to create a new configuration file."
purgeFalseUnsupported = "_purge=false is not supported for keywords that can have multiple values"
settingDefaultShell = "Setting default shell"
settingSshdConfig = "Setting sshd_config"
shellPathDoesNotExist = "shell path does not exist: '%{shell}'"
Expand All @@ -73,12 +81,8 @@ writingTempConfig = "Writing temporary sshd_config file"

[util]
cleanupFailed = "Failed to clean up temporary file %{path}: %{error}"
deserializeFailed = "Failed to deserialize match input: %{error}"
getIgnoresInputFilters = "get command does not support filtering based on input settings, provided input will be ignored"
inputMustBeBoolean = "value of '%{input}' must be true or false"
invalidValue = "Key: '%{key}' cannot have empty value"
matchBlockMissingCriteria = "Match block must contain 'criteria' field"
sshdConfigNotFound = "sshd_config not found at path: '%{path}'"
sshdConfigReadFailed = "failed to read sshd_config at path: '%{path}'"
sshdElevation = "elevated security context required"
tempFileCreated = "temporary file created at: %{path}"
Expand Down
2 changes: 2 additions & 0 deletions resources/sshdconfig/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ pub enum SshdConfigError {
CommandError(String),
#[error("{t}: {0}", t = t!("error.envVar"))]
EnvVarError(#[from] std::env::VarError),
#[error("{t}", t = t!("error.fileNotFound", path = .0))]
FileNotFound(String),
#[error("{t}: {0}", t = t!("error.fmt"))]
FmtError(#[from] std::fmt::Error),
#[error("{t}: {0}", t = t!("error.invalidInput"))]
Expand Down
201 changes: 201 additions & 0 deletions resources/sshdconfig/src/formatter.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

use rust_i18n::t;
use serde::Deserialize;
use serde_json::{Map, Value};
use std::{fmt, fmt::Write};
use tracing::warn;

use crate::error::SshdConfigError;
use crate::metadata::{MULTI_ARG_KEYWORDS_COMMA_SEP, REPEATABLE_KEYWORDS};

#[derive(Debug, Deserialize)]
struct MatchBlock {
criteria: Map<String, Value>,
#[serde(flatten)]
contents: Map<String, Value>,
}

#[derive(Clone, Debug)]
pub struct SshdConfigValue<'a> {
is_repeatable: bool,
key: &'a str,
separator: ValueSeparator,
value: &'a Value,
}

#[derive(Clone, Copy, Debug)]
pub enum ValueSeparator {
Comma,
Space,
}

impl<'a> SshdConfigValue<'a> {
/// Create a new SSHD config value, returning an error if the value is empty/invalid
pub fn try_new(key: &'a str, value: &'a Value, override_separator: Option<ValueSeparator>) -> Result<Self, SshdConfigError> {
if matches!(value, Value::Null | Value::Object(_)) {
return Err(SshdConfigError::ParserError(
t!("formatter.invalidValue", key = key).to_string()
));
}

if let Value::Array(arr) = value {
if arr.is_empty() {
return Err(SshdConfigError::ParserError(
t!("formatter.invalidValue", key = key).to_string()
));
}
}

let separator = match override_separator {
Some(separator) => separator,
None => {
if MULTI_ARG_KEYWORDS_COMMA_SEP.contains(&key) {
ValueSeparator::Comma
} else {
ValueSeparator::Space
}
}
};

let is_repeatable = REPEATABLE_KEYWORDS.contains(&key);

Ok(Self {
is_repeatable,
key,
separator,
value,
})
}

pub fn write_to_config(&self, config_text: &mut String) -> Result<(), SshdConfigError> {
if self.is_repeatable {
if let Value::Array(arr) = self.value {
for item in arr {
let item = SshdConfigValue::try_new(self.key, item, Some(self.separator))?;
writeln!(config_text, "{} {item}", self.key)?;
}
} else {
writeln!(config_text, "{} {self}", self.key)?;
}
} else {
writeln!(config_text, "{} {self}", self.key)?;
}
Ok(())
}
}

impl fmt::Display for SshdConfigValue<'_> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self.value {
Value::Array(arr) => {
if arr.is_empty() {
return Ok(());
}

let separator = match self.separator {
ValueSeparator::Comma => ",",
ValueSeparator::Space => " ",
};

let mut first = true;
for item in arr {
if let Ok(sshd_config_value) = SshdConfigValue::try_new(self.key, item, Some(self.separator)) {
let formatted = sshd_config_value.to_string();
if !formatted.is_empty() {
if !first {
write!(f, "{separator}")?;
}
write!(f, "{formatted}")?;
first = false;
}
} else {
warn!("{}", t!("formatter.invalidArrayItem", key = self.key, item = item).to_string());
}
}
Ok(())
},
Value::Bool(b) => write!(f, "{}", if *b { "yes" } else { "no" }),
Value::Number(n) => write!(f, "{n}"),
Value::String(s) => {
if s.contains(char::is_whitespace) {
write!(f, "\"{s}\"")
} else {
write!(f, "{s}")
}
},
Value::Null | Value::Object(_) => Ok(()),
}
}
}

fn format_match_block(match_obj: &Value) -> Result<String, SshdConfigError> {
let match_block = match serde_json::from_value::<MatchBlock>(match_obj.clone()) {
Ok(result) => {
result
}
Err(e) => {
return Err(SshdConfigError::ParserError(t!("formatter.deserializeFailed", error = e).to_string()));
}
};

if match_block.criteria.is_empty() {
return Err(SshdConfigError::InvalidInput(t!("formatter.matchBlockMissingCriteria").to_string()));
}

let mut match_parts = vec![];
let mut result = vec![];

for (key, value) in &match_block.criteria {
// all match criteria values are comma-separated
let sshd_config_value = SshdConfigValue::try_new(key, value, Some(ValueSeparator::Comma))?;
match_parts.push(format!("{key} {sshd_config_value}"));
}

// Write the Match line with the formatted criteria(s)
result.push(match_parts.join(" "));

// Format other keywords in the match block
for (key, value) in &match_block.contents {
let sshd_config_value = SshdConfigValue::try_new(key, value, None)?;
result.push(format!("\t{key} {sshd_config_value}"));
}

Ok(result.join("\n"))
}

/// Write configuration map to config text string
///
/// # Errors
///
/// This function will return an error if formatting fails.
pub fn write_config_map_to_text(global_map: &Map<String, Value>) -> Result<String, SshdConfigError> {
let match_map = global_map.get("match");
let mut config_text = String::new();

for (key, value) in global_map {
let key_lower = key.to_lowercase();

if key_lower == "match" {
continue; // match blocks are handled after global settings
}

let sshd_config_value = SshdConfigValue::try_new(key, value, None)?;
sshd_config_value.write_to_config(&mut config_text)?;
}

if let Some(match_map) = match_map {
if let Value::Array(arr) = match_map {
for item in arr {
let formatted = format_match_block(item)?;
writeln!(&mut config_text, "Match {formatted}")?;
}
} else {
let formatted = format_match_block(match_map)?;
writeln!(&mut config_text, "Match {formatted}")?;
}
}

Ok(config_text)
}
25 changes: 18 additions & 7 deletions resources/sshdconfig/src/inputs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,28 +7,39 @@ use std::path::PathBuf;

#[derive(Debug, Default, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct CommandInfo {
#[serde(rename = "_clobber")]
pub clobber: bool,
/// Switch to include defaults in the output
#[serde(rename = "_includeDefaults")]
pub include_defaults: bool,
/// input provided with the command
pub input: Map<String, Value>,
/// metadata provided with the command
pub metadata: Metadata,
#[serde(rename = "_purge")]
pub purge: bool,
/// additional arguments for the call to sshd -T
pub sshd_args: Option<SshdCommandArgs>
}

impl CommandInfo {
/// Create a new `CommandInfo` instance.
pub fn new(include_defaults: bool) -> Self {
pub fn new(
include_defaults: bool,
input: Map<String, Value>,
metadata: Metadata,
purge: bool,
sshd_args: Option<SshdCommandArgs>
) -> Self {
// Lowercase keys for case-insensitive comparison
let input = input.into_iter()
.map(|(k, v)| (k.to_lowercase(), v))
.collect();

Self {
clobber: false,
include_defaults,
input: Map::new(),
metadata: Metadata::new(),
sshd_args: None
input,
metadata,
purge,
sshd_args
}
}
}
Expand Down
1 change: 1 addition & 0 deletions resources/sshdconfig/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ use util::{build_command_info, enable_tracing};

mod args;
mod error;
mod formatter;
mod get;
mod inputs;
mod metadata;
Expand Down
Loading