diff --git a/Cargo.lock b/Cargo.lock index 2056c7034..0b02139b9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1111,6 +1111,16 @@ version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" +[[package]] +name = "filecontent" +version = "0.1.0" +dependencies = [ + "rust-i18n", + "serde", + "serde_json", + "sha2", +] + [[package]] name = "find-msvc-tools" version = "0.1.9" diff --git a/Cargo.toml b/Cargo.toml index 65eccb184..077c6aeb3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,6 +17,7 @@ members = [ "lib/dsc-lib", "resources/dism_dsc", "resources/dscecho", + "resources/filecontent", "resources/osinfo", "resources/process", "resources/registry", @@ -49,6 +50,7 @@ default-members = [ "lib/dsc-lib", "resources/dism_dsc", "resources/dscecho", + "resources/filecontent", "resources/osinfo", "resources/process", "resources/registry", @@ -82,6 +84,7 @@ Windows = [ "lib/dsc-lib", "resources/dism_dsc", "resources/dscecho", + "resources/filecontent", "resources/osinfo", "resources/process", "resources/registry", @@ -107,6 +110,7 @@ macOS = [ "lib/dsc-lib-telemetry", "lib/dsc-lib", "resources/dscecho", + "resources/filecontent", "resources/osinfo", "resources/process", "resources/runcommandonset", @@ -128,6 +132,7 @@ Linux = [ "lib/dsc-lib-telemetry", "lib/dsc-lib", "resources/dscecho", + "resources/filecontent", "resources/osinfo", "resources/process", "resources/runcommandonset", @@ -223,6 +228,8 @@ 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 +sha2 = { version = "0.10.9" } # dsc, dsc-lib, y2j serde_yaml = { version = "0.9" } # dsc-lib-jsonschema-macros diff --git a/data.build.json b/data.build.json index 9a6d58d03..f4639284b 100644 --- a/data.build.json +++ b/data.build.json @@ -11,6 +11,8 @@ "dsc-bicep-ext", "dscecho", "echo.dsc.resource.json", + "filecontent", + "filecontent.dsc.resource.json", "assertion.dsc.resource.json", "apt.dsc.resource.json", "apt.dsc.resource.sh", @@ -41,6 +43,8 @@ "dsc-bicep-ext", "dscecho", "echo.dsc.resource.json", + "filecontent", + "filecontent.dsc.resource.json", "assertion.dsc.resource.json", "brew.dsc.resource.json", "brew.dsc.resource.sh", @@ -74,6 +78,8 @@ "dsc-bicep-ext.exe", "dscecho.exe", "echo.dsc.resource.json", + "filecontent.exe", + "filecontent.dsc.resource.json", "assertion.dsc.resource.json", "featureondemand.dsc.resource.json", "group.dsc.resource.json", @@ -335,6 +341,20 @@ ] } }, + { + "Name": "filecontent", + "Kind": "Resource", + "RelativePath": "resources/filecontent", + "IsRust": true, + "Binaries": [ + "filecontent" + ], + "CopyFiles": { + "All": [ + "filecontent.dsc.resource.json" + ] + } + }, { "Name": "osinfo", "Kind": "Resource", diff --git a/dsc/tests/dsc_resource_list.tests.ps1 b/dsc/tests/dsc_resource_list.tests.ps1 index e3ebb912f..577ea016b 100644 --- a/dsc/tests/dsc_resource_list.tests.ps1 +++ b/dsc/tests/dsc_resource_list.tests.ps1 @@ -25,10 +25,10 @@ Describe 'Tests for listing resources' { It 'dsc resource list --tags "" and --description " work' -TestCases @( if ($IsLinux) { - @{ tags = 'linux'; description = $null; expectedCount = 2; expectedType = @('DSC.PackageManagement/Apt', 'Microsoft/OSInfo') } + @{ tags = 'linux'; description = $null; expectedCount = 3; expectedType = @('DSC.PackageManagement/Apt', 'Microsoft/FileContent', 'Microsoft/OSInfo') } } else { - @{ tags = 'linux'; description = $null; expectedCount = 1; expectedType = 'Microsoft/OSInfo' } + @{ tags = 'linux'; description = $null; expectedCount = 2; expectedType = @('Microsoft/FileContent', 'Microsoft/OSInfo') } } @{ tags = $null; description = 'operating system'; expectedCount = 1; expectedType = 'Microsoft/OSInfo' } @{ tags = 'linux'; description = 'operating system'; expectedCount = 1; expectedType = 'Microsoft/OSInfo' } diff --git a/resources/filecontent/.project.data.json b/resources/filecontent/.project.data.json new file mode 100644 index 000000000..15b7f760d --- /dev/null +++ b/resources/filecontent/.project.data.json @@ -0,0 +1,13 @@ +{ + "Name": "filecontent", + "Kind": "Resource", + "IsRust": true, + "Binaries": [ + "filecontent" + ], + "CopyFiles": { + "All": [ + "filecontent.dsc.resource.json" + ] + } +} diff --git a/resources/filecontent/Cargo.toml b/resources/filecontent/Cargo.toml new file mode 100644 index 000000000..b37af55a8 --- /dev/null +++ b/resources/filecontent/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "filecontent" +version = "0.1.0" +edition = "2024" + +[[bin]] +name = "filecontent" +path = "src/main.rs" + +[package.metadata.i18n] +available-locales = ["en-us"] +default-locale = "en-us" +load-path = "locales" + +[dependencies] +rust-i18n = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +sha2 = { workspace = true } diff --git a/resources/filecontent/filecontent.dsc.resource.json b/resources/filecontent/filecontent.dsc.resource.json new file mode 100644 index 000000000..e1a8b3e4c --- /dev/null +++ b/resources/filecontent/filecontent.dsc.resource.json @@ -0,0 +1,111 @@ +{ + "$schema": "https://aka.ms/dsc/schemas/v3/bundled/resource/manifest.json", + "type": "Microsoft/FileContent", + "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" + }, + "export": { + "executable": "filecontent", + "args": [ + "export", + { + "jsonInputArg": "--input", + "mandatory": true + } + ], + "supportsFiltering": true + }, + "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.", + "writeOnly": true + }, + "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 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/resources/filecontent/locales/en-us.toml b/resources/filecontent/locales/en-us.toml new file mode 100644 index 000000000..824ff01c1 --- /dev/null +++ b/resources/filecontent/locales/en-us.toml @@ -0,0 +1,26 @@ +_version = 1 + +[main] +missingOperation = "Missing operation. Usage: filecontent --input " +unknownOperation = "Unknown operation: '%{operation}'. Expected: get, set, test, or export" +missingInput = "Missing --input argument" +missingInputValue = "Missing value for --input argument" +invalidJson = "Invalid JSON input: %{error}" +serializeError = "Failed to serialize output: %{error}" + +[input] +emptyPath = "The path must not be empty" +invalidHash = "%{name} must contain exactly %{length} hexadecimal characters" + +[get] +readError = "Failed to read file '%{path}': %{error}" + +[set] +contentRequired = "The content property is required when setting a file" +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" + +[export] +readError = "Failed to export file '%{path}' as UTF-8 content: %{error}" diff --git a/resources/filecontent/src/file.rs b/resources/filecontent/src/file.rs new file mode 100644 index 000000000..21705bf48 --- /dev/null +++ b/resources/filecontent/src/file.rs @@ -0,0 +1,234 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use crate::types::{ExportState, FileContent, FileState}; +use rust_i18n::t; +use sha2::{Digest, Sha256, Sha512}; +use std::fs::{self, File}; +use std::io::{BufReader, Read}; +use std::path::Path; + +pub fn get(input: &FileContent) -> Result { + validate_input(input)?; + read_state(&input.path) +} + +pub fn set(input: &FileContent) -> Result { + validate_input(input)?; + let path = Path::new(&input.path); + + if input.exist == Some(false) { + match fs::remove_file(path) { + Ok(()) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => { + return Err(t!( + "set.removeError", + path = input.path.as_str(), + error = error.to_string() + ) + .to_string()); + } + } + return read_state(&input.path); + } + + let Some(content) = input.content.as_deref() else { + return Err(t!("set.contentRequired").to_string()); + }; + validate_content_hashes(input, content)?; + + fs::write(path, content.as_bytes()).map_err(|error| { + t!( + "set.writeError", + path = input.path.as_str(), + error = error.to_string() + ) + .to_string() + })?; + + read_state(&input.path) +} + +pub fn test(input: &FileContent) -> Result { + validate_input(input)?; + let mut actual = read_state(&input.path)?; + + let in_desired_state = if input.exist == Some(false) { + !actual.exist + } else if !actual.exist { + false + } else { + desired_hashes(input) + .iter() + .all(|(algorithm, desired)| match *algorithm { + HashAlgorithm::Sha256 => actual + .sha256 + .as_deref() + .is_some_and(|value| value.eq_ignore_ascii_case(desired)), + HashAlgorithm::Sha512 => actual + .sha512 + .as_deref() + .is_some_and(|value| value.eq_ignore_ascii_case(desired)), + }) + }; + + actual.in_desired_state = Some(in_desired_state); + Ok(actual) +} + +pub fn export(input: &FileContent) -> Result { + validate_input(input)?; + let bytes = match fs::read(&input.path) { + Ok(bytes) => bytes, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + return Ok(ExportState { + path: input.path.clone(), + content: None, + sha256: None, + sha512: None, + exist: false, + }); + } + Err(error) => { + return Err(t!( + "export.readError", + path = input.path.as_str(), + error = error.to_string() + ) + .to_string()); + } + }; + let content = String::from_utf8(bytes).map_err(|error| { + t!( + "export.readError", + path = input.path.as_str(), + error = error.to_string() + ) + .to_string() + })?; + let (sha256, sha512) = hash_bytes(content.as_bytes()); + + Ok(ExportState { + path: input.path.clone(), + content: Some(content), + sha256: Some(sha256), + sha512: Some(sha512), + exist: true, + }) +} + +#[derive(Clone, Copy)] +enum HashAlgorithm { + Sha256, + Sha512, +} + +fn desired_hashes(input: &FileContent) -> Vec<(HashAlgorithm, String)> { + let mut hashes = Vec::with_capacity(2); + if let Some(content) = input.content.as_deref() { + let (sha256, _) = hash_bytes(content.as_bytes()); + hashes.push((HashAlgorithm::Sha256, sha256)); + } + if let Some(sha256) = input.sha256.as_ref() { + hashes.push((HashAlgorithm::Sha256, sha256.clone())); + } + if let Some(sha512) = input.sha512.as_ref() { + hashes.push((HashAlgorithm::Sha512, sha512.clone())); + } + hashes +} + +fn validate_content_hashes(input: &FileContent, content: &str) -> Result<(), String> { + let (sha256, sha512) = hash_bytes(content.as_bytes()); + if input + .sha256 + .as_deref() + .is_some_and(|desired| !sha256.eq_ignore_ascii_case(desired)) + { + return Err(t!("set.sha256Mismatch").to_string()); + } + if input + .sha512 + .as_deref() + .is_some_and(|desired| !sha512.eq_ignore_ascii_case(desired)) + { + return Err(t!("set.sha512Mismatch").to_string()); + } + Ok(()) +} + +fn validate_input(input: &FileContent) -> Result<(), String> { + if input.path.is_empty() { + return Err(t!("input.emptyPath").to_string()); + } + validate_hash(input.sha256.as_deref(), 64, "sha256")?; + validate_hash(input.sha512.as_deref(), 128, "sha512") +} + +fn validate_hash(value: Option<&str>, length: usize, name: &str) -> Result<(), String> { + if let Some(value) = value + && (value.len() != length || !value.bytes().all(|byte| byte.is_ascii_hexdigit())) + { + return Err(t!("input.invalidHash", name = name, length = length).to_string()); + } + Ok(()) +} + +fn read_state(path: &str) -> Result { + let file = match File::open(path) { + Ok(file) => file, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + return Ok(FileState { + path: path.to_string(), + sha256: None, + sha512: None, + exist: false, + in_desired_state: None, + }); + } + Err(error) => { + return Err(t!("get.readError", path = path, error = error.to_string()).to_string()); + } + }; + + let (sha256, sha512) = hash_reader(file) + .map_err(|error| t!("get.readError", path = path, error = error.to_string()).to_string())?; + Ok(FileState { + path: path.to_string(), + sha256: Some(sha256), + sha512: Some(sha512), + exist: true, + in_desired_state: None, + }) +} + +fn hash_reader(file: File) -> std::io::Result<(String, String)> { + let mut reader = BufReader::new(file); + let mut sha256 = Sha256::new(); + let mut sha512 = Sha512::new(); + let mut buffer = vec![0_u8; 64 * 1024]; + loop { + let count = reader.read(&mut buffer)?; + if count == 0 { + break; + } + sha256.update(&buffer[..count]); + sha512.update(&buffer[..count]); + } + Ok(( + format!("{:x}", sha256.finalize()), + format!("{:x}", sha512.finalize()), + )) +} + +fn hash_bytes(bytes: &[u8]) -> (String, String) { + let mut sha256 = Sha256::new(); + let mut sha512 = Sha512::new(); + sha256.update(bytes); + sha512.update(bytes); + ( + format!("{:x}", sha256.finalize()), + format!("{:x}", sha512.finalize()), + ) +} diff --git a/resources/filecontent/src/main.rs b/resources/filecontent/src/main.rs new file mode 100644 index 000000000..e295a3f96 --- /dev/null +++ b/resources/filecontent/src/main.rs @@ -0,0 +1,89 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +mod file; +mod types; + +use crate::file::{export, get, set, test}; +use crate::types::FileContent; +use rust_i18n::t; +use serde::Serialize; +use serde_json::json; +use std::env; +use std::process::exit; + +rust_i18n::i18n!("locales", fallback = "en-us"); + +const EXIT_SUCCESS: i32 = 0; +const EXIT_INVALID_ARGS: i32 = 1; +const EXIT_INVALID_INPUT: i32 = 2; +const EXIT_RESOURCE_ERROR: i32 = 3; + +fn main() { + let args: Vec = env::args().skip(1).collect(); + let Some(operation) = args.first() else { + fail(EXIT_INVALID_ARGS, &t!("main.missingOperation")); + }; + + let input = parse_input_arg(&args[1..]); + match operation.as_str() { + "get" => handle_result(get(&require_input(input))), + "set" => handle_result(set(&require_input(input))), + "test" => handle_result(test(&require_input(input))), + "export" => handle_result(export(&require_input(input))), + _ => fail( + EXIT_INVALID_ARGS, + &t!("main.unknownOperation", operation = operation), + ), + } +} + +fn parse_input_arg(args: &[String]) -> Option<&str> { + let mut index = 0; + while index < args.len() { + if args[index] == "--input" || args[index] == "-i" { + let Some(input) = args.get(index + 1) else { + fail(EXIT_INVALID_ARGS, &t!("main.missingInputValue")); + }; + return Some(input); + } + index += 1; + } + None +} + +fn require_input(input: Option<&str>) -> FileContent { + let Some(input) = input else { + fail(EXIT_INVALID_ARGS, &t!("main.missingInput")); + }; + + serde_json::from_str(input).unwrap_or_else(|error| { + fail( + EXIT_INVALID_INPUT, + &t!("main.invalidJson", error = error.to_string()), + ) + }) +} + +fn handle_result(result: Result) -> ! { + let value = match result { + Ok(value) => value, + Err(error) => fail(EXIT_RESOURCE_ERROR, &error), + }; + + match serde_json::to_string(&value) { + Ok(json) => { + println!("{json}"); + exit(EXIT_SUCCESS); + } + Err(error) => fail( + EXIT_RESOURCE_ERROR, + &t!("main.serializeError", error = error.to_string()), + ), + } +} + +fn fail(exit_code: i32, message: &str) -> ! { + eprintln!("{}", json!({ "error": message })); + exit(exit_code); +} diff --git a/resources/filecontent/src/types.rs b/resources/filecontent/src/types.rs new file mode 100644 index 000000000..24744523c --- /dev/null +++ b/resources/filecontent/src/types.rs @@ -0,0 +1,43 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct FileContent { + pub path: String, + pub content: Option, + pub sha256: Option, + pub sha512: Option, + #[serde(rename = "_exist")] + pub exist: Option, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct FileState { + pub path: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub sha256: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub sha512: Option, + #[serde(rename = "_exist")] + pub exist: bool, + #[serde(rename = "_inDesiredState", skip_serializing_if = "Option::is_none")] + pub in_desired_state: Option, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ExportState { + pub path: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub content: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub sha256: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub sha512: Option, + #[serde(rename = "_exist")] + pub exist: bool, +} diff --git a/resources/filecontent/tests/filecontent_export.tests.ps1 b/resources/filecontent/tests/filecontent_export.tests.ps1 new file mode 100644 index 000000000..453d7dcf5 --- /dev/null +++ b/resources/filecontent/tests/filecontent_export.tests.ps1 @@ -0,0 +1,27 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +Describe 'FileContent export tests' { + BeforeAll { + $resourceType = 'Microsoft/FileContent' + $filePath = Join-Path $TestDrive 'export.txt' + [System.IO.File]::WriteAllText( + $filePath, + "hello`nworld", + [System.Text.UTF8Encoding]::new($false) + ) + } + + It 'Returns content and hashes' { + $json = @{ path = $filePath } | ConvertTo-Json -Compress + $out = $json | dsc resource export -r $resourceType -f - 2>$TestDrive/error.log + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $TestDrive/error.log) + $properties = ($out | ConvertFrom-Json).resources[0].properties + + $properties.path | Should -BeExactly $filePath + $properties.content | Should -BeExactly "hello`nworld" + $properties.sha256 | Should -Not -BeNullOrEmpty + $properties.sha512 | Should -Not -BeNullOrEmpty + $properties._exist | Should -BeTrue + } +} diff --git a/resources/filecontent/tests/filecontent_get.tests.ps1 b/resources/filecontent/tests/filecontent_get.tests.ps1 new file mode 100644 index 000000000..3e327a2f1 --- /dev/null +++ b/resources/filecontent/tests/filecontent_get.tests.ps1 @@ -0,0 +1,54 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +Describe 'FileContent get tests' { + BeforeAll { + $resourceType = 'Microsoft/FileContent' + $filePath = Join-Path $TestDrive 'get.txt' + [System.IO.File]::WriteAllText( + $filePath, + 'hello', + [System.Text.UTF8Encoding]::new($false) + ) + } + + It 'Returns hashes without content for an existing file' { + $json = @{ path = $filePath } | ConvertTo-Json -Compress + $out = $json | dsc resource get -r $resourceType -f - 2>$TestDrive/error.log + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $TestDrive/error.log) + $actual = ($out | ConvertFrom-Json).actualState + + $actual.path | Should -BeExactly $filePath + $actual._exist | Should -BeTrue + $actual.sha256 | Should -BeExactly '2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824' + $actual.sha512 | Should -BeExactly '9b71d224bd62f3785d96d46ad3ea3d73319bfbc2890caadae2dff72519673ca72323c3d99ba5c11d7c7acc6e14b8c5da0c4663475c2e5c3adef46f73bcdec043' + $actual.PSObject.Properties.Name | Should -Not -Contain 'content' + } + + It 'Returns nonexistence without hashes for a missing file' { + $missingPath = Join-Path $TestDrive 'missing.txt' + $json = @{ path = $missingPath } | ConvertTo-Json -Compress + $out = $json | dsc resource get -r $resourceType -f - 2>$TestDrive/error.log + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $TestDrive/error.log) + $actual = ($out | ConvertFrom-Json).actualState + + $actual._exist | Should -BeFalse + $actual.PSObject.Properties.Name | Should -Not -Contain 'sha256' + $actual.PSObject.Properties.Name | Should -Not -Contain 'sha512' + } + + It 'Accepts the short input argument' { + $json = @{ path = $filePath } | ConvertTo-Json -Compress + $actual = filecontent 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 + + $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/filecontent/tests/filecontent_set.tests.ps1 new file mode 100644 index 000000000..759bb17b2 --- /dev/null +++ b/resources/filecontent/tests/filecontent_set.tests.ps1 @@ -0,0 +1,39 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +Describe 'FileContent set tests' { + BeforeAll { + $resourceType = 'Microsoft/FileContent' + } + + BeforeEach { + $filePath = Join-Path $TestDrive "$([System.Guid]::NewGuid()).txt" + } + + AfterEach { + Remove-Item -LiteralPath $filePath -Force -ErrorAction Ignore + } + + It 'Creates a UTF-8 file and returns its hashes' { + $json = @{ path = $filePath; content = 'hello' } | ConvertTo-Json -Compress + $out = $json | dsc resource set -r $resourceType -f - 2>$TestDrive/error.log + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $TestDrive/error.log) + $actual = ($out | ConvertFrom-Json).afterState + + [System.IO.File]::ReadAllText($filePath) | Should -BeExactly 'hello' + $actual._exist | Should -BeTrue + $actual.sha256 | Should -BeExactly '2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824' + $actual.PSObject.Properties.Name | Should -Not -Contain 'content' + } + + It 'Removes a file when _exist is false' { + [System.IO.File]::WriteAllText($filePath, 'remove me') + $json = @{ path = $filePath; _exist = $false } | ConvertTo-Json -Compress + $out = $json | dsc resource set -r $resourceType -f - 2>$TestDrive/error.log + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $TestDrive/error.log) + $actual = ($out | ConvertFrom-Json).afterState + + $filePath | Should -Not -Exist + $actual._exist | Should -BeFalse + } +} diff --git a/resources/filecontent/tests/filecontent_test.tests.ps1 b/resources/filecontent/tests/filecontent_test.tests.ps1 new file mode 100644 index 000000000..2a88e2b4f --- /dev/null +++ b/resources/filecontent/tests/filecontent_test.tests.ps1 @@ -0,0 +1,51 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +Describe 'FileContent test tests' { + BeforeAll { + $resourceType = 'Microsoft/FileContent' + $filePath = Join-Path $TestDrive 'test.txt' + [System.IO.File]::WriteAllText( + $filePath, + 'hello', + [System.Text.UTF8Encoding]::new($false) + ) + } + + It 'Compares content to the file by hash when content is the only desired value' { + $json = @{ path = $filePath; content = 'hello' } | ConvertTo-Json -Compress + $out = $json | dsc resource test -r $resourceType -f - 2>$TestDrive/error.log + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $TestDrive/error.log) + + ($out | ConvertFrom-Json).inDesiredState | Should -BeTrue + } + + It 'Detects different content when content is the only desired value' { + $json = @{ path = $filePath; content = 'different' } | ConvertTo-Json -Compress + $out = $json | dsc resource test -r $resourceType -f - 2>$TestDrive/error.log + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $TestDrive/error.log) + + ($out | ConvertFrom-Json).inDesiredState | Should -BeFalse + } + + It 'Compares supplied SHA-256 and SHA-512 hashes' { + $json = @{ + path = $filePath + sha256 = '2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824' + sha512 = '9b71d224bd62f3785d96d46ad3ea3d73319bfbc2890caadae2dff72519673ca72323c3d99ba5c11d7c7acc6e14b8c5da0c4663475c2e5c3adef46f73bcdec043' + } | ConvertTo-Json -Compress + $out = $json | dsc resource test -r $resourceType -f - 2>$TestDrive/error.log + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $TestDrive/error.log) + + ($out | ConvertFrom-Json).inDesiredState | Should -BeTrue + } + + It 'Reports desired state when an absent file should not exist' { + $missingPath = Join-Path $TestDrive 'absent.txt' + $json = @{ path = $missingPath; _exist = $false } | ConvertTo-Json -Compress + $out = $json | dsc resource test -r $resourceType -f - 2>$TestDrive/error.log + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $TestDrive/error.log) + + ($out | ConvertFrom-Json).inDesiredState | Should -BeTrue + } +}