meshtastic 0.0.187 → 0.0.188
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 +4 -4
- data/documentation/README.md +2 -1
- data/documentation/admin-backup.md +82 -3
- data/documentation/admin-config.md +28 -1
- data/documentation/admin-firmware-migration.md +171 -0
- data/documentation/admin-firmware.md +7 -3
- data/documentation/admin.md +16 -0
- data/documentation/mesh-interface.md +46 -18
- data/documentation/mqtt.md +63 -71
- data/documentation/waypoint.md +54 -0
- data/lib/meshtastic/admin/backup.rb +44 -5
- data/lib/meshtastic/admin/channel.rb +3 -8
- data/lib/meshtastic/admin/config.rb +63 -1
- data/lib/meshtastic/admin/firmware.rb +43 -10
- data/lib/meshtastic/admin/validation.rb +78 -0
- data/lib/meshtastic/admin.rb +28 -22
- data/lib/meshtastic/mesh_interface.rb +123 -43
- data/lib/meshtastic/module_config.rb +2 -0
- data/lib/meshtastic/mqtt.rb +21 -12
- data/lib/meshtastic/version.rb +1 -1
- data/lib/meshtastic/waypoint.rb +85 -0
- data/lib/meshtastic.rb +1 -0
- data/spec/lib/meshtastic/admin/backup_spec.rb +153 -9
- data/spec/lib/meshtastic/admin/config_spec.rb +70 -1
- data/spec/lib/meshtastic/admin/firmware_migration_spec.rb +121 -0
- data/spec/lib/meshtastic/admin/firmware_spec.rb +1 -1
- data/spec/lib/meshtastic/admin/firmware_target_selection_spec.rb +44 -0
- data/spec/lib/meshtastic/admin/validation_spec.rb +89 -0
- data/spec/lib/meshtastic/admin_spec.rb +77 -0
- data/spec/lib/meshtastic/bluetooth_spec.rb +26 -1
- data/spec/lib/meshtastic/mesh_interface_spec.rb +54 -0
- data/spec/lib/meshtastic/module_config_spec.rb +1 -1
- data/spec/lib/meshtastic/mqtt_spec.rb +165 -0
- data/spec/lib/meshtastic/serial_spec.rb +26 -0
- data/spec/lib/meshtastic/tcp_spec.rb +29 -0
- data/spec/lib/meshtastic/waypoint_spec.rb +55 -0
- data/spec/support/channel_crypto_reference.rb +29 -0
- data/spec/support/firmware_release_artifact_audit.rb +39 -0
- metadata +11 -1
|
@@ -18,7 +18,7 @@ class BackupRadioPeer
|
|
|
18
18
|
device: Meshtastic::Config.new(device: { serial_enabled: false }),
|
|
19
19
|
network: Meshtastic::Config.new(network: { wifi_enabled: true, wifi_psk: 'password' }),
|
|
20
20
|
mqtt: Meshtastic::ModuleConfig.new(mqtt: { enabled: false, password: 'broker-secret' }),
|
|
21
|
-
channel: Meshtastic::Channel.new(index: 0, role: :PRIMARY, settings: { psk: "\x00\xff".b, name: 'Test' }),
|
|
21
|
+
channel: Meshtastic::Channel.new(index: 0, role: :PRIMARY, settings: { psk: "\x00\xff".b * 8, name: 'Test' }),
|
|
22
22
|
ui: Meshtastic::DeviceUIConfig.new(calibration_data: "\x00\xff".b)
|
|
23
23
|
}
|
|
24
24
|
@handle = { serial_conn: self, my_node_num: 0xaabbccdd, from_radio_queue: Queue.new }
|
|
@@ -75,6 +75,150 @@ describe 'Meshtastic::Admin::Backup' do
|
|
|
75
75
|
let(:peer) { BackupRadioPeer.new }
|
|
76
76
|
let(:options) { { transport_obj: peer.handle, config_types: [:DEVICE_CONFIG], module_config_types: [:MQTT_CONFIG], channel_indexes: [0], include_ui: true, timeout: 0.3 } }
|
|
77
77
|
|
|
78
|
+
it 'rejects semantic errors in every backup format before any requests' do
|
|
79
|
+
invalid_messages = [Meshtastic::User.new(long_name: 'é' * 13),
|
|
80
|
+
Meshtastic::Channel.new(index: 0, settings: { psk: 'bad' }),
|
|
81
|
+
Meshtastic::Config.new(lora: { region: 999 })]
|
|
82
|
+
invalid_messages.zip([['owner', nil], ['channel', 0], %w[config LORA_CONFIG]]).each do |message, (section, slot)|
|
|
83
|
+
document = { 'format' => backup_api::FORMAT, 'version' => 1, 'warning' => '', 'records' => [
|
|
84
|
+
{ 'section' => section, 'slot' => slot, 'value' => JSON.parse(message.class.encode_json(message, preserve_proto_fieldnames: true)) }
|
|
85
|
+
] }
|
|
86
|
+
expect { backup_api.import(transport_obj: peer.handle, backup: document, dry_run: true) }.to raise_error(ArgumentError)
|
|
87
|
+
end
|
|
88
|
+
profile = Meshtastic::DeviceProfile.new(long_name: 'é' * 13)
|
|
89
|
+
expect { backup_api.import(transport_obj: peer.handle, backup: profile.to_proto) }.to raise_error(ArgumentError)
|
|
90
|
+
expect(peer.sent).to be_empty
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
it 'requires explicit identity-key restoration without conflating omitted owner metadata' do
|
|
94
|
+
profile = Meshtastic::DeviceProfile.new(config: { security: { private_key: 'k' * 32, public_key: 'p' * 32 } })
|
|
95
|
+
bytes = profile.to_proto
|
|
96
|
+
expect { backup_api.import(transport_obj: peer.handle, backup: bytes) }.to raise_error(ArgumentError, /identity_keys/)
|
|
97
|
+
expect(peer.sent).to be_empty
|
|
98
|
+
result = backup_api.import(transport_obj: peer.handle, backup: bytes, identity_keys: :restore, timeout: 0.3)
|
|
99
|
+
expect(result).to include(acknowledged: 1, persistence_verified: false, reconnect_required: true)
|
|
100
|
+
expect(result[:warnings].join).to match(/node number.*reconnect/i)
|
|
101
|
+
expect(peer.sent.last.set_config.security.private_key).to eq('k' * 32)
|
|
102
|
+
expect(bytes).to eq(profile.to_proto)
|
|
103
|
+
peer.sent.clear
|
|
104
|
+
clean = Meshtastic::DeviceProfile.new(config: { security: { serial_enabled: true } })
|
|
105
|
+
expect { backup_api.import(transport_obj: peer.handle, backup: clean.to_proto, dry_run: true) }.not_to raise_error
|
|
106
|
+
expect(peer.sent).to be_empty
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
it 'checks policy consistently for JSON security keys and channel sharing before edit requests' do
|
|
110
|
+
messages = [[Meshtastic::Config.new(security: { public_key: 'p' * 32 }), 'config', 'SECURITY_CONFIG', { identity_keys: :restore }],
|
|
111
|
+
[Meshtastic::Channel.new(settings: { uplink_enabled: true }), 'channel', 0, { allow_privacy_changes: true }]]
|
|
112
|
+
messages.each do |message, section, slot, consent|
|
|
113
|
+
document = { 'format' => backup_api::FORMAT, 'version' => 1, 'warning' => '', 'records' => [
|
|
114
|
+
{ 'section' => section, 'slot' => slot, 'value' => JSON.parse(message.class.encode_json(message, preserve_proto_fieldnames: true)) }
|
|
115
|
+
] }
|
|
116
|
+
expect { backup_api.import(transport_obj: peer.handle, backup: document, edit_transaction: true) }.to raise_error(ArgumentError)
|
|
117
|
+
report = backup_api.import({ transport_obj: peer.handle, backup: document, dry_run: true }.merge(consent))
|
|
118
|
+
expect(report[:planned]).to eq(1)
|
|
119
|
+
expect(peer.sent).to be_empty
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
it 'documents explicit restore consent and reconnect limitations in runtime help' do
|
|
124
|
+
expect { backup_api.help }.to output(/identity_keys:.*:reject.*:restore.*allow_privacy_changes:.*reconnect/m).to_stdout
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
it 'requires privacy opt-in for location and broker sharing without silently redacting values' do
|
|
128
|
+
profiles = [Meshtastic::DeviceProfile.new(fixed_position: { latitude_i: 1 }),
|
|
129
|
+
Meshtastic::DeviceProfile.new(config: { position: {} }),
|
|
130
|
+
Meshtastic::DeviceProfile.new(module_config: { mqtt: { enabled: true } }),
|
|
131
|
+
Meshtastic::DeviceProfile.new(channel_url: Meshtastic::Admin::Channel.export_url(channels: [
|
|
132
|
+
Meshtastic::Channel.new(role: :PRIMARY, settings: { module_settings: { position_precision: 32 } })
|
|
133
|
+
]))]
|
|
134
|
+
profiles.each do |profile|
|
|
135
|
+
bytes = profile.to_proto
|
|
136
|
+
expect { backup_api.import(transport_obj: peer.handle, backup: bytes) }.to raise_error(ArgumentError, /allow_privacy_changes/)
|
|
137
|
+
expect(peer.sent).to be_empty
|
|
138
|
+
report = backup_api.import(transport_obj: peer.handle, backup: bytes, dry_run: true, allow_privacy_changes: true)
|
|
139
|
+
expect(report[:warnings].join).to match(/privacy.*location.*MQTT/i)
|
|
140
|
+
expect(report[:planned]).to be_positive
|
|
141
|
+
expect(bytes).to eq(profile.to_proto)
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
describe 'Meshtastic::Admin::Backup telemetry privacy' do
|
|
147
|
+
let(:backup_api) { Meshtastic::Admin::Backup }
|
|
148
|
+
let(:peer) { BackupRadioPeer.new }
|
|
149
|
+
|
|
150
|
+
%i[json device_profile].each do |format|
|
|
151
|
+
context "with #{format} input" do
|
|
152
|
+
let(:telemetry) { {} }
|
|
153
|
+
let(:message) { Meshtastic::ModuleConfig.new(telemetry: telemetry) }
|
|
154
|
+
let(:backup) do
|
|
155
|
+
if format == :device_profile
|
|
156
|
+
Meshtastic::DeviceProfile.new(long_name: 'Portable', module_config: { telemetry: telemetry }).to_proto
|
|
157
|
+
else
|
|
158
|
+
JSON.generate({ 'format' => backup_api::FORMAT, 'version' => 1, 'warning' => '', 'records' => [
|
|
159
|
+
{ 'section' => 'owner', 'slot' => nil, 'value' => { 'long_name' => 'Portable' } },
|
|
160
|
+
{ 'section' => 'module_config', 'slot' => 'TELEMETRY_CONFIG',
|
|
161
|
+
'value' => JSON.parse(message.class.encode_json(message, preserve_proto_fieldnames: true)) }
|
|
162
|
+
] })
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
let(:options) { { transport_obj: peer.handle, backup: backup, format: format, timeout: 0.3 } }
|
|
166
|
+
|
|
167
|
+
%i[device_telemetry_enabled environment_measurement_enabled air_quality_enabled power_measurement_enabled health_measurement_enabled].each do |flag|
|
|
168
|
+
it "rejects #{flag} before any requests, including dry runs and edit transactions" do
|
|
169
|
+
telemetry[flag] = true
|
|
170
|
+
[{}, { dry_run: true }, { edit_transaction: true }, { allow_privacy_changes: false }].each do |policy|
|
|
171
|
+
expect { backup_api.import(options.merge(policy)) }.to raise_error(ArgumentError, /allow_privacy_changes/)
|
|
172
|
+
expect(peer.sent).to be_empty
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
it "restores #{flag} unchanged only with explicit consent and warns without values" do
|
|
177
|
+
telemetry.merge!(flag => true, device_update_interval: 123_457)
|
|
178
|
+
original = backup.dup
|
|
179
|
+
report = backup_api.import(options.merge(allow_privacy_changes: true, dry_run: true))
|
|
180
|
+
expect(peer.sent).to be_empty
|
|
181
|
+
expect(report[:warnings].join).to match(/telemetry.*device.*environment.*air quality.*power.*health/i)
|
|
182
|
+
expect(report.inspect).not_to include('123457', 'Portable')
|
|
183
|
+
result = backup_api.import(options.merge(allow_privacy_changes: true, verify: true))
|
|
184
|
+
expect(result).to include(status: :readback_matched, acknowledged: 2, readback_matched: 2)
|
|
185
|
+
expect(peer.sent.find { |sent| sent.payload_variant == :set_module_config }.set_module_config).to eq(message)
|
|
186
|
+
expect(backup).to eq(original)
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
it 'permits disabled broadcasts with nonzero intervals and local display preferences' do
|
|
191
|
+
telemetry.merge!(device_telemetry_enabled: false, environment_measurement_enabled: false,
|
|
192
|
+
air_quality_enabled: false, power_measurement_enabled: false, health_measurement_enabled: false,
|
|
193
|
+
device_update_interval: 60, environment_update_interval: 60, air_quality_interval: 60,
|
|
194
|
+
power_update_interval: 60, health_update_interval: 60, environment_screen_enabled: true,
|
|
195
|
+
power_screen_enabled: true, health_screen_enabled: true, air_quality_screen_enabled: true,
|
|
196
|
+
environment_display_fahrenheit: true)
|
|
197
|
+
result = backup_api.import(options.merge(verify: true))
|
|
198
|
+
expect(result).to include(status: :readback_matched, acknowledged: 2)
|
|
199
|
+
expect(result[:warnings].join).not_to include('Privacy:')
|
|
200
|
+
expect(peer.state[:TELEMETRY_CONFIG]).to eq(message)
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
it 'permits an empty telemetry section without privacy consent' do
|
|
204
|
+
result = backup_api.import(options.merge(dry_run: true))
|
|
205
|
+
expect(result).to include(status: :dry_run, planned: 2)
|
|
206
|
+
expect(result[:warnings].join).not_to include('Privacy:')
|
|
207
|
+
expect(peer.sent).to be_empty
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
it 'documents telemetry consent in runtime help' do
|
|
213
|
+
expect { backup_api.help }.to output(/allow_privacy_changes:.*telemetry.*device.*environment.*air quality.*power.*health/).to_stdout
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
describe 'Meshtastic::Admin::Backup export' do
|
|
218
|
+
let(:backup_api) { Meshtastic::Admin::Backup }
|
|
219
|
+
let(:peer) { BackupRadioPeer.new }
|
|
220
|
+
let(:options) { { transport_obj: peer.handle, config_types: [:DEVICE_CONFIG], module_config_types: [:MQTT_CONFIG], channel_indexes: [0], include_ui: true, timeout: 0.3 } }
|
|
221
|
+
|
|
78
222
|
it 'exports binary DeviceProfile bytes with fresh values and presence-aware semantic round trips' do
|
|
79
223
|
peer.state[:owner].is_unmessagable = false
|
|
80
224
|
peer.state[:channel].settings.psk = "\x01".b
|
|
@@ -153,7 +297,7 @@ describe 'Meshtastic::Admin::Backup' do
|
|
|
153
297
|
expect(profile.canned_messages).to eq('')
|
|
154
298
|
expect(peer.sent.map(&:payload_variant)).to eq(%i[get_ringtone_request get_canned_message_module_messages_request])
|
|
155
299
|
peer.sent.clear
|
|
156
|
-
expect(backup_api.import(transport_obj: peer.handle, backup: result[:backup], dry_run: true)[:planned]).to eq(3)
|
|
300
|
+
expect(backup_api.import(transport_obj: peer.handle, backup: result[:backup], dry_run: true, allow_privacy_changes: true)[:planned]).to eq(3)
|
|
157
301
|
expect(peer.sent).to be_empty
|
|
158
302
|
[nil, 'bad', { unknown: 1 }].each do |position|
|
|
159
303
|
expect { backup_api.export(opts.merge(fixed_position: position)) }.to raise_error(ArgumentError)
|
|
@@ -183,7 +327,7 @@ describe 'Meshtastic::Admin::Backup' do
|
|
|
183
327
|
expect(profile.module_config[field.name]).to eq(peer.state[backup_api::MODULE_TYPES[index]][field.name])
|
|
184
328
|
end
|
|
185
329
|
peer.sent.clear
|
|
186
|
-
expect(backup_api.import(transport_obj: peer.handle, backup: result[:backup], dry_run: true)[:planned]).to eq(25)
|
|
330
|
+
expect(backup_api.import(transport_obj: peer.handle, backup: result[:backup], dry_run: true, allow_privacy_changes: true)[:planned]).to eq(25)
|
|
187
331
|
expect(peer.sent).to be_empty
|
|
188
332
|
peer.state[:device] = Meshtastic::Config.new(network: {})
|
|
189
333
|
expect { backup_api.export(options.merge(format: :device_profile, include_ui: false, channel_indexes: [])) }.to raise_error(ArgumentError, /slot/)
|
|
@@ -219,14 +363,14 @@ describe 'Meshtastic::Admin::Backup import and JSON export' do
|
|
|
219
363
|
Meshtastic::Config.descriptor.to_a.take(8).each { |field| config[field.name] = field.subtype.msgclass.new }
|
|
220
364
|
modules = Meshtastic::LocalModuleConfig.new(version: 24)
|
|
221
365
|
Meshtastic::ModuleConfig.descriptor.each { |field| modules[field.name] = field.subtype.msgclass.new }
|
|
222
|
-
config.security = Meshtastic::Config::SecurityConfig.new(private_key: "\x00\xff".b, admin_key: ['
|
|
366
|
+
config.security = Meshtastic::Config::SecurityConfig.new(private_key: "\x00\xff".b * 16, admin_key: ['k' * 32], serial_enabled: false)
|
|
223
367
|
config.lora = Meshtastic::Config::LoRaConfig.new(region: :US, tx_power: -1, frequency_offset: 1.25, ignore_incoming: [1, 0xffffffff])
|
|
224
368
|
modules.mqtt = Meshtastic::ModuleConfig::MQTTConfig.new(enabled: false, password: 'synthetic-password')
|
|
225
369
|
channels = Meshtastic::ChannelSet.new(settings: [{ name: 'test', psk: "\x01".b }], lora_config: config.lora)
|
|
226
370
|
profile = Meshtastic::DeviceProfile.new(long_name: 'Profile', short_name: '', is_licensed: false, is_unmessagable: false,
|
|
227
371
|
config: config, module_config: modules, channel_url: "https://meshtastic.org/e/##{Base64.urlsafe_encode64(channels.to_proto, padding: false)}",
|
|
228
372
|
fixed_position: { latitude_i: 0, longitude_i: 0, altitude: 0 }, ringtone: '', canned_messages: '')
|
|
229
|
-
result = backup_api.import(transport_obj: peer.handle, backup: profile.to_proto, format: :device_profile, verify: true, timeout: 0.3)
|
|
373
|
+
result = backup_api.import(transport_obj: peer.handle, backup: profile.to_proto, format: :device_profile, verify: true, identity_keys: :restore, allow_privacy_changes: true, timeout: 0.3)
|
|
230
374
|
expect(result).to include(status: :readback_incomplete, planned: 30, acknowledged: 30, readback_matched: 29)
|
|
231
375
|
expect(result[:records].find { |record| record[:section] == 'fixed_position' }).to include(readback: :unsupported)
|
|
232
376
|
expect(peer.state[:owner]).to eq(Meshtastic::User.new(long_name: 'Profile', short_name: '', is_licensed: false, is_unmessagable: false))
|
|
@@ -344,7 +488,7 @@ describe 'Meshtastic::Admin::Backup missing write ACK recovery' do
|
|
|
344
488
|
let(:backup_api) { Meshtastic::Admin::Backup }
|
|
345
489
|
let(:peer) { BackupRadioPeer.new }
|
|
346
490
|
let(:profile) { Meshtastic::DeviceProfile.new(module_config: { mqtt: { enabled: true, password: 'synthetic-secret' } }, config: { lora: { region: :US } }) }
|
|
347
|
-
let(:options) { { transport_obj: peer.handle, backup: profile.to_proto, edit_transaction: true, timeout: 0.15 } }
|
|
491
|
+
let(:options) { { transport_obj: peer.handle, backup: profile.to_proto, edit_transaction: true, allow_privacy_changes: true, timeout: 0.15 } }
|
|
348
492
|
|
|
349
493
|
before { peer.drop_ack_on = :set_module_config }
|
|
350
494
|
|
|
@@ -517,13 +661,13 @@ describe 'Meshtastic::Admin::Backup DeviceProfile validation' do
|
|
|
517
661
|
it 'keeps ordinary profile writes in source plan order before disruptive sections' do
|
|
518
662
|
modules = Meshtastic::ModuleConfig.descriptor.to_h { |field| [field.name.to_sym, {}] }
|
|
519
663
|
profile = Meshtastic::DeviceProfile.new(long_name: 'order', ringtone: '', canned_messages: '', config: { device: {}, position: {}, power: {}, display: {}, lora: {}, security: {} }, module_config: modules)
|
|
520
|
-
plan = backup_api.import(transport_obj: peer.handle, backup: profile.to_proto, dry_run: true)[:plan]
|
|
664
|
+
plan = backup_api.import(transport_obj: peer.handle, backup: profile.to_proto, dry_run: true, allow_privacy_changes: true)[:plan]
|
|
521
665
|
expect(plan.map { |entry| entry[:slot] || entry[:section] }).to eq(%w[owner ringtone canned_messages DEVICE_CONFIG POSITION_CONFIG POWER_CONFIG DISPLAY_CONFIG] + backup_api::MODULE_TYPES.map(&:to_s) + %w[LORA_CONFIG SECURITY_CONFIG])
|
|
522
666
|
expect(peer.sent).to be_empty
|
|
523
667
|
end
|
|
524
668
|
|
|
525
669
|
it 'auto-detects binary content and cfg paths while retaining explicit and automatic JSON input' do
|
|
526
|
-
profile = Meshtastic::DeviceProfile.new(long_name: 'x' *
|
|
670
|
+
profile = Meshtastic::DeviceProfile.new(long_name: 'x' * 24, is_unmessagable: false)
|
|
527
671
|
Dir.mktmpdir do |directory|
|
|
528
672
|
['profile.cfg', 'profile.bin'].each do |name|
|
|
529
673
|
path = File.join(directory, name)
|
|
@@ -683,7 +827,7 @@ describe 'Meshtastic::Admin::Backup validation and readback' do
|
|
|
683
827
|
end
|
|
684
828
|
document = backup_api.export(options.merge(config_types: backup_api::CONFIG_TYPES, module_config_types: backup_api::MODULE_TYPES, include_owner: false, include_ui: false, channel_indexes: []))[:backup]
|
|
685
829
|
peer.sent.clear
|
|
686
|
-
result = backup_api.import(transport_obj: peer.handle, backup: document, timeout: 0.3)
|
|
830
|
+
result = backup_api.import(transport_obj: peer.handle, backup: document, allow_privacy_changes: true, timeout: 0.3)
|
|
687
831
|
expect(result[:acknowledged]).to eq(configs.length + modules.length)
|
|
688
832
|
restored = peer.sent.map { |message| message.public_send(message.payload_variant) }
|
|
689
833
|
expect(restored).to match_array(configs + modules)
|
|
@@ -9,7 +9,7 @@ RSpec.describe Meshtastic::Admin::Config do
|
|
|
9
9
|
end
|
|
10
10
|
end
|
|
11
11
|
|
|
12
|
-
|
|
12
|
+
module AdminConfigSpecHelpers
|
|
13
13
|
def fake_serial_obj
|
|
14
14
|
written = +''.b
|
|
15
15
|
serial_conn = Object.new
|
|
@@ -28,6 +28,10 @@ describe Meshtastic::Admin::Config do
|
|
|
28
28
|
body = frame.byteslice(4, (frame.getbyte(2) << 8) + frame.getbyte(3))
|
|
29
29
|
Meshtastic::AdminMessage.decode(Meshtastic::ToRadio.decode(body).packet.decoded.payload)
|
|
30
30
|
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
describe Meshtastic::Admin::Config do
|
|
34
|
+
include AdminConfigSpecHelpers
|
|
31
35
|
|
|
32
36
|
it 'rejects legacy connection keys rather than silently filtering them' do
|
|
33
37
|
%i[serial_obj bluetooth_obj tcp_obj mqtt_obj].each do |key|
|
|
@@ -123,6 +127,71 @@ describe Meshtastic::Admin::Config do
|
|
|
123
127
|
expect(message.get_config_request).to eq(config_type)
|
|
124
128
|
end
|
|
125
129
|
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
describe Meshtastic::Admin::Config, 'read-modify-readback helpers' do
|
|
133
|
+
include AdminConfigSpecHelpers
|
|
134
|
+
|
|
135
|
+
it 'updates signature policy with fresh correlated readback while preserving other security fields' do
|
|
136
|
+
current = Meshtastic::Config.new(security: { serial_enabled: true, public_key: 'k' * 32 })
|
|
137
|
+
handle = fake_serial_obj.merge(from_radio_queue: Queue.new, messages: [])
|
|
138
|
+
handle[:serial_conn].define_singleton_method(:write) do |bytes|
|
|
139
|
+
packet = Meshtastic::ToRadio.decode(bytes.byteslice(4..)).packet
|
|
140
|
+
message = Meshtastic::AdminMessage.decode(packet.decoded.payload)
|
|
141
|
+
handle[:messages] << message
|
|
142
|
+
if message.payload_variant == :get_config_request
|
|
143
|
+
payload = Meshtastic::AdminMessage.new(get_config_response: current).to_proto
|
|
144
|
+
data = Meshtastic::Data.new(portnum: :ADMIN_APP, request_id: packet.id, payload: payload)
|
|
145
|
+
else
|
|
146
|
+
current = Meshtastic::Config.decode(message.set_config.to_proto)
|
|
147
|
+
data = Meshtastic::Data.new(portnum: :ROUTING_APP, request_id: packet.id)
|
|
148
|
+
end
|
|
149
|
+
handle[:from_radio_queue] << Meshtastic::FromRadio.new(packet: Meshtastic::MeshPacket.new(from: packet.to, decoded: data))
|
|
150
|
+
bytes.bytesize
|
|
151
|
+
end
|
|
152
|
+
result = described_class.set_packet_signature_policy(transport_obj: handle, policy: :PACKET_SIGNATURE_POLICY_STRICT)
|
|
153
|
+
expect(current.security).to have_attributes(serial_enabled: true, public_key: 'k' * 32, packet_signature_policy: :PACKET_SIGNATURE_POLICY_STRICT)
|
|
154
|
+
expect(handle[:messages].map(&:payload_variant)).to eq(%i[get_config_request set_config get_config_request])
|
|
155
|
+
expect(result).to include(matched: true, reboot_requested: false)
|
|
156
|
+
expect(result[:acknowledgment][:delivery]).to eq(:acknowledged)
|
|
157
|
+
expect(result[:readback][:delivery]).to eq(:readback)
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
it 'sets local receive-only without resetting LoRa fields or issuing a reboot and reports mismatched readback' do
|
|
161
|
+
[true, false].each do |apply|
|
|
162
|
+
current = Meshtastic::Config.new(lora: { region: :US, hop_limit: 3, tx_enabled: true })
|
|
163
|
+
handle = fake_serial_obj.merge(from_radio_queue: Queue.new, messages: [])
|
|
164
|
+
handle[:serial_conn].define_singleton_method(:write) do |bytes|
|
|
165
|
+
packet = Meshtastic::ToRadio.decode(bytes.byteslice(4..)).packet
|
|
166
|
+
message = Meshtastic::AdminMessage.decode(packet.decoded.payload)
|
|
167
|
+
handle[:messages] << message
|
|
168
|
+
if message.payload_variant == :get_config_request
|
|
169
|
+
data = Meshtastic::Data.new(portnum: :ADMIN_APP, request_id: packet.id,
|
|
170
|
+
payload: Meshtastic::AdminMessage.new(get_config_response: current).to_proto)
|
|
171
|
+
else
|
|
172
|
+
current = message.set_config if apply
|
|
173
|
+
data = Meshtastic::Data.new(portnum: :ROUTING_APP, request_id: packet.id)
|
|
174
|
+
end
|
|
175
|
+
handle[:from_radio_queue] << Meshtastic::FromRadio.new(packet: Meshtastic::MeshPacket.new(from: packet.to, decoded: data))
|
|
176
|
+
bytes.bytesize
|
|
177
|
+
end
|
|
178
|
+
result = described_class.set_receive_only(transport_obj: handle, enabled: true)
|
|
179
|
+
expect(result).to include(matched: apply, reboot_requested: false)
|
|
180
|
+
expect(handle[:messages][1].set_config.lora).to have_attributes(region: :US, hop_limit: 3, tx_enabled: false)
|
|
181
|
+
expect(handle[:messages].map(&:payload_variant)).to eq(%i[get_config_request set_config get_config_request])
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
it 'rejects invalid policy and receive-only settings before any transmission' do
|
|
186
|
+
handle = fake_serial_obj
|
|
187
|
+
[99, nil, true, 'PACKET_SIGNATURE_POLICY_STRICT', :UNKNOWN].each do |policy|
|
|
188
|
+
expect { described_class.set_packet_signature_policy(transport_obj: handle, policy: policy) }.to raise_error(ArgumentError)
|
|
189
|
+
end
|
|
190
|
+
expect { described_class.set_receive_only(transport_obj: handle, enabled: 'true') }.to raise_error(ArgumentError)
|
|
191
|
+
expect { described_class.set_receive_only(transport_obj: handle, enabled: true, to: '!aabbccdd') }.to raise_error(ArgumentError, /local/)
|
|
192
|
+
expect { described_class.set_receive_only(transport_obj: MQTTClient.new, enabled: true) }.to raise_error(ArgumentError, /local/)
|
|
193
|
+
expect(handle[:written]).to be_empty
|
|
194
|
+
end
|
|
126
195
|
|
|
127
196
|
it 'prints usage without raising' do
|
|
128
197
|
expect { described_class.help }.to output(/USAGE/).to_stdout
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
|
|
5
|
+
RSpec.describe Meshtastic::Admin::Firmware, 'v2.8.1 migration (no hardware)' do
|
|
6
|
+
let(:version) { '2.8.1.8e6a88d' }
|
|
7
|
+
let(:public_key) { 'k'.b * 32 }
|
|
8
|
+
|
|
9
|
+
# Hardware writes are mocked; ToRadio, AdminMessage and FromRadio codecs and
|
|
10
|
+
# Admin source/request correlation are production code, not mocked requests.
|
|
11
|
+
def radio(kind, version:, key:, decoys: false, fresh: true)
|
|
12
|
+
queue = Queue.new
|
|
13
|
+
requests = []
|
|
14
|
+
writer = Object.new
|
|
15
|
+
writer.define_singleton_method(:write) do |bytes|
|
|
16
|
+
packet = Meshtastic::ToRadio.decode(kind == :bluetooth ? bytes : bytes.byteslice(4..)).packet
|
|
17
|
+
request = Meshtastic::AdminMessage.decode(packet.decoded.payload)
|
|
18
|
+
requests << [packet.to, request.payload_variant]
|
|
19
|
+
response = if request.get_owner_request
|
|
20
|
+
Meshtastic::AdminMessage.new(get_owner_response: Meshtastic::User.new(public_key: key))
|
|
21
|
+
else
|
|
22
|
+
Meshtastic::AdminMessage.new(get_device_metadata_response: Meshtastic::DeviceMetadata.new(firmware_version: version))
|
|
23
|
+
end
|
|
24
|
+
sources = decoys ? [[999, packet.id], [456, packet.id - 1]] : []
|
|
25
|
+
sources << [456, packet.id] if fresh
|
|
26
|
+
sources.each do |from, id|
|
|
27
|
+
frame = Meshtastic::FromRadio.new(packet: Meshtastic::MeshPacket.new(from: from, decoded: Meshtastic::Data.new(portnum: :ADMIN_APP, request_id: id, payload: response.to_proto)))
|
|
28
|
+
queue << Meshtastic::FromRadio.decode(frame.to_proto)
|
|
29
|
+
end
|
|
30
|
+
bytes.bytesize
|
|
31
|
+
end
|
|
32
|
+
writer.define_singleton_method(:flush) { true }
|
|
33
|
+
handle = { my_node_num: 456, from_radio_queue: queue, tx_mutex: Mutex.new, metadata: { firmware_version: '2.8.1.8e6a88d' } }
|
|
34
|
+
handle[kind == :bluetooth ? :bluetooth_conn : :serial_conn] = writer
|
|
35
|
+
handle[:tcp_socket] = writer if kind == :tcp
|
|
36
|
+
transport = { serial: Meshtastic::Serial, bluetooth: Meshtastic::Bluetooth, tcp: Meshtastic::TCP }.fetch(kind)
|
|
37
|
+
allow(transport).to receive(:wait_for_config).and_return(handle)
|
|
38
|
+
allow(transport).to receive(:disconnect)
|
|
39
|
+
[handle, requests]
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
it 'rejects a reconnect callback returning a different transport before Admin traffic' do
|
|
43
|
+
handle, requests = radio(:serial, version: version, key: public_key)
|
|
44
|
+
allow(Meshtastic::Bluetooth).to receive(:wait_for_config).and_return(handle)
|
|
45
|
+
expect(Meshtastic::Serial).to receive(:disconnect).with(serial_obj: handle)
|
|
46
|
+
expect(Meshtastic::Bluetooth).not_to receive(:disconnect)
|
|
47
|
+
expect do
|
|
48
|
+
described_class.verify_reboot(transport: :bluetooth, reconnect: ->(_) { handle }, expected_version: version, reboot_delay: 0, timeout: 0.1)
|
|
49
|
+
end.to raise_error(ArgumentError, /transport/)
|
|
50
|
+
expect(requests).to be_empty
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
it 'validates migration identity and explicit endpoint settings before invoking any loader' do
|
|
54
|
+
base = { transport: :serial, connection: { block_dev: '/dev/ttyACM0' }, expected_version: version }
|
|
55
|
+
invalid = [{ expected_public_key: 'short' }, { expected_public_key: 'a' * 64 }, { expected_public_key: "\x00" * 32 },
|
|
56
|
+
{ previous_node: 123 }, { expected_node: 0xffffffff }, { previous_node: '!0000007b', expected_public_key: public_key },
|
|
57
|
+
{ to: '!000001c8' }, { connection: {} }, { transport: :mqtt }]
|
|
58
|
+
expect(described_class::BLE).not_to receive(:install)
|
|
59
|
+
invalid.each do |options|
|
|
60
|
+
expect { described_class.install(protocol: :unified_ble, bytes: 'abc', verify: base.merge(options)) }.to raise_error(ArgumentError)
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
it 'documents the public continuity options in actual runtime help' do
|
|
65
|
+
expect { described_class.help }.to output(/expected_public_key:.*previous_node:/m).to_stdout
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
%i[serial bluetooth tcp].each do |kind|
|
|
69
|
+
%w[2.8.1.wrongsuffix 2.8.1 2.8.0.8e6a88d 2.7.23.b246bcd].each do |wrong|
|
|
70
|
+
it "rejects #{wrong} despite exact cached metadata over #{kind}" do
|
|
71
|
+
handle, = radio(kind, version: wrong, key: public_key)
|
|
72
|
+
expect do
|
|
73
|
+
described_class.verify_reboot(transport: kind, reconnect: ->(_) { handle }, expected_version: version,
|
|
74
|
+
expected_public_key: public_key, previous_node: 123, reboot_delay: 0, timeout: 1)
|
|
75
|
+
end.to raise_error(IOError, /version mismatch/)
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
it "does not accept an arbitrary new node with another key over #{kind}" do
|
|
80
|
+
handle, requests = radio(kind, version: version, key: 'x' * 32)
|
|
81
|
+
expect do
|
|
82
|
+
described_class.verify_reboot(transport: kind, reconnect: ->(_) { handle }, expected_version: version,
|
|
83
|
+
expected_public_key: public_key, previous_node: 123, reboot_delay: 0, timeout: 1)
|
|
84
|
+
end.to raise_error(IOError, 'Post-reboot public-key identity mismatch')
|
|
85
|
+
expect(requests).to eq([[456, :get_owner_request]])
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
it "does not relax an explicitly strict numeric expectation over #{kind}" do
|
|
89
|
+
handle, requests = radio(kind, version: version, key: public_key)
|
|
90
|
+
expect do
|
|
91
|
+
described_class.verify_reboot(transport: kind, reconnect: ->(_) { handle }, expected_version: version,
|
|
92
|
+
expected_node: 123, expected_public_key: public_key, reboot_delay: 0, timeout: 1)
|
|
93
|
+
end.to raise_error(IOError, /node identity mismatch/)
|
|
94
|
+
expect(requests).to be_empty
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
[false, true].each do |owner|
|
|
98
|
+
it "rejects stale or wrong-source #{owner ? 'owner' : 'metadata'} and retains unrelated traffic over #{kind}" do
|
|
99
|
+
handle, = radio(kind, version: version, key: public_key, decoys: true, fresh: false)
|
|
100
|
+
identity = owner ? { expected_public_key: public_key } : {}
|
|
101
|
+
expect do
|
|
102
|
+
described_class.verify_reboot({ transport: kind, reconnect: ->(_) { handle }, expected_version: version,
|
|
103
|
+
reboot_delay: 0, timeout: 0.05 }.merge(identity))
|
|
104
|
+
end.to raise_error(Timeout::Error)
|
|
105
|
+
expect(handle[:from_radio_queue].size).to eq(2)
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
it "allows an explicit key-continuous node migration over #{kind} without exposing keys" do
|
|
110
|
+
handle, requests = radio(kind, version: version, key: public_key, decoys: true)
|
|
111
|
+
result = described_class.verify_reboot(transport: kind, reconnect: ->(_) { handle }, expected_version: version,
|
|
112
|
+
expected_public_key: public_key, previous_node: 123, reboot_delay: 0, timeout: 1)
|
|
113
|
+
expect(result).to include(status: :boot_verified, node_num: 456, previous_node_num: 123,
|
|
114
|
+
node_id: '!000001c8', previous_node_id: '!0000007b', node_changed: true,
|
|
115
|
+
identity: :public_key_continuity)
|
|
116
|
+
expect(requests).to eq([[456, :get_owner_request], [456, :get_device_metadata_request]])
|
|
117
|
+
expect(result.inspect).not_to include(public_key)
|
|
118
|
+
expect(handle[:from_radio_queue].size).to eq(4)
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
@@ -134,7 +134,7 @@ RSpec.describe Meshtastic::Admin::Firmware do
|
|
|
134
134
|
handle = { serial_conn: Object.new, my_node_num: 123 }
|
|
135
135
|
expect(Meshtastic::Serial).to receive(:wait_for_config).with(serial_obj: handle, timeout: 1).and_return(handle)
|
|
136
136
|
expect(Meshtastic::Serial).to receive(:disconnect).with(serial_obj: handle)
|
|
137
|
-
expect(Meshtastic::Admin).to receive(:request).with(transport_obj: handle, get_device_metadata_request: true, timeout: 1)
|
|
137
|
+
expect(Meshtastic::Admin).to receive(:request).with(transport_obj: handle, to: 123, get_device_metadata_request: true, timeout: 1)
|
|
138
138
|
.and_return(value: Meshtastic::DeviceMetadata.new(firmware_version: 'expected'))
|
|
139
139
|
result = described_class.verify_reboot(transport: :serial, reconnect: ->(_options) { handle },
|
|
140
140
|
expected_version: 'expected', reboot_delay: 0, timeout: 1)
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
|
|
5
|
+
RSpec.describe Meshtastic::Admin::Firmware, 'installer target selection (no hardware)' do
|
|
6
|
+
it 'rejects unimplemented chips before opening a serial programmer' do
|
|
7
|
+
expect(UART).not_to receive(:open)
|
|
8
|
+
%i[esp32c6 rp2350 stm32 nrf52840].each do |chip|
|
|
9
|
+
expect do
|
|
10
|
+
described_class.install(protocol: :esp_rom, chip: chip, bytes: 'image', port: '/dev/ttyACM0', offset: 0x10000, flash_size: 4_194_304)
|
|
11
|
+
end.to raise_error(ArgumentError, /chip must/)
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
it 'never guesses a Secure Nordic or unsupported board loader from an artifact' do
|
|
16
|
+
expect(Socket).not_to receive(:tcp)
|
|
17
|
+
expect(UART).not_to receive(:open)
|
|
18
|
+
%i[nordic_secure_dfu rp2350 stm32 mqtt].each do |protocol|
|
|
19
|
+
expect { described_class.install(protocol: protocol, bytes: 'image') }.to raise_error(NotImplementedError)
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
it 'does not pretend that full erase is implemented' do
|
|
24
|
+
expect(UART).not_to receive(:open)
|
|
25
|
+
expect(Socket).not_to receive(:tcp)
|
|
26
|
+
%i[esp_rom unified_wifi unified_ble nordic_dfu uf2 swd].each do |protocol|
|
|
27
|
+
expect { described_class.install(protocol: protocol, bytes: 'image', full_erase: true) }.to raise_error(ArgumentError, /[Uu]nsupported|[Uu]nknown/)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
it 'rejects unsupported RP2350 UF2 families before opening a destination' do
|
|
32
|
+
expect(File).not_to receive(:open)
|
|
33
|
+
expect do
|
|
34
|
+
described_class.install(protocol: :uf2, bytes: 'image', family_id: 0xe48bff59, mount: '/selected/volume', board_id: 'RP2350')
|
|
35
|
+
end.to raise_error(ArgumentError, /Unsupported UF2 family/)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
it 'requires the exact supported SWD chip before invoking any programmer' do
|
|
39
|
+
expect(described_class::Hex).not_to receive(:program)
|
|
40
|
+
expect do
|
|
41
|
+
described_class::Hex.validate(bytes: ':00000001FF', expected_chip: :stm32)
|
|
42
|
+
end.to raise_error(ArgumentError, /nrf52840/)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
|
|
5
|
+
describe 'Admin semantic validation' do
|
|
6
|
+
it 'rejects owner names beyond the pinned firmware UTF-8 byte limit without truncating' do
|
|
7
|
+
name = 'é' * 13
|
|
8
|
+
user = Meshtastic::User.new(long_name: name)
|
|
9
|
+
expect { Meshtastic::Admin.encode(set_owner: user) }.to raise_error(ArgumentError, /24/)
|
|
10
|
+
expect(user.long_name).to eq(name)
|
|
11
|
+
expect(Meshtastic::Admin.encode(set_owner: Meshtastic::User.new(long_name: 'é' * 12)).set_owner.long_name.bytesize).to eq(24)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
it 'rejects names firmware cannot represent, while preserving omitted names' do
|
|
15
|
+
[{ long_name: "bad\0name" }, { long_name: " \t" }, { short_name: 'ééé' }].each do |fields|
|
|
16
|
+
expect { Meshtastic::Admin.encode(set_owner: Meshtastic::User.new(fields)) }.to raise_error(ArgumentError)
|
|
17
|
+
end
|
|
18
|
+
expect { Meshtastic::Admin.encode(set_owner: Meshtastic::User.new) }.not_to raise_error
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
it 'validates ham nanopb fields and the composed owner name before encoding' do
|
|
22
|
+
[{ call_sign: '' }, { call_sign: ' ' }, { call_sign: 'ABCDEFGH' },
|
|
23
|
+
{ call_sign: 'N0CALL', long_name: 'é' * 8 }, { call_sign: 'N0CALL', short_name: 'ABCDE' }].each do |fields|
|
|
24
|
+
expect { Meshtastic::Admin.encode(set_ham_mode: Meshtastic::HamParameters.new(fields)) }.to raise_error(ArgumentError)
|
|
25
|
+
end
|
|
26
|
+
expect { Meshtastic::Admin.encode(set_ham_mode: Meshtastic::HamParameters.new(call_sign: 'N0CALL', long_name: 'é' * 7)) }.not_to raise_error
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
it 'rejects unknown enum integers recursively and accepts known numeric enum values' do
|
|
30
|
+
[Meshtastic::AdminMessage.new(get_config_request: 999),
|
|
31
|
+
Meshtastic::AdminMessage.new(set_config: { lora: { region: 999 } }),
|
|
32
|
+
Meshtastic::AdminMessage.new(set_module_config: { serial: { mode: 999 } })].each do |message|
|
|
33
|
+
expect { Meshtastic::Admin.encode(message: message) }.to raise_error(ArgumentError, /enum/)
|
|
34
|
+
end
|
|
35
|
+
expect { Meshtastic::Admin.encode(get_config_request: 0) }.not_to raise_error
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
it 'requires a selected writable config section but allows an explicitly selected default section' do
|
|
39
|
+
[Meshtastic::AdminMessage.new(set_config: Meshtastic::Config.new),
|
|
40
|
+
Meshtastic::AdminMessage.new(set_module_config: Meshtastic::ModuleConfig.new),
|
|
41
|
+
Meshtastic::AdminMessage.new(set_config: { sessionkey: {} }),
|
|
42
|
+
Meshtastic::AdminMessage.new(set_config: { device_ui: {} })].each do |message|
|
|
43
|
+
expect { Meshtastic::Admin.encode(message: message) }.to raise_error(ArgumentError, /section|sessionkey|device_ui/)
|
|
44
|
+
end
|
|
45
|
+
expect { Meshtastic::Admin.encode(set_config: Meshtastic::Config.new(device: {})) }.not_to raise_error
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
it 'applies channel PSK, name, index and role validation to raw Admin writes' do
|
|
49
|
+
[{ index: 8 }, { index: -1 }, { role: 99 }, { settings: { psk: 'bad' } },
|
|
50
|
+
{ settings: { name: 'é' * 6 } }, { settings: { name: "x\0y" } }].each do |fields|
|
|
51
|
+
expect { Meshtastic::Admin.encode(set_channel: Meshtastic::Channel.new(fields)) }.to raise_error(ArgumentError)
|
|
52
|
+
end
|
|
53
|
+
[0, 1, 16, 32].each do |length|
|
|
54
|
+
channel = Meshtastic::Channel.new(index: 7, role: :PRIMARY, settings: { name: 'é' * 5, psk: 'x' * length })
|
|
55
|
+
expect(Meshtastic::Admin.encode(set_channel: channel).set_channel).to eq(channel)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
it 'preflights every URL section and rejects invalid offline settings before any write' do
|
|
60
|
+
expect(Meshtastic::Admin).not_to receive(:send)
|
|
61
|
+
expect { Meshtastic::Admin::Channel.build_settings(name: "x\0y") }.to raise_error(ArgumentError)
|
|
62
|
+
set = Meshtastic::ChannelSet.new(settings: [{ psk: 'x' }], lora_config: { region: 999 })
|
|
63
|
+
url = "https://meshtastic.org/e/##{Base64.urlsafe_encode64(set.to_proto, padding: false)}"
|
|
64
|
+
expect { Meshtastic::Admin::Channel.apply_url(url: url, transport_obj: { serial_conn: Object.new }) }.to raise_error(ArgumentError)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
it 'rejects malformed security key sizes rather than silently triggering firmware identity preservation' do
|
|
68
|
+
[{ private_key: 'bad' }, { public_key: 'bad' }, { admin_key: ['bad'] }].each do |fields|
|
|
69
|
+
expect { Meshtastic::Admin.encode(set_config: Meshtastic::Config.new(security: fields)) }.to raise_error(ArgumentError, /key/)
|
|
70
|
+
end
|
|
71
|
+
expect { Meshtastic::Admin.encode(set_config: Meshtastic::Config.new(security: { private_key: 'k' * 32 })) }.not_to raise_error
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
it 'rejects invalid mutations before delivery or remote session acquisition across public setters' do
|
|
75
|
+
handle = { serial_conn: Object.new, my_node_num: 0xaabbccdd, from_radio_queue: Queue.new }
|
|
76
|
+
transport = { transport_obj: handle, to: '!aabbccee' }
|
|
77
|
+
expect(Meshtastic).not_to receive(:deliver_data)
|
|
78
|
+
[[:set_owner, { long_name: 'x' * 25 }], [:set_ham_mode, { call_sign: '' }],
|
|
79
|
+
[:set_channel, { channel_settings: Meshtastic::Channel.new(index: 8) }],
|
|
80
|
+
[:set_config, { config: Meshtastic::Config.new }],
|
|
81
|
+
[:set_module_config, { module_config: Meshtastic::ModuleConfig.new }]].each do |method, payload|
|
|
82
|
+
expect { Meshtastic::Admin.public_send(method, transport.merge(payload)) }.to raise_error(ArgumentError)
|
|
83
|
+
end
|
|
84
|
+
expect { Meshtastic::Admin::Config.set_lora(transport.merge(lora: { region: 999 })) }.to raise_error(ArgumentError)
|
|
85
|
+
expect { Meshtastic::ModuleConfig.set(transport.merge(module_config: Meshtastic::ModuleConfig.new)) }.to raise_error(ArgumentError)
|
|
86
|
+
expect { Meshtastic::Admin::Channel.set(transport.merge(settings: { psk: 'bad' })) }.to raise_error(ArgumentError)
|
|
87
|
+
expect(handle).not_to have_key(:admin_sessions)
|
|
88
|
+
end
|
|
89
|
+
end
|