Skip to content

Commit 26eab67

Browse files
committed
Address review: close the glob validation gap and harden the object reads
- validate_dir! runs the glob branch's directory through the same component walk as plain accesses: git ls-tree on a symlinked directory or a regular file returns an empty listing with exit 0, which would have silently diverged from Dir[] on a checkout instead of falling back. - Shadow mode rescues FallbackRequired ahead of the catch-all so the fallback reason taxonomy survives -- shadow is what produces the data the rollout gate is evaluated on. Events also carry the active mode. - git_read clears the GIT_* repository-selection environment variables it no longer inherits Shipit::Command's scrubbed BASE_ENV protection against, and takes pathspecs via :(literal) so directory names with glob characters are read verbatim. - git_object evaluation now guards core.autocrlf/core.attributesfile at runtime instead of relying on a point-in-time preflight: those convert checkouts without leaving a trace in the tree. autocrlf false/input are safe; core.eol alone is inert without text attributes, which the .gitattributes guards already cover.
1 parent f3e9fa9 commit 26eab67

5 files changed

Lines changed: 137 additions & 9 deletions

File tree

app/models/shipit/deploy_spec/git_object_file_system.rb

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,12 @@ class DeploySpec
1818
# escaping the repository, runaway inherit_from chains), FallbackRequired
1919
# is raised and the caller is expected to fall back to the checkout-based
2020
# code path.
21+
#
22+
# Unlike the checkout path (which clones a snapshot first), reads target
23+
# the stack's live git cache. A concurrent ClearGitCacheJob or git gc
24+
# degrades to a command failure, which the caller treats as a fallback;
25+
# since every read is pinned to a single commit sha there is no torn-read
26+
# hazard.
2127
class GitObjectFileSystem < FileSystem
2228
class FallbackRequired < StandardError
2329
attr_reader :reason, :detail
@@ -50,6 +56,7 @@ def file(path, root: false)
5056
pathname = super
5157
if path.to_s.match?(GLOB_CHARS)
5258
dir = repo_rel(pathname.dirname)
59+
validate_dir!(dir)
5360
pattern = File.basename(path.to_s)
5461
entries(dir).each_key do |name|
5562
next unless File.fnmatch(pattern, name)
@@ -149,6 +156,26 @@ def blob_mode(key)
149156
(parts.last && entries(prefix)[parts.last]) || :absent
150157
end
151158

159+
# The glob branch lists a directory without materializing a file, so the
160+
# directory's own path components must be validated explicitly:
161+
# `git ls-tree <sha> -- '<dir>/'` on a symlinked directory or on a
162+
# regular file returns an empty listing with exit 0, which would
163+
# silently diverge from Dir[] on a checkout (which follows symlinks).
164+
def validate_dir!(dir)
165+
return if dir.empty?
166+
167+
case blob_mode(dir)
168+
when TREE_MODE, :absent
169+
nil
170+
when SYMLINK_MODE
171+
raise FallbackRequired.new(:symlink, dir)
172+
when GITLINK_MODE
173+
raise FallbackRequired.new(:submodule, dir)
174+
else
175+
raise FallbackRequired.new(:file_in_path, dir)
176+
end
177+
end
178+
152179
# Memoized directory listings, keyed by canonical repo-relative dir
153180
# ("" = root). Any listed ancestor containing a .gitattributes entry
154181
# forces a fallback: a checkout applies eol/text/smudge attributes,

lib/shipit/stack_commands.rb

Lines changed: 51 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,10 @@ def cacheable_deploy_spec(commit: nil)
107107
.reject { |key| old_config[key] == new_config[key] }
108108
notify_checkout_less(:shadow_mismatch, detail: differing.first(5).join(','))
109109
end
110+
rescue DeploySpec::GitObjectFileSystem::FallbackRequired => e
111+
# Preserve the reason taxonomy: shadow mode is what produces the
112+
# fallback-rate data the rollout gate is evaluated on.
113+
notify_checkout_less(:fallback, reason: e.reason, detail: e.detail)
110114
rescue StandardError => e
111115
notify_checkout_less(:fallback, reason: :shadow_error, detail: "#{e.class}: #{e.message}")
112116
end
@@ -126,7 +130,9 @@ def git_read_object(sha, repo_rel_path)
126130
# children rather than the directory entry itself.
127131
def git_ls_dir(sha, repo_rel_dir)
128132
args = ['ls-tree', '-z', sha]
129-
args += ['--', "#{repo_rel_dir}/"] unless repo_rel_dir.empty?
133+
# :(literal) disables pathspec magic so directory names containing
134+
# glob characters or a leading ':' are taken verbatim.
135+
args += ['--', ":(literal)#{repo_rel_dir}/"] unless repo_rel_dir.empty?
130136
output = git_read(*args)
131137
output.split("\0").each_with_object({}) do |record, listing|
132138
next if record.empty?
@@ -211,6 +217,8 @@ def checkout_cacheable_deploy_spec(commit)
211217
end
212218

213219
def git_object_cacheable_deploy_spec(commit)
220+
ensure_no_checkout_conversion_config!
221+
214222
unless fetched?(commit).tap(&:run).success?
215223
@stack.acquire_git_cache_lock do
216224
fetch.run! unless fetched?(commit).tap(&:run).success?
@@ -222,6 +230,24 @@ def git_object_cacheable_deploy_spec(commit)
222230
end
223231
end
224232

233+
# A checkout applies core.autocrlf conversions and attributes from a
234+
# core.attributesfile, neither of which leaves a trace in the tree, while
235+
# `git cat-file` emits raw bytes. Guarding at runtime (rather than a
236+
# point-in-time preflight) keeps the guarantee if a base image change
237+
# introduces such config later. autocrlf=false and autocrlf=input do not
238+
# convert on checkout; core.eol alone is inert without text attributes,
239+
# which are covered by the in-tree .gitattributes guard and the
240+
# attributesfile check here.
241+
def ensure_no_checkout_conversion_config!
242+
output = git_read('config', '--get-regexp', '^core\.(autocrlf|attributesfile)$', allow_failure: true)
243+
output.split("\n").each do |line|
244+
key, value = line.split(' ', 2)
245+
next if key == 'core.autocrlf' && %w[false input].include?(value.to_s.downcase)
246+
247+
raise DeploySpec::GitObjectFileSystem::FallbackRequired.new(:git_config, line)
248+
end
249+
end
250+
225251
def normalize_spec_config(value, root)
226252
case value
227253
when String then value.gsub(root, '$SPEC_ROOT')
@@ -231,15 +257,35 @@ def normalize_spec_config(value, root)
231257
end
232258
end
233259

234-
def git_read(*args)
235-
output, error, status = Open3.capture3('git', *args, chdir: @stack.git_path.to_s, binmode: true)
236-
raise Command::Failed.new("git #{args.first} failed: #{error.strip}", status.exitstatus) unless status.success?
260+
# Environment variables that select which repository/object store git
261+
# operates on. Cleared explicitly: git_read bypasses Shipit::Command (and
262+
# therefore its scrubbed BASE_ENV), and any of these set on the worker
263+
# process would silently override chdir:.
264+
GIT_REPO_SELECTION_ENV = {
265+
'GIT_DIR' => nil,
266+
'GIT_WORK_TREE' => nil,
267+
'GIT_INDEX_FILE' => nil,
268+
'GIT_OBJECT_DIRECTORY' => nil,
269+
'GIT_ALTERNATE_OBJECT_DIRECTORIES' => nil,
270+
'GIT_COMMON_DIR' => nil
271+
}.freeze
272+
273+
def git_read(*args, allow_failure: false)
274+
output, error, status = Open3.capture3(
275+
GIT_REPO_SELECTION_ENV, 'git', *args,
276+
chdir: @stack.git_path.to_s, binmode: true
277+
)
278+
unless status.success?
279+
return "" if allow_failure
280+
281+
raise Command::Failed.new("git #{args.first} failed: #{error.strip}", status.exitstatus)
282+
end
237283

238284
output
239285
end
240286

241287
def notify_checkout_less(event, reason: nil, detail: nil)
242-
payload = { stack_id: @stack.id, event:, reason:, detail: }.compact
288+
payload = { stack_id: @stack.id, mode: Shipit.checkout_less_deploy_spec, event:, reason:, detail: }.compact
243289
ActiveSupport::Notifications.instrument('checkout_less_deploy_spec.shipit', payload)
244290
if event == :hit
245291
Rails.logger.debug { "[checkout_less_deploy_spec] hit stack=#{@stack.id}" }

test/jobs/cache_deploy_spec_job_test.rb

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -45,17 +45,17 @@ class CacheDeploySpecJobTest < ActiveSupport::TestCase
4545
@stack.stubs(:commits).returns(stub(reachable:))
4646
@stack.stubs(:update!) # side-effect callbacks are irrelevant to this test
4747

48-
StackCommands.any_instance.expects(:with_temporary_working_directory)
49-
.with(commit: @last_commit, recursive: false).yields(Pathname(Dir.tmpdir))
48+
StackCommands.any_instance.expects(:cacheable_deploy_spec)
49+
.with(commit: @last_commit).returns(DeploySpec.new({}))
5050

5151
assert_enqueued_with(job: CacheDeploySpecJob, args: [@stack]) do
5252
@job.perform(@stack)
5353
end
5454
end
5555

5656
test "#perform does not re-enqueue itself when the head is unchanged" do
57-
StackCommands.any_instance.expects(:with_temporary_working_directory)
58-
.with(commit: @last_commit, recursive: false).yields(Pathname(Dir.tmpdir))
57+
StackCommands.any_instance.expects(:cacheable_deploy_spec)
58+
.with(commit: @last_commit).returns(DeploySpec.new({}))
5959

6060
assert_no_enqueued_jobs(only: CacheDeploySpecJob) do
6161
@job.perform(@stack)

test/models/shipit/deploy_spec/git_object_file_system_test.rb

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,42 @@ class GitObjectFileSystemTest < ActiveSupport::TestCase
181181
assert_equal :gitattributes, error.reason
182182
end
183183

184+
test "fallback: glob listing under a symlinked directory" do
185+
repo, sha = make_repo('realdir/x.gemspec' => "Gem::Specification.new\n") do |dir|
186+
File.symlink('realdir', dir.join('linkdir'))
187+
end
188+
commands = commands_for(repo)
189+
190+
Dir.mktmpdir do |dir|
191+
fs = GitObjectFileSystem.new(dir, @stack, commands:, sha:)
192+
error = assert_raises(GitObjectFileSystem::FallbackRequired) do
193+
fs.file('linkdir/*.gemspec', root: true)
194+
end
195+
assert_equal :symlink, error.reason
196+
end
197+
end
198+
199+
test "fallback: repository-level checkout conversion config" do
200+
repo, _sha = make_repo('shipit.production.yml' => "deploy:\n override:\n - echo x\n")
201+
git(repo, 'config', 'core.autocrlf', 'true')
202+
commands = commands_for(repo)
203+
204+
error = assert_raises(GitObjectFileSystem::FallbackRequired) do
205+
commands.send(:ensure_no_checkout_conversion_config!)
206+
end
207+
assert_equal :git_config, error.reason
208+
end
209+
210+
test "checkout conversion config guard tolerates safe autocrlf values" do
211+
repo, _sha = make_repo('shipit.production.yml' => "deploy:\n override:\n - echo x\n")
212+
git(repo, 'config', 'core.autocrlf', 'input')
213+
commands = commands_for(repo)
214+
215+
assert_nothing_raised do
216+
commands.send(:ensure_no_checkout_conversion_config!)
217+
end
218+
end
219+
184220
# --- Idempotency ---
185221

186222
test "repeat access to the same file reads the object database once" do

test/unit/cacheable_deploy_spec_test.rb

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,25 @@ class CacheableDeploySpecTest < ActiveSupport::TestCase
101101
assert_includes @events.first[:detail], 'deploy'
102102
end
103103

104+
test "shadow mode preserves the fallback reason taxonomy" do
105+
Shipit.checkout_less_deploy_spec = :shadow
106+
error = DeploySpec::GitObjectFileSystem::FallbackRequired.new(:gitattributes, 'app')
107+
@commands.expects(:checkout_cacheable_deploy_spec).with(@commit).returns([@old_spec, '/tmp/old'])
108+
@commands.expects(:git_object_cacheable_deploy_spec).raises(error)
109+
110+
assert_equal @old_spec, @commands.cacheable_deploy_spec(commit: @commit)
111+
assert_equal :gitattributes, @events.first[:reason]
112+
assert_equal 'app', @events.first[:detail]
113+
end
114+
115+
test "events carry the active mode" do
116+
Shipit.checkout_less_deploy_spec = :enabled
117+
@commands.expects(:git_object_cacheable_deploy_spec).with(@commit).returns([@new_spec, '/tmp/new'])
118+
119+
@commands.cacheable_deploy_spec(commit: @commit)
120+
assert_equal :enabled, @events.first[:mode]
121+
end
122+
104123
test "shadow mode never propagates new-path exceptions" do
105124
Shipit.checkout_less_deploy_spec = :shadow
106125
@commands.expects(:checkout_cacheable_deploy_spec).with(@commit).returns([@old_spec, '/tmp/old'])

0 commit comments

Comments
 (0)