From 4d490e5c749ad21d2ec0a38a6420e9db21393dcd Mon Sep 17 00:00:00 2001 From: Nick Anderson Date: Thu, 16 Jul 2026 23:23:12 -0500 Subject: [PATCH 1/2] Added acceptance tests for the immutable-bit write error message (ENT-14297) Cover every path where cf-agent can fail to write a file because the immutable bit is set and is not being overridden, asserting that the error names the immutable bit: - content (14, fails at the in-place open) - edit_line (15) - copy_from (16) - mustache (17) fail at the temporary-file rename in - cfengine template (18) OverrideImmutableRename() - edit_xml (19) - inline_mustache (20) Each test drives a self-contained sub policy in its own agent process: the sub creates the target, sets the immutable bit via body fsattrs, and then attempts the modification. The test asserts on the captured output using native functions only (execresult_as_data + regcmp) and never fails on the sub's non-zero exit. Teardown clears the immutable bit natively. --- .../10_files/unsafe/14_immutable_content.cf | 73 +++++++++++++++++ .../unsafe/14_immutable_content.sub.cf | 30 +++++++ .../10_files/unsafe/15_immutable_edit_line.cf | 77 ++++++++++++++++++ .../unsafe/15_immutable_edit_line.sub.cf | 35 ++++++++ .../10_files/unsafe/16_immutable_copy_from.cf | 78 ++++++++++++++++++ .../unsafe/16_immutable_copy_from.sub.cf | 37 +++++++++ .../unsafe/17_immutable_template_mustache.cf | 79 ++++++++++++++++++ .../17_immutable_template_mustache.sub.cf | 33 ++++++++ .../unsafe/18_immutable_template_cfengine.cf | 80 +++++++++++++++++++ .../18_immutable_template_cfengine.sub.cf | 34 ++++++++ .../10_files/unsafe/19_immutable_edit_xml.cf | 77 ++++++++++++++++++ .../unsafe/19_immutable_edit_xml.sub.cf | 35 ++++++++ .../20_immutable_template_inline_mustache.cf | 78 ++++++++++++++++++ ..._immutable_template_inline_mustache.sub.cf | 31 +++++++ 14 files changed, 777 insertions(+) create mode 100644 tests/acceptance/10_files/unsafe/14_immutable_content.cf create mode 100644 tests/acceptance/10_files/unsafe/14_immutable_content.sub.cf create mode 100644 tests/acceptance/10_files/unsafe/15_immutable_edit_line.cf create mode 100644 tests/acceptance/10_files/unsafe/15_immutable_edit_line.sub.cf create mode 100644 tests/acceptance/10_files/unsafe/16_immutable_copy_from.cf create mode 100644 tests/acceptance/10_files/unsafe/16_immutable_copy_from.sub.cf create mode 100644 tests/acceptance/10_files/unsafe/17_immutable_template_mustache.cf create mode 100644 tests/acceptance/10_files/unsafe/17_immutable_template_mustache.sub.cf create mode 100644 tests/acceptance/10_files/unsafe/18_immutable_template_cfengine.cf create mode 100644 tests/acceptance/10_files/unsafe/18_immutable_template_cfengine.sub.cf create mode 100644 tests/acceptance/10_files/unsafe/19_immutable_edit_xml.cf create mode 100644 tests/acceptance/10_files/unsafe/19_immutable_edit_xml.sub.cf create mode 100644 tests/acceptance/10_files/unsafe/20_immutable_template_inline_mustache.cf create mode 100644 tests/acceptance/10_files/unsafe/20_immutable_template_inline_mustache.sub.cf diff --git a/tests/acceptance/10_files/unsafe/14_immutable_content.cf b/tests/acceptance/10_files/unsafe/14_immutable_content.cf new file mode 100644 index 0000000000..ffaab2545d --- /dev/null +++ b/tests/acceptance/10_files/unsafe/14_immutable_content.cf @@ -0,0 +1,73 @@ +############################################################################## +# +# Test that agent reports the immutable bit in the error message when it +# cannot write to an immutable file (content attribute, no override). +# +# The whole scenario (create the file, set it immutable, then fail to write it) +# runs in a self-contained sub policy: its setup happens in its own agent +# process, so it does not depend on this test's evaluation order, and its +# non-zero exit does not fail this test. We only assert on the captured error +# output. Teardown clears the immutable bit natively via body fsattrs. +# +############################################################################## +body common control +{ + inputs => { "../../default.sub.cf" }; + bundlesequence => { default("$(this.promise_filename)") }; + version => "1.0"; +} + +bundle agent global +{ + vars: + "testfile" string => "/tmp/14_immutable.txt"; +} + +body fsattrs clear_immutable +{ + immutable => "false"; +} + +bundle agent test +{ + meta: + "description" -> { "ENT-14297" } + string => "Test that the error message names the immutable bit when agent cannot write to an immutable file"; + + "test_skip_unsupported" string => "hpux|aix|solaris|windows"; +} + +bundle agent check +{ + vars: + "run" + data => execresult_as_data( + "$(sys.cf_agent) -KI -f $(with)", "noshell", "both" + ), + with => regex_replace("$(this.promise_filename)", "\.cf$", ".sub.cf", ""); + + classes: + "ok" + expression => regcmp( + ".*Cannot open file.*the immutable bit is set.*", "$(run[output])" + ); + + reports: + ok:: + "$(this.promise_filename) Pass"; + + !ok:: + "$(this.promise_filename) FAIL"; + + DEBUG:: + "$(this.bundle): agent exited '$(run[exit_code])', output was:"; + "$(run[output])"; +} + +bundle agent destroy +{ + files: + "$(global.testfile)" + delete => tidy, + fsattrs => clear_immutable; +} diff --git a/tests/acceptance/10_files/unsafe/14_immutable_content.sub.cf b/tests/acceptance/10_files/unsafe/14_immutable_content.sub.cf new file mode 100644 index 0000000000..a0e7393a2f --- /dev/null +++ b/tests/acceptance/10_files/unsafe/14_immutable_content.sub.cf @@ -0,0 +1,30 @@ +body common control +{ + bundlesequence => { "setup", "try_modify" }; +} + +body fsattrs set_immutable +{ + immutable => "true"; +} + +bundle agent setup +{ + files: + # Create the target and set the immutable bit natively. This runs in its + # own agent process (invoked from the test's check bundle), so setup is + # ordered before the write below and does not depend on the parent test. + "/tmp/14_immutable.txt" + content => "original content", + fsattrs => set_immutable; +} + +bundle agent try_modify +{ + files: + # No fsattrs => ... immutable, so the immutable bit is not overridden. + # The content differs from what setup wrote, so the agent tries to open the + # immutable file for writing and fails. + "/tmp/14_immutable.txt" + content => "the agent tries to modify an immutable file"; +} diff --git a/tests/acceptance/10_files/unsafe/15_immutable_edit_line.cf b/tests/acceptance/10_files/unsafe/15_immutable_edit_line.cf new file mode 100644 index 0000000000..45859a7fcd --- /dev/null +++ b/tests/acceptance/10_files/unsafe/15_immutable_edit_line.cf @@ -0,0 +1,77 @@ +############################################################################## +# +# Test that agent reports the immutable bit in the error message when it +# cannot write to an immutable file via edit_line (no override). +# +# edit_line writes a temporary file and renames it over the target, so the +# failure surfaces from OverrideImmutableRename(). The whole scenario (create +# the file, set it immutable, then fail to edit it) runs in a self-contained +# sub policy: its setup happens in its own agent process, so it does not depend +# on this test's evaluation order, and its non-zero exit does not fail this +# test. We only assert on the captured error output. Teardown clears the +# immutable bit natively via body fsattrs. +# +############################################################################## +body common control +{ + inputs => { "../../default.sub.cf" }; + bundlesequence => { default("$(this.promise_filename)") }; + version => "1.0"; +} + +bundle agent global +{ + vars: + "testfile" string => "/tmp/15_immutable.txt"; +} + +body fsattrs clear_immutable +{ + immutable => "false"; +} + +bundle agent test +{ + meta: + "description" -> { "ENT-14297" } + string => "Test that edit_line on an immutable file names the immutable bit in the error"; + + "test_skip_unsupported" string => "hpux|aix|solaris|windows"; +} + +bundle agent check +{ + vars: + "run" + data => execresult_as_data( + "$(sys.cf_agent) -KI -f $(with)", "noshell", "both" + ), + with => regex_replace("$(this.promise_filename)", "\.cf$", ".sub.cf", ""); + + classes: + "ok" + expression => regcmp( + ".*Failed to replace.*the immutable bit is set.*", "$(run[output])" + ); + + reports: + ok:: + "$(this.promise_filename) Pass"; + + !ok:: + "$(this.promise_filename) FAIL"; + + DEBUG:: + "$(this.bundle): agent exited '$(run[exit_code])', output was:"; + "$(run[output])"; +} + +bundle agent destroy +{ + files: + "$(global.testfile)" + delete => tidy, + fsattrs => clear_immutable; + + "$(global.testfile).cf-before-edit" delete => tidy; +} diff --git a/tests/acceptance/10_files/unsafe/15_immutable_edit_line.sub.cf b/tests/acceptance/10_files/unsafe/15_immutable_edit_line.sub.cf new file mode 100644 index 0000000000..8750c26fb7 --- /dev/null +++ b/tests/acceptance/10_files/unsafe/15_immutable_edit_line.sub.cf @@ -0,0 +1,35 @@ +body common control +{ + bundlesequence => { "setup", "try_modify" }; +} + +body fsattrs set_immutable +{ + immutable => "true"; +} + +bundle edit_line insert_a_line +{ + insert_lines: + "a line the agent tries to insert into an immutable file"; +} + +bundle agent setup +{ + files: + # Create the target and set the immutable bit natively. This runs in its + # own agent process (invoked from the test's check bundle), so setup is + # ordered before the edit below and does not depend on the parent test. + "/tmp/15_immutable.txt" + content => "original line", + fsattrs => set_immutable; +} + +bundle agent try_modify +{ + files: + # No fsattrs => ... immutable, so the immutable bit is not overridden. + # Inserting a new line changes the file, so the agent writes a temporary + # file and renames it over the immutable target, which fails. + "/tmp/15_immutable.txt" edit_line => insert_a_line; +} diff --git a/tests/acceptance/10_files/unsafe/16_immutable_copy_from.cf b/tests/acceptance/10_files/unsafe/16_immutable_copy_from.cf new file mode 100644 index 0000000000..0c8865bdec --- /dev/null +++ b/tests/acceptance/10_files/unsafe/16_immutable_copy_from.cf @@ -0,0 +1,78 @@ +############################################################################## +# +# Test that agent reports the immutable bit in the error message when it +# cannot write to an immutable file via copy_from (no override). +# +# copy_from writes a temporary file and renames it over the target, so the +# failure surfaces from OverrideImmutableRename(). The whole scenario (create +# source + immutable destination, then fail to copy) runs in a self-contained +# sub policy: its setup happens in its own agent process, so it does not depend +# on this test's evaluation order, and its non-zero exit does not fail this +# test. We only assert on the captured error output. Teardown clears the +# immutable bit natively via body fsattrs. +# +############################################################################## +body common control +{ + inputs => { "../../default.sub.cf" }; + bundlesequence => { default("$(this.promise_filename)") }; + version => "1.0"; +} + +bundle agent global +{ + vars: + "source" string => "/tmp/16_immutable.src"; + "testfile" string => "/tmp/16_immutable.txt"; +} + +body fsattrs clear_immutable +{ + immutable => "false"; +} + +bundle agent test +{ + meta: + "description" -> { "ENT-14297" } + string => "Test that copy_from onto an immutable file names the immutable bit in the error"; + + "test_skip_unsupported" string => "hpux|aix|solaris|windows"; +} + +bundle agent check +{ + vars: + "run" + data => execresult_as_data( + "$(sys.cf_agent) -KI -f $(with)", "noshell", "both" + ), + with => regex_replace("$(this.promise_filename)", "\.cf$", ".sub.cf", ""); + + classes: + "ok" + expression => regcmp( + ".*Failed to replace.*the immutable bit is set.*", "$(run[output])" + ); + + reports: + ok:: + "$(this.promise_filename) Pass"; + + !ok:: + "$(this.promise_filename) FAIL"; + + DEBUG:: + "$(this.bundle): agent exited '$(run[exit_code])', output was:"; + "$(run[output])"; +} + +bundle agent destroy +{ + files: + "$(global.testfile)" + delete => tidy, + fsattrs => clear_immutable; + + "$(global.source)" delete => tidy; +} diff --git a/tests/acceptance/10_files/unsafe/16_immutable_copy_from.sub.cf b/tests/acceptance/10_files/unsafe/16_immutable_copy_from.sub.cf new file mode 100644 index 0000000000..93ef40ed1b --- /dev/null +++ b/tests/acceptance/10_files/unsafe/16_immutable_copy_from.sub.cf @@ -0,0 +1,37 @@ +body common control +{ + bundlesequence => { "setup", "try_modify" }; +} + +body fsattrs set_immutable +{ + immutable => "true"; +} + +body copy_from cp_digest(from) +{ + source => "$(from)"; + compare => "digest"; +} + +bundle agent setup +{ + files: + # Source is a normal file; the destination is immutable. This runs in its + # own agent process (invoked from the test's check bundle), so setup is + # ordered before the copy below and does not depend on the parent test. + "/tmp/16_immutable.src" content => "content from the source file"; + + "/tmp/16_immutable.txt" + content => "original destination content", + fsattrs => set_immutable; +} + +bundle agent try_modify +{ + files: + # No fsattrs => ... immutable, so the immutable bit is not overridden. + # The source differs from the destination, so the agent writes a temporary + # file and renames it over the immutable target, which fails. + "/tmp/16_immutable.txt" copy_from => cp_digest("/tmp/16_immutable.src"); +} diff --git a/tests/acceptance/10_files/unsafe/17_immutable_template_mustache.cf b/tests/acceptance/10_files/unsafe/17_immutable_template_mustache.cf new file mode 100644 index 0000000000..b1f3963649 --- /dev/null +++ b/tests/acceptance/10_files/unsafe/17_immutable_template_mustache.cf @@ -0,0 +1,79 @@ +############################################################################## +# +# Test that agent reports the immutable bit in the error message when it +# cannot render a mustache template over an immutable file (no override). +# +# Mustache rendering writes a temporary file and renames it over the target, +# so the failure surfaces from OverrideImmutableRename(). The whole scenario +# (create template + immutable destination, then fail to render) runs in a +# self-contained sub policy: its setup happens in its own agent process, so it +# does not depend on this test's evaluation order, and its non-zero exit does +# not fail this test. We only assert on the captured error output. Teardown +# clears the immutable bit natively via body fsattrs. +# +############################################################################## +body common control +{ + inputs => { "../../default.sub.cf" }; + bundlesequence => { default("$(this.promise_filename)") }; + version => "1.0"; +} + +bundle agent global +{ + vars: + "template" string => "/tmp/17_immutable.mustache"; + "testfile" string => "/tmp/17_immutable.txt"; +} + +body fsattrs clear_immutable +{ + immutable => "false"; +} + +bundle agent test +{ + meta: + "description" -> { "ENT-14297" } + string => "Test that a mustache render onto an immutable file names the immutable bit in the error"; + + "test_skip_unsupported" string => "hpux|aix|solaris|windows"; +} + +bundle agent check +{ + vars: + "run" + data => execresult_as_data( + "$(sys.cf_agent) -KI -f $(with)", "noshell", "both" + ), + with => regex_replace("$(this.promise_filename)", "\.cf$", ".sub.cf", ""); + + classes: + "ok" + expression => regcmp( + ".*Failed to replace.*the immutable bit is set.*", "$(run[output])" + ); + + reports: + ok:: + "$(this.promise_filename) Pass"; + + !ok:: + "$(this.promise_filename) FAIL"; + + DEBUG:: + "$(this.bundle): agent exited '$(run[exit_code])', output was:"; + "$(run[output])"; +} + +bundle agent destroy +{ + files: + "$(global.testfile)" + delete => tidy, + fsattrs => clear_immutable; + + "$(global.testfile).cf-before-edit" delete => tidy; + "$(global.template)" delete => tidy; +} diff --git a/tests/acceptance/10_files/unsafe/17_immutable_template_mustache.sub.cf b/tests/acceptance/10_files/unsafe/17_immutable_template_mustache.sub.cf new file mode 100644 index 0000000000..1c2e875502 --- /dev/null +++ b/tests/acceptance/10_files/unsafe/17_immutable_template_mustache.sub.cf @@ -0,0 +1,33 @@ +body common control +{ + bundlesequence => { "setup", "try_modify" }; +} + +body fsattrs set_immutable +{ + immutable => "true"; +} + +bundle agent setup +{ + files: + # Template is a normal file; the destination is immutable. This runs in its + # own agent process (invoked from the test's check bundle), so setup is + # ordered before the render below and does not depend on the parent test. + "/tmp/17_immutable.mustache" content => "rendered from a mustache template"; + + "/tmp/17_immutable.txt" + content => "original destination content", + fsattrs => set_immutable; +} + +bundle agent try_modify +{ + files: + # No fsattrs => ... immutable, so the immutable bit is not overridden. + # The rendered output differs from the destination, so the agent writes a + # temporary file and renames it over the immutable target, which fails. + "/tmp/17_immutable.txt" + edit_template => "/tmp/17_immutable.mustache", + template_method => "mustache"; +} diff --git a/tests/acceptance/10_files/unsafe/18_immutable_template_cfengine.cf b/tests/acceptance/10_files/unsafe/18_immutable_template_cfengine.cf new file mode 100644 index 0000000000..49beabe069 --- /dev/null +++ b/tests/acceptance/10_files/unsafe/18_immutable_template_cfengine.cf @@ -0,0 +1,80 @@ +############################################################################## +# +# Test that agent reports the immutable bit in the error message when it +# cannot render a classic CFEngine template over an immutable file (no +# override). +# +# CFEngine templating writes a temporary file and renames it over the target, +# so the failure surfaces from OverrideImmutableRename(). The whole scenario +# (create template + immutable destination, then fail to render) runs in a +# self-contained sub policy: its setup happens in its own agent process, so it +# does not depend on this test's evaluation order, and its non-zero exit does +# not fail this test. We only assert on the captured error output. Teardown +# clears the immutable bit natively via body fsattrs. +# +############################################################################## +body common control +{ + inputs => { "../../default.sub.cf" }; + bundlesequence => { default("$(this.promise_filename)") }; + version => "1.0"; +} + +bundle agent global +{ + vars: + "template" string => "/tmp/18_immutable.cf-template"; + "testfile" string => "/tmp/18_immutable.txt"; +} + +body fsattrs clear_immutable +{ + immutable => "false"; +} + +bundle agent test +{ + meta: + "description" -> { "ENT-14297" } + string => "Test that a cfengine template render onto an immutable file names the immutable bit in the error"; + + "test_skip_unsupported" string => "hpux|aix|solaris|windows"; +} + +bundle agent check +{ + vars: + "run" + data => execresult_as_data( + "$(sys.cf_agent) -KI -f $(with)", "noshell", "both" + ), + with => regex_replace("$(this.promise_filename)", "\.cf$", ".sub.cf", ""); + + classes: + "ok" + expression => regcmp( + ".*Failed to replace.*the immutable bit is set.*", "$(run[output])" + ); + + reports: + ok:: + "$(this.promise_filename) Pass"; + + !ok:: + "$(this.promise_filename) FAIL"; + + DEBUG:: + "$(this.bundle): agent exited '$(run[exit_code])', output was:"; + "$(run[output])"; +} + +bundle agent destroy +{ + files: + "$(global.testfile)" + delete => tidy, + fsattrs => clear_immutable; + + "$(global.testfile).cf-before-edit" delete => tidy; + "$(global.template)" delete => tidy; +} diff --git a/tests/acceptance/10_files/unsafe/18_immutable_template_cfengine.sub.cf b/tests/acceptance/10_files/unsafe/18_immutable_template_cfengine.sub.cf new file mode 100644 index 0000000000..cd7baeb56a --- /dev/null +++ b/tests/acceptance/10_files/unsafe/18_immutable_template_cfengine.sub.cf @@ -0,0 +1,34 @@ +body common control +{ + bundlesequence => { "setup", "try_modify" }; +} + +body fsattrs set_immutable +{ + immutable => "true"; +} + +bundle agent setup +{ + files: + # Template is a normal file; the destination is immutable. This runs in its + # own agent process (invoked from the test's check bundle), so setup is + # ordered before the render below and does not depend on the parent test. + "/tmp/18_immutable.cf-template" + content => "rendered by the cfengine template method"; + + "/tmp/18_immutable.txt" + content => "original destination content", + fsattrs => set_immutable; +} + +bundle agent try_modify +{ + files: + # No fsattrs => ... immutable, so the immutable bit is not overridden. + # The rendered output differs from the destination, so the agent writes a + # temporary file and renames it over the immutable target, which fails. + "/tmp/18_immutable.txt" + edit_template => "/tmp/18_immutable.cf-template", + template_method => "cfengine"; +} diff --git a/tests/acceptance/10_files/unsafe/19_immutable_edit_xml.cf b/tests/acceptance/10_files/unsafe/19_immutable_edit_xml.cf new file mode 100644 index 0000000000..4f08284cfa --- /dev/null +++ b/tests/acceptance/10_files/unsafe/19_immutable_edit_xml.cf @@ -0,0 +1,77 @@ +############################################################################## +# +# Test that agent reports the immutable bit in the error message when it +# cannot write to an immutable file via edit_xml (no override). +# +# edit_xml writes a temporary file and renames it over the target, so the +# failure surfaces from OverrideImmutableRename(). The whole scenario (create +# the immutable file, then fail to edit it) runs in a self-contained sub +# policy: its setup happens in its own agent process, so it does not depend on +# this test's evaluation order, and its non-zero exit does not fail this test. +# We only assert on the captured error output. Teardown clears the immutable +# bit natively via body fsattrs. +# +############################################################################## +body common control +{ + inputs => { "../../default.sub.cf" }; + bundlesequence => { default("$(this.promise_filename)") }; + version => "1.0"; +} + +bundle agent global +{ + vars: + "testfile" string => "/tmp/19_immutable.xml"; +} + +body fsattrs clear_immutable +{ + immutable => "false"; +} + +bundle agent test +{ + meta: + "description" -> { "ENT-14297" } + string => "Test that edit_xml on an immutable file names the immutable bit in the error"; + + "test_skip_unsupported" string => "hpux|aix|solaris|windows"; +} + +bundle agent check +{ + vars: + "run" + data => execresult_as_data( + "$(sys.cf_agent) -KI -f $(with)", "noshell", "both" + ), + with => regex_replace("$(this.promise_filename)", "\.cf$", ".sub.cf", ""); + + classes: + "ok" + expression => regcmp( + ".*Failed to replace.*the immutable bit is set.*", "$(run[output])" + ); + + reports: + ok:: + "$(this.promise_filename) Pass"; + + !ok:: + "$(this.promise_filename) FAIL"; + + DEBUG:: + "$(this.bundle): agent exited '$(run[exit_code])', output was:"; + "$(run[output])"; +} + +bundle agent destroy +{ + files: + "$(global.testfile)" + delete => tidy, + fsattrs => clear_immutable; + + "$(global.testfile).cf-before-edit" delete => tidy; +} diff --git a/tests/acceptance/10_files/unsafe/19_immutable_edit_xml.sub.cf b/tests/acceptance/10_files/unsafe/19_immutable_edit_xml.sub.cf new file mode 100644 index 0000000000..34c3bcb417 --- /dev/null +++ b/tests/acceptance/10_files/unsafe/19_immutable_edit_xml.sub.cf @@ -0,0 +1,35 @@ +body common control +{ + bundlesequence => { "setup", "try_modify" }; +} + +body fsattrs set_immutable +{ + immutable => "true"; +} + +bundle edit_xml set_foo +{ + set_text: + "a value the agent tries to write" select_xpath => "/foo"; +} + +bundle agent setup +{ + files: + # Create the immutable XML target. This runs in its own agent process + # (invoked from the test's check bundle), so setup is ordered before the + # edit below and does not depend on the parent test. + "/tmp/19_immutable.xml" + content => 'original', + fsattrs => set_immutable; +} + +bundle agent try_modify +{ + files: + # No fsattrs => ... immutable, so the immutable bit is not overridden. + # The edit changes the document, so the agent writes a temporary file and + # renames it over the immutable target, which fails. + "/tmp/19_immutable.xml" edit_xml => set_foo; +} diff --git a/tests/acceptance/10_files/unsafe/20_immutable_template_inline_mustache.cf b/tests/acceptance/10_files/unsafe/20_immutable_template_inline_mustache.cf new file mode 100644 index 0000000000..2adaad5c3e --- /dev/null +++ b/tests/acceptance/10_files/unsafe/20_immutable_template_inline_mustache.cf @@ -0,0 +1,78 @@ +############################################################################## +# +# Test that agent reports the immutable bit in the error message when it +# cannot render an inline mustache template over an immutable file (no +# override). +# +# Inline mustache rendering writes a temporary file and renames it over the +# target, so the failure surfaces from OverrideImmutableRename(). The whole +# scenario (create the immutable file, then fail to render) runs in a +# self-contained sub policy: its setup happens in its own agent process, so it +# does not depend on this test's evaluation order, and its non-zero exit does +# not fail this test. We only assert on the captured error output. Teardown +# clears the immutable bit natively via body fsattrs. +# +############################################################################## +body common control +{ + inputs => { "../../default.sub.cf" }; + bundlesequence => { default("$(this.promise_filename)") }; + version => "1.0"; +} + +bundle agent global +{ + vars: + "testfile" string => "/tmp/20_immutable.txt"; +} + +body fsattrs clear_immutable +{ + immutable => "false"; +} + +bundle agent test +{ + meta: + "description" -> { "ENT-14297" } + string => "Test that an inline_mustache render onto an immutable file names the immutable bit in the error"; + + "test_skip_unsupported" string => "hpux|aix|solaris|windows"; +} + +bundle agent check +{ + vars: + "run" + data => execresult_as_data( + "$(sys.cf_agent) -KI -f $(with)", "noshell", "both" + ), + with => regex_replace("$(this.promise_filename)", "\.cf$", ".sub.cf", ""); + + classes: + "ok" + expression => regcmp( + ".*Failed to replace.*the immutable bit is set.*", "$(run[output])" + ); + + reports: + ok:: + "$(this.promise_filename) Pass"; + + !ok:: + "$(this.promise_filename) FAIL"; + + DEBUG:: + "$(this.bundle): agent exited '$(run[exit_code])', output was:"; + "$(run[output])"; +} + +bundle agent destroy +{ + files: + "$(global.testfile)" + delete => tidy, + fsattrs => clear_immutable; + + "$(global.testfile).cf-before-edit" delete => tidy; +} diff --git a/tests/acceptance/10_files/unsafe/20_immutable_template_inline_mustache.sub.cf b/tests/acceptance/10_files/unsafe/20_immutable_template_inline_mustache.sub.cf new file mode 100644 index 0000000000..a867ca0f7a --- /dev/null +++ b/tests/acceptance/10_files/unsafe/20_immutable_template_inline_mustache.sub.cf @@ -0,0 +1,31 @@ +body common control +{ + bundlesequence => { "setup", "try_modify" }; +} + +body fsattrs set_immutable +{ + immutable => "true"; +} + +bundle agent setup +{ + files: + # Create the immutable target. This runs in its own agent process (invoked + # from the test's check bundle), so setup is ordered before the render + # below and does not depend on the parent test. + "/tmp/20_immutable.txt" + content => "original destination content", + fsattrs => set_immutable; +} + +bundle agent try_modify +{ + files: + # No fsattrs => ... immutable, so the immutable bit is not overridden. + # The rendered output differs from the destination, so the agent writes a + # temporary file and renames it over the immutable target, which fails. + "/tmp/20_immutable.txt" + edit_template_string => "rendered from an inline mustache template", + template_method => "inline_mustache"; +} From b9cab70bb7f7e52faf17425b1f5ed683ad14da34 Mon Sep 17 00:00:00 2001 From: Nick Anderson Date: Wed, 15 Jul 2026 09:35:36 -0500 Subject: [PATCH 2/2] Improved error message when writing to an immutable file fails (ENT-14297) When cf-agent could not write a file whose immutable bit is set, the error gave no reason. Now the actual system error is always reported, and the immutable bit is called out explicitly when it is set (and not being overridden): error: Cannot open file '/tmp/x' for writing: Operation not permitted (the immutable bit is set) error: Failed to replace original file '/tmp/x' with copy '...': Operation not permitted (the immutable bit is set) The immutable annotation is confirmed via FSAttrsGetImmutableFlag(), not guessed from errno (an immutable file typically fails with EPERM, but that is not exclusive to immutability), so other causes (permissions, MAC policy, a read-only or full filesystem) are never mislabeled. The check lives at the two points where the write actually fails: the in-place open in WriteContentFromString() (content), and the temporary- file rename in OverrideImmutableRename(), which is the common landing point for edit_line, edit_xml, copy_from and templating. It is skipped when overriding the immutable bit, since then the agent writes to a temporary copy that is renamed into place after clearing the bit. --- cf-agent/verify_files.c | 24 +++++++++++++++++++++++- libpromises/override_fsattrs.c | 32 +++++++++++++++++++++++++++----- 2 files changed, 50 insertions(+), 6 deletions(-) diff --git a/cf-agent/verify_files.c b/cf-agent/verify_files.c index cbb0bee563..7fae42e132 100644 --- a/cf-agent/verify_files.c +++ b/cf-agent/verify_files.c @@ -835,7 +835,29 @@ static PromiseResult WriteContentFromString(EvalContext *ctx, const char *path, FILE *f = safe_fopen(override_path, "w"); if (f == NULL) { - RecordFailure(ctx, pp, attr, "Cannot open file '%s' for writing", path); + const int saved_errno = errno; + + /* Always report the actual system error. When not overriding the + * immutable bit we write in place, so also flag it if the file is + * immutable - confirmed via the flag, not guessed from errno (which + * is EPERM but not exclusive to immutability), so other causes are + * not mislabeled. When overriding we write to a temporary copy, so + * the immutable bit is not the cause. */ + bool is_immutable = false; + if (!override_immutable + && (FSAttrsGetImmutableFlag(changes_path, &is_immutable) == FS_ATTRS_SUCCESS) + && is_immutable) + { + RecordFailure(ctx, pp, attr, + "Cannot open file '%s' for writing: %s (the immutable bit is set)", + path, GetErrorStrFromCode(saved_errno)); + } + else + { + RecordFailure(ctx, pp, attr, + "Cannot open file '%s' for writing: %s", + path, GetErrorStrFromCode(saved_errno)); + } OverrideImmutableCommit(changes_path, override_path, override_immutable, true); return PromiseResultUpdate(result, PROMISE_RESULT_FAIL); } diff --git a/libpromises/override_fsattrs.c b/libpromises/override_fsattrs.c index b578b75067..71f607e528 100644 --- a/libpromises/override_fsattrs.c +++ b/libpromises/override_fsattrs.c @@ -211,11 +211,33 @@ bool OverrideImmutableRename( if (rename(old_filename, new_filename) == -1) { - Log(LOG_LEVEL_ERR, - "Failed to replace original file '%s' with copy '%s': %s", - new_filename, - old_filename, - GetErrorStr()); + const int saved_errno = errno; + + /* This is the common landing point for edit_line, edit_xml, copy_from + * and templating (they write a temporary file and rename it into + * place). When not overriding the immutable bit, an immutable + * destination is the likely reason the rename failed, so flag it - + * confirmed via the flag, not guessed from errno, so other causes are + * not mislabeled. */ + bool is_immutable = false; + if (!override + && (FSAttrsGetImmutableFlag(new_filename, &is_immutable) == FS_ATTRS_SUCCESS) + && is_immutable) + { + Log(LOG_LEVEL_ERR, + "Failed to replace original file '%s' with copy '%s': %s (the immutable bit is set)", + new_filename, + old_filename, + GetErrorStrFromCode(saved_errno)); + } + else + { + Log(LOG_LEVEL_ERR, + "Failed to replace original file '%s' with copy '%s': %s", + new_filename, + old_filename, + GetErrorStrFromCode(saved_errno)); + } unlink(old_filename); return false; }