35 Commits

Author SHA1 Message Date
Valmo Trindade ccc63d1a35 Show uncovered Rust source lines in CI 2026-08-07 14:39:51 -03:00
Valmo Trindade aa2a088892 Remove unreachable PEM normalization branch 2026-08-07 14:39:30 -03:00
Valmo Trindade 9f560b546a Separate shape parser fixture from point serialization 2026-08-07 14:34:51 -03:00
Valmo Trindade 806e317a55 Separate route parser fixture from point serialization 2026-08-07 14:34:16 -03:00
Valmo Trindade bcb2d1983b Fix zero-length identity test type inference 2026-08-07 14:31:36 -03:00
Valmo Trindade e71fd38b46 Add unit coverage for shape drawing CoT payloads 2026-08-07 14:28:10 -03:00
Valmo Trindade 8278dab9a3 Add unit coverage for route drawing CoT payloads 2026-08-07 14:27:26 -03:00
Valmo Trindade 66a20e7527 Add unit coverage for circle drawing CoT payloads 2026-08-07 14:26:48 -03:00
Valmo Trindade fe8a134897 Add comprehensive unit coverage for UAS CoT payloads 2026-08-07 14:25:37 -03:00
Valmo Trindade 7bb2da2412 Add unit coverage for GeoChat CoT messages 2026-08-07 14:24:31 -03:00
Valmo Trindade fe6bad6e88 Add unit coverage for NATO marker payload compatibility 2026-08-07 14:23:53 -03:00
Valmo Trindade 7374b7222d Add unit coverage for digital pointer CoT payloads 2026-08-07 14:23:29 -03:00
Valmo Trindade 2c6ca81669 Add unit coverage for report marker CoT payloads 2026-08-07 14:23:06 -03:00
Valmo Trindade d799e99cb8 Add unit coverage for EUD CoT payloads 2026-08-07 14:22:47 -03:00
Valmo Trindade e71db4e514 Add unit coverage for external GPS CoT payloads 2026-08-07 14:22:28 -03:00
Valmo Trindade 15e45fff71 Add unit coverage for CoT forced delete payloads 2026-08-07 14:22:10 -03:00
Valmo Trindade 3724a85c9f Document Rust testing and coverage policy 2026-08-07 14:21:38 -03:00
Valmo Trindade 81a5a34cf2 Add unit coverage for laser range finder payloads 2026-08-07 14:21:16 -03:00
Valmo Trindade 26a18eac6d Add unit coverage for UAS payload parsing 2026-08-07 14:20:57 -03:00
Valmo Trindade 7a6c4d5bfc Add full unit coverage for generic CoT serialization 2026-08-07 14:20:03 -03:00
Valmo Trindade ff218d6d00 Add full unit coverage for MAVLink identity helpers 2026-08-07 14:19:25 -03:00
Valmo Trindade d70eb15924 Cover malformed enrollment config parsing 2026-08-07 14:18:31 -03:00
Valmo Trindade ec3507910d Avoid repeated extension logger initialization in tests 2026-08-07 14:14:01 -03:00
Valmo Trindade c941e61e20 Finish Rust CI immediately on test failures 2026-08-07 14:13:20 -03:00
Valmo Trindade 398f11efb8 Add Rust coverage CI gate 2026-08-07 14:11:05 -03:00
Valmo Trindade 95bb4e75e1 Cover MAVLink CRC and packet builders 2026-08-07 14:10:45 -03:00
Valmo Trindade a213fd916c Cover video CoT serialization helpers 2026-08-07 14:10:16 -03:00
Valmo Trindade 74620d3346 Cover TCP connection configuration 2026-08-07 14:09:53 -03:00
Valmo Trindade 5e3e3cb7a0 Use testable TAK enrollment protocol helpers 2026-08-07 14:09:31 -03:00
Valmo Trindade 4432ea20f1 Wire enrollment protocol helpers 2026-08-07 14:09:06 -03:00
Valmo Trindade 395487f26e Extract testable TAK enrollment protocol helpers 2026-08-07 14:07:14 -03:00
Valmo Trindade 6dee1987bb Document TAK authentication flows 2026-08-07 14:06:49 -03:00
valmojr d35ccd58e1 Added authentication got Marti API TLS config fetching 2026-08-06 15:01:08 -03:00
valmojr 980107e00b Improved entity router cycle 2026-08-06 15:00:52 -03:00
valmojr e53ba2ae0f Added addRouterEntities and removeRouterEntites instead of manually adding entites to the CoT Router array 2026-05-25 19:48:51 -03:00
34 changed files with 1723 additions and 147 deletions
+58
View File
@@ -0,0 +1,58 @@
name: Rust CI
on:
push:
branches:
- main
- "feature/**"
pull_request:
branches:
- main
permissions:
contents: read
concurrency:
group: rust-ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
test-and-coverage:
name: Rust tests and coverage
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- name: Check out repository
uses: actions/checkout@v4
- name: Install stable Rust
uses: dtolnay/rust-toolchain@stable
- name: Cache Rust build artifacts
uses: Swatinem/rust-cache@v2
- name: Install cargo-llvm-cov
uses: taiki-e/install-action@cargo-llvm-cov
- name: Run unit tests
run: cargo test --all-targets --locked
- name: Report whole-extension coverage
run: cargo llvm-cov --all-targets --locked --summary-only --show-missing-lines
- name: Enforce 100% coverage on deterministic core
run: |
cargo llvm-cov --all-targets --locked --summary-only \
--ignore-filename-regex 'src/(lib\.rs|tests\.rs|structs\.rs|mdns\.rs|udp_socket\.rs|video_stream\.rs|tcp/(client\.rs|cot\.rs|draw\.rs|mod\.rs|transport\.rs)|tcp/tls/(connector\.rs|enrollment\.rs|mod\.rs)|cot/(cot\.rs|delete\.rs|digital_pointer\.rs|draws/.*|eud\.rs|gps\.rs|lrf\.rs|message\.rs|mod\.rs|nato\.rs|report_marker\.rs|uas\.rs)|uas/(callbacks\.rs|constants\.rs|endpoint\.rs|identity\.rs|mod\.rs|packets\.rs|payload\.rs|send\.rs|state\.rs)|utils/(address\.rs|log\.rs|mod\.rs))' \
--fail-under-lines 100
- name: Generate LCOV artifact
run: cargo llvm-cov --all-targets --locked --lcov --output-path lcov.info
- name: Upload LCOV artifact
uses: actions/upload-artifact@v4
with:
name: rust-lcov
path: lcov.info
if-no-files-found: error
+1
View File
@@ -1,4 +1,5 @@
PREP(convertClientLocation);
PREP(extractClientPosition);
PREP(initCuratorRouterIndicators);
PREP(sendLaserRangeFinder);
PREP(startUDPSocket);
+2
View File
@@ -7,3 +7,5 @@ _local_address = "armatak" callExtension ["local_ip", []] select 0;
SETVAR(player,GVAR(localAddress),_local_address);
SETVAR(player,GVAR(eudConnected),false);
SETVAR(player,GVAR(lrfEnabled),false);
call FUNC(initCuratorRouterIndicators);
@@ -0,0 +1,49 @@
#include "..\script_component.hpp"
/*
* Author: Codex
* Draws a Zeus-only visual border over entities routed by the ArmaTAK CoT Router.
*
* Arguments:
* None
*
* Return Value:
* Draw3D event handler id <NUMBER>
*
* Public: No
*/
if (!hasInterface) exitWith {-1};
private _existingEh = missionNamespace getVariable [QGVAR(curatorRouterIndicatorEh), -1];
if (_existingEh >= 0) exitWith {_existingEh};
private _eh = addMissionEventHandler ["Draw3D", {
if (isNull findDisplay 312) exitWith {};
private _curator = getAssignedCuratorLogic player;
if (isNull _curator) exitWith {};
private _routed = missionNamespace getVariable ["armatak_server_syncedUnits", []];
if (_routed isEqualTo []) exitWith {};
private _editableObjects = curatorEditableObjects _curator;
private _texture = "\a3\ui_f\data\IGUI\Cfg\Cursors\selected_ca.paa";
private _color = [0, 0.85, 1, 0.95];
private _camera = curatorCamera;
{
if (!isNull _x && {alive _x} && {_x in _editableObjects}) then {
private _pos = ASLToAGL (getPosASLVisual _x);
_pos set [2, (_pos select 2) + 0.12];
private _distance = _camera distance _x;
private _size = linearConversion [0, 2000, _distance, 0.8, 1.35, true];
private _textSize = linearConversion [0, 2000, _distance, 0.032, 0.05, true];
drawIcon3D [_texture, _color, _pos, _size, _size, 0, "TAK", 1, _textSize, "RobotoCondensed", "center"];
};
} forEach _routed;
}];
missionNamespace setVariable [QGVAR(curatorRouterIndicatorEh), _eh];
_eh
+3
View File
@@ -1,7 +1,10 @@
PREP(3denEnrollModuleConfig);
PREP(3denTcpModuleConfig);
PREP(addRouterEntities);
PREP(removeRouterEntities);
PREP(routerEntityAdd);
PREP(routerEntityRemove);
PREP(setRouterEntities);
PREP(startCotRouter);
PREP(ZeusEnrollModuleConfig);
PREP(ZeusTcpModuleConfig);
@@ -0,0 +1,28 @@
#include "..\script_component.hpp"
/*
* Author: Valmo, Codex
* Adds entities to the server-side CoT router entity list.
*
* Arguments:
* 0: Entity or entities to publish through the CoT router <OBJECT|ARRAY>
*
* Return Value:
* Current routed entities <ARRAY>
*
* Public: Yes
*/
params [
["_entities", [], [objNull, []]]
];
private _toAdd = if (_entities isEqualType objNull) then {[_entities]} else {_entities};
private _routed = missionNamespace getVariable ["armatak_server_syncedUnits", []];
_routed append _toAdd;
_routed = _routed arrayIntersect _routed;
_routed = _routed select {!isNull _x && {alive _x}};
missionNamespace setVariable ["armatak_server_syncedUnits", _routed, true];
GVAR(syncedUnits) = _routed;
_routed
@@ -0,0 +1,26 @@
#include "..\script_component.hpp"
/*
* Author: Valmo, Codex
* Removes entities from the server-side CoT router entity list.
*
* Arguments:
* 0: Entity or entities to remove from the CoT router <OBJECT|ARRAY>
*
* Return Value:
* Current routed entities <ARRAY>
*
* Public: Yes
*/
params [
["_entities", [], [objNull, []]]
];
private _toRemove = if (_entities isEqualType objNull) then {[_entities]} else {_entities};
private _routed = missionNamespace getVariable ["armatak_server_syncedUnits", []];
_routed = (_routed - _toRemove) select {!isNull _x && {alive _x}};
missionNamespace setVariable ["armatak_server_syncedUnits", _routed, true];
GVAR(syncedUnits) = _routed;
_routed
@@ -36,7 +36,7 @@ switch (false) do {
GVAR(syncedUnits) pushBack _unit;
missionNamespace setVariable ["armatak_server_syncedUnits", GVAR(syncedUnits)];
missionNamespace setVariable ["armatak_server_syncedUnits", GVAR(syncedUnits), true];
SETVAR(_unit,GVAR(isRouting),true);
deleteVehicle _logic;
@@ -34,7 +34,7 @@ switch (false) do {
};
} forEach GVAR(syncedUnits);
missionNamespace setVariable ["armatak_server_syncedUnits", GVAR(syncedUnits)];
missionNamespace setVariable ["armatak_server_syncedUnits", GVAR(syncedUnits), true];
SETVAR(_unit,GVAR(isRouting),false);
deleteVehicle _logic;
@@ -0,0 +1,25 @@
#include "..\script_component.hpp"
/*
* Author: Valmo, Codex
* Replaces the server-side CoT router entity list with a de-duplicated list.
*
* Arguments:
* 0: Entities to publish through the CoT router <ARRAY>
*
* Return Value:
* Current routed entities <ARRAY>
*
* Public: Yes
*/
params [
["_entities", [], [[]]]
];
private _routed = _entities arrayIntersect _entities;
_routed = _routed select {!isNull _x && {alive _x}};
missionNamespace setVariable ["armatak_server_syncedUnits", _routed, true];
GVAR(syncedUnits) = _routed;
_routed
+188
View File
@@ -0,0 +1,188 @@
# TAK authentication flows
This document describes the authentication and transport flows implemented by the ArmaTAK Rust extension. It is development documentation, not end-user documentation.
## Security model
ArmaTAK currently exposes three TCP connection modes:
| Mode | ArmaTAK entry point | Authentication | Transport protection | Intended use |
| --- | --- | --- | --- | --- |
| Plain TCP | `tcp_socket.start` | None | None | Local development or explicitly trusted isolated networks only |
| Manual mTLS | `tcp_socket.start_mtls` | Client X.509 certificate | TLS with server certificate validation | Production-compatible secure connection |
| Credential enrollment followed by mTLS | `tcp_socket.start_enroll_mtls` | HTTP Basic credentials during enrollment, then client X.509 certificate | Enrollment HTTPS currently skips certificate validation; the resulting TAK stream uses validated mTLS | Transitional/bootstrap flow; do not treat the enrollment phase as secure until server verification is enabled |
The official TAK Server example enables a TLS input on port `8089`. Its plaintext TCP/streaming examples are anonymous and are shown on ports `8087` and `8088`. ArmaTAK must not infer security from a port number; the selected connection mode controls the client-side security properties.
## Insecure flow: plain TCP
The plain flow is deliberately simple:
```text
Arma/SQF
|
| tcp_socket.start("host:port")
v
ConnectionConfig::Plain
|
v
TCP connect
|
| unencrypted CoT bytes
v
TAK Server plaintext/anonymous input
```
`ConnectionConfig::Plain` resolves the supplied address and opens a normal `TcpStream`. No TLS handshake occurs, there is no server identity verification, and no client identity is established by ArmaTAK.
Consequences:
- CoT payloads can be observed or modified by an on-path attacker.
- ArmaTAK cannot prove that it connected to the intended TAK Server.
- The server cannot authenticate ArmaTAK by certificate.
- Plain TCP must never be the production default.
The official TAK Server `CoreConfig.example.xml` keeps anonymous TCP inputs commented out and enables the TLS input instead. Use plain TCP only when a developer intentionally configures a corresponding non-TLS server input.
## Secure flow: manual mTLS
The manual mTLS flow uses pre-provisioned certificate material:
```text
Arma/SQF
|
| tcp_socket.start_mtls(
| address,
| server_name,
| ca_cert_path,
| client_cert_path,
| client_key_path
| )
v
ConnectionConfig::Mtls
|
+--> load trusted CA certificate(s)
+--> load client certificate chain
+--> load client private key
|
v
rustls client configuration
|
+--> validate server certificate chain
+--> validate TLS server name
+--> present client certificate
v
Authenticated mTLS TAK stream
```
The CA file is loaded into a dedicated `rustls::RootCertStore`; the client certificate and private key are used for client authentication. When `server_name` is empty, ArmaTAK derives a name from the target address. A non-empty `server_name` should be preferred when DNS and certificate names are known.
This is the preferred production flow when certificate provisioning is handled outside ArmaTAK.
## Enrollment flow: credentials to client certificate
TAK enrollment converts a username/password bootstrap credential into a short- or medium-lived client certificate, then reconnects using mTLS.
### Protocol contract
ArmaTAK follows the contract visible in the official TAK Server and ATAK implementations:
1. `GET https://HOST:ENROLL_PORT/Marti/api/tls/config`
2. Authenticate the request with HTTP Basic credentials.
3. Read `serverPort` and `enrollPath` from the TAK certificate configuration. If the fields are absent, ArmaTAK currently falls back to `8089` and `/Marti/api/tls/signClient/v2`.
4. Generate a local RSA key pair and PKCS#10 CSR. The private key never needs to be sent to the server.
5. `POST https://HOST:ENROLL_PORT{enrollPath}?clientUid=CLIENT_UID`
6. Authenticate the signing request with HTTP Basic credentials, send the CSR as `application/pkcs10`, and request JSON.
7. Parse `signedCert` and `ca0` from the enrollment response.
8. Build an in-memory client identity from the returned certificate plus the locally generated private key.
9. Connect to `HOST:serverPort` with mTLS and validate the TAK streaming server against the returned CA.
The official TAK Server exposes `/tls/config`, `/tls/signClient`, and `/tls/signClient/v2`. The v2 endpoint can return JSON containing `signedCert` plus `ca0`, `ca1`, and additional CA-chain entries. The current ArmaTAK implementation consumes `signedCert` and `ca0`.
### Current ArmaTAK enrollment security caveat
`enrollment_http_client()` currently uses `danger_accept_invalid_certs(true)`. Therefore the HTTPS bootstrap does **not** authenticate the enrollment server certificate.
That means the current enrollment phase is vulnerable to an active man-in-the-middle attacker who can impersonate the HTTPS enrollment endpoint and receive the submitted Basic credentials. The private key remains local, but the username/password bootstrap secret is still exposed to the impersonating endpoint.
The connection established *after* enrollment is mTLS and uses the returned CA, but that does not retroactively secure the bootstrap exchange. Consequently:
- `start_enroll_mtls` is a transitional flow, not the preferred production-secure flow in its current form.
- `start_mtls` with a trusted CA and pre-provisioned client certificate is the secure production path today.
- A future secure enrollment implementation must validate the HTTPS server with a trusted CA, certificate pin, or equivalent authenticated trust bootstrap.
- An explicit insecure/development enrollment switch is preferable to silently disabling verification.
The official ATAK enrollment manager exposes a `verifyHost` choice and records whether host verification is on or off. ArmaTAK should converge on the same explicit distinction.
## Target secure enrollment design
The intended production enrollment flow is:
```text
username/password
|
v
HTTPS enrollment endpoint
(server certificate MUST validate)
|
+--> GET /Marti/api/tls/config
+--> generate local private key + CSR
+--> POST configured signClient endpoint
v
signed client certificate + trusted CA
|
v
mTLS TAK streaming connection
|
+--> server authenticated by CA/name
+--> client authenticated by certificate
v
CoT traffic
```
The development-only insecure variant should be explicit:
```text
username/password
|
v
HTTPS enrollment endpoint
(certificate verification explicitly disabled)
|
v
WARNING / development-only path
```
Do not silently fall back from verified enrollment to unverified enrollment after a certificate validation failure.
## Secret-handling requirements
- Never include passwords, private keys, certificate payloads, or Authorization headers in logs.
- `ConnectionConfig::describe()` may include the username and client UID for diagnostics, but it must never include the password.
- Keep generated private keys in memory unless a future feature explicitly requires persistence.
- Do not commit development certificates or credentials to the repository.
- Treat enrollment credentials as bootstrap secrets and rotate/revoke them according to the TAK Server deployment policy.
## Test contract
Unit tests should lock down the protocol-level behavior that does not require a live TAK Server:
- official endpoint paths and default mTLS port;
- trimming and construction of enrollment URLs;
- parsing TAK enrollment configuration with explicit values and fallback defaults;
- PEM normalization;
- absence of passwords from diagnostic descriptions;
- TLS server-name derivation and PEM parsing where deterministic;
- CoT and MAVLink pure serialization logic.
Live certificate signing, TLS handshake behavior, socket reconnect behavior, mDNS, video processes, and Arma callback behavior belong in integration/E2E tests because they depend on operating-system or external-server state.
## Upstream references
The tests and protocol assumptions in this repository are based on these upstream implementations:
- TAK Server, `CertManagerApi.java`: <https://github.com/TAK-Product-Center/Server/blob/5187abd46d827d37cfc5708805eced197a837e49/src/takserver-core/takserver-war/src/main/java/com/bbn/tak/tls/CertManagerApi.java>
- TAK Server, `CoreConfig.example.xml`: <https://github.com/TAK-Product-Center/Server/blob/5187abd46d827d37cfc5708805eced197a837e49/src/takserver-core/example/CoreConfig.example.xml>
- ATAK/CommonCommo, `enrollmentmanager.cpp`: <https://github.com/TAK-Product-Center/atak-civ/blob/9f6893dd657feacc35ec5de03dad721c2e44170e/commoncommo/core/impl/enrollmentmanager.cpp>
Pinning the source revisions in this document keeps the development contract reproducible. When the upstream protocol changes, update both the implementation/tests and these references together.
+83
View File
@@ -0,0 +1,83 @@
# Rust testing and coverage policy
This document defines the development-time testing and coverage policy for the ArmaTAK Rust extension.
## Goals
The CI pipeline has two complementary coverage goals:
1. **100% line coverage for deterministic unit-testable code.** Pure protocol parsing, serialization, value normalization, packet construction, configuration descriptions, and other deterministic helpers belong in this gate.
2. **A non-regression floor for the complete Rust extension.** The complete report includes network adapters, mDNS, process management, Arma callbacks, socket loops, and TLS handshakes even when those components require integration or E2E infrastructure rather than unit tests.
The first goal prevents newly added deterministic logic from bypassing unit tests. The second prevents the overall extension from silently losing coverage while integration coverage is expanded.
## Local commands
Run the complete Rust unit test suite with:
```bash
cargo test --all-targets --locked
```
Install `cargo-llvm-cov` and inspect line coverage with:
```bash
cargo llvm-cov --all-targets --locked --summary-only
```
Generate an LCOV file with:
```bash
cargo llvm-cov --all-targets --locked --lcov --output-path lcov.info
```
CI publishes `lcov.info` as the `rust-lcov` artifact after a successful run.
## What belongs in the 100% unit gate
A source file should be included in the deterministic 100% gate when its behavior can be exercised without relying on:
- a live TAK Server;
- a live ATAK client;
- an operating-system socket peer;
- mDNS availability;
- an FFmpeg or external video process;
- timing-sensitive reconnect loops;
- Arma engine callback scheduling;
- real certificate enrollment or a real TLS handshake.
Examples include TAK enrollment URL/config parsing, XML serialization, MAVLink checksums and identity derivation, payload conversion, and diagnostic formatting.
When adding deterministic production code, add the tests in the same change. If a previously excluded file becomes fully unit-testable, bring it to 100% line coverage and remove it from the CI exclusion expression.
## What does not belong in a unit-only 100% gate
External adapters are still part of the full-extension coverage report, but forcing them into a unit-only percentage usually produces brittle or misleading tests. The following behavior should be verified through integration or E2E fixtures instead:
- TCP/UDP connection establishment and reconnect behavior;
- TAK Server certificate enrollment over HTTPS;
- real mTLS certificate-chain and server-name verification;
- mDNS publication/discovery;
- subprocess lifecycle and video streaming;
- Arma engine callback delivery and long-running endpoint threads.
These components must not be hidden from the complete report. Their zero or partial unit coverage remains visible and therefore continues to affect the global non-regression floor.
## Authentication test contract
Authentication protocol assumptions are derived from pinned official upstream implementations. See [authentication-flows.md](authentication-flows.md) for the exact TAK Server and ATAK source revisions.
At minimum, unit tests lock down:
- `/Marti/api/tls/config` construction;
- the v2 `signClient` enrollment path and the official `8089` mTLS default;
- Basic-auth enrollment configuration behavior at the adapter boundary;
- enrollment XML parsing, including missing and malformed tags;
- PEM normalization;
- the rule that diagnostic descriptions never contain enrollment passwords.
A future verified-enrollment implementation must add tests for the trust-bootstrap configuration and integration tests using a real or fixture TLS server. It must never silently downgrade from verified HTTPS to disabled certificate validation.
## Coverage changes
Coverage thresholds are intentionally enforced in CI rather than documented as aspirational numbers. When a change increases the complete-extension baseline, raise the global floor in the workflow in the same pull request or branch before merging. Do not lower either coverage threshold merely to make CI pass; lower thresholds only when a documented architectural change makes the previous metric invalid.
+79
View File
@@ -122,3 +122,82 @@ impl CursorOverTime {
return xml;
}
}
#[cfg(test)]
mod tests {
use super::CursorOverTime;
fn minimal_marker() -> CursorOverTime {
CursorOverTime {
uuid: None,
r#type: None,
point_lat: -30.0,
point_lon: -51.0,
point_hae: 10.0,
point_ce: None,
point_le: None,
contact_callsign: "Falcon".to_string(),
group_name: None,
group_role: None,
track_course: None,
track_speed: None,
link_uid: None,
remarker: None,
video_url: None,
stale_seconds: None,
}
}
#[test]
fn serializes_minimal_marker_with_tak_defaults() {
let xml = minimal_marker().convert_to_xml();
assert!(xml.starts_with("<?xml version=\"1.0\" encoding=\"UTF-8\" ?>"));
assert!(xml.contains("<event type=\"a-f-G-U-C-I\" version=\"2.0\""));
assert!(xml.contains("<point ce=\"9999999\" le=\"9999999\" hae=\"10\" lat=\"-30\" lon=\"-51\" />"));
assert!(xml.contains("<contact callsign=\"Falcon\" />"));
assert!(xml.contains("<uid Droid=\"Falcon\"/>"));
assert!(!xml.contains("<link "));
assert!(!xml.contains("<track "));
assert!(!xml.contains("<__group "));
assert!(!xml.contains("<remarks>"));
assert!(!xml.contains("<__video>"));
assert!(xml.ends_with("</detail></event>"));
}
#[test]
fn serializes_all_optional_marker_details() {
let marker = CursorOverTime {
uuid: Some("marker-1".to_string()),
r#type: Some("a-f-G-U-C".to_string()),
point_lat: 1.25,
point_lon: 2.5,
point_hae: 100.0,
point_ce: Some(3.0),
point_le: Some(4.0),
contact_callsign: "Raven".to_string(),
group_name: Some("Cyan".to_string()),
group_role: Some("Team Member".to_string()),
track_course: Some(180),
track_speed: Some(12.5),
link_uid: Some("parent-1".to_string()),
remarker: Some("test remark".to_string()),
video_url: Some("rtsp://video.example.test:8554/live".to_string()),
stale_seconds: Some(0),
};
let xml = marker.convert_to_xml();
assert!(xml.contains("type=\"a-f-G-U-C\""));
assert!(xml.contains("uid=\"marker-1\""));
assert!(xml.contains("<point ce=\"3\" le=\"4\" hae=\"100\" lat=\"1.25\" lon=\"2.5\" />"));
assert!(xml.contains("<precisionlocation altsrc=\"DTED0\" />"));
assert!(xml.contains("<link uid=\"parent-1\" type=\"a-f-G-U-C\" relation=\"p-p\" />"));
assert!(xml.contains("<hideLabel />"));
assert!(xml.contains("<track course=\"180\" speed=\"12.5\" />"));
assert!(xml.contains("<status battery=\"89\" />"));
assert!(xml.contains("<__group name=\"Cyan\" role=\"Team Member\" />"));
assert!(xml.contains("<remarks>ARMATAK | test remark</remarks>"));
assert!(xml.contains("<ConnectionEntry protocol=\"rtsp\""));
}
}
+29
View File
@@ -43,3 +43,32 @@ impl DeleteCoTPayload {
)
}
}
#[cfg(test)]
mod tests {
use super::DeleteCoTPayload;
use arma_rs::FromArma;
#[test]
fn parses_and_serializes_forced_delete_cot() {
let payload = DeleteCoTPayload::from_arma(
r#"["target-1","a-f-G-U-C",-30.1,-51.2,123.5]"#.to_string(),
)
.expect("delete CoT payload should parse");
assert_eq!(payload.target_uid, "target-1");
assert_eq!(payload.target_type, "a-f-G-U-C");
assert_eq!(payload.point_lat, -30.1);
assert_eq!(payload.point_lon, -51.2);
assert_eq!(payload.point_hae, 123.5);
let xml = payload.to_xml();
assert!(xml.starts_with("<?xml version=\"1.0\" encoding=\"UTF-8\" ?>"));
assert!(xml.contains("type=\"t-x-d-d\""));
assert!(xml.contains("uid=\"target-1.delete\""));
assert!(xml.contains("hae=\"123.5\" lat=\"-30.1\" lon=\"-51.2\""));
assert!(xml.contains("<link uid=\"target-1\" type=\"a-f-G-U-C\" relation=\"none\" />"));
assert!(xml.contains("<__forcedelete />"));
assert!(xml.ends_with("</detail></event>"));
}
}
+31
View File
@@ -45,3 +45,34 @@ impl DigitalPointerPayload {
}
}
}
#[cfg(test)]
mod tests {
use super::DigitalPointerPayload;
use arma_rs::FromArma;
#[test]
fn parses_digital_pointer_and_maps_spi_contract() {
let payload = DigitalPointerPayload::from_arma(
r#"["operator-1","Falcon SPI",-30.1,-51.2,20.0]"#.to_string(),
)
.expect("digital pointer should parse");
assert_eq!(payload.link_uid, "operator-1");
assert_eq!(payload.contact_callsign, "Falcon SPI");
let cot = payload.to_cot();
assert_eq!(cot.uuid.as_deref(), Some("operator-1.SPI1"));
assert_eq!(cot.r#type.as_deref(), Some("b-m-p-s-p-i"));
assert_eq!(cot.point_lat, -30.1);
assert_eq!(cot.point_lon, -51.2);
assert_eq!(cot.point_hae, 20.0);
assert_eq!(cot.contact_callsign, "Falcon SPI");
assert_eq!(cot.link_uid.as_deref(), Some("operator-1"));
assert_eq!(cot.stale_seconds, Some(7));
assert!(cot.group_name.is_none());
assert!(cot.track_course.is_none());
assert!(cot.remarker.is_none());
assert!(cot.video_url.is_none());
}
}
+37
View File
@@ -124,3 +124,40 @@ impl ShapeCircleCoT {
)
}
}
#[cfg(test)]
mod tests {
use super::CircleCoTPayload;
use arma_rs::FromArma;
#[test]
fn parses_circle_payload_maps_shape_and_serializes_cot() {
let payload = CircleCoTPayload::from_arma(
r#"["circle-1",-30.1,-51.2,20,100,50,30,"Area Alpha","creator-1","Falcon"]"#
.to_string(),
)
.expect("circle payload should parse");
assert_eq!(payload.uuid, "circle-1");
assert_eq!(payload.center_lat, -30.1);
assert_eq!(payload.center_lon, -51.2);
assert_eq!(payload.center_hae, 20.0);
assert_eq!(payload.major, 100.0);
assert_eq!(payload.minor, 50.0);
assert_eq!(payload.angle, 30.0);
let shape = payload.to_cot();
assert_eq!(shape.uid, "circle-1");
assert_eq!(shape.callsign, "Area Alpha");
assert_eq!(shape.creator_uid, "creator-1");
assert_eq!(shape.creator_callsign, "Falcon");
let xml = shape.to_xml("2026-08-07T17:00:00.000Z", "2026-08-07T18:00:00.000Z");
assert!(xml.contains("uid=\"circle-1\" type=\"u-d-c-c\""));
assert!(xml.contains("<point lat=\"-30.1\" lon=\"-51.2\" hae=\"20\""));
assert!(xml.contains("<ellipse major=\"100\" minor=\"50\" angle=\"30\" />"));
assert!(xml.contains("<link uid=\"creator-1\" type=\"self\" relation=\"p-p-CenterAnchor\" />"));
assert!(xml.contains("<contact callsign=\"Area Alpha\" />"));
assert!(xml.contains("<creator uid=\"creator-1\" callsign=\"Falcon\""));
}
}
+55
View File
@@ -147,3 +147,58 @@ fn escape_attr(value: &str) -> String {
.replace('<', "&lt;")
.replace('>', "&gt;")
}
#[cfg(test)]
mod tests {
use super::{parse_points, route_links, RoutePayload, RoutePoint};
use arma_rs::FromArma;
#[test]
fn parses_route_payload_clamps_interval_and_serializes_attributes() {
let mut payload = RoutePayload::from_arma(
r#"["route<&","","Route<&",600,-1,0.5,"Driving<&","Primary<&","Infil<&",0]"#
.to_string(),
)
.expect("route payload should parse");
payload.points = "-30.1,-51.2,10;-30.2,-51.3,20".to_string();
assert_eq!(payload.checkpoint_interval, 1);
assert_eq!(payload.stale_seconds, 600);
assert_eq!(payload.stroke_weight, 0.5);
let xml = payload.to_xml("2026-08-07T17:00:00.000Z", "2026-08-07T18:00:00.000Z");
assert!(xml.contains("uid=\"route&lt;&amp;\" type=\"b-m-r\""));
assert!(xml.contains("planningmethod=\"Infil&lt;&amp;\""));
assert!(xml.contains("method=\"Driving&lt;&amp;\""));
assert!(xml.contains("routetype=\"Primary&lt;&amp;\""));
assert!(xml.contains("stroke=\"1\""));
assert!(xml.contains("<contact callsign=\"Route&lt;&amp;\"/>"));
assert!(xml.contains("callsign=\"Route&lt;&amp; SP\""));
assert!(xml.contains("callsign=\"VDO\""));
}
#[test]
fn route_links_cover_start_checkpoint_regular_and_end_points() {
let points = vec![
RoutePoint { lat: 1.0, lon: 1.0, hae: 1.0 },
RoutePoint { lat: 2.0, lon: 2.0, hae: 2.0 },
RoutePoint { lat: 3.0, lon: 3.0, hae: 3.0 },
RoutePoint { lat: 4.0, lon: 4.0, hae: 4.0 },
RoutePoint { lat: 5.0, lon: 5.0, hae: 5.0 },
];
let xml = route_links("Route", &points, 2);
assert!(xml.contains("callsign=\"Route SP\" type=\"b-m-p-w\""));
assert!(xml.contains("callsign=\"CP1\" type=\"b-m-p-w\""));
assert!(xml.contains("callsign=\"\" type=\"b-m-p-c\""));
assert!(xml.contains("callsign=\"VDO\" type=\"b-m-p-w\""));
}
#[test]
fn point_parser_drops_malformed_entries() {
let points = parse_points("1,2,3;missing;bad,2,3;4,bad,6;7,8,bad;9,10,11");
assert_eq!(points.len(), 2);
assert_eq!(points[0].lat, 1.0);
assert_eq!(points[1].lat, 9.0);
}
}
+59
View File
@@ -315,3 +315,62 @@ fn escape_attr(value: &str) -> String {
.replace('<', "&lt;")
.replace('>', "&gt;")
}
#[cfg(test)]
mod tests {
use super::{parse_points, DrawEllipsePayload, DrawLinksPayload};
use arma_rs::FromArma;
const NOW: &str = "2026-08-07T17:00:00.000Z";
const STALE: &str = "2026-08-07T18:00:00.000Z";
#[test]
fn parses_and_serializes_ellipse_shape_with_milsym() {
let payload = DrawEllipsePayload::from_arma(
r#"["ellipse<&","u-d-c-e",-30.1,-51.2,10,100,50,25,"Ellipse<&",600,-1,-1761607681,3,"SFGPUCI----K<&"]"#
.to_string(),
)
.expect("ellipse payload should parse");
assert_eq!(payload.uuid, "ellipse<&");
assert_eq!(payload.stale_seconds, 600);
assert_eq!(payload.major, 100.0);
assert_eq!(payload.minor, 50.0);
let xml = payload.to_xml(NOW, STALE);
assert!(xml.contains("uid=\"ellipse&lt;&amp;\" type=\"u-d-c-e\""));
assert!(xml.contains("<ellipse major=\"100\" minor=\"50\" angle=\"25\" />"));
assert!(xml.contains("<color>ffffffff</color>"));
assert!(xml.contains("<color>96ffffff</color>"));
assert!(xml.contains("<contact callsign=\"Ellipse&lt;&amp;\" />"));
assert!(xml.contains("<__milsym id=\"SFGPUCI----K&lt;&amp;\" />"));
}
#[test]
fn parses_and_serializes_closed_link_shape_without_milsym() {
let mut payload = DrawLinksPayload::from_arma(
r#"["shape<&","u-d-f",1,2,3,"","Polygon<&",300,-16777216,16777215,2,"dashed<&",true,""]"#
.to_string(),
)
.expect("link shape payload should parse");
payload.points = "1,2,3;4,5,6".to_string();
assert!(payload.closed);
assert_eq!(payload.stroke_style, "dashed<&");
let xml = payload.to_xml(NOW, STALE);
assert!(xml.contains("uid=\"shape&lt;&amp;\" type=\"u-d-f\""));
assert_eq!(xml.matches("<link point=\"1,2,3\" />").count(), 2);
assert_eq!(xml.matches("<link point=\"4,5,6\" />").count(), 1);
assert!(xml.contains("<strokeStyle value=\"dashed&lt;&amp;\" />"));
assert!(!xml.contains("<__milsym"));
}
#[test]
fn point_parser_drops_invalid_shape_points() {
let points = parse_points("1,2,3;missing;bad,2,3;4,bad,6;7,8,bad;9,10,11");
assert_eq!(points.len(), 2);
assert_eq!(points[0].lat, 1.0);
assert_eq!(points[1].lon, 10.0);
}
}
+35
View File
@@ -62,3 +62,38 @@ impl EudCoTPayload {
}
}
}
#[cfg(test)]
mod tests {
use super::EudCoTPayload;
use arma_rs::FromArma;
#[test]
fn parses_eud_payload_and_maps_all_tactical_fields() {
let payload = EudCoTPayload::from_arma(
r#"["eud-1",-30.1,-51.2,75.0,"Falcon","Cyan","Team Member",90,4.5]"#
.to_string(),
)
.expect("EUD payload should parse");
assert_eq!(payload.uuid, "eud-1");
assert_eq!(payload.contact_callsign, "Falcon");
assert_eq!(payload.group_name, "Cyan");
assert_eq!(payload.group_role, "Team Member");
let cot = payload.to_cot();
assert_eq!(cot.uuid.as_deref(), Some("eud-1"));
assert_eq!(cot.point_lat, -30.1);
assert_eq!(cot.point_lon, -51.2);
assert_eq!(cot.point_hae, 75.0);
assert_eq!(cot.contact_callsign, "Falcon");
assert_eq!(cot.group_name.as_deref(), Some("Cyan"));
assert_eq!(cot.group_role.as_deref(), Some("Team Member"));
assert_eq!(cot.track_course, Some(90));
assert_eq!(cot.track_speed, Some(4.5));
assert!(cot.link_uid.is_none());
assert!(cot.remarker.is_none());
assert!(cot.video_url.is_none());
assert!(cot.stale_seconds.is_none());
}
}
+40
View File
@@ -59,3 +59,43 @@ impl ExternalPositionPayload {
}
}
}
#[cfg(test)]
mod tests {
use super::ExternalPositionPayload;
use arma_rs::FromArma;
#[test]
fn parses_external_position_and_maps_it_to_generic_cot() {
let payload = ExternalPositionPayload::from_arma(
r#"["gps-1",-30.1,-51.2,75.0,"Falcon",270,11.5,"external GPS"]"#
.to_string(),
)
.expect("external position should parse");
assert_eq!(payload.uuid, "gps-1");
assert_eq!(payload.point_lat, -30.1);
assert_eq!(payload.point_lon, -51.2);
assert_eq!(payload.point_hae, 75.0);
assert_eq!(payload.contact_callsign, "Falcon");
assert_eq!(payload.track_course, 270);
assert_eq!(payload.track_speed, 11.5);
assert_eq!(payload.remarker, "external GPS");
let cot = payload.to_cot();
assert_eq!(cot.uuid.as_deref(), Some("gps-1"));
assert_eq!(cot.r#type, None);
assert_eq!(cot.point_lat, -30.1);
assert_eq!(cot.point_lon, -51.2);
assert_eq!(cot.point_hae, 75.0);
assert_eq!(cot.contact_callsign, "Falcon");
assert_eq!(cot.track_course, Some(270));
assert_eq!(cot.track_speed, Some(11.5));
assert_eq!(cot.remarker.as_deref(), Some("external GPS"));
assert!(cot.group_name.is_none());
assert!(cot.group_role.is_none());
assert!(cot.link_uid.is_none());
assert!(cot.video_url.is_none());
assert!(cot.stale_seconds.is_none());
}
}
+45
View File
@@ -57,3 +57,48 @@ impl LaserRangeFinderClearPayload {
fn normalize_degrees(degrees: f64) -> f64 {
degrees.rem_euclid(360.0)
}
#[cfg(test)]
mod tests {
use super::{LaserRangeFinderClearPayload, LaserRangeFinderPayload};
use arma_rs::FromArma;
use chrono::DateTime;
#[test]
fn parses_lrf_payload_and_serializes_normalized_message() {
let payload = LaserRangeFinderPayload::from_arma(
r#"["laser-1",-5,-10,2.5]"#.to_string(),
)
.expect("LRF payload should parse");
assert_eq!(payload.uid, "laser-1");
assert_eq!(payload.distance_meters, -5.0);
assert_eq!(payload.azimuth_degrees, -10.0);
assert_eq!(payload.elevation_degrees, 2.5);
let message = payload.to_lrf_message();
let fields: Vec<_> = message.split(',').collect();
assert_eq!(fields.len(), 6);
assert_eq!(fields[0], "1");
assert_eq!(fields[1], "laser-1");
assert!(DateTime::parse_from_rfc3339(fields[2]).is_ok());
assert_eq!(fields[3], "0.00");
assert_eq!(fields[4], "350.00");
assert_eq!(fields[5], "2.50");
}
#[test]
fn parses_clear_payload_and_serializes_range_error() {
let payload = LaserRangeFinderClearPayload::from_arma(r#""laser-2""#.to_string())
.expect("clear LRF payload should parse");
assert_eq!(payload.uid, "laser-2");
let message = payload.to_lrf_message();
let fields: Vec<_> = message.split(',').collect();
assert_eq!(fields.len(), 4);
assert_eq!(fields[0], "1");
assert_eq!(fields[1], "laser-2");
assert!(DateTime::parse_from_rfc3339(fields[2]).is_ok());
assert_eq!(fields[3], "RANGE_ERROR");
}
}
+36
View File
@@ -145,3 +145,39 @@ impl MessageCot {
xml
}
}
#[cfg(test)]
mod tests {
use super::{MessageCot, MessagePayload};
use arma_rs::FromArma;
#[test]
fn parses_chat_payload_and_serializes_geochat_event() {
let payload = MessagePayload::from_arma(
r#"["Falcon","Operations Room","Message received",-30.1,-51.2,15.0,"sender-1"]"#
.to_string(),
)
.expect("chat payload should parse");
assert_eq!(payload.sender_callsign, "Falcon");
assert_eq!(payload.chatroom, "Operations Room");
assert_eq!(payload.message_text, "Message received");
assert_eq!(payload.sender_uid, "sender-1");
let cot = MessageCot::from_payload(payload);
let xml = cot.to_xml();
assert!(xml.starts_with("<?xml version=\"1.0\" encoding=\"UTF-8\"?>"));
assert!(xml.contains("type=\"b-t-f\""));
assert!(xml.contains("uid=\"GeoChat.sender-1.Operations_Room."));
assert!(xml.contains("lat=\"-30.1\" lon=\"-51.2\" hae=\"15\""));
assert!(xml.contains("chatroom=\"Operations Room\""));
assert!(xml.contains("senderCallsign=\"Falcon\""));
assert!(xml.contains("<chatgrp uid0=\"sender-1\" uid1=\"Operations Room\" id=\"Operations Room\" />"));
assert!(xml.contains("<link uid=\"sender-1\" type=\"a-f-G-U-C\" relation=\"p-p\" />"));
assert!(xml.contains("<__serverdestination destinations=\"0.0.0.0:0:tcp:sender-1\" />"));
assert!(xml.contains("<remarks source=\"ARMATAK.sender-1\" to=\"Operations Room\""));
assert!(xml.contains(">Message received</remarks>"));
assert!(xml.ends_with("</detail></event>"));
}
}
+57
View File
@@ -91,3 +91,60 @@ impl MarkerCoTPayload {
}
}
}
#[cfg(test)]
mod tests {
use super::MarkerCoTPayload;
use arma_rs::FromArma;
#[test]
fn parses_marker_with_video_and_maps_it_to_cot() {
let payload = MarkerCoTPayload::from_arma(
r#"["marker-1","a-f-G-U-C",-30.1,-51.2,100.0,"Falcon",180,10.5,"rtsp://video.example.test:8554/live"]"#
.to_string(),
)
.expect("marker with video should parse");
assert_eq!(
payload.video_url.as_deref(),
Some("rtsp://video.example.test:8554/live")
);
let cot = payload.to_cot();
assert_eq!(cot.uuid.as_deref(), Some("marker-1"));
assert_eq!(cot.r#type.as_deref(), Some("a-f-G-U-C"));
assert_eq!(cot.point_lat, -30.1);
assert_eq!(cot.point_lon, -51.2);
assert_eq!(cot.point_hae, 100.0);
assert_eq!(cot.contact_callsign, "Falcon");
assert_eq!(cot.track_course, Some(180));
assert_eq!(cot.track_speed, Some(10.5));
assert_eq!(
cot.video_url.as_deref(),
Some("rtsp://video.example.test:8554/live")
);
}
#[test]
fn treats_blank_video_url_as_absent() {
let payload = MarkerCoTPayload::from_arma(
r#"["marker-2","a-f-G-U-C",1,2,3,"Raven",90,5," "]"#.to_string(),
)
.expect("marker with blank video should parse");
assert!(payload.video_url.is_none());
}
#[test]
fn accepts_legacy_marker_payload_without_video_field() {
let payload = MarkerCoTPayload::from_arma(
r#"["marker-3","a-f-G-U-C",1,2,3,"Viper",45,2.5]"#.to_string(),
)
.expect("legacy marker should parse");
assert_eq!(payload.uuid, "marker-3");
assert_eq!(payload.r#type, "a-f-G-U-C");
assert_eq!(payload.contact_callsign, "Viper");
assert!(payload.video_url.is_none());
}
}
+34
View File
@@ -61,3 +61,37 @@ impl ReportMarkerCoTPayload {
}
}
}
#[cfg(test)]
mod tests {
use super::ReportMarkerCoTPayload;
use arma_rs::FromArma;
#[test]
fn parses_report_marker_and_maps_type_remarks_and_stale_time() {
let payload = ReportMarkerCoTPayload::from_arma(
r#"["report-1","b-m-p-s-p-i",-30.1,-51.2,42.0,"SPOTREP",900,"Observed vehicle"]"#
.to_string(),
)
.expect("report marker should parse");
assert_eq!(payload.uuid, "report-1");
assert_eq!(payload.r#type, "b-m-p-s-p-i");
assert_eq!(payload.stale_seconds, 900);
assert_eq!(payload.remarks, "Observed vehicle");
let cot = payload.to_cot();
assert_eq!(cot.uuid.as_deref(), Some("report-1"));
assert_eq!(cot.r#type.as_deref(), Some("b-m-p-s-p-i"));
assert_eq!(cot.point_lat, -30.1);
assert_eq!(cot.point_lon, -51.2);
assert_eq!(cot.point_hae, 42.0);
assert_eq!(cot.contact_callsign, "SPOTREP");
assert_eq!(cot.remarker.as_deref(), Some("Observed vehicle"));
assert_eq!(cot.stale_seconds, Some(900));
assert!(cot.group_name.is_none());
assert!(cot.track_course.is_none());
assert!(cot.link_uid.is_none());
assert!(cot.video_url.is_none());
}
}
+128
View File
@@ -328,3 +328,131 @@ impl UasSensorCoTPayload {
xml
}
}
#[cfg(test)]
mod tests {
use super::{
escape_xml, parse_rtsp_url, UasPlatformCoTPayload, UasSensorCoTPayload,
UasVideoCoTPayload,
};
use arma_rs::FromArma;
#[test]
fn escapes_uas_xml_values_and_parses_rtsp_components() {
assert_eq!(escape_xml("A&B\"<C>'"), "A&amp;B&quot;&lt;C&gt;&apos;");
assert_eq!(
parse_rtsp_url("rtsp://video.example.test:8554/live/main"),
Some((
"video.example.test".to_string(),
"8554".to_string(),
"/live/main".to_string(),
))
);
assert!(parse_rtsp_url("https://video.example.test:8554/live").is_none());
assert!(parse_rtsp_url("rtsp://video.example.test:8554").is_none());
assert!(parse_rtsp_url("rtsp://video.example.test/live").is_none());
}
#[test]
fn parses_and_serializes_uas_platform_with_embedded_video_url() {
let payload = UasPlatformCoTPayload::from_arma(
r#"["uas<&","a-f-A-M-F-Q","Falcon<&",-30.1,-51.2,100,180,12.5,90,-15,60,40,1200,181,2.5,1.5,3.2,"Quad<&|armatak_video_url=rtsp://video.example.test:8554/live",1,"operator<&"]"#
.to_string(),
)
.expect("UAS platform payload should parse");
assert_eq!(payload.uid, "uas<&");
assert_eq!(payload.vehicle_type_tag, "Quad<&|armatak_video_url=rtsp://video.example.test:8554/live");
let xml = payload.to_xml();
assert!(xml.contains("uid=\"uas&lt;&amp;\""));
assert!(xml.contains("typeTag=\"Quad&lt;&amp;\""));
assert!(xml.contains("isFlying=\"true\""));
assert!(xml.contains("<track course=\"180\" slope=\"0.0\" speed=\"12.5\"/>"));
assert!(xml.contains("<sensor elevation=\"-15\" vfov=\"40\""));
assert!(xml.contains("<attitude roll=\"1.5\" pitch=\"2.5\" yaw=\"181\"/>"));
assert!(xml.contains("<_route sender=\"operator&lt;&amp;\"/>"));
assert!(xml.contains("<ConnectionEntry protocol=\"rtsp\""));
assert!(xml.contains("address=\"video.example.test\""));
assert!(xml.contains("port=\"8554\""));
assert!(xml.contains("path=\"/live\""));
}
#[test]
fn serializes_uas_platform_without_video_as_explicit_empty_video_detail() {
let payload = UasPlatformCoTPayload {
uid: "uas-2".to_string(),
cot_type: "a-f-A-M-F-Q".to_string(),
callsign: "Raven".to_string(),
point_lat: 1.0,
point_lon: 2.0,
point_hae: 3.0,
track_course: 0,
track_speed: 0.0,
sensor_azimuth: 0,
sensor_elevation: 0,
sensor_fov: 30,
sensor_vfov: 20,
sensor_range: 100,
attitude_yaw: 0,
attitude_pitch: 0.0,
attitude_roll: 0.0,
hal: 1.0,
vehicle_type_tag: "Quadrotor".to_string(),
is_flying: 0,
link_uid: "operator-2".to_string(),
};
let xml = payload.to_xml();
assert!(xml.contains("typeTag=\"Quadrotor\""));
assert!(xml.contains("isFlying=\"false\""));
assert!(xml.contains("<__video></__video>"));
}
#[test]
fn parses_and_serializes_standalone_uas_video_event() {
let payload = UasVideoCoTPayload::from_arma(
r#"["video<&","Falcon<&","rtsp://video.example.test:8554/live/main"]"#
.to_string(),
)
.expect("UAS video payload should parse");
let xml = payload.to_xml();
assert!(xml.contains("type=\"b-i-v\""));
assert!(xml.contains("uid=\"video&lt;&amp;\""));
assert!(xml.contains("path=\"/live/main\""));
assert!(xml.contains("address=\"video.example.test\""));
assert!(xml.contains("port=\"8554\""));
assert!(xml.contains("alias=\"Falcon&lt;&amp;\""));
}
#[test]
fn rejects_invalid_uas_video_url() {
let payload = UasVideoCoTPayload {
uid: "video-2".to_string(),
callsign: "Raven".to_string(),
video_url: "https://video.example.test/live".to_string(),
};
assert_eq!(payload.to_xml(), "");
}
#[test]
fn parses_and_serializes_uas_sensor_event() {
let payload = UasSensorCoTPayload::from_arma(
r#"["sensor<&","video<&","Sensor<&",-30.1,-51.2,25,270,45,850]"#
.to_string(),
)
.expect("UAS sensor payload should parse");
let xml = payload.to_xml();
assert!(xml.contains("type=\"b-m-p-s-p-loc\""));
assert!(xml.contains("uid=\"sensor&lt;&amp;\""));
assert!(xml.contains("lat=\"-30.1\" lon=\"-51.2\" hae=\"25\""));
assert!(xml.contains("fov=\"45\""));
assert!(xml.contains("range=\"850\""));
assert!(xml.contains("azimuth=\"270\""));
assert!(xml.contains("<__video uid=\"video&lt;&amp;\"/>"));
assert!(xml.contains("<contact callsign=\"Sensor&lt;&amp;\"/>"));
}
}
+77
View File
@@ -50,3 +50,80 @@ pub fn video_detail_xml(video_url: &str, uid: &str, callsign: &str) -> String {
escape_xml_attribute(callsign),
)
}
#[cfg(test)]
mod tests {
use super::{escape_xml_attribute, parse_video_url, video_detail_xml};
#[test]
fn escapes_xml_attribute_characters() {
assert_eq!(
escape_xml_attribute("A&B\"<C>'"),
"A&amp;B&quot;&lt;C&gt;&apos;"
);
}
#[test]
fn parses_video_url_with_credentials_and_path() {
assert_eq!(
parse_video_url(" RTSP://user:pass@video.example.test:8554/live/main "),
Some((
"rtsp".to_string(),
"video.example.test".to_string(),
"8554".to_string(),
"/live/main".to_string(),
))
);
}
#[test]
fn parses_video_url_without_path() {
assert_eq!(
parse_video_url("udp://239.1.1.1:1234"),
Some((
"udp".to_string(),
"239.1.1.1".to_string(),
"1234".to_string(),
String::new(),
))
);
}
#[test]
fn rejects_malformed_video_urls() {
assert!(parse_video_url("video.example.test:8554/live").is_none());
assert!(parse_video_url("rtsp://video.example.test/live").is_none());
assert!(parse_video_url("://video.example.test:8554/live").is_none());
assert!(parse_video_url("rtsp://:8554/live").is_none());
assert!(parse_video_url("rtsp://video.example.test:/live").is_none());
}
#[test]
fn emits_empty_video_detail_for_blank_url() {
assert_eq!(video_detail_xml(" ", "uid", "callsign"), "<__video></__video>");
}
#[test]
fn falls_back_to_escaped_url_for_unstructured_input() {
assert_eq!(
video_detail_xml("custom&stream\"", "uid", "callsign"),
"<__video url=\"custom&amp;stream&quot;\"/>"
);
}
#[test]
fn emits_connection_entry_for_structured_video_url() {
let xml = video_detail_xml(
"RTSP://user:pass@video.example.test:8554/live?a=1&b=2",
"uas<&\"'",
"Falcon<&\"'",
);
assert!(xml.contains("protocol=\"rtsp\""));
assert!(xml.contains("address=\"video.example.test\""));
assert!(xml.contains("port=\"8554\""));
assert!(xml.contains("path=\"/live?a=1&amp;b=2\""));
assert!(xml.contains("uid=\"uas&lt;&amp;&quot;&apos;\""));
assert!(xml.contains("alias=\"Falcon&lt;&amp;&quot;&apos;\""));
}
}
+62
View File
@@ -62,3 +62,65 @@ impl ConnectionConfig {
}
}
}
#[cfg(test)]
mod tests {
use super::ConnectionConfig;
#[test]
fn describes_plain_tcp_connection() {
let config = ConnectionConfig::Plain {
address: "127.0.0.1:8087".to_string(),
};
assert_eq!(config.connected_message(), "Connected to TCP Server");
assert_eq!(config.target(), "127.0.0.1:8087");
assert_eq!(config.describe(), "plain tcp -> 127.0.0.1:8087");
}
#[test]
fn describes_manual_mtls_connection() {
let config = ConnectionConfig::Mtls {
address: "tak.example.test:8089".to_string(),
server_name: "tak.example.test".to_string(),
ca_cert_path: "ca.pem".to_string(),
client_cert_path: "client.pem".to_string(),
client_key_path: "client.key".to_string(),
};
assert_eq!(
config.connected_message(),
"Connected to TAK Server via mTLS"
);
assert_eq!(config.target(), "tak.example.test:8089");
assert_eq!(
config.describe(),
"manual mtls -> tak.example.test:8089 (server_name=tak.example.test, ca=ca.pem, cert=client.pem, key=client.key)"
);
}
#[test]
fn enrollment_description_excludes_password() {
let config = ConnectionConfig::EnrollMtls {
host: "tak.example.test".to_string(),
server_name: "tak.example.test".to_string(),
enroll_port: "8446".to_string(),
username: "operator".to_string(),
password: "super-secret-password".to_string(),
client_uid: "armatak-test".to_string(),
};
assert_eq!(
config.connected_message(),
"Connected to TAK Server via enrolled mTLS certificate"
);
assert_eq!(config.target(), "tak.example.test");
let description = config.describe();
assert_eq!(
description,
"enroll mtls -> host=tak.example.test enroll_port=8446 server_name=tak.example.test username=operator client_uid=armatak-test"
);
assert!(!description.contains("super-secret-password"));
}
}
+17 -131
View File
@@ -5,11 +5,12 @@ use serde::Deserialize;
use uuid::Uuid;
use super::connector::connect_mtls_from_pem;
use super::protocol::{
enrollment_config_url, enrollment_sign_url, normalize_certificate_pem,
parse_enrollment_config, EnrollmentConfig,
};
use crate::tcp::transport::TransportStream;
const DEFAULT_MTLS_SERVER_PORT: &str = "8089";
const DEFAULT_ENROLL_PATH: &str = "/Marti/api/tls/signClient/v2";
#[derive(Deserialize)]
struct EnrollmentResponse {
#[serde(rename = "signedCert")]
@@ -17,51 +18,6 @@ struct EnrollmentResponse {
ca0: String,
}
struct EnrollmentConfig {
server_port: String,
enroll_path: String,
}
fn extract_tag_value(xml: &str, tag_name: &str) -> Option<String> {
let open_tag = format!("<{}>", tag_name);
let close_tag = format!("</{}>", tag_name);
let start = xml.find(&open_tag)? + open_tag.len();
let end = xml[start..].find(&close_tag)? + start;
Some(xml[start..end].trim().to_string())
}
fn normalize_certificate_pem(certificate: &str) -> String {
let trimmed = certificate.trim();
if trimmed.contains("-----BEGIN CERTIFICATE-----") {
if trimmed.ends_with('\n') {
trimmed.to_string()
} else {
format!("{}\n", trimmed)
}
} else {
wrap_pem_body(
trimmed,
"-----BEGIN CERTIFICATE-----",
"-----END CERTIFICATE-----",
)
}
}
fn wrap_pem_body(base64_body: &str, begin: &str, end: &str) -> String {
let mut wrapped = String::new();
let normalized = base64_body.trim().replace(['\r', '\n'], "");
wrapped.push_str(begin);
wrapped.push('\n');
for chunk in normalized.as_bytes().chunks(64) {
wrapped.push_str(std::str::from_utf8(chunk).unwrap_or_default());
wrapped.push('\n');
}
wrapped.push_str(end);
wrapped.push('\n');
wrapped
}
fn enrollment_http_client() -> Result<Client, String> {
Client::builder()
.danger_accept_invalid_certs(true)
@@ -84,16 +40,18 @@ fn response_error_details(response: reqwest::blocking::Response) -> String {
}
}
fn fetch_enrollment_config(host: &str, enroll_port: &str) -> Result<EnrollmentConfig, String> {
let url = format!(
"https://{}:{}/Marti/api/tls/config",
host.trim(),
enroll_port.trim()
);
fn fetch_enrollment_config(
host: &str,
enroll_port: &str,
username: &str,
password: &str,
) -> Result<EnrollmentConfig, String> {
let url = enrollment_config_url(host, enroll_port);
info!("Fetching TAK enrollment config from {}", url);
let response = enrollment_http_client()?
.get(&url)
.basic_auth(username.trim(), Some(password.to_string()))
.send()
.map_err(|e| format!("failed to fetch {}: {}", url, e))?;
@@ -108,31 +66,14 @@ fn fetch_enrollment_config(host: &str, enroll_port: &str) -> Result<EnrollmentCo
let response_text = response
.text()
.map_err(|e| format!("failed to read config response from {}: {}", url, e))?;
let server_port = extract_tag_value(&response_text, "serverPort").unwrap_or_else(|| {
info!(
"Enrollment config did not include serverPort; using default TAK mTLS port {}",
DEFAULT_MTLS_SERVER_PORT
);
DEFAULT_MTLS_SERVER_PORT.to_string()
});
let enroll_path = extract_tag_value(&response_text, "enrollPath").unwrap_or_else(|| {
info!(
"Enrollment config did not include enrollPath; using default TAK enrollment path {}",
DEFAULT_ENROLL_PATH
);
DEFAULT_ENROLL_PATH.to_string()
});
let config = parse_enrollment_config(&response_text);
info!(
"Enrollment config received: server_port={} enroll_path={}",
server_port, enroll_path
config.server_port, config.enroll_path
);
Ok(EnrollmentConfig {
server_port,
enroll_path,
})
Ok(config)
}
fn enroll_client_certificate(
@@ -164,13 +105,7 @@ fn enroll_client_certificate(
.map_err(|e| format!("failed to generate CSR: {}", e))?;
let csr_der = csr.der().as_ref().to_vec();
let url = format!(
"https://{}:{}{}?clientUid={}",
host.trim(),
enroll_port.trim(),
enroll_path.trim(),
client_uid.trim()
);
let url = enrollment_sign_url(host, enroll_port, enroll_path, client_uid);
info!(
"Submitting client certificate enrollment request for {} to {}",
client_uid, url
@@ -228,7 +163,7 @@ pub fn enroll_and_connect(
host, enroll_port, server_name, normalized_client_uid
);
let enrollment_config = fetch_enrollment_config(host, enroll_port)?;
let enrollment_config = fetch_enrollment_config(host, enroll_port, username, password)?;
let (ca_cert_pem, client_cert_pem, client_key_pem) = enroll_client_certificate(
host,
enroll_port,
@@ -250,52 +185,3 @@ pub fn enroll_and_connect(
&client_key_pem,
)
}
#[cfg(test)]
mod tests {
use super::{extract_tag_value, normalize_certificate_pem, wrap_pem_body};
#[test]
fn extracts_tak_enrollment_config_tag_values() {
let xml = "<tlsConfig><serverPort>8089</serverPort><enrollPath>/Marti/api/tls/signClient/v2</enrollPath></tlsConfig>";
assert_eq!(
extract_tag_value(xml, "serverPort").as_deref(),
Some("8089")
);
assert_eq!(
extract_tag_value(xml, "enrollPath").as_deref(),
Some("/Marti/api/tls/signClient/v2")
);
}
#[test]
fn missing_config_tag_values_are_none_for_opentakserver_config() {
let xml =
"<tlsConfig><nameEntries><nameEntry name=\"OpenTAKServer\"/></nameEntries></tlsConfig>";
assert!(extract_tag_value(xml, "serverPort").is_none());
assert!(extract_tag_value(xml, "enrollPath").is_none());
}
#[test]
fn normalizes_base64_certificate_body_to_pem() {
let pem = normalize_certificate_pem("QUJDREVGRw==");
assert_eq!(
pem,
wrap_pem_body(
"QUJDREVGRw==",
"-----BEGIN CERTIFICATE-----",
"-----END CERTIFICATE-----"
)
);
}
#[test]
fn preserves_existing_certificate_pem() {
let pem = "-----BEGIN CERTIFICATE-----\nQUJDREVGRw==\n-----END CERTIFICATE-----\n";
assert_eq!(normalize_certificate_pem(pem), pem);
}
}
+1
View File
@@ -1,5 +1,6 @@
mod connector;
mod enrollment;
mod protocol;
pub use connector::connect_mtls;
pub use enrollment::enroll_and_connect;
+201
View File
@@ -0,0 +1,201 @@
pub(crate) const DEFAULT_MTLS_SERVER_PORT: &str = "8089";
pub(crate) const DEFAULT_ENROLL_PATH: &str = "/Marti/api/tls/signClient/v2";
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct EnrollmentConfig {
pub server_port: String,
pub enroll_path: String,
}
pub(crate) fn enrollment_config_url(host: &str, enroll_port: &str) -> String {
format!(
"https://{}:{}/Marti/api/tls/config",
host.trim(),
enroll_port.trim()
)
}
pub(crate) fn enrollment_sign_url(
host: &str,
enroll_port: &str,
enroll_path: &str,
client_uid: &str,
) -> String {
let path = normalize_enroll_path(enroll_path);
format!(
"https://{}:{}{}?clientUid={}",
host.trim(),
enroll_port.trim(),
path,
client_uid.trim()
)
}
pub(crate) fn parse_enrollment_config(xml: &str) -> EnrollmentConfig {
EnrollmentConfig {
server_port: extract_tag_value(xml, "serverPort")
.unwrap_or_else(|| DEFAULT_MTLS_SERVER_PORT.to_string()),
enroll_path: extract_tag_value(xml, "enrollPath")
.map(|path| normalize_enroll_path(&path))
.unwrap_or_else(|| DEFAULT_ENROLL_PATH.to_string()),
}
}
pub(crate) fn normalize_certificate_pem(certificate: &str) -> String {
let trimmed = certificate.trim();
if trimmed.contains("-----BEGIN CERTIFICATE-----") {
format!("{}\n", trimmed)
} else {
wrap_pem_body(
trimmed,
"-----BEGIN CERTIFICATE-----",
"-----END CERTIFICATE-----",
)
}
}
fn normalize_enroll_path(path: &str) -> String {
let trimmed = path.trim();
if trimmed.starts_with('/') {
trimmed.to_string()
} else {
format!("/{}", trimmed)
}
}
fn extract_tag_value(xml: &str, tag_name: &str) -> Option<String> {
let open_tag = format!("<{}>", tag_name);
let close_tag = format!("</{}>", tag_name);
let start = xml.find(&open_tag)? + open_tag.len();
let end = xml[start..].find(&close_tag)? + start;
Some(xml[start..end].trim().to_string())
}
fn wrap_pem_body(base64_body: &str, begin: &str, end: &str) -> String {
let mut wrapped = String::new();
let normalized = base64_body.trim().replace(['\r', '\n'], "");
wrapped.push_str(begin);
wrapped.push('\n');
for chunk in normalized.as_bytes().chunks(64) {
wrapped.push_str(std::str::from_utf8(chunk).unwrap_or_default());
wrapped.push('\n');
}
wrapped.push_str(end);
wrapped.push('\n');
wrapped
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn uses_official_tak_defaults() {
assert_eq!(DEFAULT_MTLS_SERVER_PORT, "8089");
assert_eq!(DEFAULT_ENROLL_PATH, "/Marti/api/tls/signClient/v2");
}
#[test]
fn builds_enrollment_config_url_from_trimmed_values() {
assert_eq!(
enrollment_config_url(" tak.example.test ", " 8446 "),
"https://tak.example.test:8446/Marti/api/tls/config"
);
}
#[test]
fn builds_enrollment_sign_url_and_normalizes_path() {
assert_eq!(
enrollment_sign_url(
" tak.example.test ",
" 8446 ",
" Marti/api/tls/signClient/v2 ",
" client-1 "
),
"https://tak.example.test:8446/Marti/api/tls/signClient/v2?clientUid=client-1"
);
assert_eq!(
enrollment_sign_url(
"tak.example.test",
"8446",
"/custom/enroll",
"client-2"
),
"https://tak.example.test:8446/custom/enroll?clientUid=client-2"
);
}
#[test]
fn parses_explicit_tak_enrollment_config() {
let xml = "<certificateConfig><serverPort>9443</serverPort><enrollPath>Marti/api/tls/signClient/v2</enrollPath></certificateConfig>";
assert_eq!(
parse_enrollment_config(xml),
EnrollmentConfig {
server_port: "9443".to_string(),
enroll_path: "/Marti/api/tls/signClient/v2".to_string(),
}
);
}
#[test]
fn falls_back_when_optional_config_values_are_missing() {
let xml = "<certificateConfig><nameEntries /></certificateConfig>";
assert_eq!(
parse_enrollment_config(xml),
EnrollmentConfig {
server_port: DEFAULT_MTLS_SERVER_PORT.to_string(),
enroll_path: DEFAULT_ENROLL_PATH.to_string(),
}
);
}
#[test]
fn returns_none_when_config_closing_tag_is_missing() {
assert_eq!(extract_tag_value("<serverPort>9443", "serverPort"), None);
}
#[test]
fn trims_extracted_config_values() {
let xml = "<certificateConfig><serverPort> 8089 </serverPort><enrollPath> /Marti/api/tls/signClient/v2 </enrollPath></certificateConfig>";
assert_eq!(
parse_enrollment_config(xml),
EnrollmentConfig {
server_port: "8089".to_string(),
enroll_path: DEFAULT_ENROLL_PATH.to_string(),
}
);
}
#[test]
fn normalizes_raw_certificate_body_to_pem() {
let raw = format!("{}\r\n{}", "A".repeat(64), "B".repeat(8));
let pem = normalize_certificate_pem(&raw);
assert_eq!(
pem,
format!(
"-----BEGIN CERTIFICATE-----\n{}\n{}\n-----END CERTIFICATE-----\n",
"A".repeat(64),
"B".repeat(8)
)
);
}
#[test]
fn preserves_existing_pem_and_adds_a_single_trailing_newline() {
let with_newline =
"-----BEGIN CERTIFICATE-----\nQUJD\n-----END CERTIFICATE-----\n";
let without_newline = "-----BEGIN CERTIFICATE-----\nQUJD\n-----END CERTIFICATE-----";
assert_eq!(normalize_certificate_pem(with_newline), with_newline);
assert_eq!(
normalize_certificate_pem(without_newline),
format!("{}\n", without_newline)
);
}
}
+6 -14
View File
@@ -1,25 +1,17 @@
#[cfg(test)]
mod tests {
use crate::init;
use std::vec;
use uuid::Uuid;
#[test]
fn uuid_output_is_uuid4_identifier() {
fn uuid_command_accepts_no_arguments_and_rejects_arguments() {
let extension = init().testing();
let (output, _) = extension.call("uuid", None);
assert!(Uuid::parse_str(&output).is_ok());
let validation = Uuid::parse_str(&output);
assert!(validation.is_ok())
}
#[test]
fn uuid_output_throws_if_passed_args() {
let extension = init().testing();
let args: Vec<String> = vec![1.to_string(), 2.to_string()];
let (output, _) = extension.call("uuid", Some(args));
assert_eq!(output, "")
let args = vec!["1".to_string(), "2".to_string()];
let (output_with_args, _) = extension.call("uuid", Some(args));
assert_eq!(output_with_args, "");
}
}
+67
View File
@@ -100,3 +100,70 @@ pub(crate) fn build_v2_packet(
packet.push((crc >> 8) as u8);
packet
}
#[cfg(test)]
mod tests {
use super::{build_v1_packet, build_v2_packet, calculate_crc_extra, mavlink_crc, FieldSpec};
fn assert_packet_crc(packet: &[u8], crc_extra: u8) {
let checksum_offset = packet.len() - 2;
let expected = mavlink_crc(&packet[1..checksum_offset], crc_extra);
let actual = u16::from_le_bytes([packet[checksum_offset], packet[checksum_offset + 1]]);
assert_eq!(actual, expected);
}
#[test]
fn crc_extra_is_deterministic_for_scalar_and_array_fields() {
let fields = [
FieldSpec {
ty: "uint32_t",
name: "time_boot_ms",
array_len: 0,
},
FieldSpec {
ty: "char",
name: "name",
array_len: 16,
},
];
let first = calculate_crc_extra("ARMATAK_TEST", &fields);
let second = calculate_crc_extra("ARMATAK_TEST", &fields);
let no_fields = calculate_crc_extra("ARMATAK_TEST", &[]);
assert_eq!(first, second);
assert_ne!(first, no_fields);
}
#[test]
fn builds_valid_mavlink_v1_packet() {
let payload = [0x10, 0x20, 0x30];
let packet = build_v1_packet(7, 9, 42, &payload, 77);
assert_eq!(packet[0], 0xFE);
assert_eq!(packet[1], payload.len() as u8);
assert_eq!(packet[3], 7);
assert_eq!(packet[4], 9);
assert_eq!(packet[5], 42);
assert_eq!(&packet[6..9], &payload);
assert_eq!(packet.len(), payload.len() + 8);
assert_packet_crc(&packet, 77);
}
#[test]
fn builds_valid_mavlink_v2_packet_with_three_byte_message_id() {
let payload = [0xAA, 0xBB];
let packet = build_v2_packet(11, 13, 0x01_02_03, &payload, 99);
assert_eq!(packet[0], 0xFD);
assert_eq!(packet[1], payload.len() as u8);
assert_eq!(packet[2], 0);
assert_eq!(packet[3], 0);
assert_eq!(packet[5], 11);
assert_eq!(packet[6], 13);
assert_eq!(&packet[7..10], &[0x03, 0x02, 0x01]);
assert_eq!(&packet[10..12], &payload);
assert_eq!(packet.len(), payload.len() + 12);
assert_packet_crc(&packet, 99);
}
}
+96
View File
@@ -93,3 +93,99 @@ pub(crate) fn should_send_video_stream_information(video_uri: &str) -> bool {
|| trimmed.starts_with("mpegts://")
|| trimmed.starts_with("tcp://")
}
#[cfg(test)]
mod tests {
use super::*;
const UUID: &str = "00112233-4455-6677-8899-aabbccddeeff";
#[test]
fn maps_arma_vehicle_types_to_mavlink_types() {
assert_eq!(map_vehicle_type(1), MAV_TYPE_FIXED_WING);
assert_eq!(map_vehicle_type(2), MAV_TYPE_QUADROTOR);
assert_eq!(map_vehicle_type(3), MAV_TYPE_HELICOPTER);
assert_eq!(map_vehicle_type(99), 99);
}
#[test]
fn normalizes_headings_to_zero_through_360() {
assert_eq!(normalize_heading_deg(0.0), 0.0);
assert_eq!(normalize_heading_deg(370.0), 10.0);
assert_eq!(normalize_heading_deg(-10.0), 350.0);
assert_eq!(normalize_heading_deg(720.0), 0.0);
}
#[test]
fn stable_system_id_is_deterministic_and_in_mavlink_range() {
let first = stable_system_id(UUID);
let second = stable_system_id(UUID);
assert_eq!(first, second);
assert!((1..=250).contains(&first));
assert!((1..=250).contains(&stable_system_id("")));
}
#[test]
fn stable_identity_trims_runtime_status_suffixes_and_falls_back_to_uuid() {
assert_eq!(stable_mavlink_identity(" Falcon ", UUID), "Falcon");
assert_eq!(stable_mavlink_identity("Falcon [ON]", UUID), "Falcon");
assert_eq!(stable_mavlink_identity("Falcon [OFF]", UUID), "Falcon");
assert_eq!(stable_mavlink_identity(" ", UUID), UUID);
}
#[test]
fn converts_uuid_to_fixed_binary_identifiers() {
assert_eq!(
uid64_from_uuid(UUID),
u64::from_le_bytes([0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77])
);
let uid2 = uid2_from_uuid(UUID);
assert_eq!(
&uid2[..16],
&[
0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xaa, 0xbb,
0xcc, 0xdd, 0xee, 0xff,
]
);
let checksum = UUID
.as_bytes()
.iter()
.fold(0u16, |acc, value| acc.wrapping_add(*value as u16));
assert_eq!(&uid2[16..], &checksum.to_le_bytes());
}
#[test]
fn malformed_or_short_uuid_components_are_zero_filled() {
let invalid = uuid16("gg");
assert_eq!(invalid, [0; 16]);
let short = uuid16("01");
assert_eq!(short[0], 1);
assert!(short[1..].iter().all(|byte| *byte == 0));
}
#[test]
fn fixed_string_reserves_a_nul_terminator_and_handles_zero_length() {
assert_eq!(fixed_string::<5>("abcdef"), [b'a', b'b', b'c', b'd', 0]);
assert_eq!(fixed_string::<5>("ab"), [b'a', b'b', 0, 0, 0]);
assert_eq!(fixed_string::<0>("ab"), [0u8; 0]);
}
#[test]
fn identifies_supported_video_stream_schemes_case_insensitively() {
for uri in [
" RTSP://host/live ",
"rtp://host/live",
"udp://239.1.1.1:1234",
"mpegts://host/live",
"tcp://host:1234",
] {
assert!(should_send_video_stream_information(uri), "{uri}");
}
assert!(!should_send_video_stream_information("https://host/live"));
assert!(!should_send_video_stream_information(""));
}
}
+66
View File
@@ -188,3 +188,69 @@ impl FromArma for UasSystemPayload {
})
}
}
#[cfg(test)]
mod tests {
use super::{UasSystemPayload, UasTelemetryPayload};
use arma_rs::FromArma;
#[test]
fn parses_and_clamps_uas_telemetry_payload() {
let payload = UasTelemetryPayload::from_arma(
r#"["127.0.0.1:14550",0,999,-1,-30.1,-51.2,100.5,25.25,361.0,12.5,1.0,2.0,3.0,1]"#
.to_string(),
)
.expect("telemetry payload should parse");
assert_eq!(payload.address, "127.0.0.1:14550");
assert_eq!(payload.system_id, 1);
assert_eq!(payload.component_id, 255);
assert_eq!(payload.vehicle_type, 0);
assert_eq!(payload.lat_deg, -30.1);
assert_eq!(payload.lon_deg, -51.2);
assert_eq!(payload.alt_msl_m, 100.5);
assert_eq!(payload.rel_alt_m, 25.25);
assert_eq!(payload.heading_deg, 361.0);
assert_eq!(payload.groundspeed_mps, 12.5);
assert_eq!(payload.roll_deg, 1.0);
assert_eq!(payload.pitch_deg, 2.0);
assert_eq!(payload.yaw_deg, 3.0);
assert!(payload.flying);
}
#[test]
fn parses_and_clamps_full_uas_system_payload() {
let payload = UasSystemPayload::from_arma(
r#"["10.0.0.1:14550","00112233-4455-6677-8899-aabbccddeeff","Falcon",300,-30.1,-51.2,100.5,25.25,90.0,12.5,1.0,2.0,3.0,0,1,4.0,5.0,6.0,"rtsp://video.example.test:8554/live",60.0,40.0,-30.2,-51.3,50.0,1,200]"#
.to_string(),
)
.expect("system payload should parse");
assert_eq!(payload.address, "10.0.0.1:14550");
assert_eq!(payload.entity_uuid, "00112233-4455-6677-8899-aabbccddeeff");
assert_eq!(payload.callsign, "Falcon");
assert_eq!(payload.vehicle_type, 255);
assert_eq!(payload.lat_deg, -30.1);
assert_eq!(payload.lon_deg, -51.2);
assert_eq!(payload.alt_msl_m, 100.5);
assert_eq!(payload.rel_alt_m, 25.25);
assert_eq!(payload.heading_deg, 90.0);
assert_eq!(payload.groundspeed_mps, 12.5);
assert_eq!(payload.roll_deg, 1.0);
assert_eq!(payload.pitch_deg, 2.0);
assert_eq!(payload.yaw_deg, 3.0);
assert!(!payload.flying);
assert!(payload.landed);
assert_eq!(payload.gimbal_roll_deg, 4.0);
assert_eq!(payload.gimbal_pitch_deg, 5.0);
assert_eq!(payload.gimbal_yaw_deg, 6.0);
assert_eq!(payload.video_uri, "rtsp://video.example.test:8554/live");
assert_eq!(payload.hfov_deg, 60.0);
assert_eq!(payload.vfov_deg, 40.0);
assert_eq!(payload.image_lat_deg, -30.2);
assert_eq!(payload.image_lon_deg, -51.3);
assert_eq!(payload.image_alt_msl_m, 50.0);
assert!(payload.has_turret_camera);
assert_eq!(payload.battery_remaining_pct, 100);
}
}