stega 1.0.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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +3 -0
- data/LICENSE.txt +23 -0
- data/README.md +46 -0
- data/lib/stega/decoder.rb +119 -0
- data/lib/stega/encoder.rb +32 -0
- data/lib/stega/legacy_encoder.rb +42 -0
- data/lib/stega/sanity.rb +173 -0
- data/lib/stega/version.rb +5 -0
- data/lib/stega.rb +123 -0
- metadata +98 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 501c850e45a2cebf99cd7e9ca8189dccb25d0e9f4982dfe1e92402c43e93f989
|
|
4
|
+
data.tar.gz: 1b924447c42f6e2f6efb86d70bd1bec64f6a9bef6c0df6f9b86f37ff3aa8c527
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: e56737e0a7cdc7e84c22db82ac4db40aa7633b18560f86e0a6dac3b1fad561df66f7f9a0f49d247fea1b2bae6485c97c09852c2f0d8e0fced16072ae0fe4eda7
|
|
7
|
+
data.tar.gz: 809b6c65037f0c8d8e308b7ce3ea29b987408200376a2adb5a70c8e8db3e0659a9e79c41091609677c95c136d3603036e989934471bbf3c4f2ac0173bfac051a
|
data/CHANGELOG.md
ADDED
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
Copyright (c) 2026 Theodor Tonum
|
|
2
|
+
|
|
3
|
+
MIT License
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
|
6
|
+
a copy of this software and associated documentation files (the
|
|
7
|
+
"Software"), to deal in the Software without restriction, including
|
|
8
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
|
9
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
|
10
|
+
permit persons to whom the Software is furnished to do so, subject to
|
|
11
|
+
the following conditions:
|
|
12
|
+
|
|
13
|
+
The above copyright notice and this permission notice shall be
|
|
14
|
+
included in all copies or substantial portions of the Software.
|
|
15
|
+
|
|
16
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
|
17
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
|
18
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
|
19
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
|
20
|
+
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
|
21
|
+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
|
22
|
+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
|
23
|
+
|
data/README.md
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
[](https://rubygems.org/gems/stega)
|
|
2
|
+
|
|
3
|
+
# Stega
|
|
4
|
+
|
|
5
|
+
TBD
|
|
6
|
+
|
|
7
|
+
## Installation
|
|
8
|
+
|
|
9
|
+
Adding to a gem:
|
|
10
|
+
|
|
11
|
+
```ruby
|
|
12
|
+
# my-cool-gem.gemspec
|
|
13
|
+
Gem::Specification.new do |spec|
|
|
14
|
+
# ...
|
|
15
|
+
spec.add_dependency "stega"
|
|
16
|
+
# ...
|
|
17
|
+
end
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Or adding to your project:
|
|
21
|
+
|
|
22
|
+
```ruby
|
|
23
|
+
# Gemfile
|
|
24
|
+
gem "stega"
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
### Supported Ruby versions
|
|
28
|
+
|
|
29
|
+
- Ruby (MRI) >= 3.0
|
|
30
|
+
|
|
31
|
+
## Usage
|
|
32
|
+
|
|
33
|
+
TBD
|
|
34
|
+
|
|
35
|
+
## Contributing
|
|
36
|
+
|
|
37
|
+
Bug reports and pull requests are welcome on GitHub at [https://github.com/rorkjop/stega](https://github.com/rorkjop/stega).
|
|
38
|
+
|
|
39
|
+
## Credits
|
|
40
|
+
|
|
41
|
+
This gem is generated via [`newgem` template](https://github.com/palkan/newgem) by [@palkan](https://github.com/palkan).
|
|
42
|
+
|
|
43
|
+
## License
|
|
44
|
+
|
|
45
|
+
The gem is available as open source under the terms of the [MIT License](http://opensource.org/licenses/MIT).
|
|
46
|
+
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Stega
|
|
4
|
+
module Decoder
|
|
5
|
+
# Reverse lookup: invisible character → base-4 digit
|
|
6
|
+
BASE4_REVERSE_MAP = Encoder::BASE4_CHARS.each_with_index.to_h { |char, idx| [char, idx] }.freeze
|
|
7
|
+
|
|
8
|
+
# Reverse lookup: invisible codepoint → hex digit
|
|
9
|
+
HEX_REVERSE_MAP = LegacyEncoder::HEX_CHAR_MAP.to_h { |hex_digit, codepoint| [codepoint, hex_digit] }.freeze
|
|
10
|
+
|
|
11
|
+
# Regex character class matching all invisible stega characters
|
|
12
|
+
STEGA_CHAR_CLASS = LegacyEncoder::HEX_CHAR_MAP.values.map { |cp| format("\\u{%x}", cp) }.join
|
|
13
|
+
|
|
14
|
+
# Regex to match encoded strings (4+ invisible characters)
|
|
15
|
+
REGEX = Regexp.new("[#{STEGA_CHAR_CLASS}]{4,}")
|
|
16
|
+
|
|
17
|
+
NULL_CHAR = "\x00"
|
|
18
|
+
|
|
19
|
+
# Decode the first steganographic payload from a string.
|
|
20
|
+
def self.decode(str)
|
|
21
|
+
match = str.match(REGEX)
|
|
22
|
+
return nil unless match
|
|
23
|
+
|
|
24
|
+
decode_payload(match[0], first_only: true).first
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Decode all steganographic payloads from a string.
|
|
28
|
+
def self.decode_all(str)
|
|
29
|
+
matches = str.scan(REGEX)
|
|
30
|
+
return nil if matches.empty?
|
|
31
|
+
|
|
32
|
+
matches.flat_map { |m| decode_payload(m) }
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Decode an invisible-character payload back into JSON value(s).
|
|
36
|
+
# Detects whether it's new (base-4 with prefix) or legacy (hex-pair) encoding.
|
|
37
|
+
def self.decode_payload(encoded, first_only: false)
|
|
38
|
+
chars = encoded.chars
|
|
39
|
+
|
|
40
|
+
# If even length but not divisible by 4 or missing prefix → legacy encoding
|
|
41
|
+
if chars.length.even?
|
|
42
|
+
if (chars.length % 4 != 0) || !encoded.start_with?(Encoder::STEGA_PREFIX)
|
|
43
|
+
return decode_legacy_payload(chars, first_only)
|
|
44
|
+
end
|
|
45
|
+
else
|
|
46
|
+
raise ArgumentError, "Encoded data has invalid length"
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# New base-4 encoding: skip 4-char prefix
|
|
50
|
+
data_chars = chars[4..]
|
|
51
|
+
bytes = Array.new(data_chars.length / 4)
|
|
52
|
+
|
|
53
|
+
bytes.length.times do |i|
|
|
54
|
+
bytes[i] = (BASE4_REVERSE_MAP[data_chars[i * 4]] << 6) |
|
|
55
|
+
(BASE4_REVERSE_MAP[data_chars[i * 4 + 1]] << 4) |
|
|
56
|
+
(BASE4_REVERSE_MAP[data_chars[i * 4 + 2]] << 2) |
|
|
57
|
+
BASE4_REVERSE_MAP[data_chars[i * 4 + 3]]
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
decoded = bytes.pack("C*").force_encoding(Encoding::UTF_8)
|
|
61
|
+
|
|
62
|
+
if first_only
|
|
63
|
+
null_index = decoded.index(NULL_CHAR) || decoded.length
|
|
64
|
+
return [JSON.parse(decoded[0...null_index])]
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
decoded.split(NULL_CHAR).reject(&:empty?).map { |segment| JSON.parse(segment) }
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Decode legacy hex-pair encoded payload.
|
|
71
|
+
# Each pair of invisible characters represents one hex byte → one ASCII character.
|
|
72
|
+
def self.decode_legacy_payload(chars, first_only)
|
|
73
|
+
ascii_chars = []
|
|
74
|
+
|
|
75
|
+
(chars.length / 2).times do |i|
|
|
76
|
+
idx = (chars.length / 2) - 1 - i
|
|
77
|
+
hex_str = HEX_REVERSE_MAP[chars[idx * 2].ord].to_s +
|
|
78
|
+
HEX_REVERSE_MAP[chars[idx * 2 + 1].ord].to_s
|
|
79
|
+
ascii_chars.unshift(hex_str.to_i(16).chr)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
results = []
|
|
83
|
+
queue = [ascii_chars.join]
|
|
84
|
+
max_retries = 10
|
|
85
|
+
|
|
86
|
+
# Try to parse concatenated JSON values by splitting at parse error positions
|
|
87
|
+
until queue.empty?
|
|
88
|
+
str = queue.shift
|
|
89
|
+
begin
|
|
90
|
+
results << JSON.parse(str)
|
|
91
|
+
return results if first_only
|
|
92
|
+
rescue JSON::ParserError => e
|
|
93
|
+
max_retries -= 1
|
|
94
|
+
raise e if max_retries <= 0
|
|
95
|
+
|
|
96
|
+
# Extract position from error message
|
|
97
|
+
pos_match = e.message.match(/at (?:line \d+, )?column (\d+)/)
|
|
98
|
+
error_pos = pos_match ? pos_match[1].to_i : nil
|
|
99
|
+
|
|
100
|
+
# Ruby's JSON parser reports 1-based column, convert to 0-based index
|
|
101
|
+
# Also try to find the actual position by looking for unexpected token position
|
|
102
|
+
if error_pos.nil? || error_pos == 0
|
|
103
|
+
# Try alternate pattern
|
|
104
|
+
pos_match = e.message.match(/position (\d+)/)
|
|
105
|
+
error_pos = pos_match ? pos_match[1].to_i : nil
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
raise e if error_pos.nil? || error_pos == 0
|
|
109
|
+
|
|
110
|
+
# For 1-based column, subtract 1 for 0-based index
|
|
111
|
+
error_pos -= 1 if error_pos > 0
|
|
112
|
+
queue.unshift(str[0...error_pos], str[error_pos..])
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
results
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Stega
|
|
4
|
+
module Encoder
|
|
5
|
+
# Base-4 encoding uses 4 invisible characters
|
|
6
|
+
BASE4_CHARS = [
|
|
7
|
+
"\u200B", # ZERO WIDTH SPACE
|
|
8
|
+
"\u200C", # ZERO WIDTH NON-JOINER
|
|
9
|
+
"\u200D", # ZERO WIDTH JOINER
|
|
10
|
+
"\uFEFF" # ZERO WIDTH NO-BREAK SPACE (BOM)
|
|
11
|
+
].freeze
|
|
12
|
+
|
|
13
|
+
# Prefix: 4 zero-width spaces used as a magic marker
|
|
14
|
+
STEGA_PREFIX = (BASE4_CHARS[0] * 4)
|
|
15
|
+
|
|
16
|
+
# Encode a value into an invisible steganographic string (base-4 encoding).
|
|
17
|
+
# Each byte is split into four 2-bit groups, each mapped to an invisible character.
|
|
18
|
+
def self.encode(value)
|
|
19
|
+
json = JSON.generate(value)
|
|
20
|
+
bytes = json.encode(Encoding::UTF_8).bytes
|
|
21
|
+
|
|
22
|
+
encoded = bytes.map do |byte|
|
|
23
|
+
BASE4_CHARS[(byte >> 6) & 3] +
|
|
24
|
+
BASE4_CHARS[(byte >> 4) & 3] +
|
|
25
|
+
BASE4_CHARS[(byte >> 2) & 3] +
|
|
26
|
+
BASE4_CHARS[byte & 3]
|
|
27
|
+
end.join
|
|
28
|
+
|
|
29
|
+
STEGA_PREFIX + encoded
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Stega
|
|
4
|
+
module LegacyEncoder
|
|
5
|
+
# Unicode zero-width and invisible characters used for hex encoding
|
|
6
|
+
HEX_CHAR_MAP = {
|
|
7
|
+
"0" => 8203, # ZERO WIDTH SPACE
|
|
8
|
+
"1" => 8204, # ZERO WIDTH NON-JOINER
|
|
9
|
+
"2" => 8205, # ZERO WIDTH JOINER
|
|
10
|
+
"3" => 8290, # INVISIBLE TIMES
|
|
11
|
+
"4" => 8291, # INVISIBLE SEPARATOR
|
|
12
|
+
"5" => 8288, # WORD JOINER
|
|
13
|
+
"6" => 65279, # ZERO WIDTH NO-BREAK SPACE (BOM)
|
|
14
|
+
"7" => 8289, # INVISIBLE PLUS
|
|
15
|
+
"8" => 119_155, # MUSICAL SYMBOL BEGIN BEAM
|
|
16
|
+
"9" => 119_156, # MUSICAL SYMBOL END BEAM
|
|
17
|
+
"a" => 119_157, # MUSICAL SYMBOL BEGIN TIE
|
|
18
|
+
"b" => 119_158, # MUSICAL SYMBOL END TIE
|
|
19
|
+
"c" => 119_159, # MUSICAL SYMBOL BEGIN SLUR
|
|
20
|
+
"d" => 119_160, # MUSICAL SYMBOL END SLUR
|
|
21
|
+
"e" => 119_161, # MUSICAL SYMBOL BEGIN PHRASE
|
|
22
|
+
"f" => 119_162 # MUSICAL SYMBOL END PHRASE
|
|
23
|
+
}.freeze
|
|
24
|
+
|
|
25
|
+
# Legacy encoding: each ASCII character is encoded as two hex digits,
|
|
26
|
+
# each mapped to an invisible Unicode character.
|
|
27
|
+
def self.legacy_encode(value)
|
|
28
|
+
json = JSON.generate(value)
|
|
29
|
+
|
|
30
|
+
json.chars.map do |char|
|
|
31
|
+
code = char.ord
|
|
32
|
+
if code > 255
|
|
33
|
+
raise ArgumentError,
|
|
34
|
+
"Only ASCII edit info can be encoded. Error attempting to encode #{json} on character #{char} (#{code})"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
hex = code.to_s(16).rjust(2, "0")
|
|
38
|
+
hex.chars.map { |hex_digit| [HEX_CHAR_MAP[hex_digit]].pack("U") }.join
|
|
39
|
+
end.join
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
data/lib/stega/sanity.rb
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "set"
|
|
4
|
+
|
|
5
|
+
module Stega
|
|
6
|
+
module Sanity
|
|
7
|
+
SKIP_KEYS = Set.new(%w[_id _type _ref _key _createdAt _updatedAt _rev _originalId _system slug]).freeze
|
|
8
|
+
|
|
9
|
+
class << self
|
|
10
|
+
def encode_source_map(result, source_map, config)
|
|
11
|
+
validate_config!(config)
|
|
12
|
+
return result unless source_map
|
|
13
|
+
|
|
14
|
+
encode_into_result(result, source_map, config)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
private
|
|
18
|
+
|
|
19
|
+
def validate_config!(config)
|
|
20
|
+
raise TypeError, "enabled must be true" unless config[:enabled]
|
|
21
|
+
raise TypeError, "studio_url must be defined" unless config[:studio_url]
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def encode_into_result(result, source_map, config)
|
|
25
|
+
documents = source_map[:documents] || []
|
|
26
|
+
paths = source_map[:paths] || []
|
|
27
|
+
mappings = (source_map[:mappings] || {}).transform_keys(&:to_s)
|
|
28
|
+
|
|
29
|
+
deep_transform(result, []) do |value, path|
|
|
30
|
+
json_path = to_json_path(path)
|
|
31
|
+
mapping, matched_path = resolve_mapping(json_path, mappings)
|
|
32
|
+
|
|
33
|
+
next value unless mapping && value.is_a?(String)
|
|
34
|
+
next value unless (mapping[:type] || "value") == "value"
|
|
35
|
+
|
|
36
|
+
source = mapping[:source]
|
|
37
|
+
next value unless source && source[:type] == "documentValue"
|
|
38
|
+
|
|
39
|
+
doc_index = source[:document]
|
|
40
|
+
path_index = source[:path]
|
|
41
|
+
document = documents[doc_index]
|
|
42
|
+
|
|
43
|
+
next value unless document
|
|
44
|
+
|
|
45
|
+
context = {
|
|
46
|
+
value: value,
|
|
47
|
+
path: path,
|
|
48
|
+
document: document
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if config[:filter]
|
|
52
|
+
next value unless config[:filter].call(context)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
source_path = paths[path_index]
|
|
56
|
+
full_path = resolve_full_source_path(source_path, json_path, matched_path)
|
|
57
|
+
|
|
58
|
+
edit_url = create_edit_url(
|
|
59
|
+
studio_url: config[:studio_url],
|
|
60
|
+
document: document,
|
|
61
|
+
path: full_path,
|
|
62
|
+
omit_cross_dataset: config[:omit_cross_dataset_reference_data]
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
payload = {"origin" => "sanity.io", "href" => edit_url}
|
|
66
|
+
Stega.combine(value, payload)
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def deep_transform(obj, path, &block)
|
|
71
|
+
case obj
|
|
72
|
+
when Hash
|
|
73
|
+
obj.each_with_object({}) do |(key, value), result|
|
|
74
|
+
result[key] = if SKIP_KEYS.include?(key.to_s)
|
|
75
|
+
value
|
|
76
|
+
else
|
|
77
|
+
deep_transform(value, path + [key], &block)
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
when Array
|
|
81
|
+
obj.each_with_index.map do |value, index|
|
|
82
|
+
deep_transform(value, path + [index], &block)
|
|
83
|
+
end
|
|
84
|
+
else
|
|
85
|
+
yield(obj, path)
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def create_edit_url(studio_url:, document:, path:, omit_cross_dataset: false)
|
|
90
|
+
doc_id = document[:_id]
|
|
91
|
+
doc_type = document[:_type]
|
|
92
|
+
project_id = document[:_projectId]
|
|
93
|
+
dataset = document[:_dataset]
|
|
94
|
+
|
|
95
|
+
studio_path = json_path_to_studio_path(path)
|
|
96
|
+
|
|
97
|
+
router_parts = [
|
|
98
|
+
"mode=presentation",
|
|
99
|
+
"id=#{doc_id}",
|
|
100
|
+
"type=#{doc_type}",
|
|
101
|
+
"path=#{URI.encode_www_form_component(studio_path)}"
|
|
102
|
+
]
|
|
103
|
+
router_params = router_parts.join(";")
|
|
104
|
+
|
|
105
|
+
search_hash = {baseUrl: studio_url, id: doc_id, type: doc_type, path: studio_path, perspective: "previewDrafts"}
|
|
106
|
+
unless omit_cross_dataset
|
|
107
|
+
search_hash[:projectId] = project_id if project_id
|
|
108
|
+
search_hash[:dataset] = dataset if dataset
|
|
109
|
+
end
|
|
110
|
+
search_params = URI.encode_www_form(search_hash)
|
|
111
|
+
|
|
112
|
+
"#{studio_url}/intent/edit/#{router_params}?#{search_params}"
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def json_path_to_studio_path(json_path)
|
|
116
|
+
rest = json_path.sub(/^\$/, "")
|
|
117
|
+
segments = rest.scan(/\['([^']*)'\]|\[(\d+)\]/)
|
|
118
|
+
|
|
119
|
+
result = +""
|
|
120
|
+
segments.each_with_index do |(key, index), i|
|
|
121
|
+
if index
|
|
122
|
+
result << "[#{index}]"
|
|
123
|
+
else
|
|
124
|
+
result << "." if i > 0
|
|
125
|
+
result << key
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
result
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def resolve_mapping(json_path, mappings)
|
|
132
|
+
return [mappings[json_path], json_path] if mappings.key?(json_path)
|
|
133
|
+
|
|
134
|
+
segments = parse_path_segments(json_path)
|
|
135
|
+
(segments.length - 1).downto(1) do |i|
|
|
136
|
+
candidate = "$" + segments[0...i].join
|
|
137
|
+
return [mappings[candidate], candidate] if mappings.key?(candidate)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
return [mappings["$"], "$"] if mappings.key?("$")
|
|
141
|
+
|
|
142
|
+
nil
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def parse_path_segments(json_path)
|
|
146
|
+
json_path.sub(/^\$/, "").scan(/\['[^']*'\]|\[\d+\]/)
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def resolve_full_source_path(source_path, result_path, matched_path)
|
|
150
|
+
matched_segments = parse_path_segments(matched_path)
|
|
151
|
+
result_segments = parse_path_segments(result_path)
|
|
152
|
+
suffix_segments = result_segments[matched_segments.length..]
|
|
153
|
+
|
|
154
|
+
if suffix_segments&.any?
|
|
155
|
+
source_path + suffix_segments.join
|
|
156
|
+
else
|
|
157
|
+
source_path
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def to_json_path(path)
|
|
162
|
+
json_path = path.map do |segment|
|
|
163
|
+
if segment.is_a?(Integer) || segment =~ /^\d+$/
|
|
164
|
+
"[#{segment}]"
|
|
165
|
+
else
|
|
166
|
+
"['#{segment}']"
|
|
167
|
+
end
|
|
168
|
+
end.join("")
|
|
169
|
+
"$#{json_path}"
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
end
|
data/lib/stega.rb
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "uri"
|
|
5
|
+
require "date"
|
|
6
|
+
|
|
7
|
+
require "stega/version"
|
|
8
|
+
require "stega/encoder"
|
|
9
|
+
require "stega/legacy_encoder"
|
|
10
|
+
require "stega/decoder"
|
|
11
|
+
require "stega/sanity"
|
|
12
|
+
|
|
13
|
+
module Stega
|
|
14
|
+
# Regex to match encoded strings
|
|
15
|
+
REGEX = Decoder::REGEX
|
|
16
|
+
|
|
17
|
+
class << self
|
|
18
|
+
# Encode a value into an invisible steganographic string (base-4 encoding)
|
|
19
|
+
def encode(value)
|
|
20
|
+
Encoder.encode(value)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Decode the first steganographic payload from a string
|
|
24
|
+
def decode(str)
|
|
25
|
+
Decoder.decode(str)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Decode all steganographic payloads from a string
|
|
29
|
+
def decode_all(str)
|
|
30
|
+
Decoder.decode_all(str)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Legacy encoding using hex pairs
|
|
34
|
+
def legacy_encode(value)
|
|
35
|
+
LegacyEncoder.legacy_encode(value)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Combine a visible string with invisible encoded data
|
|
39
|
+
# mode can be :auto (default), :skip (true), or false
|
|
40
|
+
def combine(visible, data, mode = :auto)
|
|
41
|
+
skip = case mode
|
|
42
|
+
when true, :skip
|
|
43
|
+
true
|
|
44
|
+
when :auto
|
|
45
|
+
date_like?(visible) || url?(visible)
|
|
46
|
+
else
|
|
47
|
+
false
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
return visible if skip
|
|
51
|
+
|
|
52
|
+
"#{visible}#{encode(data)}"
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Split a string into its visible content and the encoded (invisible) portion
|
|
56
|
+
def split(str)
|
|
57
|
+
match = str.match(REGEX)
|
|
58
|
+
{
|
|
59
|
+
cleaned: str.gsub(REGEX, ""),
|
|
60
|
+
encoded: match ? match[0] : ""
|
|
61
|
+
}
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Deep-clean an object by removing all steganographic data
|
|
65
|
+
def clean(value)
|
|
66
|
+
return value unless value
|
|
67
|
+
|
|
68
|
+
JSON.parse(split(JSON.generate(value))[:cleaned])
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
private
|
|
72
|
+
|
|
73
|
+
# Check if a string looks like a date
|
|
74
|
+
def date_like?(str)
|
|
75
|
+
return false if str.nil? || str.empty?
|
|
76
|
+
|
|
77
|
+
# Not a date if it's a plain number
|
|
78
|
+
return false if numeric?(str)
|
|
79
|
+
|
|
80
|
+
# If it contains letters but doesn't match date pattern, not a date
|
|
81
|
+
if str.match?(/[a-z]/i) &&
|
|
82
|
+
!str.match?(/\d+(?:[-:\/]\d+){2}(?:T\d+(?:[-:\/]\d+){1,2}(\.\d+)?Z?)?/)
|
|
83
|
+
return false
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Try to parse as date
|
|
87
|
+
begin
|
|
88
|
+
Date.parse(str)
|
|
89
|
+
true
|
|
90
|
+
rescue ArgumentError, TypeError
|
|
91
|
+
false
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Check if a string is a valid URL
|
|
96
|
+
def url?(str)
|
|
97
|
+
return false if str.nil? || str.empty?
|
|
98
|
+
|
|
99
|
+
begin
|
|
100
|
+
if str.start_with?("/")
|
|
101
|
+
URI.parse("https://acme.com#{str}")
|
|
102
|
+
else
|
|
103
|
+
uri = URI.parse(str)
|
|
104
|
+
# Must have a scheme to be a valid URL
|
|
105
|
+
return false unless uri.scheme
|
|
106
|
+
|
|
107
|
+
uri
|
|
108
|
+
end
|
|
109
|
+
true
|
|
110
|
+
rescue URI::InvalidURIError
|
|
111
|
+
false
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Check if a string is numeric
|
|
116
|
+
def numeric?(str)
|
|
117
|
+
Float(str)
|
|
118
|
+
true
|
|
119
|
+
rescue ArgumentError, TypeError
|
|
120
|
+
false
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: stega
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 1.0.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Theodor Tonum
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-01 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: bundler
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - ">="
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '1.15'
|
|
19
|
+
type: :development
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - ">="
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '1.15'
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: rake
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - ">="
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '13.0'
|
|
33
|
+
type: :development
|
|
34
|
+
prerelease: false
|
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - ">="
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: '13.0'
|
|
40
|
+
- !ruby/object:Gem::Dependency
|
|
41
|
+
name: rspec
|
|
42
|
+
requirement: !ruby/object:Gem::Requirement
|
|
43
|
+
requirements:
|
|
44
|
+
- - ">="
|
|
45
|
+
- !ruby/object:Gem::Version
|
|
46
|
+
version: '3.9'
|
|
47
|
+
type: :development
|
|
48
|
+
prerelease: false
|
|
49
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
50
|
+
requirements:
|
|
51
|
+
- - ">="
|
|
52
|
+
- !ruby/object:Gem::Version
|
|
53
|
+
version: '3.9'
|
|
54
|
+
description: A Ruby implementation of Vercel's stega encoding for embedding invisible
|
|
55
|
+
data in strings, with Sanity source map support.
|
|
56
|
+
email:
|
|
57
|
+
- theodor@tonum.no
|
|
58
|
+
executables: []
|
|
59
|
+
extensions: []
|
|
60
|
+
extra_rdoc_files: []
|
|
61
|
+
files:
|
|
62
|
+
- CHANGELOG.md
|
|
63
|
+
- LICENSE.txt
|
|
64
|
+
- README.md
|
|
65
|
+
- lib/stega.rb
|
|
66
|
+
- lib/stega/decoder.rb
|
|
67
|
+
- lib/stega/encoder.rb
|
|
68
|
+
- lib/stega/legacy_encoder.rb
|
|
69
|
+
- lib/stega/sanity.rb
|
|
70
|
+
- lib/stega/version.rb
|
|
71
|
+
homepage: https://github.com/rorkjop/stega
|
|
72
|
+
licenses:
|
|
73
|
+
- MIT
|
|
74
|
+
metadata:
|
|
75
|
+
bug_tracker_uri: https://github.com/rorkjop/stega/issues
|
|
76
|
+
changelog_uri: https://github.com/rorkjop/stega/blob/main/CHANGELOG.md
|
|
77
|
+
documentation_uri: https://github.com/rorkjop/stega
|
|
78
|
+
homepage_uri: https://github.com/rorkjop/stega
|
|
79
|
+
source_code_uri: https://github.com/rorkjop/stega
|
|
80
|
+
rubygems_mfa_required: 'true'
|
|
81
|
+
rdoc_options: []
|
|
82
|
+
require_paths:
|
|
83
|
+
- lib
|
|
84
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
85
|
+
requirements:
|
|
86
|
+
- - ">="
|
|
87
|
+
- !ruby/object:Gem::Version
|
|
88
|
+
version: '3.2'
|
|
89
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
90
|
+
requirements:
|
|
91
|
+
- - ">="
|
|
92
|
+
- !ruby/object:Gem::Version
|
|
93
|
+
version: '0'
|
|
94
|
+
requirements: []
|
|
95
|
+
rubygems_version: 3.7.2
|
|
96
|
+
specification_version: 4
|
|
97
|
+
summary: Steganographic encoding and decoding for strings
|
|
98
|
+
test_files: []
|