diff --git a/CHANGELOG.md b/CHANGELOG.md index 305c7b8..c068848 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,13 @@ All notable changes to Bootprint are documented here. The project follows Semant ## Unreleased +## 0.5.0 - 2026-08-14 + +### Added + +- Capture timezone, default encodings, and locale metadata on the operating-system fingerprint. +- Built-in rules `timezone-drift`, `encoding-drift`, and `locale-drift` for those fields. + ## 0.4.0 - 2026-08-11 ### Added diff --git a/Gemfile.lock b/Gemfile.lock index 4e3ff7c..2d17bda 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,7 +1,7 @@ PATH remote: . specs: - bootprint (0.4.0) + bootprint (0.5.0) GEM remote: https://rubygems.org/ @@ -57,7 +57,7 @@ DEPENDENCIES CHECKSUMS ast (2.4.3) sha256=954615157c1d6a382bc27d690d973195e79db7f55e9765ac7c481c60bdb4d383 - bootprint (0.4.0) + bootprint (0.5.0) bundler (4.0.17) sha256=214e21431b5665dd2f99df8a5511c6b151d7a72e8015c8b38f8b775b61cbb6c1 json (2.21.2) sha256=1f1d3b7cf2b3ba1a69beca0bb6db13d5438b80bff3cd54cdaaa620b9b07c1c6a json (2.21.2-java) sha256=f01521cd90639cc26b63040fb00d2b949f18f41ecaab5c82b02fb31d0b8b736c diff --git a/README.md b/README.md index 1fb71bb..07cef66 100644 --- a/README.md +++ b/README.md @@ -56,7 +56,7 @@ Bootprint 0.2 combines a dependency compatibility analyzer, Rails boot inspector `Gemfile.lock` captures dependency resolution, but not the complete runtime contract. Ruby engine and patch level, native clients, libc, CPU architecture, Rails adapters, required configuration, filesystem behavior, and initializer ordering can all change application behavior. -Bootprint records those facts as deterministic schema-v2 JSON and evaluates them with 39 independently testable built-in rules. A finding answers four questions that a plain diff cannot: +Bootprint records those facts as deterministic schema-v2 JSON and evaluates them with 42 independently testable built-in rules. A finding answers four questions that a plain diff cannot: 1. What changed? 2. How dangerous is it? @@ -79,7 +79,7 @@ ERROR Required environment variable is missing | Capability | Bootprint 0.2 | |---|---| -| Diagnostic knowledge | 39 built-in rules across runtime, dependencies, native libraries, configuration, filesystem, and Rails boot | +| Diagnostic knowledge | 42 built-in rules across runtime, dependencies, native libraries, configuration, filesystem, locale, and Rails boot | | Severity model | `info`, `warning`, `error`, `critical` | | Report formats | Human terminal output, JSON, SARIF 2.1, Markdown | | Snapshot contract | Deterministic schema v2 with in-memory v1 migration | @@ -97,6 +97,7 @@ ERROR Required environment variable is missing | Native libraries | OpenSSL, libyaml, SQLite, PostgreSQL, MySQL, libc, compiler and header availability when detectable | | Configuration | Environment-variable names and presence, Rails environment, adapters, framework settings | | Filesystem | Temporary/log path availability, writability, path separators, case sensitivity, symlink behavior | +| Locale | Timezone name and UTC offset, default internal/external encodings, LANG / LC_ALL / charmap | | Rails boot | Framework configuration, autoload/eager-load paths, initializers, and opt-in initializer timings | | Docker | Image runtime, platforms, installed package metadata when available, workdir, entrypoint, command, permissions | diff --git a/lib/bootprint/collectors/operating_system.rb b/lib/bootprint/collectors/operating_system.rb index ecec5ec..8645671 100644 --- a/lib/bootprint/collectors/operating_system.rb +++ b/lib/bootprint/collectors/operating_system.rb @@ -19,7 +19,10 @@ def capture "processors" => Etc.nprocessors, "capabilities" => TOOLS.to_h { |tool| [tool, executable?(tool)] }, "ruby_headers" => header_state, - "ci" => ci_provider + "ci" => ci_provider, + "timezone" => timezone_state, + "encoding" => encoding_state, + "locale" => locale_state } end @@ -45,6 +48,30 @@ def ci_provider nil end + + def timezone_state + now = Time.now + { + "name" => now.zone, + "utc_offset" => now.utc_offset, + "tz" => ENV.fetch("TZ", nil) + } + end + + def encoding_state + { + "external" => Encoding.default_external.name, + "internal" => Encoding.default_internal&.name + } + end + + def locale_state + { + "lang" => ENV.fetch("LANG", nil), + "lc_all" => ENV.fetch("LC_ALL", nil), + "charmap" => Encoding.locale_charmap + } + end end end end diff --git a/lib/bootprint/rules/builtin.rb b/lib/bootprint/rules/builtin.rb index aa5c768..400229d 100644 --- a/lib/bootprint/rules/builtin.rb +++ b/lib/bootprint/rules/builtin.rb @@ -58,6 +58,24 @@ def runtime_rules fix: "Use a standard release build for production.") do |_source, target| dig(target, "runtime.debug_build") == true && { "description" => dig(target, "runtime.description") } end + rule("timezone-drift", "Timezone mismatch", :runtime, :warning, + cause: "The environments select different timezones.", + impact: "Time.now, cron, and timestamp formatting can disagree across machines.", + fix: "Set TZ consistently in local, container, and CI environments.") do |source, target| + difference(source, target, "operating_system.timezone") + end + rule("encoding-drift", "Default encoding mismatch", :runtime, :warning, + cause: "Ruby default encodings differ between environments.", + impact: "String transcoding, file IO, and JSON payloads can fail only in one environment.", + fix: "Align Encoding.default_external (and LANG/LC_ALL) with the deployment runtime.") do |source, target| + difference(source, target, "operating_system.encoding") + end + rule("locale-drift", "Locale mismatch", :runtime, :info, + cause: "LANG / LC_ALL / locale charmap differ between environments.", + impact: "Sort order, number formatting, and encoding defaults can change.", + fix: "Export the same LANG and LC_ALL in every environment.") do |source, target| + difference(source, target, "operating_system.locale") + end end def dependency_rules diff --git a/lib/bootprint/version.rb b/lib/bootprint/version.rb index a70c803..9fdabba 100644 --- a/lib/bootprint/version.rb +++ b/lib/bootprint/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module Bootprint - VERSION = "0.4.0" + VERSION = "0.5.0" end diff --git a/test/locale_capture_test.rb b/test/locale_capture_test.rb new file mode 100644 index 0000000..dfce54a --- /dev/null +++ b/test/locale_capture_test.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +require "test_helper" + +class LocaleCaptureTest < Minitest::Test + def test_operating_system_records_timezone_encoding_and_locale + captured = Bootprint::Collectors::OperatingSystem.capture + + refute_nil captured["timezone"]["utc_offset"] + refute_empty captured["encoding"]["external"] + assert captured["locale"].key?("charmap") + end + + def test_timezone_encoding_and_locale_drift_rules + source = snapshot("environment" => { "operating_system" => os_payload("UTC", "UTF-8", "C") }) + target = snapshot("environment" => { "operating_system" => os_payload("EST", "Windows-1252", "en_US.UTF-8") }) + report = Bootprint::Diagnosis.new(source, target).run + + ids = report.findings.map(&:rule_id) + assert_includes ids, "timezone-drift" + assert_includes ids, "encoding-drift" + assert_includes ids, "locale-drift" + end + + def test_matching_locale_metadata_does_not_fire + payload = os_payload("UTC", "UTF-8", "C") + source = snapshot("environment" => { "operating_system" => payload }) + target = snapshot("environment" => { "operating_system" => payload.dup }) + report = Bootprint::Diagnosis.new(source, target).run + + ids = report.findings.map(&:rule_id) + refute_includes ids, "timezone-drift" + refute_includes ids, "encoding-drift" + refute_includes ids, "locale-drift" + end + + private + + def os_payload(zone, encoding, lang) + { + "name" => "linux", + "cpu" => "x86_64", + "capabilities" => { "make" => true }, + "ruby_headers" => { "present" => true }, + "timezone" => { "name" => zone, "utc_offset" => zone == "UTC" ? 0 : -18_000, "tz" => zone }, + "encoding" => { "external" => encoding, "internal" => nil }, + "locale" => { "lang" => lang, "lc_all" => nil, "charmap" => encoding } + } + end +end