dante 0.2.0 → 0.3.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.
data/test/test_helper.rb CHANGED
@@ -1,24 +1,144 @@
1
1
  require 'rubygems'
2
+ require 'fileutils'
2
3
  require 'tempfile'
4
+ require 'tmpdir'
3
5
  require 'minitest/autorun'
4
- require 'mocha'
5
- $:.unshift File.expand_path("../../lib")
6
+ $:.unshift File.expand_path("../../lib", __FILE__)
6
7
  require 'dante'
7
8
 
9
+ module DanteTestHarness
10
+ TEST_PID = Process.pid
11
+
12
+ # The signal handlers in place before any test ran; Runner#start replaces them in
13
+ # the test process itself whenever a test runs a runner in the foreground.
14
+ ORIGINAL_TRAPS = %w(INT TERM).each_with_object({}) do |sig, traps|
15
+ traps[sig] = trap(sig, 'DEFAULT')
16
+ trap(sig, traps[sig])
17
+ end
18
+
19
+ # Daemons are forked from inside a test, so every forked process inherits the at_exit
20
+ # handlers of the test process (minitest's among them). Make forked processes leave
21
+ # without running them. It is registered during the run, after minitest's own handler,
22
+ # so it runs first.
23
+ def self.install_fork_guard
24
+ return if @fork_guard_installed
25
+ @fork_guard_installed = true
26
+ at_exit do
27
+ next if Process.pid == TEST_PID
28
+ exit!($!.is_a?(SystemExit) ? $!.status : ($! ? 1 : 0))
29
+ end
30
+ end
31
+
32
+ def before_setup
33
+ super
34
+ DanteTestHarness.install_fork_guard
35
+ @tmpdir = Dir.mktmpdir('dante-test-')
36
+ @original_argv = ARGV.dup
37
+ ARGV.clear
38
+ end
39
+
40
+ def after_teardown
41
+ stop_daemons
42
+ ensure
43
+ ARGV.replace(@original_argv)
44
+ ORIGINAL_TRAPS.each { |sig, handler| trap(sig, handler) }
45
+ FileUtils.rm_rf(@tmpdir)
46
+ super
47
+ end
48
+
49
+ # A path inside this test's own temporary directory.
50
+ def tmp_path(name)
51
+ File.join(@tmpdir, name)
52
+ end
53
+
54
+ # Polls until the block returns a truthy value and returns it, or returns nil once
55
+ # timeout seconds have passed.
56
+ def wait_until(timeout = 10, interval = 0.05)
57
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
58
+ loop do
59
+ result = yield
60
+ return result if result
61
+ return nil if Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline
62
+ sleep interval
63
+ end
64
+ end
65
+
66
+ # The pid of the daemon recorded in pid_path.
67
+ def daemon_pid(pid_path)
68
+ pid = wait_until { File.exist?(pid_path) && (n = File.read(pid_path).to_i) > 0 && n }
69
+ assert pid, "no pid written to #{pid_path}"
70
+ (@daemon_pids ||= []) << pid # stopped in teardown even if the pid file is gone by then
71
+ pid
72
+ end
73
+
74
+ # Waits until a daemon has reached its startup block, shown by it creating path (and
75
+ # writing pattern to it). Runner#daemonize returns once the pid file exists, before the
76
+ # daemon has run its startup block.
77
+ def wait_for_output(path, pattern = //)
78
+ assert wait_until { File.exist?(path) && File.read(path) =~ pattern }, "#{path} never matched #{pattern.inspect}"
79
+ end
80
+
81
+ # Waits for a daemon to exit (it is not our child: init reaps it).
82
+ def wait_for_exit(pid, timeout = 10)
83
+ assert wait_until(timeout) { !process_alive?(pid) }, "process #{pid} still running after #{timeout}s"
84
+ end
85
+
86
+ # Whether pid is a live process. A zombie counts as gone: it has exited and only waits
87
+ # for its parent (init, for a daemon) to reap it.
88
+ # ceiling: zombies are recognised through /proc (Linux) only; elsewhere a zombie counts
89
+ # as alive until init reaps it, which only makes the waits longer.
90
+ def process_alive?(pid)
91
+ Process.kill(0, pid)
92
+ stat = "/proc/#{pid}/stat"
93
+ !(File.exist?(stat) && (line = File.read(stat))[line.rindex(')') + 2] == 'Z')
94
+ rescue Errno::ESRCH, Errno::EPERM, Errno::ENOENT, RangeError # ENOENT: reaped between the two checks; RangeError: no pid_t
95
+ false
96
+ end
97
+
98
+ # Stops every daemon whose pid file is in this test's directory, or whose pid the test
99
+ # read with daemon_pid, even when the test failed: SIGTERM first, then SIGKILL for
100
+ # daemons that trap SIGTERM.
101
+ # ceiling: a pid file or recorded pid of a daemon that already exited is trusted while
102
+ # its pid is alive, so a pid reused within the test's lifetime would be signalled;
103
+ # checking the process start time in /proc would rule that out.
104
+ def stop_daemons
105
+ pid_files = Dir[File.join(@tmpdir, '**', '*.pid')]
106
+ pids = pid_files.map { |f| [File.read(f).to_i, f] } + Array(@daemon_pids).map { |p| [p, 'daemon_pid'] }
107
+ pids.uniq { |pid, _| pid }.each do |pid, pid_file|
108
+ begin
109
+ next unless pid > 0 && process_alive?(pid)
110
+ %w(TERM KILL).each do |sig|
111
+ Process.kill(sig, pid)
112
+ break if wait_until(5) { !process_alive?(pid) }
113
+ end
114
+ rescue Errno::ESRCH
115
+ next
116
+ end
117
+ raise "daemon #{pid} from #{pid_file} survived SIGKILL" if process_alive?(pid)
118
+ end
119
+ end
120
+ end
121
+
122
+ Minitest::Test.send(:include, DanteTestHarness)
123
+
8
124
  ## Kernel Extensions
9
- require 'stringio'
10
125
 
11
126
  module Kernel
12
- # Redirect standard out, standard error and the buffered logger for sprinkle to StringIO
127
+ # Redirect standard out and standard error to a file and return what was written
13
128
  # capture_stdout { any_commands; you_want } => "all output from the commands"
129
+ #
130
+ # The file is a real IO rather than a StringIO so that a daemon forked inside the block
131
+ # reopens $stdout onto its log file, as it does outside the tests.
14
132
  def capture_stdout
15
133
  return yield if ENV['DEBUG'] # Skip if debug mode
16
134
 
17
- out = StringIO.new
18
- $stdout = out
19
- $stderr = out
20
- yield
21
- return out.string
135
+ Tempfile.create('stdout', @tmpdir || Dir.tmpdir) do |out|
136
+ out.sync = true
137
+ $stdout = out
138
+ $stderr = out
139
+ yield
140
+ File.read(out.path)
141
+ end
22
142
  ensure
23
143
  $stdout = STDOUT
24
144
  $stderr = STDERR
@@ -29,8 +149,8 @@ end
29
149
  class TestingProcess
30
150
  attr_reader :tmp_path
31
151
 
32
- def initialize(name)
33
- @tmp_path = "/tmp/dante-#{name}.log"
152
+ def initialize(name, dir)
153
+ @tmp_path = File.join(dir, "dante-#{name}.log")
34
154
  end # initialize
35
155
 
36
156
  def run_a!(data=nil)
@@ -64,4 +184,14 @@ class TestingProcess
64
184
  ensure
65
185
  puts "Closing!!"
66
186
  end
67
- end # TestingProcess
187
+
188
+ # Survives SIGTERM (and SIGINT): only SIGKILL stops it
189
+ def run_d!(data=nil)
190
+ p ['The rescuer']
191
+ sleep 100
192
+ rescue SystemExit, SignalException
193
+ p ['Saved']
194
+ retry
195
+ end
196
+
197
+ end # TestingProcess
@@ -0,0 +1,194 @@
1
+ require File.expand_path('../test_helper', __FILE__)
2
+ require 'etc'
3
+
4
+ # Switching user and group needs root. CI runs this file a second time under sudo.
5
+ describe "dante runner switching user and group" do
6
+ before do
7
+ skip "switching user and group needs root (CI runs this file under sudo)" unless Process.euid == 0
8
+ skip "reads credentials from /proc" unless File.exist?("/proc/self/status")
9
+ @nobody = begin
10
+ Etc.getpwnam('nobody')
11
+ rescue ArgumentError
12
+ skip "no 'nobody' user"
13
+ end
14
+ @nobody_group = Etc.getgrgid(@nobody.gid).name
15
+
16
+ # The switched process must be able to write its pid and log files.
17
+ File.chmod(0755, @tmpdir)
18
+ @dir = tmp_path('shared')
19
+ Dir.mkdir(@dir)
20
+ File.chmod(0777, @dir)
21
+ @pid_path = File.join(@dir, 'dante.pid')
22
+ @log_path = File.join(@dir, 'dante.log')
23
+ @status_path = File.join(@dir, 'status')
24
+ end
25
+
26
+ # Runs the block in a forked child that starts with the given supplementary groups,
27
+ # as a process started by root might have. Fails the test if the block raises. The
28
+ # error goes through a file rather than a pipe, which a daemon started by the block
29
+ # would hold open.
30
+ def run_in_child(groups = [0, 4, 27])
31
+ error_path = tmp_path('child-error')
32
+ pid = fork do
33
+ error = File.open(error_path, 'w') # opened before the block switches user
34
+ error.sync = true # exit! below doesn't flush
35
+ begin
36
+ $stdout = $stderr = File.open(tmp_path('child.out'), 'w')
37
+ Process.groups = groups
38
+ yield
39
+ rescue Exception => e
40
+ error.write("#{e.class}: #{e.message}")
41
+ exit!(1)
42
+ end
43
+ exit!(0)
44
+ end
45
+ Process.wait(pid)
46
+ assert_equal [0, ""], [$?.exitstatus, File.read(error_path)]
47
+ end
48
+
49
+ # The real, effective, saved and filesystem uids and gids and the supplementary
50
+ # groups, from the text of /proc/<pid>/status.
51
+ def credentials(status)
52
+ fields = status.lines.map { |line| line.chomp.split(/:\s*/, 2) }.to_h
53
+ { :uid => fields['Uid'].split.map(&:to_i), :gid => fields['Gid'].split.map(&:to_i),
54
+ :groups => fields['Groups'].to_s.split.map(&:to_i).sort }
55
+ end
56
+
57
+ # The supplementary groups the group database gives user, with primary group gid.
58
+ def groups_of(user, gid)
59
+ gids = [gid]
60
+ Etc.group { |group| gids << group.gid if group.mem.include?(user) }
61
+ gids.uniq.sort
62
+ end
63
+
64
+ # A uid with no passwd entry.
65
+ def unused_uid
66
+ (4242..60000).find do |uid|
67
+ begin
68
+ Etc.getpwuid(uid)
69
+ false
70
+ rescue ArgumentError
71
+ true
72
+ end
73
+ end
74
+ end
75
+
76
+ # The startup block of a foreground runner: records the process's credentials.
77
+ def record_status
78
+ status_path = @status_path
79
+ lambda { |_opts| File.write(status_path, File.read('/proc/self/status')) }
80
+ end
81
+
82
+ it "gives a daemon started with -u the user's uid, primary group and groups" do
83
+ run_in_child do
84
+ ARGV.replace(['-d', '-P', @pid_path, '-l', @log_path, '-u', 'nobody'])
85
+ Dante::Runner.new('test-process').execute { sleep 100 }
86
+ end
87
+ creds = credentials(File.read("/proc/#{daemon_pid(@pid_path)}/status"))
88
+
89
+ assert_equal [@nobody.uid] * 4, creds[:uid]
90
+ assert_equal [@nobody.gid] * 4, creds[:gid]
91
+ assert_equal groups_of('nobody', @nobody.gid), creds[:groups]
92
+ assert_empty creds[:groups] & [0, 4, 27]
93
+ end
94
+
95
+ it "gives a daemon started with -u and -G that group and the user's groups" do
96
+ group = begin
97
+ Etc.getgrgid(1)
98
+ rescue ArgumentError
99
+ skip "no group with gid 1"
100
+ end
101
+ run_in_child do
102
+ ARGV.replace(['-d', '-P', @pid_path, '-l', @log_path, '-u', 'nobody', '-G', group.name])
103
+ Dante::Runner.new('test-process').execute { sleep 100 }
104
+ end
105
+ creds = credentials(File.read("/proc/#{daemon_pid(@pid_path)}/status"))
106
+
107
+ assert_equal [@nobody.uid] * 4, creds[:uid]
108
+ assert_equal [group.gid] * 4, creds[:gid]
109
+ assert_equal groups_of('nobody', group.gid), creds[:groups]
110
+ assert_empty creds[:groups] & [0, 4, 27]
111
+ end
112
+
113
+ it "keeps root's uid and leaves only the new group with -G alone" do
114
+ run_in_child do
115
+ ARGV.replace(['-G', @nobody_group])
116
+ Dante::Runner.new('test-process').execute(&record_status)
117
+ end
118
+ creds = credentials(File.read(@status_path))
119
+
120
+ assert_equal [0] * 4, creds[:uid]
121
+ assert_equal [@nobody.gid] * 4, creds[:gid]
122
+ assert_equal [@nobody.gid], creds[:groups]
123
+ end
124
+
125
+ it "accepts a uid, and gives it the user's primary group and groups" do
126
+ run_in_child do
127
+ Dante::Runner.new('test-process').execute(:user => @nobody.uid, &record_status)
128
+ end
129
+ creds = credentials(File.read(@status_path))
130
+
131
+ assert_equal [@nobody.uid] * 4, creds[:uid]
132
+ assert_equal [@nobody.gid] * 4, creds[:gid]
133
+ assert_equal groups_of('nobody', @nobody.gid), creds[:groups]
134
+ end
135
+
136
+ it "keeps the group of a uid with no passwd entry and leaves only that group" do
137
+ uid = unused_uid
138
+ run_in_child do
139
+ Dante::Runner.new('test-process').execute(:user => uid, &record_status)
140
+ end
141
+ creds = credentials(File.read(@status_path))
142
+
143
+ assert_equal [uid] * 4, creds[:uid]
144
+ assert_equal [0] * 4, creds[:gid]
145
+ assert_equal [0], creds[:groups]
146
+ end
147
+
148
+ it "gives a uid with no passwd entry the group named by :group" do
149
+ uid = unused_uid
150
+ run_in_child do
151
+ Dante::Runner.new('test-process').execute(:user => uid, :group => @nobody.gid, &record_status)
152
+ end
153
+ creds = credentials(File.read(@status_path))
154
+
155
+ assert_equal [uid] * 4, creds[:uid]
156
+ assert_equal [@nobody.gid] * 4, creds[:gid]
157
+ assert_equal [@nobody.gid], creds[:groups]
158
+ end
159
+
160
+ it "keeps the current group when not root and given only the current user" do
161
+ group = begin
162
+ Etc.getgrgid(1)
163
+ rescue ArgumentError
164
+ skip "no group with gid 1"
165
+ end
166
+ run_in_child do
167
+ Dante::Runner.new('test-process').execute(:user => 'nobody', :group => group.gid) {}
168
+ # now nobody, with a group other than nobody's primary group
169
+ Dante::Runner.new('test-process').execute(:user => 'nobody', &record_status)
170
+ end
171
+ creds = credentials(File.read(@status_path))
172
+
173
+ assert_equal [@nobody.uid] * 4, creds[:uid]
174
+ assert_equal [group.gid] * 4, creds[:gid]
175
+ end
176
+
177
+ it "gives a user its supplementary groups from the group database" do
178
+ member = nil
179
+ Etc.group do |group|
180
+ member ||= group.mem.find { |name| name != 'root' && (Etc.getpwnam(name) rescue nil) }
181
+ end
182
+ skip "no user is a member of a group in the group database" unless member
183
+ passwd = Etc.getpwnam(member)
184
+ run_in_child([0]) do
185
+ Dante::Runner.new('test-process').execute(:user => member, &record_status)
186
+ end
187
+ creds = credentials(File.read(@status_path))
188
+
189
+ assert_equal [passwd.uid] * 4, creds[:uid]
190
+ assert_equal [passwd.gid] * 4, creds[:gid]
191
+ assert_equal groups_of(member, passwd.gid), creds[:groups]
192
+ assert_operator creds[:groups].size, :>, 1
193
+ end
194
+ end
metadata CHANGED
@@ -1,57 +1,43 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: dante
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Nathan Esquenazi
8
- autorequire:
8
+ autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2013-12-04 00:00:00.000000000 Z
11
+ date: 2026-10-31 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rake
15
15
  requirement: !ruby/object:Gem::Requirement
16
16
  requirements:
17
- - - ! '>='
17
+ - - ">="
18
18
  - !ruby/object:Gem::Version
19
19
  version: '0'
20
20
  type: :development
21
21
  prerelease: false
22
22
  version_requirements: !ruby/object:Gem::Requirement
23
23
  requirements:
24
- - - ! '>='
24
+ - - ">="
25
25
  - !ruby/object:Gem::Version
26
26
  version: '0'
27
27
  - !ruby/object:Gem::Dependency
28
28
  name: minitest
29
29
  requirement: !ruby/object:Gem::Requirement
30
30
  requirements:
31
- - - ! '>='
31
+ - - ">="
32
32
  - !ruby/object:Gem::Version
33
- version: '0'
33
+ version: '5.0'
34
34
  type: :development
35
35
  prerelease: false
36
36
  version_requirements: !ruby/object:Gem::Requirement
37
37
  requirements:
38
- - - ! '>='
38
+ - - ">="
39
39
  - !ruby/object:Gem::Version
40
- version: '0'
41
- - !ruby/object:Gem::Dependency
42
- name: mocha
43
- requirement: !ruby/object:Gem::Requirement
44
- requirements:
45
- - - ! '>='
46
- - !ruby/object:Gem::Version
47
- version: '0'
48
- type: :development
49
- prerelease: false
50
- version_requirements: !ruby/object:Gem::Requirement
51
- requirements:
52
- - - ! '>='
53
- - !ruby/object:Gem::Version
54
- version: '0'
40
+ version: '5.0'
55
41
  description: Turn any process into a demon.
56
42
  email:
57
43
  - nesquena@gmail.com
@@ -59,7 +45,10 @@ executables: []
59
45
  extensions: []
60
46
  extra_rdoc_files: []
61
47
  files:
62
- - .gitignore
48
+ - ".github/workflows/ci.yml"
49
+ - ".github/workflows/release.yml"
50
+ - ".gitignore"
51
+ - CHANGELOG.md
63
52
  - Gemfile
64
53
  - LICENSE
65
54
  - README.md
@@ -71,30 +60,28 @@ files:
71
60
  - test/dante_test.rb
72
61
  - test/runner_test.rb
73
62
  - test/test_helper.rb
74
- homepage: https://github.com/bazaarlabs/dante
75
- licenses: []
63
+ - test/user_switch_test.rb
64
+ homepage: https://github.com/nesquena/dante
65
+ licenses:
66
+ - MIT
76
67
  metadata: {}
77
- post_install_message:
68
+ post_install_message:
78
69
  rdoc_options: []
79
70
  require_paths:
80
71
  - lib
81
72
  required_ruby_version: !ruby/object:Gem::Requirement
82
73
  requirements:
83
- - - ! '>='
74
+ - - ">="
84
75
  - !ruby/object:Gem::Version
85
- version: '0'
76
+ version: '2.7'
86
77
  required_rubygems_version: !ruby/object:Gem::Requirement
87
78
  requirements:
88
- - - ! '>='
79
+ - - ">="
89
80
  - !ruby/object:Gem::Version
90
81
  version: '0'
91
82
  requirements: []
92
- rubyforge_project: dante
93
- rubygems_version: 2.0.7
94
- signing_key:
83
+ rubygems_version: 3.5.22
84
+ signing_key:
95
85
  specification_version: 4
96
86
  summary: Turn any process into a demon
97
- test_files:
98
- - test/dante_test.rb
99
- - test/runner_test.rb
100
- - test/test_helper.rb
87
+ test_files: []