canopus 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.
Files changed (146) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +5 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +148 -0
  5. data/docs/adr/001-runtime-gem-components.md +24 -0
  6. data/docs/adr/002-bounded-diff-engine.md +22 -0
  7. data/docs/adr/003-background-language-analysis.md +22 -0
  8. data/docs/adr/004-persistent-display-map.md +23 -0
  9. data/docs/adr/005-windows-runtime.md +27 -0
  10. data/docs/adr/README.md +36 -0
  11. data/docs/distribution.md +27 -0
  12. data/docs/lsp.md +38 -0
  13. data/docs/performance.md +40 -0
  14. data/docs/snippets.md +32 -0
  15. data/docs/vim.md +24 -0
  16. data/docs/workspace_edits.md +32 -0
  17. data/examples/native_smoke.rb +25 -0
  18. data/examples/plugins/word_count.rb +11 -0
  19. data/exe/canopus +19 -0
  20. data/lib/canopus/block_map.rb +19 -0
  21. data/lib/canopus/buffer.rb +293 -0
  22. data/lib/canopus/cli.rb +171 -0
  23. data/lib/canopus/controller.rb +507 -0
  24. data/lib/canopus/data_compat.rb +25 -0
  25. data/lib/canopus/display_map/line_builder.rb +54 -0
  26. data/lib/canopus/display_map/line_set.rb +8 -0
  27. data/lib/canopus/display_map/pending_line_set.rb +9 -0
  28. data/lib/canopus/display_map/summary.rb +23 -0
  29. data/lib/canopus/display_map/worker.rb +89 -0
  30. data/lib/canopus/display_map.rb +350 -0
  31. data/lib/canopus/display_point.rb +5 -0
  32. data/lib/canopus/editor/snippet_expandable.rb +281 -0
  33. data/lib/canopus/editor.rb +445 -0
  34. data/lib/canopus/error.rb +5 -0
  35. data/lib/canopus/fold_map.rb +87 -0
  36. data/lib/canopus/git/blame.rb +50 -0
  37. data/lib/canopus/git/commit.rb +7 -0
  38. data/lib/canopus/git/corrupt_object.rb +7 -0
  39. data/lib/canopus/git/diff.rb +131 -0
  40. data/lib/canopus/git/index.rb +94 -0
  41. data/lib/canopus/git/object_database.rb +45 -0
  42. data/lib/canopus/git/pack.rb +186 -0
  43. data/lib/canopus/git/repository.rb +313 -0
  44. data/lib/canopus/git/status.rb +74 -0
  45. data/lib/canopus/git/tree_entry.rb +7 -0
  46. data/lib/canopus/git.rb +15 -0
  47. data/lib/canopus/icon_theme.rb +43 -0
  48. data/lib/canopus/language/background_analysis/job.rb +12 -0
  49. data/lib/canopus/language/background_analysis/scheduler.rb +70 -0
  50. data/lib/canopus/language/background_analysis.rb +280 -0
  51. data/lib/canopus/language/definition.rb +7 -0
  52. data/lib/canopus/language/document.rb +143 -0
  53. data/lib/canopus/language/symbol.rb +7 -0
  54. data/lib/canopus/language/syntax_worker.rb +91 -0
  55. data/lib/canopus/language.rb +42 -0
  56. data/lib/canopus/lazy_rope.rb +218 -0
  57. data/lib/canopus/lsp/client.rb +299 -0
  58. data/lib/canopus/lsp/error.rb +7 -0
  59. data/lib/canopus/lsp/future/subscription.rb +9 -0
  60. data/lib/canopus/lsp/future.rb +87 -0
  61. data/lib/canopus/lsp/protocol.rb +83 -0
  62. data/lib/canopus/lsp/server_error.rb +13 -0
  63. data/lib/canopus/lsp/timeout.rb +7 -0
  64. data/lib/canopus/lsp/transport.rb +123 -0
  65. data/lib/canopus/lsp.rb +19 -0
  66. data/lib/canopus/markdown.rb +69 -0
  67. data/lib/canopus/match_data_compat.rb +17 -0
  68. data/lib/canopus/multi_buffer.rb +266 -0
  69. data/lib/canopus/pane.rb +61 -0
  70. data/lib/canopus/patch/composite.rb +12 -0
  71. data/lib/canopus/patch/reload.rb +18 -0
  72. data/lib/canopus/patch.rb +27 -0
  73. data/lib/canopus/performance_recorder.rb +207 -0
  74. data/lib/canopus/plugins/api.rb +50 -0
  75. data/lib/canopus/plugins/isolated_runtime.rb +167 -0
  76. data/lib/canopus/plugins/local_runtime.rb +25 -0
  77. data/lib/canopus/plugins/permission_denied.rb +7 -0
  78. data/lib/canopus/plugins/registry.rb +30 -0
  79. data/lib/canopus/plugins.rb +11 -0
  80. data/lib/canopus/project/ignore_matcher.rb +104 -0
  81. data/lib/canopus/project/search.rb +164 -0
  82. data/lib/canopus/project/search_worker/cancelled.rb +3 -0
  83. data/lib/canopus/project/search_worker/runner.rb +9 -0
  84. data/lib/canopus/project/search_worker.rb +108 -0
  85. data/lib/canopus/project/tree.rb +48 -0
  86. data/lib/canopus/project/watcher.rb +59 -0
  87. data/lib/canopus/project.rb +127 -0
  88. data/lib/canopus/regexp_compat.rb +30 -0
  89. data/lib/canopus/save_conflict.rb +5 -0
  90. data/lib/canopus/selection.rb +11 -0
  91. data/lib/canopus/settings.rb +118 -0
  92. data/lib/canopus/snippet/transform.rb +209 -0
  93. data/lib/canopus/snippet.rb +183 -0
  94. data/lib/canopus/tab_map.rb +30 -0
  95. data/lib/canopus/terminal/cell.rb +7 -0
  96. data/lib/canopus/terminal/grid.rb +321 -0
  97. data/lib/canopus/terminal/pty.rb +103 -0
  98. data/lib/canopus/terminal/scrollback.rb +38 -0
  99. data/lib/canopus/terminal/vt.rb +398 -0
  100. data/lib/canopus/terminal.rb +13 -0
  101. data/lib/canopus/theme.rb +43 -0
  102. data/lib/canopus/version.rb +5 -0
  103. data/lib/canopus/vim/commandable.rb +148 -0
  104. data/lib/canopus/vim/motionable.rb +321 -0
  105. data/lib/canopus/vim/operator_capable.rb +377 -0
  106. data/lib/canopus/vim/text_object_selectable.rb +141 -0
  107. data/lib/canopus/vim.rb +426 -0
  108. data/lib/canopus/workspace/edit/executable.rb +139 -0
  109. data/lib/canopus/workspace/edit/node.rb +8 -0
  110. data/lib/canopus/workspace/edit/plan.rb +170 -0
  111. data/lib/canopus/workspace/edit/resource_preparable.rb +98 -0
  112. data/lib/canopus/workspace/edit.rb +6 -0
  113. data/lib/canopus/workspace/file_change_aware.rb +40 -0
  114. data/lib/canopus/workspace/file_previewable.rb +31 -0
  115. data/lib/canopus/workspace/git_aware.rb +140 -0
  116. data/lib/canopus/workspace/language_aware.rb +413 -0
  117. data/lib/canopus/workspace/language_server_configurable.rb +181 -0
  118. data/lib/canopus/workspace/project_searchable.rb +208 -0
  119. data/lib/canopus/workspace/project_tree_editable.rb +82 -0
  120. data/lib/canopus/workspace/session_persistable.rb +153 -0
  121. data/lib/canopus/workspace/settings_aware.rb +91 -0
  122. data/lib/canopus/workspace/view/terminal_presentable.rb +185 -0
  123. data/lib/canopus/workspace/view.rb +643 -0
  124. data/lib/canopus/workspace.rb +525 -0
  125. data/lib/canopus/wrap_map.rb +108 -0
  126. data/lib/canopus.rb +24 -0
  127. data/sig/canopus.rbs +375 -0
  128. data/sig/controller.rbs +55 -0
  129. data/sig/display_stages.rbs +49 -0
  130. data/sig/git.rbs +140 -0
  131. data/sig/lsp.rbs +99 -0
  132. data/sig/markdown.rbs +11 -0
  133. data/sig/performance_recorder.rbs +23 -0
  134. data/sig/search_services.rbs +8 -0
  135. data/sig/services.rbs +81 -0
  136. data/sig/snippet.rbs +42 -0
  137. data/sig/terminal.rbs +125 -0
  138. data/sig/vim.rbs +27 -0
  139. data/sig/workspace_edits.rbs +11 -0
  140. data/sig/workspace_services.rbs +117 -0
  141. data/tools/certify_snippet_regex.rb +35 -0
  142. data/tools/check_dependencies.rb +80 -0
  143. data/tools/native_check.rb +70 -0
  144. data/tools/package.rb +113 -0
  145. data/tools/package_test.rb +32 -0
  146. metadata +314 -0
@@ -0,0 +1,131 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Canopus
4
+ module Git
5
+ module Diff
6
+ Edit = Struct.new(:kind, :old_line, :new_line, :text, keyword_init: true)
7
+ Hunk = Struct.new(:old_start, :old_count, :new_start, :new_count, :edits, keyword_init: true) do
8
+ def old_text = edits.reject { |edit| edit.kind == :insert }.map(&:text).join
9
+ def new_text = edits.reject { |edit| edit.kind == :delete }.map(&:text).join
10
+ end
11
+ module_function
12
+
13
+ # Myers shortest edit script, with matching prefixes/suffixes removed first.
14
+ def edits(before, after)
15
+ left = before.is_a?(String) ? before.lines : before
16
+ right = after.is_a?(String) ? after.lines : after
17
+ prefix = 0
18
+ prefix += 1 while prefix < left.length && prefix < right.length && left[prefix] == right[prefix]
19
+ suffix = 0
20
+ suffix += 1 while suffix < left.length - prefix && suffix < right.length - prefix && left[-suffix - 1] == right[-suffix - 1]
21
+ a = left.slice(prefix, left.length - prefix - suffix)
22
+ b = right.slice(prefix, right.length - prefix - suffix)
23
+ middle = script(a, b)
24
+ pairs = left.first(prefix).map { |line| [:equal, line] } + middle + (suffix.zero? ? [] : left.last(suffix).map { |line| [:equal, line] })
25
+ old_line = new_line = 1
26
+ pairs.map do |kind, text|
27
+ edit = Edit.new(kind: kind, old_line: old_line, new_line: new_line, text: text)
28
+ old_line += 1 unless kind == :insert
29
+ new_line += 1 unless kind == :delete
30
+ edit
31
+ end
32
+ end
33
+
34
+ def hunks(before, after, context: 3)
35
+ raise ArgumentError, "context must be nonnegative" unless context.is_a?(Integer) && context >= 0
36
+ changes = edits(before, after)
37
+ ranges = []
38
+ changes.each_with_index do |edit, index|
39
+ next if edit.kind == :equal
40
+ first = [0, index - context].max
41
+ last = [changes.length - 1, index + context].min
42
+ if ranges.last && first <= ranges.last[1] + 1
43
+ ranges.last[1] = last
44
+ else
45
+ ranges << [first, last]
46
+ end
47
+ end
48
+ ranges.map do |first, last|
49
+ items = changes[first..last]
50
+ old_count = items.count { |edit| edit.kind != :insert }
51
+ new_count = items.count { |edit| edit.kind != :delete }
52
+ Hunk.new(old_start: items.first.old_line - (old_count.zero? ? 1 : 0), old_count: old_count,
53
+ new_start: items.first.new_line - (new_count.zero? ? 1 : 0), new_count: new_count, edits: items)
54
+ end
55
+ end
56
+
57
+ def revert(text, hunk)
58
+ lines = text.lines
59
+ start = hunk.new_count.zero? ? hunk.new_start : hunk.new_start - 1
60
+ raise ArgumentError, "hunk no longer applies" unless lines.slice(start, hunk.new_count)&.join == hunk.new_text
61
+ lines[start, hunk.new_count] = hunk.old_text.lines
62
+ lines.join
63
+ end
64
+
65
+ def unified(before, after, old_name: "a/file", new_name: "b/file", context: 3)
66
+ output = +"--- #{old_name}\n+++ #{new_name}\n"
67
+ hunks(before, after, context: context).each do |hunk|
68
+ output << "@@ -#{hunk.old_start},#{hunk.old_count} +#{hunk.new_start},#{hunk.new_count} @@\n"
69
+ hunk.edits.each do |edit|
70
+ output << {equal: " ", delete: "-", insert: "+"}.fetch(edit.kind) << edit.text
71
+ output << "\n\\n" unless edit.text.end_with?("\n")
72
+ end
73
+ end
74
+ output
75
+ end
76
+
77
+ def script(left, right)
78
+ return right.map { |line| [:insert, line] } if left.empty?
79
+ return left.map { |line| [:delete, line] } if right.empty?
80
+ # Entirely replaced files have no diagonals to search: avoid O(n*m).
81
+ return left.map { |line| [:delete, line] } + right.map { |line| [:insert, line] } unless left.intersect?(right)
82
+ # The linear-space bisect avoids quadratic trace storage on large edits.
83
+ x, y = bisect(left, right)
84
+ if !x || (x.zero? && y.zero?) || (x == left.length && y == right.length)
85
+ return left.map { |line| [:delete, line] } + right.map { |line| [:insert, line] }
86
+ end
87
+ # Reuse prefix/suffix trimming in each half through a local edit script.
88
+ [edits(left[0...x], right[0...y]), edits(left[x..], right[y..])].flatten.map { |edit| [edit.kind, edit.text] }
89
+ end
90
+ private_class_method :script
91
+
92
+ def bisect(left, right)
93
+ n, m = left.length, right.length
94
+ limit = (n + m + 1) / 2
95
+ forward = {1 => 0}
96
+ reverse = {1 => 0}
97
+ delta = n - m
98
+ odd = delta.odd?
99
+ (0..limit).each do |depth|
100
+ (-depth..depth).step(2) do |k|
101
+ x = k == -depth || (k != depth && forward.fetch(k - 1, -1) < forward.fetch(k + 1, -1)) ? forward.fetch(k + 1, 0) : forward.fetch(k - 1, 0) + 1
102
+ y = x - k
103
+ while x < n && y < m && x >= 0 && y >= 0 && left[x] == right[y]
104
+ x += 1
105
+ y += 1
106
+ end
107
+ forward[k] = x
108
+ if odd && reverse.key?(delta - k) && x + reverse[delta - k] >= n
109
+ return [x, y]
110
+ end
111
+ end
112
+ (-depth..depth).step(2) do |k|
113
+ x = k == -depth || (k != depth && reverse.fetch(k - 1, -1) < reverse.fetch(k + 1, -1)) ? reverse.fetch(k + 1, 0) : reverse.fetch(k - 1, 0) + 1
114
+ y = x - k
115
+ while x < n && y < m && x >= 0 && y >= 0 && left[n - x - 1] == right[m - y - 1]
116
+ x += 1
117
+ y += 1
118
+ end
119
+ reverse[k] = x
120
+ if !odd && forward.key?(delta - k) && forward[delta - k] + x >= n
121
+ middle_x = forward[delta - k]
122
+ return [middle_x, middle_x - (delta - k)]
123
+ end
124
+ end
125
+ end
126
+ nil
127
+ end
128
+ private_class_method :bisect
129
+ end
130
+ end
131
+ end
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest/sha1"
4
+
5
+ module Canopus
6
+ module Git
7
+ class Index
8
+ Entry = Struct.new(:path, :oid, :mode, :size, :mtime, :mtime_nsec, :ctime, :ctime_nsec,
9
+ :dev, :ino, :uid, :gid, :stage, :flags, :extended_flags, keyword_init: true)
10
+ include Enumerable
11
+ attr_reader :entries, :version, :path
12
+
13
+ def initialize(path)
14
+ @path = path
15
+ @entries = []
16
+ @version = 2
17
+ parse(File.binread(path)) if File.file?(path)
18
+ end
19
+
20
+ def each(&block) = entries.each(&block)
21
+ def [](path) = entries.find { |entry| entry.path == path && entry.stage.zero? }
22
+
23
+ def self.encode(entries)
24
+ bytes = +"DIRC".b << [2, entries.length].pack("N2")
25
+ entries.sort_by { |entry| [entry.path.b, entry.stage || 0] }.each do |entry|
26
+ name = entry.path.b
27
+ fields = %i[ctime ctime_nsec mtime mtime_nsec dev ino mode uid gid size].map { |field| entry.public_send(field).to_i & 0xffffffff }
28
+ record = fields.pack("N10") + [entry.oid].pack("H*") + [[name.bytesize, 0xfff].min | ((entry.stage || 0) << 12)].pack("n") + name + "\0"
29
+ record << "\0" * ((8 - record.bytesize % 8) % 8)
30
+ bytes << record
31
+ end
32
+ bytes + Digest::SHA1.digest(bytes)
33
+ end
34
+
35
+ private
36
+
37
+ def parse(bytes)
38
+ raise CorruptObject, "invalid Git index" unless bytes.bytesize >= 32 && bytes.start_with?("DIRC")
39
+ raise CorruptObject, "Git index checksum mismatch" unless Digest::SHA1.digest(bytes[0...-20]) == bytes[-20, 20]
40
+ @version, count = bytes[4, 8].unpack("N2")
41
+ raise CorruptObject, "unsupported Git index version #{version}" unless [2, 3, 4].include?(version)
42
+ offset = 12
43
+ previous = "".b
44
+ count.times do
45
+ start = offset
46
+ raise CorruptObject, "truncated Git index entry" if offset + 62 > bytes.bytesize - 20
47
+ fields = bytes[offset, 40].unpack("N10")
48
+ oid = bytes[offset + 40, 20].unpack1("H*")
49
+ flags = bytes[offset + 60, 2].unpack1("n")
50
+ offset += 62
51
+ extended = 0
52
+ if (flags & 0x4000).positive?
53
+ raise CorruptObject, "invalid index extended flags" if version == 2 || offset + 2 > bytes.bytesize - 20
54
+ extended = bytes[offset, 2].unpack1("n")
55
+ offset += 2
56
+ end
57
+ if version == 4
58
+ byte = bytes.getbyte(offset)
59
+ raise CorruptObject, "truncated index path prefix" unless byte
60
+ offset += 1
61
+ strip = byte & 0x7f
62
+ while (byte & 0x80).positive?
63
+ byte = bytes.getbyte(offset)
64
+ raise CorruptObject, "invalid index path prefix" unless byte && strip <= previous.bytesize
65
+ offset += 1
66
+ strip = ((strip + 1) << 7) | (byte & 0x7f)
67
+ end
68
+ raise CorruptObject, "index path prefix outside previous path" if strip > previous.bytesize
69
+ end
70
+ ending = bytes.index("\0", offset)
71
+ raise CorruptObject, "unterminated index path" unless ending && ending < bytes.bytesize - 20
72
+ name = bytes[offset...ending]
73
+ name = previous.byteslice(0, previous.bytesize - strip) + name if version == 4
74
+ raise CorruptObject, "unsafe index path" if name.empty? || name.start_with?("/") || name.split("/").any? { |part| part == ".." || part == ".git" }
75
+ previous = name
76
+ offset = ending + 1
77
+ offset += (8 - (offset - start) % 8) % 8 unless version == 4
78
+ values = %i[ctime ctime_nsec mtime mtime_nsec dev ino mode uid gid size].zip(fields).to_h
79
+ entries << Entry.new(**values, path: name.force_encoding(Encoding::UTF_8), oid: oid,
80
+ flags: flags, extended_flags: extended, stage: (flags >> 12) & 3)
81
+ end
82
+ while offset < bytes.bytesize - 20
83
+ raise CorruptObject, "truncated index extension" if offset + 8 > bytes.bytesize - 20
84
+ signature = bytes[offset, 4]
85
+ size = bytes[offset + 4, 4].unpack1("N")
86
+ # Lowercase extensions change index interpretation (e.g. split index).
87
+ raise CorruptObject, "unsupported mandatory index extension #{signature}" if signature[0].match?(/[a-z]/)
88
+ offset += 8 + size
89
+ raise CorruptObject, "truncated index extension payload" if offset > bytes.bytesize - 20
90
+ end
91
+ end
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "pack"
4
+
5
+ module Canopus
6
+ module Git
7
+ class ObjectDatabase
8
+ attr_reader :directory
9
+
10
+ def initialize(directory)
11
+ @directory = File.expand_path(directory)
12
+ @packs = nil
13
+ end
14
+
15
+ def self.hash(type, data) = Digest::SHA1.hexdigest("#{type} #{data.bytesize}\0".b + data.b)
16
+
17
+ def read(oid, seen = [])
18
+ raise ArgumentError, "expected a full SHA-1 object id" unless /\A[0-9a-f]{40}\z/.match?(oid.to_s)
19
+ raise CorruptObject, "cyclic object reference" if seen.include?(oid) || seen.length > 128
20
+ loose = File.join(directory, oid[0, 2], oid[2..])
21
+ object = if File.file?(loose)
22
+ inflated = Zlib::Inflate.inflate(File.binread(loose))
23
+ header, data = inflated.split("\0", 2)
24
+ type, size = header.split(" ", 2)
25
+ raise CorruptObject, "invalid loose object header" unless %w[commit tree blob tag].include?(type) && size&.match?(/\A\d+\z/) && data && data.bytesize == size.to_i
26
+ [type, data]
27
+ else
28
+ pack = packs.find { |entry| entry.include?(oid) }
29
+ unless pack
30
+ @packs = nil # New packs may appear during background GC.
31
+ pack = packs.find { |entry| entry.include?(oid) }
32
+ end
33
+ raise KeyError, "Git object not found: #{oid}" unless pack
34
+ pack.read(oid) { |base| read(base, seen + [oid]) }
35
+ end
36
+ raise CorruptObject, "object SHA-1 mismatch: #{oid}" unless self.class.hash(*object) == oid
37
+ object
38
+ rescue Zlib::Error => error
39
+ raise CorruptObject, error.message
40
+ end
41
+
42
+ def packs = @packs ||= Dir[File.join(directory, "pack", "*.idx")].sort.map { |path| Pack.new(path) }
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,186 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "zlib"
4
+ require "digest/sha1"
5
+ require_relative "corrupt_object"
6
+
7
+ module Canopus
8
+ module Git
9
+ class Pack
10
+ TYPES = {1 => "commit", 2 => "tree", 3 => "blob", 4 => "tag"}.freeze
11
+ MAX_OBJECT_SIZE = 512 * 1024 * 1024
12
+ attr_reader :path, :offsets
13
+
14
+ def initialize(index_path)
15
+ @path = index_path.sub(/\.idx\z/, ".pack")
16
+ @offsets = read_index(File.binread(index_path))
17
+ File.open(path, "rb") do |file|
18
+ header = file.read(12)
19
+ raise CorruptObject, "invalid pack header" unless header&.bytesize == 12 && header[0, 4] == "PACK" && [2, 3].include?(header[4, 4].unpack1("N"))
20
+ raise CorruptObject, "pack/index object count mismatch" unless header[8, 4].unpack1("N") == offsets.length
21
+ file.seek(-20, IO::SEEK_END)
22
+ raise CorruptObject, "pack/index checksum mismatch" unless file.read(20) == @pack_checksum
23
+ end
24
+ @cache = {}
25
+ end
26
+
27
+ def include?(oid) = offsets.key?(oid)
28
+
29
+ def read(oid, &resolve)
30
+ offset = offsets[oid]
31
+ raise KeyError, "object not in pack: #{oid}" unless offset
32
+ read_at(offset, [], &resolve)
33
+ end
34
+
35
+ def self.apply_delta(base, delta)
36
+ cursor = 0
37
+ read_size = lambda do
38
+ size = shift = 0
39
+ loop do
40
+ byte = delta.getbyte(cursor)
41
+ raise CorruptObject, "truncated delta header" unless byte && shift <= 63
42
+ cursor += 1
43
+ size |= (byte & 0x7f) << shift
44
+ break if (byte & 0x80).zero?
45
+ shift += 7
46
+ end
47
+ size
48
+ end
49
+ source_size = read_size.call
50
+ target_size = read_size.call
51
+ raise CorruptObject, "delta base size mismatch" unless base.bytesize == source_size
52
+ raise CorruptObject, "delta too large" if target_size > MAX_OBJECT_SIZE
53
+ output = +"".b
54
+ while cursor < delta.bytesize
55
+ opcode = delta.getbyte(cursor)
56
+ cursor += 1
57
+ if (opcode & 0x80).positive?
58
+ offset = length = 0
59
+ 7.times do |bit|
60
+ next if (opcode & (1 << bit)).zero?
61
+ byte = delta.getbyte(cursor)
62
+ raise CorruptObject, "truncated delta copy" unless byte
63
+ cursor += 1
64
+ bit < 4 ? offset |= byte << (bit * 8) : length |= byte << ((bit - 4) * 8)
65
+ end
66
+ length = 0x10000 if length.zero?
67
+ raise CorruptObject, "delta copy outside base" if offset + length > base.bytesize
68
+ output << base.byteslice(offset, length)
69
+ elsif opcode.positive?
70
+ raise CorruptObject, "truncated delta insert" if cursor + opcode > delta.bytesize
71
+ output << delta.byteslice(cursor, opcode)
72
+ cursor += opcode
73
+ else
74
+ raise CorruptObject, "invalid delta opcode"
75
+ end
76
+ raise CorruptObject, "delta exceeds target size" if output.bytesize > target_size
77
+ end
78
+ raise CorruptObject, "delta target size mismatch" unless output.bytesize == target_size
79
+ output
80
+ end
81
+
82
+ private
83
+
84
+ def read_index(bytes)
85
+ raise CorruptObject, "truncated pack index" if bytes.bytesize < 1064
86
+ raise CorruptObject, "pack index checksum mismatch" unless Digest::SHA1.digest(bytes[0...-20]) == bytes[-20, 20]
87
+ @pack_checksum = bytes[-40, 20]
88
+ version = bytes.start_with?("\xfftOc".b) ? bytes[4, 4].unpack1("N") : 1
89
+ raise CorruptObject, "unsupported pack index version #{version}" unless [1, 2].include?(version)
90
+ start = version == 1 ? 0 : 8
91
+ fanout = bytes[start, 1024].unpack("N*")
92
+ raise CorruptObject, "invalid pack index fanout" unless fanout.each_cons(2).all? { |a, b| a <= b }
93
+ count = fanout.last
94
+ cursor = start + 1024
95
+ minimum = cursor + count * (version == 1 ? 24 : 28) + 40
96
+ raise CorruptObject, "truncated pack index entries" if minimum > bytes.bytesize
97
+ if version == 1
98
+ return count.times.to_h do |index|
99
+ position = cursor + index * 24
100
+ [bytes[position + 4, 20].unpack1("H*"), bytes[position, 4].unpack1("N")]
101
+ end
102
+ end
103
+ names = cursor
104
+ positions = cursor + count * 24
105
+ large_positions = positions + count * 4
106
+ count.times.to_h do |index|
107
+ offset = bytes[positions + index * 4, 4].unpack1("N")
108
+ if offset >= 0x80000000
109
+ location = large_positions + (offset & 0x7fffffff) * 8
110
+ raise CorruptObject, "truncated 64-bit pack offset" if location + 8 > bytes.bytesize - 40
111
+ offset = bytes[location, 8].unpack1("Q>")
112
+ end
113
+ [bytes[names + index * 20, 20].unpack1("H*"), offset]
114
+ end
115
+ end
116
+
117
+ def read_at(offset, stack, &resolve)
118
+ return @cache[offset] if @cache.key?(offset)
119
+ raise CorruptObject, "cyclic or excessive pack delta chain" if stack.include?(offset) || stack.length > 128
120
+ stack = stack + [offset]
121
+ type = data = base_offset = base_oid = nil
122
+ File.open(path, "rb") do |file|
123
+ raise CorruptObject, "object offset outside pack" unless offset >= 12 && offset < file.size - 20
124
+ file.seek(offset)
125
+ byte = file.getbyte
126
+ type = (byte >> 4) & 7
127
+ size = byte & 15
128
+ shift = 4
129
+ while (byte & 0x80).positive?
130
+ byte = file.getbyte
131
+ raise CorruptObject, "truncated pack object size" unless byte && shift <= 63
132
+ size |= (byte & 0x7f) << shift
133
+ shift += 7
134
+ end
135
+ raise CorruptObject, "pack object too large" if size > MAX_OBJECT_SIZE
136
+ if type == 6
137
+ byte = file.getbyte
138
+ raise CorruptObject, "truncated delta offset" unless byte
139
+ distance = byte & 0x7f
140
+ count = 0
141
+ while (byte & 0x80).positive?
142
+ byte = file.getbyte
143
+ count += 1
144
+ raise CorruptObject, "invalid delta offset" unless byte && count <= 9
145
+ distance = ((distance + 1) << 7) | (byte & 0x7f)
146
+ end
147
+ base_offset = offset - distance
148
+ raise CorruptObject, "invalid delta base offset" unless base_offset >= 12 && base_offset < offset
149
+ elsif type == 7
150
+ raw = file.read(20)
151
+ raise CorruptObject, "truncated delta reference" unless raw&.bytesize == 20
152
+ base_oid = raw.unpack1("H*")
153
+ elsif !TYPES.key?(type)
154
+ raise CorruptObject, "invalid packed object type #{type}"
155
+ end
156
+ inflater = Zlib::Inflate.new
157
+ begin
158
+ data = +"".b
159
+ until inflater.finished?
160
+ chunk = file.read(16_384)
161
+ raise CorruptObject, "truncated compressed object" unless chunk
162
+ inflater.inflate(chunk) do |part|
163
+ data << part
164
+ raise CorruptObject, "packed object exceeds declared size" if data.bytesize > size
165
+ end
166
+ end
167
+ rescue Zlib::Error => error
168
+ raise CorruptObject, error.message
169
+ ensure
170
+ inflater.close
171
+ end
172
+ raise CorruptObject, "packed object size mismatch" unless data.bytesize == size
173
+ end
174
+ object = if base_offset || base_oid
175
+ base_type, base = base_offset ? read_at(base_offset, stack, &resolve) : resolve.call(base_oid)
176
+ [base_type, self.class.apply_delta(base, data)]
177
+ else
178
+ [TYPES.fetch(type), data]
179
+ end
180
+ # ponytail: bound object cache by count; byte budgeting if large blobs dominate.
181
+ @cache.shift if @cache.length >= 128
182
+ @cache[offset] = object.map(&:freeze).freeze
183
+ end
184
+ end
185
+ end
186
+ end