Skip to content

Commit 362e72e

Browse files
authored
Merge pull request #1224 from SteveL-MSFT/exe-validation
Enable resource manifests to have relative paths
2 parents ec33ed2 + 0899801 commit 362e72e

File tree

7 files changed

+100
-24
lines changed

7 files changed

+100
-24
lines changed

dsc/tests/dsc_discovery.tests.ps1

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -245,4 +245,55 @@ Describe 'tests for resource discovery' {
245245
$env:DSC_RESOURCE_PATH = $oldPath
246246
}
247247
}
248+
249+
It 'Resource manifest using relative path to exe: <path>' -TestCases @(
250+
@{ path = '../dscecho'; success = $true }
251+
@{ path = '../foo/dscecho'; success = $false }
252+
) {
253+
param($path, $success)
254+
$manifest = @"
255+
{
256+
"`$schema": "https://aka.ms/dsc/schemas/v3/bundled/resource/manifest.json",
257+
"type": "Microsoft.DSC.Debug/Echo",
258+
"version": "1.0.0",
259+
"description": "Echo resource for testing and debugging purposes",
260+
"get": {
261+
"executable": "$path",
262+
"args": [
263+
{
264+
"jsonInputArg": "--input",
265+
"mandatory": true
266+
}
267+
]
268+
},
269+
"schema": {
270+
"command": {
271+
"executable": "$path"
272+
}
273+
}
274+
}
275+
"@
276+
$dscEcho = Get-Command dscecho -ErrorAction Stop
277+
# copy to testdrive
278+
Copy-Item -Path "$($dscEcho.Source)" -Destination $testdrive
279+
# create manifest in subfolder
280+
$subfolder = Join-Path $testdrive 'subfolder'
281+
New-Item -Path $subfolder -ItemType Directory -Force | Out-Null
282+
Set-Content -Path (Join-Path $subfolder 'test.dsc.resource.json') -Value $manifest
283+
284+
try {
285+
$env:DSC_RESOURCE_PATH = $subfolder
286+
$out = dsc resource get -r 'Microsoft.DSC.Debug/Echo' -i '{"output":"RelativePathTest"}' 2> "$testdrive/error.txt" | ConvertFrom-Json
287+
if ($success) {
288+
$LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw -Path "$testdrive/error.txt")
289+
$out.actualState.output | Should -BeExactly 'RelativePathTest'
290+
} else {
291+
$LASTEXITCODE | Should -Be 2 -Because (Get-Content -Raw -Path "$testdrive/error.txt")
292+
(Get-Content -Raw -Path "$testdrive/error.txt") | Should -Match "ERROR.*?Executable '\.\./foo/dscecho(\.exe)?' not found"
293+
}
294+
}
295+
finally {
296+
$env:DSC_RESOURCE_PATH = $null
297+
}
298+
}
248299
}

dsc/tests/dsc_extension_discover.tests.ps1

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -138,7 +138,7 @@ Describe 'Discover extension tests' {
138138
$out = dsc -l warn resource list 2> $TestDrive/error.log | ConvertFrom-Json
139139
$LASTEXITCODE | Should -Be 0
140140
$out.Count | Should -BeGreaterThan 0
141-
(Get-Content -Path "$TestDrive/error.log" -Raw) | Should -BeLike "*WARN Extension 'Microsoft.Windows.Appx/Discover' failed to discover resources: Command: Operation program not found for executable 'powershell'*" -Because (Get-Content -Path "$TestDrive/error.log" -Raw | Out-String)
141+
(Get-Content -Path "$TestDrive/error.log" -Raw) | Should -BeLike "*WARN Extension 'Microsoft.Windows.Appx/Discover' failed to discover resources: Command: Operation Executable 'powershell' not found*" -Because (Get-Content -Path "$TestDrive/error.log" -Raw | Out-String)
142142
} finally {
143143
$env:PATH = $oldPath
144144
}

lib/dsc-lib/locales/en-us.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -698,3 +698,5 @@ failedToGetExePath = "Can't get 'dsc' executable path"
698698
settingNotFound = "Setting '%{name}' not found"
699699
failedToAbsolutizePath = "Failed to absolutize path '%{path}'"
700700
invalidExitCodeKey = "Invalid exit code key '%{key}'"
701+
executableNotFoundInWorkingDirectory = "Executable '%{executable}' not found with working directory '%{cwd}'"
702+
executableNotFound = "Executable '%{executable}' not found"

lib/dsc-lib/src/discovery/command_discovery.rs

Lines changed: 19 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -20,14 +20,13 @@ use serde::Deserialize;
2020
use std::{collections::{BTreeMap, HashMap, HashSet}, sync::{LazyLock, RwLock}};
2121
use std::env;
2222
use std::ffi::OsStr;
23-
use std::fs;
23+
use std::fs::{create_dir_all, read, read_to_string, write};
2424
use std::path::{Path, PathBuf};
2525
use std::str::FromStr;
2626
use tracing::{debug, info, trace, warn};
27-
use which::which;
2827

2928
use crate::util::get_setting;
30-
use crate::util::get_exe_path;
29+
use crate::util::{canonicalize_which, get_exe_path};
3130

3231
const DSC_EXTENSION_EXTENSIONS: [&str; 3] = [".dsc.extension.json", ".dsc.extension.yaml", ".dsc.extension.yml"];
3332
const DSC_MANIFEST_LIST_EXTENSIONS: [&str; 3] = [".dsc.manifests.json", ".dsc.manifests.yaml", ".dsc.manifests.yml"];
@@ -621,7 +620,7 @@ fn insert_resource(resources: &mut BTreeMap<String, Vec<DscResource>>, resource:
621620
///
622621
/// * Returns a `DscError` if the manifest could not be loaded or parsed.
623622
pub fn load_manifest(path: &Path) -> Result<Vec<ImportedManifest>, DscError> {
624-
let contents = fs::read_to_string(path)?;
623+
let contents = read_to_string(path)?;
625624
let file_name_lowercase = path.file_name().and_then(OsStr::to_str).unwrap_or("").to_lowercase();
626625
let extension_is_json = path.extension().is_some_and(|ext| ext.eq_ignore_ascii_case("json"));
627626
if DSC_RESOURCE_EXTENSIONS.iter().any(|ext| file_name_lowercase.ends_with(ext)) {
@@ -711,38 +710,38 @@ fn load_resource_manifest(path: &Path, manifest: &ResourceManifest) -> Result<Ds
711710

712711
let mut capabilities: Vec<Capability> = vec![];
713712
if let Some(get) = &manifest.get {
714-
verify_executable(&manifest.resource_type, "get", &get.executable);
713+
verify_executable(&manifest.resource_type, "get", &get.executable, path.parent().unwrap());
715714
capabilities.push(Capability::Get);
716715
}
717716
if let Some(set) = &manifest.set {
718-
verify_executable(&manifest.resource_type, "set", &set.executable);
717+
verify_executable(&manifest.resource_type, "set", &set.executable, path.parent().unwrap());
719718
capabilities.push(Capability::Set);
720719
if set.handles_exist == Some(true) {
721720
capabilities.push(Capability::SetHandlesExist);
722721
}
723722
}
724723
if let Some(what_if) = &manifest.what_if {
725-
verify_executable(&manifest.resource_type, "what_if", &what_if.executable);
724+
verify_executable(&manifest.resource_type, "what_if", &what_if.executable, path.parent().unwrap());
726725
capabilities.push(Capability::WhatIf);
727726
}
728727
if let Some(test) = &manifest.test {
729-
verify_executable(&manifest.resource_type, "test", &test.executable);
728+
verify_executable(&manifest.resource_type, "test", &test.executable, path.parent().unwrap());
730729
capabilities.push(Capability::Test);
731730
}
732731
if let Some(delete) = &manifest.delete {
733-
verify_executable(&manifest.resource_type, "delete", &delete.executable);
732+
verify_executable(&manifest.resource_type, "delete", &delete.executable, path.parent().unwrap());
734733
capabilities.push(Capability::Delete);
735734
}
736735
if let Some(export) = &manifest.export {
737-
verify_executable(&manifest.resource_type, "export", &export.executable);
736+
verify_executable(&manifest.resource_type, "export", &export.executable, path.parent().unwrap());
738737
capabilities.push(Capability::Export);
739738
}
740739
if let Some(resolve) = &manifest.resolve {
741-
verify_executable(&manifest.resource_type, "resolve", &resolve.executable);
740+
verify_executable(&manifest.resource_type, "resolve", &resolve.executable, path.parent().unwrap());
742741
capabilities.push(Capability::Resolve);
743742
}
744743
if let Some(SchemaKind::Command(command)) = &manifest.schema {
745-
verify_executable(&manifest.resource_type, "schema", &command.executable);
744+
verify_executable(&manifest.resource_type, "schema", &command.executable, path.parent().unwrap());
746745
}
747746

748747
let resource = DscResource {
@@ -768,15 +767,15 @@ fn load_extension_manifest(path: &Path, manifest: &ExtensionManifest) -> Result<
768767

769768
let mut capabilities: Vec<dscextension::Capability> = vec![];
770769
if let Some(discover) = &manifest.discover {
771-
verify_executable(&manifest.r#type, "discover", &discover.executable);
770+
verify_executable(&manifest.r#type, "discover", &discover.executable, path.parent().unwrap());
772771
capabilities.push(dscextension::Capability::Discover);
773772
}
774773
if let Some(secret) = &manifest.secret {
775-
verify_executable(&manifest.r#type, "secret", &secret.executable);
774+
verify_executable(&manifest.r#type, "secret", &secret.executable, path.parent().unwrap());
776775
capabilities.push(dscextension::Capability::Secret);
777776
}
778777
let import_extensions = if let Some(import) = &manifest.import {
779-
verify_executable(&manifest.r#type, "import", &import.executable);
778+
verify_executable(&manifest.r#type, "import", &import.executable, path.parent().unwrap());
780779
capabilities.push(dscextension::Capability::Import);
781780
if import.file_extensions.is_empty() {
782781
warn!("{}", t!("discovery.commandDiscovery.importExtensionsEmpty", extension = manifest.r#type));
@@ -803,8 +802,8 @@ fn load_extension_manifest(path: &Path, manifest: &ExtensionManifest) -> Result<
803802
Ok(extension)
804803
}
805804

806-
fn verify_executable(resource: &str, operation: &str, executable: &str) {
807-
if which(executable).is_err() {
805+
fn verify_executable(resource: &str, operation: &str, executable: &str, directory: &Path) {
806+
if canonicalize_which(executable, Some(directory.to_string_lossy().as_ref())).is_err() {
808807
info!("{}", t!("discovery.commandDiscovery.executableNotFound", resource = resource, operation = operation, executable = executable));
809808
}
810809
}
@@ -839,8 +838,8 @@ fn save_adapted_resources_lookup_table(lookup_table: &HashMap<String, String>)
839838

840839
let path = std::path::Path::new(&file_path);
841840
if let Some(prefix) = path.parent() {
842-
if fs::create_dir_all(prefix).is_ok() {
843-
if fs::write(file_path.clone(), lookup_table_json).is_err() {
841+
if create_dir_all(prefix).is_ok() {
842+
if write(file_path.clone(), lookup_table_json).is_err() {
844843
info!("Unable to write lookup_table file {file_path:?}");
845844
}
846845
} else {
@@ -858,7 +857,7 @@ fn load_adapted_resources_lookup_table() -> HashMap<String, String>
858857
{
859858
let file_path = get_lookup_table_file_path();
860859

861-
let lookup_table: HashMap<String, String> = match fs::read(file_path.clone()){
860+
let lookup_table: HashMap<String, String> = match read(file_path.clone()){
862861
Ok(data) => { serde_json::from_slice(&data).unwrap_or_default() },
863862
Err(_) => { HashMap::new() }
864863
};

lib/dsc-lib/src/dscerror.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,9 @@ pub enum DscError {
2626
#[error("{t} '{0}' [{t2} {1}] {t3}: {2}", t = t!("dscerror.commandResource"), t2 = t!("dscerror.exitCode"), t3 = t!("dscerror.manifestDescription"))]
2727
CommandExitFromManifest(String, i32, String),
2828

29+
#[error("{0}")]
30+
CommandNotFound(String),
31+
2932
#[error("{t} {0} {t2} '{1}'", t = t!("dscerror.commandOperation"), t2 = t!("dscerror.forExecutable"))]
3033
CommandOperation(String, String),
3134

lib/dsc-lib/src/dscresources/command_resource.rs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ use rust_i18n::t;
77
use serde::Deserialize;
88
use serde_json::{Map, Value};
99
use std::{collections::HashMap, env, process::Stdio};
10-
use crate::configure::{config_doc::ExecutionKind, config_result::{ResourceGetResult, ResourceTestResult}};
10+
use crate::{configure::{config_doc::ExecutionKind, config_result::{ResourceGetResult, ResourceTestResult}}, util::canonicalize_which};
1111
use crate::dscerror::DscError;
1212
use super::{dscresource::{get_diff, redact}, invoke_result::{ExportResult, GetResult, ResolveResult, SetResult, TestResult, ValidateResult, ResourceGetResponse, ResourceSetResponse, ResourceTestResponse, get_in_desired_state}, resource_manifest::{ArgKind, InputKind, Kind, ResourceManifest, ReturnKind, SchemaKind}};
1313
use tracing::{error, warn, info, debug, trace};
@@ -763,6 +763,7 @@ fn convert_hashmap_string_keys_to_i32(input: Option<&HashMap<String, String>>) -
763763
#[allow(clippy::implicit_hasher)]
764764
pub fn invoke_command(executable: &str, args: Option<Vec<String>>, input: Option<&str>, cwd: Option<&str>, env: Option<HashMap<String, String>>, exit_codes: Option<&HashMap<String, String>>) -> Result<(i32, String, String), DscError> {
765765
let exit_codes = convert_hashmap_string_keys_to_i32(exit_codes)?;
766+
let executable = canonicalize_which(executable, cwd)?;
766767

767768
tokio::runtime::Builder::new_multi_thread().enable_all().build().unwrap().block_on(
768769
async {
@@ -771,7 +772,7 @@ pub fn invoke_command(executable: &str, args: Option<Vec<String>>, input: Option
771772
trace!("{}", t!("dscresources.commandResource.commandCwd", cwd = cwd));
772773
}
773774

774-
match run_process_async(executable, args, input, cwd, env, exit_codes.as_ref()).await {
775+
match run_process_async(&executable, args, input, cwd, env, exit_codes.as_ref()).await {
775776
Ok((code, stdout, stderr)) => {
776777
Ok((code, stdout, stderr))
777778
},

lib/dsc-lib/src/util.rs

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,13 @@ use rust_i18n::t;
66
use serde_json::Value;
77
use std::{
88
fs,
9-
fs::File,
9+
fs::{canonicalize, File},
1010
io::BufReader,
1111
path::{Path, PathBuf},
1212
env,
1313
};
1414
use tracing::debug;
15+
use which::which;
1516

1617
pub struct DscSettingValue {
1718
pub setting: Value,
@@ -232,6 +233,25 @@ pub fn resource_id(type_name: &str, name: &str) -> String {
232233
result
233234
}
234235

236+
pub fn canonicalize_which(executable: &str, cwd: Option<&str>) -> Result<String, DscError> {
237+
// Use PathBuf to handle path separators robustly
238+
let mut executable_path = PathBuf::from(executable);
239+
if cfg!(target_os = "windows") && executable_path.extension().is_none() {
240+
executable_path.set_extension("exe");
241+
}
242+
if which(executable).is_err() {
243+
if let Some(cwd) = cwd {
244+
let cwd_path = Path::new(cwd);
245+
if let Ok(canonical_path) = canonicalize(cwd_path.join(&executable_path)) {
246+
return Ok(canonical_path.to_string_lossy().to_string());
247+
}
248+
return Err(DscError::CommandOperation(t!("util.executableNotFoundInWorkingDirectory", executable = &executable, cwd = cwd_path.to_string_lossy()).to_string(), executable_path.to_string_lossy().to_string()));
249+
}
250+
return Err(DscError::CommandOperation(t!("util.executableNotFound", executable = &executable).to_string(), executable.to_string()));
251+
}
252+
Ok(executable.to_string())
253+
}
254+
235255
#[macro_export]
236256
macro_rules! locked_is_empty {
237257
($lockable:expr) => {{

0 commit comments

Comments
 (0)