rails-profiler 0.30.15 → 0.31.1
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 +4 -4
- data/CHANGELOG.md +99 -0
- data/app/controllers/profiler/api/ajax_controller.rb +6 -0
- data/app/controllers/profiler/api/env_vars_controller.rb +10 -0
- data/lib/profiler/cluster/security.rb +46 -4
- data/lib/profiler/cluster/slave_proxy.rb +6 -0
- data/lib/profiler/collectors/http_collector.rb +1 -1
- data/lib/profiler/configuration.rb +36 -5
- data/lib/profiler/console_profiler.rb +1 -1
- data/lib/profiler/env_override_store.rb +54 -43
- data/lib/profiler/job_profiler.rb +1 -1
- data/lib/profiler/mcp/body_formatter.rb +4 -4
- data/lib/profiler/mcp/file_cache.rb +19 -7
- data/lib/profiler/mcp/tools/delete_env_var.rb +3 -1
- data/lib/profiler/mcp/tools/list_env_vars.rb +3 -0
- data/lib/profiler/mcp/tools/reset_all_env_vars.rb +3 -0
- data/lib/profiler/mcp/tools/reset_env_var.rb +3 -0
- data/lib/profiler/mcp/tools/set_env_var.rb +2 -0
- data/lib/profiler/storage/base_store.rb +5 -1
- data/lib/profiler/storage/blob_store.rb +28 -4
- data/lib/profiler/storage/file_store.rb +21 -15
- data/lib/profiler/storage/memory_store.rb +6 -0
- data/lib/profiler/storage/private_files.rb +135 -0
- data/lib/profiler/storage/redis_store.rb +6 -0
- data/lib/profiler/storage/sqlite_store.rb +26 -8
- data/lib/profiler/storage/token.rb +24 -0
- data/lib/profiler/test_profiler.rb +1 -1
- data/lib/profiler/version.rb +1 -1
- data/lib/profiler.rb +11 -0
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f57ca8fcbd7fef2ddd81f41ab6c1b7b254e814ec986692ed906533f0c9042d4b
|
|
4
|
+
data.tar.gz: 66e0072789af17ddb711b3911c60d5ebaa683d040117ee44f2dba7b30e126ce7
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 9378049525fb7fa8127ddcbe51be0f02a2fe27915cae7aad5cdbce22ce80ba6a5c2ee69b67980366f51f32cb29306c6d14d41878b3773aadaff4a7c5df0fe661
|
|
7
|
+
data.tar.gz: 1b881dbe5033362810063c9d86946978c94e7dcb95cad19130a729e80f1e6bdbfff20e754feb6f8c9f37dcb3d7bc3dd2571e282463824cd918fa0ea201f1087e
|
data/CHANGELOG.md
CHANGED
|
@@ -19,6 +19,105 @@ every commit of every tag interval is accounted for one way or the other.
|
|
|
19
19
|
|
|
20
20
|
## [Unreleased]
|
|
21
21
|
|
|
22
|
+
## [0.31.1] - 2026-10-05
|
|
23
|
+
|
|
24
|
+
<!-- stamped -->
|
|
25
|
+
|
|
26
|
+
### Fixed
|
|
27
|
+
|
|
28
|
+
- **Storage:** Keep the file store's profiles in `tmp_path/profiles` by default, and take only the
|
|
29
|
+
files named after a profile token for profiles. Profiles shared `tmp_path` with
|
|
30
|
+
`env_overrides.json`, which the store listed as a profile with no token and deleted when the
|
|
31
|
+
profiles were cleared. An application that sets `storage_options[:path]` keeps its directory,
|
|
32
|
+
used as given (no subdirectory is added).
|
|
33
|
+
- **MCP:** Keep the bodies saved by the MCP tools in `tmp_path/mcp-cache`, and let their hourly
|
|
34
|
+
cleanup remove only the token directories it wrote there. It removed every directory of
|
|
35
|
+
`tmp_path` older than an hour, the SQLite store's blobs included, at random (one save in
|
|
36
|
+
twenty), which lost the stored HTTP response bodies.
|
|
37
|
+
- **Configuration:** `config.tmp_path` is always a `Pathname`: it was a `String` outside Rails, and
|
|
38
|
+
kept a `String` assigned to it, which made every env override operation fail with only a
|
|
39
|
+
warning. A failure to read or write the env overrides now reaches the Env tab (`500` with the
|
|
40
|
+
error, `ENV` left unchanged) and the MCP env tools (an error answer) instead of reporting a
|
|
41
|
+
success; at boot, before a Sidekiq job and before a console evaluation it is still a warning,
|
|
42
|
+
so an override never makes the application fail.
|
|
43
|
+
- **Jobs:** A storage error while saving a profile no longer makes the application fail: a job,
|
|
44
|
+
a console command, a test, or an outbound HTTP call that ends after its profile was collected
|
|
45
|
+
now goes on, with its own result or its own exception, and the error is printed as a warning.
|
|
46
|
+
The save of a job, console or test profile ran with nothing to catch it, so a full disk, a
|
|
47
|
+
Redis that went away or, from this version, a symbolic link refused in place of the SQLite
|
|
48
|
+
database made the job fail, or replaced the job's own exception. The HTTP middleware already
|
|
49
|
+
kept requests going.
|
|
50
|
+
- **Upgrading:** for the default `tmp_path`; adapt the paths if you set one.
|
|
51
|
+
- Profiles saved by the file store in `tmp/rails-profiler` itself are no longer listed, and are
|
|
52
|
+
not moved. To remove them, with the old MCP body cache, run from the application root:
|
|
53
|
+
```sh
|
|
54
|
+
LC_ALL=C find tmp/rails-profiler -maxdepth 1 -type f -name '[0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f].json' -delete
|
|
55
|
+
LC_ALL=C find tmp/rails-profiler -mindepth 1 -maxdepth 1 -type d -name '[0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f]' -exec rm -rf {} +
|
|
56
|
+
```
|
|
57
|
+
Both only match names made of 32 lower-case hexadecimal digits, a profile token (`LC_ALL=C`
|
|
58
|
+
keeps `[0-9a-f]` from matching upper-case letters in some locales): `env_overrides.json`, the
|
|
59
|
+
SQLite database, `blobs`, `profiles` and `mcp-cache` are left alone.
|
|
60
|
+
- To keep reading the old profiles instead, set
|
|
61
|
+
`config.storage_options = { path: Rails.root.join("tmp", "rails-profiler") }`, and then do
|
|
62
|
+
**not** run the first command: it would delete the current profiles.
|
|
63
|
+
- The directory `tmp/rails-profiler` created by an earlier version keeps its mode: run
|
|
64
|
+
`chmod 700 tmp/rails-profiler` to restrict it.
|
|
65
|
+
- If the web process and the workers reading the env overrides or a shared file or SQLite store
|
|
66
|
+
run as two different users, set `config.restrict_storage_permissions = false` to create new
|
|
67
|
+
files with the modes of the umask, as before. It does not reopen what was already created
|
|
68
|
+
`0700` and `0600`: run `chmod -R u=rwX,go=rX tmp/rails-profiler` for the modes earlier
|
|
69
|
+
versions created with the usual umask (`0755` and `0644`). A worker under another user that
|
|
70
|
+
also writes there (it opens `env_overrides.json.lock` for writing) needs the
|
|
71
|
+
files belonging to a group of both users (the same primary group for both, or
|
|
72
|
+
`chgrp -R <group> tmp/rails-profiler` then `find tmp/rails-profiler -type d -exec chmod g+s {} +`
|
|
73
|
+
so that new files inherit it), a umask of `002` for both processes, and
|
|
74
|
+
`chmod -R ug=rwX,o=rX tmp/rails-profiler`. The option also follows a symbolic link in place
|
|
75
|
+
of the SQLite database, its `-wal` or `-shm` file or the lock of the env overrides, and drops
|
|
76
|
+
the warning about a shared `tmp_path`, as before.
|
|
77
|
+
|
|
78
|
+
### Security
|
|
79
|
+
|
|
80
|
+
- **Storage:** Check every profile token against the format the profiler issues (32 hexadecimal
|
|
81
|
+
characters) in every storage backend (file, memory, Redis, SQLite and its blobs), before it
|
|
82
|
+
becomes a file name, a directory or a key. A token such as `../../config/important` reached the
|
|
83
|
+
file store as a path: `DELETE /_profiler/api/profiles/:id` deleted, and
|
|
84
|
+
`POST /_profiler/api/ajax/link` overwrote, a `.json` file outside the storage directory. A
|
|
85
|
+
malformed token is now not found (`404` from the API, `nil` from the storage, no exception),
|
|
86
|
+
whoever asks: the controllers, the cluster proxy, which no longer forwards it to a slave, or the
|
|
87
|
+
MCP tools. Saving under one raises an `ArgumentError`. `POST /_profiler/api/ajax/link` answers
|
|
88
|
+
`400` for a malformed `parent_token` instead of storing it. The MCP tools no longer save a body
|
|
89
|
+
(`save_bodies`) under a token sent by a slave that is not a profile token. There is no option to
|
|
90
|
+
accept other tokens: the profiler never issues them, and accepting them would reopen the path.
|
|
91
|
+
- **Storage:** Create the profiler's directories `0700` and its files `0600`, whatever the umask:
|
|
92
|
+
the file store's profiles, the SQLite database and its `-wal` and `-shm` files, the blobs, the
|
|
93
|
+
env overrides with their lock and temporary files, and the MCP body cache. They were created
|
|
94
|
+
with the process defaults, usually `0755` and `0644`, readable by every local user although
|
|
95
|
+
profiles hold cookies, tokens and environment values. A file the profiler writes again (a
|
|
96
|
+
profile, the SQLite database at startup) is brought back to `0600`; a directory that already
|
|
97
|
+
exists keeps its mode. Writing a profile now goes through a temporary file renamed over it. A
|
|
98
|
+
symbolic link placed where the profiler keeps its SQLite database, its `-wal` or `-shm` file or
|
|
99
|
+
the lock of the env overrides is refused instead of followed, and an existing `tmp_path` that
|
|
100
|
+
belongs to another user or that group or others can write to is reported once with a warning.
|
|
101
|
+
|
|
102
|
+
## [0.31.0] - 2026-10-05
|
|
103
|
+
|
|
104
|
+
<!-- stamped -->
|
|
105
|
+
|
|
106
|
+
### Added
|
|
107
|
+
|
|
108
|
+
- **Cluster:** `config.cluster_allowed_slave_urls` accepts `Regexp` entries next to URLs, for
|
|
109
|
+
slaves whose names are not known in advance, such as one container per git worktree:
|
|
110
|
+
`[%r{\Ahttp://travel-api-[a-z0-9-]+:3000\z}]`. Until now such a setup had to fall back to
|
|
111
|
+
`:any`, which reopens the server-side request forgery closed in 0.30.11. A pattern must start
|
|
112
|
+
with `\A` and end with `\z`, and compile once wrapped to match the whole URL; any other `Regexp`
|
|
113
|
+
raises an `ArgumentError` when assigned. It is matched as a whole against the URL as the master
|
|
114
|
+
keeps it (lower-case scheme and host, no default port, no trailing slash), after the existing
|
|
115
|
+
checks, which it cannot lift: user info, a query, a fragment or a `.` or `..` segment are still
|
|
116
|
+
refused, and plain HTTP to a host that is not a loopback address still needs
|
|
117
|
+
`cluster_allow_insecure_http`. A `String` entry is always a URL, never a pattern. The default,
|
|
118
|
+
`[]`, still refuses every URL. A slave URL whose path segment decodes to a `\` is now refused
|
|
119
|
+
like one that decodes to a `/`.
|
|
120
|
+
|
|
22
121
|
## [0.30.15] - 2026-10-05
|
|
23
122
|
|
|
24
123
|
<!-- stamped -->
|
|
@@ -12,6 +12,12 @@ module Profiler
|
|
|
12
12
|
return render json: { error: "Missing parent_token or child_token" }, status: :bad_request
|
|
13
13
|
end
|
|
14
14
|
|
|
15
|
+
# The parent token is written into the child profile: a value the gem never issues is
|
|
16
|
+
# refused rather than stored. A malformed child token is not found by the storage.
|
|
17
|
+
unless Profiler::Storage::Token.valid?(parent_token)
|
|
18
|
+
return render json: { error: "Invalid parent_token" }, status: :bad_request
|
|
19
|
+
end
|
|
20
|
+
|
|
15
21
|
# Load child profile
|
|
16
22
|
child_profile = Profiler.storage.load(child_token)
|
|
17
23
|
unless child_profile
|
|
@@ -3,6 +3,13 @@
|
|
|
3
3
|
module Profiler
|
|
4
4
|
module Api
|
|
5
5
|
class EnvVarsController < ApplicationController
|
|
6
|
+
# The overrides file could not be read or written: nothing was changed, ENV included.
|
|
7
|
+
rescue_from Profiler::EnvOverrideStore::Error do |error|
|
|
8
|
+
key = params[:key].to_s.strip
|
|
9
|
+
message = key.empty? ? "#{error.message}; nothing was changed" : "#{error.message}; #{key} was left unchanged"
|
|
10
|
+
render json: { error: message }, status: :internal_server_error
|
|
11
|
+
end
|
|
12
|
+
|
|
6
13
|
def show
|
|
7
14
|
variables = Profiler::Redaction.env_snapshot
|
|
8
15
|
overrides = Profiler::Redaction.env_overrides(Profiler.env_override_store.all_overrides)
|
|
@@ -72,8 +79,11 @@ module Profiler
|
|
|
72
79
|
ENV[key].nil? ? nil : Profiler::Redaction.env_value(key, ENV[key])
|
|
73
80
|
end
|
|
74
81
|
|
|
82
|
+
# Read back after a change that succeeded: a failure here must not answer that nothing changed.
|
|
75
83
|
def redacted_override(key)
|
|
76
84
|
Profiler::Redaction.env_overrides(Profiler.env_override_store.all_overrides)[key]
|
|
85
|
+
rescue Profiler::EnvOverrideStore::Error
|
|
86
|
+
nil
|
|
77
87
|
end
|
|
78
88
|
end
|
|
79
89
|
end
|
|
@@ -82,11 +82,27 @@ module Profiler
|
|
|
82
82
|
|
|
83
83
|
allowed = config.cluster_allowed_slave_urls
|
|
84
84
|
return nil if allowed == :any
|
|
85
|
-
|
|
85
|
+
|
|
86
|
+
registered = normalized_url(url)
|
|
87
|
+
return nil if Array(allowed).any? { |entry| entry_allows?(entry, target, registered) }
|
|
86
88
|
|
|
87
89
|
"#{url} is not allowed by config.cluster_allowed_slave_urls"
|
|
88
90
|
end
|
|
89
91
|
|
|
92
|
+
# Whether a Regexp entry of cluster_allowed_slave_urls is anchored at both ends: its source
|
|
93
|
+
# starts with \A and ends with an unescaped \z. \Z, ^ and $ do not count, since they let a
|
|
94
|
+
# trailing newline or another line through. A check of the written form, so that a pattern
|
|
95
|
+
# reads as what it does; the match itself is made whole by whole_url_pattern.
|
|
96
|
+
def anchored_pattern?(pattern)
|
|
97
|
+
return false unless pattern.is_a?(Regexp)
|
|
98
|
+
|
|
99
|
+
source = pattern.source
|
|
100
|
+
return false unless source.start_with?("\\A")
|
|
101
|
+
|
|
102
|
+
ending = source.match(/(\\+)z\z/)
|
|
103
|
+
!ending.nil? && ending[1].length.odd? && source.length > 4
|
|
104
|
+
end
|
|
105
|
+
|
|
90
106
|
# Same transport rule for the master URL a slave sends its secret to.
|
|
91
107
|
def master_url_denial(url)
|
|
92
108
|
target = normalize(url)
|
|
@@ -139,6 +155,32 @@ module Profiler
|
|
|
139
155
|
"#{path.inspect} is not a valid path"
|
|
140
156
|
end
|
|
141
157
|
|
|
158
|
+
# One entry of cluster_allowed_slave_urls against a URL already through normalize and the
|
|
159
|
+
# transport rule. A Regexp is matched, as a whole, against the URL as the registry keeps it
|
|
160
|
+
# (normalized_url), and only when anchored; anything else is an URL compared by covers?,
|
|
161
|
+
# so a String is never read as a pattern.
|
|
162
|
+
def entry_allows?(entry, target, registered)
|
|
163
|
+
if entry.is_a?(Regexp)
|
|
164
|
+
return false unless registered && anchored_pattern?(entry)
|
|
165
|
+
|
|
166
|
+
whole_url_pattern(entry).match?(registered)
|
|
167
|
+
else
|
|
168
|
+
covers?(normalize(entry), target)
|
|
169
|
+
end
|
|
170
|
+
rescue RegexpError
|
|
171
|
+
false
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# The pattern wrapped again in \A(?:...)\z, so that a top-level alternation (\Aa|b\z)
|
|
175
|
+
# cannot leave one branch anchored at a single end: this wrapping, not anchored_pattern?,
|
|
176
|
+
# is what makes the match cover the whole URL. Compiled once per pattern. Raises
|
|
177
|
+
# RegexpError when the wrapping does not compile, for instance when an x-mode comment
|
|
178
|
+
# swallows the closing \z.
|
|
179
|
+
def whole_url_pattern(pattern)
|
|
180
|
+
@whole_url_patterns ||= {}
|
|
181
|
+
@whole_url_patterns[pattern] ||= Regexp.new("\\A(?:#{pattern.source})\\z", pattern.options)
|
|
182
|
+
end
|
|
183
|
+
|
|
142
184
|
# Scheme, host and port compared after normalization (case, default port, IPv6 brackets),
|
|
143
185
|
# and a path prefix compared segment by segment.
|
|
144
186
|
def covers?(entry, target)
|
|
@@ -149,8 +191,8 @@ module Profiler
|
|
|
149
191
|
end
|
|
150
192
|
|
|
151
193
|
# Returns { scheme:, host:, port:, segments: }, or nil for anything that is not a plain
|
|
152
|
-
# http(s) URL: user info, a query, a fragment, or a "." or ".." path segment
|
|
153
|
-
# rather than interpreted.
|
|
194
|
+
# http(s) URL: user info, a query, a fragment, or a "." or ".." path segment, or one that
|
|
195
|
+
# decodes to a "/" or a "\\", are refused rather than interpreted.
|
|
154
196
|
def normalize(url)
|
|
155
197
|
uri = URI.parse(url.to_s.strip)
|
|
156
198
|
return nil unless uri.is_a?(URI::HTTP) && uri.host && !uri.host.empty?
|
|
@@ -158,7 +200,7 @@ module Profiler
|
|
|
158
200
|
|
|
159
201
|
segments = uri.path.to_s.split("/").reject(&:empty?)
|
|
160
202
|
decoded = segments.map { |segment| URI.decode_www_form_component(segment) }
|
|
161
|
-
return nil if decoded.any? { |segment| segment == "." || segment == ".." || segment.
|
|
203
|
+
return nil if decoded.any? { |segment| segment == "." || segment == ".." || segment.match?(%r{[/\\]}) }
|
|
162
204
|
|
|
163
205
|
{ scheme: uri.scheme.downcase, host: uri.hostname.downcase, port: uri.port, segments: decoded }
|
|
164
206
|
rescue URI::Error, ArgumentError
|
|
@@ -4,6 +4,7 @@ require "net/http"
|
|
|
4
4
|
require "json"
|
|
5
5
|
require "uri"
|
|
6
6
|
require_relative "security"
|
|
7
|
+
require_relative "../storage/token"
|
|
7
8
|
|
|
8
9
|
module Profiler
|
|
9
10
|
module Cluster
|
|
@@ -39,7 +40,10 @@ module Profiler
|
|
|
39
40
|
Array(data["profiles"]).map { |h| profile_from_api(h) }
|
|
40
41
|
end
|
|
41
42
|
|
|
43
|
+
# A token that is not one the gem issues is not found, without asking the slave.
|
|
42
44
|
def load(token)
|
|
45
|
+
return nil unless Storage::Token.valid?(token)
|
|
46
|
+
|
|
43
47
|
data = get_json("/_profiler/api/profiles/#{Security.escape_segment(token)}")
|
|
44
48
|
return nil unless data["token"] || data["profile"]
|
|
45
49
|
|
|
@@ -48,6 +52,8 @@ module Profiler
|
|
|
48
52
|
end
|
|
49
53
|
|
|
50
54
|
def find_by_parent(parent_token)
|
|
55
|
+
return [] unless Storage::Token.valid?(parent_token)
|
|
56
|
+
|
|
51
57
|
data = get_json("/_profiler/api/profiles", parent_token: parent_token, all_types: true)
|
|
52
58
|
Array(data["profiles"]).map { |h| profile_from_api(h) }
|
|
53
59
|
end
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "pathname"
|
|
3
4
|
require_relative "local_request"
|
|
4
5
|
|
|
5
6
|
module Profiler
|
|
@@ -39,7 +40,7 @@ module Profiler
|
|
|
39
40
|
:track_http, :slow_http_threshold, :http_skip_hosts, :http_backtrace_depth,
|
|
40
41
|
:track_jobs,
|
|
41
42
|
:track_console,
|
|
42
|
-
:apply_env_overrides_when_disabled,
|
|
43
|
+
:apply_env_overrides_when_disabled, :restrict_storage_permissions,
|
|
43
44
|
:test_runner_allow_undiscovered_files,
|
|
44
45
|
:track_mailers, :capture_mail_body, :sanitize_mailer_recipients, :mailer_skip_actions,
|
|
45
46
|
:compress_bodies, :compress_body_threshold,
|
|
@@ -47,9 +48,7 @@ module Profiler
|
|
|
47
48
|
:name, :master_url, :self_url,
|
|
48
49
|
:cluster_heartbeat_interval, :cluster_offline_threshold,
|
|
49
50
|
:cluster_master, :cluster_secret, :cluster_require_secret,
|
|
50
|
-
:
|
|
51
|
-
|
|
52
|
-
attr_writer :tmp_path
|
|
51
|
+
:cluster_allow_insecure_http
|
|
53
52
|
|
|
54
53
|
attr_reader :authorize_block, :enabled, :track_tests
|
|
55
54
|
|
|
@@ -100,6 +99,9 @@ module Profiler
|
|
|
100
99
|
@filter_parameters = DEFAULT_FILTER_PARAMETERS.dup
|
|
101
100
|
@env_allowlist = DEFAULT_ENV_ALLOWLIST.dup
|
|
102
101
|
@tmp_path = nil
|
|
102
|
+
# The profiler's directories 0700 and files 0600 (Profiler::Storage::PrivateFiles). false
|
|
103
|
+
# leaves the modes to the umask, for a web process and workers running under two users.
|
|
104
|
+
@restrict_storage_permissions = true
|
|
103
105
|
@name = nil
|
|
104
106
|
@master_url = nil
|
|
105
107
|
@self_url = nil
|
|
@@ -113,10 +115,39 @@ module Profiler
|
|
|
113
115
|
@cluster_allow_insecure_http = false
|
|
114
116
|
end
|
|
115
117
|
|
|
118
|
+
# Always a Pathname, whether set from a String or left to its default.
|
|
116
119
|
def tmp_path
|
|
117
120
|
@tmp_path || default_tmp_path
|
|
118
121
|
end
|
|
119
122
|
|
|
123
|
+
def tmp_path=(value)
|
|
124
|
+
@tmp_path = value.nil? ? nil : Pathname.new(value)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
attr_reader :cluster_allowed_slave_urls
|
|
128
|
+
|
|
129
|
+
# :any, or a list of slave URLs and anchored Regexp patterns (see Profiler::Cluster::Security).
|
|
130
|
+
# A Regexp that is not anchored with \A and \z is refused here, at boot, rather than ignored.
|
|
131
|
+
def cluster_allowed_slave_urls=(value)
|
|
132
|
+
unless value == :any
|
|
133
|
+
require_relative "cluster/security"
|
|
134
|
+
Array(value).grep(Regexp).each do |pattern|
|
|
135
|
+
unless Profiler::Cluster::Security.anchored_pattern?(pattern)
|
|
136
|
+
raise ArgumentError, "config.cluster_allowed_slave_urls: #{pattern.inspect} must start with \\A and " \
|
|
137
|
+
"end with \\z, so that it matches the whole slave URL"
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
begin
|
|
141
|
+
Profiler::Cluster::Security.whole_url_pattern(pattern)
|
|
142
|
+
rescue RegexpError => e
|
|
143
|
+
raise ArgumentError, "config.cluster_allowed_slave_urls: #{pattern.inspect} cannot be matched against " \
|
|
144
|
+
"the whole slave URL (#{e.message}); an x-mode comment must not hide the \\z"
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
@cluster_allowed_slave_urls = value
|
|
149
|
+
end
|
|
150
|
+
|
|
120
151
|
def enabled=(value)
|
|
121
152
|
@assigned |= [:enabled]
|
|
122
153
|
@enabled = value
|
|
@@ -202,7 +233,7 @@ module Profiler
|
|
|
202
233
|
if defined?(Rails) && Rails.respond_to?(:root) && Rails.root
|
|
203
234
|
Rails.root.join("tmp", "rails-profiler")
|
|
204
235
|
else
|
|
205
|
-
File.expand_path("tmp/rails-profiler", Dir.pwd)
|
|
236
|
+
Pathname.new(File.expand_path("tmp/rails-profiler", Dir.pwd))
|
|
206
237
|
end
|
|
207
238
|
end
|
|
208
239
|
|
|
@@ -2,9 +2,16 @@
|
|
|
2
2
|
|
|
3
3
|
require "json"
|
|
4
4
|
require "fileutils"
|
|
5
|
+
require_relative "storage/private_files"
|
|
5
6
|
|
|
6
7
|
module Profiler
|
|
7
8
|
class EnvOverrideStore
|
|
9
|
+
# Raised by the methods a caller asked for (set, delete, reset, reset_all, clear and
|
|
10
|
+
# all_overrides) when the overrides file cannot be read or written. The caller then knows that
|
|
11
|
+
# nothing was changed. apply! and apply_at_boot! still only warn: they run at boot and before
|
|
12
|
+
# the application's jobs, where an override must never make the application fail.
|
|
13
|
+
class Error < Profiler::Error; end
|
|
14
|
+
|
|
8
15
|
DELETED_SENTINEL = "__profiler_deleted__"
|
|
9
16
|
RESTORE_SENTINEL = "__profiler_restore__"
|
|
10
17
|
|
|
@@ -30,53 +37,53 @@ module Profiler
|
|
|
30
37
|
end
|
|
31
38
|
|
|
32
39
|
def set(key, value)
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
40
|
+
surfacing("set #{key}") do
|
|
41
|
+
remember_process_original(key)
|
|
42
|
+
update_overrides do |overrides|
|
|
43
|
+
overrides[key] = { "value" => value.to_s, "original" => original_for(overrides, key) }
|
|
44
|
+
end
|
|
36
45
|
end
|
|
37
|
-
rescue => e
|
|
38
|
-
warn "[Profiler] EnvOverrideStore: failed to set #{key}: #{e.message}"
|
|
39
46
|
end
|
|
40
47
|
|
|
41
48
|
def delete(key)
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
49
|
+
surfacing("delete #{key}") do
|
|
50
|
+
remember_process_original(key)
|
|
51
|
+
update_overrides do |overrides|
|
|
52
|
+
overrides[key] = { "value" => DELETED_SENTINEL, "original" => original_for(overrides, key) }
|
|
53
|
+
end
|
|
45
54
|
end
|
|
46
|
-
rescue => e
|
|
47
|
-
warn "[Profiler] EnvOverrideStore: failed to delete #{key}: #{e.message}"
|
|
48
55
|
end
|
|
49
56
|
|
|
50
57
|
# Returns whether ENV was written in this process.
|
|
51
58
|
def reset(key)
|
|
52
59
|
entry = nil
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
60
|
+
surfacing("reset #{key}") do
|
|
61
|
+
update_overrides do |overrides|
|
|
62
|
+
entry = overrides[key]
|
|
63
|
+
# Keep a RESTORE entry so Sidekiq workers pick it up on next job
|
|
64
|
+
overrides[key] = { "value" => RESTORE_SENTINEL, "original" => entry["original"] } if entry
|
|
65
|
+
end
|
|
57
66
|
end
|
|
58
67
|
|
|
59
68
|
# Apply immediately to the current (web) process
|
|
60
69
|
restore_env(key, entry, blocked: blocked_reason)
|
|
61
|
-
rescue => e
|
|
62
|
-
warn "[Profiler] EnvOverrideStore: failed to reset #{key}: #{e.message}"
|
|
63
70
|
end
|
|
64
71
|
|
|
65
72
|
def reset_all
|
|
66
73
|
originals = {}
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
74
|
+
surfacing("reset the overrides") do
|
|
75
|
+
update_overrides do |overrides|
|
|
76
|
+
overrides.each do |key, entry|
|
|
77
|
+
originals[key] = entry.is_a?(Hash) ? entry["original"] : nil
|
|
78
|
+
# Leave a RESTORE sentinel for Sidekiq workers to pick up
|
|
79
|
+
overrides[key] = { "value" => RESTORE_SENTINEL, "original" => originals[key] }
|
|
80
|
+
end
|
|
72
81
|
end
|
|
73
82
|
end
|
|
74
83
|
|
|
75
84
|
# Apply immediately to the current (web) process
|
|
76
85
|
blocked = blocked_reason
|
|
77
86
|
originals.each { |key, original| restore_env(key, { "original" => original }, blocked: blocked) }
|
|
78
|
-
rescue => e
|
|
79
|
-
warn "[Profiler] EnvOverrideStore: failed to reset all: #{e.message}"
|
|
80
87
|
end
|
|
81
88
|
|
|
82
89
|
# Why the persisted overrides must not reach ENV in this process, or nil when they may.
|
|
@@ -154,27 +161,36 @@ module Profiler
|
|
|
154
161
|
warn "[Profiler] EnvOverrideStore: failed to apply overrides: #{e.message}"
|
|
155
162
|
end
|
|
156
163
|
|
|
157
|
-
# Returns active overrides (excludes RESTORE entries
|
|
164
|
+
# Returns active overrides (excludes RESTORE entries, already being reverted)
|
|
165
|
+
# A file that does not parse reads as no override; one that cannot be read raises Error.
|
|
158
166
|
def all_overrides
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
167
|
+
surfacing("read the overrides") do
|
|
168
|
+
load_overrides.reject do |_, entry|
|
|
169
|
+
value = entry.is_a?(Hash) ? entry["value"] : entry
|
|
170
|
+
value == RESTORE_SENTINEL
|
|
171
|
+
end.transform_values do |entry|
|
|
172
|
+
entry.is_a?(Hash) ? entry : { "value" => entry, "original" => nil }
|
|
173
|
+
end
|
|
164
174
|
end
|
|
165
|
-
rescue => e
|
|
166
|
-
warn "[Profiler] EnvOverrideStore: failed to load overrides: #{e.message}"
|
|
167
|
-
{}
|
|
168
175
|
end
|
|
169
176
|
|
|
170
177
|
def clear
|
|
171
|
-
with_lock { FileUtils.rm_f(override_file_path) }
|
|
172
|
-
rescue => e
|
|
173
|
-
warn "[Profiler] EnvOverrideStore: failed to clear: #{e.message}"
|
|
178
|
+
surfacing("clear the overrides") { with_lock { FileUtils.rm_f(override_file_path) } }
|
|
174
179
|
end
|
|
175
180
|
|
|
176
181
|
private
|
|
177
182
|
|
|
183
|
+
# The message names the file relative to tmp_path, and an Errno by its description only: it
|
|
184
|
+
# goes back to the Env tab, the MCP tools and the test runner output, without absolute paths.
|
|
185
|
+
def surfacing(action)
|
|
186
|
+
yield
|
|
187
|
+
rescue Error
|
|
188
|
+
raise
|
|
189
|
+
rescue StandardError => e
|
|
190
|
+
reason = e.is_a?(SystemCallError) ? e.class.new.message : e.class.name
|
|
191
|
+
raise Error, "could not #{action} in env_overrides.json under tmp_path: #{reason}"
|
|
192
|
+
end
|
|
193
|
+
|
|
178
194
|
def remember_process_original(key)
|
|
179
195
|
@process_originals_lock.synchronize do
|
|
180
196
|
@process_originals[key] = ENV[key] unless @process_originals.key?(key)
|
|
@@ -227,8 +243,8 @@ module Profiler
|
|
|
227
243
|
# An exclusive flock on a file next to the overrides file. Not reentrant: never nest it.
|
|
228
244
|
def with_lock
|
|
229
245
|
path = override_file_path
|
|
230
|
-
|
|
231
|
-
|
|
246
|
+
Storage::PrivateFiles.tmp_dir
|
|
247
|
+
Storage::PrivateFiles.open("#{path}.lock") do |lock|
|
|
232
248
|
lock.flock(File::LOCK_EX)
|
|
233
249
|
yield
|
|
234
250
|
end
|
|
@@ -237,12 +253,7 @@ module Profiler
|
|
|
237
253
|
# Written to a temporary file then renamed over the old one, so that a reader never sees a
|
|
238
254
|
# half-written file, which would parse as no override at all.
|
|
239
255
|
def save_overrides(overrides)
|
|
240
|
-
|
|
241
|
-
tmp = "#{path}.#{Process.pid}.#{Thread.current.object_id}.tmp"
|
|
242
|
-
File.write(tmp, JSON.generate(overrides))
|
|
243
|
-
File.rename(tmp, path)
|
|
244
|
-
ensure
|
|
245
|
-
FileUtils.rm_f(tmp) if tmp
|
|
256
|
+
Storage::PrivateFiles.write(override_file_path, JSON.generate(overrides))
|
|
246
257
|
end
|
|
247
258
|
end
|
|
248
259
|
end
|
|
@@ -9,10 +9,10 @@ module Profiler
|
|
|
9
9
|
# Formats a body string for MCP output.
|
|
10
10
|
#
|
|
11
11
|
# params keys used:
|
|
12
|
-
# "save_bodies" (boolean)
|
|
13
|
-
# "max_body_size" (number)
|
|
14
|
-
# "json_path" (string)
|
|
15
|
-
# "xml_path" (string)
|
|
12
|
+
# "save_bodies" (boolean): save to {tmp_path}/mcp-cache/{token}/{name} and return path
|
|
13
|
+
# "max_body_size" (number): truncate inline body at N chars
|
|
14
|
+
# "json_path" (string): JSONPath to extract from body when save_bodies is true
|
|
15
|
+
# "xml_path" (string): XPath to extract from body when save_bodies is true
|
|
16
16
|
#
|
|
17
17
|
# Returns a formatted string ready to embed in markdown, or nil if body is blank.
|
|
18
18
|
def self.format_body(token, name, body, encoding, params)
|
|
@@ -1,22 +1,31 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "fileutils"
|
|
4
|
+
require_relative "../storage/private_files"
|
|
5
|
+
require_relative "../storage/token"
|
|
4
6
|
|
|
5
7
|
module Profiler
|
|
6
8
|
module MCP
|
|
9
|
+
# The bodies the MCP tools save on request (save_bodies), under tmp_path/mcp-cache/<token>/<name>.
|
|
10
|
+
# The token comes from a profile, which a slave may have sent: one that is not a token the gem
|
|
11
|
+
# issues, or a name that is not one the tools use, is refused. The cleanup only removes the
|
|
12
|
+
# token directories of this cache: tmp_path also holds the profiles, the SQLite blobs and the
|
|
13
|
+
# env overrides.
|
|
7
14
|
class FileCache
|
|
15
|
+
DIR_NAME = "mcp-cache"
|
|
16
|
+
NAME_FORMAT = /\A[a-z0-9_]+\z/
|
|
17
|
+
|
|
8
18
|
def self.base_dir
|
|
9
|
-
Profiler.configuration.tmp_path.to_s
|
|
19
|
+
Profiler.configuration.tmp_path.join(DIR_NAME).to_s
|
|
10
20
|
end
|
|
11
21
|
|
|
12
22
|
def self.save(token, name, content)
|
|
23
|
+
return nil unless Storage::Token.valid?(token) && name.is_a?(String) && NAME_FORMAT.match?(name)
|
|
24
|
+
|
|
13
25
|
cleanup if rand < 0.05
|
|
14
26
|
|
|
15
|
-
dir =
|
|
16
|
-
|
|
17
|
-
path = File.join(dir, name)
|
|
18
|
-
File.write(path, content)
|
|
19
|
-
path
|
|
27
|
+
dir = Storage::PrivateFiles.tmp_dir(DIR_NAME, token)
|
|
28
|
+
Storage::PrivateFiles.write(dir.join(name), content)
|
|
20
29
|
rescue Errno::EACCES, Errno::EROFS
|
|
21
30
|
nil
|
|
22
31
|
end
|
|
@@ -25,7 +34,10 @@ module Profiler
|
|
|
25
34
|
bd = base_dir
|
|
26
35
|
return unless Dir.exist?(bd)
|
|
27
36
|
|
|
28
|
-
Dir.
|
|
37
|
+
Dir.children(bd).each do |entry|
|
|
38
|
+
next unless Storage::Token.valid?(entry)
|
|
39
|
+
|
|
40
|
+
dir = File.join(bd, entry)
|
|
29
41
|
FileUtils.rm_rf(dir) if File.directory?(dir) && (Time.now - File.mtime(dir)) > max_age
|
|
30
42
|
end
|
|
31
43
|
end
|
|
@@ -26,7 +26,9 @@ module Profiler
|
|
|
26
26
|
Profiler.env_override_store.delete(key)
|
|
27
27
|
ENV.delete(key)
|
|
28
28
|
|
|
29
|
-
[{ type: "text", text: "Deleted #{key}. Override persisted
|
|
29
|
+
[{ type: "text", text: "Deleted #{key}. Override persisted: it will remain deleted across restarts until reset." }]
|
|
30
|
+
rescue Profiler::EnvOverrideStore::Error => e
|
|
31
|
+
::MCP::Tool::Response.new([{ type: "text", text: "Error: #{e.message}; #{key} was left unchanged." }], error: true)
|
|
30
32
|
end
|
|
31
33
|
end
|
|
32
34
|
end
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "mcp"
|
|
3
4
|
require_relative "../slave_support"
|
|
4
5
|
require_relative "../../env_override_store"
|
|
5
6
|
require_relative "../../redaction"
|
|
@@ -61,6 +62,8 @@ module Profiler
|
|
|
61
62
|
end
|
|
62
63
|
|
|
63
64
|
[{ type: "text", text: text }]
|
|
65
|
+
rescue Profiler::EnvOverrideStore::Error => e
|
|
66
|
+
::MCP::Tool::Response.new([{ type: "text", text: "Error: #{e.message}." }], error: true)
|
|
64
67
|
end
|
|
65
68
|
|
|
66
69
|
private
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "mcp"
|
|
3
4
|
require_relative "../slave_support"
|
|
4
5
|
|
|
5
6
|
module Profiler
|
|
@@ -22,6 +23,8 @@ module Profiler
|
|
|
22
23
|
Profiler.env_override_store.reset_all
|
|
23
24
|
|
|
24
25
|
[{ type: "text", text: "Reset #{count} environment variable#{"s" if count != 1} to original values." }]
|
|
26
|
+
rescue Profiler::EnvOverrideStore::Error => e
|
|
27
|
+
::MCP::Tool::Response.new([{ type: "text", text: "Error: #{e.message}; nothing was changed." }], error: true)
|
|
25
28
|
end
|
|
26
29
|
end
|
|
27
30
|
end
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "mcp"
|
|
3
4
|
require_relative "../slave_support"
|
|
4
5
|
|
|
5
6
|
require "uri"
|
|
@@ -27,6 +28,8 @@ module Profiler
|
|
|
27
28
|
|
|
28
29
|
done = restored ? "original value restored" : "ENV left unchanged"
|
|
29
30
|
[{ type: "text", text: "Reset #{key}: override removed, #{done} in this process." }]
|
|
31
|
+
rescue Profiler::EnvOverrideStore::Error => e
|
|
32
|
+
::MCP::Tool::Response.new([{ type: "text", text: "Error: #{e.message}; #{key} was left unchanged." }], error: true)
|
|
30
33
|
end
|
|
31
34
|
end
|
|
32
35
|
end
|
|
@@ -33,6 +33,8 @@ module Profiler
|
|
|
33
33
|
ENV[key] = value
|
|
34
34
|
|
|
35
35
|
[{ type: "text", text: "Set #{key}=#{value}" }]
|
|
36
|
+
rescue Profiler::EnvOverrideStore::Error => e
|
|
37
|
+
::MCP::Tool::Response.new([{ type: "text", text: "Error: #{e.message}; #{key} was left unchanged." }], error: true)
|
|
36
38
|
end
|
|
37
39
|
end
|
|
38
40
|
end
|
|
@@ -1,12 +1,16 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "token"
|
|
4
|
+
|
|
3
5
|
module Profiler
|
|
4
6
|
module Storage
|
|
5
7
|
class BaseStore
|
|
6
8
|
# Public interface: persists the profile then records the save, for the pages showing it
|
|
7
9
|
# to see it changed (Profiler::SSE).
|
|
8
|
-
# Subclasses implement #do_save, not #save.
|
|
10
|
+
# Subclasses implement #do_save, not #save. The token must be one the gem issued
|
|
11
|
+
# (Storage::Token); every other public method answers "not found" for any other token.
|
|
9
12
|
def save(token, profile)
|
|
13
|
+
Token.validate!(token)
|
|
10
14
|
result = do_save(token, profile)
|
|
11
15
|
broadcast_event(token)
|
|
12
16
|
result
|
|
@@ -2,22 +2,34 @@
|
|
|
2
2
|
|
|
3
3
|
require "fileutils"
|
|
4
4
|
require "json"
|
|
5
|
+
require_relative "private_files"
|
|
6
|
+
require_relative "token"
|
|
5
7
|
|
|
6
8
|
module Profiler
|
|
7
9
|
module Storage
|
|
10
|
+
# Large collector payloads of the SQLite store, one directory per profile token and one JSON
|
|
11
|
+
# file per collector. A token or a name that is not one the gem writes is never a path: reads
|
|
12
|
+
# find nothing and writes are refused.
|
|
8
13
|
class BlobStore
|
|
14
|
+
NAME_FORMAT = /\A[a-z0-9_]+\z/
|
|
15
|
+
|
|
9
16
|
def initialize(path)
|
|
10
|
-
@path = path
|
|
11
|
-
|
|
17
|
+
@path = path.to_s
|
|
18
|
+
PrivateFiles.mkdir(@path)
|
|
12
19
|
end
|
|
13
20
|
|
|
14
21
|
def write(token, collector_name, data)
|
|
22
|
+
Token.validate!(token)
|
|
23
|
+
raise ArgumentError, "invalid blob name: #{collector_name.inspect[0, 80]}" unless valid_name?(collector_name)
|
|
24
|
+
|
|
15
25
|
dir = token_dir(token)
|
|
16
|
-
|
|
17
|
-
|
|
26
|
+
PrivateFiles.mkdir(dir)
|
|
27
|
+
PrivateFiles.write(File.join(dir, "#{collector_name}.json"), JSON.generate(data))
|
|
18
28
|
end
|
|
19
29
|
|
|
20
30
|
def read(token, collector_name)
|
|
31
|
+
return nil unless valid?(token, collector_name)
|
|
32
|
+
|
|
21
33
|
file = File.join(token_dir(token), "#{collector_name}.json")
|
|
22
34
|
return nil unless File.exist?(file)
|
|
23
35
|
|
|
@@ -28,16 +40,28 @@ module Profiler
|
|
|
28
40
|
end
|
|
29
41
|
|
|
30
42
|
def delete(token)
|
|
43
|
+
return unless Token.valid?(token)
|
|
44
|
+
|
|
31
45
|
dir = token_dir(token)
|
|
32
46
|
FileUtils.rm_rf(dir) if File.directory?(dir)
|
|
33
47
|
end
|
|
34
48
|
|
|
35
49
|
def exists?(token, collector_name)
|
|
50
|
+
return false unless valid?(token, collector_name)
|
|
51
|
+
|
|
36
52
|
File.exist?(File.join(token_dir(token), "#{collector_name}.json"))
|
|
37
53
|
end
|
|
38
54
|
|
|
39
55
|
private
|
|
40
56
|
|
|
57
|
+
def valid?(token, collector_name)
|
|
58
|
+
Token.valid?(token) && valid_name?(collector_name)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def valid_name?(collector_name)
|
|
62
|
+
collector_name.is_a?(String) && NAME_FORMAT.match?(collector_name)
|
|
63
|
+
end
|
|
64
|
+
|
|
41
65
|
def token_dir(token)
|
|
42
66
|
File.join(@path, token)
|
|
43
67
|
end
|
|
@@ -3,28 +3,38 @@
|
|
|
3
3
|
require "fileutils"
|
|
4
4
|
require "json"
|
|
5
5
|
require_relative "base_store"
|
|
6
|
+
require_relative "private_files"
|
|
7
|
+
require_relative "token"
|
|
6
8
|
require_relative "../models/profile"
|
|
7
9
|
|
|
8
10
|
module Profiler
|
|
9
11
|
module Storage
|
|
10
|
-
# File-based profile storage backend. Persists each profile as a JSON file
|
|
11
|
-
# under tmp_path
|
|
12
|
+
# File-based profile storage backend. Persists each profile as a JSON file named after its
|
|
13
|
+
# token, under tmp_path/profiles by default or the directory given as options[:path], and
|
|
14
|
+
# evicts the oldest files when total size exceeds max_size. Only the files named after a
|
|
15
|
+
# token are taken for profiles, so a path shared with other files (tmp_path itself, where the
|
|
16
|
+
# env overrides live) is safe.
|
|
12
17
|
class FileStore < BaseStore
|
|
13
18
|
def initialize(options = {})
|
|
14
19
|
super()
|
|
15
|
-
@path = options[:path] || default_path
|
|
16
20
|
@max_size = options[:max_size] || (100 * 1024 * 1024) # 100 MB
|
|
17
|
-
|
|
21
|
+
if options[:path]
|
|
22
|
+
@path = options[:path].to_s
|
|
23
|
+
PrivateFiles.mkdir(@path)
|
|
24
|
+
else
|
|
25
|
+
@path = PrivateFiles.tmp_dir("profiles").to_s
|
|
26
|
+
end
|
|
18
27
|
end
|
|
19
28
|
|
|
20
29
|
def do_save(token, profile)
|
|
21
|
-
|
|
22
|
-
File.write(file_path, profile.to_json)
|
|
30
|
+
PrivateFiles.write(profile_file_path(token), profile.to_json)
|
|
23
31
|
cleanup_if_needed
|
|
24
32
|
token
|
|
25
33
|
end
|
|
26
34
|
|
|
27
35
|
def load(token)
|
|
36
|
+
return nil unless Token.valid?(token)
|
|
37
|
+
|
|
28
38
|
file_path = profile_file_path(token)
|
|
29
39
|
return nil unless File.exist?(file_path)
|
|
30
40
|
|
|
@@ -55,6 +65,8 @@ module Profiler
|
|
|
55
65
|
end
|
|
56
66
|
|
|
57
67
|
def find_by_parent(parent_token)
|
|
68
|
+
return [] unless Token.valid?(parent_token)
|
|
69
|
+
|
|
58
70
|
profile_files
|
|
59
71
|
.map { |f| load(File.basename(f, ".json")) }
|
|
60
72
|
.compact
|
|
@@ -63,6 +75,8 @@ module Profiler
|
|
|
63
75
|
end
|
|
64
76
|
|
|
65
77
|
def delete(token)
|
|
78
|
+
return unless Token.valid?(token)
|
|
79
|
+
|
|
66
80
|
FileUtils.rm_f(profile_file_path(token))
|
|
67
81
|
end
|
|
68
82
|
|
|
@@ -85,20 +99,12 @@ module Profiler
|
|
|
85
99
|
|
|
86
100
|
private
|
|
87
101
|
|
|
88
|
-
def default_path
|
|
89
|
-
Profiler.configuration.tmp_path
|
|
90
|
-
end
|
|
91
|
-
|
|
92
|
-
def ensure_directory_exists
|
|
93
|
-
FileUtils.mkdir_p(@path) unless File.directory?(@path)
|
|
94
|
-
end
|
|
95
|
-
|
|
96
102
|
def profile_file_path(token)
|
|
97
103
|
File.join(@path, "#{token}.json")
|
|
98
104
|
end
|
|
99
105
|
|
|
100
106
|
def profile_files
|
|
101
|
-
Dir.glob(File.join(@path, "*.json"))
|
|
107
|
+
Dir.glob(File.join(@path, "*.json")).select { |f| Token.valid?(File.basename(f, ".json")) }
|
|
102
108
|
end
|
|
103
109
|
|
|
104
110
|
def cleanup_if_needed # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
@@ -19,6 +19,8 @@ module Profiler
|
|
|
19
19
|
end
|
|
20
20
|
|
|
21
21
|
def load(token)
|
|
22
|
+
return nil unless Token.valid?(token)
|
|
23
|
+
|
|
22
24
|
data = @profiles[token]
|
|
23
25
|
return nil unless data
|
|
24
26
|
|
|
@@ -43,6 +45,8 @@ module Profiler
|
|
|
43
45
|
end
|
|
44
46
|
|
|
45
47
|
def find_by_parent(parent_token)
|
|
48
|
+
return [] unless Token.valid?(parent_token)
|
|
49
|
+
|
|
46
50
|
@profiles.values
|
|
47
51
|
.map { |data| deserialize_profile(data) }
|
|
48
52
|
.select { |profile| profile.parent_token == parent_token }
|
|
@@ -50,6 +54,8 @@ module Profiler
|
|
|
50
54
|
end
|
|
51
55
|
|
|
52
56
|
def delete(token)
|
|
57
|
+
return unless Token.valid?(token)
|
|
58
|
+
|
|
53
59
|
@profiles.delete(token)
|
|
54
60
|
end
|
|
55
61
|
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "securerandom"
|
|
5
|
+
|
|
6
|
+
module Profiler
|
|
7
|
+
module Storage
|
|
8
|
+
# Creates the profiler's directories and files readable by the process user only (0700 and
|
|
9
|
+
# 0600), whatever the umask: profiles hold cookies, tokens and environment values. A directory
|
|
10
|
+
# that already exists keeps its mode. config.restrict_storage_permissions = false leaves the
|
|
11
|
+
# modes to the umask, for a web process and workers running under two users.
|
|
12
|
+
module PrivateFiles
|
|
13
|
+
DIR_MODE = 0o700
|
|
14
|
+
FILE_MODE = 0o600
|
|
15
|
+
# Where the platform has it, and while the modes are restricted: a symbolic link placed where
|
|
16
|
+
# the profiler expects its own file is not followed (ELOOP) instead of being written through.
|
|
17
|
+
NOFOLLOW = defined?(File::NOFOLLOW) ? File::NOFOLLOW : 0
|
|
18
|
+
|
|
19
|
+
@warned = {}
|
|
20
|
+
@warn_mutex = Mutex.new
|
|
21
|
+
|
|
22
|
+
module_function
|
|
23
|
+
|
|
24
|
+
def restricted?
|
|
25
|
+
Profiler.configuration.restrict_storage_permissions ? true : false
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Creates the missing parents with the umask's mode, so that the application's own
|
|
29
|
+
# directories (tmp/ for instance) are left as they would be, and the directory itself 0700.
|
|
30
|
+
def mkdir(path)
|
|
31
|
+
path = path.to_s
|
|
32
|
+
return if File.directory?(path)
|
|
33
|
+
return FileUtils.mkdir_p(path) unless restricted?
|
|
34
|
+
|
|
35
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
36
|
+
begin
|
|
37
|
+
Dir.mkdir(path, DIR_MODE)
|
|
38
|
+
File.chmod(DIR_MODE, path)
|
|
39
|
+
rescue Errno::EEXIST
|
|
40
|
+
raise unless File.directory?(path)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# A directory under tmp_path, created with tmp_path itself as #mkdir creates a directory.
|
|
45
|
+
def tmp_dir(*parts)
|
|
46
|
+
dir = Profiler.configuration.tmp_path
|
|
47
|
+
mkdir(dir)
|
|
48
|
+
warn_if_shared(dir)
|
|
49
|
+
parts.each do |part|
|
|
50
|
+
dir = dir.join(part)
|
|
51
|
+
mkdir(dir)
|
|
52
|
+
end
|
|
53
|
+
dir
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Writes to a temporary file next to path then renames it over path: a reader never sees a
|
|
57
|
+
# half-written file, and the file has its mode from the start, even when it replaces an
|
|
58
|
+
# older one with a wider mode.
|
|
59
|
+
def write(path, data)
|
|
60
|
+
path = path.to_s
|
|
61
|
+
tmp = File.join(File.dirname(path), ".#{File.basename(path)}.#{Process.pid}.#{SecureRandom.hex(4)}.tmp")
|
|
62
|
+
File.open(tmp, File::WRONLY | File::CREAT | File::EXCL, new_file_mode) do |file|
|
|
63
|
+
file.chmod(FILE_MODE) if restricted?
|
|
64
|
+
file.write(data)
|
|
65
|
+
end
|
|
66
|
+
File.rename(tmp, path)
|
|
67
|
+
tmp = nil
|
|
68
|
+
path
|
|
69
|
+
ensure
|
|
70
|
+
FileUtils.rm_f(tmp) if tmp
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Opens path for reading and writing, creating it 0600 when missing.
|
|
74
|
+
def open(path, &block)
|
|
75
|
+
File.open(path.to_s, File::RDWR | File::CREAT | nofollow, new_file_mode, &block)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Creates an empty file 0600 when missing, or brings an existing one back to 0600.
|
|
79
|
+
def touch(path)
|
|
80
|
+
path = path.to_s
|
|
81
|
+
refuse_link(path)
|
|
82
|
+
File.open(path, File::WRONLY | File::CREAT | nofollow, new_file_mode) { |file| file.chmod(FILE_MODE) if restricted? }
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Brings an existing file back to 0600; a missing one is left missing.
|
|
86
|
+
def restrict(path)
|
|
87
|
+
path = path.to_s
|
|
88
|
+
refuse_link(path)
|
|
89
|
+
File.chmod(FILE_MODE, path) if restricted? && File.exist?(path)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# A symbolic link where the profiler keeps a file of its own would make it change the mode of
|
|
93
|
+
# (and write to) whatever the link points at. Refused while the permissions are restricted.
|
|
94
|
+
def refuse_link(path)
|
|
95
|
+
return unless restricted? && File.symlink?(path)
|
|
96
|
+
|
|
97
|
+
raise Profiler::Error, "Refusing to use #{path}: it is a symbolic link, where the profiler expects " \
|
|
98
|
+
"a file of its own. Remove it, or set config.restrict_storage_permissions = false, " \
|
|
99
|
+
"which follows such links and creates the files with the modes of the umask."
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Says once per directory when an existing tmp_path belongs to another user or can be written
|
|
103
|
+
# to by others (tmp/rails-profiler under a shared /tmp, outside Rails): they could place files
|
|
104
|
+
# or links there before the profiler does.
|
|
105
|
+
def warn_if_shared(dir)
|
|
106
|
+
return unless restricted?
|
|
107
|
+
|
|
108
|
+
stat = File.stat(dir.to_s)
|
|
109
|
+
return if stat.uid == Process.euid && (stat.mode & 0o022).zero?
|
|
110
|
+
|
|
111
|
+
key = dir.to_s
|
|
112
|
+
first = @warn_mutex.synchronize { @warned[key] ? false : (@warned[key] = true) }
|
|
113
|
+
return unless first
|
|
114
|
+
|
|
115
|
+
warn "[Profiler] tmp_path #{key} belongs to another user or is writable by group or others " \
|
|
116
|
+
"(mode #{format("%o", stat.mode & 0o777)}): other local users could place files there. Set " \
|
|
117
|
+
"config.tmp_path to a directory of your own, or run chmod 700 on it."
|
|
118
|
+
rescue SystemCallError
|
|
119
|
+
nil
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def reset_warnings!
|
|
123
|
+
@warn_mutex.synchronize { @warned = {} }
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def nofollow
|
|
127
|
+
restricted? ? NOFOLLOW : 0
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def new_file_mode
|
|
131
|
+
restricted? ? FILE_MODE : 0o666
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
@@ -29,6 +29,8 @@ module Profiler
|
|
|
29
29
|
end
|
|
30
30
|
|
|
31
31
|
def load(token)
|
|
32
|
+
return nil unless Token.valid?(token)
|
|
33
|
+
|
|
32
34
|
key = profile_key(token)
|
|
33
35
|
json_data = @redis.get(key)
|
|
34
36
|
return nil unless json_data
|
|
@@ -50,6 +52,8 @@ module Profiler
|
|
|
50
52
|
end
|
|
51
53
|
|
|
52
54
|
def find_by_parent(parent_token)
|
|
55
|
+
return [] unless Token.valid?(parent_token)
|
|
56
|
+
|
|
53
57
|
# Get all tokens from the sorted set
|
|
54
58
|
tokens = @redis.zrange(list_key, 0, -1)
|
|
55
59
|
|
|
@@ -61,6 +65,8 @@ module Profiler
|
|
|
61
65
|
end
|
|
62
66
|
|
|
63
67
|
def delete(token)
|
|
68
|
+
return unless Token.valid?(token)
|
|
69
|
+
|
|
64
70
|
@redis.del(profile_key(token))
|
|
65
71
|
@redis.zrem(list_key, token)
|
|
66
72
|
end
|
|
@@ -4,6 +4,8 @@ require "json"
|
|
|
4
4
|
require "fileutils"
|
|
5
5
|
require_relative "base_store"
|
|
6
6
|
require_relative "blob_store"
|
|
7
|
+
require_relative "private_files"
|
|
8
|
+
require_relative "token"
|
|
7
9
|
require_relative "../models/profile"
|
|
8
10
|
|
|
9
11
|
module Profiler
|
|
@@ -12,12 +14,12 @@ module Profiler
|
|
|
12
14
|
def initialize(options = {})
|
|
13
15
|
require "sqlite3"
|
|
14
16
|
|
|
15
|
-
db_path = options[:database] || default_db_path
|
|
16
|
-
blob_path = options[:blob_path] ||
|
|
17
|
+
db_path = (options[:database] || default_db_path).to_s
|
|
18
|
+
blob_path = options[:blob_path] || PrivateFiles.tmp_dir("blobs")
|
|
17
19
|
|
|
18
|
-
|
|
20
|
+
prepare_database_file(db_path, default: options[:database].nil?)
|
|
19
21
|
|
|
20
|
-
@db = SQLite3::Database.new(db_path
|
|
22
|
+
@db = SQLite3::Database.new(db_path)
|
|
21
23
|
@db.results_as_hash = true
|
|
22
24
|
@db.busy_timeout = 5000
|
|
23
25
|
@db.execute("PRAGMA journal_mode=WAL")
|
|
@@ -83,6 +85,8 @@ module Profiler
|
|
|
83
85
|
end
|
|
84
86
|
|
|
85
87
|
def load(token)
|
|
88
|
+
return nil unless Token.valid?(token)
|
|
89
|
+
|
|
86
90
|
row = @db.get_first_row(
|
|
87
91
|
"SELECT * FROM profiler_profiles WHERE token = :token", token: token
|
|
88
92
|
)
|
|
@@ -103,6 +107,8 @@ module Profiler
|
|
|
103
107
|
end
|
|
104
108
|
|
|
105
109
|
def find_by_parent(parent_token)
|
|
110
|
+
return [] unless Token.valid?(parent_token)
|
|
111
|
+
|
|
106
112
|
rows = @db.execute(
|
|
107
113
|
"SELECT * FROM profiler_profiles WHERE parent_token = :parent_token ORDER BY started_at ASC",
|
|
108
114
|
parent_token: parent_token
|
|
@@ -111,6 +117,8 @@ module Profiler
|
|
|
111
117
|
end
|
|
112
118
|
|
|
113
119
|
def delete(token)
|
|
120
|
+
return unless Token.valid?(token)
|
|
121
|
+
|
|
114
122
|
@db.execute("DELETE FROM profiler_profiles WHERE token = :token", token: token)
|
|
115
123
|
@blob_store.delete(token)
|
|
116
124
|
end
|
|
@@ -264,13 +272,23 @@ module Profiler
|
|
|
264
272
|
default
|
|
265
273
|
end
|
|
266
274
|
|
|
267
|
-
|
|
268
|
-
|
|
275
|
+
# SQLite gives the -wal and -shm files the mode of the database file: create it 0600 before
|
|
276
|
+
# SQLite does, and bring an older one, and its companions, back to 0600. An in-memory or URI
|
|
277
|
+
# database is left to SQLite.
|
|
278
|
+
def prepare_database_file(db_path, default:)
|
|
279
|
+
return if db_path == ":memory:" || db_path.start_with?("file:")
|
|
280
|
+
|
|
281
|
+
default ? PrivateFiles.tmp_dir : PrivateFiles.mkdir(File.dirname(db_path))
|
|
282
|
+
# A link in place of one of them is refused: SQLite would follow it.
|
|
283
|
+
%w[-wal -shm].each { |suffix| PrivateFiles.refuse_link("#{db_path}#{suffix}") }
|
|
284
|
+
PrivateFiles.touch(db_path)
|
|
285
|
+
%w[-wal -shm].each { |suffix| PrivateFiles.restrict("#{db_path}#{suffix}") }
|
|
269
286
|
end
|
|
270
287
|
|
|
271
|
-
def
|
|
272
|
-
|
|
288
|
+
def default_db_path
|
|
289
|
+
Profiler.configuration.tmp_path.join("profiler.db")
|
|
273
290
|
end
|
|
291
|
+
|
|
274
292
|
end
|
|
275
293
|
end
|
|
276
294
|
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Profiler
|
|
4
|
+
module Storage
|
|
5
|
+
# The format of a profile token, as Models::Profile issues it (SecureRandom.hex(16)) for every
|
|
6
|
+
# kind of profile. The backends check a token against it before they turn it into a file name,
|
|
7
|
+
# a directory or a key: a token is client input for the controllers, the cluster proxy and the
|
|
8
|
+
# MCP tools, which all call the storage directly.
|
|
9
|
+
module Token
|
|
10
|
+
FORMAT = /\A[0-9a-f]{32}\z/
|
|
11
|
+
|
|
12
|
+
def self.valid?(token)
|
|
13
|
+
token.is_a?(String) && FORMAT.match?(token)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# For the writers: the gem only writes under the tokens it issued, so another one is a bug.
|
|
17
|
+
def self.validate!(token)
|
|
18
|
+
raise ArgumentError, "invalid profile token: #{token.inspect[0, 80]}" unless valid?(token)
|
|
19
|
+
|
|
20
|
+
token
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
data/lib/profiler/version.rb
CHANGED
data/lib/profiler.rb
CHANGED
|
@@ -28,6 +28,15 @@ module Profiler
|
|
|
28
28
|
@storage ||= configuration.storage_backend
|
|
29
29
|
end
|
|
30
30
|
|
|
31
|
+
# Saves a profile from a path of the application (a job, a console command, a test, an
|
|
32
|
+
# outbound HTTP call): a storage error loses the profile, never the application's work.
|
|
33
|
+
def save_profile(profile, from:)
|
|
34
|
+
storage.save(profile.token, profile)
|
|
35
|
+
rescue StandardError => e
|
|
36
|
+
warn "[Profiler] #{from}: could not save profile #{profile.token}: #{e.class}: #{e.message}"
|
|
37
|
+
nil
|
|
38
|
+
end
|
|
39
|
+
|
|
31
40
|
def env_override_store
|
|
32
41
|
@env_override_store ||= EnvOverrideStore.new
|
|
33
42
|
end
|
|
@@ -124,6 +133,8 @@ require_relative "profiler/collectors/i18n_collector"
|
|
|
124
133
|
require_relative "profiler/collectors/env_collector"
|
|
125
134
|
require_relative "profiler/collectors/mailer_collector"
|
|
126
135
|
|
|
136
|
+
require_relative "profiler/storage/token"
|
|
137
|
+
require_relative "profiler/storage/private_files"
|
|
127
138
|
require_relative "profiler/env_override_store"
|
|
128
139
|
require_relative "profiler/instrumentation/thread_context_propagation"
|
|
129
140
|
require_relative "profiler/sse/event_bus"
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: rails-profiler
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.31.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Sébastien Duplessy
|
|
@@ -222,8 +222,10 @@ files:
|
|
|
222
222
|
- lib/profiler/storage/blob_store.rb
|
|
223
223
|
- lib/profiler/storage/file_store.rb
|
|
224
224
|
- lib/profiler/storage/memory_store.rb
|
|
225
|
+
- lib/profiler/storage/private_files.rb
|
|
225
226
|
- lib/profiler/storage/redis_store.rb
|
|
226
227
|
- lib/profiler/storage/sqlite_store.rb
|
|
228
|
+
- lib/profiler/storage/token.rb
|
|
227
229
|
- lib/profiler/tasks/profiler.rake
|
|
228
230
|
- lib/profiler/test_helpers/minitest_support.rb
|
|
229
231
|
- lib/profiler/test_helpers/reporter.rb
|