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.
Files changed (137) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +55 -0
  3. data/README.md +191 -19
  4. data/assets/keymaps/emacs.jsonc +5 -0
  5. data/assets/keymaps/jetbrains.jsonc +5 -0
  6. data/assets/keymaps/sublime.jsonc +5 -0
  7. data/assets/keymaps/vscode.jsonc +5 -0
  8. data/docs/debugging.md +143 -0
  9. data/docs/distribution.md +40 -9
  10. data/docs/lsp.md +131 -0
  11. data/docs/performance.md +18 -0
  12. data/docs/providers.md +36 -0
  13. data/docs/release-log.md +12 -0
  14. data/docs/tasks.md +64 -0
  15. data/docs/testing.md +49 -0
  16. data/docs/vim.md +3 -0
  17. data/exe/canopus +4 -1
  18. data/lib/canopus/buffer.rb +291 -50
  19. data/lib/canopus/cli.rb +5 -2
  20. data/lib/canopus/command.rb +13 -4
  21. data/lib/canopus/controller.rb +183 -40
  22. data/lib/canopus/debug/breakpoints.rb +550 -0
  23. data/lib/canopus/debug/configuration.rb +291 -0
  24. data/lib/canopus/debug/console.rb +243 -0
  25. data/lib/canopus/debug/panel.rb +487 -0
  26. data/lib/canopus/debug/session.rb +354 -0
  27. data/lib/canopus/decoration.rb +4 -2
  28. data/lib/canopus/denebola_compat.rb +27 -0
  29. data/lib/canopus/diagnostics.rb +173 -0
  30. data/lib/canopus/display_map/overlay_map.rb +6 -2
  31. data/lib/canopus/editor/snippet_expandable.rb +3 -2
  32. data/lib/canopus/editor.rb +122 -31
  33. data/lib/canopus/git/state.rb +100 -0
  34. data/lib/canopus/language/background_analysis.rb +93 -14
  35. data/lib/canopus/language/document.rb +70 -34
  36. data/lib/canopus/language/symbol.rb +1 -0
  37. data/lib/canopus/language/syntax_worker.rb +61 -9
  38. data/lib/canopus/language.rb +3 -0
  39. data/lib/canopus/minimap.rb +201 -0
  40. data/lib/canopus/patch.rb +3 -1
  41. data/lib/canopus/plugins/api.rb +4 -2
  42. data/lib/canopus/plugins/isolated_runtime.rb +64 -7
  43. data/lib/canopus/plugins/registry.rb +114 -3
  44. data/lib/canopus/plugins.rb +8 -7
  45. data/lib/canopus/project.rb +15 -6
  46. data/lib/canopus/provider.rb +192 -0
  47. data/lib/canopus/regexp_compat.rb +36 -4
  48. data/lib/canopus/selection.rb +12 -0
  49. data/lib/canopus/settings/schema.rb +201 -0
  50. data/lib/canopus/settings.rb +328 -49
  51. data/lib/canopus/task/configuration.rb +356 -0
  52. data/lib/canopus/task/problem_matcher.rb +264 -0
  53. data/lib/canopus/task/runner.rb +314 -0
  54. data/lib/canopus/test_runner/bounded_ignore.rb +144 -0
  55. data/lib/canopus/test_runner/discovery.rb +97 -0
  56. data/lib/canopus/test_runner/execution.rb +194 -0
  57. data/lib/canopus/test_runner/minitest.rb +116 -0
  58. data/lib/canopus/test_runner/rspec.rb +103 -0
  59. data/lib/canopus/test_runner/safe_walker.rb +37 -0
  60. data/lib/canopus/test_runner.rb +54 -0
  61. data/lib/canopus/theme/import.rb +131 -0
  62. data/lib/canopus/theme.rb +28 -4
  63. data/lib/canopus/version.rb +1 -1
  64. data/lib/canopus/vim/motionable.rb +1 -1
  65. data/lib/canopus/vim.rb +10 -16
  66. data/lib/canopus/workspace/auto_savable.rb +83 -0
  67. data/lib/canopus/workspace/debug_aware.rb +600 -0
  68. data/lib/canopus/workspace/edit/plan.rb +2 -2
  69. data/lib/canopus/workspace/editor_configurable.rb +113 -0
  70. data/lib/canopus/workspace/file_change_aware.rb +11 -3
  71. data/lib/canopus/workspace/git_aware.rb +80 -34
  72. data/lib/canopus/workspace/git_blame.rb +175 -0
  73. data/lib/canopus/workspace/git_conflict_resolution.rb +410 -0
  74. data/lib/canopus/workspace/git_diff_view.rb +641 -0
  75. data/lib/canopus/workspace/git_history.rb +171 -0
  76. data/lib/canopus/workspace/git_remote.rb +267 -0
  77. data/lib/canopus/workspace/git_staging.rb +583 -0
  78. data/lib/canopus/workspace/hierarchy_aware.rb +477 -0
  79. data/lib/canopus/workspace/keymap_aware.rb +66 -0
  80. data/lib/canopus/workspace/language_aware.rb +3721 -135
  81. data/lib/canopus/workspace/language_server_configurable.rb +287 -71
  82. data/lib/canopus/workspace/persistent_undo.rb +169 -0
  83. data/lib/canopus/workspace/problems_aware.rb +131 -0
  84. data/lib/canopus/workspace/project_searchable.rb +88 -11
  85. data/lib/canopus/workspace/project_tree_editable.rb +3 -1
  86. data/lib/canopus/workspace/recoverable.rb +204 -0
  87. data/lib/canopus/workspace/session_persistable.rb +85 -13
  88. data/lib/canopus/workspace/settings_aware.rb +76 -2
  89. data/lib/canopus/workspace/task_aware.rb +194 -0
  90. data/lib/canopus/workspace/test_aware.rb +394 -0
  91. data/lib/canopus/workspace/trust.rb +73 -0
  92. data/lib/canopus/workspace/view/terminal_presentable.rb +298 -47
  93. data/lib/canopus/workspace/view.rb +399 -74
  94. data/lib/canopus/workspace.rb +688 -44
  95. data/lib/canopus.rb +20 -2
  96. data/sig/canopus.rbs +138 -31
  97. data/sig/controller.rbs +10 -3
  98. data/sig/debug.rbs +121 -0
  99. data/sig/decoration.rbs +12 -2
  100. data/sig/diagnostics.rbs +26 -0
  101. data/sig/minimap.rbs +20 -0
  102. data/sig/provider.rbs +29 -0
  103. data/sig/services.rbs +1 -16
  104. data/sig/task.rbs +92 -0
  105. data/sig/test_runner.rbs +109 -0
  106. data/sig/trust.rbs +17 -0
  107. data/sig/workspace_edits.rbs +2 -2
  108. data/sig/workspace_services.rbs +234 -2
  109. data/tools/check_dependencies.rb +21 -2
  110. data/tools/package.rb +172 -14
  111. data/tools/package_signature_test.rb +33 -0
  112. data/tools/package_test.rb +29 -0
  113. data/tools/sign_package.rb +123 -0
  114. data/tools/update.rb +189 -0
  115. data/tools/update_test.rb +71 -0
  116. metadata +206 -31
  117. data/lib/canopus/lsp/client.rb +0 -307
  118. data/lib/canopus/lsp/error.rb +0 -7
  119. data/lib/canopus/lsp/future/subscription.rb +0 -9
  120. data/lib/canopus/lsp/future.rb +0 -87
  121. data/lib/canopus/lsp/protocol.rb +0 -83
  122. data/lib/canopus/lsp/server_error.rb +0 -13
  123. data/lib/canopus/lsp/timeout.rb +0 -7
  124. data/lib/canopus/lsp/transport.rb +0 -123
  125. data/lib/canopus/lsp.rb +0 -19
  126. data/lib/canopus/project/search.rb +0 -164
  127. data/lib/canopus/project/search_worker/cancelled.rb +0 -3
  128. data/lib/canopus/project/search_worker/runner.rb +0 -9
  129. data/lib/canopus/project/search_worker.rb +0 -108
  130. data/lib/canopus/terminal/cell.rb +0 -7
  131. data/lib/canopus/terminal/grid.rb +0 -321
  132. data/lib/canopus/terminal/pty.rb +0 -178
  133. data/lib/canopus/terminal/scrollback.rb +0 -38
  134. data/lib/canopus/terminal/vt.rb +0 -398
  135. data/lib/canopus/terminal.rb +0 -13
  136. data/sig/lsp.rbs +0 -99
  137. 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
- # Packages the source distribution; never downloads or bundles Ruby or gems.
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
- runtime = File.join(output, platform == "mac" ? "Contents/Resources/canopus" : "share/canopus")
34
- FileUtils.mkdir_p(runtime)
35
- entries.each { |entry| FileUtils.cp_r(entry, runtime, preserve: true) }
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
- File.write(executable, "@echo off\r\nsetlocal DisableDelayedExpansion\r\n\"#{ruby.gsub('%', '%%')}\" \"%~dp0share\\canopus\\exe\\canopus\" %*\r\nexit /b %errorlevel%\r\n")
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
- File.write(File.join(output, "package.json"), JSON.pretty_generate({name: "canopus", version: version, platform: platform, ruby: ruby, bundled_ruby: false}) + "\n")
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 shell_launcher(path, ruby, relative)
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 #{Shellwords.escape(ruby)} --yjit "$here/#{relative}" "$@"
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; no runtime is bundled") { |value| options[:ruby] = value }
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."
@@ -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