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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3c2b81c8fa6e9c3e9838a696ccb0813d28e2a18f7cd46f49e724cc3122cb7ed5
4
- data.tar.gz: 1b5338586438ce21609dad591ddd740d1fe4277f7d997f4c84eb7a0a097c8f6a
3
+ metadata.gz: a3291bf80fb998a68f1e58c0427dfcaa77e8392761b329db1a637700037f3dfb
4
+ data.tar.gz: 657770601dfa66713d0f0b0de8fb6b9434348a236c486a111f46b8e8df75784c
5
5
  SHA512:
6
- metadata.gz: 803eb4a258b8776e6dc07e98ce77fe58f903236e68bfb486d1bb785b7d888128649a151d637b1b6e5f710a8574c5f1b13238148ae43c9cae83d9501543dbdb19
7
- data.tar.gz: 327c1a76590d7b5695545c1003c4ad97d6638b73287524f0fc51464837f5e4524f915e1e3828f23b89e4d4fdac776d64531f5270ec375444dca5b8aa7d651a5f
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
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Ace
4
4
  module Lab
5
- VERSION = "0.14.0"
5
+ VERSION = "0.14.1"
6
6
  end
7
7
  end
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.0
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-30 00:00:00.000000000 Z
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