dialecto-rails 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +196 -0
  4. data/action.yml +83 -0
  5. data/exe/dialecto-rails +6 -0
  6. data/lib/dialecto-rails.rb +1 -0
  7. data/lib/dialecto_rails/config.rb +206 -0
  8. data/lib/dialecto_rails/errors.rb +21 -0
  9. data/lib/dialecto_rails/in_context/args.rb +37 -0
  10. data/lib/dialecto_rails/in_context/assets/overlay.js +2784 -0
  11. data/lib/dialecto_rails/in_context/backend_hook.rb +39 -0
  12. data/lib/dialecto_rails/in_context/csp.rb +72 -0
  13. data/lib/dialecto_rails/in_context/git_context.rb +103 -0
  14. data/lib/dialecto_rails/in_context/key_index.rb +128 -0
  15. data/lib/dialecto_rails/in_context/launch_token.rb +78 -0
  16. data/lib/dialecto_rails/in_context/live_middleware.rb +307 -0
  17. data/lib/dialecto_rails/in_context/live_runtime.rb +53 -0
  18. data/lib/dialecto_rails/in_context/live_store.rb +14 -0
  19. data/lib/dialecto_rails/in_context/marker.rb +106 -0
  20. data/lib/dialecto_rails/in_context/marking.rb +122 -0
  21. data/lib/dialecto_rails/in_context/middleware.rb +202 -0
  22. data/lib/dialecto_rails/in_context/overrides.rb +61 -0
  23. data/lib/dialecto_rails/in_context/railtie_stub.rb +76 -0
  24. data/lib/dialecto_rails/in_context/runtime.rb +52 -0
  25. data/lib/dialecto_rails/in_context/sanitizer.rb +19 -0
  26. data/lib/dialecto_rails/in_context/store.rb +41 -0
  27. data/lib/dialecto_rails/scan/catalogs.rb +46 -0
  28. data/lib/dialecto_rails/scan/cli.rb +339 -0
  29. data/lib/dialecto_rails/scan/client.rb +117 -0
  30. data/lib/dialecto_rails/scan/envelope.rb +100 -0
  31. data/lib/dialecto_rails/scan/git.rb +68 -0
  32. data/lib/dialecto_rails/scan/worktree.rb +41 -0
  33. data/lib/dialecto_rails/unmarked.rb +42 -0
  34. data/lib/dialecto_rails/usages/call_args.rb +78 -0
  35. data/lib/dialecto_rails/usages/catalog_keys.rb +67 -0
  36. data/lib/dialecto_rails/usages/check.rb +70 -0
  37. data/lib/dialecto_rails/usages/haml_extractor.rb +260 -0
  38. data/lib/dialecto_rails/usages/keys.rb +206 -0
  39. data/lib/dialecto_rails/usages/known_wrappers.rb +31 -0
  40. data/lib/dialecto_rails/usages/lazy_scope.rb +52 -0
  41. data/lib/dialecto_rails/usages/parse_check.rb +75 -0
  42. data/lib/dialecto_rails/usages/read_by_name.json +28 -0
  43. data/lib/dialecto_rails/usages/record.rb +91 -0
  44. data/lib/dialecto_rails/usages/ruby_visitor.rb +628 -0
  45. data/lib/dialecto_rails/usages/scanner.rb +241 -0
  46. data/lib/dialecto_rails/usages/summary.rb +191 -0
  47. data/lib/dialecto_rails/usages/templates.rb +45 -0
  48. data/lib/dialecto_rails/usages/wrappers.rb +336 -0
  49. data/lib/dialecto_rails/version.rb +3 -0
  50. data/lib/dialecto_rails.rb +14 -0
  51. metadata +119 -0
@@ -0,0 +1,39 @@
1
+ module DialectoRails
2
+ module InContext
3
+ # Prepended on the class of the I18n backend (spec-0023 §7.4), outside `Pluralization` and `Fallbacks`, so
4
+ # it sees the final string whichever of them ran. Never raises into the app: any failure returns the
5
+ # unmarked result.
6
+ #
7
+ # In live mode `gate` answers whether this thread is rendering for an editor; while it says no, a lookup
8
+ # takes the plain path and the hook leaves no trace.
9
+ module BackendHook
10
+ PLURAL = :dialecto_rails_plural
11
+
12
+ class << self
13
+ attr_accessor :marking, :logger, :gate
14
+
15
+ def active? = !marking.nil? && (gate.nil? || gate.call)
16
+ end
17
+
18
+ def translate(locale, key, options = ::I18n::EMPTY_HASH)
19
+ return super unless BackendHook.active?
20
+
21
+ marking = BackendHook.marking
22
+ Thread.current[PLURAL] = nil
23
+ result = super
24
+ begin
25
+ marking.call(locale, key, options, result, Thread.current[PLURAL])
26
+ rescue => error # standard errors only; the hook never breaks a page
27
+ BackendHook.logger&.debug("dialecto-rails: marking skipped (#{error.class}: #{error.message})")
28
+ result
29
+ end
30
+ end
31
+
32
+ def pluralize(locale, entry, count)
33
+ result = super
34
+ Thread.current[PLURAL] = [entry, result] if entry.is_a?(Hash) && BackendHook.active?
35
+ result
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,72 @@
1
+ module DialectoRails
2
+ module InContext
3
+ # In development, lets the overlay's origin through the app's own Content-Security-Policy (spec-0023
4
+ # §7.6): the origin is appended to the directives the policy already defines, or to `default-src` when it
5
+ # defines none of them. Only ever called after the development guard.
6
+ module Csp
7
+ DIRECTIVES = %w[script-src frame-src connect-src img-src style-src font-src].freeze
8
+
9
+ module_function
10
+
11
+ def allow(policy, origin)
12
+ return unless policy.respond_to?(:directives)
13
+
14
+ directives = policy.directives
15
+ present = DIRECTIVES.select { |name| directives.key?(name) }
16
+ if present.empty?
17
+ add(policy, "default-src", origin) if directives.key?("default-src")
18
+ else
19
+ present.each { |name| add(policy, name, origin) }
20
+ end
21
+ end
22
+
23
+ # Live mode: the same idea for one response's header text rather than the app's policy object, and only
24
+ # for frames, since the overlay comes from the site's own origin. `frame-src`, else `child-src`, else
25
+ # `default-src` governs frames; a policy with none of them lets frames through already. The browser
26
+ # enforces every policy, so several (an Array, or values joined by newlines) are widened one by one.
27
+ def widen_frame_src(header, origin)
28
+ return header.map { |policy| widen_frame_src(policy, origin) } if header.is_a?(Array)
29
+
30
+ policies = header.to_s.split("\n")
31
+ return policies.map { |policy| widen_frame_src(policy, origin) }.join("\n") if policies.size > 1
32
+
33
+ directives = header.to_s.split(";").map(&:strip).reject(&:empty?)
34
+ found = %w[frame-src child-src default-src].lazy.filter_map do |name|
35
+ directives.find { |directive| directive.split(/\s+/).first.downcase == name }
36
+ end.first
37
+ return header unless found
38
+
39
+ name, *sources = found.split(/\s+/)
40
+ sources = sources.reject { |source| source.casecmp?("'none'") }
41
+ sources << origin unless sources.include?(origin)
42
+ widened = (["frame-src"] + sources).join(" ")
43
+ return directives.map { |directive| directive.equal?(found) ? widened : directive }.join("; ") if name.casecmp?("frame-src")
44
+
45
+ (directives + [widened]).join("; ")
46
+ end
47
+
48
+ # Live mode: the nonce a script needs. A browser takes it from `script-src-elem`, else `script-src`, else
49
+ # `default-src`, and a nonce in `style-src` or elsewhere does not allow a script. `header` is the
50
+ # `content-security-policy` value: one policy, several joined by newlines, or an array of them. The first
51
+ # policy whose governing directive has a nonce wins.
52
+ def script_nonce(header)
53
+ Array(header).flat_map { |value| value.to_s.split("\n") }.each do |policy|
54
+ directives = policy.split(";").map(&:strip).reject(&:empty?)
55
+ governing = %w[script-src-elem script-src default-src].lazy.filter_map do |name|
56
+ directives.find { |directive| directive.split(/\s+/).first.downcase == name }
57
+ end.first
58
+ nonce = governing && governing[/'nonce-([A-Za-z0-9+\/=_-]+)'/, 1]
59
+ return nonce if nonce
60
+ end
61
+ nil
62
+ end
63
+
64
+ def add(policy, name, origin)
65
+ sources = Array(policy.directives[name]).reject { |source| source == "'none'" }
66
+ return if sources.include?(origin)
67
+
68
+ policy.public_send(name.tr("-", "_"), *sources, origin)
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,103 @@
1
+ require "uri"
2
+ require "open3"
3
+ require_relative "../version"
4
+
5
+ module DialectoRails
6
+ module InContext
7
+ # The site's context for the sidebar (spec-0016 §4, served at `/__dialecto/context`): `{repo, project,
8
+ # branch, sha, dirty, addon}`, read from git on demand so switching branches needs no restart. A git
9
+ # failure only leaves its field empty. Argument arrays, no shell; each call has a 5 s timeout.
10
+ class GitContext
11
+ SEGMENT = /\A[A-Za-z0-9._-]+\z/
12
+ SHA = /\A[0-9a-f]{40}\z/
13
+ MAX_DIRTY = 200
14
+ TIMEOUT = 5
15
+ CACHE_SECONDS = 1.0
16
+
17
+ def initialize(root:, project: nil, catalogs: "config/locales",
18
+ clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) })
19
+ @root = root.to_s
20
+ @project = project
21
+ @catalogs = catalogs
22
+ @clock = clock
23
+ @mutex = Mutex.new
24
+ @cached = nil
25
+ @cached_at = nil
26
+ end
27
+
28
+ def read
29
+ @mutex.synchronize do
30
+ now = @clock.call
31
+ return @cached if @cached && now - @cached_at < CACHE_SECONDS
32
+
33
+ @cached_at = now
34
+ @cached = build
35
+ end
36
+ end
37
+
38
+ def self.parse_github_remote(remote)
39
+ remote = remote.to_s.strip
40
+ host, path =
41
+ if remote.include?("://")
42
+ uri = URI.parse(remote) rescue nil
43
+ [uri&.host, uri&.path] if uri && %w[http https ssh git].include?(uri.scheme)
44
+ elsif (match = remote.match(%r{\A[^@\s/:]+@([^:/\s]+):(.+)\z}))
45
+ [match[1], match[2]]
46
+ end
47
+ return nil unless host && path && host.downcase == "github.com"
48
+
49
+ segments = path.delete_prefix("/").chomp("/").delete_suffix(".git").split("/")
50
+ segments.length == 2 && segments.all? { |segment| segment.match?(SEGMENT) } ? segments.join("/") : nil
51
+ end
52
+
53
+ # Repo-root-relative paths (sorted, unique, capped) from `git status --porcelain=v1 -z`.
54
+ def self.parse_porcelain_z(output)
55
+ fields = output.to_s.split("\0")
56
+ paths = []
57
+ until fields.empty?
58
+ field = fields.shift
59
+ match = field.match(/\A(.)(.) (.+)\z/m)
60
+ next unless match
61
+
62
+ paths << match[3]
63
+ renamed = "RC".include?(match[1]) || "RC".include?(match[2])
64
+ original = renamed ? fields.shift : nil
65
+ paths << original if original && !original.empty?
66
+ end
67
+ paths.uniq.sort.first(MAX_DIRTY)
68
+ end
69
+
70
+ private
71
+
72
+ def build
73
+ branch = git("rev-parse", "--abbrev-ref", "HEAD").to_s.strip
74
+ sha = git("rev-parse", "HEAD").to_s.strip
75
+ {
76
+ repo: self.class.parse_github_remote(git("remote", "get-url", "origin")),
77
+ project: @project,
78
+ branch: ["", "HEAD"].include?(branch) ? nil : branch,
79
+ sha: sha.match?(SHA) ? sha : nil,
80
+ dirty: self.class.parse_porcelain_z(git("status", "--porcelain=v1", "-z", "--untracked-files=all", "--",
81
+ File.expand_path(@catalogs, @root))),
82
+ addon: "rails@#{DialectoRails::VERSION}"
83
+ }
84
+ end
85
+
86
+ def git(*args)
87
+ Open3.popen3("git", "-C", @root, *args) do |stdin, stdout, stderr, waiter|
88
+ stdin.close
89
+ stdout.binmode
90
+ reader = Thread.new { stdout.read }
91
+ Thread.new { stderr.read }
92
+ unless waiter.join(TIMEOUT)
93
+ Process.kill("KILL", waiter.pid) rescue nil
94
+ return nil
95
+ end
96
+ waiter.value.success? ? reader.value.force_encoding(Encoding::UTF_8) : nil
97
+ end
98
+ rescue SystemCallError
99
+ nil
100
+ end
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,128 @@
1
+ require "psych"
2
+ require_relative "../scan/catalogs"
3
+
4
+ module DialectoRails
5
+ module InContext
6
+ # `(locale, key) → domain`, answered from the project's own locale files without running any code. A
7
+ # string whose key isn't here is not marked: that leaves out every gem's locale files, `default:` text and
8
+ # missing translations, none of which Dialecto holds.
9
+ #
10
+ # The domain is the file's path relative to `config/locales/` (spec-0015 §1), the server's bucket. Files
11
+ # are read as a YAML syntax tree (`Psych.parse`, never `load`), so tags and aliases can't execute or expand;
12
+ # a key reached only through a `<<: *alias` merge isn't indexed (the server locks those too).
13
+ class KeyIndex
14
+ RECHECK_MS = 500
15
+ LOCALES_DIR = "config/locales/".freeze
16
+
17
+ def initialize(root:, catalog_paths:, load_path: -> { ::I18n.load_path },
18
+ clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC, :millisecond) })
19
+ @root = real(File.expand_path(root)).chomp("/") + "/"
20
+ @catalogs = Scan::Catalogs.new(catalog_paths)
21
+ @load_path = load_path
22
+ @clock = clock
23
+ @mutex = Mutex.new
24
+ @signature = nil
25
+ @checked_at = nil
26
+ @table = {}
27
+ end
28
+
29
+ # The domain of `key` in `locale`, or nil.
30
+ def domain(locale, key)
31
+ refresh
32
+ @table.dig(locale.to_s, key)
33
+ end
34
+
35
+ def locales
36
+ refresh
37
+ @table.keys
38
+ end
39
+
40
+ # Catalog files inside the project, in load order (later ones win a duplicate key).
41
+ def files
42
+ @load_path.call.filter_map do |path|
43
+ full = real(File.expand_path(path.to_s))
44
+ next unless full.start_with?(@root) && full.end_with?(".yml", ".yaml")
45
+
46
+ relative = full.delete_prefix(@root)
47
+ relative if @catalogs.match?(relative)
48
+ end.uniq
49
+ end
50
+
51
+ def self.domain_for(relative_path)
52
+ at = relative_path.start_with?(LOCALES_DIR) ? 0 : relative_path.index("/" + LOCALES_DIR)
53
+ return relative_path unless at
54
+
55
+ relative_path[(at.zero? ? LOCALES_DIR.length : at + 1 + LOCALES_DIR.length)..]
56
+ end
57
+
58
+ private
59
+
60
+ # Symlinks resolved on both sides, so a project reached through one (macOS /var, a linked checkout) still
61
+ # matches the paths Rails puts on the load path.
62
+ def real(path)
63
+ File.realpath(path)
64
+ rescue SystemCallError
65
+ path
66
+ end
67
+
68
+ def refresh
69
+ now = @clock.call
70
+ return if @checked_at && now - @checked_at < RECHECK_MS
71
+
72
+ @mutex.synchronize do
73
+ @checked_at = now
74
+ paths = files
75
+ signature = paths.map { |path| [path, mtime(path)] }
76
+ next if signature == @signature
77
+
78
+ @signature = signature
79
+ @table = build(paths)
80
+ end
81
+ end
82
+
83
+ def mtime(path)
84
+ File.mtime(File.join(@root, path)).to_f
85
+ rescue SystemCallError
86
+ nil
87
+ end
88
+
89
+ def build(paths)
90
+ table = Hash.new { |hash, locale| hash[locale] = {} }
91
+ paths.each do |path|
92
+ document = parse(File.join(@root, path))
93
+ root = document&.root
94
+ next unless root.is_a?(Psych::Nodes::Mapping)
95
+
96
+ domain = self.class.domain_for(path)
97
+ pairs(root).each do |locale_node, value|
98
+ next unless locale_node.is_a?(Psych::Nodes::Scalar)
99
+
100
+ walk(value, [], table[locale_node.value], domain)
101
+ end
102
+ end
103
+ table.default_proc = nil
104
+ table
105
+ end
106
+
107
+ def parse(path)
108
+ Psych.parse_file(path)
109
+ rescue Psych::SyntaxError, SystemCallError, EncodingError
110
+ nil
111
+ end
112
+
113
+ def pairs(mapping) = mapping.children.each_slice(2).to_a
114
+
115
+ def walk(node, path, table, domain)
116
+ return unless node.is_a?(Psych::Nodes::Mapping)
117
+
118
+ pairs(node).each do |key_node, value|
119
+ next unless key_node.is_a?(Psych::Nodes::Scalar) && key_node.value != "<<"
120
+
121
+ key_path = path + [key_node.value]
122
+ table[key_path.join(".")] = domain
123
+ walk(value, key_path, table, domain)
124
+ end
125
+ end
126
+ end
127
+ end
128
+ end
@@ -0,0 +1,78 @@
1
+ require "json"
2
+ require "openssl"
3
+ require_relative "../config"
4
+
5
+ module DialectoRails
6
+ module InContext
7
+ # Verifies the launch token Dialecto mints for an editor (spec-0025 §3) with no call to Dialecto:
8
+ #
9
+ # token = base64url(payload) "." base64url(signature)
10
+ # signed = "dialecto-live-edit-v1." base64url(payload)
11
+ #
12
+ # The signature is Ed25519 under the repo's key, and the purpose prefix keeps a signature made for another
13
+ # purpose (a release manifest) from verifying here. Needs the openssl gem 3.0 or later (Ruby 3.2 ships
14
+ # 3.1) on OpenSSL 1.1.1 or later. Pure: the caller passes the key, the site's origin and the time.
15
+ module LaunchToken
16
+ PREFIX = "dialecto-live-edit-v1.".freeze
17
+ BASE64URL = /\A[A-Za-z0-9_-]*\z/
18
+
19
+ Result = Struct.new(:claims, :reason) do
20
+ def ok? = reason.nil?
21
+ end
22
+
23
+ module_function
24
+
25
+ # `phase` is `:start` when the token first arrives (which also requires `start_by` not to have passed)
26
+ # or `:session` for the cookie on later requests (only `exp` matters). `reason` is one of `:malformed`,
27
+ # `:bad_signature`, `:expired`, `:late`, `:wrong_origin` and `:wrong_repo`.
28
+ #
29
+ # With `repo` ("owner/name") the token's repo claim must match it, ignoring case (`:wrong_repo`): a
30
+ # cheap check against a key pasted from the wrong repo.
31
+ def verify(token, public_key, origin, now, phase = :session, repo: nil)
32
+ return failure(:malformed) unless token.is_a?(String) && public_key.is_a?(String) && public_key.bytesize == 32
33
+ return failure(:malformed) unless origin.is_a?(String) && now.is_a?(Integer)
34
+
35
+ payload_b64, signature_b64, extra = token.split(".", 3)
36
+ return failure(:malformed) if payload_b64.nil? || signature_b64.nil? || extra
37
+
38
+ signature = decode(signature_b64)
39
+ return failure(:malformed) unless signature&.bytesize == 64
40
+ return failure(:bad_signature) unless signed?(payload_b64, signature, public_key)
41
+
42
+ claims = claims(payload_b64)
43
+ return failure(:malformed) unless claims
44
+
45
+ return failure(:expired) if now >= claims["exp"]
46
+ return failure(:late) if phase == :start && now > claims["start_by"]
47
+ return failure(:wrong_origin) unless Config.normalize_origin(claims["origin"]) == origin
48
+ return failure(:wrong_repo) if repo && !(claims["repo"].is_a?(String) && claims["repo"].casecmp?(repo))
49
+
50
+ Result.new(claims, nil)
51
+ end
52
+
53
+ def failure(reason) = Result.new(nil, reason)
54
+
55
+ def decode(text)
56
+ return nil unless text.match?(BASE64URL) && text.length % 4 != 1
57
+
58
+ (text.tr("-_", "+/") + "=" * (-text.length % 4)).unpack1("m")
59
+ end
60
+
61
+ def signed?(payload_b64, signature, public_key)
62
+ OpenSSL::PKey.new_raw_public_key("ED25519", public_key).verify(nil, signature, PREFIX + payload_b64)
63
+ rescue OpenSSL::PKey::PKeyError
64
+ false
65
+ end
66
+
67
+ def claims(payload_b64)
68
+ json = decode(payload_b64)
69
+ claims = json && JSON.parse(json.force_encoding(Encoding::UTF_8))
70
+ valid = claims.is_a?(Hash) && claims["v"] == 1 && claims["origin"].is_a?(String) &&
71
+ claims["exp"].is_a?(Integer) && claims["start_by"].is_a?(Integer)
72
+ valid ? claims : nil
73
+ rescue JSON::ParserError, EncodingError
74
+ nil
75
+ end
76
+ end
77
+ end
78
+ end