rptrace 0.1.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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: db5900c94042eb61d01de6b3c3394d23833fde0ccf3c23a7974618d52686cf68
4
+ data.tar.gz: a34a2007bca148522855b1e5019c1c6d5975440621a9132ec50d6a71f6f04c20
5
+ SHA512:
6
+ metadata.gz: 748068d4a08b509c8ccd31bf30c5643236b7b8d9df3eaaf737c251c67173c162629a0e5a3780efb0a69037a9bee66de38f0a4905a0bf63cc05f91e971cd6aef8
7
+ data.tar.gz: 4a87fb8ea9fa2c52f2d36b1889d2d124f5bc53728688a351b2e5c3c5c9785584967b80d37684a410773fb360134cb6cd1e2c73c9c1dccb8431bf1a61eddd1cc3
data/.yardopts ADDED
@@ -0,0 +1,4 @@
1
+ --readme README.md
2
+ --output-dir doc
3
+ --markup markdown
4
+ lib/**/*.rb
data/CHANGELOG.md ADDED
@@ -0,0 +1,7 @@
1
+ # Changelog
2
+
3
+ ## Unreleased
4
+
5
+ ## 0.1.0 (2026-02-21)
6
+
7
+ - Initial release
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Yudai Takada
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,183 @@
1
+ # rptrace
2
+
3
+ `rptrace` is a Ruby wrapper for Linux `ptrace(2)` focused on building tracers and debugger-like tooling with a Ruby-friendly API.
4
+
5
+ ## Overview and Motivation
6
+
7
+ Linux `ptrace(2)` is powerful but low-level. This gem wraps process control, register/memory access, and syscall decoding behind a small Ruby API so you can build:
8
+
9
+ - `strace`-like tools
10
+ - process instrumentation utilities
11
+ - debugger-oriented experiments
12
+
13
+ ## Features
14
+
15
+ - Top-level namespace is `Rptrace` (no `Rptrace::Ruby` nesting)
16
+ - `Tracee` API for `spawn`, `attach`, `cont`, `syscall`, `detach`, and `wait`
17
+ - Register and memory wrappers (`Registers`, `Memory`)
18
+ - `/proc/<pid>/maps` parser (`ProcMaps`, `Tracee#memory_maps`)
19
+ - Software breakpoints on x86_64 (`Tracee#set_breakpoint`, `remove_breakpoint`)
20
+ - Syscall lookup (`Rptrace::Syscall`) for `x86_64`/`aarch64`
21
+ - High-level tracing helper `Rptrace.strace` (`follow_children` / `yield_seccomp` supported)
22
+ - ptrace event helpers (`Tracee#event_message`, `Tracee#seccomp_data`, `Tracee#seccomp_metadata`, `Tracee#seccomp_filter`)
23
+
24
+ ## Installation
25
+
26
+ Add this line to your application's Gemfile:
27
+
28
+ ```ruby
29
+ gem "rptrace"
30
+ ```
31
+
32
+ And then execute:
33
+
34
+ ```bash
35
+ bundle install
36
+ ```
37
+
38
+ ## Quick Start
39
+
40
+ ```ruby
41
+ require "rptrace"
42
+
43
+ Rptrace.strace("/bin/ls", "-la", "/tmp") do |event|
44
+ next unless event.exit?
45
+
46
+ puts event
47
+ end
48
+ ```
49
+
50
+ Follow child processes/threads (clone/fork/vfork):
51
+
52
+ ```ruby
53
+ Rptrace.strace("/usr/bin/ruby", "-e", "pid = fork { sleep 0.1 }; Process.wait(pid)", follow_children: true) do |event|
54
+ next unless event.enter?
55
+ puts "pid=#{event.tracee.pid} #{event.syscall.name}"
56
+ end
57
+ ```
58
+
59
+ Include seccomp stop events in trace stream:
60
+
61
+ ```ruby
62
+ Rptrace.strace("/bin/ls", "/tmp", yield_seccomp: true) do |event|
63
+ case event
64
+ when Rptrace::SyscallEvent
65
+ puts event if event.exit?
66
+ when Rptrace::SeccompEvent
67
+ warn event.to_s
68
+ end
69
+ end
70
+ ```
71
+
72
+ Set and clear a software breakpoint (x86_64):
73
+
74
+ ```ruby
75
+ tracee = Rptrace::Tracee.attach(target_pid)
76
+ bp = tracee.set_breakpoint(0x401000)
77
+ # ...
78
+ bp.restore
79
+ ```
80
+
81
+ Inspect seccomp filter metadata and decoded BPF instructions:
82
+
83
+ ```ruby
84
+ tracee = Rptrace::Tracee.attach(target_pid)
85
+ tracee.enable_seccomp_events!
86
+ supported = tracee.seccomp_supported?
87
+ available = tracee.seccomp_filter_available?(index: 0)
88
+ meta = tracee.seccomp_metadata(index: 0) # => { filter_off: 0, flags: ... }
89
+ flag_names = tracee.seccomp_metadata_flag_names(index: 0) # => [:tsync, :log, ...]
90
+ insns = tracee.seccomp_filter(index: 0) # => [{ code:, jt:, jf:, k: }, ...]
91
+ ```
92
+
93
+ ## Permission Guide
94
+
95
+ `ptrace` requires privilege on Linux:
96
+
97
+ - run as `root`, or
98
+ - run with `CAP_SYS_PTRACE`, and
99
+ - ensure Yama policy allows tracing (`/proc/sys/kernel/yama/ptrace_scope`)
100
+
101
+ Integration specs are opt-in and require:
102
+
103
+ ```bash
104
+ PTRACE_RUN_INTEGRATION=1 bundle exec rspec spec/integration
105
+ ```
106
+
107
+ You can inspect local ptrace capability setup from Ruby:
108
+
109
+ ```ruby
110
+ diagnostics = Rptrace.ptrace_permissions
111
+ puts diagnostics # => { ptrace_privileged:, cap_sys_ptrace:, yama_ptrace_scope:, hints: [...] }
112
+ ```
113
+
114
+ Fail fast with an actionable permission error:
115
+
116
+ ```ruby
117
+ Rptrace.ensure_ptrace_privileged!(request: :attach)
118
+ ```
119
+
120
+ ## Examples
121
+
122
+ - `examples/simple_strace.rb`
123
+ - `examples/syscall_counter.rb`
124
+ - `examples/file_access_tracer.rb`
125
+ - `examples/memory_reader.rb`
126
+
127
+ ## API Reference
128
+
129
+ - Generate docs: `bundle exec yard doc`
130
+ - Open index: `doc/index.html`
131
+
132
+ ## Development
133
+
134
+ ```bash
135
+ bundle exec rspec
136
+ ```
137
+
138
+ Run specs with coverage threshold check:
139
+
140
+ ```bash
141
+ COVERAGE=1 COVERAGE_MIN_LINE=95 bundle exec rspec spec/unit spec/rptrace_spec.rb
142
+ ```
143
+
144
+ Generate syscall tables from Linux headers (`x86_64` / `aarch64`):
145
+
146
+ ```bash
147
+ bundle exec rake syscall:generate
148
+ ```
149
+
150
+ You can override header paths with:
151
+
152
+ - `PTRACE_SYSCALL_HEADER_X86_64`
153
+ - `PTRACE_SYSCALL_HEADER_AARCH64`
154
+
155
+ Optional task controls:
156
+
157
+ - `ARCH=x86_64` (or `ARCH=x86_64,aarch64`) to limit architectures
158
+ - `STRICT=1` to fail if any requested architecture header is missing
159
+
160
+ Generate YARD documentation:
161
+
162
+ ```bash
163
+ bundle exec yard doc
164
+ ```
165
+
166
+ ## Release
167
+
168
+ - CI release workflow: `.github/workflows/release.yml`
169
+ - Trigger by pushing a tag (example: `v0.1.0`) or via `workflow_dispatch`
170
+ - Set repository secret `RUBYGEMS_API_KEY` to enable `gem push`
171
+ - Local preflight: `bundle exec rake release:preflight`
172
+ - Local credential check: `bundle exec rake release:check_credentials`
173
+
174
+ ## Limitations
175
+
176
+ - Linux only
177
+ - Ruby 3.1+
178
+ - Architecture support: `x86_64` and `aarch64`
179
+ - Integration tests require ptrace permission (`root` or `CAP_SYS_PTRACE`)
180
+
181
+ ## License
182
+
183
+ MIT
data/Rakefile ADDED
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+ require_relative "lib/rptrace/syscall_table/generator"
6
+ require "yaml"
7
+
8
+ RSpec::Core::RakeTask.new(:spec)
9
+
10
+ namespace :syscall do
11
+ desc "Generate syscall tables from Linux headers"
12
+ task :generate do
13
+ arches = ENV.fetch("ARCH", "").split(",").map(&:strip).reject(&:empty?).map(&:to_sym)
14
+ arches = Rptrace::SyscallTable::Generator::ARCH_CONFIG.keys if arches.empty?
15
+ strict = ENV["STRICT"] == "1"
16
+
17
+ if strict
18
+ results = Rptrace::SyscallTable::Generator.generate_all(root_dir: __dir__, arches: arches, skip_missing: false)
19
+ skipped = []
20
+ else
21
+ output = Rptrace::SyscallTable::Generator.generate_available(root_dir: __dir__, arches: arches)
22
+ results = output.fetch(:generated)
23
+ skipped = output.fetch(:skipped)
24
+ end
25
+
26
+ abort("syscall:generate failed: no headers found for requested architectures") if results.empty?
27
+
28
+ results.each do |result|
29
+ puts "generated #{result[:arch]} table (#{result[:entries_count]} entries)"
30
+ puts " header: #{result[:header_path]}"
31
+ puts " output: #{result[:output_path]}"
32
+ end
33
+
34
+ skipped.each do |entry|
35
+ warn "skipped #{entry[:arch]}: #{entry[:reason]}"
36
+ end
37
+ rescue Rptrace::SyscallTable::Generator::HeaderNotFoundError, ArgumentError => e
38
+ abort("syscall:generate failed: #{e.message}")
39
+ end
40
+ end
41
+
42
+ namespace :release do
43
+ desc "Run release preflight checks (unit specs, docs, gem build)"
44
+ task :preflight do
45
+ sh "bundle exec rspec spec/unit spec/rptrace_spec.rb"
46
+ sh "bundle exec yard doc -n --no-cache"
47
+ sh "gem build rptrace.gemspec"
48
+ end
49
+
50
+ desc "Check RubyGems API key availability for gem push"
51
+ task :check_credentials do
52
+ env_key = ENV["RUBYGEMS_API_KEY"]
53
+ if env_key && !env_key.strip.empty?
54
+ puts "RUBYGEMS_API_KEY is set in environment"
55
+ next
56
+ end
57
+
58
+ credentials_path = File.expand_path("~/.gem/credentials")
59
+ if File.readable?(credentials_path)
60
+ credentials = YAML.load_file(credentials_path) || {}
61
+ api_key = credentials[":rubygems_api_key"] || credentials["rubygems_api_key"]
62
+ if api_key && !api_key.to_s.strip.empty?
63
+ puts "RubyGems API key found in #{credentials_path}"
64
+ next
65
+ end
66
+ end
67
+
68
+ abort("release:check_credentials failed: RUBYGEMS_API_KEY is not configured")
69
+ end
70
+ end
71
+
72
+ task default: :spec
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rptrace"
4
+
5
+ if ARGV.empty?
6
+ warn "usage: bundle exec ruby examples/file_access_tracer.rb <command> [args...]"
7
+ exit 1
8
+ end
9
+
10
+ command = ARGV.shift
11
+
12
+ Rptrace.strace(command, *ARGV) do |event|
13
+ next unless event.exit?
14
+ next unless %i[open openat].include?(event.syscall.name)
15
+
16
+ puts event.to_s
17
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rptrace"
4
+
5
+ pid_str = ARGV.shift || begin
6
+ warn "usage: bundle exec ruby examples/memory_reader.rb <pid> [address_hex] [length]"
7
+ exit 1
8
+ end
9
+
10
+ pid = Integer(pid_str, 10)
11
+
12
+ requested_address = nil
13
+ length_arg = nil
14
+
15
+ case ARGV.length
16
+ when 0
17
+ length_arg = "64"
18
+ when 1
19
+ token = ARGV[0]
20
+ if token.start_with?("0x", "0X")
21
+ requested_address = Integer(token, 0)
22
+ length_arg = "64"
23
+ else
24
+ length_arg = token
25
+ end
26
+ else
27
+ requested_address = Integer(ARGV[0], 0)
28
+ length_arg = ARGV[1]
29
+ end
30
+
31
+ length = Integer(length_arg, 10)
32
+ raise ArgumentError, "length must be positive" if length <= 0
33
+
34
+ tracee = Rptrace::Tracee.attach(pid)
35
+
36
+ begin
37
+ candidates = []
38
+ if requested_address
39
+ candidates << requested_address
40
+ else
41
+ registers = tracee.registers.read
42
+ arch_candidates = case Rptrace::CStructs.arch
43
+ when :x86_64 then [registers[:rdi], registers[:rsp]]
44
+ when :aarch64 then [registers[:x0], registers[:sp]]
45
+ else []
46
+ end
47
+ candidates.concat(arch_candidates.compact)
48
+ tracee.memory_maps.each do |map|
49
+ next unless map.readable?
50
+
51
+ candidates << map.start_addr
52
+ end
53
+ end
54
+
55
+ address = candidates.find do |candidate|
56
+ tracee.memory.read(candidate, 1)
57
+ true
58
+ rescue Rptrace::Error
59
+ false
60
+ end
61
+
62
+ unless address
63
+ warn "failed to find readable address for pid=#{pid}"
64
+ exit 1
65
+ end
66
+
67
+ data = tracee.memory.read(address, length)
68
+ puts "pid=#{pid} addr=0x#{address.to_s(16)} bytes=#{data.unpack1('H*')} size=#{data.bytesize}"
69
+ ensure
70
+ tracee&.detach
71
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rptrace"
4
+
5
+ if ARGV.empty?
6
+ warn "usage: bundle exec ruby examples/simple_strace.rb <command> [args...]"
7
+ exit 1
8
+ end
9
+
10
+ command = ARGV.shift
11
+
12
+ Rptrace.strace(command, *ARGV) do |event|
13
+ next unless event.exit?
14
+
15
+ puts event
16
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rptrace"
4
+
5
+ if ARGV.empty?
6
+ warn "usage: bundle exec ruby examples/syscall_counter.rb <command> [args...]"
7
+ exit 1
8
+ end
9
+
10
+ command = ARGV.shift
11
+ counts = Hash.new(0)
12
+
13
+ Rptrace.strace(command, *ARGV) do |event|
14
+ next unless event.exit?
15
+
16
+ counts[event.syscall.name] += 1
17
+ end
18
+
19
+ counts.sort_by { |(_name, count)| -count }.each do |name, count|
20
+ puts format("%-20s %d", name, count)
21
+ end
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fiddle"
4
+ require "fiddle/import"
5
+
6
+ module Rptrace
7
+ # Low-level libc bindings for ptrace and waitpid.
8
+ module Binding
9
+ extend Fiddle::Importer
10
+ include Constants
11
+
12
+ dlload Fiddle::Handle::DEFAULT
13
+
14
+ extern "long ptrace(int, int, unsigned long, unsigned long)"
15
+ extern "int waitpid(int, void*, int)"
16
+ extern "int fork()"
17
+ extern "int execvp(char*, void*)"
18
+
19
+ # Maps errno values to specific Rptrace error subclasses.
20
+ ERRNO_CLASS_MAP = {
21
+ Errno::EPERM::Errno => PermissionError,
22
+ Errno::ESRCH::Errno => NoProcessError,
23
+ Errno::EBUSY::Errno => BusyError,
24
+ Errno::EINVAL::Errno => InvalidArgError
25
+ }.freeze
26
+ # Guidance suffix appended to EPERM-related ptrace errors.
27
+ PERMISSION_HINT = "try running as root, granting CAP_SYS_PTRACE, and checking /proc/sys/kernel/yama/ptrace_scope".freeze
28
+
29
+ class << self
30
+ # Calls ptrace and raises mapped Rptrace::Error subclasses on failure.
31
+ #
32
+ # @param request [Integer, Symbol]
33
+ # @param pid [Integer]
34
+ # @param addr [Integer]
35
+ # @param data [Integer]
36
+ # @return [Integer]
37
+ def safe_ptrace(request, pid, addr, data)
38
+ clear_errno!
39
+ result = ptrace(request, pid, addr, data)
40
+ errno = Fiddle.last_error
41
+ return result unless result == -1 && errno.positive?
42
+
43
+ raise_ptrace_error(errno, request)
44
+ end
45
+
46
+ # Calls waitpid and decodes raw status.
47
+ #
48
+ # @param pid [Integer]
49
+ # @param flags [Integer]
50
+ # @return [Array<(Integer, Integer)>] waited pid and raw status
51
+ def safe_waitpid(pid, flags: 0)
52
+ status_ptr = Fiddle::Pointer.malloc(Fiddle::SIZEOF_INT)
53
+
54
+ loop do
55
+ clear_errno!
56
+ waited_pid = waitpid(pid, status_ptr, flags)
57
+ errno = Fiddle.last_error
58
+
59
+ next if waited_pid == -1 && errno == Errno::EINTR::Errno
60
+ raise_ptrace_error(errno, :waitpid) if waited_pid == -1
61
+
62
+ status = status_ptr[0, Fiddle::SIZEOF_INT].unpack1("i")
63
+ return [waited_pid, status]
64
+ end
65
+ end
66
+
67
+ # Resets thread-local errno to zero.
68
+ #
69
+ # @return [Integer]
70
+ def clear_errno!
71
+ Fiddle.last_error = 0
72
+ end
73
+
74
+ # Raises a mapped Rptrace::Error subclass for an errno code.
75
+ #
76
+ # @param errno [Integer]
77
+ # @param request [Integer, Symbol]
78
+ # @raise [Rptrace::Error]
79
+ # @return [void]
80
+ def raise_ptrace_error(errno, request)
81
+ klass = ERRNO_CLASS_MAP.fetch(errno, Error)
82
+ message = SystemCallError.new("ptrace", errno).message
83
+ message = "#{message}; #{PERMISSION_HINT}" if klass == PermissionError
84
+ raise klass.new(message, errno: errno, request: request)
85
+ end
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rptrace
4
+ # Software breakpoint descriptor for a traced process.
5
+ class Breakpoint
6
+ attr_reader :tracee, :address, :original_byte
7
+
8
+ # @param tracee [Rptrace::Tracee]
9
+ # @param address [Integer]
10
+ # @param original_byte [String] one-byte opcode before patching
11
+ # @param enabled [Boolean]
12
+ def initialize(tracee:, address:, original_byte:, enabled: true)
13
+ byte = original_byte.to_s.b
14
+ raise ArgumentError, "original_byte must be exactly one byte" unless byte.bytesize == 1
15
+
16
+ @tracee = tracee
17
+ @address = Integer(address)
18
+ @original_byte = byte
19
+ @enabled = enabled
20
+ end
21
+
22
+ # @return [Boolean]
23
+ def enabled?
24
+ @enabled
25
+ end
26
+
27
+ # @return [Rptrace::Breakpoint]
28
+ def disable!
29
+ @enabled = false
30
+ self
31
+ end
32
+
33
+ # Restores original opcode through the owning tracee.
34
+ #
35
+ # @return [Rptrace::Breakpoint, nil]
36
+ def restore
37
+ tracee.remove_breakpoint(address)
38
+ end
39
+
40
+ # @return [String]
41
+ def inspect
42
+ state = enabled? ? "enabled" : "disabled"
43
+ "#<#{self.class} addr=0x#{address.to_s(16)} state=#{state}>"
44
+ end
45
+ end
46
+ end