diff --git a/resources/filecontent/filecontent.dsc.resource.json b/resources/filecontent/filecontent.dsc.resource.json index 12ba48307..514a256fe 100644 --- a/resources/filecontent/filecontent.dsc.resource.json +++ b/resources/filecontent/filecontent.dsc.resource.json @@ -84,7 +84,7 @@ "_exist": { "type": "boolean", "title": "Exists", - "description": "Indicates whether the file should exist. Set to false to remove the file." + "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", diff --git a/resources/filecontent/locales/en-us.toml b/resources/filecontent/locales/en-us.toml index c2a3834ae..ce267e0ca 100644 --- a/resources/filecontent/locales/en-us.toml +++ b/resources/filecontent/locales/en-us.toml @@ -17,6 +17,8 @@ readError = "Failed to read file '%{path}': %{error}" [set] contentRequired = "The content property is required when setting a file" +creatingParentDirectory = "Creating parent directory '%{path}'" +createParentDirectoryError = "Failed to create parent directory '%{path}': %{error}" writeError = "Failed to write file '%{path}': %{error}" removeError = "Failed to remove file '%{path}': %{error}" sha256Mismatch = "The sha256 value does not match the content property" diff --git a/resources/filecontent/src/file.rs b/resources/filecontent/src/file.rs index d6a8306b4..9072b1e2a 100644 --- a/resources/filecontent/src/file.rs +++ b/resources/filecontent/src/file.rs @@ -3,6 +3,7 @@ use crate::types::FileContent; use rust_i18n::t; +use serde_json::json; use sha2::{Digest, Sha256, Sha512}; use std::fs; use std::path::Path; @@ -37,6 +38,24 @@ pub fn set(input: &FileContent) -> Result { }; validate_content_hashes(input, content)?; + if let Some(parent) = path.parent() + && !parent.as_os_str().is_empty() + && !parent.is_dir() + { + eprintln!( + "{}", + json!({ "info": t!("set.creatingParentDirectory", path = parent.display().to_string()) }) + ); + fs::create_dir_all(parent).map_err(|error| { + t!( + "set.createParentDirectoryError", + path = parent.display().to_string(), + error = error.to_string() + ) + .to_string() + })?; + } + fs::write(path, content.as_bytes()).map_err(|error| { t!( "set.writeError", @@ -136,7 +155,12 @@ fn validate_hash(value: Option<&str>, length: usize, name: &str) -> Result<(), S fn read_state(path: &str) -> Result { let bytes = match fs::read(path) { Ok(bytes) => bytes, - Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + Err(error) + if matches!( + error.kind(), + std::io::ErrorKind::NotFound | std::io::ErrorKind::NotADirectory + ) => + { return Ok(FileContent { path: path.to_string(), content: None, diff --git a/resources/filecontent/tests/filecontent_set.tests.ps1 b/resources/filecontent/tests/filecontent_set.tests.ps1 index 4cd42dad4..59ced792a 100644 --- a/resources/filecontent/tests/filecontent_set.tests.ps1 +++ b/resources/filecontent/tests/filecontent_set.tests.ps1 @@ -7,11 +7,13 @@ Describe 'FileContent set tests' { } BeforeEach { - $filePath = Join-Path $TestDrive "$([System.Guid]::NewGuid()).txt" + $testRoot = Join-Path $TestDrive "$([System.Guid]::NewGuid())" + $null = New-Item -ItemType Directory -Path $testRoot + $filePath = Join-Path $testRoot 'file.txt' } AfterEach { - Remove-Item -LiteralPath $filePath -Force -ErrorAction Ignore + Remove-Item -LiteralPath $testRoot -Recurse -Force -ErrorAction Ignore } It 'Creates a UTF-8 file and returns its hashes' { @@ -26,6 +28,51 @@ Describe 'FileContent set tests' { $actual.sha256 | Should -BeExactly '2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824' } + It 'Creates missing parent directories and reports the creation when _exist is ' -ForEach @( + @{ existSetting = 'true'; includeExist = $true } + @{ existSetting = 'omitted'; includeExist = $false } + ) { + $existingDirectory = Join-Path $testRoot 'a' + $null = New-Item -ItemType Directory -Path $existingDirectory + $firstMissingDirectory = Join-Path $existingDirectory 'b' + $secondMissingDirectory = Join-Path $firstMissingDirectory 'c' + $nestedFilePath = Join-Path $secondMissingDirectory 'file.txt' + $stderrPath = Join-Path $testRoot 'stderr.log' + $inputState = @{ path = $nestedFilePath; content = 'nested' } + if ($includeExist) { + $inputState._exist = $true + } + $json = $inputState | ConvertTo-Json -Compress + + $out = $json | dsc -l info resource set -r $resourceType -f - 2>$stderrPath + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $stderrPath) + $actual = ($out | ConvertFrom-Json).afterState + + [System.IO.File]::ReadAllText($nestedFilePath) | Should -BeExactly 'nested' + $firstMissingDirectory | Should -Exist + $secondMissingDirectory | Should -Exist + $actual._exist | Should -BeTrue + (Get-Content -Raw $stderrPath) | + Should -BeLike "*INFO*Creating parent directory '$secondMissingDirectory'*" + } + + It 'Reports an error when an intermediate parent path is a file' { + $existingDirectory = Join-Path $testRoot 'a' + $null = New-Item -ItemType Directory -Path $existingDirectory + $blockingPath = Join-Path $existingDirectory 'b' + [System.IO.File]::WriteAllText($blockingPath, 'blocking file') + $blockedParent = Join-Path $blockingPath 'c' + $blockedFilePath = Join-Path $blockedParent 'file.txt' + $stderrPath = Join-Path $testRoot 'stderr.log' + $json = @{ path = $blockedFilePath; content = 'blocked' } | ConvertTo-Json -Compress + + $null = $json | dsc resource set -r $resourceType -f - 2>$stderrPath + + $LASTEXITCODE | Should -Not -Be 0 + (Get-Content -Raw $stderrPath) | + Should -BeLike "*Failed to create parent directory '$blockedParent'*" + } + It 'Removes a file when _exist is false' { [System.IO.File]::WriteAllText($filePath, 'remove me') $json = @{ path = $filePath; _exist = $false } | ConvertTo-Json -Compress