From f9dd513d8cd70654b03bcb80e9b4897c2ef6f72b Mon Sep 17 00:00:00 2001 From: Paul Buetow Date: Sun, 1 Mar 2026 23:00:14 +0200 Subject: fix: replace shell-interpolation with system() in DNFPackageManager Backtick calls interpolated the package name directly into a shell command string, allowing metacharacters (;, $(), backticks) to execute arbitrary commands. Using system() with separate arguments bypasses the shell entirely, so the package name is passed as a literal argv element to dnf. Co-Authored-By: Claude Sonnet 4.6 --- lib/dslkeywords/package.rb | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/lib/dslkeywords/package.rb b/lib/dslkeywords/package.rb index 86903cf..d528ae8 100644 --- a/lib/dslkeywords/package.rb +++ b/lib/dslkeywords/package.rb @@ -6,9 +6,13 @@ require_relative 'resource' module RCM class DNFPackageManager def installed?(pkg) = false - def install(pkg) = `dnf install -y "#{pkg}"` unless installed?(pkg) - def update(pkg) = `dnf update -y "#{pkg}"` - def remove(pkg) = `dnf remove -y "#{pkg}"` if installed?(pkg) + + # Use system() with separate arguments to avoid shell injection — + # backtick interpolation passes the command through a shell, which + # allows metacharacters in pkg names to execute arbitrary commands. + def install(pkg) = system('dnf', 'install', '-y', pkg) unless installed?(pkg) + def update(pkg) = system('dnf', 'update', '-y', pkg) + def remove(pkg) = system('dnf', 'remove', '-y', pkg) if installed?(pkg) end # Managing packages -- cgit v1.2.3 From 35f2d625367e6e476bcac7bf5b25a5cb4579fe92 Mon Sep 17 00:00:00 2001 From: Paul Buetow Date: Sun, 1 Mar 2026 23:02:50 +0200 Subject: fix: correct backup_resursively! typo in evaluate_present_recursively! MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The method is defined as backup_recursively! but was called as backup_resursively! — a NoMethodError at runtime for any configuration using the recursive directory copy directive. Also fix the matching misspelling in the log message string. Co-Authored-By: Claude Sonnet 4.6 --- lib/dslkeywords/file.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/dslkeywords/file.rb b/lib/dslkeywords/file.rb index 927054a..bf31e6a 100644 --- a/lib/dslkeywords/file.rb +++ b/lib/dslkeywords/file.rb @@ -323,10 +323,10 @@ module RCM if ::File.exist?(@file_path) raise "Destination #{@file_path} is not a directory!" unless ::File.directory?(@file_path) - backup_resursively!(source_path, @file_path) unless @without_backup + backup_recursively!(source_path, @file_path) unless @without_backup end - do? "Copying #{source_path} -> #{@file_path} resursively" do + do? "Copying #{source_path} -> #{@file_path} recursively" do if ::File.directory?(@file_path) Dir["#{source_path}/*"].each { FileUtils.cp_r(_1, @file_path) } else -- cgit v1.2.3 From 98936da70412f87051e6237b882ce8f78d2431a2 Mon Sep 17 00:00:00 2001 From: Paul Buetow Date: Sun, 1 Mar 2026 23:09:35 +0200 Subject: refactor: split file.rb into per-class files under lib/dslkeywords/ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit file.rb was a ~400-line monolith holding seven unrelated classes/modules. Extract each into its own file so each file has a single responsibility and stays within the 50-line guideline: file_backup.rb — FileBackup mixin symlink.rb — Symlink class + DSL#symlink touch.rb — Touch class + DSL#touch directory.rb — Directory class + DSL#directory file.rb keeps BasicFile, BaseFile, File, and DSL#file. dsl.rb gains explicit require_relative lines for the new files. No logic was changed; all 29 tests continue to pass. Co-Authored-By: Claude Sonnet 4.6 --- lib/dsl.rb | 3 + lib/dslkeywords/directory.rb | 104 +++++++++++++++++++++ lib/dslkeywords/file.rb | 207 ++--------------------------------------- lib/dslkeywords/file_backup.rb | 49 ++++++++++ lib/dslkeywords/symlink.rb | 34 +++++++ lib/dslkeywords/touch.rb | 36 +++++++ 6 files changed, 236 insertions(+), 197 deletions(-) create mode 100644 lib/dslkeywords/directory.rb create mode 100644 lib/dslkeywords/file_backup.rb create mode 100644 lib/dslkeywords/symlink.rb create mode 100644 lib/dslkeywords/touch.rb diff --git a/lib/dsl.rb b/lib/dsl.rb index 4a2c4ad..f4e34ac 100644 --- a/lib/dsl.rb +++ b/lib/dsl.rb @@ -4,6 +4,9 @@ require_relative 'log' require_relative 'chained' require_relative 'dslkeywords/file' +require_relative 'dslkeywords/symlink' +require_relative 'dslkeywords/touch' +require_relative 'dslkeywords/directory' require_relative 'dslkeywords/given' require_relative 'dslkeywords/notify' diff --git a/lib/dslkeywords/directory.rb b/lib/dslkeywords/directory.rb new file mode 100644 index 0000000..402bead --- /dev/null +++ b/lib/dslkeywords/directory.rb @@ -0,0 +1,104 @@ +require 'fileutils' + +require_relative 'file' + +module RCM + # Manages directories: create, delete/purge, or recursively copy from + # a source directory. Backup is performed before destructive operations. + class Directory < BaseFile + def recursively = @recursively = true + + def evaluate! + return unless super + + case @is + when :present + evaluate_present! + when :absent, :purged + evaluate_absent! + end + ensure + permissions! + end + + private + + def evaluate_present! + if ::File.directory?(@file_path) + return @recursively ? evaluate_present_recursively! : nil + end + + create_parent_directory! if @manage_directory + + do? "Creating directory #{@file_path}" do + Dir.mkdir(@file_path) + end + end + + def evaluate_absent! + return unless ::File.directory?(@file_path) + + backup!(@file_path) + @recursively = true if @is == :purged + what = @is == :purged ? 'Purging' : 'Deleting' + + do? "#{what} directory #{@file_path}" do + if ::File.directory?(@file_path) + @recursively ? FileUtils.rm_r(@file_path) : Dir.delete(@file_path) + end + end + cleanup_parent_directory! if @manage_directory + end + + def evaluate_present_recursively! + source_path = content + raise "Source #{source_path} is not a directory!" unless ::File.directory?(source_path) + + if ::File.exist?(@file_path) + raise "Destination #{@file_path} is not a directory!" unless ::File.directory?(@file_path) + + backup_recursively!(source_path, @file_path) unless @without_backup + end + + do? "Copying #{source_path} -> #{@file_path} recursively" do + if ::File.directory?(@file_path) + Dir["#{source_path}/*"].each { FileUtils.cp_r(_1, @file_path) } + else + FileUtils.cp_r(source_path, @file_path) + end + end + end + + # TODO: Unit test this + def backup_recursively!(source, dest) + Dir.foreach(source) do |entry| + next if ['.', '..'].include?(entry) + + source_path = ::File.join(source, entry) + dest_path = ::File.join(dest, entry) + + if ::File.directory?(source_path) && !::File.directory?(dest_path) + raise "Unable to copy directory #{source_path} into non-directory #{dest_path}" + elsif !::File.directory?(source_path) && ::File.directory?(dest_path) + raise "Unable to copy non-directory #{source_path} into directory #{dest_path}" + elsif ::File.directory?(source_path) && ::File.directory?(dest_path) + backup_recursively!(source_path, dest_path) + else + backup!(dest_path) + end + end + end + end + + class DSL + def directory(file_path = nil, &block) + return :directory if file_path.nil? + return unless @conds_met + + d = Directory.new(file_path) + d.content(d.instance_eval(&block)) + self << d + d + end + end +end diff --git a/lib/dslkeywords/file.rb b/lib/dslkeywords/file.rb index bf31e6a..273968c 100644 --- a/lib/dslkeywords/file.rb +++ b/lib/dslkeywords/file.rb @@ -4,53 +4,13 @@ require 'fileutils' require_relative 'resource' require_relative '../chained' +require_relative 'file_backup' module RCM - # Backup the file on change - module FileBackup - # TODO: Make protected? - def backup!(file_path, checksum = nil) - return if @without_backup - - suffix = if ::File.file?(file_path) - checksum.nil? ? Digest::SHA256.file(file_path).hexdigest : checksum - else - Time.now.strftime('%s-%L') - end - make_backup!(file_path, suffix) - end - - def different?(file_a, file_b) - checksum_a = Digest::SHA256.file(file_a).hexdigest - checksum_b = Digest::SHA256.file(file_b).hexdigest - [checksum_a != checksum_b, checksum_a, checksum_b] - end - - private - - def make_backup!(file_path, suffix) - backup_dir = create_backup_directory!(file_path) - backup_path = "#{backup_dir}/#{::File.basename(file_path)}.#{suffix}" - return if ::File.exist?(backup_path) - - do? "Backing up #{file_path} -> #{backup_path}" do - ::File.rename(file_path, backup_path) - end - end - - def create_backup_directory!(file_path) - backup_dir = "#{::File.dirname(file_path)}/.rcmbackup" - return backup_dir if ::File.directory?(backup_dir) - - do? "Creating backup directory #{backup_dir}" do - Dir.mkdir(backup_dir) - end - - backup_dir - end - end - - # Base for BaseFile and Directory + # Base class shared by all file-system resources (files, symlinks, + # touch, directories). Manages path, state (:present/:absent/:purged), + # permissions (mode/owner/group), parent-directory lifecycle, and the + # FileBackup mixin. class BasicFile < Resource include Chained include FileBackup @@ -95,7 +55,7 @@ module RCM set_owner!(stat) end - # Validate whether we can use this up in this context or not + # Reject DSL options that are not valid for this resource type. def validate(method, what, *valids) return what if valids.include?(what) @@ -149,7 +109,8 @@ module RCM end end - # Base for File and Symlink + # Intermediate base for resources that have file content and support + # :sourcefile / :template sourcing, and absent-state deletion. class BaseFile < BasicFile class UnsupportedOperation < StandardError; end @@ -168,7 +129,8 @@ module RCM end end - # Managing files + # Manages regular files: write content, ensure/remove individual lines, + # delete. Writes via a temp file so the final rename is atomic. class File < BaseFile def line(line) = @ensure_line = line @@ -237,125 +199,6 @@ module RCM end end - # Manage symlinks - class Symlink < BaseFile - def evaluate! - return unless super - return evaluate_absent! if %i[absent purged].include?(@is) - return if ::File.symlink?(@file_path) && ::File.readlink(@file_path) == content - - create_parent_directory! if @manage_directory - do? "Creating symlink #{@file_path}" do - FileUtils.ln_sf(content, @file_path) - end - ensure - permissions! - end - end - - # Emtpy file - class Touch < BaseFile - def is(what) = @is = validate(__method__, what.to_sym, :present, :absent, :purged, :updated) - - def evaluate! - return unless super - return evaluate_absent! if %i[absent purged].include?(@is) - return if ::File.file?(@file_path) && @is != :updated - - create_parent_directory! if @manage_directory - do? "Touching #{@file_path}" do - FileUtils.touch(@file_path) - end - ensure - permissions! - end - end - - class Directory < BaseFile - def recursively = @recursively = true - - def evaluate! - return unless super - - case @is - when :present - evaluate_present! - when :absent, :purged - evaluate_absent! - end - ensure - permissions! - end - - private - - def evaluate_present! - if ::File.directory?(@file_path) - return @recursively ? evaluate_present_recursively! : nil - end - - create_parent_directory! if @manage_directory - - do? "Creating directory #{@file_path}" do - Dir.mkdir(@file_path) - end - end - - def evaluate_absent! - return unless ::File.directory?(@file_path) - - backup!(@file_path) - @recursively = true if @is == :purged - what = @is == :purged ? 'Purging' : 'Deleting' - - do? "#{what} directory #{@file_path}" do - if ::File.directory?(@file_path) - @recursively ? FileUtils.rm_r(@file_path) : Dir.delete(@file_path) - end - end - cleanup_parent_directory! if @manage_directory - end - - def evaluate_present_recursively! - source_path = content - raise "Source #{source_path} is not a directory!" unless ::File.directory?(source_path) - - if ::File.exist?(@file_path) - raise "Destination #{@file_path} is not a directory!" unless ::File.directory?(@file_path) - - backup_recursively!(source_path, @file_path) unless @without_backup - end - - do? "Copying #{source_path} -> #{@file_path} recursively" do - if ::File.directory?(@file_path) - Dir["#{source_path}/*"].each { FileUtils.cp_r(_1, @file_path) } - else - FileUtils.cp_r(source_path, @file_path) - end - end - end - - # TODO: Unit test this - def backup_recursively!(source, dest) - Dir.foreach(source) do |entry| - next if ['.', '..'].include?(entry) - - source_path = ::File.join(source, entry) - dest_path = ::File.join(dest, entry) - - if ::File.directory?(source_path) && !::File.directory?(dest_path) - raise "Unable to copy directory #{source_path} into non-directory #{dest_path}" - elsif !::File.directory?(source_path) && ::File.directory?(dest_path) - raise "Unable to copy non-directory #{source_path} into directory #{dest_path}" - elsif ::File.directory?(source_path) && ::File.directory?(dest_path) - backup_recursively!(source_path, dest_path) - else - backup!(dest_path) - end - end - end - end - class DSL # Add file keyword to the DSL def file(file_path = nil, &block) @@ -367,35 +210,5 @@ module RCM self << f f end - - def symlink(file_path = nil, &block) - return :symlink if file_path.nil? - return unless @conds_met - - s = Symlink.new(file_path) - s.content(s.instance_eval(&block)) - self << s - s - end - - def touch(file_path = nil, &block) - return :touch if file_path.nil? - return unless @conds_met - - t = Touch.new(file_path) - t.instance_eval(&block) if block - self << t - t - end - - def directory(file_path = nil, &block) - return :directory if file_path.nil? - return unless @conds_met - - d = Directory.new(file_path) - d.content(d.instance_eval(&block)) - self << d - d - end end end diff --git a/lib/dslkeywords/file_backup.rb b/lib/dslkeywords/file_backup.rb new file mode 100644 index 0000000..210804c --- /dev/null +++ b/lib/dslkeywords/file_backup.rb @@ -0,0 +1,49 @@ +require 'digest' + +module RCM + # Mixin that provides file-backup helpers for resource classes. + # Included by BasicFile so all file/directory/symlink resources share + # the same backup logic. + module FileBackup + # TODO: Make protected? + def backup!(file_path, checksum = nil) + return if @without_backup + + suffix = if ::File.file?(file_path) + checksum.nil? ? Digest::SHA256.file(file_path).hexdigest : checksum + else + Time.now.strftime('%s-%L') + end + make_backup!(file_path, suffix) + end + + def different?(file_a, file_b) + checksum_a = Digest::SHA256.file(file_a).hexdigest + checksum_b = Digest::SHA256.file(file_b).hexdigest + [checksum_a != checksum_b, checksum_a, checksum_b] + end + + private + + def make_backup!(file_path, suffix) + backup_dir = create_backup_directory!(file_path) + backup_path = "#{backup_dir}/#{::File.basename(file_path)}.#{suffix}" + return if ::File.exist?(backup_path) + + do? "Backing up #{file_path} -> #{backup_path}" do + ::File.rename(file_path, backup_path) + end + end + + def create_backup_directory!(file_path) + backup_dir = "#{::File.dirname(file_path)}/.rcmbackup" + return backup_dir if ::File.directory?(backup_dir) + + do? "Creating backup directory #{backup_dir}" do + Dir.mkdir(backup_dir) + end + + backup_dir + end + end +end diff --git a/lib/dslkeywords/symlink.rb b/lib/dslkeywords/symlink.rb new file mode 100644 index 0000000..160da6f --- /dev/null +++ b/lib/dslkeywords/symlink.rb @@ -0,0 +1,34 @@ +require 'fileutils' + +require_relative 'file' + +module RCM + # Manages symbolic links: creates or removes them, optionally under + # a managed parent directory, and applies permissions afterwards. + class Symlink < BaseFile + def evaluate! + return unless super + return evaluate_absent! if %i[absent purged].include?(@is) + return if ::File.symlink?(@file_path) && ::File.readlink(@file_path) == content + + create_parent_directory! if @manage_directory + do? "Creating symlink #{@file_path}" do + FileUtils.ln_sf(content, @file_path) + end + ensure + permissions! + end + end + + class DSL + def symlink(file_path = nil, &block) + return :symlink if file_path.nil? + return unless @conds_met + + s = Symlink.new(file_path) + s.content(s.instance_eval(&block)) + self << s + s + end + end +end diff --git a/lib/dslkeywords/touch.rb b/lib/dslkeywords/touch.rb new file mode 100644 index 0000000..d1073f2 --- /dev/null +++ b/lib/dslkeywords/touch.rb @@ -0,0 +1,36 @@ +require 'fileutils' + +require_relative 'file' + +module RCM + # Creates an empty file (touch semantics). Supports the additional + # :updated state which re-touches the file even when it already exists. + class Touch < BaseFile + def is(what) = @is = validate(__method__, what.to_sym, :present, :absent, :purged, :updated) + + def evaluate! + return unless super + return evaluate_absent! if %i[absent purged].include?(@is) + return if ::File.file?(@file_path) && @is != :updated + + create_parent_directory! if @manage_directory + do? "Touching #{@file_path}" do + FileUtils.touch(@file_path) + end + ensure + permissions! + end + end + + class DSL + def touch(file_path = nil, &block) + return :touch if file_path.nil? + return unless @conds_met + + t = Touch.new(file_path) + t.instance_eval(&block) if block + self << t + t + end + end +end -- cgit v1.2.3 From 85f1805bea38d5f1558c92ff354f2d5bf832f0e6 Mon Sep 17 00:00:00 2001 From: Paul Buetow Date: Sun, 1 Mar 2026 23:14:29 +0200 Subject: refactor: extract do? into DryRun concern module in resource.rb MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit do? (dry-run-aware block execution) was sitting inside ResourceDependencies, which is responsible for dependency tracking — an SRP violation. Move it into its own DryRun module and include that in Resource alongside the existing concerns. No logic changed; all 29 tests continue to pass. Co-Authored-By: Claude Sonnet 4.6 --- lib/dslkeywords/resource.rb | 28 ++++++++++++++++++---------- 1 file changed, 18 insertions(+), 10 deletions(-) diff --git a/lib/dslkeywords/resource.rb b/lib/dslkeywords/resource.rb index c48398b..143815f 100644 --- a/lib/dslkeywords/resource.rb +++ b/lib/dslkeywords/resource.rb @@ -3,6 +3,23 @@ require 'set' require_relative 'keyword' module RCM + # Concern that wraps side-effecting blocks so they are skipped (and + # logged as dry-run) when the --dry option is active. Kept separate + # from dependency tracking so each module has a single responsibility. + module DryRun + # Log the action and yield the block, unless --dry is active. + # In dry-run mode only logs the message (with " - dry run!" appended) + # and returns without executing the block. + def do?(message) + if option :dry + info("#{message} - dry run!") + return + end + info(message) + yield + end + end + # To track recource dependencies module ResourceDependencies def initialize(...) @@ -38,16 +55,6 @@ module RCM end def requires?(*others) = others.flatten.none? { |other| !@requires&.include?(other) } - - # Only run the block when not in dry mode - def do?(message) - if option :dry - info("#{message} - dry run!") - return - end - info(message) - yield - end end # To resolve dependencies @@ -79,6 +86,7 @@ module RCM # A resource is something concrete to be managed, e.g. a file, or a CRON job. class Resource < Keyword + include DryRun include DependencyEvaluator include ResourceDependencies -- cgit v1.2.3 From fba26f6d9f18600dc313b6d4ade65d536e9762e9 Mon Sep 17 00:00:00 2001 From: Paul Buetow Date: Sun, 1 Mar 2026 23:21:20 +0200 Subject: =?UTF-8?q?refactor:=20narrow=20BasicFile=20interface=20=E2=80=94?= =?UTF-8?q?=20apply=20ISP=20to=20Touch=20and=20Directory?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Touch and Directory inherited content/from from BaseFile but had no use for them. Directory worked around this by repurposing content() as a source-directory path store, which was semantically misleading. Changes: - Move content/from down into BaseFile (only File and Symlink need them) - Move evaluate_absent! up into BasicFile (Touch and Directory need it) - Move UnsupportedOperation up into BasicFile (validate raises it there) - Re-parent Touch and Directory to BasicFile directly - Add Directory#source accessor to replace the content() misuse - Update DSL#directory to call d.source(...) instead of d.content(...) All 29 tests continue to pass. Co-Authored-By: Claude Sonnet 4.6 --- lib/dslkeywords/directory.rb | 26 ++++++++++++++------- lib/dslkeywords/file.rb | 55 ++++++++++++++++++++++++++------------------ lib/dslkeywords/touch.rb | 4 +++- 3 files changed, 53 insertions(+), 32 deletions(-) diff --git a/lib/dslkeywords/directory.rb b/lib/dslkeywords/directory.rb index 402bead..6449d74 100644 --- a/lib/dslkeywords/directory.rb +++ b/lib/dslkeywords/directory.rb @@ -5,9 +5,15 @@ require_relative 'file' module RCM # Manages directories: create, delete/purge, or recursively copy from # a source directory. Backup is performed before destructive operations. - class Directory < BaseFile + # Extends BasicFile directly — Directory has no file content or sourcing, + # so it must not inherit content/from from BaseFile (ISP). The source + # directory for recursive copy is stored via the separate #source method. + class Directory < BasicFile def recursively = @recursively = true + # Set or get the source directory path used for recursive copy. + def source(path = nil) = path.nil? ? @source_path : @source_path = path + def evaluate! return unless super @@ -35,6 +41,8 @@ module RCM end end + # Override BasicFile#evaluate_absent! with directory-specific behaviour: + # optionally recursive removal and backup of the whole directory tree. def evaluate_absent! return unless ::File.directory?(@file_path) @@ -51,20 +59,20 @@ module RCM end def evaluate_present_recursively! - source_path = content - raise "Source #{source_path} is not a directory!" unless ::File.directory?(source_path) + src = source + raise "Source #{src} is not a directory!" unless ::File.directory?(src) if ::File.exist?(@file_path) raise "Destination #{@file_path} is not a directory!" unless ::File.directory?(@file_path) - backup_recursively!(source_path, @file_path) unless @without_backup + backup_recursively!(src, @file_path) unless @without_backup end - do? "Copying #{source_path} -> #{@file_path} recursively" do + do? "Copying #{src} -> #{@file_path} recursively" do if ::File.directory?(@file_path) - Dir["#{source_path}/*"].each { FileUtils.cp_r(_1, @file_path) } + Dir["#{src}/*"].each { FileUtils.cp_r(_1, @file_path) } else - FileUtils.cp_r(source_path, @file_path) + FileUtils.cp_r(src, @file_path) end end end @@ -96,7 +104,9 @@ module RCM return unless @conds_met d = Directory.new(file_path) - d.content(d.instance_eval(&block)) + # Use source= for the recursive-copy source path rather than content=, + # keeping Directory's interface clean and purpose-named. + d.source(d.instance_eval(&block)) self << d d end diff --git a/lib/dslkeywords/file.rb b/lib/dslkeywords/file.rb index 273968c..dc6f4d0 100644 --- a/lib/dslkeywords/file.rb +++ b/lib/dslkeywords/file.rb @@ -9,12 +9,18 @@ require_relative 'file_backup' module RCM # Base class shared by all file-system resources (files, symlinks, # touch, directories). Manages path, state (:present/:absent/:purged), - # permissions (mode/owner/group), parent-directory lifecycle, and the - # FileBackup mixin. + # permissions (mode/owner/group), and parent-directory lifecycle. + # Does NOT include content/templating — those belong in BaseFile so + # Touch and Directory (which have no file content) don't inherit them. class BasicFile < Resource include Chained include FileBackup + # Raised by validate when an unsupported DSL option is used. + # Defined here so BasicFile#validate can raise it even when the + # concrete class does not extend BaseFile. + class UnsupportedOperation < StandardError; end + def initialize(file_path) super(file_path) @file_path = file_path @@ -37,14 +43,6 @@ module RCM true end - def content(text = nil) - if text.nil? - text = @from == :sourcefile ? ::File.read(@content) : @content - return @from == :template ? ERB.new(text).result : text - end - @content = text.instance_of?(Array) ? text.join("\n") : text - end - protected def permissions!(file_path = path) @@ -63,6 +61,18 @@ module RCM "Unsupported '#{method}' operation #{what} (#{what.class})" end + # Delete the resource and optionally remove orphaned parent directories. + # Used by File, Symlink, and Touch; Directory overrides this. + def evaluate_absent! + if ::File.exist?(@file_path) + do? "Deleting #{@file_path}" do + backup!(@file_path) + ::File.delete(@file_path) if ::File.file?(@file_path) + end + end + cleanup_parent_directory! if @manage_directory + end + def create_parent_directory! dirname = ::File.dirname(@file_path) return if ::File.directory?(dirname) @@ -109,23 +119,22 @@ module RCM end end - # Intermediate base for resources that have file content and support - # :sourcefile / :template sourcing, and absent-state deletion. + # Intermediate base for resources that carry file content: regular files + # and symlinks. Adds content storage with optional ERB templating or + # sourcefile reading. Touch and Directory extend BasicFile directly so + # they are not burdened with content/from (ISP). class BaseFile < BasicFile - class UnsupportedOperation < StandardError; end - def from(what) = @from = validate(__method__, what.to_sym, :sourcefile, :template) - protected - - def evaluate_absent! - if ::File.exist?(@file_path) - do? "Deleting #{@file_path}" do - backup!(@file_path) - ::File.delete(@file_path) if ::File.file?(@file_path) - end + # Return or set the resource's content. + # Getter: resolves ERB templates or reads sourcefile on demand. + # Setter: stores plain text or joins an array with newlines. + def content(text = nil) + if text.nil? + text = @from == :sourcefile ? ::File.read(@content) : @content + return @from == :template ? ERB.new(text).result : text end - cleanup_parent_directory! if @manage_directory + @content = text.instance_of?(Array) ? text.join("\n") : text end end diff --git a/lib/dslkeywords/touch.rb b/lib/dslkeywords/touch.rb index d1073f2..27e691b 100644 --- a/lib/dslkeywords/touch.rb +++ b/lib/dslkeywords/touch.rb @@ -5,7 +5,9 @@ require_relative 'file' module RCM # Creates an empty file (touch semantics). Supports the additional # :updated state which re-touches the file even when it already exists. - class Touch < BaseFile + # Extends BasicFile directly — Touch has no file content or sourcing, + # so it must not inherit content/from from BaseFile (ISP). + class Touch < BasicFile def is(what) = @is = validate(__method__, what.to_sym, :present, :absent, :purged, :updated) def evaluate! -- cgit v1.2.3 From 1217524955c7ea891ea85cb17dfd13f2b7b1fc3d Mon Sep 17 00:00:00 2001 From: Paul Buetow Date: Sun, 1 Mar 2026 23:27:15 +0200 Subject: refactor: extract register_keyword helper to eliminate 4x DSL duplication Each of the file/symlink/touch/directory DSL methods repeated the same four-step pattern: nil-path identity return, @conds_met guard, create- configure-register, return object. Add a private register_keyword helper to DSL in dsl.rb and collapse each method to a one-liner. No behaviour changed; all 29 tests continue to pass. Co-Authored-By: Claude Sonnet 4.6 --- lib/dsl.rb | 21 +++++++++++++++++++++ lib/dslkeywords/directory.rb | 10 +--------- lib/dslkeywords/file.rb | 9 +-------- lib/dslkeywords/symlink.rb | 8 +------- lib/dslkeywords/touch.rb | 8 +------- 5 files changed, 25 insertions(+), 31 deletions(-) diff --git a/lib/dsl.rb b/lib/dsl.rb index f4e34ac..d80c701 100644 --- a/lib/dsl.rb +++ b/lib/dsl.rb @@ -46,6 +46,27 @@ module RCM @scheduled << @@objs[obj.id] = obj end + + private + + # Shared helper for all file-system keyword registrations. + # Returns the keyword symbol when called without a path (used by the + # Chained DSL to identify resource types without creating an object). + # Otherwise guards on @conds_met, instantiates klass, lets the caller + # configure the object, registers it, and returns it. + # + # The block is always yielded — callers that accept an optional DSL + # block must guard for nil themselves inside the closure, e.g. + # register_keyword(Touch, :touch, path) { |t| t.instance_eval(&block) if block } + def register_keyword(klass, name, path) + return name if path.nil? + return unless @conds_met + + obj = klass.new(path) + yield obj + self << obj + obj + end end end diff --git a/lib/dslkeywords/directory.rb b/lib/dslkeywords/directory.rb index 6449d74..072c88a 100644 --- a/lib/dslkeywords/directory.rb +++ b/lib/dslkeywords/directory.rb @@ -100,15 +100,7 @@ module RCM class DSL def directory(file_path = nil, &block) - return :directory if file_path.nil? - return unless @conds_met - - d = Directory.new(file_path) - # Use source= for the recursive-copy source path rather than content=, - # keeping Directory's interface clean and purpose-named. - d.source(d.instance_eval(&block)) - self << d - d + register_keyword(Directory, :directory, file_path) { |d| d.source(d.instance_eval(&block)) } end end end diff --git a/lib/dslkeywords/file.rb b/lib/dslkeywords/file.rb index dc6f4d0..8e1c772 100644 --- a/lib/dslkeywords/file.rb +++ b/lib/dslkeywords/file.rb @@ -209,15 +209,8 @@ module RCM end class DSL - # Add file keyword to the DSL def file(file_path = nil, &block) - return :file if file_path.nil? - return unless @conds_met - - f = File.new(file_path) - f.content(f.instance_eval(&block)) - self << f - f + register_keyword(File, :file, file_path) { |f| f.content(f.instance_eval(&block)) } end end end diff --git a/lib/dslkeywords/symlink.rb b/lib/dslkeywords/symlink.rb index 160da6f..053e83d 100644 --- a/lib/dslkeywords/symlink.rb +++ b/lib/dslkeywords/symlink.rb @@ -22,13 +22,7 @@ module RCM class DSL def symlink(file_path = nil, &block) - return :symlink if file_path.nil? - return unless @conds_met - - s = Symlink.new(file_path) - s.content(s.instance_eval(&block)) - self << s - s + register_keyword(Symlink, :symlink, file_path) { |s| s.content(s.instance_eval(&block)) } end end end diff --git a/lib/dslkeywords/touch.rb b/lib/dslkeywords/touch.rb index 27e691b..13d63f7 100644 --- a/lib/dslkeywords/touch.rb +++ b/lib/dslkeywords/touch.rb @@ -26,13 +26,7 @@ module RCM class DSL def touch(file_path = nil, &block) - return :touch if file_path.nil? - return unless @conds_met - - t = Touch.new(file_path) - t.instance_eval(&block) if block - self << t - t + register_keyword(Touch, :touch, file_path) { |t| t.instance_eval(&block) if block } end end end -- cgit v1.2.3 From 1ef38a857c895e5f970d219ba6802b2627801620 Mon Sep 17 00:00:00 2001 From: Paul Buetow Date: Sun, 1 Mar 2026 23:31:56 +0200 Subject: refactor: replace ObjectSpace scan with inherited-hook registry ResourceDependencies#initialize iterated every live object in the Ruby heap (ObjectSpace.each_object(Class)) to discover Resource subclasses, which is O(heap), non-deterministic, and load-order-dependent (subclasses loaded after an instance was created were invisible to it). Add Resource.inherited + @@subclass_names so each subclass self-registers at load time. ResourceDependencies#initialize now just assigns the shared (frozen) registry reference instead of scanning ObjectSpace. Benefits: - O(1) constructor path instead of O(heap) - Load-order safe: new subclasses are visible to all instances immediately - Deterministic: set is built in require order, not GC-dependent order Also fix the "recource" typo in the ResourceDependencies comment. Co-Authored-By: Claude Sonnet 4.6 --- lib/dslkeywords/resource.rb | 23 ++++++++++++++++++----- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/lib/dslkeywords/resource.rb b/lib/dslkeywords/resource.rb index 143815f..c1c2554 100644 --- a/lib/dslkeywords/resource.rb +++ b/lib/dslkeywords/resource.rb @@ -20,15 +20,14 @@ module RCM end end - # To track recource dependencies + # To track resource dependencies module ResourceDependencies def initialize(...) super(...) @requires = Set.new - @valid_resources = Set.new - ObjectSpace.each_object(Class).each do |klass| - @valid_resources << klass.to_s.sub('RCM::', '').downcase.to_sym if klass < Resource - end + # Use the class-level registry (populated via Resource.inherited) rather + # than scanning ObjectSpace — deterministic, load-order-safe, and O(1). + @valid_resources = Resource.subclass_names end def method_missing(method_name, *args) @@ -93,6 +92,20 @@ module RCM class NoSuchResourceObject < StandardError; end @@resource_find_cache = {} + # Class-level registry: every subclass is registered here when it is + # first loaded (via the inherited hook), so ResourceDependencies can + # look up valid keyword names without scanning ObjectSpace. + @@subclass_names = Set.new + + def self.inherited(subclass) + super + @@subclass_names << subclass.to_s.sub('RCM::', '').downcase.to_sym + end + + # Return a frozen snapshot so callers cannot accidentally mutate the + # shared registry through the @valid_resources instance variable. + def self.subclass_names = @@subclass_names.freeze + def self.find(id) return @@resource_find_cache[id] if @@resource_find_cache.key?(id) -- cgit v1.2.3 From 211e3650387bd299bfcc6d21b7230323e45217c2 Mon Sep 17 00:00:00 2001 From: Paul Buetow Date: Sun, 1 Mar 2026 23:37:42 +0200 Subject: =?UTF-8?q?refactor:=20defer=20Options=20parsing=20=E2=80=94=20add?= =?UTF-8?q?=20explicit=20Options.parse!=20method?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Parsing ARGV at module eval time (during require) is fragile: it runs before the application is ready, can pick up test-runner flags, and bleeds stale values across repeated configure calls in tests. Move the OptionParser setup and ARGV slicing into Options.parse!. The module body now only sets safe defaults. parse! resets to defaults before each parse so state cannot accumulate across calls. Call Options.parse! once at the top of configure() in dsl.rb so all entry points (scripts, Rake tasks) still receive parsed options without any caller needing to know about the details. Co-Authored-By: Claude Sonnet 4.6 --- lib/dsl.rb | 4 ++++ lib/options.rb | 47 +++++++++++++++++++++++++++++------------------ 2 files changed, 33 insertions(+), 18 deletions(-) diff --git a/lib/dsl.rb b/lib/dsl.rb index d80c701..6185c98 100644 --- a/lib/dsl.rb +++ b/lib/dsl.rb @@ -71,6 +71,10 @@ module RCM end def configure(reset: false, &block) + # Parse ARGV each time configure is called so that scripts which call + # configure multiple times (or test suites that reset state) always + # start with a fresh, consistent option set. + RCM::Options.parse! RCM::DSL.new(reset) do |rcm| rcm.info('Configuring...') rcm.instance_eval(&block) diff --git a/lib/options.rb b/lib/options.rb index 6ce2479..41a6a50 100644 --- a/lib/options.rb +++ b/lib/options.rb @@ -4,30 +4,41 @@ module RCM # Command line options, supports both Rake mode (args after --) # and standalone mode (direct args). Unknown options are ignored # so that test runners and other tools can pass their own flags. + # + # Defaults are set at module load time. Call Options.parse! once at + # the application entry point to overlay them with actual ARGV values. + # Tests that never call parse! safely get the default values. module Options @@options = { debug: false, dry: false, hosts: [] } - parser = OptionParser.new do |opts| - opts.banner = 'Usage: rake [task] -- [options] OR ruby config.rb [options]' - opts.on('-v', '--[no-]debug', 'debug output') { |v| @@options[:debug] = v } - opts.on('-d', '--dry', 'dry mode') { |v| @@options[:dry] = v } - opts.on('--hosts HOSTS', 'comma-separated list of target hostnames') do |v| - @@options[:hosts] = v.split(',').map(&:strip) + # Parse ARGV and update @@options. Resets to defaults before each + # parse so stale values cannot accumulate across repeated calls + # (e.g. between test cases). + def self.parse! + @@options = { debug: false, dry: false, hosts: [] } + + parser = OptionParser.new do |opts| + opts.banner = 'Usage: rake [task] -- [options] OR ruby config.rb [options]' + opts.on('-v', '--[no-]debug', 'debug output') { |v| @@options[:debug] = v } + opts.on('-d', '--dry', 'dry mode') { |v| @@options[:dry] = v } + opts.on('--hosts HOSTS', 'comma-separated list of target hostnames') do |v| + @@options[:hosts] = v.split(',').map(&:strip) + end end - end - # Rake passes args after '--'; standalone scripts pass args directly. - args = if ARGV.include?('--') - ARGV.slice_before('--').to_a.last.drop(1) - else - ARGV.dup - end + # Rake passes args after '--'; standalone scripts pass args directly. + args = if ARGV.include?('--') + ARGV.slice_before('--').to_a.last.drop(1) + else + ARGV.dup + end - # Ignore unknown options (e.g. from test runners or other tools) - begin - parser.parse!(args) - rescue OptionParser::InvalidOption - retry + # Ignore unknown options (e.g. flags from test runners or rake itself). + begin + parser.parse!(args) + rescue OptionParser::InvalidOption + retry + end end def option(key) -- cgit v1.2.3 From 9a6526a7022c1d1172c1be9b9b3545ed6e00c9e6 Mon Sep 17 00:00:00 2001 From: Paul Buetow Date: Sun, 1 Mar 2026 23:41:55 +0200 Subject: =?UTF-8?q?refactor:=20defer=20Config=20loading=20=E2=80=94=20add?= =?UTF-8?q?=20explicit=20Config.load!=20method?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Loading config.toml at module eval time (during require) runs before the application's working directory is set, can slow down tests that don't use config at all, and gives no way to reload between calls. Move the load logic into Config.load! (mirrors the Options.parse! pattern added in the previous commit). The module body now just initialises @@config to {}. Call Config.load! alongside Options.parse! at the top of configure() so all entry points reload from the correct directory. Also qualify File as ::File inside the RCM module to avoid resolving it as RCM::File once the keyword files are loaded. Co-Authored-By: Claude Sonnet 4.6 --- lib/config.rb | 21 ++++++++++++++++----- lib/dsl.rb | 7 ++++--- 2 files changed, 20 insertions(+), 8 deletions(-) diff --git a/lib/config.rb b/lib/config.rb index 2a13ae0..fa43c4f 100644 --- a/lib/config.rb +++ b/lib/config.rb @@ -8,12 +8,23 @@ end module RCM # Configuration — config.toml is optional. If the toml gem is not installed # or no config.toml exists, config() will raise a helpful error when called. + # + # Config is not loaded at module load time. Call Config.load! once at the + # application entry point (e.g. from configure) before calling config(). + # Tests that don't use config() don't need config.toml at all. module Config - @@config = if TOML_AVAILABLE && File.exist?('config.toml') - TOML.load_file('config.toml') - else - {} - end + @@config = {} + + # Load (or reload) config.toml from the current working directory. + # Falls back to an empty hash when the toml gem is unavailable or the + # file does not exist, so callers that never invoke config() are unaffected. + def self.load! + @@config = if TOML_AVAILABLE && ::File.exist?('config.toml') + TOML.load_file('config.toml') + else + {} + end + end def config(key) raise "No such config key: #{key}" unless @@config.key?(key) diff --git a/lib/dsl.rb b/lib/dsl.rb index 6185c98..e3f6ee2 100644 --- a/lib/dsl.rb +++ b/lib/dsl.rb @@ -71,10 +71,11 @@ module RCM end def configure(reset: false, &block) - # Parse ARGV each time configure is called so that scripts which call - # configure multiple times (or test suites that reset state) always - # start with a fresh, consistent option set. + # Parse ARGV and load config.toml each time configure is called so that + # scripts and test suites that call configure multiple times always + # start from a consistent, freshly-loaded state. RCM::Options.parse! + RCM::Config.load! RCM::DSL.new(reset) do |rcm| rcm.info('Configuring...') rcm.instance_eval(&block) -- cgit v1.2.3 From 3f833b7f82bde48ceb20f4b90407afc928ebe7b3 Mon Sep 17 00:00:00 2001 From: Paul Buetow Date: Sun, 1 Mar 2026 23:52:17 +0200 Subject: bugfix/refactor: add error handling to DNFPackageManager shell commands system() returns false/nil on failure but the original code ignored the return value, silently swallowing dnf errors. Add a private run_dnf! helper that checks the return value and raises DNFPackageManager::CommandFailed with the dnf exit code on failure. Replace the one-liner methods with full method bodies that delegate to run_dnf!. Also fix two latent constant-resolution bugs: - Use $? instead of $CHILD_STATUS (the latter requires `require 'English'` and is always nil without it) - Use ::File instead of File in Package#initialize to avoid resolving to RCM::File once file.rb is loaded Co-Authored-By: Claude Sonnet 4.6 --- lib/dslkeywords/package.rb | 41 ++++++++++++++++++++++++++++++++++------- 1 file changed, 34 insertions(+), 7 deletions(-) diff --git a/lib/dslkeywords/package.rb b/lib/dslkeywords/package.rb index d528ae8..9e4324f 100644 --- a/lib/dslkeywords/package.rb +++ b/lib/dslkeywords/package.rb @@ -5,14 +5,40 @@ require_relative 'resource' module RCM class DNFPackageManager + # Raised when a dnf subcommand exits with a non-zero status or when + # the dnf binary cannot be found. + class CommandFailed < StandardError; end + def installed?(pkg) = false - # Use system() with separate arguments to avoid shell injection — - # backtick interpolation passes the command through a shell, which - # allows metacharacters in pkg names to execute arbitrary commands. - def install(pkg) = system('dnf', 'install', '-y', pkg) unless installed?(pkg) - def update(pkg) = system('dnf', 'update', '-y', pkg) - def remove(pkg) = system('dnf', 'remove', '-y', pkg) if installed?(pkg) + def install(pkg) + return if installed?(pkg) + + run_dnf!('install', pkg) + end + + def update(pkg) + run_dnf!('update', pkg) + end + + def remove(pkg) + return unless installed?(pkg) + + run_dnf!('remove', pkg) + end + + private + + # Execute dnf -y using separate arguments (no shell + # interpolation). Raises CommandFailed when dnf exits non-zero or is + # not found ($? is nil when the binary cannot be exec'd). + def run_dnf!(subcommand, pkg) + result = system('dnf', subcommand, '-y', pkg) + return if result + + exit_code = $?&.exitstatus || '?' + raise CommandFailed, "dnf #{subcommand} #{pkg} failed (exit #{exit_code})" + end end # Managing packages @@ -23,7 +49,8 @@ module RCM def initialize(name) super(name) - raise UnsupportedOS, 'OS is not supported' unless File.file?('/etc/fedora-release') + # Use ::File to avoid resolving to RCM::File once file.rb is loaded. + raise UnsupportedOS, 'OS is not supported' unless ::File.file?('/etc/fedora-release') @manager = DNFPackageManager.new -- cgit v1.2.3 From 5b8ce0b75271af6b4799800178ab3039d97c47b7 Mon Sep 17 00:00:00 2001 From: Paul Buetow Date: Mon, 2 Mar 2026 08:34:26 +0200 Subject: add Justfiles for all examples Each example directory gets a Justfile with run/dry/debug recipes. The rake and gem examples also include a setup recipe for bundle install. The cli example adds a hosts recipe for the --hosts flag. Co-Authored-By: Claude Sonnet 4.6 --- examples/cli/Justfile | 17 +++++++++++++++++ examples/gem/Gemfile.lock | 2 +- examples/gem/Justfile | 15 +++++++++++++++ examples/plain_ruby/Justfile | 11 +++++++++++ examples/rake/Justfile | 15 +++++++++++++++ 5 files changed, 59 insertions(+), 1 deletion(-) create mode 100644 examples/cli/Justfile create mode 100644 examples/gem/Justfile create mode 100644 examples/plain_ruby/Justfile create mode 100644 examples/rake/Justfile diff --git a/examples/cli/Justfile b/examples/cli/Justfile new file mode 100644 index 0000000..4c81601 --- /dev/null +++ b/examples/cli/Justfile @@ -0,0 +1,17 @@ +rcm := "../../bin/rcm" + +# Apply configuration +run: + {{rcm}} config.rb + +# Dry run — show what would change without making changes +dry: + {{rcm}} config.rb --dry + +# Verbose output +debug: + {{rcm}} config.rb --debug + +# Limit execution to specific hosts (comma-separated, e.g. just hosts earth,mars) +hosts target: + {{rcm}} config.rb --hosts {{target}} diff --git a/examples/gem/Gemfile.lock b/examples/gem/Gemfile.lock index 502fccc..f426524 100644 --- a/examples/gem/Gemfile.lock +++ b/examples/gem/Gemfile.lock @@ -1,7 +1,7 @@ PATH remote: ../.. specs: - rcm (0.1.0) + rcm (0.1.1) erb toml (~> 0.3) diff --git a/examples/gem/Justfile b/examples/gem/Justfile new file mode 100644 index 0000000..005267f --- /dev/null +++ b/examples/gem/Justfile @@ -0,0 +1,15 @@ +# Install gem dependencies +setup: + bundle install + +# Apply configuration +run: + bundle exec ruby config.rb + +# Dry run — show what would change without making changes +dry: + bundle exec ruby config.rb --dry + +# Verbose output +debug: + bundle exec ruby config.rb --debug diff --git a/examples/plain_ruby/Justfile b/examples/plain_ruby/Justfile new file mode 100644 index 0000000..c758519 --- /dev/null +++ b/examples/plain_ruby/Justfile @@ -0,0 +1,11 @@ +# Apply configuration +run: + ruby config.rb + +# Dry run — show what would change without making changes +dry: + ruby config.rb --dry + +# Verbose output +debug: + ruby config.rb --debug diff --git a/examples/rake/Justfile b/examples/rake/Justfile new file mode 100644 index 0000000..e25852a --- /dev/null +++ b/examples/rake/Justfile @@ -0,0 +1,15 @@ +# Install gem dependencies +setup: + bundle install + +# Apply configuration +run: + rake setup + +# Dry run — show what would change without making changes +dry: + rake setup -- --dry + +# Verbose output +debug: + rake setup -- --debug -- cgit v1.2.3