File generate_spec.rb of Package smar-apparmor-profiles

#!/usr/bin/env ruby

# Generates spec so manual repetition is not necessary

require "fileutils"
require "pathname"
require "yaml"

require_relative "systemd_override"

def set_log_level(&block)
    # Set to true to enable trace messages.
    trace = false
    #trace = true

    # Set to true to enable debug messages.
    debug = false
    debug = true if trace
    #debug = true

    block.call trace, debug
end

set_log_level do |trace, debug|
    TRACE = trace
    DEBUG = debug
end

def verb(message)
    puts "[INFO] #{message}"
end

def debug(message)
    puts "[DEBUG] #{message}" if DEBUG
end

def trace(message)
    puts "[TRACE] #{message}" if TRACE
end

class GenerateSpec
    INSTALL_RULES_TARGET_LINE = "___INSTALL_RULES_HERE___"
    PACKAGES_TARGET_LINE = "___PACKAGES_HERE___"
    INPUT_FILE = "smar-apparmor-profiles.spec.in"
    OUTPUT_FILE = "smar-apparmor-profiles.spec"

    def initialize
        @packages = load_packages
        @systemd_override = SystemdOverride.new
    end

    def generate
        install_line_rows = generate_install_lines
        package_rows = generate_package_rows

        lines = File.readlines INPUT_FILE

        lines.each_with_index do |line, line_index|
            line.strip!

            if line == INSTALL_RULES_TARGET_LINE
                # Replace target first to avoid infinite loop.
                lines[line_index] = "## Generated install commands starts."
                lines[line_index+=1] = ""

                install_line_rows.each_with_index do |row, row_index|
                    lines.insert((line_index + row_index), row)
                end
            end
        end

        # Loop twice so that line indices are correct.
        lines.each_with_index do |line, line_index|
            line.strip!

            if line == PACKAGES_TARGET_LINE
                # Replace target first to avoid infinite loop.
                lines[line_index] = "## Generated packages starts."
                lines[line_index+=1] = ""

                package_rows.each_with_index do |package, index|
                    trace "index: #{index}, package: “#{package}”"
                    lines.insert((line_index + index), package)

                    # Append newline after package rows.
                    line_index += 1
                    lines.insert((line_index + index), "\n")
                end
            end
        end

        File.open OUTPUT_FILE, "w" do |file|
            file.puts lines
        end
    end

    def finalize
        @systemd_override.finalize
    end

    private def generate_install_lines
        out = []

        @packages.each do |package|
            definition = generate_install_line package

            out << definition.join("\n")
        end

        out
    end

    private def generate_install_line package
        trace "Generating install line for #{package}"

        definition = []
        package["files"]&.each do |file|
            definition << "mv profiles/#{file} %{buildroot}%{_sysconfdir}/apparmor.d"
        end
        package["local"]&.each do |file|
            definition << "mv profiles/local/#{file} %{buildroot}%{_sysconfdir}/apparmor.d/local"
        end
        package["namespaces"]&.each do |file|
            unless File.symlink? file
                definition << "mv namespaces/#{file} %{buildroot}%{_sysconfdir}/apparmor.d/namespaces.d"
            else
                # If a file is a symlink, it should be added manually
                # by the package where it is a symlink to.
                warn "File #{file} needs to be owned manually (1)."
            end
        end
        package["namespace_directories"]&.each do |directory|
            definition << "mv namespaces/#{directory}/ %{buildroot}%{_sysconfdir}/apparmor.d/namespaces.d"
        end
        package["included_abstractions"]&.each do |file|
            definition << "mv abstractions/#{file} %{buildroot}%{_sysconfdir}/apparmor.d/abstractions"
        end
        package["included_tunables"]&.each do |file|
            definition << "mv tunables/#{file} %{buildroot}%{_sysconfdir}/apparmor.d/tunables"
        end
        package["extra_files"]&.each do |directory, files|
            extra_files_line definition, directory, files
        end
        package["load_profile_by_systemd"]&.each do |service_name, profile_name|
            @systemd_override.install_lines_for_load_profile_by_systemd definition, package, service_name, profile_name
        end

        package["in_directory"]&.each do |target, data|
            in_directory_lines definition, target, data, parent_package: package
        end

        definition
    end

    private def in_directory_lines definition, target, package, parent_package:
        unless package.respond_to? :each_pair
            STDERR.puts "Syntax error in a YAML config."
            STDERR.puts "Package “#{parent_package["name"]}” has invalid content in block “in_directory”:"
            STDERR.puts
            STDERR.puts package.inspect
            STDERR.puts
            STDERR.puts "Maybe you’re missing “files:” keyword to denote a hash?"
            exit 20
        end
        package["files"]&.each do |file|
            definition << "mv profiles/#{target}/#{file} %{buildroot}%{_sysconfdir}/apparmor.d"
        end
        package["local"]&.each do |file|
            definition << "mv profiles/#{target}/local/#{file} %{buildroot}%{_sysconfdir}/apparmor.d/local"
        end
        package["included_abstractions"]&.each do |file|
            definition << "mv profiles/#{target}/abstractions/#{file} %{buildroot}%{_sysconfdir}/apparmor.d/abstractions"
        end
        package["included_tunables"]&.each do |file|
            definition << "mv profiles/#{target}/tunables/#{file} %{buildroot}%{_sysconfdir}/apparmor.d/tunables"
        end
        if package["extra_files"]
            extra_files_line definition, "profiles/#{target}", package["extra_files"]
        end
        if package["rpm_scriptlets_symlinks"]
            rpm_scriptlets_symlinks definition, package["rpm_scriptlets_symlinks"]
        end
    end

    private def extra_files_line definition, directory, files
        case directory
        when "ssh"
            directory = "profiles/security/ssh"
            files.each do |file|
                destination_dir = File.dirname file
                definition << "mkdir -p '%{buildroot}%{_sysconfdir}/apparmor.d/#{destination_dir}'"
                definition << "mv #{directory}/#{file} '%{buildroot}%{_sysconfdir}/apparmor.d/#{destination_dir}'"
            end
        when "gcc"
            directory = "profiles/compilation"
            files.each do |file|
                destination_dir = File.dirname file
                definition << "mkdir -p '%{buildroot}%{_sysconfdir}/apparmor.d/#{destination_dir}'"
                definition << "mv #{directory}/#{file} '%{buildroot}%{_sysconfdir}/apparmor.d/#{destination_dir}'"
            end
        when "shells"
            directory = "profiles/system/shells"
            files.each do |file|
                destination_dir = File.dirname file
                definition << "mkdir -p '%{buildroot}%{_sysconfdir}/apparmor.d/#{destination_dir}'"
                definition << "mv #{directory}/#{file} '%{buildroot}%{_sysconfdir}/apparmor.d/#{destination_dir}'"
            end
        else
            add_lines = lambda do |file|
                if file.respond_to? :to_hash
                    # Special case.

                    unless file.size == 1
                        raise "Unrecognized hash value: #{file.inspect}"
                    end
                    source_file, target_file = file.each_pair.next

                    destination_dir = File.dirname target_file
                    definition << "mkdir -p '%{buildroot}#{destination_dir}'"
                    definition << "mv #{directory}/#{source_file} '%{buildroot}#{target_file}'"
                else
                    # Normal case.
                    destination_dir = File.dirname file
                    definition << "mkdir -p '%{buildroot}%{_sysconfdir}/apparmor.d/#{destination_dir}'"
                    definition << "mv #{directory}/#{file} '%{buildroot}%{_sysconfdir}/apparmor.d/#{destination_dir}'"
                end
            end

            # This check allows similar style to what in_directory
            # block’s extra_files allows.
            if files.nil?
                file = directory
                directory = "." # Root of the repository.

                add_lines.call file
                return
            end

            files.each do |file|
                add_lines.call file
            end
        end
    end

    private def rpm_scriptlets_symlinks definition, files
        files.each do |file|
            rpm_scriptlets_d = "%{buildroot}%{_sysconfdir}/apparmor.d/namespaces.d/rpm-scriptlets.d"
            profile_name = File.basename file

            definition << %{ln -t '#{rpm_scriptlets_d}' -s '../../#{profile_name}'}
        end
    end

    # Generates the package rows that are inserted to template spec.
    private def generate_package_rows
        out = []

        @packages.each do |package|
            definition = <<~EOF
            # #{package["name"]}
            %package -n #{package["name"]}-profiles
            Summary:        AppArmor profiles for #{package["name"]}
            Supplements:    #{package["name"]}
            BuildArch:      noarch
            EOF

            package["provides"]&.each do |provided|
                definition += "Provides: #{provided}-profiles\n"
            end

            package["abstractions"]&.each do |abstraction|
                definition += "Requires: smar-apparmor-profiles-#{abstraction}-abstractions\n"
            end

            package["supplements"]&.each do |supplement|
                definition += "Supplements: #{supplement}\n"
            end

            package["requires"]&.each do |required|
                name = "#{required}-profiles"
                name = "smar-apparmor-profiles-common" if required == "common"
                definition += "Requires: #{name}\n"
            end

            package["recommends"]&.each do |recommended|
                name = "#{recommended}-profiles"
                name = "smar-apparmor-profiles-common" if recommended == "common"
                definition += "Recommends: #{name}\n"
            end

            package["suggests"]&.each do |suggested|
                name = "#{suggested}-profiles"
                name = "smar-apparmor-profiles-common" if suggested == "common"
                definition += "Suggests: #{name}\n"
            end

            file_list_clause = %|%files -n #{package["name"]}-profiles|
            if package["namespace_directories"] && !package["namespace_directories"].empty?
                raise "This is for now only hacked for rpm-scriptlets. I think." unless package["name"] == "rpm"
                file_list_clause = %|#{file_list_clause} -f namespace_files.#{package["name"]}|
            end

            definition += <<~EOF
            %description -n #{package["name"]}-profiles
            AppArmor profiles for #{package["name"]} from project smar-apparmor-profiles.

            #{file_list_clause}
            EOF


            definition = generate_file_rows definition, package

            out << definition
        end

        out
    end

    private def generate_file_rows definition, package
        package["files"]&.each do |file|
            basename = File.basename file
            definition += "%config %{_sysconfdir}/apparmor.d/#{basename}\n"
        end

        package["local"]&.each do |file|
            basename = File.basename file
            definition += "%config(noreplace) %{_sysconfdir}/apparmor.d/local/#{basename}\n"
        end

        package["namespaces"]&.each do |file|
            unless File.symlink? file
                basename = File.basename file
                definition += "%config %{_sysconfdir}/apparmor.d/namespaces.d/#{basename}\n"
            else
                # If a file is a symlink, it should be added manually
                # by the package where it is a symlink to.
                warn "File #{file} needs to be owned manually (2)."
            end
        end

        package["namespace_directories"]&.each do |directory|
            basename = File.basename directory
            definition += "%dir %{_sysconfdir}/apparmor.d/namespaces.d/#{basename}\n"
            #definition += "%config %{_sysconfdir}/apparmor.d/namespaces.d/#{basename}\n"
        end

        package["included_abstractions"]&.each do |file|
            basename = File.basename file
            definition += "%config(noreplace) %{_sysconfdir}/apparmor.d/abstractions/#{basename}\n"
        end

        package["included_tunables"]&.each do |file|
            basename = File.basename file
            definition += "%config(noreplace) %{_sysconfdir}/apparmor.d/tunables/#{basename}\n"
        end

        package["extra_directories"]&.each do |directory|
            if directory.respond_to? :to_hash
                # Special case.

                unless directory.size == 1
                    raise "Unrecognized hash value for extra_directories: #{directory.inspect}"
                end
                tag, target_directory = directory.each_pair.next

                unless tag == "absolute_path"
                    raise "Only absolute_path is supported for tag value for extra_directories. directory: #{directory.inspect}"
                end

                definition += "%dir #{target_directory}\n"
            else
                # Normal case.
                definition += "%dir %{_sysconfdir}/apparmor.d/#{directory}\n"
            end
        end

        package["extra_files"]&.each do |directory, files|
            config_tag = lambda do |filename|
                if filename[0..5] == "local/"
                    "%config(noreplace)"
                else
                    "%config"
                end
            end

            append_to_definition = lambda do |file|
                unless file.respond_to? :to_hash
                    # Normal case.
                    definition += "#{config_tag.call(file)} %{_sysconfdir}/apparmor.d/#{file}\n"
                    return
                end

                # Special case.

                unless file.size == 1
                    raise "Unrecognized hash value for extra_files: #{file.inspect}"
                end
                source_file, target_file = file.each_pair.next

                definition += "#{config_tag.call(file)} #{target_file}\n"
            end

            if files.nil?
                file = directory
                append_to_definition.call file
                next
            end

            files.each do |file|
                append_to_definition.call file
            end
        end

        package["load_profile_by_systemd"]&.each do |service_name, profile_name|
            definition += load_profile_by_systemd_for_profile service_name, profile_name
        end

        package["rpm_scriptlets_symlinks"]&.each do |file|
            basename = File.basename file
            definition += "%config %{_sysconfdir}/apparmor.d/namespaces.d/rpm-scriptlets.d/#{basename}\n"
        end

        package["in_directory"]&.each do |directory, hashes|
            hashes.each do |name, values|
                hash = { name => values }
                definition = generate_file_rows definition, hash
            end
        end

        definition
    end

    private def load_profile_by_systemd_for_profile service_name, profile_name
        definition = ""

        if [ "system", "system_conditional" ].include? service_name
            profile_name.each do |service_name, profile_name|
                if profile_name.respond_to? :each_pair
                    profile_name = profile_name["profile"]
                end

                definition += "%dir %{_unitdir}/#{service_name}.service.d\n"
                definition += "%{_unitdir}/#{service_name}.service.d/#{profile_name}.conf\n"
            end

            return definition
        end

        #if [ "user", "user_conditional" ].include? service_name
        if service_name == "user"
            profile_name.each do |service_name, profile_name|
                if profile_name.respond_to? :each_pair
                    profile_name = profile_name["profile"]
                end

                definition += "%dir %{_userunitdir}/#{service_name}.service.d\n"
                definition += "%{_userunitdir}/#{service_name}.service.d/#{profile_name}.conf\n"
            end

            return definition
        end

        definition += "%dir %{_unitdir}/#{service_name}.service.d\n"
        definition += "%{_unitdir}/#{service_name}.service.d/#{profile_name}.conf\n"

        definition
    end

    # WARNING: This is not too safe to use, so trust the yaml files.
    private def load_packages
        main = YAML.load_file "main.yaml"

        packages = []

        main["includes"].each do |file|
            packages += YAML.load_file(file)
        end

        packages.sort do |a, b|
            # rpm needs to be first as some other profiles requires
            # namespaces.d/rpm-scriptlets.d/ dir it creates.
            next -1 if a["name"] == "rpm"
            next 1 if b["name"] == "rpm"

            a["name"] <=> b["name"]
        end
    end
end

generate_spec = GenerateSpec.new
generate_spec.generate
generate_spec.finalize