canopus 0.4.0 → 0.5.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 +4 -4
- data/CHANGELOG.md +55 -0
- data/README.md +191 -19
- data/assets/keymaps/emacs.jsonc +5 -0
- data/assets/keymaps/jetbrains.jsonc +5 -0
- data/assets/keymaps/sublime.jsonc +5 -0
- data/assets/keymaps/vscode.jsonc +5 -0
- data/docs/debugging.md +143 -0
- data/docs/distribution.md +40 -9
- data/docs/lsp.md +131 -0
- data/docs/performance.md +18 -0
- data/docs/providers.md +36 -0
- data/docs/release-log.md +12 -0
- data/docs/tasks.md +64 -0
- data/docs/testing.md +49 -0
- data/docs/vim.md +3 -0
- data/exe/canopus +4 -1
- data/lib/canopus/buffer.rb +291 -50
- data/lib/canopus/cli.rb +5 -2
- data/lib/canopus/command.rb +13 -4
- data/lib/canopus/controller.rb +183 -40
- data/lib/canopus/debug/breakpoints.rb +550 -0
- data/lib/canopus/debug/configuration.rb +291 -0
- data/lib/canopus/debug/console.rb +243 -0
- data/lib/canopus/debug/panel.rb +487 -0
- data/lib/canopus/debug/session.rb +354 -0
- data/lib/canopus/decoration.rb +4 -2
- data/lib/canopus/denebola_compat.rb +27 -0
- data/lib/canopus/diagnostics.rb +173 -0
- data/lib/canopus/display_map/overlay_map.rb +6 -2
- data/lib/canopus/editor/snippet_expandable.rb +3 -2
- data/lib/canopus/editor.rb +122 -31
- data/lib/canopus/git/state.rb +100 -0
- data/lib/canopus/language/background_analysis.rb +93 -14
- data/lib/canopus/language/document.rb +70 -34
- data/lib/canopus/language/symbol.rb +1 -0
- data/lib/canopus/language/syntax_worker.rb +61 -9
- data/lib/canopus/language.rb +3 -0
- data/lib/canopus/minimap.rb +201 -0
- data/lib/canopus/patch.rb +3 -1
- data/lib/canopus/plugins/api.rb +4 -2
- data/lib/canopus/plugins/isolated_runtime.rb +64 -7
- data/lib/canopus/plugins/registry.rb +114 -3
- data/lib/canopus/plugins.rb +8 -7
- data/lib/canopus/project.rb +15 -6
- data/lib/canopus/provider.rb +192 -0
- data/lib/canopus/regexp_compat.rb +36 -4
- data/lib/canopus/selection.rb +12 -0
- data/lib/canopus/settings/schema.rb +201 -0
- data/lib/canopus/settings.rb +328 -49
- data/lib/canopus/task/configuration.rb +356 -0
- data/lib/canopus/task/problem_matcher.rb +264 -0
- data/lib/canopus/task/runner.rb +314 -0
- data/lib/canopus/test_runner/bounded_ignore.rb +144 -0
- data/lib/canopus/test_runner/discovery.rb +97 -0
- data/lib/canopus/test_runner/execution.rb +194 -0
- data/lib/canopus/test_runner/minitest.rb +116 -0
- data/lib/canopus/test_runner/rspec.rb +103 -0
- data/lib/canopus/test_runner/safe_walker.rb +37 -0
- data/lib/canopus/test_runner.rb +54 -0
- data/lib/canopus/theme/import.rb +131 -0
- data/lib/canopus/theme.rb +28 -4
- data/lib/canopus/version.rb +1 -1
- data/lib/canopus/vim/motionable.rb +1 -1
- data/lib/canopus/vim.rb +10 -16
- data/lib/canopus/workspace/auto_savable.rb +83 -0
- data/lib/canopus/workspace/debug_aware.rb +600 -0
- data/lib/canopus/workspace/edit/plan.rb +2 -2
- data/lib/canopus/workspace/editor_configurable.rb +113 -0
- data/lib/canopus/workspace/file_change_aware.rb +11 -3
- data/lib/canopus/workspace/git_aware.rb +80 -34
- data/lib/canopus/workspace/git_blame.rb +175 -0
- data/lib/canopus/workspace/git_conflict_resolution.rb +410 -0
- data/lib/canopus/workspace/git_diff_view.rb +641 -0
- data/lib/canopus/workspace/git_history.rb +171 -0
- data/lib/canopus/workspace/git_remote.rb +267 -0
- data/lib/canopus/workspace/git_staging.rb +583 -0
- data/lib/canopus/workspace/hierarchy_aware.rb +477 -0
- data/lib/canopus/workspace/keymap_aware.rb +66 -0
- data/lib/canopus/workspace/language_aware.rb +3721 -135
- data/lib/canopus/workspace/language_server_configurable.rb +287 -71
- data/lib/canopus/workspace/persistent_undo.rb +169 -0
- data/lib/canopus/workspace/problems_aware.rb +131 -0
- data/lib/canopus/workspace/project_searchable.rb +88 -11
- data/lib/canopus/workspace/project_tree_editable.rb +3 -1
- data/lib/canopus/workspace/recoverable.rb +204 -0
- data/lib/canopus/workspace/session_persistable.rb +85 -13
- data/lib/canopus/workspace/settings_aware.rb +76 -2
- data/lib/canopus/workspace/task_aware.rb +194 -0
- data/lib/canopus/workspace/test_aware.rb +394 -0
- data/lib/canopus/workspace/trust.rb +73 -0
- data/lib/canopus/workspace/view/terminal_presentable.rb +298 -47
- data/lib/canopus/workspace/view.rb +399 -74
- data/lib/canopus/workspace.rb +688 -44
- data/lib/canopus.rb +20 -2
- data/sig/canopus.rbs +138 -31
- data/sig/controller.rbs +10 -3
- data/sig/debug.rbs +121 -0
- data/sig/decoration.rbs +12 -2
- data/sig/diagnostics.rbs +26 -0
- data/sig/minimap.rbs +20 -0
- data/sig/provider.rbs +29 -0
- data/sig/services.rbs +1 -16
- data/sig/task.rbs +92 -0
- data/sig/test_runner.rbs +109 -0
- data/sig/trust.rbs +17 -0
- data/sig/workspace_edits.rbs +2 -2
- data/sig/workspace_services.rbs +234 -2
- data/tools/check_dependencies.rb +21 -2
- data/tools/package.rb +172 -14
- data/tools/package_signature_test.rb +33 -0
- data/tools/package_test.rb +29 -0
- data/tools/sign_package.rb +123 -0
- data/tools/update.rb +189 -0
- data/tools/update_test.rb +71 -0
- metadata +206 -31
- data/lib/canopus/lsp/client.rb +0 -307
- data/lib/canopus/lsp/error.rb +0 -7
- data/lib/canopus/lsp/future/subscription.rb +0 -9
- data/lib/canopus/lsp/future.rb +0 -87
- data/lib/canopus/lsp/protocol.rb +0 -83
- data/lib/canopus/lsp/server_error.rb +0 -13
- data/lib/canopus/lsp/timeout.rb +0 -7
- data/lib/canopus/lsp/transport.rb +0 -123
- data/lib/canopus/lsp.rb +0 -19
- data/lib/canopus/project/search.rb +0 -164
- data/lib/canopus/project/search_worker/cancelled.rb +0 -3
- data/lib/canopus/project/search_worker/runner.rb +0 -9
- data/lib/canopus/project/search_worker.rb +0 -108
- data/lib/canopus/terminal/cell.rb +0 -7
- data/lib/canopus/terminal/grid.rb +0 -321
- data/lib/canopus/terminal/pty.rb +0 -178
- data/lib/canopus/terminal/scrollback.rb +0 -38
- data/lib/canopus/terminal/vt.rb +0 -398
- data/lib/canopus/terminal.rb +0 -13
- data/sig/lsp.rbs +0 -99
- data/sig/terminal.rbs +0 -130
data/tools/package.rb
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "fileutils"
|
|
4
|
+
require "digest"
|
|
4
5
|
require "optparse"
|
|
5
6
|
require "rbconfig"
|
|
6
7
|
require "cgi"
|
|
@@ -10,8 +11,7 @@ require "shellwords"
|
|
|
10
11
|
module CanopusPackage
|
|
11
12
|
module_function
|
|
12
13
|
|
|
13
|
-
|
|
14
|
-
def build(platform:, output:, ruby: nil, source: File.expand_path("..", __dir__))
|
|
14
|
+
def build(platform:, output:, ruby: nil, ruby_root: nil, source: File.expand_path("..", __dir__))
|
|
15
15
|
raise ArgumentError, "platform must be mac, linux, or windows" unless %w[mac linux windows].include?(platform)
|
|
16
16
|
output, source = File.expand_path(output), File.realpath(source)
|
|
17
17
|
raise ArgumentError, "output cannot contain control characters" if output.match?(/[\x00-\x1f\x7f]/)
|
|
@@ -30,13 +30,14 @@ module CanopusPackage
|
|
|
30
30
|
candidates = [path, *Dir.glob(File.join(path, "**", "*"), File::FNM_DOTMATCH)]
|
|
31
31
|
raise ArgumentError, "source distribution contains symlinks" if candidates.any? { |candidate| File.symlink?(candidate) }
|
|
32
32
|
end
|
|
33
|
-
|
|
34
|
-
FileUtils.mkdir_p(
|
|
35
|
-
entries.each { |entry| FileUtils.cp_r(entry,
|
|
33
|
+
application_root = File.join(output, platform == "mac" ? "Contents/Resources/canopus" : "share/canopus")
|
|
34
|
+
FileUtils.mkdir_p(application_root)
|
|
35
|
+
entries.each { |entry| FileUtils.cp_r(entry, application_root, preserve: true) }
|
|
36
|
+
bundled_ruby = ruby_root && bundle_ruby(ruby_root, output)
|
|
36
37
|
case platform
|
|
37
38
|
when "mac"
|
|
38
39
|
executable = File.join(output, "Contents", "MacOS", "canopus")
|
|
39
|
-
shell_launcher(executable, ruby, "../Resources/canopus/exe/canopus")
|
|
40
|
+
shell_launcher(executable, ruby, "../Resources/canopus/exe/canopus", bundled_ruby && "../Resources/runtime/#{bundled_ruby}")
|
|
40
41
|
File.write(File.join(output, "Contents", "Info.plist"), <<~PLIST)
|
|
41
42
|
<?xml version="1.0" encoding="UTF-8"?>
|
|
42
43
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
@@ -50,11 +51,20 @@ module CanopusPackage
|
|
|
50
51
|
<key>CFBundleVersion</key><string>#{CGI.escapeHTML(version)}</string>
|
|
51
52
|
<key>NSHighResolutionCapable</key><true/>
|
|
52
53
|
<key>NSSupportsAutomaticGraphicsSwitching</key><true/>
|
|
54
|
+
<key>CFBundleDocumentTypes</key>
|
|
55
|
+
<array><dict>
|
|
56
|
+
<key>CFBundleTypeName</key><string>Text Document</string>
|
|
57
|
+
<key>CFBundleTypeRole</key><string>Editor</string>
|
|
58
|
+
<key>CFBundleTypeExtensions</key>
|
|
59
|
+
<array><string>txt</string><string>md</string><string>markdown</string><string>rb</string><string>json</string><string>xml</string></array>
|
|
60
|
+
<key>LSItemContentTypes</key>
|
|
61
|
+
<array><string>public.plain-text</string><string>net.daringfireball.markdown</string><string>public.json</string><string>public.xml</string></array>
|
|
62
|
+
</dict></array>
|
|
53
63
|
</dict></plist>
|
|
54
64
|
PLIST
|
|
55
65
|
when "linux"
|
|
56
66
|
executable = File.join(output, "bin", "canopus")
|
|
57
|
-
shell_launcher(executable, ruby, "../share/canopus/exe/canopus")
|
|
67
|
+
shell_launcher(executable, ruby, "../share/canopus/exe/canopus", bundled_ruby && "../runtime/#{bundled_ruby}")
|
|
58
68
|
desktop = File.join(output, "share", "applications", "canopus.desktop")
|
|
59
69
|
FileUtils.mkdir_p(File.dirname(desktop))
|
|
60
70
|
quoted = executable.gsub(/[\\"`$%]/) { |character| character == "%" ? "%%" : character == "\\" ? "\\" * 4 : "\\" * 2 + character }
|
|
@@ -66,12 +76,14 @@ module CanopusPackage
|
|
|
66
76
|
Exec="#{quoted}" %F
|
|
67
77
|
Terminal=false
|
|
68
78
|
Categories=Development;TextEditor;
|
|
69
|
-
MimeType=text/plain;
|
|
79
|
+
MimeType=text/plain;text/markdown;application/json;application/xml;text/x-ruby;
|
|
70
80
|
StartupNotify=true
|
|
71
81
|
DESKTOP
|
|
82
|
+
write_linux_installers(output)
|
|
72
83
|
when "windows"
|
|
73
84
|
executable = File.join(output, "canopus.cmd")
|
|
74
|
-
|
|
85
|
+
command = bundled_ruby ? "%~dp0runtime\\#{bundled_ruby.tr('/', '\\')}" : ruby.gsub('%', '%%')
|
|
86
|
+
File.write(executable, "@echo off\r\nsetlocal DisableDelayedExpansion\r\n\"#{command}\" \"%~dp0share\\canopus\\exe\\canopus\" %*\r\nexit /b %errorlevel%\r\n")
|
|
75
87
|
File.write(File.join(output, "Create-Shortcut.ps1"), <<~POWERSHELL.gsub("\n", "\r\n"))
|
|
76
88
|
param([string]$Destination = [Environment]::GetFolderPath('Desktop'))
|
|
77
89
|
$ErrorActionPreference = 'Stop'
|
|
@@ -82,30 +94,176 @@ module CanopusPackage
|
|
|
82
94
|
$shortcut.Description = 'Canopus text editor (requires CRuby 3.1+)'
|
|
83
95
|
$shortcut.Save()
|
|
84
96
|
POWERSHELL
|
|
97
|
+
write_windows_installers(output, version)
|
|
85
98
|
end
|
|
86
|
-
|
|
99
|
+
metadata = {name: "canopus", version: version, platform: platform, ruby: bundled_ruby ? "runtime/#{bundled_ruby}" : ruby, bundled_ruby: !bundled_ruby.nil?}
|
|
100
|
+
File.write(File.join(output, "package.json"), JSON.pretty_generate(metadata) + "\n")
|
|
101
|
+
write_manifest(output, version)
|
|
87
102
|
executable
|
|
88
103
|
end
|
|
89
104
|
|
|
90
|
-
def
|
|
105
|
+
def bundle_ruby(root, output)
|
|
106
|
+
root = File.realpath(root)
|
|
107
|
+
raise ArgumentError, "ruby root must be a directory" unless File.directory?(root)
|
|
108
|
+
raise ArgumentError, "package output cannot be inside ruby root" if output.start_with?(root + File::SEPARATOR)
|
|
109
|
+
files = Dir.glob(File.join(root, "**", "*"), File::FNM_DOTMATCH).reject { |path| [".", ".."].include?(File.basename(path)) }
|
|
110
|
+
raise ArgumentError, "ruby root contains symlinks" if files.any? { |path| File.symlink?(path) }
|
|
111
|
+
relative = if File.file?(File.join(root, "bin", "ruby"))
|
|
112
|
+
"bin/ruby"
|
|
113
|
+
elsif File.file?(File.join(root, "bin", "ruby.exe"))
|
|
114
|
+
"bin/ruby.exe"
|
|
115
|
+
else
|
|
116
|
+
raise ArgumentError, "ruby root must contain bin/ruby or bin/ruby.exe"
|
|
117
|
+
end
|
|
118
|
+
destination = File.join(output, "runtime")
|
|
119
|
+
FileUtils.mkdir_p(destination)
|
|
120
|
+
Dir.children(root).each { |entry| FileUtils.cp_r(File.join(root, entry), destination, preserve: true) }
|
|
121
|
+
relative
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def write_manifest(output, version)
|
|
125
|
+
files = Dir.glob(File.join(output, "**", "*"), File::FNM_DOTMATCH).filter_map do |path|
|
|
126
|
+
next unless File.file?(path) && !File.symlink?(path)
|
|
127
|
+
|
|
128
|
+
[path.delete_prefix(output + File::SEPARATOR), Digest::SHA256.file(path).hexdigest]
|
|
129
|
+
end.to_h
|
|
130
|
+
File.write(File.join(output, "manifest.json"), JSON.pretty_generate(version: version, algorithm: "SHA-256", files: files) + "\n")
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def shell_launcher(path, ruby, relative, bundled = nil)
|
|
91
134
|
FileUtils.mkdir_p(File.dirname(path))
|
|
135
|
+
command = bundled ? '"$here/' + bundled + '"' : Shellwords.escape(ruby)
|
|
92
136
|
File.write(path, <<~SH)
|
|
93
137
|
#!/bin/sh
|
|
94
138
|
set -eu
|
|
95
139
|
here=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
|
|
96
|
-
exec #{
|
|
140
|
+
exec #{command} --yjit "$here/#{relative}" "$@"
|
|
97
141
|
SH
|
|
98
142
|
File.chmod(0o755, path)
|
|
99
143
|
end
|
|
144
|
+
|
|
145
|
+
def write_linux_installers(output)
|
|
146
|
+
File.write(File.join(output, "Install.sh"), <<~SH)
|
|
147
|
+
#!/bin/sh
|
|
148
|
+
set -eu
|
|
149
|
+
package=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
|
|
150
|
+
root=${XDG_DATA_HOME:-"$HOME/.local/share"}/canopus
|
|
151
|
+
applications=${XDG_DATA_HOME:-"$HOME/.local/share"}/applications
|
|
152
|
+
desktop="$applications/canopus.desktop"
|
|
153
|
+
marker="$root/.canopus-installed"
|
|
154
|
+
if [ -e "$root" ]; then
|
|
155
|
+
printf '%s\n' "Canopus is already installed in $root; run Uninstall.sh first" >&2
|
|
156
|
+
exit 1
|
|
157
|
+
fi
|
|
158
|
+
if [ -e "$desktop" ] && ! grep -q '^# canopus-managed$' "$desktop"; then
|
|
159
|
+
printf '%s\n' "Refusing to replace existing desktop entry: $desktop" >&2
|
|
160
|
+
exit 1
|
|
161
|
+
fi
|
|
162
|
+
mkdir -p "$root" "$applications"
|
|
163
|
+
cp -R "$package/." "$root/"
|
|
164
|
+
printf '%s\n' canopus-installed > "$marker"
|
|
165
|
+
cat > "$desktop" <<DESKTOP
|
|
166
|
+
# canopus-managed
|
|
167
|
+
[Desktop Entry]
|
|
168
|
+
Type=Application
|
|
169
|
+
Name=Canopus
|
|
170
|
+
Comment=Pure Ruby text editor
|
|
171
|
+
Exec="$root/bin/canopus" %F
|
|
172
|
+
Terminal=false
|
|
173
|
+
Categories=Development;TextEditor;
|
|
174
|
+
MimeType=text/plain;text/markdown;application/json;application/xml;text/x-ruby;
|
|
175
|
+
StartupNotify=true
|
|
176
|
+
DESKTOP
|
|
177
|
+
command -v update-desktop-database >/dev/null 2>&1 && update-desktop-database "$applications" || true
|
|
178
|
+
printf '%s\n' "Installed Canopus in $root"
|
|
179
|
+
SH
|
|
180
|
+
File.chmod(0o755, File.join(output, "Install.sh"))
|
|
181
|
+
File.write(File.join(output, "Uninstall.sh"), <<~SH)
|
|
182
|
+
#!/bin/sh
|
|
183
|
+
set -eu
|
|
184
|
+
root=${XDG_DATA_HOME:-"$HOME/.local/share"}/canopus
|
|
185
|
+
applications=${XDG_DATA_HOME:-"$HOME/.local/share"}/applications
|
|
186
|
+
marker="$root/.canopus-installed"
|
|
187
|
+
[ -f "$marker" ] && grep -qx canopus-installed "$marker" || {
|
|
188
|
+
printf '%s\n' "Canopus install marker is missing: $root" >&2
|
|
189
|
+
exit 1
|
|
190
|
+
}
|
|
191
|
+
rm -rf -- "$root"
|
|
192
|
+
desktop="$applications/canopus.desktop"
|
|
193
|
+
if [ -f "$desktop" ] && grep -q '^# canopus-managed$' "$desktop"; then
|
|
194
|
+
rm -f -- "$desktop"
|
|
195
|
+
fi
|
|
196
|
+
command -v update-desktop-database >/dev/null 2>&1 && update-desktop-database "$applications" || true
|
|
197
|
+
printf '%s\n' "Uninstalled Canopus"
|
|
198
|
+
SH
|
|
199
|
+
File.chmod(0o755, File.join(output, "Uninstall.sh"))
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def write_windows_installers(output, version)
|
|
203
|
+
installers = {
|
|
204
|
+
"Install.ps1" => <<~POWERSHELL,
|
|
205
|
+
param([string]$Destination = (Join-Path $env:LOCALAPPDATA 'Canopus'))
|
|
206
|
+
$ErrorActionPreference = 'Stop'
|
|
207
|
+
$package = Split-Path -Parent $MyInvocation.MyCommand.Path
|
|
208
|
+
if (Test-Path -LiteralPath $Destination) { throw "Destination already exists: $Destination" }
|
|
209
|
+
$progId = 'Canopus.Document'
|
|
210
|
+
$class = "Registry::HKEY_CURRENT_USER\\Software\\Classes\\$progId"
|
|
211
|
+
if (Test-Path -LiteralPath $class) { throw "ProgID already exists; uninstall first: $progId" }
|
|
212
|
+
New-Item -ItemType Directory -Force -Path $Destination | Out-Null
|
|
213
|
+
Copy-Item -Path (Join-Path $package '*') -Destination $Destination -Recurse -Force
|
|
214
|
+
New-Item -Force -Path "$class\\shell\\open\\command" | Out-Null
|
|
215
|
+
Set-ItemProperty -Path $class -Name '(Default)' -Value 'Canopus document'
|
|
216
|
+
Set-ItemProperty -Path "$class\\shell\\open\\command" -Name '(Default)' -Value ('"' + $Destination + '\\canopus.cmd" "%1"')
|
|
217
|
+
Set-ItemProperty -Path $class -Name 'FriendlyTypeName' -Value 'Canopus document'
|
|
218
|
+
Set-ItemProperty -Path $class -Name CanopusManaged -Value 1
|
|
219
|
+
foreach ($extension in @('.txt', '.md', '.markdown', '.rb', '.json', '.xml')) {
|
|
220
|
+
$openWith = "Registry::HKEY_CURRENT_USER\\Software\\Classes\\$extension\\OpenWithProgids"
|
|
221
|
+
New-Item -Force -Path $openWith | Out-Null
|
|
222
|
+
New-ItemProperty -Force -Path $openWith -Name $progId -Value '' | Out-Null
|
|
223
|
+
}
|
|
224
|
+
$uninstall = 'Registry::HKEY_CURRENT_USER\\Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\Canopus'
|
|
225
|
+
New-Item -Force -Path $uninstall | Out-Null
|
|
226
|
+
Set-ItemProperty -Path $uninstall -Name DisplayName -Value 'Canopus'
|
|
227
|
+
Set-ItemProperty -Path $uninstall -Name DisplayVersion -Value '#{version}'
|
|
228
|
+
Set-ItemProperty -Path $uninstall -Name InstallLocation -Value $Destination
|
|
229
|
+
Set-ItemProperty -Path $uninstall -Name UninstallString -Value ('powershell.exe -ExecutionPolicy Bypass -File "' + $Destination + '\\Uninstall.ps1"')
|
|
230
|
+
Write-Output "Installed Canopus in $Destination"
|
|
231
|
+
POWERSHELL
|
|
232
|
+
"Uninstall.ps1" => <<~POWERSHELL
|
|
233
|
+
param([string]$Destination = $PSScriptRoot)
|
|
234
|
+
$ErrorActionPreference = 'Stop'
|
|
235
|
+
$progId = 'Canopus.Document'
|
|
236
|
+
$uninstall = 'Registry::HKEY_CURRENT_USER\\Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\Canopus'
|
|
237
|
+
$record = Get-ItemProperty -Path $uninstall -ErrorAction SilentlyContinue
|
|
238
|
+
if (!$record -or $record.InstallLocation -ne $Destination) { throw "Canopus install record does not match: $Destination" }
|
|
239
|
+
$class = "Registry::HKEY_CURRENT_USER\\Software\\Classes\\$progId"
|
|
240
|
+
$owner = Get-ItemProperty -Path $class -Name CanopusManaged -ErrorAction SilentlyContinue
|
|
241
|
+
if ($owner.CanopusManaged -ne 1) { throw "Canopus ProgID is not managed by this installer" }
|
|
242
|
+
foreach ($extension in @('.txt', '.md', '.markdown', '.rb', '.json', '.xml')) {
|
|
243
|
+
$openWith = "Registry::HKEY_CURRENT_USER\\Software\\Classes\\$extension\\OpenWithProgids"
|
|
244
|
+
Remove-ItemProperty -Path $openWith -Name $progId -ErrorAction SilentlyContinue
|
|
245
|
+
}
|
|
246
|
+
Remove-Item -LiteralPath $class -Recurse -Force -ErrorAction SilentlyContinue
|
|
247
|
+
Remove-Item -LiteralPath $uninstall -Recurse -Force -ErrorAction SilentlyContinue
|
|
248
|
+
if (Test-Path -LiteralPath $Destination) { Remove-Item -LiteralPath $Destination -Recurse -Force }
|
|
249
|
+
Write-Output 'Uninstalled Canopus'
|
|
250
|
+
POWERSHELL
|
|
251
|
+
}
|
|
252
|
+
installers.each do |name, contents|
|
|
253
|
+
path = File.join(output, name)
|
|
254
|
+
File.write(path, contents.gsub("\n", "\r\n"))
|
|
255
|
+
end
|
|
256
|
+
end
|
|
100
257
|
end
|
|
101
258
|
|
|
102
259
|
if $PROGRAM_NAME == __FILE__
|
|
103
260
|
options = {}
|
|
104
261
|
parser = OptionParser.new do |flags|
|
|
105
|
-
flags.banner = "Usage: ruby tools/package.rb --platform mac|linux|windows --output PATH [--ruby PATH]"
|
|
262
|
+
flags.banner = "Usage: ruby tools/package.rb --platform mac|linux|windows --output PATH [--ruby PATH|--ruby-root DIRECTORY]"
|
|
106
263
|
flags.on("--platform NAME") { |value| options[:platform] = value }
|
|
107
264
|
flags.on("--output PATH") { |value| options[:output] = value }
|
|
108
|
-
flags.on("--ruby PATH", "System Ruby executable
|
|
265
|
+
flags.on("--ruby PATH", "System Ruby executable when no runtime is bundled") { |value| options[:ruby] = value }
|
|
266
|
+
flags.on("--ruby-root DIRECTORY", "Relocatable Ruby root containing bin/ruby") { |value| options[:ruby_root] = value }
|
|
109
267
|
end
|
|
110
268
|
parser.parse!
|
|
111
269
|
abort parser.to_s unless options[:platform] && options[:output]
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
require "openssl"
|
|
6
|
+
require "tmpdir"
|
|
7
|
+
require_relative "sign_package"
|
|
8
|
+
|
|
9
|
+
Dir.mktmpdir("canopus-signature-") do |directory|
|
|
10
|
+
package = File.join(directory, "package")
|
|
11
|
+
FileUtils.mkdir_p(package)
|
|
12
|
+
File.write(File.join(package, "canopus.txt"), "package\n")
|
|
13
|
+
manifest = {"version" => "0.0.0", "algorithm" => "SHA-256", "files" => {"canopus.txt" => Digest::SHA256.file(File.join(package, "canopus.txt")).hexdigest}}
|
|
14
|
+
File.write(File.join(package, "manifest.json"), JSON.pretty_generate(manifest) + "\n")
|
|
15
|
+
|
|
16
|
+
key = OpenSSL::PKey::RSA.new(2048)
|
|
17
|
+
private_key = File.join(directory, "private.pem")
|
|
18
|
+
public_key = File.join(directory, "public.pem")
|
|
19
|
+
File.open(private_key, "w", 0o600) { |file| file.write(key.to_pem) }
|
|
20
|
+
File.write(public_key, key.public_key.to_pem)
|
|
21
|
+
|
|
22
|
+
CanopusPackageSignature.sign(package, private_key)
|
|
23
|
+
raise "signature did not verify" unless CanopusPackageSignature.verify(package, public_key)
|
|
24
|
+
|
|
25
|
+
File.open(File.join(package, "canopus.txt"), "a") { |file| file << "tampered\n" }
|
|
26
|
+
begin
|
|
27
|
+
CanopusPackageSignature.verify(package, public_key)
|
|
28
|
+
raise "tampered package was accepted"
|
|
29
|
+
rescue CanopusPackageSignature::Error => error
|
|
30
|
+
raise unless error.message.include?("manifest hash mismatch")
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
puts "Package signing and verification passed."
|
data/tools/package_test.rb
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require "tmpdir"
|
|
4
4
|
require "open3"
|
|
5
5
|
require_relative "package"
|
|
6
|
+
require "fileutils"
|
|
6
7
|
|
|
7
8
|
Dir.mktmpdir("canopus-package-") do |directory|
|
|
8
9
|
%w[mac linux windows].each do |platform|
|
|
@@ -10,6 +11,24 @@ Dir.mktmpdir("canopus-package-") do |directory|
|
|
|
10
11
|
launcher = CanopusPackage.build(platform: platform, output: output)
|
|
11
12
|
metadata = JSON.parse(File.read(File.join(output, "package.json")))
|
|
12
13
|
raise "runtime must not be bundled" unless metadata["bundled_ruby"] == false
|
|
14
|
+
manifest = JSON.parse(File.read(File.join(output, "manifest.json")))
|
|
15
|
+
raise "manifest algorithm" unless manifest["algorithm"] == "SHA-256"
|
|
16
|
+
raise "manifest missing package metadata" unless manifest["files"].key?("package.json")
|
|
17
|
+
case platform
|
|
18
|
+
when "mac"
|
|
19
|
+
raise "missing mac document types" unless File.read(File.join(output, "Contents", "Info.plist")).include?("CFBundleDocumentTypes")
|
|
20
|
+
when "linux"
|
|
21
|
+
raise "missing Linux installer" unless File.executable?(File.join(output, "Install.sh")) && File.executable?(File.join(output, "Uninstall.sh"))
|
|
22
|
+
raise "missing Linux MIME associations" unless File.read(File.join(output, "share", "applications", "canopus.desktop")).include?("text/markdown")
|
|
23
|
+
raise "Linux installer missing ownership marker" unless File.read(File.join(output, "Install.sh")).include?("canopus-installed")
|
|
24
|
+
raise "Linux installer missing manifest entry" unless manifest["files"].key?("Install.sh")
|
|
25
|
+
when "windows"
|
|
26
|
+
raise "missing Windows installer" unless File.file?(File.join(output, "Install.ps1")) && File.file?(File.join(output, "Uninstall.ps1"))
|
|
27
|
+
installer = File.read(File.join(output, "Install.ps1"))
|
|
28
|
+
raise "missing Windows ProgID" unless installer.include?("Canopus.Document") && installer.include?("Copy-Item -Path")
|
|
29
|
+
raise "Windows installer missing ownership marker" unless installer.include?("CanopusManaged")
|
|
30
|
+
raise "Windows installer missing manifest entry" unless manifest["files"].key?("Install.ps1")
|
|
31
|
+
end
|
|
13
32
|
if platform == "windows"
|
|
14
33
|
raise "missing shortcut installer" unless File.file?(File.join(output, "Create-Shortcut.ps1"))
|
|
15
34
|
raise "unsafe launcher argument forwarding" unless File.read(launcher).include?("%*")
|
|
@@ -29,4 +48,14 @@ Dir.mktmpdir("canopus-package-") do |directory|
|
|
|
29
48
|
end
|
|
30
49
|
end
|
|
31
50
|
end
|
|
51
|
+
Dir.mktmpdir("canopus-ruby-root-") do |directory|
|
|
52
|
+
root = File.join(directory, "ruby")
|
|
53
|
+
FileUtils.mkdir_p(File.join(root, "bin"))
|
|
54
|
+
FileUtils.cp(RbConfig.ruby, File.join(root, "bin", "ruby"))
|
|
55
|
+
output = File.join(directory, "bundled")
|
|
56
|
+
CanopusPackage.build(platform: "linux", output: output, ruby_root: root)
|
|
57
|
+
metadata = JSON.parse(File.read(File.join(output, "package.json")))
|
|
58
|
+
raise "bundled runtime metadata" unless metadata["bundled_ruby"] && metadata["ruby"] == "runtime/bin/ruby"
|
|
59
|
+
raise "bundled launcher path" unless File.read(File.join(output, "bin", "canopus")).include?("../runtime/bin/ruby")
|
|
60
|
+
end
|
|
32
61
|
puts "Package layouts, launchers, and headless rendering passed (Windows runtime not executed)."
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "base64"
|
|
4
|
+
require "digest"
|
|
5
|
+
require "json"
|
|
6
|
+
require "openssl"
|
|
7
|
+
require "optparse"
|
|
8
|
+
require "tempfile"
|
|
9
|
+
|
|
10
|
+
module CanopusPackageSignature
|
|
11
|
+
ALGORITHM = "RSA-SHA256"
|
|
12
|
+
SIGNATURE_FILE = "signature.json"
|
|
13
|
+
MANIFEST_FILE = "manifest.json"
|
|
14
|
+
|
|
15
|
+
class Error < StandardError; end
|
|
16
|
+
|
|
17
|
+
module_function
|
|
18
|
+
|
|
19
|
+
def sign(package, private_key_path)
|
|
20
|
+
package = package_directory(package)
|
|
21
|
+
key = OpenSSL::PKey.read(File.binread(private_key_path))
|
|
22
|
+
raise Error, "an RSA private key is required" unless key.is_a?(OpenSSL::PKey::RSA) && key.private?
|
|
23
|
+
|
|
24
|
+
payload = File.binread(File.join(package, MANIFEST_FILE))
|
|
25
|
+
signature = {
|
|
26
|
+
"version" => 1,
|
|
27
|
+
"algorithm" => ALGORITHM,
|
|
28
|
+
"manifest" => MANIFEST_FILE,
|
|
29
|
+
"key_id" => Digest::SHA256.hexdigest(key.public_key.to_der),
|
|
30
|
+
"signature" => Base64.strict_encode64(key.sign(OpenSSL::Digest::SHA256.new, payload))
|
|
31
|
+
}
|
|
32
|
+
atomic_write(File.join(package, SIGNATURE_FILE), JSON.pretty_generate(signature) + "\n")
|
|
33
|
+
signature
|
|
34
|
+
rescue Errno::ENOENT => error
|
|
35
|
+
raise Error, error.message
|
|
36
|
+
rescue ArgumentError, OpenSSL::OpenSSLError, JSON::ParserError => error
|
|
37
|
+
raise Error, "cannot sign package: #{error.message}"
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def verify(package, public_key_path)
|
|
41
|
+
package = package_directory(package)
|
|
42
|
+
metadata = JSON.parse(File.read(File.join(package, SIGNATURE_FILE)))
|
|
43
|
+
raise Error, "unsupported package signature" unless metadata["version"] == 1 && metadata["algorithm"] == ALGORITHM && metadata["manifest"] == MANIFEST_FILE
|
|
44
|
+
|
|
45
|
+
key = OpenSSL::PKey.read(File.binread(public_key_path)).public_key
|
|
46
|
+
raise Error, "an RSA public key is required" unless key.is_a?(OpenSSL::PKey::RSA)
|
|
47
|
+
raise Error, "package signature key does not match" unless metadata["key_id"] == Digest::SHA256.hexdigest(key.to_der)
|
|
48
|
+
|
|
49
|
+
payload = File.binread(File.join(package, MANIFEST_FILE))
|
|
50
|
+
signature = Base64.strict_decode64(metadata.fetch("signature"))
|
|
51
|
+
valid = key.verify(OpenSSL::Digest::SHA256.new, signature, payload)
|
|
52
|
+
raise Error, "package signature is invalid" unless valid
|
|
53
|
+
|
|
54
|
+
verify_manifest(package, JSON.parse(payload))
|
|
55
|
+
true
|
|
56
|
+
rescue Errno::ENOENT, JSON::ParserError, ArgumentError => error
|
|
57
|
+
raise Error, "cannot verify package: #{error.message}"
|
|
58
|
+
rescue OpenSSL::OpenSSLError => error
|
|
59
|
+
raise Error, "cannot verify package: #{error.message}"
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def package_directory(package)
|
|
63
|
+
package = File.realpath(package)
|
|
64
|
+
raise Error, "package must be a directory" unless File.directory?(package)
|
|
65
|
+
|
|
66
|
+
package
|
|
67
|
+
rescue Errno::ENOENT => error
|
|
68
|
+
raise Error, error.message
|
|
69
|
+
end
|
|
70
|
+
private_class_method :package_directory
|
|
71
|
+
|
|
72
|
+
def verify_manifest(package, manifest)
|
|
73
|
+
raise Error, "invalid package manifest" unless manifest["algorithm"] == "SHA-256" && manifest["files"].is_a?(Hash)
|
|
74
|
+
|
|
75
|
+
manifest["files"].each do |relative, digest|
|
|
76
|
+
raise Error, "invalid manifest entry" unless relative.is_a?(String) && digest.is_a?(String) && digest.match?(/\A[0-9a-f]{64}\z/)
|
|
77
|
+
path = File.expand_path(relative, package)
|
|
78
|
+
raise Error, "manifest path escapes package" unless path.start_with?(package + File::SEPARATOR) && !File.symlink?(path)
|
|
79
|
+
raise Error, "manifest file is missing: #{relative}" unless File.file?(path)
|
|
80
|
+
raise Error, "manifest hash mismatch: #{relative}" unless Digest::SHA256.file(path).hexdigest == digest
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
private_class_method :verify_manifest
|
|
84
|
+
|
|
85
|
+
def atomic_write(path, content)
|
|
86
|
+
Tempfile.create([".signature-", ".json"], File.dirname(path)) do |file|
|
|
87
|
+
file.write(content)
|
|
88
|
+
file.flush
|
|
89
|
+
file.fsync
|
|
90
|
+
file.close
|
|
91
|
+
File.rename(file.path, path)
|
|
92
|
+
end
|
|
93
|
+
rescue SystemCallError => error
|
|
94
|
+
raise Error, "cannot write package signature: #{error.message}"
|
|
95
|
+
end
|
|
96
|
+
private_class_method :atomic_write
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
if $PROGRAM_NAME == __FILE__
|
|
100
|
+
options = {}
|
|
101
|
+
parser = OptionParser.new do |flags|
|
|
102
|
+
flags.banner = "Usage: ruby tools/sign_package.rb --package PATH --private-key PATH | --verify --public-key PATH"
|
|
103
|
+
flags.on("--package PATH", "Package directory") { |value| options[:package] = value }
|
|
104
|
+
flags.on("--private-key PATH", "RSA private key used for signing") { |value| options[:private_key] = value }
|
|
105
|
+
flags.on("--public-key PATH", "RSA public key used for verification") { |value| options[:public_key] = value }
|
|
106
|
+
flags.on("--verify", "Verify the package signature and manifest") { options[:verify] = true }
|
|
107
|
+
end
|
|
108
|
+
parser.parse!
|
|
109
|
+
abort parser.to_s unless options[:package] && (options[:private_key] || (options[:verify] && options[:public_key]))
|
|
110
|
+
|
|
111
|
+
begin
|
|
112
|
+
if options[:verify]
|
|
113
|
+
CanopusPackageSignature.verify(options[:package], options[:public_key])
|
|
114
|
+
puts "Package signature and manifest verified."
|
|
115
|
+
else
|
|
116
|
+
CanopusPackageSignature.sign(options[:package], options[:private_key])
|
|
117
|
+
puts "Package signature written to #{File.join(options[:package], CanopusPackageSignature::SIGNATURE_FILE)}."
|
|
118
|
+
end
|
|
119
|
+
rescue CanopusPackageSignature::Error, OpenSSL::OpenSSLError, Errno::ENOENT => error
|
|
120
|
+
warn "package signature: #{error.message}"
|
|
121
|
+
exit 1
|
|
122
|
+
end
|
|
123
|
+
end
|
data/tools/update.rb
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "base64"
|
|
4
|
+
require "digest"
|
|
5
|
+
require "fileutils"
|
|
6
|
+
require "json"
|
|
7
|
+
require "net/http"
|
|
8
|
+
require "openssl"
|
|
9
|
+
require "optparse"
|
|
10
|
+
require "open-uri"
|
|
11
|
+
require "rubygems/package"
|
|
12
|
+
require "rubygems/version"
|
|
13
|
+
require "securerandom"
|
|
14
|
+
require "tmpdir"
|
|
15
|
+
require "uri"
|
|
16
|
+
require "zlib"
|
|
17
|
+
|
|
18
|
+
module CanopusUpdater
|
|
19
|
+
FIELDS = %w[channel platform version url size sha256].freeze
|
|
20
|
+
ALGORITHM = "RSA-SHA256"
|
|
21
|
+
MAX_DOWNLOAD = 512 * 1024 * 1024
|
|
22
|
+
MAX_EXTRACTED = 1 * 1024 * 1024 * 1024
|
|
23
|
+
|
|
24
|
+
class Error < StandardError; end
|
|
25
|
+
|
|
26
|
+
module_function
|
|
27
|
+
|
|
28
|
+
def signed_payload(feed)
|
|
29
|
+
JSON.generate(FIELDS.to_h { |field| [field, feed.fetch(field)] })
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def check(feed, public_key:, channel:, platform:, current_version:)
|
|
33
|
+
feed = feed.is_a?(String) ? JSON.parse(feed) : feed
|
|
34
|
+
raise Error, "update feed must be an object" unless feed.is_a?(Hash)
|
|
35
|
+
raise Error, "unsupported update channel" unless feed["channel"] == channel
|
|
36
|
+
raise Error, "unsupported update platform" unless feed["platform"] == platform
|
|
37
|
+
validate_feed!(feed)
|
|
38
|
+
verify_signature!(feed, public_key)
|
|
39
|
+
raise Error, "update version is invalid" unless Gem::Version.new(feed["version"]) > Gem::Version.new(current_version)
|
|
40
|
+
|
|
41
|
+
feed
|
|
42
|
+
rescue JSON::ParserError, ArgumentError => error
|
|
43
|
+
raise Error, "invalid update feed: #{error.message}"
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def apply(feed:, public_key:, channel:, platform:, current_version:, install_dir:)
|
|
47
|
+
release = check(feed, public_key: public_key, channel: channel, platform: platform, current_version: current_version)
|
|
48
|
+
install_dir = File.realpath(install_dir)
|
|
49
|
+
raise Error, "install directory must be a directory" unless File.directory?(install_dir)
|
|
50
|
+
|
|
51
|
+
Dir.mktmpdir(".canopus-update-", File.dirname(install_dir)) do |directory|
|
|
52
|
+
archive = File.join(directory, "package.tar.gz")
|
|
53
|
+
download(release.fetch("url"), archive, expected_size: release.fetch("size"))
|
|
54
|
+
raise Error, "update archive hash mismatch" unless Digest::SHA256.file(archive).hexdigest == release.fetch("sha256")
|
|
55
|
+
staged = File.join(directory, "package")
|
|
56
|
+
extract(archive, staged)
|
|
57
|
+
replace(install_dir, staged)
|
|
58
|
+
end
|
|
59
|
+
release
|
|
60
|
+
rescue Errno::ENOENT, Errno::EACCES, SystemCallError => error
|
|
61
|
+
raise Error, "update failed: #{error.message}"
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def validate_feed!(feed)
|
|
65
|
+
raise Error, "update feed is missing fields" unless FIELDS.all? { |field| feed.key?(field) }
|
|
66
|
+
uri = feed["url"].is_a?(String) ? URI(feed.fetch("url")) : nil
|
|
67
|
+
raise Error, "update URL must use HTTPS" unless uri&.scheme == "https" && uri.host && !uri.user
|
|
68
|
+
raise Error, "invalid update size" unless feed["size"].is_a?(Integer) && feed["size"].between?(1, MAX_DOWNLOAD)
|
|
69
|
+
raise Error, "invalid update hash" unless feed["sha256"].is_a?(String) && feed["sha256"].match?(/\A[0-9a-f]{64}\z/)
|
|
70
|
+
raise Error, "invalid update signature" unless feed["signature"].is_a?(String) && feed["key_id"].is_a?(String)
|
|
71
|
+
Gem::Version.new(feed.fetch("version"))
|
|
72
|
+
true
|
|
73
|
+
rescue URI::InvalidURIError => error
|
|
74
|
+
raise Error, "invalid update URL: #{error.message}"
|
|
75
|
+
end
|
|
76
|
+
private_class_method :validate_feed!
|
|
77
|
+
|
|
78
|
+
def verify_signature!(feed, public_key)
|
|
79
|
+
key = OpenSSL::PKey.read(File.binread(public_key)).public_key
|
|
80
|
+
raise Error, "an RSA public key is required" unless key.is_a?(OpenSSL::PKey::RSA)
|
|
81
|
+
raise Error, "update signing key does not match" unless feed["key_id"] == Digest::SHA256.hexdigest(key.to_der)
|
|
82
|
+
|
|
83
|
+
signature = Base64.strict_decode64(feed.fetch("signature"))
|
|
84
|
+
raise Error, "update signature is invalid" unless key.verify(OpenSSL::Digest::SHA256.new, signature, signed_payload(feed))
|
|
85
|
+
rescue OpenSSL::OpenSSLError, ArgumentError => error
|
|
86
|
+
raise Error, "invalid update signature: #{error.message}"
|
|
87
|
+
end
|
|
88
|
+
private_class_method :verify_signature!
|
|
89
|
+
|
|
90
|
+
def download(url, destination, expected_size:)
|
|
91
|
+
uri = URI(url)
|
|
92
|
+
raise Error, "update URL must use HTTPS" unless uri.scheme == "https"
|
|
93
|
+
request = Net::HTTP::Get.new(uri.request_uri)
|
|
94
|
+
bytes = 0
|
|
95
|
+
File.open(destination, "wb", 0o600) do |file|
|
|
96
|
+
Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) do |http|
|
|
97
|
+
response = http.request(request) do |body|
|
|
98
|
+
raise Error, "update download failed: #{body.code}" unless body.is_a?(Net::HTTPSuccess)
|
|
99
|
+
if body["content-length"] && body["content-length"].to_i != expected_size
|
|
100
|
+
raise Error, "update size does not match feed"
|
|
101
|
+
end
|
|
102
|
+
body.read_body do |chunk|
|
|
103
|
+
bytes += chunk.bytesize
|
|
104
|
+
raise Error, "update download is too large" if bytes > expected_size || bytes > MAX_DOWNLOAD
|
|
105
|
+
file.write(chunk)
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
raise Error, "update download failed: #{response.code}" unless response.is_a?(Net::HTTPSuccess)
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
raise Error, "update size does not match feed" unless bytes == expected_size
|
|
112
|
+
rescue URI::InvalidURIError, SocketError, IOError, OpenSSL::OpenSSLError, Net::OpenTimeout, Net::ReadTimeout => error
|
|
113
|
+
raise Error, "update download failed: #{error.message}"
|
|
114
|
+
end
|
|
115
|
+
private_class_method :download
|
|
116
|
+
|
|
117
|
+
def extract(archive, destination)
|
|
118
|
+
FileUtils.mkdir_p(destination)
|
|
119
|
+
seen, total = {}, 0
|
|
120
|
+
Zlib::GzipReader.open(archive) do |gzip|
|
|
121
|
+
Gem::Package::TarReader.new(gzip).each do |entry|
|
|
122
|
+
name = entry.full_name.to_s
|
|
123
|
+
raise Error, "unsafe update archive path" if name.empty? || name.include?("\0") || name.start_with?("/") || name.split("/").include?("..")
|
|
124
|
+
raise Error, "duplicate update archive path" if seen[name]
|
|
125
|
+
seen[name] = true
|
|
126
|
+
path = File.expand_path(name, destination)
|
|
127
|
+
raise Error, "unsafe update archive path" unless path == destination || path.start_with?(destination + File::SEPARATOR)
|
|
128
|
+
if entry.directory?
|
|
129
|
+
FileUtils.mkdir_p(path)
|
|
130
|
+
elsif entry.file?
|
|
131
|
+
total += entry.size
|
|
132
|
+
raise Error, "update archive is too large" if total > MAX_EXTRACTED
|
|
133
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
134
|
+
mode = entry.header.mode.to_i & 0o777
|
|
135
|
+
mode = 0o644 if mode.zero?
|
|
136
|
+
File.open(path, "wb", mode) { |file| IO.copy_stream(entry, file) }
|
|
137
|
+
else
|
|
138
|
+
raise Error, "unsupported update archive entry"
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
raise Error, "update archive is missing package metadata" unless File.file?(File.join(destination, "package.json")) && File.file?(File.join(destination, "manifest.json"))
|
|
143
|
+
destination
|
|
144
|
+
rescue Zlib::GzipFile::Error, Gem::Package::TarInvalidError, EOFError => error
|
|
145
|
+
raise Error, "invalid update archive: #{error.message}"
|
|
146
|
+
end
|
|
147
|
+
private_class_method :extract
|
|
148
|
+
|
|
149
|
+
def replace(install_dir, staged)
|
|
150
|
+
backup = "#{install_dir}.backup-#{Process.pid}-#{SecureRandom.hex(4)}"
|
|
151
|
+
File.rename(install_dir, backup)
|
|
152
|
+
begin
|
|
153
|
+
File.rename(staged, install_dir)
|
|
154
|
+
rescue SystemCallError => error
|
|
155
|
+
File.rename(backup, install_dir) rescue nil
|
|
156
|
+
raise Error, "cannot activate update: #{error.message}"
|
|
157
|
+
end
|
|
158
|
+
FileUtils.rm_rf(backup)
|
|
159
|
+
end
|
|
160
|
+
private_class_method :replace
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
if $PROGRAM_NAME == __FILE__
|
|
164
|
+
platform = RUBY_PLATFORM.match?(/darwin/) ? "mac" : RUBY_PLATFORM.match?(/mswin|mingw/) ? "windows" : "linux"
|
|
165
|
+
options = {channel: "stable", platform: platform}
|
|
166
|
+
parser = OptionParser.new do |flags|
|
|
167
|
+
flags.banner = "Usage: ruby tools/update.rb --feed URL --public-key PATH --install-dir PATH --current-version VERSION"
|
|
168
|
+
flags.on("--feed URL", "Signed update feed URL") { |value| options[:feed] = value }
|
|
169
|
+
flags.on("--public-key PATH", "RSA public key used to verify the feed") { |value| options[:public_key] = value }
|
|
170
|
+
flags.on("--install-dir PATH", "Existing package directory") { |value| options[:install_dir] = value }
|
|
171
|
+
flags.on("--current-version VERSION", "Installed package version") { |value| options[:current_version] = value }
|
|
172
|
+
flags.on("--channel NAME", "Update channel (default: stable)") { |value| options[:channel] = value }
|
|
173
|
+
flags.on("--platform NAME", "Package platform") { |value| options[:platform] = value }
|
|
174
|
+
end
|
|
175
|
+
parser.parse!
|
|
176
|
+
abort parser.to_s unless %i[feed public_key install_dir current_version].all? { |key| options[key] }
|
|
177
|
+
|
|
178
|
+
begin
|
|
179
|
+
feed_uri = URI(options.fetch(:feed))
|
|
180
|
+
raise CanopusUpdater::Error, "feed URL must use HTTPS" unless feed_uri.scheme == "https" && feed_uri.host && !feed_uri.user
|
|
181
|
+
feed = feed_uri.open(allow_redirections: false, &:read)
|
|
182
|
+
release = CanopusUpdater.apply(feed: feed, public_key: options.fetch(:public_key), channel: options.fetch(:channel),
|
|
183
|
+
platform: options.fetch(:platform), current_version: options.fetch(:current_version), install_dir: options.fetch(:install_dir))
|
|
184
|
+
puts "Updated Canopus to #{release.fetch("version")}."
|
|
185
|
+
rescue CanopusUpdater::Error, OpenSSL::OpenSSLError, Errno::ENOENT, URI::InvalidURIError => error
|
|
186
|
+
warn "update: #{error.message}"
|
|
187
|
+
exit 1
|
|
188
|
+
end
|
|
189
|
+
end
|