wheneverd 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. checksums.yaml +4 -4
  2. data/.ruby-version +1 -0
  3. data/CHANGELOG.md +16 -0
  4. data/Gemfile.lock +38 -33
  5. data/README.md +64 -7
  6. data/lib/wheneverd/cli/activate.rb +5 -5
  7. data/lib/wheneverd/cli/deactivate.rb +6 -6
  8. data/lib/wheneverd/cli/reload.rb +8 -8
  9. data/lib/wheneverd/cli/status.rb +13 -6
  10. data/lib/wheneverd/cli.rb +10 -8
  11. data/lib/wheneverd/dsl/context.rb +46 -0
  12. data/lib/wheneverd/dsl/period_parser.rb +8 -107
  13. data/lib/wheneverd/dsl/period_strategy/array_strategy.rb +29 -0
  14. data/lib/wheneverd/dsl/period_strategy/base.rb +65 -0
  15. data/lib/wheneverd/dsl/period_strategy/duration_strategy.rb +33 -0
  16. data/lib/wheneverd/dsl/period_strategy/string_strategy.rb +51 -0
  17. data/lib/wheneverd/dsl/period_strategy/symbol_strategy.rb +31 -0
  18. data/lib/wheneverd/dsl/period_strategy.rb +43 -0
  19. data/lib/wheneverd/duration.rb +1 -7
  20. data/lib/wheneverd/errors.rb +3 -0
  21. data/lib/wheneverd/interval.rb +22 -7
  22. data/lib/wheneverd/schedule.rb +15 -1
  23. data/lib/wheneverd/service.rb +105 -0
  24. data/lib/wheneverd/systemd/cron_parser/dow_parser.rb +208 -0
  25. data/lib/wheneverd/systemd/cron_parser/field_parser.rb +163 -0
  26. data/lib/wheneverd/systemd/cron_parser.rb +56 -303
  27. data/lib/wheneverd/systemd/renderer.rb +31 -66
  28. data/lib/wheneverd/systemd/unit_content_builder.rb +99 -0
  29. data/lib/wheneverd/systemd/unit_deleter.rb +2 -28
  30. data/lib/wheneverd/systemd/unit_lister.rb +2 -28
  31. data/lib/wheneverd/systemd/unit_namer.rb +6 -15
  32. data/lib/wheneverd/systemd/unit_path_utils.rb +54 -0
  33. data/lib/wheneverd/systemd/unit_writer.rb +2 -28
  34. data/lib/wheneverd/trigger/base.rb +22 -0
  35. data/lib/wheneverd/trigger/boot.rb +8 -6
  36. data/lib/wheneverd/trigger/calendar.rb +7 -0
  37. data/lib/wheneverd/trigger/interval.rb +8 -6
  38. data/lib/wheneverd/validation.rb +89 -0
  39. data/lib/wheneverd/version.rb +1 -1
  40. data/lib/wheneverd.rb +5 -1
  41. data/test/cli_activate_test.rb +27 -0
  42. data/test/cli_reload_test.rb +23 -0
  43. data/test/cli_status_test.rb +14 -4
  44. data/test/domain_model_test.rb +105 -0
  45. data/test/dsl_context_shell_test.rb +31 -0
  46. data/test/systemd_cron_parser_test.rb +41 -25
  47. data/test/systemd_renderer_errors_test.rb +1 -1
  48. data/test/systemd_renderer_test.rb +73 -0
  49. metadata +16 -2
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "calendar_symbol_period_list"
3
+ require_relative "period_strategy"
4
4
 
5
5
  module Wheneverd
6
6
  module DSL
@@ -8,24 +8,12 @@ module Wheneverd
8
8
  #
9
9
  # Supported period forms are described in the README.
10
10
  #
11
- # Notes:
12
- #
13
- # - Interval strings and {Wheneverd::Duration} values produce monotonic triggers
14
- # ({Wheneverd::Trigger::Interval}).
15
- # - Calendar symbol periods and cron strings produce calendar triggers
16
- # ({Wheneverd::Trigger::Calendar}).
17
- # - The `at:` option is only valid for calendar triggers, with a convenience exception for
18
- # `every 1.day, at: ...` which is treated as a daily calendar trigger.
11
+ # Uses a strategy pattern to delegate parsing to specialized strategy classes:
12
+ # - {PeriodStrategy::DurationStrategy} for Duration values
13
+ # - {PeriodStrategy::StringStrategy} for interval strings and cron expressions
14
+ # - {PeriodStrategy::SymbolStrategy} for calendar symbols
15
+ # - {PeriodStrategy::ArrayStrategy} for arrays of calendar symbols
19
16
  class PeriodParser
20
- DAY_SECONDS = 60 * 60 * 24
21
- REBOOT_NOT_SUPPORTED_MESSAGE =
22
- "The :reboot period is not supported; use an interval or calendar period instead"
23
-
24
- CALENDAR_SYMBOLS = %i[
25
- hour day month year weekday weekend
26
- monday tuesday wednesday thursday friday saturday sunday
27
- ].freeze
28
-
29
17
  attr_reader :path
30
18
 
31
19
  # @param path [String] schedule path for error reporting
@@ -38,98 +26,11 @@ module Wheneverd
38
26
  # @return [Wheneverd::Trigger::Interval, Wheneverd::Trigger::Calendar]
39
27
  def trigger_for(period, at:)
40
28
  at_times = AtNormalizer.normalize(at, path: path)
41
- trigger_for_period(period, at_times: at_times)
29
+ strategy = PeriodStrategy.for(period, path: path)
30
+ strategy.parse(period, at_times: at_times)
42
31
  rescue Wheneverd::InvalidIntervalError => e
43
32
  raise InvalidPeriodError.new(e.message, path: path)
44
33
  end
45
-
46
- private
47
-
48
- def trigger_for_period(period, at_times:)
49
- return duration_trigger_for(period, at_times: at_times) if period.is_a?(Wheneverd::Duration)
50
- return array_trigger_for(period, at_times: at_times) if period.is_a?(Array)
51
- return string_trigger_for(period, at_times: at_times) if period.is_a?(String)
52
- return symbol_trigger_for(period, at_times: at_times) if period.is_a?(Symbol)
53
-
54
- raise InvalidPeriodError.new("Unsupported period type: #{period.class}", path: path)
55
- end
56
-
57
- def duration_trigger_for(duration, at_times:)
58
- if at_times.any?
59
- return daily_calendar_trigger(at_times) if duration.to_i == DAY_SECONDS
60
-
61
- raise InvalidPeriodError.new("at: is only supported with calendar periods", path: path)
62
- end
63
-
64
- Wheneverd::Trigger::Interval.new(seconds: duration.to_i)
65
- end
66
-
67
- def daily_calendar_trigger(at_times)
68
- Wheneverd::Trigger::Calendar.new(on_calendar: build_calendar_specs("day", at_times))
69
- end
70
-
71
- def string_trigger_for(str, at_times:)
72
- period_str = str.strip
73
-
74
- return interval_trigger(period_str, at_times: at_times) if interval_string?(period_str)
75
-
76
- return cron_trigger(period_str, at_times: at_times) if cron_string?(period_str)
77
-
78
- raise InvalidPeriodError.new("Unrecognized period #{period_str.inspect}", path: path)
79
- end
80
-
81
- def interval_trigger(period_str, at_times:)
82
- if at_times.any?
83
- raise InvalidPeriodError.new("at: is not supported for interval periods", path: path)
84
- end
85
-
86
- seconds = Wheneverd::Interval.parse(period_str)
87
- Wheneverd::Trigger::Interval.new(seconds: seconds)
88
- end
89
-
90
- def cron_trigger(period_str, at_times:)
91
- if at_times.any?
92
- raise InvalidPeriodError.new("at: is not supported for cron periods", path: path)
93
- end
94
-
95
- Wheneverd::Trigger::Calendar.new(on_calendar: ["cron:#{period_str}"])
96
- end
97
-
98
- def symbol_trigger_for(sym, at_times:)
99
- raise InvalidPeriodError.new(REBOOT_NOT_SUPPORTED_MESSAGE, path: path) if sym == :reboot
100
-
101
- if CALENDAR_SYMBOLS.include?(sym)
102
- return Wheneverd::Trigger::Calendar.new(
103
- on_calendar: build_calendar_specs(sym.to_s, at_times)
104
- )
105
- end
106
-
107
- raise InvalidPeriodError.new("Unknown period symbol: #{sym.inspect}", path: path)
108
- end
109
-
110
- def array_trigger_for(periods, at_times:)
111
- bases = CalendarSymbolPeriodList.validate(
112
- periods,
113
- allowed_symbols: CALENDAR_SYMBOLS,
114
- path: path
115
- ).map(&:to_s)
116
- specs = bases.flat_map { |base| build_calendar_specs(base, at_times) }.uniq
117
- Wheneverd::Trigger::Calendar.new(on_calendar: specs)
118
- end
119
-
120
- def build_calendar_specs(base, at_times)
121
- return [base] if at_times.empty?
122
-
123
- at_times.map { |t| "#{base}@#{t}" }
124
- end
125
-
126
- def interval_string?(str)
127
- /\A-?\d+[smhdw]\z/.match?(str)
128
- end
129
-
130
- def cron_string?(str)
131
- str.split(/\s+/).length == 5
132
- end
133
34
  end
134
35
  end
135
36
  end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../calendar_symbol_period_list"
4
+
5
+ module Wheneverd
6
+ module DSL
7
+ module PeriodStrategy
8
+ # Strategy for parsing Array period values.
9
+ #
10
+ # Handles arrays of calendar symbols like [:monday, :wednesday, :friday].
11
+ class ArrayStrategy < Base
12
+ def handles?(period)
13
+ period.is_a?(Array)
14
+ end
15
+
16
+ def parse(periods, at_times:)
17
+ bases = CalendarSymbolPeriodList.validate(
18
+ periods,
19
+ allowed_symbols: CALENDAR_SYMBOLS,
20
+ path: path
21
+ ).map(&:to_s)
22
+
23
+ specs = bases.flat_map { |base| build_calendar_specs(base, at_times) }.uniq
24
+ calendar_trigger(on_calendar: specs)
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wheneverd
4
+ module DSL
5
+ module PeriodStrategy
6
+ # Base class for period parsing strategies.
7
+ #
8
+ # Each strategy handles a specific type of period value (Duration, String, Symbol, Array)
9
+ # and converts it into a trigger object.
10
+ class Base
11
+ DAY_SECONDS = 60 * 60 * 24
12
+
13
+ CALENDAR_SYMBOLS = %i[
14
+ hour day month year weekday weekend
15
+ monday tuesday wednesday thursday friday saturday sunday
16
+ ].freeze
17
+
18
+ attr_reader :path
19
+
20
+ # @param path [String] schedule path for error reporting
21
+ def initialize(path:)
22
+ @path = path
23
+ end
24
+
25
+ # Check if this strategy handles the given period type.
26
+ #
27
+ # @param period [Object] the period value
28
+ # @return [Boolean]
29
+ def handles?(_period)
30
+ raise NotImplementedError, "#{self.class} must implement #handles?"
31
+ end
32
+
33
+ # Parse the period into a trigger.
34
+ #
35
+ # @param period [Object] the period value
36
+ # @param at_times [Array<String>] normalized at: times
37
+ # @return [Wheneverd::Trigger::Interval, Wheneverd::Trigger::Calendar]
38
+ # @raise [Wheneverd::DSL::InvalidPeriodError]
39
+ def parse(_period, at_times:)
40
+ raise NotImplementedError, "#{self.class} must implement #parse"
41
+ end
42
+
43
+ protected
44
+
45
+ def raise_period_error(message)
46
+ raise InvalidPeriodError.new(message, path: path)
47
+ end
48
+
49
+ def build_calendar_specs(base, at_times)
50
+ return [base] if at_times.empty?
51
+
52
+ at_times.map { |t| "#{base}@#{t}" }
53
+ end
54
+
55
+ def calendar_trigger(on_calendar:)
56
+ Wheneverd::Trigger::Calendar.new(on_calendar: on_calendar)
57
+ end
58
+
59
+ def interval_trigger(seconds:)
60
+ Wheneverd::Trigger::Interval.new(seconds: seconds)
61
+ end
62
+ end
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wheneverd
4
+ module DSL
5
+ module PeriodStrategy
6
+ # Strategy for parsing Duration period values.
7
+ #
8
+ # Duration values produce Interval triggers, except for 1.day with at: times
9
+ # which produces a Calendar trigger for daily scheduling.
10
+ class DurationStrategy < Base
11
+ def handles?(period)
12
+ period.is_a?(Wheneverd::Duration)
13
+ end
14
+
15
+ def parse(duration, at_times:)
16
+ if at_times.any?
17
+ return daily_calendar_trigger(at_times) if duration.to_i == DAY_SECONDS
18
+
19
+ raise_period_error("at: is only supported with calendar periods")
20
+ end
21
+
22
+ interval_trigger(seconds: duration.to_i)
23
+ end
24
+
25
+ private
26
+
27
+ def daily_calendar_trigger(at_times)
28
+ calendar_trigger(on_calendar: build_calendar_specs("day", at_times))
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wheneverd
4
+ module DSL
5
+ module PeriodStrategy
6
+ # Strategy for parsing String period values.
7
+ #
8
+ # Handles interval strings (e.g., "5m", "1h") and cron expressions.
9
+ class StringStrategy < Base
10
+ INTERVAL_PATTERN = /\A-?\d+[smhdw]\z/.freeze
11
+
12
+ def handles?(period)
13
+ period.is_a?(String)
14
+ end
15
+
16
+ def parse(str, at_times:)
17
+ period_str = str.strip
18
+
19
+ return parse_interval(period_str, at_times: at_times) if interval_string?(period_str)
20
+
21
+ return parse_cron(period_str, at_times: at_times) if cron_string?(period_str)
22
+
23
+ raise_period_error("Unrecognized period #{period_str.inspect}")
24
+ end
25
+
26
+ private
27
+
28
+ def parse_interval(period_str, at_times:)
29
+ raise_period_error("at: is not supported for interval periods") if at_times.any?
30
+
31
+ seconds = Wheneverd::Interval.parse(period_str)
32
+ interval_trigger(seconds: seconds)
33
+ end
34
+
35
+ def parse_cron(period_str, at_times:)
36
+ raise_period_error("at: is not supported for cron periods") if at_times.any?
37
+
38
+ calendar_trigger(on_calendar: ["cron:#{period_str}"])
39
+ end
40
+
41
+ def interval_string?(str)
42
+ INTERVAL_PATTERN.match?(str)
43
+ end
44
+
45
+ def cron_string?(str)
46
+ str.split(/\s+/).length == 5
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wheneverd
4
+ module DSL
5
+ module PeriodStrategy
6
+ # Strategy for parsing Symbol period values.
7
+ #
8
+ # Handles calendar symbols like :day, :monday, :weekend, etc.
9
+ class SymbolStrategy < Base
10
+ REBOOT_NOT_SUPPORTED_MESSAGE =
11
+ "The :reboot period is not supported; use an interval or calendar period instead"
12
+
13
+ def handles?(period)
14
+ period.is_a?(Symbol)
15
+ end
16
+
17
+ def parse(sym, at_times:)
18
+ raise_period_error(REBOOT_NOT_SUPPORTED_MESSAGE) if sym == :reboot
19
+
20
+ if CALENDAR_SYMBOLS.include?(sym)
21
+ return calendar_trigger(
22
+ on_calendar: build_calendar_specs(sym.to_s, at_times)
23
+ )
24
+ end
25
+
26
+ raise_period_error("Unknown period symbol: #{sym.inspect}")
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "period_strategy/base"
4
+ require_relative "period_strategy/duration_strategy"
5
+ require_relative "period_strategy/string_strategy"
6
+ require_relative "period_strategy/symbol_strategy"
7
+ require_relative "period_strategy/array_strategy"
8
+
9
+ module Wheneverd
10
+ module DSL
11
+ # Period parsing strategies for converting DSL period values into triggers.
12
+ #
13
+ # Each strategy handles a specific type of period value and converts it
14
+ # into the appropriate trigger type.
15
+ module PeriodStrategy
16
+ # Default strategies in order of precedence.
17
+ DEFAULT_STRATEGIES = [
18
+ DurationStrategy,
19
+ ArrayStrategy,
20
+ StringStrategy,
21
+ SymbolStrategy
22
+ ].freeze
23
+
24
+ # Find the strategy that handles the given period type.
25
+ #
26
+ # @param period [Object] the period value
27
+ # @param path [String] schedule path for error reporting
28
+ # @return [Base] the strategy instance
29
+ # @raise [Wheneverd::DSL::InvalidPeriodError] if no strategy handles the period
30
+ def self.for(period, path:)
31
+ strategy_class = DEFAULT_STRATEGIES.find do |klass|
32
+ klass.new(path: path).handles?(period)
33
+ end
34
+
35
+ if strategy_class.nil?
36
+ raise InvalidPeriodError.new("Unsupported period type: #{period.class}", path: path)
37
+ end
38
+
39
+ strategy_class.new(path: path)
40
+ end
41
+ end
42
+ end
43
+ end
@@ -11,13 +11,7 @@ module Wheneverd
11
11
 
12
12
  # @param seconds [Integer] duration in seconds (must be positive)
13
13
  def initialize(seconds)
14
- unless seconds.is_a?(Integer)
15
- raise ArgumentError, "Duration seconds must be an Integer (got #{seconds.class})"
16
- end
17
-
18
- raise ArgumentError, "Duration seconds must be positive (got #{seconds})" if seconds <= 0
19
-
20
- @seconds = seconds
14
+ @seconds = Validation.positive_integer(seconds, name: "Duration seconds")
21
15
  end
22
16
 
23
17
  def to_i
@@ -1,6 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Wheneverd
4
+ # Base error class for wheneverd exceptions.
5
+ class Error < StandardError; end
6
+
4
7
  # Raised when {Wheneverd::Interval.parse} cannot parse or validate an interval string.
5
8
  class InvalidIntervalError < Error; end
6
9
 
@@ -21,17 +21,32 @@ module Wheneverd
21
21
  # @return [Integer] seconds
22
22
  # @raise [Wheneverd::InvalidIntervalError] if the input is invalid
23
23
  def self.parse(str)
24
- input = str.to_s.strip
24
+ input = normalize_input(str)
25
+ match = parse_match(input)
26
+ n = parse_number(match[:n], input)
27
+ n * MULTIPLIERS.fetch(match[:unit])
28
+ end
29
+
30
+ def self.normalize_input(str)
31
+ str.to_s.strip
32
+ end
33
+ private_class_method :normalize_input
34
+
35
+ def self.parse_match(input)
25
36
  match = FORMAT.match(input)
26
- unless match
27
- raise InvalidIntervalError,
28
- "Invalid interval #{input.inspect}; expected <n>s|m|h|d|w (example: \"5m\")"
29
- end
37
+ return match if match
38
+
39
+ raise InvalidIntervalError,
40
+ "Invalid interval #{input.inspect}; expected <n>s|m|h|d|w (example: \"5m\")"
41
+ end
42
+ private_class_method :parse_match
30
43
 
31
- n = Integer(match[:n], 10)
44
+ def self.parse_number(number_str, input)
45
+ n = Integer(number_str, 10)
32
46
  raise InvalidIntervalError, "Interval must be positive (got #{input.inspect})" if n <= 0
33
47
 
34
- n * MULTIPLIERS.fetch(match[:unit])
48
+ n
35
49
  end
50
+ private_class_method :parse_number
36
51
  end
37
52
  end
@@ -8,9 +8,14 @@ module Wheneverd
8
8
  # @return [Array<Entry>]
9
9
  attr_reader :entries
10
10
 
11
+ # @return [Array<Wheneverd::Service>]
12
+ attr_reader :services
13
+
11
14
  # @param entries [Array<Entry>]
12
- def initialize(entries: [])
15
+ # @param services [Array<Wheneverd::Service>]
16
+ def initialize(entries: [], services: [])
13
17
  @entries = entries.dup
18
+ @services = services.dup
14
19
  end
15
20
 
16
21
  # Append an entry to the schedule.
@@ -21,5 +26,14 @@ module Wheneverd
21
26
  entries << entry
22
27
  self
23
28
  end
29
+
30
+ # Append a long-running service to the schedule.
31
+ #
32
+ # @param service [Wheneverd::Service]
33
+ # @return [Schedule] self
34
+ def add_service(service)
35
+ services << service
36
+ self
37
+ end
24
38
  end
25
39
  end
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wheneverd
4
+ # A long-running systemd user service managed alongside scheduled timers.
5
+ class Service
6
+ SAFE_SETTING_NAME = /\A[A-Za-z][A-Za-z0-9]*\z/.freeze
7
+
8
+ # @return [String]
9
+ attr_reader :name
10
+
11
+ # @return [Wheneverd::Job::Command]
12
+ attr_reader :command
13
+
14
+ # @return [String]
15
+ attr_reader :restart
16
+
17
+ # @return [String]
18
+ attr_reader :restart_sec
19
+
20
+ # @return [Array<String>]
21
+ attr_reader :service_lines
22
+
23
+ # @param name [String] stable service name within the schedule
24
+ # @param command [String, Array<String>] command to run as ExecStart
25
+ # @param restart [String] systemd Restart= value
26
+ # @param restart_sec [String] systemd RestartSec= value
27
+ # @param service [Hash, Array<String>] extra [Service] lines
28
+ def initialize(name:, command:, restart: "always", restart_sec: "5s", service: {})
29
+ @name = normalize_name(name)
30
+ @command = Wheneverd::Job::Command.new(command: command)
31
+ @restart = normalize_required_value(restart, "restart")
32
+ @restart_sec = normalize_required_value(restart_sec, "restart_sec")
33
+ @service_lines = normalize_service_lines(service)
34
+ end
35
+
36
+ # Stable signature used for unit naming.
37
+ #
38
+ # @return [String]
39
+ def signature
40
+ [
41
+ "service:#{name}",
42
+ command.signature,
43
+ "restart:#{restart}",
44
+ "restart_sec:#{restart_sec}",
45
+ *service_lines
46
+ ].join("\n")
47
+ end
48
+
49
+ private
50
+
51
+ def normalize_name(value)
52
+ str = value.to_s.strip
53
+ raise InvalidCommandError, "Service name must not be empty" if str.empty?
54
+
55
+ str
56
+ end
57
+
58
+ def normalize_required_value(value, field)
59
+ str = value.to_s.strip
60
+ raise InvalidCommandError, "Service #{field} must not be empty" if str.empty?
61
+
62
+ str
63
+ end
64
+
65
+ def normalize_service_lines(value)
66
+ case value
67
+ when Hash
68
+ value.map { |key, setting| normalize_service_setting(key, setting) }
69
+ when Array
70
+ value.map { |line| normalize_service_line(line) }
71
+ else
72
+ raise InvalidCommandError, "Service extra settings must be a Hash or Array"
73
+ end
74
+ end
75
+
76
+ def normalize_service_setting(key, value)
77
+ setting_name = key.to_s.strip
78
+ unless SAFE_SETTING_NAME.match?(setting_name)
79
+ raise InvalidCommandError, "Invalid service setting name: #{setting_name.inspect}"
80
+ end
81
+
82
+ "#{setting_name}=#{normalize_service_setting_value(value)}"
83
+ end
84
+
85
+ def normalize_service_setting_value(value)
86
+ str = value.to_s.strip
87
+ raise InvalidCommandError, "Service setting values must not be empty" if str.empty?
88
+ if str.match?(/[\0\r\n]/)
89
+ raise InvalidCommandError, "Service setting values must not include NUL or newlines"
90
+ end
91
+
92
+ str
93
+ end
94
+
95
+ def normalize_service_line(line)
96
+ str = line.to_s.strip
97
+ raise InvalidCommandError, "Service lines must not be empty" if str.empty?
98
+ if str.match?(/[\0\r\n]/) || !str.include?("=")
99
+ raise InvalidCommandError, "Service lines must be single KEY=VALUE lines"
100
+ end
101
+
102
+ str
103
+ end
104
+ end
105
+ end