Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
70893db
feat: Add file loading code for flag overrides
kinyoklion Sep 28, 2026
1f0f406
fix: Expand value-only overrides to a flag served by fallthrough
kinyoklion Oct 1, 2026
9a8bb54
fix: Report an exception from apply as a failed reload
kinyoklion Oct 3, 2026
79ffe8f
fix: Watch a data file directory again after it is deleted
kinyoklion Oct 3, 2026
f32482a
fix: Report an entry that cannot be deserialized as a merge error
kinyoklion Oct 3, 2026
fdac95f
test: Make the file data specs portable across platforms and runtimes
kinyoklion Oct 3, 2026
8a8fd02
test: Run the deleted directory example only with inotify
kinyoklion Oct 3, 2026
55c254e
fix: Start the watcher retry again when a directory is lost while the…
kinyoklion Oct 3, 2026
e839e85
test: Cover the deduplication of a repeated rejection by apply
kinyoklion Oct 3, 2026
ce2d2bf
test: Wait for the legacy V2 source's change detection without Queue#…
kinyoklion Oct 3, 2026
cd44db0
fix: Watch each data file directory independently
kinyoklion Oct 8, 2026
fe7112f
test: Let the retry recovery spec tolerate its own ordinary write
kinyoklion Oct 13, 2026
f9b11cf
fix: Keep file data through in-place rewrites, and choose the format …
kinyoklion Nov 3, 2026
2aa159b
fix: Use the key an entry appears under as the key of its flag or seg…
kinyoklion Nov 4, 2026
c482b92
fix: Follow a symbolic link to a data file in watch mode
kinyoklion Nov 4, 2026
01b1e65
docs: Rewrap a paragraph of the reloader class comment
kinyoklion Nov 4, 2026
b0e8680
fix: Resolve a symbolic link again until its target's directory is wa…
kinyoklion Nov 4, 2026
a43c443
fix: Expand a relative symbolic link against the real directory that …
kinyoklion Nov 4, 2026
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
7 changes: 7 additions & 0 deletions lib/ldclient-rb/impl/file_data.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# frozen_string_literal: true

require "ldclient-rb/impl/file_data/document"
require "ldclient-rb/impl/file_data/merge"
require "ldclient-rb/impl/file_data/poller"
require "ldclient-rb/impl/file_data/reloader"
require "ldclient-rb/impl/file_data/watcher"
267 changes: 267 additions & 0 deletions lib/ldclient-rb/impl/file_data/document.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,267 @@
# frozen_string_literal: true

require "ldclient-rb/impl/model/serialization"

require "json"
require "yaml"

module LaunchDarkly
module Impl
#
# Shared file reading, parsing, and merging code for the components that load flag and
# segment data from local files.
#
# @private
#
module FileData
#
# Raised when a file cannot be read or parsed. It carries the path so that callers can
# tell a per-file failure from a failure to merge the files' contents.
#
class ReadError < StandardError
# @return [String]
attr_reader :path

# @return [Boolean] true when the file does not exist
attr_reader :missing

#
# @param path [String]
# @param message [String]
# @param missing [Boolean]
#
def initialize(path, message, missing: false)
super("#{message} [#{path}]")
@path = path
@missing = missing
end
end

#
# The parsed form of one data file. A document may contain full flag definitions, flag key
# to value entries, and segment definitions. Every hash has symbol keys.
#
class Document
# @return [Hash{Symbol => Hash}]
attr_reader :flags

# @return [Hash{Symbol => Object}]
attr_reader :flag_values

# @return [Hash{Symbol => Hash}]
attr_reader :segments

#
# @param flags [Hash{Symbol => Hash}]
# @param flag_values [Hash{Symbol => Object}]
# @param segments [Hash{Symbol => Hash}]
#
def initialize(flags: {}, flag_values: {}, segments: {})
@flags = flags
@flag_values = flag_values
@segments = segments
end

# The document of YAML content that is only a null, such as an empty file, a comment, or
# "---": a document with no entries.
NONE = new(flags: {}.freeze, flag_values: {}.freeze, segments: {}.freeze).freeze

#
# Parses the content of a data file in the given format. {FileData.format_of} chooses the
# format of a file from its name.
#
# Whether content with no entries is valid depends on the format, like the rest of the
# content. Content that is empty or only white space is not a JSON document, so it fails to
# parse. In YAML it is a document with no entries, {NONE}, and so is a YAML document that is
# only a null, such as one with only a comment. A document that is not a mapping, or whose
# "flags", "flagValues", or "segments" member is not a mapping, is an error, and so is
# content that is not valid UTF-8, in either format.
#
# @param content [String] UTF-8 content
# @param format [Symbol] `:json` or `:yaml`
# @return [Document]
# @raise [ArgumentError] if the content is not a valid document
# @raise [JSON::ParserError, Psych::SyntaxError] if the content cannot be parsed
#
def self.parse(content, format:)
FileData.check_encoding(content)
case format
when :json
# A file that is being rewritten in place can be empty for a moment. Failing to parse
# keeps its last good data until it is complete.
raise ArgumentError, "the file has no content, which is not a JSON document" if FileData.blank?(content)

raw = JSON.parse(content)
when :yaml
# The YAML parser rejects some white space, such as a tab, that is still no content.
return NONE if FileData.blank?(content)

raw = YAML.safe_load(content)
return NONE if raw.nil?
else
raise ArgumentError, "unknown file format #{format.inspect}"
end
raise ArgumentError, "file content must be an object" unless raw.is_a?(Hash)

data = FileData.symbolize_keys(raw)
Document.new(
flags: section(data, :flags),
flag_values: section(data, :flagValues),
segments: section(data, :segments)
)
end

#
# Reads and parses one data file.
#
# @param path [String]
# @return [Document]
# @raise [ReadError] if the file cannot be read or parsed
#
def self.read(path)
content = FileData.read_file(path)
FileData.parse_file(path, content)
end

private_class_method def self.section(data, name)
value = data[name]
return {} if value.nil?
raise ArgumentError, "\"#{name}\" must be an object" unless value.is_a?(Hash)

value
end
end

# The endings of the names of the files that are read as YAML, compared without regard to
# case. Every other file is read as JSON.
YAML_FILE_NAME_ENDINGS = [".yaml", ".yml"].freeze

# The bytes that count as white space when deciding whether content is empty.
WHITE_SPACE_BYTES = [0x20, 0x09, 0x0a, 0x0d, 0x0b, 0x0c].freeze
private_constant :WHITE_SPACE_BYTES

#
# Returns the format of a data file, which its name decides: a name that ends in ".yaml" or
# ".yml", in any case, is YAML, and any other name is JSON.
#
# @param path [String]
# @return [Symbol] `:json` or `:yaml`
#
def self.format_of(path)
path.downcase.end_with?(*YAML_FILE_NAME_ENDINGS) ? :yaml : :json
end

#
# Raises an error if the content is not valid UTF-8.
#
# @param content [String]
# @raise [ArgumentError] if the content is not valid UTF-8
#
def self.check_encoding(content)
return if content.valid_encoding?

if content.b.start_with?(*UTF16_AND_UTF32_BOMS)
raise ArgumentError, "the file is encoded in UTF-16 or UTF-32; data files must be UTF-8"
end
raise ArgumentError, "the file is not valid UTF-8"
end

#
# Returns true if the content is empty or only white space.
#
# @param content [String]
# @return [Boolean]
#
def self.blank?(content)
content.each_byte.all? { |byte| WHITE_SPACE_BYTES.include?(byte) }
end

# The byte order marks of UTF-8 and of the encodings that files must not use.
UTF8_BOM = "\xEF\xBB\xBF".b.freeze
UTF16_AND_UTF32_BOMS = ["\xFF\xFE".b, "\xFE\xFF".b, "\x00\x00\xFE\xFF".b].freeze
private_constant :UTF8_BOM, :UTF16_AND_UTF32_BOMS

#
# Reads the raw content of one file, as UTF-8 and without a UTF-8 byte order mark, whatever
# the process's default encoding. The bytes are not checked here: {Document.parse} rejects
# content that is not valid UTF-8, so that such a file fails to load like any other content
# that cannot be parsed.
#
# @param path [String]
# @return [String]
# @raise [ReadError] if the file cannot be read
#
def self.read_file(path)
content = File.binread(path)
content = content.byteslice(UTF8_BOM.bytesize..) if content.start_with?(UTF8_BOM)
content.force_encoding(Encoding::UTF_8)
rescue Errno::ENOENT => e
raise ReadError.new(path, "unable to read file: #{e.message}", missing: true)
rescue SystemCallError, IOError => e
raise ReadError.new(path, "unable to read file: #{e.message}")
end

#
# Parses raw content that was read from the given path.
#
# @param path [String]
# @param content [String]
# @return [Document]
# @raise [ReadError] if the content cannot be parsed
#
def self.parse_file(path, content)
Document.parse(content, format: format_of(path))
rescue StandardError => e
raise ReadError.new(path, "error parsing file: #{e.message}")
end

#
# Recursively converts hash keys to symbols. The SDK expects all data model objects to
# have symbol keys.
#
# @param value [Object]
# @return [Object]
#
def self.symbolize_keys(value)
case value
when Hash
value.to_h { |k, v| [k.to_s.to_sym, symbolize_keys(v)] }

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

An operator who writes a YAML flag with an unquoted on: true gets a flag that is off, and nothing in the logs says why. YAML 1.1 reads the key on as the boolean true, and k.to_s.to_sym turns it into :true, so the flag has no on field and defaults to off. The same thing happens to flagValues keys named yes, no, on, or off.

Both existing file sources call k.to_sym here, which raises on true, so today the same file fails to load and the last good data stays in place. Can this raise too, with a message that tells the user to quote the key? That keeps the existing outcome and makes the fix obvious to whoever is editing the file during an incident.

when Array
value.map { |v| symbolize_keys(v) }
else
value
end

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

YAML on key silently disables flags

Medium Severity

YAML.safe_load treats unquoted on as a boolean, and symbolize_keys then stores that field as :true. Full flag definitions never see :on, so FeatureFlag treats them as off. Natural YAML like on: true therefore loads as a disabled flag instead of failing or preserving the on field.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 2e31db3. Configure here.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This reply was written by an AI agent (Claude Code) working on behalf of @kinyoklion.

Leaving this as is. Under YAML 1.1 an unquoted on key parses as the boolean true. Ruby's existing file data sources read YAML the same way (YAML.safe_load followed by symbolize_all_keys in file_data_source.rb and file_data_source_v2.rb on main), as do the Go and Python ports. The override docs (launchdarkly/sdk-meta#714, launchdarkly/ld-docs-private#8869) quote "on" in the YAML example and tell readers to do the same.

end

#
# Expands a flag key to value entry into a full flag definition that returns the given
# value for every context. The flag is on, has the value as its only variation, and
# serves that variation as its fallthrough, so an evaluation reports the FALLTHROUGH
# reason kind.
#
# @param key [String]
# @param value [Object]
# @return [Hash]
#
def self.make_flag_with_value(key, value)
{
key: key,
on: true,
version: 1,
fallthrough: { variation: 0 },
variations: [value],
}
end

#
# Converts each path to an absolute path.
#
# @param paths [Array<String>, String]
# @return [Array<String>]
#
def self.absolute_paths(paths)
Array(paths).map { |p| File.absolute_path(p.to_s) }
end
end
end
end
Loading
Loading