number_station 0.2.0 → 0.3.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.
@@ -19,25 +19,102 @@
19
19
  along with this program. If not, see <https://www.gnu.org/licenses/>.
20
20
  =end
21
21
 
22
+ require 'yaml'
23
+ require 'fileutils'
24
+ require 'date'
25
+
22
26
  module NumberStation
23
27
  class ConfigReader
28
+ def self.read_config
29
+ config_path = user_config_path || default_config_path
30
+ load_config(config_path)
31
+ end
32
+
33
+ def self.user_config_path
34
+ yaml_path = File.join(Dir.home, "number_station", "conf.yaml")
35
+ json_path = File.join(Dir.home, "number_station", "conf.json")
36
+
37
+ # Prefer YAML, but support JSON for backward compatibility
38
+ return yaml_path if File.exist?(yaml_path)
39
+ return json_path if File.exist?(json_path)
40
+ nil
41
+ end
42
+
43
+ def self.default_config_path
44
+ yaml_path = File.join(File.dirname(__FILE__), "../../resources/conf.yaml")
45
+ json_path = File.join(File.dirname(__FILE__), "../../resources/conf.json")
46
+
47
+ # Prefer YAML, but support JSON for backward compatibility
48
+ return yaml_path if File.exist?(yaml_path)
49
+ return json_path if File.exist?(json_path)
50
+ yaml_path # Default to YAML path
51
+ end
52
+
53
+ def self.load_config(config_path)
54
+ config_data = if config_path.end_with?('.yaml') || config_path.end_with?('.yml')
55
+ # Use safe_load with permitted classes to handle Date objects
56
+ YAML.safe_load(File.read(config_path), permitted_classes: [Date, Time], aliases: true)
57
+ else
58
+ require 'json'
59
+ JSON.parse(File.read(config_path))
60
+ end
61
+
62
+ NumberStation.set_data(config_data)
63
+ setup_logger(config_data["logging"]["level"])
64
+ NumberStation.log.debug "Reading in config file: #{config_path}"
65
+ rescue StandardError => e
66
+ # Ensure logger exists before trying to log
67
+ unless NumberStation.log
68
+ setup_logger(Logger::WARN)
69
+ end
70
+ NumberStation.log.error "Failed to load config: #{e.message}"
71
+ raise
72
+ end
73
+
74
+ def self.setup_logger(level)
75
+ NumberStation.set_log(Logger.new(STDOUT))
76
+ NumberStation.log.level = level
77
+ end
78
+
79
+ def self.save_config(config_data = nil)
80
+ config_data ||= NumberStation.data
81
+ config_path = user_config_path || File.join(Dir.home, "number_station", "conf.yaml")
82
+
83
+ # Ensure directory exists
84
+ FileUtils.mkdir_p(File.dirname(config_path))
85
+
86
+ # Convert Date/Time objects to strings before saving
87
+ sanitized_data = sanitize_for_yaml(config_data.dup)
88
+
89
+ # Save as YAML
90
+ File.write(config_path, sanitized_data.to_yaml)
91
+ NumberStation.set_data(config_data) # Update in-memory data (keep original format)
92
+ NumberStation.log.debug "Saved config file: #{config_path}"
93
+ config_path
94
+ rescue StandardError => e
95
+ # Ensure logger exists before trying to log
96
+ unless NumberStation.log
97
+ setup_logger(Logger::WARN)
98
+ end
99
+ NumberStation.log.error "Failed to save config: #{e.message}"
100
+ raise
101
+ end
24
102
 
25
- def self.read_config()
26
- begin
27
- config_file_path = File.join(Dir.home, "number_station/conf.json")
28
- NumberStation.set_data( JSON.parse(File.read(config_file_path)) )
29
- NumberStation.set_log( Logger.new(STDOUT) )
30
- NumberStation.log.level = NumberStation.data["logging"]["level"]
31
- NumberStation.log.debug "Reading in config file: #{config_file_path}"
32
- rescue Exception => e
33
- config_file_path = File.join(File.dirname(__FILE__), "../../config/conf.json")
34
- NumberStation.set_data( JSON.parse(File.read(config_file_path)) )
35
- NumberStation.set_log( Logger.new(STDOUT) )
36
- NumberStation.log.level = NumberStation.data["logging"]["level"]
37
- NumberStation.log.debug "Reading in default config file: #{config_file_path}"
103
+ def self.sanitize_for_yaml(data)
104
+ case data
105
+ when Hash
106
+ data.each_with_object({}) do |(key, value), result|
107
+ result[key] = sanitize_for_yaml(value)
108
+ end
109
+ when Array
110
+ data.map { |item| sanitize_for_yaml(item) }
111
+ when Date, Time
112
+ data.to_s
113
+ else
114
+ data
38
115
  end
39
- NumberStation.log.debug "NumberStation::ConfigReader#read_config"
40
116
  end
41
117
 
118
+ private
42
119
  end
43
120
  end
@@ -18,47 +18,91 @@
18
18
  You should have received a copy of the GNU General Public License
19
19
  along with this program. If not, see <https://www.gnu.org/licenses/>.
20
20
  =end
21
- require "securerandom"
22
21
  require "json"
23
22
 
24
23
  module NumberStation
25
-
26
- def self.decrypt_message(message, pad_path, pad_num)
27
- NumberStation.log.debug "message length: #{message.size}"
28
- message_byte_array = message.scan(/.{1}/).each_slice(2).map { |f, l| (Integer(f,16) << 4) + Integer(l,16) }
24
+ def self.decrypt_message(message, pad_path, pad_num, message_file_path = nil)
25
+ # Strip whitespace and newlines to handle formatted input (groups of 5)
26
+ cleaned_message = message.gsub(/[\s\n\r]/, '')
27
+ NumberStation.log.debug "original message length: #{message.size}, cleaned length: #{cleaned_message.size}"
28
+
29
+ pad_data = load_pad_data(pad_path)
30
+ pad_key = pad_data["pads"][pad_num]["key"]
31
+
32
+ validate_message_length(cleaned_message.size, pad_key.size)
29
33
 
30
- begin
31
- pad_data = JSON.parse(File.read(pad_path))
32
- rescue Exception => e
33
- raise e
34
- end
34
+ message_bytes = hex_string_to_bytes(cleaned_message)
35
+ pad_bytes = hex_string_to_bytes(pad_key)
36
+ decrypted_bytes = xor_decrypt(message_bytes, pad_bytes)
37
+ decrypted_string = bytes_to_string(decrypted_bytes)
35
38
 
36
- crypto_hex_str = pad_data["pads"][pad_num]["key"]
37
- if message.size > crypto_hex_str.size
38
- NumberStation.log.error "Error: The message length is greater than pad length. Unable to continue decryption."
39
- exit
40
- end
41
- NumberStation.log.debug "message length less than pad length: #{message.size <= crypto_hex_str.size}"
39
+ # Extract encrypted filename if message_file_path is provided
40
+ encrypted_filename = message_file_path ? File.basename(message_file_path) : nil
41
+ write_decrypted_file(pad_data["id"], pad_num, decrypted_string, encrypted_filename)
42
+ decrypted_string
43
+ end
42
44
 
43
- crypto_byte_array = crypto_hex_str.scan(/.{1}/).each_slice(2).map { |f, l| (Integer(f,16) << 4) + Integer(l,16) }
45
+ private
44
46
 
45
- decrypted_byte_array = []
46
- message_byte_array.each_with_index do |i, index|
47
- decrypted_byte_array << (i ^ crypto_byte_array[index])
47
+ def self.load_pad_data(pad_path)
48
+ JSON.parse(File.read(pad_path))
49
+ rescue StandardError => e
50
+ NumberStation.log.error "Failed to load pad file: #{e.message}"
51
+ raise
52
+ end
53
+
54
+ def self.validate_message_length(message_size, pad_size)
55
+ if message_size > pad_size
56
+ NumberStation.log.error "Message length (#{message_size}) is greater than pad length (#{pad_size}). Unable to continue decryption."
57
+ raise ArgumentError, "Message too long for pad"
48
58
  end
59
+ NumberStation.log.debug "message length less than pad length: #{message_size <= pad_size}"
60
+ end
49
61
 
50
- decrypted_string = decrypted_byte_array.pack('U*').force_encoding('utf-8')
62
+ def self.hex_string_to_bytes(hex_string)
63
+ hex_string.scan(/.{2}/).map { |pair| pair.to_i(16) }
64
+ end
51
65
 
52
- begin
53
- f_name = "#{pad_data["id"]}_#{pad_num}_#{Time.now.to_i}_decrypted.txt"
54
- NumberStation.log.info "Writing decrypted message to file #{f_name}"
55
- f = File.open(f_name, "w")
56
- f.write(decrypted_string)
57
- f.close
58
- rescue Exception => e
59
- raise e
60
- end
61
- return decrypted_string
66
+ def self.xor_decrypt(message_bytes, pad_bytes)
67
+ message_bytes.map.with_index { |byte, index| byte ^ pad_bytes[index] }
68
+ end
69
+
70
+ def self.bytes_to_string(bytes)
71
+ bytes.pack('U*').force_encoding('utf-8')
62
72
  end
63
73
 
74
+ def self.write_decrypted_file(pad_id, pad_num, decrypted_content, encrypted_filename = nil)
75
+ # Only write to file if decrypting from a file (not from string)
76
+ unless encrypted_filename
77
+ NumberStation.log.debug "Decrypted message (not saving to file)"
78
+ return
79
+ end
80
+
81
+ # Try to extract agent name and pad info from encrypted filename if provided
82
+ if encrypted_filename && !encrypted_filename.empty?
83
+ # Parse encrypted filename format: agentname_name-of-the-pad-file_padnumber_encrypted.txt
84
+ # Example: Abyss_Abyss-2026-01-12-001_pad2_encrypted.txt
85
+ if encrypted_filename.match(/^(.+?)_(.+?)_pad(\d+)_encrypted\.txt$/)
86
+ agent_name = $1
87
+ pad_filename = $2
88
+ pad_num_from_filename = $3
89
+
90
+ # Use the same format but with _decrypted.txt
91
+ filename = "#{agent_name}_#{pad_filename}_pad#{pad_num_from_filename}_decrypted.txt"
92
+ NumberStation.log.info "Writing decrypted message to file #{filename}"
93
+ File.write(filename, decrypted_content)
94
+ return
95
+ end
96
+ end
97
+
98
+ # Fallback: extract agent name from pad_path if available
99
+ # For now, use pad_id and pad_num as fallback
100
+ filename = "#{pad_id}_pad#{pad_num}_decrypted.txt"
101
+
102
+ NumberStation.log.info "Writing decrypted message to file #{filename}"
103
+ File.write(filename, decrypted_content)
104
+ rescue StandardError => e
105
+ NumberStation.log.error "Failed to write decrypted file: #{e.message}"
106
+ raise
107
+ end
64
108
  end
@@ -18,62 +18,133 @@
18
18
  You should have received a copy of the GNU General Public License
19
19
  along with this program. If not, see <https://www.gnu.org/licenses/>.
20
20
  =end
21
- require "securerandom"
22
21
  require "json"
23
22
 
24
23
  module NumberStation
25
-
26
- def self.encrypt_message(message, pad_path, pad_num)
24
+ def self.encrypt_message(message, pad_path, pad_num, message_file_path = nil)
27
25
  NumberStation.log.debug "message length: #{message.size}"
28
- message_byte_array = message.unpack('U*')
26
+
27
+ pad_data = load_pad_data(pad_path)
28
+ pad_key = pad_data["pads"][pad_num]["key"]
29
+
30
+ validate_message_length(message.size, pad_key.size)
31
+ mark_pad_as_consumed(pad_data, pad_path, pad_num)
29
32
 
30
- begin
31
- pad_data = JSON.parse(File.read(pad_path))
32
- rescue Exception => e
33
- raise e
34
- end
33
+ message_bytes = message.unpack('U*')
34
+ pad_bytes = hex_string_to_bytes(pad_key)
35
+ encrypted_bytes = xor_encrypt(message_bytes, pad_bytes)
36
+ encrypted_hex = bytes_to_hex_string(encrypted_bytes)
37
+ formatted_hex = format_hex_in_groups(encrypted_hex, 5)
38
+
39
+ write_encrypted_file(pad_path, pad_num, formatted_hex, message_file_path)
40
+ formatted_hex
41
+ end
35
42
 
36
- crypto_hex_str = pad_data["pads"][pad_num]["key"]
43
+ private
37
44
 
38
- if message.size > crypto_hex_str.size
39
- NumberStation.log.error "Exception: message length is larger than pad length. Break the message into smaller parts."
40
- exit
45
+ def self.load_pad_data(pad_path)
46
+ JSON.parse(File.read(pad_path))
47
+ rescue StandardError => e
48
+ NumberStation.log.error "Failed to load pad file: #{e.message}"
49
+ raise
50
+ end
51
+
52
+ def self.validate_message_length(message_size, pad_size)
53
+ if message_size > pad_size
54
+ NumberStation.log.error "Message length (#{message_size}) is larger than pad length (#{pad_size}). Break the message into smaller parts."
55
+ raise ArgumentError, "Message too long for pad"
41
56
  end
42
57
  NumberStation.log.debug "message length less than pad length"
58
+ end
43
59
 
44
- unless pad_data["pads"][pad_num]["consumed"]
45
- NumberStation.log.debug "Marking key as consumed"
46
- pad_data["pads"][pad_num]["epoch_date"] = Time.now.to_i
47
- pad_data["pads"][pad_num]["consumed"] = true
48
- f = File.open(pad_path, "w")
49
- f.write(pad_data.to_json)
50
- f.close
51
- else
52
- msg = "Warning pad #{pad_num} has been consumed on #{Time.at(pad_data["pads"][pad_num]["epoch_date"])}"
53
- NumberStation.log.error msg
54
- exit
60
+ def self.mark_pad_as_consumed(pad_data, pad_path, pad_num)
61
+ pad = pad_data["pads"][pad_num]
62
+
63
+ if pad["consumed"]
64
+ consumed_date = Time.at(pad["epoch_date"])
65
+ error_msg = "Pad #{pad_num} has already been consumed on #{consumed_date}"
66
+ NumberStation.log.error error_msg
67
+ raise ArgumentError, error_msg
55
68
  end
56
69
 
57
- crypto_byte_array = crypto_hex_str.scan(/.{1}/).each_slice(2).map { |f, l| (Integer(f,16) << 4) + Integer(l,16) }
70
+ NumberStation.log.debug "Marking key as consumed"
71
+ pad["epoch_date"] = Time.now.to_i
72
+ pad["consumed"] = true
73
+ File.write(pad_path, pad_data.to_json)
74
+ end
75
+
76
+ def self.hex_string_to_bytes(hex_string)
77
+ hex_string.scan(/.{2}/).map { |pair| pair.to_i(16) }
78
+ end
79
+
80
+ def self.xor_encrypt(message_bytes, pad_bytes)
81
+ message_bytes.map.with_index { |byte, index| byte ^ pad_bytes[index] }
82
+ end
83
+
84
+ def self.bytes_to_hex_string(bytes)
85
+ bytes.map { |byte| '%02x' % (byte & 0xFF) }.join
86
+ end
58
87
 
59
- encrypted_byte_array = []
60
- message_byte_array.each_with_index do |i, index|
61
- encrypted_byte_array << (i ^ crypto_byte_array[index])
88
+ def self.format_hex_in_groups(hex_string, group_size)
89
+ hex_string.scan(/.{1,#{group_size}}/).join(' ')
90
+ end
91
+
92
+ def self.write_encrypted_file(pad_path, pad_num, encrypted_content, message_file_path = nil)
93
+ # Extract agent name from pad path if it's in an agent subdirectory
94
+ # Path format: ~/number_station/pads/AGENTNAME/padfile.json
95
+ agent_name = extract_agent_name_from_path(pad_path)
96
+
97
+ # Extract pad filename without extension
98
+ pad_basename = File.basename(pad_path, ".json")
99
+
100
+ # Build filename: agentname_name-of-the-pad-file_padnumber_encrypted.txt
101
+ if agent_name && !agent_name.empty?
102
+ filename = "#{agent_name}_#{pad_basename}_pad#{pad_num}_encrypted.txt"
103
+ else
104
+ # Fallback if no agent name found
105
+ filename = "#{pad_basename}_pad#{pad_num}_encrypted.txt"
62
106
  end
63
-
64
- encrypted_byte_str = encrypted_byte_array.map { |n| '%02X' % (n & 0xFF) }.join.downcase
65
-
66
- begin
67
- f_name = "#{pad_data["id"]}_#{pad_num}_#{Time.now.to_i}.txt"
68
- NumberStation.log.info "Writing encrypted message to file #{f_name}"
69
- f = File.open(f_name, "w")
70
- f.write(encrypted_byte_str)
71
- f.close
72
- rescue Exception => e
73
- raise e
107
+
108
+ # Write to file if:
109
+ # 1. Encrypting from a file (message_file_path is actual file path), OR
110
+ # 2. Encrypting from string but agent was specified (message_file_path is "string_input")
111
+ if message_file_path
112
+ NumberStation.log.info "Writing encrypted message to file #{filename}"
113
+ File.write(filename, encrypted_content)
114
+ else
115
+ NumberStation.log.debug "Encrypted message (not saving to file): #{filename}"
74
116
  end
75
-
76
- return encrypted_byte_str
117
+ rescue StandardError => e
118
+ NumberStation.log.error "Failed to write encrypted file: #{e.message}"
119
+ raise
77
120
  end
78
121
 
122
+ def self.extract_agent_name_from_path(pad_path)
123
+ # Extract agent name from path like: ~/number_station/pads/AGENTNAME/padfile.json
124
+ # or: /path/to/pads/AGENTNAME/padfile.json
125
+ normalized_path = File.expand_path(pad_path)
126
+ pads_dir = File.expand_path(File.join(Dir.home, "number_station", "pads"))
127
+
128
+ # Check if pad is in a subdirectory of pads directory
129
+ if normalized_path.start_with?(pads_dir + File::SEPARATOR)
130
+ relative_path = normalized_path.sub(pads_dir + File::SEPARATOR, "")
131
+ path_parts = relative_path.split(File::SEPARATOR)
132
+
133
+ # If path has multiple parts, first part is likely agent name
134
+ if path_parts.length > 1
135
+ agent_name = path_parts.first
136
+ # Verify it's not just the pad filename (should be a directory)
137
+ return agent_name if File.directory?(File.join(pads_dir, agent_name))
138
+ end
139
+ end
140
+
141
+ # Try to extract from filename if it starts with agent name pattern
142
+ # Format: agentname-YYYY-MM-DD.json
143
+ pad_basename = File.basename(pad_path, ".json")
144
+ if pad_basename.match?(/^([\w-]+)-\d{4}-\d{2}-\d{2}(-\d{3})?$/)
145
+ return $1
146
+ end
147
+
148
+ nil
149
+ end
79
150
  end
@@ -0,0 +1,171 @@
1
+ =begin
2
+ Ruby Number Station
3
+ Author: David Kirwan https://gitub.com/davidkirwan
4
+ Licence: GPL 3.0
5
+ NumberStation is a collection of utilities to aid in the running of a number station
6
+ Copyright (C) 2018 David Kirwan
7
+
8
+ This program is free software: you can redistribute it and/or modify
9
+ it under the terms of the GNU General Public License as published by
10
+ the Free Software Foundation, either version 3 of the License, or
11
+ (at your option) any later version.
12
+
13
+ This program is distributed in the hope that it will be useful,
14
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
15
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
16
+ GNU General Public License for more details.
17
+
18
+ You should have received a copy of the GNU General Public License
19
+ along with this program. If not, see <https://www.gnu.org/licenses/>.
20
+ =end
21
+
22
+ require "json"
23
+
24
+ module NumberStation
25
+ def self.examine_pads(pads_directory = nil)
26
+ pads_dir = pads_directory || File.join(Dir.home, "number_station", "pads")
27
+
28
+ unless Dir.exist?(pads_dir)
29
+ NumberStation.log.warn "Pads directory does not exist: #{pads_dir}"
30
+ return []
31
+ end
32
+
33
+ # Look for pad files in various formats:
34
+ # - Old format: one_time_pad_XXXXX.json (random number)
35
+ # - New format: agentname-YYYY-MM-DD.json or one_time_pad-YYYY-MM-DD.json (date-based)
36
+ # - New format with counter: agentname-YYYY-MM-DD-001.json
37
+ pad_files = Dir.glob(File.join(pads_dir, "*.json")).select do |file|
38
+ basename = File.basename(file)
39
+ # Match old format: one_time_pad_XXXXX.json
40
+ # Match new format: agentname-YYYY-MM-DD.json or one_time_pad-YYYY-MM-DD.json
41
+ # Match new format with counter: agentname-YYYY-MM-DD-001.json
42
+ basename.match?(/^(one_time_pad|[\w-]+)[_-]\d{4}-\d{2}-\d{2}(-\d{3})?\.json$/) ||
43
+ basename.match?(/^one_time_pad_\d+\.json$/) ||
44
+ basename.match?(/^[\w-]+_\d+\.json$/)
45
+ end
46
+
47
+ if pad_files.empty?
48
+ NumberStation.log.info "No pad files found in #{pads_dir}"
49
+ return []
50
+ end
51
+
52
+ pad_files.map { |file| examine_pad_file(file) }
53
+ end
54
+
55
+ def self.find_next_available_pad(pads_directory = nil, min_length = nil, require_unconsumed = true, agent_name = nil)
56
+ # Determine pads directory based on agent name
57
+ if agent_name && !agent_name.empty?
58
+ pads_dir = File.join(Dir.home, "number_station", "pads", agent_name)
59
+ else
60
+ pads_dir = pads_directory || File.join(Dir.home, "number_station", "pads")
61
+ end
62
+
63
+ unless Dir.exist?(pads_dir)
64
+ error_msg = agent_name ? "Agent pad directory does not exist: #{pads_dir}" : "Pads directory does not exist: #{pads_dir}"
65
+ raise ArgumentError, error_msg
66
+ end
67
+
68
+ # Look for pad files in various formats:
69
+ # - Old format: one_time_pad_XXXXX.json (random number)
70
+ # - New format: agentname-YYYY-MM-DD.json or one_time_pad-YYYY-MM-DD.json (date-based)
71
+ # - New format with counter: agentname-YYYY-MM-DD-001.json
72
+ pad_files = Dir.glob(File.join(pads_dir, "*.json")).select do |file|
73
+ basename = File.basename(file)
74
+ # Match old format: one_time_pad_XXXXX.json
75
+ # Match new format: agentname-YYYY-MM-DD.json or one_time_pad-YYYY-MM-DD.json
76
+ # Match new format with counter: agentname-YYYY-MM-DD-001.json
77
+ basename.match?(/^(one_time_pad|[\w-]+)[_-]\d{4}-\d{2}-\d{2}(-\d{3})?\.json$/) ||
78
+ basename.match?(/^one_time_pad_\d+\.json$/) ||
79
+ basename.match?(/^[\w-]+_\d+\.json$/)
80
+ end
81
+
82
+ if pad_files.empty?
83
+ error_msg = agent_name ? "No pad files found for agent '#{agent_name}' in #{pads_dir}" : "No pad files found in #{pads_dir}"
84
+ raise ArgumentError, error_msg
85
+ end
86
+
87
+ # Sort pads to find the oldest one
88
+ # For date-based filenames, alphabetical sort works (YYYY-MM-DD format)
89
+ # For old format (random numbers), sort alphabetically still works
90
+ pad_files.sort!
91
+
92
+ # Find the oldest pad file that has at least one unconsumed pad
93
+ pad_files.each do |file_path|
94
+ pad_data = JSON.parse(File.read(file_path))
95
+ pads_hash = pad_data["pads"]
96
+
97
+ next if pads_hash.nil? || pads_hash.empty?
98
+
99
+ # Check if this pad file has any unconsumed pads
100
+ has_unconsumed = pads_hash.values.any? { |pad| !pad["consumed"] }
101
+ next if require_unconsumed && !has_unconsumed
102
+
103
+ # Find first pad (unconsumed if require_unconsumed is true)
104
+ pads_hash.each do |pad_num_str, pad|
105
+ next if require_unconsumed && pad["consumed"]
106
+
107
+ # Check if pad is long enough if min_length is specified
108
+ if min_length
109
+ pad_key_length = pad["key"].length / 2 # Convert hex length to byte length
110
+ next if pad_key_length < min_length
111
+ end
112
+
113
+ return {
114
+ pad_path: file_path,
115
+ pad_num: pad_num_str,
116
+ pad_id: pad_data["id"].to_s
117
+ }
118
+ end
119
+ end
120
+
121
+ error_msg = require_unconsumed ? "No available (unconsumed) pads found" : "No pads found"
122
+ error_msg += agent_name ? " for agent '#{agent_name}'" : ""
123
+ raise ArgumentError, "#{error_msg} in #{pads_dir}"
124
+ rescue JSON::ParserError => e
125
+ NumberStation.log.error "Failed to parse pad file: #{e.message}"
126
+ raise
127
+ rescue StandardError => e
128
+ NumberStation.log.error "Error finding pad: #{e.message}"
129
+ raise
130
+ end
131
+
132
+ def self.examine_pad_file(file_path)
133
+ pad_data = JSON.parse(File.read(file_path))
134
+ filename = File.basename(file_path)
135
+
136
+ # Pad data structure: { "id" => "...", "pads" => { "0" => {...}, "1" => {...}, ... } }
137
+ pads_hash = pad_data["pads"]
138
+
139
+ if pads_hash.nil? || pads_hash.empty?
140
+ return {
141
+ filename: filename,
142
+ error: "No pads found in file"
143
+ }
144
+ end
145
+
146
+ # Get max message length from first pad's key length
147
+ # Key is hex string, so bytes = hex_length / 2
148
+ # Max message length in characters equals pad length in bytes
149
+ first_pad_key = pads_hash.values.first["key"]
150
+ max_message_length = first_pad_key.length / 2
151
+
152
+ # Count unconsumed pads
153
+ unconsumed_count = pads_hash.values.count { |pad| !pad["consumed"] }
154
+ total_pads = pads_hash.size
155
+
156
+ {
157
+ filename: filename,
158
+ pad_id: pad_data["id"].to_s,
159
+ max_message_length: max_message_length,
160
+ total_pads: total_pads,
161
+ unconsumed_pads: unconsumed_count,
162
+ consumed_pads: total_pads - unconsumed_count
163
+ }
164
+ rescue StandardError => e
165
+ NumberStation.log.error "Failed to examine pad file #{file_path}: #{e.message}"
166
+ {
167
+ filename: File.basename(file_path),
168
+ error: e.message
169
+ }
170
+ end
171
+ end