ace-lab 0.14.0 → 0.14.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 +11 -0
- data/lib/ace/lab/molecules/gem_payload_permissions.rb +174 -0
- data/lib/ace/lab/molecules/gemset_provisioner.rb +6 -0
- data/lib/ace/lab/version.rb +1 -1
- metadata +3 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: a3291bf80fb998a68f1e58c0427dfcaa77e8392761b329db1a637700037f3dfb
|
|
4
|
+
data.tar.gz: 657770601dfa66713d0f0b0de8fb6b9434348a236c486a111f46b8e8df75784c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 3e3cb3595c7042859c5bd9b7dbe721657a614fffbeeac2a6c422d7607ca0ad0510460f68085a802eaacb71a60c723ba98301d3e44a13e7090f36d4e88c766069
|
|
7
|
+
data.tar.gz: e6dd9400e2424f955b178c394c042ce9b1a4aa42f7a854c6bdbd6965d9d43038fccc6d2a732a2e92896d5ec4f3737672578dc478e9996ea503e5606290bfcc92
|
data/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,17 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [Unreleased]
|
|
9
|
+
|
|
10
|
+
## [0.14.1] - 2026-10-31
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
|
|
14
|
+
- Converge public installed gem payload permissions for shared operational
|
|
15
|
+
runtimes, including resolved dependencies and native extension outputs.
|
|
16
|
+
Validate cached archive identity and archive-owned payload paths before changes; preserve unknown files
|
|
17
|
+
and reject symlink escapes, with identical dry-run inventory validation.
|
|
18
|
+
|
|
8
19
|
## [0.14.0] - 2026-10-30
|
|
9
20
|
|
|
10
21
|
### Fixed
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "open3"
|
|
5
|
+
require "rbconfig"
|
|
6
|
+
require "find"
|
|
7
|
+
|
|
8
|
+
module Ace
|
|
9
|
+
module Lab
|
|
10
|
+
module Molecules
|
|
11
|
+
# Installed specifications select identity; cached gem archives own payload
|
|
12
|
+
# membership (installed gemspecs omit files). No recursive runtime sweep.
|
|
13
|
+
class GemPayloadPermissions
|
|
14
|
+
class Error < StandardError; end
|
|
15
|
+
INVENTORY = <<~'CODE'
|
|
16
|
+
require 'json'
|
|
17
|
+
require 'rubygems'
|
|
18
|
+
require 'rubygems/package'
|
|
19
|
+
home = ARGV.shift
|
|
20
|
+
Gem.use_paths(home)
|
|
21
|
+
queue = [Gem::Specification.find_by_name(ARGV.shift, ARGV.shift)]
|
|
22
|
+
dry_run = ARGV.shift == "dry-run"
|
|
23
|
+
seen = {}; rows = []
|
|
24
|
+
until queue.empty?
|
|
25
|
+
spec = queue.shift
|
|
26
|
+
next if seen[spec.full_name]
|
|
27
|
+
seen[spec.full_name] = true
|
|
28
|
+
unless spec.default_gem?
|
|
29
|
+
cache = File.join(home, "cache", "#{spec.full_name}.gem")
|
|
30
|
+
unless File.file?(cache) && !File.symlink?(cache) && !File.symlink?(File.dirname(cache))
|
|
31
|
+
puts JSON.generate(error: "installed gem payload cache missing or symlinked: #{cache}")
|
|
32
|
+
exit 1
|
|
33
|
+
end
|
|
34
|
+
package = Gem::Package.new(cache)
|
|
35
|
+
package.verify
|
|
36
|
+
unless package.spec.full_name == spec.full_name
|
|
37
|
+
puts JSON.generate(error: "installed gem payload cache identity mismatch: #{cache}")
|
|
38
|
+
exit 1
|
|
39
|
+
end
|
|
40
|
+
rows << {root: spec.full_gem_path, spec: spec.loaded_from,
|
|
41
|
+
files: package.contents, executables: spec.executables,
|
|
42
|
+
bindir: spec.bindir, extension: spec.extensions.empty? ? nil : spec.extension_dir}
|
|
43
|
+
end
|
|
44
|
+
spec.runtime_dependencies.each do |dep|
|
|
45
|
+
begin
|
|
46
|
+
queue << dep.to_spec
|
|
47
|
+
rescue Gem::MissingSpecError
|
|
48
|
+
# Convergence already predicts installing absent dependencies.
|
|
49
|
+
# Validate the existing payload; do not invent future files.
|
|
50
|
+
raise unless dry_run
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
puts JSON.generate(rows)
|
|
55
|
+
CODE
|
|
56
|
+
|
|
57
|
+
def initialize(gem_home:, dry_run:, privileged:)
|
|
58
|
+
@declared_home = File.expand_path(gem_home)
|
|
59
|
+
raise Error, "gem runtime is a symlink" if File.symlink?(@declared_home)
|
|
60
|
+
@home = File.realpath(@declared_home)
|
|
61
|
+
@dry_run, @privileged = dry_run, privileged
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def call(name:, version:)
|
|
65
|
+
# Default Ruby gems need no mutation in this runtime. With no local
|
|
66
|
+
# specifications there cannot be any owned installed payload here.
|
|
67
|
+
return [] unless File.directory?(File.join(@home, "specifications"))
|
|
68
|
+
|
|
69
|
+
env = ENV.keys.grep(/\ABUNDLE/).to_h { |key| [key, nil] }
|
|
70
|
+
env.merge!("RUBYOPT" => nil, "RUBYLIB" => nil, "GEM_HOME" => @home, "GEM_PATH" => @home)
|
|
71
|
+
out, _err, status = Open3.capture3(env, RbConfig.ruby, "-e", INVENTORY, @home, name, version, @dry_run ? "dry-run" : "apply")
|
|
72
|
+
unless status.success?
|
|
73
|
+
detail = begin
|
|
74
|
+
JSON.parse(out)["error"]
|
|
75
|
+
rescue JSON::ParserError
|
|
76
|
+
nil
|
|
77
|
+
end
|
|
78
|
+
raise Error, detail || "could not inventory installed gem payload #{name} #{version}"
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
converge(JSON.parse(out))
|
|
82
|
+
rescue SystemCallError, JSON::ParserError => e
|
|
83
|
+
raise Error, "gem payload permission convergence failed (#{e.class})"
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def converge(rows)
|
|
87
|
+
paths = {}
|
|
88
|
+
executables = []
|
|
89
|
+
rows.each do |row|
|
|
90
|
+
root = validate(row.fetch("root"))
|
|
91
|
+
raise Error, "invalid installed gem root" unless File.dirname(root) == File.join(@home, "gems")
|
|
92
|
+
spec = validate(row.fetch("spec"))
|
|
93
|
+
raise Error, "invalid installed gem specification" unless File.dirname(spec) == File.join(@home, "specifications")
|
|
94
|
+
add(paths, spec)
|
|
95
|
+
row.fetch("files").each { |file| add(paths, payload_path(root, file)) }
|
|
96
|
+
row.fetch("executables").each do |exe|
|
|
97
|
+
raise Error, "invalid gem executable" unless File.basename(exe) == exe && !%w[. ..].include?(exe)
|
|
98
|
+
[payload_path(root, File.join(row.fetch("bindir"), exe)), File.join(@home, "bin", exe)].each do |path|
|
|
99
|
+
add(paths, path)
|
|
100
|
+
executables << validate(path)
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
if (extension = row["extension"])
|
|
104
|
+
extension = validate(extension)
|
|
105
|
+
raise Error, "invalid gem extension directory" unless
|
|
106
|
+
extension.start_with?(File.join(@home, "extensions") + "/") &&
|
|
107
|
+
File.basename(extension) == File.basename(root)
|
|
108
|
+
Find.find(extension) do |path|
|
|
109
|
+
validate(path)
|
|
110
|
+
add(paths, path)
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
changes = paths.filter_map do |path, stat|
|
|
115
|
+
desired = stat.directory? || executables.include?(path) || (stat.mode & 0o111).positive? ? 0o755 : 0o644
|
|
116
|
+
owner_change = @privileged && (stat.uid != 0 || stat.gid != 0)
|
|
117
|
+
next if (stat.mode & 0o7777) == desired && !owner_change
|
|
118
|
+
{"path" => path, "mode" => desired, "owner_change" => owner_change,
|
|
119
|
+
"status" => @dry_run ? "would_normalize" : "normalized"}
|
|
120
|
+
end
|
|
121
|
+
# Inventory validation is complete before the first mutation.
|
|
122
|
+
unless @dry_run
|
|
123
|
+
changes.each do |change|
|
|
124
|
+
path = validate(change.fetch("path"))
|
|
125
|
+
current = File.lstat(path)
|
|
126
|
+
original = paths.fetch(path)
|
|
127
|
+
raise Error, "gem payload changed during permission convergence: #{path}" unless
|
|
128
|
+
current.ino == original.ino && current.dev == original.dev && !current.symlink?
|
|
129
|
+
File.chown(0, 0, path) if change["owner_change"]
|
|
130
|
+
File.chmod(change.fetch("mode"), path)
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
changes
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
private
|
|
137
|
+
|
|
138
|
+
def payload_path(root, relative)
|
|
139
|
+
path = File.expand_path(relative, root)
|
|
140
|
+
raise Error, "gem payload escapes specification root" unless path.start_with?(root + "/")
|
|
141
|
+
path
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def validate(path)
|
|
145
|
+
path = File.expand_path(path)
|
|
146
|
+
if path == @declared_home || path.start_with?(@declared_home + "/")
|
|
147
|
+
path = @home + path.delete_prefix(@declared_home)
|
|
148
|
+
end
|
|
149
|
+
raise Error, "gem payload escapes declared runtime: #{path}" unless path == @home || path.start_with?(@home + "/")
|
|
150
|
+
cursor = path
|
|
151
|
+
loop do
|
|
152
|
+
stat = File.lstat(cursor)
|
|
153
|
+
raise Error, "gem payload symlink refused: #{cursor}" if stat.symlink?
|
|
154
|
+
raise Error, "unsupported gem payload: #{cursor}" unless stat.file? || stat.directory?
|
|
155
|
+
break if cursor == @home
|
|
156
|
+
cursor = File.dirname(cursor)
|
|
157
|
+
end
|
|
158
|
+
path
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def add(paths, path)
|
|
162
|
+
path = validate(path)
|
|
163
|
+
loop do
|
|
164
|
+
stat = File.lstat(path)
|
|
165
|
+
raise Error, "hardlinked gem payload refused: #{path}" if stat.file? && stat.nlink > 1
|
|
166
|
+
paths[path] = stat
|
|
167
|
+
break if path == @home
|
|
168
|
+
path = File.dirname(path)
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
end
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require_relative "../errors"
|
|
4
4
|
require "open3"
|
|
5
5
|
require "fileutils"
|
|
6
|
+
require_relative "gem_payload_permissions"
|
|
6
7
|
|
|
7
8
|
module Ace
|
|
8
9
|
module Lab
|
|
@@ -210,7 +211,12 @@ module Ace
|
|
|
210
211
|
closure = converge_closure(gem, gem_home)
|
|
211
212
|
return report.merge("status" => "failed", "message" => closure) if closure.is_a?(String)
|
|
212
213
|
report["closure"] = closure
|
|
214
|
+
permissions = GemPayloadPermissions.new(gem_home: gem_home, dry_run: @dry_run,
|
|
215
|
+
privileged: @privileged.call).call(name: gem["name"], version: gem["version"])
|
|
216
|
+
report["permissions"] = permissions
|
|
213
217
|
report
|
|
218
|
+
rescue GemPayloadPermissions::Error => e
|
|
219
|
+
report.merge("status" => "failed", "message" => e.message)
|
|
214
220
|
end
|
|
215
221
|
|
|
216
222
|
# @return [Array<Hash>, String] per-version removal reports or a
|
data/lib/ace/lab/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: ace-lab
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.14.
|
|
4
|
+
version: 0.14.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Michal Czyz
|
|
8
8
|
bindir: exe
|
|
9
9
|
cert_chain: []
|
|
10
|
-
date: 2026-
|
|
10
|
+
date: 2026-11-01 00:00:00.000000000 Z
|
|
11
11
|
dependencies:
|
|
12
12
|
- !ruby/object:Gem::Dependency
|
|
13
13
|
name: ace-support-cli
|
|
@@ -212,6 +212,7 @@ files:
|
|
|
212
212
|
- lib/ace/lab/molecules/extension_provisioner.rb
|
|
213
213
|
- lib/ace/lab/molecules/forge_merge_config.rb
|
|
214
214
|
- lib/ace/lab/molecules/forgejo_merge_adapter.rb
|
|
215
|
+
- lib/ace/lab/molecules/gem_payload_permissions.rb
|
|
215
216
|
- lib/ace/lab/molecules/gemset_provisioner.rb
|
|
216
217
|
- lib/ace/lab/molecules/grant_provisioner.rb
|
|
217
218
|
- lib/ace/lab/molecules/grant_resolver.rb
|