kamal-lint 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 (37) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +52 -0
  3. data/MIT-LICENSE +21 -0
  4. data/README.md +190 -0
  5. data/action.yml +61 -0
  6. data/bin/kamal-lint +7 -0
  7. data/lib/kamal/lint/check.rb +90 -0
  8. data/lib/kamal/lint/checks/accessory_image_latest.rb +43 -0
  9. data/lib/kamal/lint/checks/accessory_placement_missing.rb +49 -0
  10. data/lib/kamal/lint/checks/accessory_role_undefined.rb +43 -0
  11. data/lib/kamal/lint/checks/boot_limit_exceeds_hosts.rb +34 -0
  12. data/lib/kamal/lint/checks/builder_registry_secret_undeclared.rb +42 -0
  13. data/lib/kamal/lint/checks/empty_web_role.rb +37 -0
  14. data/lib/kamal/lint/checks/image_registry_mismatch.rb +39 -0
  15. data/lib/kamal/lint/checks/kamal_secrets_not_gitignored.rb +56 -0
  16. data/lib/kamal/lint/checks/missing_proxy_healthcheck.rb +27 -0
  17. data/lib/kamal/lint/checks/missing_service_name.rb +46 -0
  18. data/lib/kamal/lint/checks/registry_without_explicit_server.rb +37 -0
  19. data/lib/kamal/lint/checks/role_hosts_empty.rb +35 -0
  20. data/lib/kamal/lint/checks/secret_in_env_clear.rb +45 -0
  21. data/lib/kamal/lint/checks/secret_not_declared.rb +58 -0
  22. data/lib/kamal/lint/checks/ssl_without_host.rb +37 -0
  23. data/lib/kamal/lint/checks/traefik_legacy_keys.rb +58 -0
  24. data/lib/kamal/lint/cli.rb +109 -0
  25. data/lib/kamal/lint/finding.rb +32 -0
  26. data/lib/kamal/lint/formatters/github.rb +55 -0
  27. data/lib/kamal/lint/formatters/human.rb +118 -0
  28. data/lib/kamal/lint/formatters/json.rb +38 -0
  29. data/lib/kamal/lint/kamal_version.rb +62 -0
  30. data/lib/kamal/lint/loader.rb +175 -0
  31. data/lib/kamal/lint/registry.rb +32 -0
  32. data/lib/kamal/lint/runner.rb +102 -0
  33. data/lib/kamal/lint/secrets_file.rb +29 -0
  34. data/lib/kamal/lint/servers_helper.rb +60 -0
  35. data/lib/kamal/lint/version.rb +7 -0
  36. data/lib/kamal/lint.rb +63 -0
  37. metadata +177 -0
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kamal
4
+ module Lint
5
+ module Checks
6
+ class ImageRegistryMismatch < Check
7
+ id "image-registry-mismatch"
8
+ severity :error
9
+ since "2.0.0"
10
+ title "`image:` registry prefix doesn't match `builder.registry.server`"
11
+
12
+ # Docker Hub accepts unprefixed images (`myorg/myapp` resolves to
13
+ # `docker.io/myorg/myapp`), so we don't flag a missing prefix when the
14
+ # configured registry is Docker Hub under any of its canonical names.
15
+ DOCKER_HUB_HOSTS = %w[docker.io index.docker.io registry.hub.docker.com].freeze
16
+
17
+ def call
18
+ image = parsed["image"]
19
+ registry = parsed["registry"] || parsed.dig("builder", "registry") || {}
20
+ server = registry["server"] if registry.is_a?(Hash)
21
+ return [] unless image.is_a?(String) && server.is_a?(String) && !server.empty?
22
+
23
+ normalized_server = server.sub(%r{/+\z}, "")
24
+ return [] if DOCKER_HUB_HOSTS.include?(normalized_server)
25
+
26
+ prefix = "#{normalized_server}/"
27
+ return [] if image.start_with?(prefix)
28
+
29
+ [ finding(
30
+ message: "image `#{image}` does not include the configured registry `#{server}`; Kamal will push to the wrong registry",
31
+ line: context.line_for([ "image" ])
32
+ ) ]
33
+ end
34
+ end
35
+
36
+ Lint.registry.register(ImageRegistryMismatch)
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kamal
4
+ module Lint
5
+ module Checks
6
+ class KamalSecretsNotGitignored < Check
7
+ id "kamal-secrets-not-gitignored"
8
+ severity :warning
9
+ since "2.0.0"
10
+ autofixable true
11
+ title ".kamal/secrets is not in .gitignore"
12
+
13
+ # We only flag when:
14
+ # - a .kamal/secrets file exists (so there's something to leak), AND
15
+ # - it is NOT covered by .gitignore (or .gitignore is missing).
16
+ def call
17
+ return [] unless File.exist?(context.secrets_path)
18
+ return [] if gitignored?
19
+
20
+ [ finding(
21
+ message: ".kamal/secrets exists but isn't ignored by .gitignore; you risk committing real secrets",
22
+ line: 1,
23
+ autofix: method(:apply_fix)
24
+ ) ]
25
+ end
26
+
27
+ def gitignored?
28
+ return false unless File.exist?(context.gitignore_path)
29
+
30
+ File.foreach(context.gitignore_path).any? do |line|
31
+ stripped = line.strip
32
+ next false if stripped.empty? || stripped.start_with?("#")
33
+
34
+ stripped == ".kamal/secrets" ||
35
+ stripped == "/.kamal/secrets" ||
36
+ stripped == ".kamal/*" ||
37
+ stripped == ".kamal/" ||
38
+ stripped == ".kamal"
39
+ end
40
+ end
41
+
42
+ def apply_fix(ctx)
43
+ path = ctx.gitignore_path
44
+ existing = File.exist?(path) ? File.read(path) : ""
45
+ existing = existing + "\n" unless existing.empty? || existing.end_with?("\n")
46
+ File.write(path, "#{existing}.kamal/secrets\n")
47
+ true
48
+ rescue => _e
49
+ false
50
+ end
51
+ end
52
+
53
+ Lint.registry.register(KamalSecretsNotGitignored)
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kamal
4
+ module Lint
5
+ module Checks
6
+ class MissingProxyHealthcheck < Check
7
+ id "missing-proxy-healthcheck"
8
+ severity :warning
9
+ since "2.0.0"
10
+ title "`proxy:` block has no healthcheck — zero-downtime deploys may fail"
11
+
12
+ def call
13
+ proxy = parsed["proxy"]
14
+ return [] unless proxy.is_a?(Hash)
15
+ return [] if proxy.key?("healthcheck")
16
+
17
+ [ finding(
18
+ message: "proxy block has no `healthcheck:` configured; Kamal-proxy can't verify a new release before cutover",
19
+ line: context.line_for([ "proxy" ])
20
+ ) ]
21
+ end
22
+ end
23
+
24
+ Lint.registry.register(MissingProxyHealthcheck)
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kamal
4
+ module Lint
5
+ module Checks
6
+ class MissingServiceName < Check
7
+ id "missing-service-name"
8
+ severity :error
9
+ since "2.0.0"
10
+ autofixable true
11
+ title "`service:` is required and missing"
12
+
13
+ def call
14
+ service = parsed["service"]
15
+ return [] if service.is_a?(String) && !service.strip.empty?
16
+
17
+ [ finding(
18
+ message: "`service:` is required; without it Kamal can't name the deployed container",
19
+ line: 1,
20
+ autofix: method(:apply_fix)
21
+ ) ]
22
+ end
23
+
24
+ def apply_fix(ctx)
25
+ file = ctx.file_for_finding
26
+ text = File.read(file)
27
+ parsed = YAML.safe_load(text, aliases: true) || {}
28
+ return false if parsed["service"].is_a?(String) && !parsed["service"].empty?
29
+
30
+ name = File.basename(ctx.working_dir).gsub(/[^A-Za-z0-9_-]/, "-")
31
+ return false if name.empty?
32
+
33
+ # Parse-and-dump so the fix composes safely with other autofixes
34
+ # that may also rewrite the file. The trade-off is that comments
35
+ # in the original YAML are lost — documented in the README.
36
+ File.write(file, YAML.dump({ "service" => name }.merge(parsed)))
37
+ true
38
+ rescue
39
+ false
40
+ end
41
+ end
42
+
43
+ Lint.registry.register(MissingServiceName)
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kamal
4
+ module Lint
5
+ module Checks
6
+ class RegistryWithoutExplicitServer < Check
7
+ id "registry-without-explicit-server"
8
+ severity :warning
9
+ since "2.0.0"
10
+ title "`registry.server` not set; image will default to Docker Hub"
11
+
12
+ def call
13
+ registry = parsed["registry"] || parsed.dig("builder", "registry")
14
+ return [] unless registry.is_a?(Hash)
15
+
16
+ server = registry["server"]
17
+ return [] if server.is_a?(String) && !server.empty?
18
+
19
+ image = parsed["image"]
20
+ # If image already has an explicit registry prefix (host with a "."),
21
+ # this is intentional and we don't warn.
22
+ if image.is_a?(String) && image.include?("/")
23
+ first_segment = image.split("/", 2).first
24
+ return [] if first_segment.include?(".") || first_segment.include?(":")
25
+ end
26
+
27
+ [ finding(
28
+ message: "registry has no `server:` set; Kamal will push/pull from Docker Hub by default",
29
+ line: context.line_for([ "registry" ]) || context.line_for([ "registry", "username" ])
30
+ ) ]
31
+ end
32
+ end
33
+
34
+ Lint.registry.register(RegistryWithoutExplicitServer)
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../servers_helper"
4
+
5
+ module Kamal
6
+ module Lint
7
+ module Checks
8
+ class RoleHostsEmpty < Check
9
+ id "role-hosts-empty"
10
+ severity :error
11
+ since "2.0.0"
12
+ title "A role under `servers:` has no hosts"
13
+
14
+ def call
15
+ servers = parsed["servers"]
16
+ return [] unless servers.is_a?(Hash)
17
+
18
+ findings = []
19
+ servers.each do |role, entry|
20
+ hosts = ServersHelper.extract_hosts(entry)
21
+ next unless hosts.empty?
22
+
23
+ findings << finding(
24
+ message: "role `#{role}` under `servers` has no hosts; deploys to this role will silently no-op",
25
+ line: context.line_for([ "servers", role.to_s ])
26
+ )
27
+ end
28
+ findings
29
+ end
30
+ end
31
+
32
+ Lint.registry.register(RoleHostsEmpty)
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kamal
4
+ module Lint
5
+ module Checks
6
+ class SecretInEnvClear < Check
7
+ id "secret-in-env-clear"
8
+ severity :warning
9
+ since "2.0.0"
10
+ title "Value in `env.clear` looks like a secret"
11
+
12
+ SECRET_KEY_PATTERN = /(\A|_)(KEY|SECRET|TOKEN|PASSWORD|PWD|CREDENTIALS?)(_|\z)/i
13
+
14
+ def call
15
+ findings = []
16
+ scan_env(parsed["env"], [ "env" ], findings)
17
+ (parsed["accessories"] || {}).each do |name, accessory|
18
+ scan_env(accessory["env"], [ "accessories", name, "env" ], findings) if accessory.is_a?(Hash)
19
+ end
20
+ findings
21
+ end
22
+
23
+ private
24
+
25
+ def scan_env(env, prefix, findings)
26
+ return unless env.is_a?(Hash)
27
+
28
+ clear = env["clear"]
29
+ return unless clear.is_a?(Hash)
30
+
31
+ clear.each do |key, _value|
32
+ next unless key.is_a?(String) && key.match?(SECRET_KEY_PATTERN)
33
+
34
+ findings << finding(
35
+ message: "env.clear contains `#{key}` which looks like a secret; move it to env.secret + .kamal/secrets",
36
+ line: context.line_for(prefix + [ "clear", key ])
37
+ )
38
+ end
39
+ end
40
+ end
41
+
42
+ Lint.registry.register(SecretInEnvClear)
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kamal
4
+ module Lint
5
+ module Checks
6
+ class SecretNotDeclared < Check
7
+ id "secret-not-declared"
8
+ severity :error
9
+ since "2.0.0"
10
+ title "env.secret references a key not declared in .kamal/secrets"
11
+
12
+ def call
13
+ findings = []
14
+ declared = context.secrets
15
+
16
+ referenced_secret_keys.each do |path, name|
17
+ next if declared.include?(name)
18
+
19
+ findings << finding(
20
+ message: "env.secret references `#{name}` but it isn't declared in .kamal/secrets",
21
+ line: context.line_for(path)
22
+ )
23
+ end
24
+
25
+ findings
26
+ end
27
+
28
+ private
29
+
30
+ def referenced_secret_keys
31
+ refs = []
32
+ collect_secrets(parsed["env"], [ "env" ], refs)
33
+ (parsed["accessories"] || {}).each do |name, accessory|
34
+ next unless accessory.is_a?(Hash)
35
+
36
+ collect_secrets(accessory["env"], [ "accessories", name, "env" ], refs)
37
+ end
38
+ refs
39
+ end
40
+
41
+ def collect_secrets(env_block, prefix, refs)
42
+ return unless env_block.is_a?(Hash)
43
+
44
+ list = env_block["secret"]
45
+ return unless list.is_a?(Array)
46
+
47
+ list.each_with_index do |name, idx|
48
+ next unless name.is_a?(String)
49
+
50
+ refs << [ prefix + [ "secret", idx.to_s ], name ]
51
+ end
52
+ end
53
+ end
54
+
55
+ Lint.registry.register(SecretNotDeclared)
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kamal
4
+ module Lint
5
+ module Checks
6
+ class SslWithoutHost < Check
7
+ id "ssl-without-host"
8
+ severity :error
9
+ since "2.0.0"
10
+ title "SSL enabled without a host configured"
11
+
12
+ def call
13
+ proxy = parsed["proxy"]
14
+ return [] unless proxy.is_a?(Hash)
15
+
16
+ ssl_enabled = proxy["ssl"] == true
17
+ return [] unless ssl_enabled
18
+
19
+ host = proxy["host"]
20
+ hosts = proxy["hosts"]
21
+
22
+ host_set = (host.is_a?(String) && !host.empty?) ||
23
+ (hosts.is_a?(Array) && hosts.any? { |h| h.is_a?(String) && !h.empty? })
24
+
25
+ return [] if host_set
26
+
27
+ [ finding(
28
+ message: "proxy.ssl: true requires `host:` (or `hosts:`) to be set for automatic Let's Encrypt provisioning",
29
+ line: context.line_for([ "proxy", "ssl" ]) || context.line_for([ "proxy" ])
30
+ ) ]
31
+ end
32
+ end
33
+
34
+ Lint.registry.register(SslWithoutHost)
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kamal
4
+ module Lint
5
+ module Checks
6
+ class TraefikLegacyKeys < Check
7
+ id "traefik-legacy-keys"
8
+ severity :warning
9
+ since "2.0.0"
10
+ autofixable true
11
+ title "Kamal 1.x `traefik:` keys present (use `proxy:` in Kamal 2+)"
12
+
13
+ def call
14
+ return [] unless parsed.key?("traefik")
15
+
16
+ [ finding(
17
+ message: "`traefik:` block is Kamal 1.x legacy and is ignored in Kamal 2+; use `proxy:` instead",
18
+ line: context.line_for([ "traefik" ]),
19
+ autofix: method(:apply_fix)
20
+ ) ]
21
+ end
22
+
23
+ def apply_fix(ctx)
24
+ file = ctx.file_for_finding
25
+ text = File.read(file)
26
+ parsed = YAML.safe_load(text, aliases: true) || {}
27
+ return false unless parsed.is_a?(Hash) && parsed.key?("traefik")
28
+
29
+ traefik = parsed.delete("traefik") || {}
30
+ proxy = parsed["proxy"] || {}
31
+
32
+ # Conservative translation:
33
+ # - traefik.host → proxy.host
34
+ # - traefik.ssl_redirect → proxy.ssl: true (Kamal 2 handles SSL via proxy.ssl)
35
+ # - traefik.args.entryPoints.address: ":<port>" → proxy.app_port: <port>
36
+ if (host = traefik["host"]) && !proxy["host"]
37
+ proxy["host"] = host
38
+ end
39
+ if traefik["ssl_redirect"] == true || traefik.dig("args", "entrypoints.websecure.address")
40
+ proxy["ssl"] = true unless proxy.key?("ssl")
41
+ end
42
+ if (addr = traefik.dig("args", "entrypoints.web.address")) && addr.is_a?(String)
43
+ port = addr.scan(/\d+/).first
44
+ proxy["app_port"] = port.to_i if port && !proxy.key?("app_port")
45
+ end
46
+
47
+ parsed["proxy"] = proxy unless proxy.empty?
48
+ File.write(file, YAML.dump(parsed))
49
+ true
50
+ rescue => _e
51
+ false
52
+ end
53
+ end
54
+
55
+ Lint.registry.register(TraefikLegacyKeys)
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "thor"
4
+
5
+ module Kamal
6
+ module Lint
7
+ class CLI < Thor
8
+ def self.exit_on_failure?
9
+ true
10
+ end
11
+
12
+ class_option :config_file, aliases: "-c", type: :string,
13
+ default: "config/deploy.yml",
14
+ desc: "Path to the Kamal deploy.yml"
15
+ class_option :destination, aliases: "-d", type: :string,
16
+ desc: "Destination override (e.g. production → config/deploy.production.yml)"
17
+ class_option :format, aliases: "-f", type: :string, default: "human",
18
+ enum: %w[human json github],
19
+ desc: "Output format"
20
+ class_option :fail_on, type: :string, default: "warning",
21
+ enum: %w[error warning info],
22
+ desc: "Minimum severity that causes a non-zero exit code"
23
+ class_option :fix, type: :boolean, default: false,
24
+ desc: "Apply safe autofixes in-place"
25
+ class_option :"kamal-version", type: :string,
26
+ desc: "Override detected Kamal version"
27
+ class_option :"include-kamal-errors", type: :boolean, default: false,
28
+ desc: "Also surface errors from Kamal's own loader (off by default; use `kamal config` for that)"
29
+ class_option :no_color, type: :boolean, default: false,
30
+ desc: "Disable colored output"
31
+
32
+ desc "lint", "Lint the Kamal deploy.yml (default command)"
33
+ def lint
34
+ runner = Runner.new(
35
+ config_file: options[:config_file],
36
+ destination: options[:destination],
37
+ kamal_version: options[:"kamal-version"],
38
+ fix: options[:fix],
39
+ include_kamal_errors: options[:"include-kamal-errors"]
40
+ )
41
+ result = runner.call
42
+ formatter = build_formatter(options[:format], options[:no_color])
43
+ formatter.render(result)
44
+ formatter.render_fix_summary(result) if options[:fix]
45
+ exit(result.exit_code(fail_on: options[:fail_on].to_sym))
46
+ rescue ConfigNotFoundError => e
47
+ warn "kamal-lint: #{e.message}"
48
+ exit(2)
49
+ end
50
+
51
+ default_task :lint
52
+
53
+ desc "list-checks", "List all registered checks"
54
+ def list_checks
55
+ registry = Lint.registry
56
+ format = options[:format]
57
+
58
+ if format == "json"
59
+ require "json"
60
+ payload = registry.all.map do |check|
61
+ {
62
+ id: check.id,
63
+ severity: check.severity.to_s,
64
+ title: check.title,
65
+ since: check.since,
66
+ until_version: check.until_version,
67
+ autofixable: check.autofixable
68
+ }
69
+ end
70
+ puts JSON.pretty_generate(payload)
71
+ else
72
+ puts "#{"ID".ljust(38)} #{"SEVERITY".ljust(9)} #{"SINCE".ljust(8)} TITLE"
73
+ puts "-" * 110
74
+ registry.all.each do |check|
75
+ line = [
76
+ check.id.to_s.ljust(38),
77
+ check.severity.to_s.ljust(9),
78
+ (check.since || "—").to_s.ljust(8),
79
+ check.title.to_s
80
+ ].join(" ")
81
+ line += " (autofixable)" if check.autofixable
82
+ puts line
83
+ end
84
+ puts
85
+ puts "Total: #{registry.all.size} checks"
86
+ end
87
+ end
88
+
89
+ desc "version", "Show version"
90
+ def version
91
+ puts "kamal-lint #{Kamal::Lint::VERSION}"
92
+ end
93
+
94
+ map "--version" => :version
95
+ map "-v" => :version
96
+
97
+ no_commands do
98
+ def build_formatter(name, no_color)
99
+ klass = FORMATTERS.fetch(name) { raise ArgumentError, "unknown formatter: #{name}" }
100
+ if klass == Formatters::Human
101
+ klass.new(color: !no_color && $stdout.tty?)
102
+ else
103
+ klass.new
104
+ end
105
+ end
106
+ end
107
+ end
108
+ end
109
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kamal
4
+ module Lint
5
+ Finding = Struct.new(
6
+ :check_id,
7
+ :severity,
8
+ :message,
9
+ :file,
10
+ :line,
11
+ :column,
12
+ :autofix,
13
+ keyword_init: true
14
+ ) do
15
+ def autofixable?
16
+ !autofix.nil?
17
+ end
18
+
19
+ def to_h
20
+ {
21
+ check: check_id,
22
+ severity: severity.to_s,
23
+ message: message,
24
+ file: file,
25
+ line: line,
26
+ column: column,
27
+ autofixable: autofixable?
28
+ }
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kamal
4
+ module Lint
5
+ module Formatters
6
+ # GitHub Actions workflow command output.
7
+ # See: https://docs.github.com/en/actions/using-workflows/workflow-commands-for-github-actions
8
+ class Github
9
+ LEVEL = {
10
+ error: "error",
11
+ warning: "warning",
12
+ info: "notice"
13
+ }.freeze
14
+
15
+ def initialize(io: $stdout)
16
+ @io = io
17
+ end
18
+
19
+ def render(result)
20
+ result.findings.each do |finding|
21
+ level = LEVEL[finding.severity] || "notice"
22
+ attrs = {
23
+ file: finding.file,
24
+ line: finding.line,
25
+ col: finding.column,
26
+ title: "kamal-lint: #{finding.check_id}"
27
+ }.compact
28
+
29
+ attr_str = attrs.map { |k, v| "#{k}=#{escape_property(v.to_s)}" }.join(",")
30
+ message = escape_message(finding.message)
31
+ @io.puts "::#{level} #{attr_str}::#{message}"
32
+ end
33
+ end
34
+
35
+ def render_fix_summary(result)
36
+ return if result.fixed.empty?
37
+
38
+ result.fixed.each do |finding|
39
+ @io.puts "::notice file=#{finding.file},title=kamal-lint autofix::Fixed #{finding.check_id}"
40
+ end
41
+ end
42
+
43
+ private
44
+
45
+ def escape_message(value)
46
+ value.to_s.gsub("%", "%25").gsub("\r", "%0D").gsub("\n", "%0A")
47
+ end
48
+
49
+ def escape_property(value)
50
+ value.to_s.gsub("%", "%25").gsub("\r", "%0D").gsub("\n", "%0A").gsub(":", "%3A").gsub(",", "%2C")
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end