openc3-cosmos-cfdp 1.0.2 → 2.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 +4 -4
- data/README.md +10 -1
- data/microservices/CFDP/Gemfile +3 -11
- data/microservices/CFDP/Gemfile.dev +35 -0
- data/microservices/CFDP/Gemfile.prod +29 -0
- data/microservices/CFDP/app/controllers/cfdp_controller.rb +8 -0
- data/microservices/CFDP/app/models/cfdp_crc_checksum.rb +3 -1
- data/microservices/CFDP/app/models/cfdp_mib.rb +2 -1
- data/microservices/CFDP/app/models/cfdp_pdu.rb +43 -19
- data/microservices/CFDP/app/models/cfdp_receive_transaction.rb +7 -2
- data/microservices/CFDP/app/models/cfdp_source_transaction.rb +6 -1
- data/microservices/CFDP/app/models/cfdp_transaction.rb +1 -1
- data/microservices/CFDP/app/models/cfdp_user.rb +14 -4
- data/microservices/CFDP/config/application.rb +0 -11
- data/microservices/CFDP/config/environments/test.rb +1 -1
- data/microservices/CFDP/lib/cfdp_pdu/cfdp_pdu_file_data.rb +6 -2
- data/microservices/CFDP/lib/cfdp_pdu/cfdp_pdu_finished.rb +17 -5
- data/microservices/CFDP/lib/cfdp_pdu/cfdp_pdu_metadata.rb +28 -11
- data/microservices/CFDP/lib/cfdp_pdu/cfdp_pdu_tlv.rb +5 -2
- data/microservices/CFDP/lib/cfdp_pdu/cfdp_pdu_user_ops.rb +16 -9
- data/microservices/CFDP/spec/controllers/cfdp_controller_spec.rb +373 -0
- data/microservices/CFDP/spec/models/cfdp_crc_checksum_spec.rb +134 -0
- data/microservices/CFDP/spec/models/cfdp_mib_spec.rb +389 -0
- data/microservices/CFDP/spec/models/cfdp_pdu_metadata_spec.rb +102 -1
- data/microservices/CFDP/spec/models/cfdp_pdu_user_ops_spec.rb +562 -0
- data/microservices/CFDP/spec/models/cfdp_receive_transaction_spec.rb +598 -0
- data/microservices/CFDP/spec/models/cfdp_transaction_spec.rb +331 -0
- data/microservices/CFDP/spec/models/cfdp_user_spec.rb +575 -0
- metadata +15 -6
| @@ -0,0 +1,389 @@ | |
| 1 | 
            +
            # encoding: ascii-8bit
         | 
| 2 | 
            +
             | 
| 3 | 
            +
            # Copyright 2025 OpenC3, Inc.
         | 
| 4 | 
            +
            # All Rights Reserved.
         | 
| 5 | 
            +
            #
         | 
| 6 | 
            +
            # Licensed for Evaluation and Educational Use
         | 
| 7 | 
            +
            #
         | 
| 8 | 
            +
            # This file may only be used commercially under the terms of a commercial license
         | 
| 9 | 
            +
            # purchased from OpenC3, Inc.
         | 
| 10 | 
            +
            #
         | 
| 11 | 
            +
            # This program is distributed in the hope that it will be useful,
         | 
| 12 | 
            +
            # but WITHOUT ANY WARRANTY; without even the implied warranty of
         | 
| 13 | 
            +
            # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
         | 
| 14 | 
            +
            #
         | 
| 15 | 
            +
            # The development of this software was funded in-whole or in-part by Sandia National Laboratories.
         | 
| 16 | 
            +
            # See https://github.com/OpenC3/openc3-cosmos-cfdp/pull/12 for details
         | 
| 17 | 
            +
             | 
| 18 | 
            +
            require 'rails_helper'
         | 
| 19 | 
            +
            require 'tempfile'
         | 
| 20 | 
            +
             | 
| 21 | 
            +
            RSpec.describe CfdpMib do
         | 
| 22 | 
            +
              before(:each) do
         | 
| 23 | 
            +
                allow(OpenC3::Logger).to receive(:info)
         | 
| 24 | 
            +
                allow(OpenC3::Logger).to receive(:error)
         | 
| 25 | 
            +
                CfdpMib.clear
         | 
| 26 | 
            +
              end
         | 
| 27 | 
            +
             | 
| 28 | 
            +
              describe "entity management" do
         | 
| 29 | 
            +
                it "defines an entity with default values" do
         | 
| 30 | 
            +
                  entity = CfdpMib.define_entity(1)
         | 
| 31 | 
            +
                  expect(entity['id']).to eq(1)
         | 
| 32 | 
            +
                  expect(entity['protocol_version_number']).to eq(1)
         | 
| 33 | 
            +
                  expect(entity['default_transmission_mode']).to eq('UNACKNOWLEDGED')
         | 
| 34 | 
            +
                  expect(entity['fault_handler']["FILE_CHECKSUM_FAILURE"]).to eq("IGNORE_ERROR")
         | 
| 35 | 
            +
                end
         | 
| 36 | 
            +
             | 
| 37 | 
            +
                it "returns entity by id" do
         | 
| 38 | 
            +
                  CfdpMib.define_entity(1)
         | 
| 39 | 
            +
                  entity = CfdpMib.entity(1)
         | 
| 40 | 
            +
                  expect(entity['id']).to eq(1)
         | 
| 41 | 
            +
                end
         | 
| 42 | 
            +
             | 
| 43 | 
            +
                it "sets source entity id" do
         | 
| 44 | 
            +
                  CfdpMib.define_entity(1)
         | 
| 45 | 
            +
                  CfdpMib.source_entity_id = 1
         | 
| 46 | 
            +
                  expect(CfdpMib.source_entity_id).to eq(1)
         | 
| 47 | 
            +
                end
         | 
| 48 | 
            +
             | 
| 49 | 
            +
                it "returns source entity" do
         | 
| 50 | 
            +
                  CfdpMib.define_entity(1)
         | 
| 51 | 
            +
                  CfdpMib.source_entity_id = 1
         | 
| 52 | 
            +
                  entity = CfdpMib.source_entity
         | 
| 53 | 
            +
                  expect(entity['id']).to eq(1)
         | 
| 54 | 
            +
                end
         | 
| 55 | 
            +
              end
         | 
| 56 | 
            +
             | 
| 57 | 
            +
              describe "set_entity_value" do
         | 
| 58 | 
            +
                before(:each) do
         | 
| 59 | 
            +
                  CfdpMib.define_entity(1)
         | 
| 60 | 
            +
                end
         | 
| 61 | 
            +
             | 
| 62 | 
            +
                it "sets integer values" do
         | 
| 63 | 
            +
                  CfdpMib.set_entity_value(1, 'protocol_version_number', 2)
         | 
| 64 | 
            +
                  expect(CfdpMib.entity(1)['protocol_version_number']).to eq(2)
         | 
| 65 | 
            +
                end
         | 
| 66 | 
            +
             | 
| 67 | 
            +
                it "sets boolean values" do
         | 
| 68 | 
            +
                  CfdpMib.set_entity_value(1, 'immediate_nak_mode', false)
         | 
| 69 | 
            +
                  expect(CfdpMib.entity(1)['immediate_nak_mode']).to eq(false)
         | 
| 70 | 
            +
                end
         | 
| 71 | 
            +
             | 
| 72 | 
            +
                it "sets transmission mode values" do
         | 
| 73 | 
            +
                  CfdpMib.set_entity_value(1, 'default_transmission_mode', 'ACKNOWLEDGED')
         | 
| 74 | 
            +
                  expect(CfdpMib.entity(1)['default_transmission_mode']).to eq('ACKNOWLEDGED')
         | 
| 75 | 
            +
                end
         | 
| 76 | 
            +
             | 
| 77 | 
            +
                it "sets cmd_info values" do
         | 
| 78 | 
            +
                  CfdpMib.set_entity_value(1, 'cmd_info', ['TARGET', 'PACKET', 'ITEM'])
         | 
| 79 | 
            +
                  expect(CfdpMib.entity(1)['cmd_info']).to eq(['TARGET', 'PACKET', 'ITEM'])
         | 
| 80 | 
            +
                end
         | 
| 81 | 
            +
             | 
| 82 | 
            +
                it "adds tlm_info values" do
         | 
| 83 | 
            +
                  CfdpMib.set_entity_value(1, 'tlm_info', ['TARGET', 'PACKET', 'ITEM'])
         | 
| 84 | 
            +
                  expect(CfdpMib.entity(1)['tlm_info']).to eq([['TARGET', 'PACKET', 'ITEM']])
         | 
| 85 | 
            +
                end
         | 
| 86 | 
            +
             | 
| 87 | 
            +
                it "raises an error for unknown options" do
         | 
| 88 | 
            +
                  expect { CfdpMib.set_entity_value(1, 'unknown_option', 'value') }.to raise_error(RuntimeError, /Unknown OPTION/)
         | 
| 89 | 
            +
                end
         | 
| 90 | 
            +
             | 
| 91 | 
            +
                it "raises an error for invalid tlm_info" do
         | 
| 92 | 
            +
                  expect { CfdpMib.set_entity_value(1, 'tlm_info', ['TARGET', 'PACKET']) }.to raise_error(RuntimeError, /Invalid tlm_info/)
         | 
| 93 | 
            +
                end
         | 
| 94 | 
            +
             | 
| 95 | 
            +
                it "raises an error for invalid cmd_info" do
         | 
| 96 | 
            +
                  expect { CfdpMib.set_entity_value(1, 'cmd_info', ['TARGET', 'PACKET']) }.to raise_error(RuntimeError, /Invalid cmd_info/)
         | 
| 97 | 
            +
                end
         | 
| 98 | 
            +
              end
         | 
| 99 | 
            +
             | 
| 100 | 
            +
              describe "file operations" do
         | 
| 101 | 
            +
                before(:each) do
         | 
| 102 | 
            +
                  CfdpMib.root_path = "/tmp"
         | 
| 103 | 
            +
                  @tmp_file = Tempfile.new('cfdp_test')
         | 
| 104 | 
            +
                  @tmp_file.write("test data")
         | 
| 105 | 
            +
                  @tmp_file.close
         | 
| 106 | 
            +
                end
         | 
| 107 | 
            +
             | 
| 108 | 
            +
                after(:each) do
         | 
| 109 | 
            +
                  @tmp_file.unlink if @tmp_file
         | 
| 110 | 
            +
                end
         | 
| 111 | 
            +
             | 
| 112 | 
            +
                it "gets a source file" do
         | 
| 113 | 
            +
                  allow(File).to receive(:open).and_return(@tmp_file)
         | 
| 114 | 
            +
                  file = CfdpMib.get_source_file("test.txt")
         | 
| 115 | 
            +
                  expect(file).to eq(@tmp_file)
         | 
| 116 | 
            +
                end
         | 
| 117 | 
            +
             | 
| 118 | 
            +
                it "handles missing source files" do
         | 
| 119 | 
            +
                  allow(File).to receive(:open).and_raise(Errno::ENOENT.new("No such file"))
         | 
| 120 | 
            +
                  file = CfdpMib.get_source_file("missing.txt")
         | 
| 121 | 
            +
                  expect(file).to be_nil
         | 
| 122 | 
            +
                end
         | 
| 123 | 
            +
             | 
| 124 | 
            +
                it "completes a source file" do
         | 
| 125 | 
            +
                  allow(@tmp_file).to receive(:close)
         | 
| 126 | 
            +
                  CfdpMib.complete_source_file(@tmp_file)
         | 
| 127 | 
            +
                  # Just verifying no errors are raised
         | 
| 128 | 
            +
                end
         | 
| 129 | 
            +
             | 
| 130 | 
            +
                it "puts a destination file" do
         | 
| 131 | 
            +
                  # Create a fake tempfile for testing
         | 
| 132 | 
            +
                  temp = Tempfile.new('cfdp_dest')
         | 
| 133 | 
            +
                  allow(temp).to receive(:persist).and_return(true)
         | 
| 134 | 
            +
                  allow(temp).to receive(:unlink).and_return(true)
         | 
| 135 | 
            +
                  allow(temp).to receive(:open).and_return(temp)
         | 
| 136 | 
            +
                  allow(temp).to receive(:read).and_return("test data")
         | 
| 137 | 
            +
             | 
| 138 | 
            +
                  result = CfdpMib.put_destination_file("test_dest.txt", temp)
         | 
| 139 | 
            +
                  expect(result).to be true
         | 
| 140 | 
            +
                end
         | 
| 141 | 
            +
             | 
| 142 | 
            +
                it "handles errors while putting destination files" do
         | 
| 143 | 
            +
                  temp = Tempfile.new('cfdp_dest')
         | 
| 144 | 
            +
                  allow(temp).to receive(:persist).and_raise("File write error")
         | 
| 145 | 
            +
             | 
| 146 | 
            +
                  result = CfdpMib.put_destination_file("test_dest.txt", temp)
         | 
| 147 | 
            +
                  expect(result).to be false
         | 
| 148 | 
            +
                end
         | 
| 149 | 
            +
              end
         | 
| 150 | 
            +
             | 
| 151 | 
            +
              describe "filestore_request" do
         | 
| 152 | 
            +
                before(:each) do
         | 
| 153 | 
            +
                  CfdpMib.root_path = "/tmp"
         | 
| 154 | 
            +
                  @tmp_file1 = Tempfile.new('cfdp_test1')
         | 
| 155 | 
            +
                  @tmp_file1.write("test data 1")
         | 
| 156 | 
            +
                  @tmp_file1.close
         | 
| 157 | 
            +
             | 
| 158 | 
            +
                  @tmp_file2 = Tempfile.new('cfdp_test2')
         | 
| 159 | 
            +
                  @tmp_file2.write("test data 2")
         | 
| 160 | 
            +
                  @tmp_file2.close
         | 
| 161 | 
            +
             | 
| 162 | 
            +
                  # Setup mocks for File operations
         | 
| 163 | 
            +
                  allow(File).to receive(:absolute_path) { |path| path }
         | 
| 164 | 
            +
                  allow(File).to receive(:exist?).and_return(true)
         | 
| 165 | 
            +
                  allow(FileUtils).to receive(:touch).and_return(true)
         | 
| 166 | 
            +
                  allow(FileUtils).to receive(:rm).and_return(true)
         | 
| 167 | 
            +
                  allow(FileUtils).to receive(:mv).and_return(true)
         | 
| 168 | 
            +
                  allow(FileUtils).to receive(:mkdir).and_return(true)
         | 
| 169 | 
            +
                  allow(FileUtils).to receive(:rmdir).and_return(true)
         | 
| 170 | 
            +
                  allow(File).to receive(:open).and_yield(StringIO.new)
         | 
| 171 | 
            +
                  allow(File).to receive(:read).and_return("test data")
         | 
| 172 | 
            +
                  allow(Dir).to receive(:exist?).and_return(true)
         | 
| 173 | 
            +
                end
         | 
| 174 | 
            +
             | 
| 175 | 
            +
                after(:each) do
         | 
| 176 | 
            +
                  @tmp_file1.unlink if @tmp_file1
         | 
| 177 | 
            +
                  @tmp_file2.unlink if @tmp_file2
         | 
| 178 | 
            +
                end
         | 
| 179 | 
            +
             | 
| 180 | 
            +
                it "handles CREATE_FILE action" do
         | 
| 181 | 
            +
                  status, message = CfdpMib.filestore_request("CREATE_FILE", "test_create.txt", nil)
         | 
| 182 | 
            +
                  expect(status).to eq("SUCCESSFUL")
         | 
| 183 | 
            +
                end
         | 
| 184 | 
            +
             | 
| 185 | 
            +
                it "handles DELETE_FILE action when file exists" do
         | 
| 186 | 
            +
                  status, message = CfdpMib.filestore_request("DELETE_FILE", "test_delete.txt", nil)
         | 
| 187 | 
            +
                  expect(status).to eq("SUCCESSFUL")
         | 
| 188 | 
            +
                end
         | 
| 189 | 
            +
             | 
| 190 | 
            +
                it "handles DELETE_FILE action when file doesn't exist" do
         | 
| 191 | 
            +
                  allow(File).to receive(:exist?).and_return(false)
         | 
| 192 | 
            +
                  status, message = CfdpMib.filestore_request("DELETE_FILE", "missing.txt", nil)
         | 
| 193 | 
            +
                  expect(status).to eq("FILE_DOES_NOT_EXIST")
         | 
| 194 | 
            +
                end
         | 
| 195 | 
            +
             | 
| 196 | 
            +
                it "handles RENAME_FILE action" do
         | 
| 197 | 
            +
                  allow(File).to receive(:exist?).with("/tmp/old.txt").and_return(true)
         | 
| 198 | 
            +
                  allow(File).to receive(:exist?).with("/tmp/new.txt").and_return(false)
         | 
| 199 | 
            +
                  status, message = CfdpMib.filestore_request("RENAME_FILE", "old.txt", "new.txt")
         | 
| 200 | 
            +
                  expect(status).to eq("SUCCESSFUL")
         | 
| 201 | 
            +
                end
         | 
| 202 | 
            +
             | 
| 203 | 
            +
                it "handles RENAME_FILE when destination already exists" do
         | 
| 204 | 
            +
                  # First call to exist? for the destination file
         | 
| 205 | 
            +
                  allow(File).to receive(:exist?).with("/tmp/new.txt").and_return(true)
         | 
| 206 | 
            +
             | 
| 207 | 
            +
                  status, message = CfdpMib.filestore_request("RENAME_FILE", "old.txt", "new.txt")
         | 
| 208 | 
            +
                  expect(status).to eq("NEW_FILE_ALREADY_EXISTS")
         | 
| 209 | 
            +
                end
         | 
| 210 | 
            +
             | 
| 211 | 
            +
                it "handles APPEND_FILE action" do
         | 
| 212 | 
            +
                  status, message = CfdpMib.filestore_request("APPEND_FILE", "file1.txt", "file2.txt")
         | 
| 213 | 
            +
                  expect(status).to eq("SUCCESSFUL")
         | 
| 214 | 
            +
                end
         | 
| 215 | 
            +
             | 
| 216 | 
            +
                it "handles REPLACE_FILE action" do
         | 
| 217 | 
            +
                  status, message = CfdpMib.filestore_request("REPLACE_FILE", "file1.txt", "file2.txt")
         | 
| 218 | 
            +
                  expect(status).to eq("SUCCESSFUL")
         | 
| 219 | 
            +
                end
         | 
| 220 | 
            +
             | 
| 221 | 
            +
                it "handles CREATE_DIRECTORY action" do
         | 
| 222 | 
            +
                  status, message = CfdpMib.filestore_request("CREATE_DIRECTORY", "new_dir", nil)
         | 
| 223 | 
            +
                  expect(status).to eq("SUCCESSFUL")
         | 
| 224 | 
            +
                end
         | 
| 225 | 
            +
             | 
| 226 | 
            +
                it "handles REMOVE_DIRECTORY action" do
         | 
| 227 | 
            +
                  status, message = CfdpMib.filestore_request("REMOVE_DIRECTORY", "test_dir", nil)
         | 
| 228 | 
            +
                  expect(status).to eq("SUCCESSFUL")
         | 
| 229 | 
            +
                end
         | 
| 230 | 
            +
             | 
| 231 | 
            +
                it "handles DENY_FILE action" do
         | 
| 232 | 
            +
                  status, message = CfdpMib.filestore_request("DENY_FILE", "test_file.txt", nil)
         | 
| 233 | 
            +
                  expect(status).to eq("SUCCESSFUL")
         | 
| 234 | 
            +
                end
         | 
| 235 | 
            +
             | 
| 236 | 
            +
                it "handles DENY_DIRECTORY action" do
         | 
| 237 | 
            +
                  status, message = CfdpMib.filestore_request("DENY_DIRECTORY", "test_dir", nil)
         | 
| 238 | 
            +
                  expect(status).to eq("SUCCESSFUL")
         | 
| 239 | 
            +
                end
         | 
| 240 | 
            +
             | 
| 241 | 
            +
                it "handles unknown action codes" do
         | 
| 242 | 
            +
                  status, message = CfdpMib.filestore_request("UNKNOWN_ACTION", "file.txt", nil)
         | 
| 243 | 
            +
                  expect(status).to eq("NOT_PERFORMED")
         | 
| 244 | 
            +
                  expect(message).to include("Unknown action code")
         | 
| 245 | 
            +
                end
         | 
| 246 | 
            +
             | 
| 247 | 
            +
                it "handles file path safety" do
         | 
| 248 | 
            +
                  allow(File).to receive(:absolute_path).with("/tmp/../dangerous.txt").and_return("/dangerous.txt")
         | 
| 249 | 
            +
             | 
| 250 | 
            +
                  status, message = CfdpMib.filestore_request("CREATE_FILE", "../dangerous.txt", nil)
         | 
| 251 | 
            +
                  expect(status).to eq("NOT_ALLOWED")
         | 
| 252 | 
            +
                  expect(message).to include("Dangerous filename")
         | 
| 253 | 
            +
                end
         | 
| 254 | 
            +
             | 
| 255 | 
            +
                it "handles exceptions during file operations" do
         | 
| 256 | 
            +
                  allow(FileUtils).to receive(:touch).and_raise("File system error")
         | 
| 257 | 
            +
             | 
| 258 | 
            +
                  status, message = CfdpMib.filestore_request("CREATE_FILE", "test.txt", nil)
         | 
| 259 | 
            +
                  expect(status).to eq("NOT_ALLOWED")
         | 
| 260 | 
            +
                  expect(message).to include("File system error")
         | 
| 261 | 
            +
                end
         | 
| 262 | 
            +
              end
         | 
| 263 | 
            +
             | 
| 264 | 
            +
              describe "directory_listing" do
         | 
| 265 | 
            +
                before(:each) do
         | 
| 266 | 
            +
                  CfdpMib.root_path = "/tmp"
         | 
| 267 | 
            +
             | 
| 268 | 
            +
                  # Setup mocks
         | 
| 269 | 
            +
                  allow(File).to receive(:absolute_path) { |path| path }
         | 
| 270 | 
            +
                  allow(File).to receive(:join) { |*args| args.join('/') }
         | 
| 271 | 
            +
                  allow(Dir).to receive(:entries).and_return(['.', '..', 'file1.txt', 'file2.txt', 'subdir'])
         | 
| 272 | 
            +
                  allow(File).to receive(:directory?).with("/tmp/test_dir/subdir").and_return(true)
         | 
| 273 | 
            +
                  allow(File).to receive(:directory?).with("/tmp/test_dir/file1.txt").and_return(false)
         | 
| 274 | 
            +
                  allow(File).to receive(:directory?).with("/tmp/test_dir/file2.txt").and_return(false)
         | 
| 275 | 
            +
             | 
| 276 | 
            +
                  file_stat = double("File::Stat")
         | 
| 277 | 
            +
                  allow(file_stat).to receive(:mtime).and_return(Time.now)
         | 
| 278 | 
            +
                  allow(file_stat).to receive(:size).and_return(1024)
         | 
| 279 | 
            +
                  allow(File).to receive(:stat).and_return(file_stat)
         | 
| 280 | 
            +
                end
         | 
| 281 | 
            +
             | 
| 282 | 
            +
                it "returns a JSON listing of files and directories" do
         | 
| 283 | 
            +
                  result = CfdpMib.directory_listing("test_dir", "result.txt")
         | 
| 284 | 
            +
                  expect(result).to be_a(String)
         | 
| 285 | 
            +
             | 
| 286 | 
            +
                  # Parse the JSON and verify it contains the expected entries
         | 
| 287 | 
            +
                  json = JSON.parse(result)
         | 
| 288 | 
            +
                  expect(json).to be_an(Array)
         | 
| 289 | 
            +
                  expect(json.size).to eq(3) # file1.txt, file2.txt, subdir
         | 
| 290 | 
            +
             | 
| 291 | 
            +
                  # Check for directory and file entries
         | 
| 292 | 
            +
                  dir_entry = json.find { |entry| entry["directory"] == "subdir" }
         | 
| 293 | 
            +
                  expect(dir_entry).to be_present
         | 
| 294 | 
            +
             | 
| 295 | 
            +
                  file_entry = json.find { |entry| entry["name"] == "file1.txt" }
         | 
| 296 | 
            +
                  expect(file_entry).to be_present
         | 
| 297 | 
            +
                  expect(file_entry["size"]).to eq(1024)
         | 
| 298 | 
            +
                end
         | 
| 299 | 
            +
             | 
| 300 | 
            +
                it "handles file path safety" do
         | 
| 301 | 
            +
                  allow(File).to receive(:absolute_path).with("/tmp/../dangerous").and_return("/dangerous")
         | 
| 302 | 
            +
             | 
| 303 | 
            +
                  result = CfdpMib.directory_listing("../dangerous", "result.txt")
         | 
| 304 | 
            +
                  expect(result).to be_nil
         | 
| 305 | 
            +
                end
         | 
| 306 | 
            +
              end
         | 
| 307 | 
            +
             | 
| 308 | 
            +
              describe "setup" do
         | 
| 309 | 
            +
                before(:each) do
         | 
| 310 | 
            +
                  @mock_model = double("MicroserviceModel")
         | 
| 311 | 
            +
                  @options = [
         | 
| 312 | 
            +
                    ["source_entity_id", "1"],
         | 
| 313 | 
            +
                    ["destination_entity_id", "2"],
         | 
| 314 | 
            +
                    ["root_path", "/tmp"],
         | 
| 315 | 
            +
                    ["protocol_version_number", "1"],
         | 
| 316 | 
            +
                    ["ack_timer_interval", "300"],
         | 
| 317 | 
            +
                    ["immediate_nak_mode", "true"],
         | 
| 318 | 
            +
                    ["cmd_info", "TARGET", "PACKET", "ITEM"],
         | 
| 319 | 
            +
                    ["tlm_info", "TARGET", "TLM_PKT", "ITEM"]
         | 
| 320 | 
            +
                  ]
         | 
| 321 | 
            +
                  allow(@mock_model).to receive(:options).and_return(@options)
         | 
| 322 | 
            +
                  allow(OpenC3::MicroserviceModel).to receive(:get_model).and_return(@mock_model)
         | 
| 323 | 
            +
                end
         | 
| 324 | 
            +
             | 
| 325 | 
            +
                it "initializes MIB from options" do
         | 
| 326 | 
            +
                  CfdpMib.setup
         | 
| 327 | 
            +
             | 
| 328 | 
            +
                  # Verify entities were created
         | 
| 329 | 
            +
                  expect(CfdpMib.source_entity_id).to eq(1)
         | 
| 330 | 
            +
                  expect(CfdpMib.entity(1)).to be_present
         | 
| 331 | 
            +
                  expect(CfdpMib.entity(2)).to be_present
         | 
| 332 | 
            +
             | 
| 333 | 
            +
                  # Verify options were applied
         | 
| 334 | 
            +
                  expect(CfdpMib.entity(2)['protocol_version_number']).to eq(1)
         | 
| 335 | 
            +
                  expect(CfdpMib.entity(2)['ack_timer_interval']).to eq(300)
         | 
| 336 | 
            +
                  expect(CfdpMib.entity(2)['immediate_nak_mode']).to eq(true)
         | 
| 337 | 
            +
                  expect(CfdpMib.entity(2)['cmd_info']).to eq(["TARGET", "PACKET", "ITEM"])
         | 
| 338 | 
            +
                  expect(CfdpMib.entity(2)['tlm_info']).to include(["TARGET", "TLM_PKT", "ITEM"])
         | 
| 339 | 
            +
             | 
| 340 | 
            +
                  # Verify root path was set
         | 
| 341 | 
            +
                  expect(CfdpMib.root_path).to eq("/tmp")
         | 
| 342 | 
            +
                end
         | 
| 343 | 
            +
             | 
| 344 | 
            +
                it "raises error when required options are missing" do
         | 
| 345 | 
            +
                  # Remove required options
         | 
| 346 | 
            +
                  @options.delete_if { |opt| opt[0] == "source_entity_id" }
         | 
| 347 | 
            +
             | 
| 348 | 
            +
                  expect { CfdpMib.setup }.to raise_error(RuntimeError, /OPTION source_entity_id is required/)
         | 
| 349 | 
            +
                end
         | 
| 350 | 
            +
              end
         | 
| 351 | 
            +
             | 
| 352 | 
            +
              describe "cleanup_old_transactions" do
         | 
| 353 | 
            +
                before(:each) do
         | 
| 354 | 
            +
                  # Setup entities
         | 
| 355 | 
            +
                  CfdpMib.define_entity(1)
         | 
| 356 | 
            +
                  CfdpMib.source_entity_id = 1
         | 
| 357 | 
            +
                  CfdpMib.entity(1)['transaction_retain_seconds'] = 60
         | 
| 358 | 
            +
             | 
| 359 | 
            +
                  # Create mock transactions
         | 
| 360 | 
            +
                  @active_tx = double("Transaction")
         | 
| 361 | 
            +
                  allow(@active_tx).to receive(:complete_time).and_return(nil)
         | 
| 362 | 
            +
             | 
| 363 | 
            +
                  @recent_tx = double("Transaction")
         | 
| 364 | 
            +
                  allow(@recent_tx).to receive(:complete_time).and_return(Time.now.utc - 30)
         | 
| 365 | 
            +
             | 
| 366 | 
            +
                  @old_tx = double("Transaction")
         | 
| 367 | 
            +
                  allow(@old_tx).to receive(:complete_time).and_return(Time.now.utc - 120)
         | 
| 368 | 
            +
             | 
| 369 | 
            +
                  # Add transactions to the MIB
         | 
| 370 | 
            +
                  CfdpMib.transactions["tx1"] = @active_tx
         | 
| 371 | 
            +
                  CfdpMib.transactions["tx2"] = @recent_tx
         | 
| 372 | 
            +
                  CfdpMib.transactions["tx3"] = @old_tx
         | 
| 373 | 
            +
                end
         | 
| 374 | 
            +
             | 
| 375 | 
            +
                it "removes old completed transactions" do
         | 
| 376 | 
            +
                  # Verify there are 3 transactions before cleanup
         | 
| 377 | 
            +
                  expect(CfdpMib.transactions.size).to eq(3)
         | 
| 378 | 
            +
             | 
| 379 | 
            +
                  # Run cleanup
         | 
| 380 | 
            +
                  CfdpMib.cleanup_old_transactions
         | 
| 381 | 
            +
             | 
| 382 | 
            +
                  # Verify only old transaction was removed
         | 
| 383 | 
            +
                  expect(CfdpMib.transactions.size).to eq(2)
         | 
| 384 | 
            +
                  expect(CfdpMib.transactions).to have_key("tx1")
         | 
| 385 | 
            +
                  expect(CfdpMib.transactions).to have_key("tx2")
         | 
| 386 | 
            +
                  expect(CfdpMib.transactions).not_to have_key("tx3")
         | 
| 387 | 
            +
                end
         | 
| 388 | 
            +
              end
         | 
| 389 | 
            +
            end
         | 
| @@ -1,6 +1,6 @@ | |
| 1 1 | 
             
            # encoding: ascii-8bit
         | 
| 2 2 |  | 
| 3 | 
            -
            # Copyright  | 
| 3 | 
            +
            # Copyright 2025 OpenC3, Inc.
         | 
| 4 4 | 
             
            # All Rights Reserved.
         | 
| 5 5 | 
             
            #
         | 
| 6 6 | 
             
            # Licensed for Evaluation and Educational Use
         | 
| @@ -13,6 +13,9 @@ | |
| 13 13 | 
             
            # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
         | 
| 14 14 | 
             
            #
         | 
| 15 15 | 
             
            # The development of this software was funded in-whole or in-part by MethaneSAT LLC.
         | 
| 16 | 
            +
            #
         | 
| 17 | 
            +
            # The development of this software was funded in-part by Sandia National Laboratories.
         | 
| 18 | 
            +
            # See https://github.com/OpenC3/openc3-cosmos-cfdp/pull/12 for details
         | 
| 16 19 |  | 
| 17 20 | 
             
            require 'rails_helper'
         | 
| 18 21 | 
             
            require 'cfdp_pdu'
         | 
| @@ -74,6 +77,7 @@ RSpec.describe CfdpPdu, type: :model do | |
| 74 77 | 
             
                  expect(buffer[23..26].unpack('A*')[0]).to eql 'test'
         | 
| 75 78 |  | 
| 76 79 | 
             
                  hash = {}
         | 
| 80 | 
            +
                  hash['VERSION'] = 1
         | 
| 77 81 | 
             
                  # decom takes just the Metadata specific part of the buffer
         | 
| 78 82 | 
             
                  # so start at offset 8 and ignore the 2 checksum bytes
         | 
| 79 83 | 
             
                  CfdpPdu.decom_metadata_pdu_contents(CfdpPdu.new(crcs_required: false), hash, buffer[8..-3])
         | 
| @@ -154,6 +158,7 @@ RSpec.describe CfdpPdu, type: :model do | |
| 154 158 | 
             
                  expect(buffer[46..48].unpack('A*')[0]).to eql 'end'
         | 
| 155 159 |  | 
| 156 160 | 
             
                  hash = {}
         | 
| 161 | 
            +
                  hash['VERSION'] = 1
         | 
| 157 162 | 
             
                  # decom takes just the Metadata specific part of the buffer
         | 
| 158 163 | 
             
                  # so start at offset 8 and ignore the 2 checksum bytes
         | 
| 159 164 | 
             
                  CfdpPdu.decom_metadata_pdu_contents(CfdpPdu.new(crcs_required: false), hash, buffer[8..-3])
         | 
| @@ -216,6 +221,7 @@ RSpec.describe CfdpPdu, type: :model do | |
| 216 221 | 
             
                  expect(buffer[29..33].unpack('A*')[0]).to eql 'Hello'
         | 
| 217 222 |  | 
| 218 223 | 
             
                  hash = {}
         | 
| 224 | 
            +
                  hash['VERSION'] = 1
         | 
| 219 225 | 
             
                  # decom takes just the Metadata specific part of the buffer
         | 
| 220 226 | 
             
                  # so start at offset 8 and ignore the 2 checksum bytes
         | 
| 221 227 | 
             
                  CfdpPdu.decom_metadata_pdu_contents(CfdpPdu.new(crcs_required: false), hash, buffer[8..-3])
         | 
| @@ -274,6 +280,7 @@ RSpec.describe CfdpPdu, type: :model do | |
| 274 280 | 
             
                  expect(buffer[29].unpack('C')[0] & 0xF).to eql 4
         | 
| 275 281 |  | 
| 276 282 | 
             
                  hash = {}
         | 
| 283 | 
            +
                  hash['VERSION'] = 1
         | 
| 277 284 | 
             
                  # decom takes just the Metadata specific part of the buffer
         | 
| 278 285 | 
             
                  # so start at offset 8 and ignore the 2 checksum bytes
         | 
| 279 286 | 
             
                  CfdpPdu.decom_metadata_pdu_contents(CfdpPdu.new(crcs_required: false), hash, buffer[8..-3])
         | 
| @@ -331,6 +338,7 @@ RSpec.describe CfdpPdu, type: :model do | |
| 331 338 | 
             
                  expect(buffer[29..32].unpack('A*')[0]).to eql 'flow'
         | 
| 332 339 |  | 
| 333 340 | 
             
                  hash = {}
         | 
| 341 | 
            +
                  hash['VERSION'] = 1
         | 
| 334 342 | 
             
                  # decom takes just the Metadata specific part of the buffer
         | 
| 335 343 | 
             
                  # so start at offset 8 and ignore the 2 checksum bytes
         | 
| 336 344 | 
             
                  CfdpPdu.decom_metadata_pdu_contents(CfdpPdu.new(crcs_required: false), hash, buffer[8..-3])
         | 
| @@ -343,5 +351,98 @@ RSpec.describe CfdpPdu, type: :model do | |
| 343 351 | 
             
                  expect(tlv['TYPE']).to eql 'FLOW_LABEL'
         | 
| 344 352 | 
             
                  expect(tlv['FLOW_LABEL']).to eql 'flow'
         | 
| 345 353 | 
             
                end
         | 
| 354 | 
            +
             | 
| 355 | 
            +
                it "builds a Metadata PDU for version 0" do
         | 
| 356 | 
            +
                  destination_entity = CfdpMib.entity(@destination_entity_id)
         | 
| 357 | 
            +
                  destination_entity['protocol_version_number'] = 0
         | 
| 358 | 
            +
                  buffer = CfdpPdu.build_metadata_pdu(
         | 
| 359 | 
            +
                    source_entity: CfdpMib.entity(@source_entity_id),
         | 
| 360 | 
            +
                    transaction_seq_num: 1,
         | 
| 361 | 
            +
                    destination_entity: destination_entity,
         | 
| 362 | 
            +
                    file_size: 0xDEADBEEF,
         | 
| 363 | 
            +
                    segmentation_control: "NOT_PRESERVED",
         | 
| 364 | 
            +
                    transmission_mode: nil,
         | 
| 365 | 
            +
                    source_file_name: "filename",
         | 
| 366 | 
            +
                    destination_file_name: "test",
         | 
| 367 | 
            +
                    closure_requested: 0,
         | 
| 368 | 
            +
                    options: [])
         | 
| 369 | 
            +
                  # puts buffer.formatted
         | 
| 370 | 
            +
                  expect(buffer.length).to eql 29
         | 
| 371 | 
            +
             | 
| 372 | 
            +
                  # By default the first 7 bytes are the header
         | 
| 373 | 
            +
                  # This assumes 1 byte per entity ID and sequence number
         | 
| 374 | 
            +
                  expect(buffer[1..2].unpack('n')[0]).to eql 22 # PDU_DATA_LENGTH - Directive Code plus Data plus CRC
         | 
| 375 | 
            +
             | 
| 376 | 
            +
                  # Directive Code
         | 
| 377 | 
            +
                  expect(buffer[7].unpack('C')[0]).to eql 7 # Metadata per Table 5-4
         | 
| 378 | 
            +
                  # Closure requested
         | 
| 379 | 
            +
                  expect(buffer[8].unpack('C')[0] >> 6).to eql 0
         | 
| 380 | 
            +
                  # Checksum type
         | 
| 381 | 
            +
                  expect(buffer[8].unpack('C')[0] & 0xF).to eql 0 # legacy modular checksum
         | 
| 382 | 
            +
                  # File Size
         | 
| 383 | 
            +
                  expect(buffer[9..12].unpack('N')[0]).to eql 0xDEADBEEF
         | 
| 384 | 
            +
                  # Source File Name
         | 
| 385 | 
            +
                  expect(buffer[13].unpack('C')[0]).to eql 8
         | 
| 386 | 
            +
                  expect(buffer[14..21].unpack('A*')[0]).to eql 'filename'
         | 
| 387 | 
            +
                  # Destination File Name
         | 
| 388 | 
            +
                  expect(buffer[22].unpack('C')[0]).to eql 4
         | 
| 389 | 
            +
                  expect(buffer[23..26].unpack('A*')[0]).to eql 'test'
         | 
| 390 | 
            +
             | 
| 391 | 
            +
                  hash = {}
         | 
| 392 | 
            +
                  hash['VERSION'] = 0
         | 
| 393 | 
            +
                  # decom takes just the Metadata specific part of the buffer
         | 
| 394 | 
            +
                  # so start at offset 8 and ignore the 2 checksum bytes
         | 
| 395 | 
            +
                  CfdpPdu.decom_metadata_pdu_contents(CfdpPdu.new(crcs_required: false), hash, buffer[8..-3])
         | 
| 396 | 
            +
                  expect(hash['CLOSURE_REQUESTED']).to eql nil
         | 
| 397 | 
            +
                  expect(hash['CHECKSUM_TYPE']).to eql nil
         | 
| 398 | 
            +
                  expect(hash['FILE_SIZE']).to eql 0xDEADBEEF
         | 
| 399 | 
            +
                  expect(hash['SOURCE_FILE_NAME']).to eql 'filename'
         | 
| 400 | 
            +
                  expect(hash['DESTINATION_FILE_NAME']).to eql 'test'
         | 
| 401 | 
            +
                end
         | 
| 402 | 
            +
             | 
| 403 | 
            +
                it "builds a Metadata PDU with unknown checksum type, large file size, and default closure requested" do
         | 
| 404 | 
            +
                  destination_entity = CfdpMib.entity(@destination_entity_id)
         | 
| 405 | 
            +
                  destination_entity['default_checksum_type'] = 9 # Unsupported
         | 
| 406 | 
            +
                  buffer = CfdpPdu.build_metadata_pdu(
         | 
| 407 | 
            +
                    source_entity: CfdpMib.entity(@source_entity_id),
         | 
| 408 | 
            +
                    transaction_seq_num: 1,
         | 
| 409 | 
            +
                    destination_entity: destination_entity,
         | 
| 410 | 
            +
                    file_size: 0x100000000,
         | 
| 411 | 
            +
                    segmentation_control: "NOT_PRESERVED",
         | 
| 412 | 
            +
                    transmission_mode: nil,
         | 
| 413 | 
            +
                    source_file_name: "filename",
         | 
| 414 | 
            +
                    destination_file_name: "test",
         | 
| 415 | 
            +
                    closure_requested: nil,
         | 
| 416 | 
            +
                    options: [])
         | 
| 417 | 
            +
                  # puts buffer.formatted
         | 
| 418 | 
            +
                  expect(buffer.length).to eql 33
         | 
| 419 | 
            +
             | 
| 420 | 
            +
                  # By default the first 7 bytes are the header
         | 
| 421 | 
            +
                  # This assumes 1 byte per entity ID and sequence number
         | 
| 422 | 
            +
                  expect(buffer[1..2].unpack('n')[0]).to eql 26 # PDU_DATA_LENGTH - Directive Code plus Data plus CRC
         | 
| 423 | 
            +
             | 
| 424 | 
            +
                  # Directive Code
         | 
| 425 | 
            +
                  expect(buffer[7].unpack('C')[0]).to eql 7 # Metadata per Table 5-4
         | 
| 426 | 
            +
                  # Closure requested
         | 
| 427 | 
            +
                  expect(buffer[8].unpack('C')[0] >> 6).to eql 1
         | 
| 428 | 
            +
                  # Checksum type
         | 
| 429 | 
            +
                  expect(buffer[8].unpack('C')[0] & 0xF).to eql 0 # legacy modular checksum
         | 
| 430 | 
            +
                  # File Size
         | 
| 431 | 
            +
                  expect(buffer[9..16].unpack('Q>')[0]).to eql 0x100000000
         | 
| 432 | 
            +
                  # Source File Name
         | 
| 433 | 
            +
                  expect(buffer[17].unpack('C')[0]).to eql 8
         | 
| 434 | 
            +
                  expect(buffer[18..25].unpack('A*')[0]).to eql 'filename'
         | 
| 435 | 
            +
                  # Destination File Name
         | 
| 436 | 
            +
                  expect(buffer[26].unpack('C')[0]).to eql 4
         | 
| 437 | 
            +
                  expect(buffer[27..30].unpack('A*')[0]).to eql 'test'
         | 
| 438 | 
            +
             | 
| 439 | 
            +
                  # Test with toplevel decom
         | 
| 440 | 
            +
                  hash = CfdpPdu.decom(buffer)
         | 
| 441 | 
            +
                  expect(hash['CLOSURE_REQUESTED']).to eql "CLOSURE_REQUESTED"
         | 
| 442 | 
            +
                  expect(hash['CHECKSUM_TYPE']).to eql 0
         | 
| 443 | 
            +
                  expect(hash['FILE_SIZE']).to eql 0x100000000
         | 
| 444 | 
            +
                  expect(hash['SOURCE_FILE_NAME']).to eql 'filename'
         | 
| 445 | 
            +
                  expect(hash['DESTINATION_FILE_NAME']).to eql 'test'
         | 
| 446 | 
            +
                end
         | 
| 346 447 | 
             
              end
         | 
| 347 448 | 
             
            end
         |