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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +5 -0
- data/LICENSE.txt +21 -0
- data/README.md +148 -0
- data/docs/adr/001-runtime-gem-components.md +24 -0
- data/docs/adr/002-bounded-diff-engine.md +22 -0
- data/docs/adr/003-background-language-analysis.md +22 -0
- data/docs/adr/004-persistent-display-map.md +23 -0
- data/docs/adr/005-windows-runtime.md +27 -0
- data/docs/adr/README.md +36 -0
- data/docs/distribution.md +27 -0
- data/docs/lsp.md +38 -0
- data/docs/performance.md +40 -0
- data/docs/snippets.md +32 -0
- data/docs/vim.md +24 -0
- data/docs/workspace_edits.md +32 -0
- data/examples/native_smoke.rb +25 -0
- data/examples/plugins/word_count.rb +11 -0
- data/exe/canopus +19 -0
- data/lib/canopus/block_map.rb +19 -0
- data/lib/canopus/buffer.rb +293 -0
- data/lib/canopus/cli.rb +171 -0
- data/lib/canopus/controller.rb +507 -0
- data/lib/canopus/data_compat.rb +25 -0
- data/lib/canopus/display_map/line_builder.rb +54 -0
- data/lib/canopus/display_map/line_set.rb +8 -0
- data/lib/canopus/display_map/pending_line_set.rb +9 -0
- data/lib/canopus/display_map/summary.rb +23 -0
- data/lib/canopus/display_map/worker.rb +89 -0
- data/lib/canopus/display_map.rb +350 -0
- data/lib/canopus/display_point.rb +5 -0
- data/lib/canopus/editor/snippet_expandable.rb +281 -0
- data/lib/canopus/editor.rb +445 -0
- data/lib/canopus/error.rb +5 -0
- data/lib/canopus/fold_map.rb +87 -0
- data/lib/canopus/git/blame.rb +50 -0
- data/lib/canopus/git/commit.rb +7 -0
- data/lib/canopus/git/corrupt_object.rb +7 -0
- data/lib/canopus/git/diff.rb +131 -0
- data/lib/canopus/git/index.rb +94 -0
- data/lib/canopus/git/object_database.rb +45 -0
- data/lib/canopus/git/pack.rb +186 -0
- data/lib/canopus/git/repository.rb +313 -0
- data/lib/canopus/git/status.rb +74 -0
- data/lib/canopus/git/tree_entry.rb +7 -0
- data/lib/canopus/git.rb +15 -0
- data/lib/canopus/icon_theme.rb +43 -0
- data/lib/canopus/language/background_analysis/job.rb +12 -0
- data/lib/canopus/language/background_analysis/scheduler.rb +70 -0
- data/lib/canopus/language/background_analysis.rb +280 -0
- data/lib/canopus/language/definition.rb +7 -0
- data/lib/canopus/language/document.rb +143 -0
- data/lib/canopus/language/symbol.rb +7 -0
- data/lib/canopus/language/syntax_worker.rb +91 -0
- data/lib/canopus/language.rb +42 -0
- data/lib/canopus/lazy_rope.rb +218 -0
- data/lib/canopus/lsp/client.rb +299 -0
- data/lib/canopus/lsp/error.rb +7 -0
- data/lib/canopus/lsp/future/subscription.rb +9 -0
- data/lib/canopus/lsp/future.rb +87 -0
- data/lib/canopus/lsp/protocol.rb +83 -0
- data/lib/canopus/lsp/server_error.rb +13 -0
- data/lib/canopus/lsp/timeout.rb +7 -0
- data/lib/canopus/lsp/transport.rb +123 -0
- data/lib/canopus/lsp.rb +19 -0
- data/lib/canopus/markdown.rb +69 -0
- data/lib/canopus/match_data_compat.rb +17 -0
- data/lib/canopus/multi_buffer.rb +266 -0
- data/lib/canopus/pane.rb +61 -0
- data/lib/canopus/patch/composite.rb +12 -0
- data/lib/canopus/patch/reload.rb +18 -0
- data/lib/canopus/patch.rb +27 -0
- data/lib/canopus/performance_recorder.rb +207 -0
- data/lib/canopus/plugins/api.rb +50 -0
- data/lib/canopus/plugins/isolated_runtime.rb +167 -0
- data/lib/canopus/plugins/local_runtime.rb +25 -0
- data/lib/canopus/plugins/permission_denied.rb +7 -0
- data/lib/canopus/plugins/registry.rb +30 -0
- data/lib/canopus/plugins.rb +11 -0
- data/lib/canopus/project/ignore_matcher.rb +104 -0
- data/lib/canopus/project/search.rb +164 -0
- data/lib/canopus/project/search_worker/cancelled.rb +3 -0
- data/lib/canopus/project/search_worker/runner.rb +9 -0
- data/lib/canopus/project/search_worker.rb +108 -0
- data/lib/canopus/project/tree.rb +48 -0
- data/lib/canopus/project/watcher.rb +59 -0
- data/lib/canopus/project.rb +127 -0
- data/lib/canopus/regexp_compat.rb +30 -0
- data/lib/canopus/save_conflict.rb +5 -0
- data/lib/canopus/selection.rb +11 -0
- data/lib/canopus/settings.rb +118 -0
- data/lib/canopus/snippet/transform.rb +209 -0
- data/lib/canopus/snippet.rb +183 -0
- data/lib/canopus/tab_map.rb +30 -0
- data/lib/canopus/terminal/cell.rb +7 -0
- data/lib/canopus/terminal/grid.rb +321 -0
- data/lib/canopus/terminal/pty.rb +103 -0
- data/lib/canopus/terminal/scrollback.rb +38 -0
- data/lib/canopus/terminal/vt.rb +398 -0
- data/lib/canopus/terminal.rb +13 -0
- data/lib/canopus/theme.rb +43 -0
- data/lib/canopus/version.rb +5 -0
- data/lib/canopus/vim/commandable.rb +148 -0
- data/lib/canopus/vim/motionable.rb +321 -0
- data/lib/canopus/vim/operator_capable.rb +377 -0
- data/lib/canopus/vim/text_object_selectable.rb +141 -0
- data/lib/canopus/vim.rb +426 -0
- data/lib/canopus/workspace/edit/executable.rb +139 -0
- data/lib/canopus/workspace/edit/node.rb +8 -0
- data/lib/canopus/workspace/edit/plan.rb +170 -0
- data/lib/canopus/workspace/edit/resource_preparable.rb +98 -0
- data/lib/canopus/workspace/edit.rb +6 -0
- data/lib/canopus/workspace/file_change_aware.rb +40 -0
- data/lib/canopus/workspace/file_previewable.rb +31 -0
- data/lib/canopus/workspace/git_aware.rb +140 -0
- data/lib/canopus/workspace/language_aware.rb +413 -0
- data/lib/canopus/workspace/language_server_configurable.rb +181 -0
- data/lib/canopus/workspace/project_searchable.rb +208 -0
- data/lib/canopus/workspace/project_tree_editable.rb +82 -0
- data/lib/canopus/workspace/session_persistable.rb +153 -0
- data/lib/canopus/workspace/settings_aware.rb +91 -0
- data/lib/canopus/workspace/view/terminal_presentable.rb +185 -0
- data/lib/canopus/workspace/view.rb +643 -0
- data/lib/canopus/workspace.rb +525 -0
- data/lib/canopus/wrap_map.rb +108 -0
- data/lib/canopus.rb +24 -0
- data/sig/canopus.rbs +375 -0
- data/sig/controller.rbs +55 -0
- data/sig/display_stages.rbs +49 -0
- data/sig/git.rbs +140 -0
- data/sig/lsp.rbs +99 -0
- data/sig/markdown.rbs +11 -0
- data/sig/performance_recorder.rbs +23 -0
- data/sig/search_services.rbs +8 -0
- data/sig/services.rbs +81 -0
- data/sig/snippet.rbs +42 -0
- data/sig/terminal.rbs +125 -0
- data/sig/vim.rbs +27 -0
- data/sig/workspace_edits.rbs +11 -0
- data/sig/workspace_services.rbs +117 -0
- data/tools/certify_snippet_regex.rb +35 -0
- data/tools/check_dependencies.rb +80 -0
- data/tools/native_check.rb +70 -0
- data/tools/package.rb +113 -0
- data/tools/package_test.rb +32 -0
- 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
|