chronos-ruby 1.1.3 → 1.1.4

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.
@@ -0,0 +1,36 @@
1
+ # Deploy pela CLI legada
2
+
3
+ A CLI está incluída na release legada 1.1.4 em preparação. Após sua publicação e instalação, configure pelo ambiente:
4
+
5
+ ```sh
6
+ export CHRONOS_PROJECT_ID=seu-projeto
7
+ # Injete CHRONOS_PROJECT_KEY pelo mecanismo de secrets da publicação.
8
+ export CHRONOS_ENVIRONMENT=production
9
+ export CHRONOS_DEPLOY_ID=identidade-estavel-da-publicacao
10
+ export CHRONOS_DEPLOY_REVISION=revisao-do-artefato-publicado
11
+ bundle exec chronos deploy notify
12
+ ```
13
+
14
+ Não é necessário copiar um script Ruby. A CLI não inicializa Rails e não lê credentials ou initializer. `CHRONOS_SERVICE_NAME` deve corresponder ao serviço do runtime. `CHRONOS_DEPLOY_VERSION` pode substituir a revisão. Host padrão: https://chronosmonitor.com.br; override: CHRONOS_HOST, somente HTTPS sem credenciais, query ou fragmento. TLS mantém a verificação existente.
15
+
16
+ Aliases: DEPLOY_ENVIRONMENT, SERVICE_NAME, DEPLOY_ID, GIT_SHA e APP_VERSION. Conflitos com ENV canônica são erros. Opções públicas explícitas prevalecem sobre ENV e `config/chronos-deploy.yml`; a chave somente entra por CHRONOS_PROJECT_KEY. YAML aceita apenas project_id, host, environment, service_name, deploy_id, revision e version, com valores string. Nunca grave secrets nesse arquivo.
17
+
18
+ `deploy diagnose --offline` exige a configuração completa de notify e verifica sua leitura local, sem autenticar credenciais. `deploy verify` usa o receipt existente, sem criar Deployment; receipt não comprova projeção. `--help` e `--version` são locais. `--timeout` aceita 0 < segundos <= 60; tentativas adicionais do transporte e backlog são desativados na CLI. O timeout limita operações do transporte e flush, não é um deadline global do processo.
19
+
20
+ Saída operacional JSON schema_version=1: success e state; erros de configuração incluem orientação estática e, quando necessário, missing_fields. Verify acrescenta verification_status do contrato de receipt. Códigos: 0 sucesso local ou entrega confirmada pela gem, 1 configuração/uso inválidos, 2 entrega não confirmada, 3 falha interna. `--on-error=warn` converte somente entrega não confirmada em status 0 e preserva success=false. Um false pode acontecer após aceite remoto; não faça retry cego. Reenvio com mesmo deploy ID pode consumir outro evento; dependências podem adicionar eventos. Não há garantia de cobrança única.
21
+
22
+ Gere integrações explicitamente:
23
+
24
+ ```sh
25
+ bundle exec chronos deploy install --provider kamal --dry-run
26
+ bundle exec chronos deploy install --provider kamal
27
+ bundle exec chronos deploy install --provider kamal --check
28
+ ```
29
+
30
+ Providers: kamal, capistrano, github-actions, gitlab-ci, jenkins, azure-devops. Use `--root PATH` para destino explícito. O generator `rails generate chronos:deploy:install --provider=kamal` usa o mesmo instalador. Arquivos iguais não mudam; conflitos falham. A resposta de conflito oferece a proposta de template, omitindo o conteúdo anterior por privacidade. Faça merge manual antes de considerar `--force`. Symlinks no caminho de destino são rejeitados. O instalador usa operações relativas a descritores de diretórios POSIX (Linux/macOS), sem mudar o diretório global do processo, para impedir redirecionamento por troca concorrente de symlinks. Leituras de arquivos existentes são limitadas a 64 KiB e não seguem symlinks. A raiz deve pertencer a um contexto de publicação confiável; isso não protege contra uma conta local já autorizada a modificar o projeto inteiro. Nenhum pipeline é ativado automaticamente.
31
+
32
+ Kamal cria .kamal/hooks/post-deploy; Capistrano cria lib/capistrano/tasks/chronos_deploy.rake após deploy:published, localmente uma vez. Não combine com chronos/capistrano. Os quatro CI criam fragmentos em config/chronos/deploy: faça merge no job real depois da publicação bem sucedida, exportando identidade e revisão do artefato efetivamente publicado. O template GitLab é oculto e precisa ser incorporado ao job existente.
33
+
34
+ Aplicações antigas continuam usando API/receitas existentes até confirmar a capability da versão instalada. O SaaS deve selecionar a receita por faixa explícita de versões, sem remover scripts customizados. Publicação, backport e promoção dependem dos gates em docs/adr/legacy-deploy-cli.md.
35
+
36
+ Preparação de testes: [guia Docker para macOS](legacy-test-environment.md). Não é necessário instalar Rubies antigos ou alterar o Bundler global do Mac.
@@ -0,0 +1,59 @@
1
+ # Preparar o ambiente de testes legado no macOS
2
+
3
+ Este procedimento testa a trilha `legacy/1`. Não publica a gem nem usa credenciais reais do SaaS.
4
+
5
+ ## 1. Iniciar Docker Desktop
6
+
7
+ Instale ou atualize a edição correspondente ao processador do Mac, conforme a [documentação oficial](https://docs.docker.com/desktop/setup/install/mac-install/). Abra o aplicativo e aguarde o engine iniciar:
8
+
9
+ ```sh
10
+ open -a Docker
11
+ docker version
12
+ docker info
13
+ ```
14
+
15
+ `docker version` precisa mostrar Client e Server. Se aparecer erro no socket ou “Cannot connect”, o engine ainda não está disponível. Confirme o contexto com `docker context ls`; selecione o contexto local do Docker Desktop caso outro contexto esteja ativo.
16
+
17
+ ## 2. Entrar no repositório e executar o primeiro Ruby
18
+
19
+ ```sh
20
+ cd /Users/antoniojefferson/Develop/Projects/Rails/chronos-ruby
21
+ sh script/test_legacy_docker 2.2.10
22
+ ```
23
+
24
+ O helper usa linux/amd64 explicitamente para uniformizar as imagens antigas em Mac Intel e Apple Silicon. A [documentação de plataformas do Docker](https://docs.docker.com/build/building/multi-platform/) explica a emulação; ela pode ser mais lenta.
25
+
26
+ O repositório é montado somente para leitura. O helper copia os arquivos para /work no container, exclui configuração local do Bundler, .git, .aws e .env, e remove somente os lockfiles dessa cópia. Isso é necessário porque o lockfile atual do repositório foi gerado com ferramentas modernas e não corresponde ao gemspec legado. Nenhum lockfile local é alterado.
27
+
28
+ O container instala Bundler 1.17.3 e resolve as dependências do gemspec, incluindo Rake 12.3, RuboCop 0.49 e Minitest 5.10.3. O script executa a suíte existente, os testes da CLI/adaptadores/receptor HTTPS e o smoke da gem instalada. Certificado de teste é gerado temporariamente e explicitamente confiado; verificação TLS não é desligada.
29
+
30
+ ## 3. Executar a matriz Ruby completa
31
+
32
+ ```sh
33
+ for version in 2.2.10 2.3.8 2.4.10 2.5.9 2.6.10; do
34
+ sh script/test_legacy_docker "$version" || break
35
+ done
36
+ ```
37
+
38
+ Pare no primeiro erro e preserve o log. Uma execução interrompida não aprova os demais Rubies. Registre por imagem: versão Ruby, OpenSSL/JSON, resultado da suíte e resultado do pacote instalado.
39
+
40
+ ## 4. Executar os pares Rails suportados
41
+
42
+ ```sh
43
+ sh script/test_legacy_docker 2.2.10 examples/rails-4.2
44
+ sh script/test_legacy_docker 2.3.8 examples/rails-4.2
45
+ sh script/test_legacy_docker 2.5.9 examples/rails-5.2
46
+ sh script/test_legacy_docker 2.6.10 examples/rails-5.2
47
+ ```
48
+
49
+ Esses comandos executam o smoke existente da aplicação e o novo smoke do generator de deploy, em diretório temporário. Eles não ampliam os pares suportados.
50
+
51
+ ## 5. Tratar falhas de preparação
52
+
53
+ - Falha no pull ou acesso a rubygems.org: verificar conexão, proxy e disponibilidade do registro.
54
+ - Erro de certificado/OpenSSL nas imagens antigas: registrar imagem e mensagem, corrigir CA ou preparar imagem derivada com certificados confiáveis compatíveis. Não usar HTTP, ssl_verify=false ou mirrors sem validação TLS.
55
+ - Dependência incompatível: identificar a dependência e fixar versão compatível na definição da imagem/matriz antes de repetir. Não modernizar o gemspec legado para fazer o teste passar.
56
+ - Erro de arquitetura: confirmar execução linux/amd64 e suporte de emulação no Docker Desktop.
57
+ - Erro RuboCop/RSpec/CLI: é resultado da validação do código, não falha de instalação; corrigir antes de promover a release.
58
+
59
+ O helper prepara execuções isoladas, mas ainda não substitui imagens reproduzíveis fixadas por digest e dependências resolvidas por runtime. A aprovação final da matriz deve preservar esses registros. Depois dos gates locais, permanecem os testes SaaS de receipt/ingestão/ledger/projeção e os smokes autorizados dos fornecedores.
@@ -4,7 +4,7 @@ Version `0.6.0.pre.1` starts the legacy jobs line with optional Sidekiq 4 and 5
4
4
 
5
5
  ```ruby
6
6
  gem "sidekiq", "~> 5.0"
7
- gem "chronos-ruby", "~> 1.1.3", :require => "chronos/rails"
7
+ gem "chronos-ruby", "~> 1.1.4", :require => "chronos/rails"
8
8
  ```
9
9
 
10
10
  Plain Ruby applications continue to use `require "chronos/sidekiq"` explicitly. Both entry points install middleware through the public `configure_client` and `configure_server` APIs. They do nothing when Sidekiq is unavailable, and the framework-independent core never requires Sidekiq. Installation adds no Chronos thread or Redis/database connection per job; delivery continues through the agent's existing fixed worker pool.
@@ -0,0 +1,44 @@
1
+ # Version 1.1.4 release preparation
2
+
3
+ Version `1.1.4` prepares deploy automation for the stable legacy line. It packages the `chronos` CLI, explicit ENV/YAML configuration, optional provider installers and Rails/Rake adapters. It preserves Ruby >= 2.2.10 and < 2.7, the existing Rails/Sidekiq pairs, and protocol `schema_version: "1.0"`. This is a limited maintenance exception; it does not reopen general feature development on the legacy line.
4
+
5
+ ## Operational contract
6
+
7
+ - No notification, generator execution, or application file changes on install, require, help, or version.
8
+ - Notify requires explicit project credentials, environment, stable publication ID, and actual published revision/version. It uses `Chronos.notify_deploy` and always closes the agent.
9
+ - Verify reuses `Chronos.verify_integration`, validates its receipt, disables dependency reporting, and does not create a deploy. A receipt is not proof of SaaS projection.
10
+ - CLI transport requires verified HTTPS. Retries and backlog are disabled for this short-lived process; transport and flush operations have bounded timeouts, not a single global process deadline.
11
+ - Exit codes: 0 confirmed/local success, 1 invalid configuration, 2 unconfirmed delivery, 3 internal failure. Warning policy only converts code 2 to 0 and retains `success=false`.
12
+ - Repeated notify calls keep deployment identity but can generate another event and consumption. Dependency events can add consumption. Do not retry blindly after an unconfirmed result.
13
+ - Installers preserve conflicting files, offer a privacy-safe proposed template, and reject destination symlinks. POSIX descriptor-relative operations prevent parent-path swaps from redirecting creation or publication. CI fragments require manual merge after the actual publication.
14
+
15
+ ## Evidence and pending gates
16
+
17
+ Local Ruby 3.4 checks are supplementary evidence only. They do not establish compatibility with the supported legacy runtimes.
18
+
19
+ Before tagging, require:
20
+
21
+ - core suite, CLI, transport, privacy, shutdown/fork, and RuboCop 0.49 gates on Ruby 2.2.10, 2.3.8, 2.4.10, 2.5.9, and 2.6.10;
22
+ - Rails 4.2 on Ruby 2.2.10/2.3.8 and Rails 5.2 on Ruby 2.5.9/2.6.10, including deploy generator smoke;
23
+ - Sidekiq 4.2.10 on Ruby 2.2.10 and Sidekiq 5.2.10 on Ruby 2.5.9;
24
+ - built gem installed into clean supported runtimes, executable/help/offline diagnostics and HTTPS notification/receipt tests;
25
+ - dependency audit with the CI-pinned tooling, documentation and PR-body verification;
26
+ - query-analysis/Rack benchmarks and fake-endpoint load/privacy gates;
27
+ - authorized provider smoke tests and SaaS evidence for receipt, ingestion, identity, consumption, and projection; keep these distinct;
28
+ - reviewed source, package inspection, checksum, SPDX SBOM, and maintainer approval.
29
+
30
+ Use the [Docker test instructions](../README.md#tests). The working-tree lockfile originated from modern tooling; the isolated Docker helper resolves dependencies only in its container copy. It does not prove the direct CI workflow's dependency resolution will pass; that remains a required check.
31
+
32
+ ## SaaS rollout and transitional backport
33
+
34
+ The CLI capability is introduced by legacy release `1.1.4`. The transitional main-line backport is not included in this release. The SaaS must select recipes using explicitly validated release ranges for each line, never a single cross-line minimum. Keep old script recipes for older clients and do not delete customer-edited scripts.
35
+
36
+ ## Publication
37
+
38
+ No release has been published by this preparation. Never replace previously published artifacts. After all gates and approval, the release tag must be exactly `v1.1.4` and match `Chronos::VERSION`. Pushing that tag triggers the release workflow and its publication job; do not create or push it before approval. Publish through the configured Trusted Publishing workflow rather than a manual gem push.
39
+
40
+ ## Security regression coverage
41
+
42
+ `spec/security/` covers SQL literal confidentiality through the final APM envelope, complete original-query validation before inspection, sensitive symbols through final serialization, cyclic/deep diagnostic input and caller ownership, and deterministic symlink/concurrent-file interleavings. Conservative SQL handling omits oversized or ambiguous queries; plan evidence remains best effort. The installer uses fixed POSIX calls through standard-library Fiddle and does not execute shell commands. Unsupported filesystem backends must fail closed.
43
+
44
+ Security-fix validation: the core/security suite passed on MRI 2.2.10, 2.3.8, 2.4.10, 2.5.9 and 2.6.10 in isolated Linux/amd64 containers: 221 RSpec examples, the complete RuboCop 0.49.1 inspection (225 files), 25 standalone source tests (127 assertions), gem build/install, local diagnostics, and six installed-executable HTTPS tests (36 assertions) per runtime. The expanded installed-gem smoke also verifies explicit Kamal hook generation and idempotent check in a path containing spaces. These results do not close the separate Rails/Sidekiq, supplier, SaaS, audit, benchmark, or remote CI gates.
data/exe/chronos ADDED
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env ruby
2
+ require "chronos/cli"
3
+ exit Chronos::CLI.new.run(ARGV)
data/lib/chronos/agent.rb CHANGED
@@ -242,12 +242,17 @@ module Chronos
242
242
  end
243
243
 
244
244
  def context_for_capture(additional)
245
- merged = deep_merge(context_hash(@context_store.get), context_hash(additional))
245
+ current = context_hash(@context_store.get)
246
+ additions = context_hash(additional)
247
+ buffer = breadcrumb_buffer(additions) || breadcrumb_buffer(current)
248
+ copier = Core::BoundedCopy.new
249
+ merged = deep_merge(copier.call(current), copier.call(additions))
246
250
  merged.delete(:__chronos_captured_exceptions)
247
251
  merged.delete("__chronos_captured_exceptions")
248
252
  merged.delete(:__chronos_captured_events)
249
253
  merged.delete("__chronos_captured_events")
250
- buffer = merged.delete(:__chronos_breadcrumbs) || merged.delete("__chronos_breadcrumbs")
254
+ merged.delete(:__chronos_breadcrumbs)
255
+ merged.delete("__chronos_breadcrumbs")
251
256
  if buffer.respond_to?(:to_a)
252
257
  merged[:context] = context_hash(merged[:context]).merge("breadcrumbs" => buffer.to_a)
253
258
  end
@@ -256,9 +261,13 @@ module Chronos
256
261
  context_hash(additional)
257
262
  end
258
263
 
264
+ def breadcrumb_buffer(context)
265
+ context[:__chronos_breadcrumbs] || context["__chronos_breadcrumbs"]
266
+ end
267
+
259
268
  def telemetry_context(additional)
260
269
  merged = context_for_capture(additional)
261
- context = context_hash(merged.delete(:context) || merged.delete("context"))
270
+ context = context_hash(merged.delete(:context) || merged.delete("context")).dup
262
271
  parameters = merged.delete(:parameters) || merged.delete("parameters")
263
272
  user = merged.delete(:user) || merged.delete("user")
264
273
  context["parameters"] = parameters if parameters.is_a?(Hash) && !parameters.empty?
@@ -266,9 +275,11 @@ module Chronos
266
275
  context.merge(merged)
267
276
  end
268
277
 
269
- def deep_merge(left, right)
278
+ def deep_merge(left, right, depth = 0)
279
+ return {} if depth >= Core::BoundedCopy::MAX_DEPTH
280
+
270
281
  left.merge(right) do |_key, old_value, new_value|
271
- old_value.is_a?(Hash) && new_value.is_a?(Hash) ? deep_merge(old_value, new_value) : new_value
282
+ old_value.is_a?(Hash) && new_value.is_a?(Hash) ? deep_merge(old_value, new_value, depth + 1) : new_value
272
283
  end
273
284
  end
274
285
 
@@ -0,0 +1,117 @@
1
+ require "optparse"
2
+ require "json"
3
+ require "chronos/version"
4
+ require "chronos/deploy/configuration"
5
+ require "chronos/deploy/installer"
6
+
7
+ module Chronos
8
+ # @responsibility Parse explicit deployment commands and return sanitized operational results.
9
+ # Local command parser; operational responses use a versioned, allowlisted JSON envelope.
10
+ class CLI
11
+ HELP = "chronos deploy notify|verify|diagnose --offline|install --provider NAME\n" \
12
+ "Options: --root PATH, --project-id ID, --environment NAME, --service-name NAME,\n" \
13
+ "--deploy-id ID, --revision SHA, --version VERSION, --host URL, --timeout SECONDS,\n" \
14
+ "--json, --on-error=warn, --dry-run, --check, --force\n" \
15
+ "Project key: CHRONOS_PROJECT_KEY only. No application boot.\n" \
16
+ "Exit: 0 confirmed/local success; 1 invalid configuration; 2 unconfirmed delivery; 3 internal failure.\n" \
17
+ "warn returns 0 for unconfirmed delivery, retaining success=false. Never retry blindly.\n".freeze
18
+
19
+ def initialize(output = $stdout, env = ENV)
20
+ @output = output
21
+ @env = env
22
+ end
23
+
24
+ def run(arguments)
25
+ args = arguments.dup
26
+ return local(HELP) if help?(args)
27
+ return local(Chronos::VERSION) if args == ["--version"]
28
+ raise ArgumentError unless args.shift == "deploy"
29
+
30
+ command = args.shift
31
+ raise ArgumentError unless %w(notify verify diagnose install).include?(command)
32
+
33
+ options = parse_options(args)
34
+ return local(HELP) if options[:help]
35
+ raise ArgumentError unless args.empty? && options[:timeout] > 0 && options[:timeout] <= 60
36
+
37
+ validate_options!(command, options)
38
+ execute(command, options)
39
+ rescue Deploy::Error => e
40
+ result(false, "invalid_configuration_or_usage", 1, e.details.merge(:message => e.message))
41
+ rescue OptionParser::ParseError, ArgumentError, Psych::Exception
42
+ result(false, "invalid_configuration_or_usage", 1,
43
+ :message => "Invalid options or public configuration; see chronos --help.")
44
+ rescue StandardError
45
+ result(false, "internal_failure", 3)
46
+ end
47
+
48
+ private
49
+
50
+ def help?(args)
51
+ args.empty? || %w(help --help -h).include?(args.first)
52
+ end
53
+
54
+ def parse_options(args)
55
+ options = {:timeout => 5.0}
56
+ parser = OptionParser.new
57
+ %w(root project-id environment service-name deploy-id revision version host provider).each do |name|
58
+ parser.on("--#{name} VALUE") { |value| options[name.tr("-", "_").to_sym] = value }
59
+ end
60
+ %w(json offline dry-run check force).each do |name|
61
+ parser.on("--#{name}") { options[name.tr("-", "_").to_sym] = true }
62
+ end
63
+ parser.on("--timeout SECONDS", Float) { |value| options[:timeout] = value }
64
+ parser.on("--on-error POLICY", %w(warn fail)) { |value| options[:on_error] = value }
65
+ parser.on("--help") { options[:help] = true }
66
+ parser.parse!(args)
67
+ options
68
+ end
69
+
70
+ def execute(command, options)
71
+ return result(true, Deploy::Installer.new.install(options), 0) if command == "install"
72
+
73
+ values = Deploy::Configuration.read(options, @env)
74
+ if command == "diagnose"
75
+ raise Deploy::Error, "Diagnose requires --offline." unless options[:offline]
76
+
77
+ Deploy::Configuration.validate!(values, "notify")
78
+ return result(true, "local_configuration_only", 0,
79
+ :message => "Local configuration is complete; credentials and delivery were not authenticated.")
80
+ end
81
+ deliver(command, values, options)
82
+ end
83
+
84
+ def deliver(command, values, options)
85
+ require "chronos/deploy/runner"
86
+ delivery = Deploy::Runner.new.call(command, values, options[:timeout])
87
+ success = command == "verify" ? delivery.success? : delivery == true
88
+ details = command == "verify" ? {:verification_status => delivery.status} : {}
89
+ code = success || options[:on_error] == "warn" ? 0 : 2
90
+ result(success, success ? "confirmed" : "unconfirmed", code, details)
91
+ end
92
+
93
+ def validate_options!(command, options)
94
+ common = [:root, :json, :help]
95
+ operational = [:project_id, :environment, :service_name, :deploy_id, :revision, :version, :host, :timeout]
96
+ allowed = command == "install" ? common + [:provider, :dry_run, :check, :force, :timeout] : common + operational
97
+ allowed += [:offline] if command == "diagnose"
98
+ allowed += [:on_error] if %w(notify verify).include?(command)
99
+ unless (options.keys - allowed).empty?
100
+ raise Deploy::Error, "Option does not apply to this command; see chronos --help."
101
+ end
102
+ raise Deploy::Error, "Use --check without --force." if options[:force] && options[:check]
103
+ end
104
+
105
+ def local(message)
106
+ @output.puts(message)
107
+ 0
108
+ end
109
+
110
+ def result(success, state, code, details = {})
111
+ data = {:schema_version => 1, :success => success, :state => state}.merge(details)
112
+ # JSON is the default operational format; --json makes that intent explicit.
113
+ @output.puts(JSON.generate(data))
114
+ code
115
+ end
116
+ end
117
+ end
@@ -0,0 +1,69 @@
1
+ module Chronos
2
+ module Core
3
+ # @responsibility Copy untrusted diagnostic trees without cycles, unbounded recursion or caller mutation.
4
+ # Ruby 2.2-compatible structural budgets apply before immutable event construction.
5
+ class BoundedCopy
6
+ MAX_DEPTH = 10
7
+ MAX_ITEMS = 100
8
+ MAX_NODES = 2_000
9
+ MAX_STRING_BYTES = 8192
10
+
11
+ def call(value)
12
+ copy(value, 0, :nodes => 0, :seen => {}.compare_by_identity)
13
+ end
14
+
15
+ private
16
+
17
+ def copy(value, depth, state)
18
+ state[:nodes] += 1
19
+ return "<node limit reached>".freeze if state[:nodes] > MAX_NODES
20
+ return "<maximum depth reached>".freeze if depth >= MAX_DEPTH
21
+ return container(value, depth, state) if value.is_a?(Hash) || value.is_a?(Array)
22
+
23
+ scalar(value)
24
+ end
25
+
26
+ def container(value, depth, state)
27
+ return "<circular reference>".freeze if state[:seen][value]
28
+
29
+ state[:seen][value] = true
30
+ result = value.is_a?(Hash) ? copy_hash(value, depth, state) : copy_array(value, depth, state)
31
+ state[:seen].delete(value)
32
+ result.freeze
33
+ end
34
+
35
+ def copy_hash(value, depth, state)
36
+ result = {}
37
+ value.each do |key, child|
38
+ break if result.length >= MAX_ITEMS || state[:nodes] >= MAX_NODES
39
+
40
+ result[copy(key, depth + 1, state)] = copy(child, depth + 1, state)
41
+ end
42
+ result
43
+ end
44
+
45
+ def copy_array(value, depth, state)
46
+ result = []
47
+ value.each do |child|
48
+ break if result.length >= MAX_ITEMS || state[:nodes] >= MAX_NODES
49
+
50
+ result << copy(child, depth + 1, state)
51
+ end
52
+ result
53
+ end
54
+
55
+ def scalar(value)
56
+ case value
57
+ when String
58
+ return "<string limit reached>".freeze if value.bytesize > MAX_STRING_BYTES
59
+
60
+ value.dup.freeze
61
+ when NilClass, TrueClass, FalseClass, Symbol, Numeric
62
+ value
63
+ else
64
+ "<#{value.class.name}>".freeze
65
+ end
66
+ end
67
+ end
68
+ end
69
+ end
@@ -1,3 +1,5 @@
1
+ require "chronos/core/bounded_copy"
2
+
1
3
  module Chronos
2
4
  module Core
3
5
  # Immutable value object containing a normalized Ruby exception.
@@ -22,8 +24,9 @@ module Chronos
22
24
  attr_reader(*ATTRIBUTES)
23
25
 
24
26
  def initialize(attributes)
27
+ copied = BoundedCopy.new.call(attributes)
25
28
  ATTRIBUTES.each do |attribute|
26
- instance_variable_set("@#{attribute}", immutable_copy(attributes[attribute]))
29
+ instance_variable_set("@#{attribute}", copied[attribute])
27
30
  end
28
31
  freeze
29
32
  end
@@ -33,33 +36,6 @@ module Chronos
33
36
  result[attribute] = public_send(attribute)
34
37
  end
35
38
  end
36
-
37
- private
38
-
39
- def immutable_copy(value)
40
- case value
41
- when Hash
42
- value.each_with_object({}) do |(key, child), copy|
43
- copy[immutable_copy(key)] = immutable_copy(child)
44
- end
45
- when Array
46
- value.map { |child| immutable_copy(child) }
47
- when String
48
- value.dup
49
- when NilClass, TrueClass, FalseClass, Symbol, Numeric
50
- value
51
- else
52
- value.dup
53
- end.freeze
54
- rescue TypeError
55
- value
56
- rescue StandardError
57
- begin
58
- value.dup.freeze
59
- rescue StandardError
60
- value
61
- end
62
- end
63
39
  end
64
40
  end
65
41
  end
@@ -53,7 +53,7 @@ module Chronos
53
53
  def sanitize_type(value, depth, state)
54
54
  return sanitize_hash(value, depth, state) if value.is_a?(Hash)
55
55
  return sanitize_array(value, depth, state) if value.is_a?(Array)
56
- return @sensitive_value_filter.call(value) if value.is_a?(String)
56
+ return @sensitive_value_filter.call(value.to_s) if value.is_a?(String) || value.is_a?(Symbol)
57
57
 
58
58
  value
59
59
  end
@@ -0,0 +1,73 @@
1
+ require "strscan"
2
+
3
+ module Chronos
4
+ module Core
5
+ # @responsibility Mask SQL literals before truncation and reject incomplete or ambiguous lexical input.
6
+ # Conservative lexical validation is not a dialect-complete SQL parser.
7
+ class SqlLexer
8
+ MAX_BYTES = 4096
9
+
10
+ def initialize(sql, adapter = "")
11
+ @sql = sql
12
+ @postgres = adapter.to_s =~ /postgres/i
13
+ end
14
+
15
+ def call
16
+ return nil unless @sql.is_a?(String) && @sql.valid_encoding? && @sql.bytesize <= MAX_BYTES
17
+
18
+ @scanner = StringScanner.new(@sql)
19
+ result = ""
20
+ until @scanner.eos?
21
+ token = next_token
22
+ return nil if token.nil?
23
+
24
+ result << token
25
+ end
26
+ result
27
+ end
28
+
29
+ def single_select?
30
+ text = call
31
+ return false unless text && text.lstrip =~ /\ASELECT\b/i
32
+
33
+ text.sub(/;\s*\z/, "") !~ /;/
34
+ end
35
+
36
+ private
37
+
38
+ def next_token
39
+ return " " if @scanner.scan(/\s+|--[^\r\n]*|\#[^\r\n]*/)
40
+ return block_comment if @scanner.peek(2) == "/*"
41
+ return quoted if ["'", '"', "`"].include?(@scanner.peek(1))
42
+ return dollar_literal if @scanner.check(/\$(?:[A-Za-z_][A-Za-z0-9_]*)?\$/)
43
+
44
+ @scanner.getch
45
+ end
46
+
47
+ def block_comment
48
+ text = @scanner.scan(%r{/\*.*?\*/}m)
49
+ return nil unless text
50
+ return nil if text.start_with?("/*!", "/*M!") || text[2...-2].include?("/*")
51
+
52
+ " "
53
+ end
54
+
55
+ def quoted
56
+ quote = @scanner.peek(1)
57
+ delimiter = Regexp.escape(quote)
58
+ pattern = /#{delimiter}(?:#{delimiter * 2}|[^#{delimiter}\\])*#{delimiter}/m
59
+ text = @scanner.scan(pattern)
60
+ return nil unless text
61
+ return "?" if quote == "'" || (quote == '"' && !@postgres)
62
+
63
+ identifier = text[1...-1]
64
+ identifier =~ /\A[A-Za-z_][A-Za-z0-9_$]*\z/ ? text : "?"
65
+ end
66
+
67
+ def dollar_literal
68
+ delimiter = @scanner.scan(/\$(?:[A-Za-z_][A-Za-z0-9_]*)?\$/)
69
+ @scanner.scan_until(/#{Regexp.escape(delimiter)}/m) ? "?" : nil
70
+ end
71
+ end
72
+ end
73
+ end
@@ -1,4 +1,5 @@
1
1
  require "digest"
2
+ require "chronos/core/sql_lexer"
2
3
 
3
4
  module Chronos
4
5
  module Core
@@ -15,7 +16,7 @@ module Chronos
15
16
  # @errors Malformed or unreadable input returns bounded unknown metadata.
16
17
  # @performance Input and normalized output are capped before fingerprinting.
17
18
  class SqlNormalizer
18
- MAX_SQL_BYTES = 4096
19
+ MAX_SQL_BYTES = SqlLexer::MAX_BYTES
19
20
  MAX_NORMALIZED_BYTES = 512
20
21
  KEYWORDS = %w(
21
22
  select insert update delete from into where join left right inner outer on set values
@@ -23,7 +24,7 @@ module Chronos
23
24
  ).freeze
24
25
 
25
26
  def call(sql, metadata = {})
26
- normalized = normalize(sql)
27
+ normalized = normalize(sql, metadata)
27
28
  {
28
29
  "adapter" => adapter(metadata), "operation" => operation(normalized),
29
30
  "table" => table(normalized), "normalized_query" => normalized,
@@ -41,11 +42,10 @@ module Chronos
41
42
 
42
43
  private
43
44
 
44
- def normalize(sql)
45
- text = bounded(sql.to_s, MAX_SQL_BYTES)
46
- text = text.gsub(%r{/\*.*?\*/}m, " ").gsub(/--[^\r\n]*/, " ")
47
- text = text.gsub(/'(?:''|[^'])*'/, "?")
48
- text = text.gsub(/\$([A-Za-z_][A-Za-z0-9_]*)?\$.*?\$\1\$/m, "?")
45
+ def normalize(sql, metadata)
46
+ text = SqlLexer.new(sql, adapter(metadata)).call
47
+ return "" unless text
48
+
49
49
  text = text.gsub(/\b(?:0x[0-9a-f]+|\d+(?:\.\d+)?)\b/i, "?")
50
50
  text = text.gsub(/\b(?:true|false|null)\b/i, "?")
51
51
  text = text.gsub(/\(\s*\?(?:\s*,\s*\?)+\s*\)/, "(?)")
@@ -1,5 +1,6 @@
1
1
  require "securerandom"
2
2
  require "time"
3
+ require "chronos/core/bounded_copy"
3
4
 
4
5
  module Chronos
5
6
  module Core
@@ -21,31 +22,17 @@ module Chronos
21
22
  attr_reader :event_id, :event_type, :timestamp, :context, :payload
22
23
 
23
24
  def initialize(event_type, payload = {}, context = {}, options = {})
24
- type = event_type.to_s
25
+ type = event_type.to_s.dup
25
26
  raise ArgumentError, "unsupported telemetry event type" unless TYPES.include?(type)
26
27
 
27
28
  clock = options[:clock] || proc { Time.now }
28
- @event_id = (options[:event_id] || SecureRandom.uuid).to_s.freeze
29
+ @event_id = (options[:event_id] || SecureRandom.uuid).to_s.dup.freeze
29
30
  @event_type = type.freeze
30
31
  @timestamp = clock.call.utc.iso8601(6).freeze
31
- @context = deep_freeze(context.is_a?(Hash) ? context : {})
32
- @payload = deep_freeze(payload.is_a?(Hash) ? payload : {})
32
+ @context = BoundedCopy.new.call(context.is_a?(Hash) ? context : {})
33
+ @payload = BoundedCopy.new.call(payload.is_a?(Hash) ? payload : {})
33
34
  freeze
34
35
  end
35
-
36
- private
37
-
38
- def deep_freeze(value)
39
- if value.is_a?(Hash)
40
- value.each do |key, child|
41
- deep_freeze(key)
42
- deep_freeze(child)
43
- end
44
- elsif value.is_a?(Array)
45
- value.each { |child| deep_freeze(child) }
46
- end
47
- value.freeze
48
- end
49
36
  end
50
37
 
51
38
  # Serializes framework telemetry into the common Chronos event envelope.