Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions lib/fluent/plugin/parser.rb
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,12 @@ def build_type_converters(types)
time_type = 'string' # estimate
timezone, time_format = option.split(':', 2)
unless Fluent::Timezone.validate(timezone)
# Treat a signed numeric token as a timezone. Re-reading the
# whole option as the time format would silently produce nil
# values instead of failing here.
if /\A[+-]\d+\z/.match?(timezone)
raise Fluent::ConfigError, "Unsupported timezone '#{timezone}' for key:'#{field_name}'"
end
timezone, time_format = nil, option
end
if Fluent::TimeMixin::TIME_TYPES.include?(time_format)
Expand Down
5 changes: 5 additions & 0 deletions lib/fluent/plugin_helper/extract.rb
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,11 @@ def configure(conf)
super

if @extract_config
# An unsupported timezone resolves to nil, and TimeParser reads that
# as "no timezone was given", so without this the event time of every
# record would silently move to the host timezone.
Fluent::Timezone.validate!(@extract_config.timezone) if @extract_config.timezone

@_extract_tag_key = @extract_config.tag_key
@_extract_keep_tag_key = @extract_config.keep_tag_key
@_extract_time_key = @extract_config.time_key
Expand Down
5 changes: 5 additions & 0 deletions lib/fluent/plugin_helper/inject.rb
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,11 @@ def configure(conf)
super

if @inject_config
# An unsupported timezone resolves to nil, and TimeFormatter reads
# that as "no timezone was given", so this is the only place where
# such a typo in the configuration can be reported.
Fluent::Timezone.validate!(@inject_config.timezone) if @inject_config.timezone

@_inject_hostname_key = @inject_config.hostname_key
if @_inject_hostname_key
if self.respond_to?(:buffer_config)
Expand Down
8 changes: 6 additions & 2 deletions lib/fluent/timezone.rb
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,8 @@ def to_time

module Fluent
class Timezone
# [+-]HH:MM, [+-]HHMM, [+-]HH
NUMERIC_PATTERN = %r{\A[+-]\d\d(:?\d\d)?\z}
# [+-]HH:MM, [+-]HHMM, [+-]HH, with HH in 00..23 and MM in 00..59
NUMERIC_PATTERN = %r{\A[+-](?:[01]\d|2[0-3])(?::?[0-5]\d)?\z}

# Region/Zone, Region/Zone/Zone
NAME_PATTERN = %r{\A[^/]+/[^/]+(/[^/]+)?\z}
Expand All @@ -46,6 +46,10 @@ class Timezone
# 4. Region/Zone (e.g. "Asia/Tokyo")
# 5. Region/Zone/Zone (e.g. "America/Argentina/Buenos_Aires")
#
# In the numeric forms, HH must be 00..23 and MM must be 00..59.
# Time.zone_offset does not check these ranges. It reads "+09:60" as
# "+10:00", and Time#localtime rejects offsets of 24 hours or more.
#
# In the 4th and 5th cases, it is checked whether the specified
# timezone exists in the timezone database.
#
Expand Down
52 changes: 52 additions & 0 deletions test/plugin/test_parser.rb
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,58 @@ def test_parse
end
end

sub_test_case '#build_type_converters time with an unsupported timezone' do
setup do
@i = ExampleParser.new
end

def types_conf(field, definition)
{ 'types' => JSON.dump(field => definition) }
end

test 'raises ConfigError instead of reading the timezone as the time format' do
# '+0960' used to mean +10:00. Falling back to the whole option as the time
# format instead made every value convert to nil in the host timezone.
assert_raise(Fluent::ConfigError.new("Unsupported timezone '+0960' for key:'date'")) do
@i.configure(config_element('parse', '', types_conf('date', 'time:+0960:%Y-%m-%d %H:%M:%S')))
end
end

test 'still converts a supported numeric timezone' do
@i.configure(config_element('parse', '', types_conf('date', 'time:+0100:%Y-%m-%d %H:%M:%S')))
c = @i.type_converters['date']
with_timezone("UTC+02") do # -0200
assert_equal_event_time event_time("2026-10-26 11:00:00 +0000"), c.call("2026-10-26 12:00:00")
end
end

test 'still converts a time format that is not a timezone' do
@i.configure(config_element('parse', '', types_conf('date', 'time:%Y-%m-%d %H:%M:%S')))
c = @i.type_converters['date']
with_timezone("UTC+02") do # -0200
assert_equal_event_time event_time("2026-10-26 12:00:00 -0200"), c.call("2026-10-26 12:00:00")
end
end

test 'parses a time format that starts with a signed numeric token' do
# A time format may start with a sign and digits, e.g. a literal "+0000".
# Such a format is not a timezone and must not be rejected.
@i.configure(config_element('parse', '', types_conf('date', 'time:+0000 %Y-%m-%d %H:%M:%S')))
c = @i.type_converters['date']
with_timezone("UTC+02") do # -0200
assert_equal_event_time event_time("2026-10-26 12:00:00 -0200"), c.call("+0000 2026-10-26 12:00:00")
end
end

test 'does not raise for an empty time definition' do
# `time:` has neither a timezone nor a format. The numeric check used to
# raise NoMethodError on nil during configure.
assert_nothing_raised do
@i.configure(config_element('parse', '', types_conf('date', 'time:')))
end
end
end

sub_test_case 'example parser without any configurations' do
setup do
@current_time = Time.parse("2016-10-21 14:22:01.0 +1000")
Expand Down
20 changes: 20 additions & 0 deletions test/plugin_helper/test_extract.rb
Original file line number Diff line number Diff line change
Expand Up @@ -190,5 +190,25 @@ def config_extract_section(hash = {})
end
assert_equal_event_time(event_time("2016-09-06 13:27:01 +0900"), t)
end

# An offset that does not resolve is read by TimeParser as "no timezone",
# which used to move the event time to the host timezone silently.
test 'raises ConfigError for a timezone out of the numeric offset range' do
c = config_extract_section("time_key" => "t", "time_type" => "string", "time_format" => "%H:%M:%S, %m/%d/%Y", "timezone" => "+0960")
assert_raise Fluent::ConfigError.new("Unsupported timezone '+0960'") do
@d.configure(c)
end
end

test 'returns EventTime object, parsed as configured time_format in a named timezone' do
t = with_timezone("UTC-02") do
c = config_extract_section("time_key" => "t", "time_type" => "string", "time_format" => "%H:%M:%S, %m/%d/%Y", "timezone" => "Asia/Tokyo")
@d.configure(c)
@d.start
@d.after_start
@d.extract_time_from_record({"t" => "13:27:01, 09/06/2016", "message" => "yay!"})
end
assert_equal_event_time(event_time("2016-09-06 13:27:01 +0900"), t)
end
end
end
25 changes: 25 additions & 0 deletions test/plugin_helper/test_inject.rb
Original file line number Diff line number Diff line change
Expand Up @@ -241,7 +241,7 @@
@d.start

record = {"key1" => "value1", "key2" => 2}
assert_equal record.merge({"timedata" => "2016_06_21 08:10:11 #{local_timezone}"}), @d.inject_values_to_record('tag', time, record)

Check failure on line 244 in test/plugin_helper/test_inject.rb

View workflow job for this annotation

GitHub Actions / Ruby 4.0 on windows-11-arm

Failure

<{"key1"=>"value1", "key2"=>2, "timedata"=>"2016_06_21 08:10:11 -0800"}> expected but was <{"key1"=>"value1", "key2"=>2, "timedata"=>"2016_06_21 09:10:11 -0700"}>. diff: - {"key1"=>"value1", "key2"=>2, "timedata"=>"2016_06_21 08:10:11 -0800"} ? ^ ^ + {"key1"=>"value1", "key2"=>2, "timedata"=>"2016_06_21 09:10:11 -0700"} ? ^ ^

Check failure on line 244 in test/plugin_helper/test_inject.rb

View workflow job for this annotation

GitHub Actions / Ruby 3.4 on windows-11-arm

Failure

<{"key1"=>"value1", "key2"=>2, "timedata"=>"2016_06_21 08:10:11 -0800"}> expected but was <{"key1"=>"value1", "key2"=>2, "timedata"=>"2016_06_21 09:10:11 -0700"}>. diff: - {"key1"=>"value1", "key2"=>2, "timedata"=>"2016_06_21 08:10:11 -0800"} ? ^ ^ + {"key1"=>"value1", "key2"=>2, "timedata"=>"2016_06_21 09:10:11 -0700"} ? ^ ^
end

test 'injects time as formatted string with nanosecond in localtime if timezone not specified' do
Expand All @@ -254,7 +254,7 @@
@d.start

record = {"key1" => "value1", "key2" => 2}
assert_equal record.merge({"timedata" => "2016_06_21 08:10:11.320101224 #{local_timezone}"}), @d.inject_values_to_record('tag', time, record)

Check failure on line 257 in test/plugin_helper/test_inject.rb

View workflow job for this annotation

GitHub Actions / Ruby 4.0 on windows-11-arm

Failure

<{"key1"=>"value1", "key2"=>2, "timedata"=>"2016_06_21 08:10:11.320101224 -0800"}> expected but was <{"key1"=>"value1", "key2"=>2, "timedata"=>"2016_06_21 09:10:11.320101224 -0700"}>. diff: {"key1"=>"value1", "key2"=>2, - "timedata"=>"2016_06_21 08:10:11.320101224 -0800"} ? ^ ^ + "timedata"=>"2016_06_21 09:10:11.320101224 -0700"} ? ^ ^

Check failure on line 257 in test/plugin_helper/test_inject.rb

View workflow job for this annotation

GitHub Actions / Ruby 3.4 on windows-11-arm

Failure

<{"key1"=>"value1", "key2"=>2, "timedata"=>"2016_06_21 08:10:11.320101224 -0800"}> expected but was <{"key1"=>"value1", "key2"=>2, "timedata"=>"2016_06_21 09:10:11.320101224 -0700"}>. diff: {"key1"=>"value1", "key2"=>2, - "timedata"=>"2016_06_21 08:10:11.320101224 -0800"} ? ^ ^ + "timedata"=>"2016_06_21 09:10:11.320101224 -0700"} ? ^ ^
end

test 'injects time as formatted string with millisecond in localtime if timezone not specified' do
Expand All @@ -267,7 +267,7 @@
@d.start

record = {"key1" => "value1", "key2" => 2}
assert_equal record.merge({"timedata" => "2016_06_21 08:10:11.320 #{local_timezone}"}), @d.inject_values_to_record('tag', time, record)

Check failure on line 270 in test/plugin_helper/test_inject.rb

View workflow job for this annotation

GitHub Actions / Ruby 4.0 on windows-11-arm

Failure

<{"key1"=>"value1", "key2"=>2, "timedata"=>"2016_06_21 08:10:11.320 -0800"}> expected but was <{"key1"=>"value1", "key2"=>2, "timedata"=>"2016_06_21 09:10:11.320 -0700"}>. diff: - {"key1"=>"value1", "key2"=>2, "timedata"=>"2016_06_21 08:10:11.320 -0800"} ? ^ ^ + {"key1"=>"value1", "key2"=>2, "timedata"=>"2016_06_21 09:10:11.320 -0700"} ? ^ ^

Check failure on line 270 in test/plugin_helper/test_inject.rb

View workflow job for this annotation

GitHub Actions / Ruby 3.4 on windows-11-arm

Failure

<{"key1"=>"value1", "key2"=>2, "timedata"=>"2016_06_21 08:10:11.320 -0800"}> expected but was <{"key1"=>"value1", "key2"=>2, "timedata"=>"2016_06_21 09:10:11.320 -0700"}>. diff: - {"key1"=>"value1", "key2"=>2, "timedata"=>"2016_06_21 08:10:11.320 -0800"} ? ^ ^ + {"key1"=>"value1", "key2"=>2, "timedata"=>"2016_06_21 09:10:11.320 -0700"} ? ^ ^
end

test 'injects time as formatted string in specified timezone' do
Expand Down Expand Up @@ -557,5 +557,30 @@
assert_equal("2014-09-27T09:00:00+09:00", record['t'])
end
end

# An offset outside HH 00..23 / MM 00..59 resolves to nil, which TimeFormatter
# reads as "no timezone", so these used to format every record in the host
# timezone with nothing in the log to point at the typo.
def test_timezone_out_of_range
assert_raise Fluent::ConfigError.new("Unsupported timezone '+09:60'") do
@d.configure(config_inject_section("time_key" => "t", "time_type" => "string", "timezone" => "+09:60"))
end
end

def test_timezone_of_24_hours
# On master this configured and then raised ArgumentError per record.
assert_raise Fluent::ConfigError.new("Unsupported timezone '+24:00'") do
@d.configure(config_inject_section("time_key" => "t", "time_type" => "string", "timezone" => "+24:00"))
end
end

def test_timezone_within_the_range_is_still_accepted
with_timezone("UTC-01") do
@d.configure(config_inject_section("time_key" => "t", "time_type" => "string", "time_format" => "%Y-%m-%dT%H:%M:%S%z", "timezone" => "+10:00"))
@d.start
record = @d.inject_values_to_record('tag', @time, {})
assert_equal("2014-09-27T10:00:00+1000", record['t'])
end
end
end
end
77 changes: 77 additions & 0 deletions test/test_timezone.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
#
# Fluentd
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#

require_relative 'helper'
require 'fluent/timezone'

class TimezoneTest < ::Test::Unit::TestCase
# [+-]HH, [+-]HHMM and [+-]HH:MM with HH in 00..23 and MM in 00..59.
VALID = [
'+00', '-00', '+09', '-09', '+23', '-23',
'+09:00', '-05:30', '+00:01', '+23:59', '-23:59', '+14:00', '-12:00',
'+0900', '-0530', '+2359', '-2359',
]

# An offset that is not a valid time of day. Either it carries into the hours
# and silently means a different offset, or it leaves the range Time#localtime
# can apply and fails on every record.
INVALID = [
'+09:60', '+0960', '+00:75', '-05:99', '+23:60',
'+24:00', '-24:00', '+99:99', '-99:99', '+2400',
]

def test_validate_accepts_offsets_within_a_day
VALID.each do |timezone|
assert_true Fluent::Timezone.validate(timezone), "#{timezone} should be valid"
end
end

def test_validate_rejects_offsets_outside_a_day
INVALID.each do |timezone|
assert_false Fluent::Timezone.validate(timezone), "#{timezone} should be invalid"
assert_raise(Fluent::ConfigError.new("Unsupported timezone '#{timezone}'")) do
Fluent::Timezone.validate!(timezone)
end
end
end

def test_utc_offset_resolves_the_offsets_time_can_apply
VALID.each do |timezone|
offset = Fluent::Timezone.utc_offset(timezone)
assert_true offset.is_a?(Integer), "#{timezone} does not resolve to an offset: #{offset.inspect}"
assert_true offset.abs < 86400, "#{timezone} resolves out of range: #{offset}"
end

assert_equal 32400, Fluent::Timezone.utc_offset('+09:00')
assert_equal 32400, Fluent::Timezone.utc_offset('+0900')
assert_equal 32400, Fluent::Timezone.utc_offset('+09')
assert_equal(-19800, Fluent::Timezone.utc_offset('-0530'))

INVALID.each do |timezone|
assert_nil Fluent::Timezone.utc_offset(timezone), "#{timezone} must not resolve to an offset"
end
end

def test_formatter_builds_a_formatter_for_valid_offsets
formatter = Fluent::Timezone.formatter('+09:00', '%Y-%m-%dT%H:%M:%S%z')
assert_not_nil formatter
assert_equal '1970-01-01T09:00:00+0900', formatter.call(0)

# '+09:60' means ten o'clock UTC only because the minutes are carried, so it
# must not be accepted as a stand-in for '+10:00'.
assert_nil Fluent::Timezone.formatter('+09:60', '%Y-%m-%dT%H:%M:%S%z')
end
end
Loading