diff --git a/Cargo.lock b/Cargo.lock index 463a32b57..388a21ca3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1185,7 +1185,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" [[package]] -name = "filecontent" +name = "file_dsc" version = "0.1.0" dependencies = [ "base16ct", @@ -1193,6 +1193,7 @@ dependencies = [ "serde", "serde_json", "sha2 0.11.0", + "windows 0.62.2", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 5504aa256..51c8cdc53 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,7 +17,7 @@ members = [ "lib/dsc-lib", "resources/dism_dsc", "resources/dscecho", - "resources/filecontent", + "resources/file_dsc", "resources/osinfo", "resources/process", "resources/registry", @@ -52,7 +52,7 @@ default-members = [ "lib/dsc-lib", "resources/dism_dsc", "resources/dscecho", - "resources/filecontent", + "resources/file_dsc", "resources/osinfo", "resources/process", "resources/registry", @@ -88,7 +88,7 @@ Windows = [ "lib/dsc-lib", "resources/dism_dsc", "resources/dscecho", - "resources/filecontent", + "resources/file_dsc", "resources/osinfo", "resources/process", "resources/registry", @@ -116,7 +116,7 @@ macOS = [ "lib/dsc-lib-telemetry", "lib/dsc-lib", "resources/dscecho", - "resources/filecontent", + "resources/file_dsc", "resources/osinfo", "resources/process", "resources/runcommandonset", @@ -138,7 +138,7 @@ Linux = [ "lib/dsc-lib-telemetry", "lib/dsc-lib", "resources/dscecho", - "resources/filecontent", + "resources/file_dsc", "resources/osinfo", "resources/process", "resources/runcommandonset", @@ -160,7 +160,7 @@ strip = "symbols" # See split-debuginfo - allows us to drop the size b [workspace.dependencies] #external dependencies -# filecontent +# file_dsc base16ct = { version = "1.0.0", features = ["alloc"] } # dsc-lib base32 = { version = "0.5" } @@ -238,7 +238,7 @@ semver = { version = "1.0.28" } serde = { version = "1.0.229", features = ["derive"] } # dsc, dsc-lib, dscecho, dsc-lib-osinfo, osinfo, process, registry, dsc-lib-registry, runcommandonset, sshdconfig, dsctest, test_group_resource, y2j serde_json = { version = "1.0.151", features = ["preserve_order"] } -# filecontent +# file_dsc sha2 = { version = "0.11.0" } # dsc, dsc-lib, y2j serde_yaml = { version = "0.9" } diff --git a/data.build.json b/data.build.json index 370d36e3e..a91dc0240 100644 --- a/data.build.json +++ b/data.build.json @@ -11,8 +11,8 @@ "dsc-bicep-ext", "dscecho", "echo.dsc.resource.json", - "filecontent", - "filecontent.dsc.resource.json", + "file_dsc", + "file_dsc.dsc.manifests.json", "assertion.dsc.resource.json", "apt.dsc.resource.json", "apt.dsc.resource.sh", @@ -43,8 +43,8 @@ "dsc-bicep-ext", "dscecho", "echo.dsc.resource.json", - "filecontent", - "filecontent.dsc.resource.json", + "file_dsc", + "file_dsc.dsc.manifests.json", "assertion.dsc.resource.json", "brew.dsc.resource.json", "brew.dsc.resource.sh", @@ -78,8 +78,8 @@ "dsc-bicep-ext.exe", "dscecho.exe", "echo.dsc.resource.json", - "filecontent.exe", - "filecontent.dsc.resource.json", + "file_dsc.exe", + "file_dsc.dsc.manifests.json", "environment_variable.exe", "environment_variable.dsc.manifests.json", "assertion.dsc.resource.json", @@ -344,16 +344,16 @@ } }, { - "Name": "filecontent", + "Name": "file_dsc", "Kind": "Resource", - "RelativePath": "resources/filecontent", + "RelativePath": "resources/file_dsc", "IsRust": true, "Binaries": [ - "filecontent" + "file_dsc" ], "CopyFiles": { "All": [ - "filecontent.dsc.resource.json" + "file_dsc.dsc.manifests.json" ] } }, diff --git a/dsc/tests/dsc_test_resource_errors.tests.ps1 b/dsc/tests/dsc_test_resource_errors.tests.ps1 index e0d341b08..6532f4a50 100644 --- a/dsc/tests/dsc_test_resource_errors.tests.ps1 +++ b/dsc/tests/dsc_test_resource_errors.tests.ps1 @@ -55,26 +55,33 @@ Describe 'Test resource executable error handling' { Describe 'Rust resource executable error handling' { It 'Returns failure when FileContent has no operation' { - $null = filecontent 2>$TestDrive/error.log + $null = file_dsc 2>$TestDrive/error.log $LASTEXITCODE | Should -Be 1 (Get-Content -Raw $TestDrive/error.log) | Should -Match 'Missing operation' } It 'Returns failure when FileContent has no input' { - $null = filecontent get 2>$TestDrive/error.log + $null = file_dsc get 2>$TestDrive/error.log $LASTEXITCODE | Should -Be 1 (Get-Content -Raw $TestDrive/error.log) | Should -Match 'Missing --input argument' } It 'Returns failure when FileContent input is invalid JSON' { - $null = filecontent get --input '{invalid' 2>$TestDrive/error.log + $null = file_dsc get --input '{invalid' 2>$TestDrive/error.log $LASTEXITCODE | Should -Be 2 (Get-Content -Raw $TestDrive/error.log) | Should -Match 'Invalid JSON input' } + It 'Returns an unknown operation error before parsing ACL input' { + $null = file_dsc acl unknown --input '{invalid' 2>$TestDrive/error.log + + $LASTEXITCODE | Should -Be 1 + (Get-Content -Raw $TestDrive/error.log) | Should -Match "Unknown ACL operation: 'unknown'" + } + It 'Returns failure when DSC Echo input is invalid JSON' { $null = dscecho --input '{invalid' 2>$TestDrive/error.log diff --git a/resources/filecontent/.project.data.json b/resources/file_dsc/.project.data.json similarity index 57% rename from resources/filecontent/.project.data.json rename to resources/file_dsc/.project.data.json index 15b7f760d..0777a0206 100644 --- a/resources/filecontent/.project.data.json +++ b/resources/file_dsc/.project.data.json @@ -1,13 +1,13 @@ { - "Name": "filecontent", + "Name": "file_dsc", "Kind": "Resource", "IsRust": true, "Binaries": [ - "filecontent" + "file_dsc" ], "CopyFiles": { "All": [ - "filecontent.dsc.resource.json" + "file_dsc.dsc.manifests.json" ] } } diff --git a/resources/filecontent/Cargo.toml b/resources/file_dsc/Cargo.toml similarity index 76% rename from resources/filecontent/Cargo.toml rename to resources/file_dsc/Cargo.toml index 266c15df4..48c04998d 100644 --- a/resources/filecontent/Cargo.toml +++ b/resources/file_dsc/Cargo.toml @@ -1,10 +1,10 @@ [package] -name = "filecontent" +name = "file_dsc" version = "0.1.0" edition = "2024" [[bin]] -name = "filecontent" +name = "file_dsc" path = "src/main.rs" [package.metadata.i18n] @@ -18,3 +18,6 @@ rust-i18n = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } sha2 = { workspace = true } + +[target.'cfg(windows)'.dependencies] +windows = { workspace = true } diff --git a/resources/file_dsc/file_dsc.dsc.manifests.json b/resources/file_dsc/file_dsc.dsc.manifests.json new file mode 100644 index 000000000..f70788ed4 --- /dev/null +++ b/resources/file_dsc/file_dsc.dsc.manifests.json @@ -0,0 +1,200 @@ +{ + "resources": [ + { + "$schema": "https://aka.ms/dsc/schemas/v3/bundled/resource/manifest.json", + "type": "Microsoft.Filesystem.File/Content", + "description": "Manage the UTF-8 content of a local file.", + "tags": [ + "file", + "linux", + "macos", + "windows" + ], + "version": "0.1.0", + "get": { + "executable": "file_dsc", + "args": [ + "get", + { + "jsonInputArg": "--input", + "mandatory": true + } + ] + }, + "set": { + "executable": "file_dsc", + "args": [ + "set", + { + "jsonInputArg": "--input", + "mandatory": true + } + ], + "implementsPretest": false, + "return": "state" + }, + "test": { + "executable": "file_dsc", + "args": [ + "test", + { + "jsonInputArg": "--input", + "mandatory": true + } + ], + "return": "state" + }, + "exitCodes": { + "0": "Success", + "1": "Invalid arguments", + "2": "Invalid input", + "3": "File operation error" + }, + "schema": { + "embedded": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "File Content", + "description": "Manage the UTF-8 content of a local file.", + "type": "object", + "required": [ + "path" + ], + "properties": { + "content": { + "type": "string", + "title": "Content", + "description": "The UTF-8 content of the file." + }, + "sha256": { + "type": "string", + "title": "SHA-256", + "description": "The SHA-256 hash of the file content.", + "pattern": "^[A-Fa-f0-9]{64}$" + }, + "sha512": { + "type": "string", + "title": "SHA-512", + "description": "The SHA-512 hash of the file content.", + "pattern": "^[A-Fa-f0-9]{128}$" + }, + "path": { + "type": "string", + "title": "Path", + "description": "The path to the local file.", + "minLength": 1 + }, + "_exist": { + "type": "boolean", + "title": "Exists", + "description": "Indicates whether the file should exist. Set to true or omit to create or update the file and any missing parent directories. Set to false to remove the file." + }, + "_inDesiredState": { + "type": "boolean", + "title": "In desired state", + "description": "Indicates whether the file is in the desired state. Only returned by test.", + "readOnly": true + } + }, + "additionalProperties": false + } + } + }, + { + "$schema": "https://aka.ms/dsc/schemas/v3/bundled/resource/manifest.json", + "type": "Microsoft.Filesystem/ACL", + "description": "Manage the discretionary access control list of a local file or folder.", + "tags": [ + "file", + "folder", + "windows" + ], + "version": "0.1.0", + "get": { + "executable": "file_dsc", + "args": [ + "acl", + "get", + { + "jsonInputArg": "--input", + "mandatory": true + } + ] + }, + "set": { + "executable": "file_dsc", + "args": [ + "acl", + "set", + { + "jsonInputArg": "--input", + "mandatory": true + } + ], + "implementsPretest": false, + "return": "state" + }, + "test": { + "executable": "file_dsc", + "args": [ + "acl", + "test", + { + "jsonInputArg": "--input", + "mandatory": true + } + ], + "return": "state" + }, + "exitCodes": { + "0": "Success", + "1": "Invalid arguments", + "2": "Invalid input", + "3": "Filesystem ACL operation error" + }, + "schema": { + "embedded": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Filesystem ACL", + "description": "Manage the discretionary access control list of a local file or folder.", + "type": "object", + "required": [ + "path", + "SDDL" + ], + "properties": { + "path": { + "type": "string", + "title": "Path", + "description": "The path to the local file or folder.", + "minLength": 1, + "x-dsc-key": true + }, + "SDDL": { + "type": "string", + "title": "Security descriptor definition language", + "description": "The complete discretionary access control list in SDDL form. Setting this property replaces the existing DACL, including its protection state.", + "minLength": 1 + }, + "_inDesiredState": { + "$ref": "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/v3/resource/properties/inDesiredState.json" + } + }, + "additionalProperties": false, + "$defs": { + "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/v3/resource/properties/inDesiredState.json": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/v3/resource/properties/inDesiredState.json", + "title": "Instance is in the Desired State", + "description": "Indicates whether the instance is in the desired state. This property is only returned by the `test` method.", + "type": [ + "boolean", + "null" + ], + "readOnly": true + } + } + } + } + } + ] +} diff --git a/resources/filecontent/locales/en-us.toml b/resources/file_dsc/locales/en-us.toml similarity index 50% rename from resources/filecontent/locales/en-us.toml rename to resources/file_dsc/locales/en-us.toml index ce267e0ca..48cdf1849 100644 --- a/resources/filecontent/locales/en-us.toml +++ b/resources/file_dsc/locales/en-us.toml @@ -1,7 +1,7 @@ _version = 1 [main] -missingOperation = "Missing operation. Usage: filecontent --input " +missingOperation = "Missing operation. Usage: file_dsc --input | file_dsc acl --input " unknownOperation = "Unknown operation: '%{operation}'. Expected: get, set, or test" missingInput = "Missing --input argument" missingInputValue = "Missing value for --input argument" @@ -23,3 +23,17 @@ writeError = "Failed to write file '%{path}': %{error}" removeError = "Failed to remove file '%{path}': %{error}" sha256Mismatch = "The sha256 value does not match the content property" sha512Mismatch = "The sha512 value does not match the content property" + +[acl] +missingOperation = "Missing ACL operation. Usage: file_dsc acl --input " +unknownOperation = "Unknown ACL operation: '%{operation}'. Expected: get, set, or test" +windowsOnly = "The filesystem ACL resource is only supported on Windows" +emptySddl = "The SDDL property must not be empty" +unsupportedSddlComponent = "The SDDL property must contain only a discretionary ACL (D:) component" +pathNotFound = "The path '%{path}' does not exist" +pathCheckError = "Failed to inspect the path '%{path}': %{error}" +invalidSddl = "The SDDL property is invalid: %{error}" +getError = "Failed to read the ACL for '%{path}': %{error}" +setError = "Failed to set the ACL for '%{path}': %{error}" +convertError = "Failed to convert the security descriptor to SDDL: %{error}" +emptyDescriptor = "The security descriptor was empty" diff --git a/resources/file_dsc/src/acl.rs b/resources/file_dsc/src/acl.rs new file mode 100644 index 000000000..e458cd61a --- /dev/null +++ b/resources/file_dsc/src/acl.rs @@ -0,0 +1,238 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use crate::types::FilesystemAcl; +use rust_i18n::t; +use std::ptr; +use windows::Win32::Foundation::{HLOCAL, LocalFree}; +use windows::Win32::Security::Authorization::{ + ConvertSecurityDescriptorToStringSecurityDescriptorW, + ConvertStringSecurityDescriptorToSecurityDescriptorW, SDDL_REVISION_1, +}; +use windows::Win32::Security::{ + DACL_SECURITY_INFORMATION, GetFileSecurityW, GetSecurityDescriptorControl, + OBJECT_SECURITY_INFORMATION, PROTECTED_DACL_SECURITY_INFORMATION, PSECURITY_DESCRIPTOR, + SE_DACL_PROTECTED, SetFileSecurityW, UNPROTECTED_DACL_SECURITY_INFORMATION, +}; +use windows::core::{PCWSTR, PWSTR}; + +pub fn get(input: &FilesystemAcl) -> Result { + validate_input(input)?; + Ok(FilesystemAcl { + path: input.path.clone(), + sddl: read_sddl(&input.path)?, + in_desired_state: None, + }) +} + +pub fn set(input: &FilesystemAcl) -> Result { + validate_input(input)?; + let security_descriptor = SecurityDescriptor::from_sddl(&input.sddl)?; + let path = to_wide(&input.path); + let security_information = security_descriptor.dacl_security_information()?; + + unsafe { + SetFileSecurityW( + PCWSTR(path.as_ptr()), + security_information, + security_descriptor.as_ptr(), + ) + } + .ok() + .map_err(|error| { + t!( + "acl.setError", + path = input.path.as_str(), + error = error.to_string() + ) + .to_string() + })?; + + get(input) +} + +pub fn test(input: &FilesystemAcl) -> Result { + validate_input(input)?; + let desired = SecurityDescriptor::from_sddl(&input.sddl)?.to_sddl()?; + let mut actual = get(input)?; + actual.in_desired_state = Some(actual.sddl.eq_ignore_ascii_case(&desired)); + Ok(actual) +} + +fn validate_input(input: &FilesystemAcl) -> Result<(), String> { + if input.path.is_empty() { + return Err(t!("input.emptyPath").to_string()); + } + if input.sddl.is_empty() { + return Err(t!("acl.emptySddl").to_string()); + } + if !has_top_level_section(&input.sddl, b"D:") + || [b"O:", b"G:", b"S:"] + .iter() + .any(|section| has_top_level_section(&input.sddl, section)) + { + return Err(t!("acl.unsupportedSddlComponent").to_string()); + } + match std::path::Path::new(&input.path).try_exists() { + Ok(true) => {} + Ok(false) => { + return Err(t!("acl.pathNotFound", path = input.path.as_str()).to_string()); + } + Err(error) => { + return Err(t!( + "acl.pathCheckError", + path = input.path.as_str(), + error = error.to_string() + ) + .to_string()); + } + } + Ok(()) +} + +fn has_top_level_section(sddl: &str, section: &[u8; 2]) -> bool { + let bytes = sddl.as_bytes(); + let mut depth = 0_u32; + for index in 0..bytes.len().saturating_sub(1) { + match bytes[index] { + b'(' => depth += 1, + b')' => depth = depth.saturating_sub(1), + _ if depth == 0 + && bytes[index].eq_ignore_ascii_case(§ion[0]) + && bytes[index + 1] == section[1] => + { + return true; + } + _ => {} + } + } + false +} + +fn read_sddl(path: &str) -> Result { + let path_wide = to_wide(path); + let mut required_length = 0; + let first_result = unsafe { + GetFileSecurityW( + PCWSTR(path_wide.as_ptr()), + DACL_SECURITY_INFORMATION.0, + None, + 0, + &mut required_length, + ) + }; + if required_length == 0 { + return Err(t!( + "acl.getError", + path = path, + error = first_result + .ok() + .err() + .map_or_else(|| t!("acl.emptyDescriptor").to_string(), |error| error.to_string()) + ) + .to_string()); + } + + let mut buffer = vec![0_u8; required_length as usize]; + unsafe { + GetFileSecurityW( + PCWSTR(path_wide.as_ptr()), + DACL_SECURITY_INFORMATION.0, + Some(PSECURITY_DESCRIPTOR(buffer.as_mut_ptr().cast())), + required_length, + &mut required_length, + ) + } + .ok() + .map_err(|error| { + t!("acl.getError", path = path, error = error.to_string()).to_string() + })?; + + SecurityDescriptor::borrowed(buffer.as_mut_ptr().cast()).to_sddl() +} + +fn to_wide(value: &str) -> Vec { + value.encode_utf16().chain(std::iter::once(0)).collect() +} + +struct SecurityDescriptor { + pointer: PSECURITY_DESCRIPTOR, + owned: bool, +} + +impl SecurityDescriptor { + fn from_sddl(sddl: &str) -> Result { + let sddl_wide = to_wide(sddl); + let mut pointer = PSECURITY_DESCRIPTOR(ptr::null_mut()); + unsafe { + ConvertStringSecurityDescriptorToSecurityDescriptorW( + PCWSTR(sddl_wide.as_ptr()), + SDDL_REVISION_1, + &mut pointer, + None, + ) + } + .map_err(|error| t!("acl.invalidSddl", error = error.to_string()).to_string())?; + + Ok(Self { + pointer, + owned: true, + }) + } + + fn borrowed(pointer: *mut core::ffi::c_void) -> Self { + Self { + pointer: PSECURITY_DESCRIPTOR(pointer), + owned: false, + } + } + + fn as_ptr(&self) -> PSECURITY_DESCRIPTOR { + self.pointer + } + + fn dacl_security_information(&self) -> Result { + let mut control = 0_u16; + let mut revision = 0_u32; + unsafe { GetSecurityDescriptorControl(self.pointer, &mut control, &mut revision) } + .map_err(|error| t!("acl.invalidSddl", error = error.to_string()).to_string())?; + + let protection = if control & SE_DACL_PROTECTED.0 != 0 { + PROTECTED_DACL_SECURITY_INFORMATION + } else { + UNPROTECTED_DACL_SECURITY_INFORMATION + }; + Ok(DACL_SECURITY_INFORMATION | protection) + } + + fn to_sddl(&self) -> Result { + let mut output = PWSTR::null(); + unsafe { + ConvertSecurityDescriptorToStringSecurityDescriptorW( + self.pointer, + SDDL_REVISION_1, + DACL_SECURITY_INFORMATION, + &mut output, + None, + ) + } + .map_err(|error| t!("acl.convertError", error = error.to_string()).to_string())?; + + let result = unsafe { output.to_string() } + .map_err(|error| t!("acl.convertError", error = error.to_string()).to_string()); + unsafe { + let _ = LocalFree(Some(HLOCAL(output.0.cast()))); + } + result + } +} + +impl Drop for SecurityDescriptor { + fn drop(&mut self) { + if self.owned { + unsafe { + let _ = LocalFree(Some(HLOCAL(self.pointer.0.cast()))); + } + } + } +} diff --git a/resources/filecontent/src/file.rs b/resources/file_dsc/src/file.rs similarity index 100% rename from resources/filecontent/src/file.rs rename to resources/file_dsc/src/file.rs diff --git a/resources/filecontent/src/main.rs b/resources/file_dsc/src/main.rs similarity index 64% rename from resources/filecontent/src/main.rs rename to resources/file_dsc/src/main.rs index a39582174..c12067de2 100644 --- a/resources/filecontent/src/main.rs +++ b/resources/file_dsc/src/main.rs @@ -1,11 +1,13 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +#[cfg(windows)] +mod acl; mod file; mod types; use crate::file::{get, set, test}; -use crate::types::FileContent; +use crate::types::{FileContent, FilesystemAcl}; use rust_i18n::t; use serde::Serialize; use serde_json::json; @@ -25,6 +27,10 @@ fn main() -> ExitCode { return fail(EXIT_INVALID_ARGS, &t!("main.missingOperation")); }; + if operation == "acl" { + return handle_acl(&args[1..]); + } + let input = match parse_input_arg(&args[1..]) { Ok(input) => input, Err(code) => return code, @@ -49,6 +55,42 @@ fn main() -> ExitCode { } } +fn handle_acl(args: &[String]) -> ExitCode { + let Some(operation) = args.first() else { + return fail(EXIT_INVALID_ARGS, &t!("acl.missingOperation")); + }; + if !matches!(operation.as_str(), "get" | "set" | "test") { + return fail( + EXIT_INVALID_ARGS, + &t!("acl.unknownOperation", operation = operation), + ); + } + let input = match parse_input_arg(&args[1..]) { + Ok(input) => input, + Err(code) => return code, + }; + let input = match require_acl_input(input) { + Ok(input) => input, + Err(code) => return code, + }; + + #[cfg(windows)] + { + match operation.as_str() { + "get" => handle_result(acl::get(&input)), + "set" => handle_result(acl::set(&input)), + "test" => handle_result(acl::test(&input)), + _ => unreachable!(), + } + } + + #[cfg(not(windows))] + { + let _ = (operation, input); + fail(EXIT_RESOURCE_ERROR, &t!("acl.windowsOnly")) + } +} + fn parse_input_arg(args: &[String]) -> Result, ExitCode> { let mut index = 0; while index < args.len() { @@ -76,6 +118,19 @@ fn require_input(input: Option<&str>) -> Result { }) } +fn require_acl_input(input: Option<&str>) -> Result { + let Some(input) = input else { + return Err(fail(EXIT_INVALID_ARGS, &t!("main.missingInput"))); + }; + + serde_json::from_str(input).map_err(|error| { + fail( + EXIT_INVALID_INPUT, + &t!("main.invalidJson", error = error.to_string()), + ) + }) +} + fn handle_result(result: Result) -> ExitCode { let value = match result { Ok(value) => value, diff --git a/resources/filecontent/src/types.rs b/resources/file_dsc/src/types.rs similarity index 71% rename from resources/filecontent/src/types.rs rename to resources/file_dsc/src/types.rs index a6d387a7e..15dc604d7 100644 --- a/resources/filecontent/src/types.rs +++ b/resources/file_dsc/src/types.rs @@ -18,3 +18,13 @@ pub struct FileContent { #[serde(rename = "_inDesiredState", skip_serializing_if = "Option::is_none")] pub in_desired_state: Option, } + +#[derive(Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct FilesystemAcl { + pub path: String, + #[serde(rename = "SDDL")] + pub sddl: String, + #[serde(rename = "_inDesiredState", skip_serializing_if = "Option::is_none")] + pub in_desired_state: Option, +} diff --git a/resources/filecontent/tests/filecontent_export.tests.ps1 b/resources/file_dsc/tests/filecontent_export.tests.ps1 similarity index 100% rename from resources/filecontent/tests/filecontent_export.tests.ps1 rename to resources/file_dsc/tests/filecontent_export.tests.ps1 diff --git a/resources/filecontent/tests/filecontent_get.tests.ps1 b/resources/file_dsc/tests/filecontent_get.tests.ps1 similarity index 93% rename from resources/filecontent/tests/filecontent_get.tests.ps1 rename to resources/file_dsc/tests/filecontent_get.tests.ps1 index 7e185e08d..4b6b00828 100644 --- a/resources/filecontent/tests/filecontent_get.tests.ps1 +++ b/resources/file_dsc/tests/filecontent_get.tests.ps1 @@ -39,14 +39,14 @@ Describe 'FileContent get tests' { It 'Accepts the short input argument' { $json = @{ path = $filePath } | ConvertTo-Json -Compress - $actual = filecontent get -i $json 2>$TestDrive/error.log | ConvertFrom-Json + $actual = file_dsc get -i $json 2>$TestDrive/error.log | ConvertFrom-Json $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $TestDrive/error.log) $actual._exist | Should -BeTrue } It 'Reports a missing input value' { - $null = filecontent get --input 2>$TestDrive/error.log + $null = file_dsc get --input 2>$TestDrive/error.log $LASTEXITCODE | Should -Be 1 (Get-Content -Raw $TestDrive/error.log) | Should -Match 'Missing value for --input argument' diff --git a/resources/filecontent/tests/filecontent_set.tests.ps1 b/resources/file_dsc/tests/filecontent_set.tests.ps1 similarity index 100% rename from resources/filecontent/tests/filecontent_set.tests.ps1 rename to resources/file_dsc/tests/filecontent_set.tests.ps1 diff --git a/resources/filecontent/tests/filecontent_test.tests.ps1 b/resources/file_dsc/tests/filecontent_test.tests.ps1 similarity index 100% rename from resources/filecontent/tests/filecontent_test.tests.ps1 rename to resources/file_dsc/tests/filecontent_test.tests.ps1 diff --git a/resources/file_dsc/tests/filesystem_acl.tests.ps1 b/resources/file_dsc/tests/filesystem_acl.tests.ps1 new file mode 100644 index 000000000..8b2e03a71 --- /dev/null +++ b/resources/file_dsc/tests/filesystem_acl.tests.ps1 @@ -0,0 +1,130 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +Describe 'Microsoft.Filesystem/ACL tests' -Skip:(!$IsWindows) { + BeforeAll { + $resourceType = 'Microsoft.Filesystem/ACL' + $currentSid = [Security.Principal.WindowsIdentity]::GetCurrent().User.Value + $desiredSddl = "D:(D;;FR;;;AN)(A;;FA;;;$currentSid)" + + function ConvertTo-CanonicalSddl { + param( + [Parameter(Mandatory)] + [string]$Sddl + ) + + return [Security.AccessControl.RawSecurityDescriptor]::new($Sddl).GetSddlForm('Access') + } + } + + BeforeEach { + $testRoot = Join-Path $TestDrive "$([System.Guid]::NewGuid())" + $null = New-Item -ItemType Directory -Path $testRoot + } + + AfterEach { + Remove-Item -LiteralPath $testRoot -Recurse -Force -ErrorAction Ignore + } + + It 'Sets and gets Allow and Deny ACEs on a ' -ForEach @( + @{ itemType = 'file' } + @{ itemType = 'folder' } + ) { + $path = if ($itemType -eq 'file') { + New-Item -ItemType File -Path (Join-Path $testRoot 'item.txt') | + Select-Object -ExpandProperty FullName + } else { + New-Item -ItemType Directory -Path (Join-Path $testRoot 'folder') | + Select-Object -ExpandProperty FullName + } + $json = @{ path = $path; SDDL = $desiredSddl } | ConvertTo-Json -Compress + + $setOutput = $json | dsc resource set -r $resourceType -f - 2>$TestDrive/error.log + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $TestDrive/error.log) + $setState = ($setOutput | ConvertFrom-Json).afterState + + $getOutput = $json | dsc resource get -r $resourceType -f - 2>$TestDrive/error.log + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $TestDrive/error.log) + $getState = ($getOutput | ConvertFrom-Json).actualState + + (ConvertTo-CanonicalSddl -Sddl $setState.SDDL) | + Should -BeExactly (ConvertTo-CanonicalSddl -Sddl $desiredSddl) + (ConvertTo-CanonicalSddl -Sddl $getState.SDDL) | + Should -BeExactly (ConvertTo-CanonicalSddl -Sddl $desiredSddl) + $getState.SDDL | Should -Match '\(D;' + $getState.SDDL | Should -Match '\(A;' + } + + It 'Tests whether Allow and Deny ACEs are in the desired state' { + $path = New-Item -ItemType File -Path (Join-Path $testRoot 'test.txt') | + Select-Object -ExpandProperty FullName + $json = @{ path = $path; SDDL = $desiredSddl } | ConvertTo-Json -Compress + $null = $json | dsc resource set -r $resourceType -f - 2>$TestDrive/error.log + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $TestDrive/error.log) + + $testOutput = $json | dsc resource test -r $resourceType -f - 2>$TestDrive/error.log + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $TestDrive/error.log) + + ($testOutput | ConvertFrom-Json).inDesiredState | Should -BeTrue + } + + It 'Reports a mismatched valid DACL as not in the desired state' { + $path = New-Item -ItemType File -Path (Join-Path $testRoot 'mismatch.txt') | + Select-Object -ExpandProperty FullName + $json = @{ path = $path; SDDL = $desiredSddl } | ConvertTo-Json -Compress + $null = $json | dsc resource set -r $resourceType -f - 2>$TestDrive/error.log + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $TestDrive/error.log) + + $mismatchedJson = @{ + path = $path + SDDL = "D:(D;;FW;;;AN)(A;;FA;;;$currentSid)" + } | ConvertTo-Json -Compress + $testOutput = $mismatchedJson | + dsc resource test -r $resourceType -f - 2>$TestDrive/error.log + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $TestDrive/error.log) + + ($testOutput | ConvertFrom-Json).inDesiredState | Should -BeFalse + } + + It 'Applies and tests the DACL protection state' { + $path = New-Item -ItemType File -Path (Join-Path $testRoot 'protected.txt') | + Select-Object -ExpandProperty FullName + $protectedSddl = $desiredSddl.Replace('D:', 'D:P') + $json = @{ path = $path; SDDL = $protectedSddl } | ConvertTo-Json -Compress + + $setOutput = $json | dsc resource set -r $resourceType -f - 2>$TestDrive/error.log + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $TestDrive/error.log) + $setState = ($setOutput | ConvertFrom-Json).afterState + + $setState.SDDL | Should -Match '^D:P' + $testOutput = $json | dsc resource test -r $resourceType -f - 2>$TestDrive/error.log + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $TestDrive/error.log) + ($testOutput | ConvertFrom-Json).inDesiredState | Should -BeTrue + } + + It 'Rejects SDDL components that the resource does not manage' { + $path = New-Item -ItemType File -Path (Join-Path $testRoot 'invalid.txt') | + Select-Object -ExpandProperty FullName + $json = @{ path = $path; SDDL = "O:BAD:(A;;FA;;;$currentSid)" } | + ConvertTo-Json -Compress + + $null = $json | dsc resource set -r $resourceType -f - 2>$TestDrive/error.log + + $LASTEXITCODE | Should -Not -Be 0 + (Get-Content -Raw $TestDrive/error.log) | + Should -Match 'must contain only a discretionary ACL' + } + + It 'Reports path inspection errors without claiming the path is absent' { + $json = @{ + path = "invalid$([char]0)path" + SDDL = $desiredSddl + } | ConvertTo-Json -Compress + + $null = $json | dsc resource get -r $resourceType -f - 2>$TestDrive/error.log + + $LASTEXITCODE | Should -Not -Be 0 + (Get-Content -Raw $TestDrive/error.log) | Should -Match 'Failed to inspect the path' + (Get-Content -Raw $TestDrive/error.log) | Should -Not -Match 'does not exist' + } +} diff --git a/resources/filecontent/filecontent.dsc.resource.json b/resources/filecontent/filecontent.dsc.resource.json deleted file mode 100644 index 514a256fe..000000000 --- a/resources/filecontent/filecontent.dsc.resource.json +++ /dev/null @@ -1,99 +0,0 @@ -{ - "$schema": "https://aka.ms/dsc/schemas/v3/bundled/resource/manifest.json", - "type": "Microsoft.Filesystem.File/Content", - "description": "Manage the UTF-8 content of a local file.", - "tags": [ - "file", - "linux", - "macos", - "windows" - ], - "version": "0.1.0", - "get": { - "executable": "filecontent", - "args": [ - "get", - { - "jsonInputArg": "--input", - "mandatory": true - } - ] - }, - "set": { - "executable": "filecontent", - "args": [ - "set", - { - "jsonInputArg": "--input", - "mandatory": true - } - ], - "implementsPretest": false, - "return": "state" - }, - "test": { - "executable": "filecontent", - "args": [ - "test", - { - "jsonInputArg": "--input", - "mandatory": true - } - ], - "return": "state" - }, - "exitCodes": { - "0": "Success", - "1": "Invalid arguments", - "2": "Invalid input", - "3": "File operation error" - }, - "schema": { - "embedded": { - "$schema": "http://json-schema.org/draft-07/schema#", - "title": "File Content", - "description": "Manage the UTF-8 content of a local file.", - "type": "object", - "required": [ - "path" - ], - "properties": { - "content": { - "type": "string", - "title": "Content", - "description": "The UTF-8 content of the file." - }, - "sha256": { - "type": "string", - "title": "SHA-256", - "description": "The SHA-256 hash of the file content.", - "pattern": "^[A-Fa-f0-9]{64}$" - }, - "sha512": { - "type": "string", - "title": "SHA-512", - "description": "The SHA-512 hash of the file content.", - "pattern": "^[A-Fa-f0-9]{128}$" - }, - "path": { - "type": "string", - "title": "Path", - "description": "The path to the local file.", - "minLength": 1 - }, - "_exist": { - "type": "boolean", - "title": "Exists", - "description": "Indicates whether the file should exist. Set to true or omit to create or update the file and any missing parent directories. Set to false to remove the file." - }, - "_inDesiredState": { - "type": "boolean", - "title": "In desired state", - "description": "Indicates whether the file is in the desired state. Only returned by test.", - "readOnly": true - } - }, - "additionalProperties": false - } - } -} diff --git a/tests/helpers.build.pester.tests.ps1 b/tests/helpers.build.pester.tests.ps1 index 42a7f5728..6dc26e71f 100644 --- a/tests/helpers.build.pester.tests.ps1 +++ b/tests/helpers.build.pester.tests.ps1 @@ -6,7 +6,7 @@ Describe 'Test-ProjectWithPester' { Import-Module (Join-Path $PSScriptRoot '..' 'helpers.build.psm1') -Force $buildData = Import-DscBuildData -RefreshProjects $dscProject = $buildData.Projects | Where-Object -Property Name -EQ 'dsc' - $fileContentProject = $buildData.Projects | Where-Object -Property Name -EQ 'filecontent' + $fileContentProject = $buildData.Projects | Where-Object -Property Name -EQ 'file_dsc' } BeforeEach { @@ -24,7 +24,7 @@ Describe 'Test-ProjectWithPester' { Should -Invoke Invoke-Pester -ModuleName helpers.build -Exactly 1 -ParameterFilter { $Path.Count -eq 2 -and $Path -contains 'dsc' -and - $Path -contains 'resources/filecontent' + $Path -contains 'resources/file_dsc' } } @@ -43,7 +43,7 @@ Describe 'Test-ProjectWithPester' { Test-ProjectWithPester -Project @($dscProject, $fileContentProject) -Group 'resources' Should -Invoke Invoke-Pester -ModuleName helpers.build -Exactly 1 -ParameterFilter { - $Path.Count -eq 1 -and $Path -contains 'resources/filecontent' + $Path.Count -eq 1 -and $Path -contains 'resources/file_dsc' } }