Rewrite polling_offset, changelog_parser, and user_agent_parser in Gleam (#103)

Migrate three pure-function modules from Elixir to Gleam:

- polling_offset: deterministic hash-based polling offset (pure Gleam, no FFI)
- changelog_parser: text parsing logic in Gleam, thin Elixir wrapper for File I/O
- user_agent_parser: regex-based UA parsing in Gleam with Erlang FFI for
  atom-keyed map conversion

Add gleam_regexp dependency for regex support in Gleam modules.
Update all call sites and tests to use Gleam module atoms.

Reviewed-on: graham/towerops-web#103
This commit is contained in:
Graham McIntire 2026-03-21 10:11:06 -05:00 committed by graham
parent 6859525a1b
commit 348975dcc9
55 changed files with 2849 additions and 1958 deletions

View file

@ -4,6 +4,7 @@ target = "erlang"
[dependencies]
gleam_stdlib = ">= 0.34.0 and < 2.0.0"
gleam_regexp = ">= 1.0.0 and < 2.0.0"
[dev-dependencies]
gleeunit = ">= 1.0.0 and < 2.0.0"

View file

@ -2,44 +2,14 @@ defmodule SnmpKit.SnmpLib.Error do
@moduledoc """
Standard SNMP error handling and error code utilities.
Provides standardized error codes, error handling utilities, and error response
generation for SNMP operations. This module centralizes all SNMP-specific error
handling to ensure consistent error reporting across the library.
## SNMP Error Codes
Standard SNMP error status values as defined in RFC 1157 and RFC 3416:
- `no_error` (0) - No error occurred
- `too_big` (1) - Response message would be too large
- `no_such_name` (2) - Requested OID does not exist
- `bad_value` (3) - Invalid value for SET operation
- `read_only` (4) - Attempted to set read-only variable
- `gen_err` (5) - General error
## Usage Examples
### Basic Error Handling
# Check if an error is retriable
if SnmpKit.SnmpLib.Error.retriable_error?(error_code) do
retry_operation()
end
# Format error for logging
error_msg = SnmpKit.SnmpLib.Error.format_error(3, 2, varbinds)
Logger.error(error_msg)
### Error Response Generation
# Create error response for invalid request
{:ok, error_response} = SnmpKit.SnmpLib.Error.create_error_response(
request_pdu,
:no_such_name,
error_index
)
Core logic is implemented in Gleam (`:snmpkit@snmp_lib@error`).
This wrapper provides the idiomatic Elixir API that accepts atoms,
integers, and strings as error status identifiers.
"""
@gleam :snmpkit@snmp_lib@error
@ffi :snmpkit_error_ffi
@type error_status ::
:no_error
| :too_big
@ -53,338 +23,71 @@ defmodule SnmpKit.SnmpLib.Error do
@type varbind :: {list(), any()}
@type varbinds :: [varbind()]
# Standard SNMP error status codes (RFC 1157, RFC 3416)
@no_error 0
@too_big 1
@no_such_name 2
@bad_value 3
@read_only 4
@gen_err 5
## Standard Error Code Constants
# Additional SNMPv2c error codes
@no_access 6
@wrong_type 7
@wrong_length 8
@wrong_encoding 9
@wrong_value 10
@no_creation 11
@inconsistent_value 12
@resource_unavailable 13
@commit_failed 14
@undo_failed 15
@authorization_error 16
@not_writable 17
@inconsistent_name 18
# Errors that are typically transient and worth retrying
@retriable_errors MapSet.new([:too_big, :gen_err, :resource_unavailable])
## Standard Error Codes
@doc """
Returns the numeric code for 'no error' status.
## Examples
iex> SnmpKit.SnmpLib.Error.no_error()
0
"""
@spec no_error() :: 0
def no_error, do: @no_error
def no_error, do: 0
@doc """
Returns the numeric code for 'too big' error status.
The response message would be too large to fit in a single SNMP message.
## Examples
iex> SnmpKit.SnmpLib.Error.too_big()
1
"""
@spec too_big() :: 1
def too_big, do: @too_big
def too_big, do: 1
@doc """
Returns the numeric code for 'no such name' error status.
The requested OID does not exist on the agent.
## Examples
iex> SnmpKit.SnmpLib.Error.no_such_name()
2
"""
@spec no_such_name() :: 2
def no_such_name, do: @no_such_name
def no_such_name, do: 2
@doc """
Returns the numeric code for 'bad value' error status.
The value provided in a SET operation is invalid for the variable.
## Examples
iex> SnmpKit.SnmpLib.Error.bad_value()
3
"""
@spec bad_value() :: 3
def bad_value, do: @bad_value
def bad_value, do: 3
@doc """
Returns the numeric code for 'read only' error status.
Attempted to set a read-only variable.
## Examples
iex> SnmpKit.SnmpLib.Error.read_only()
4
"""
@spec read_only() :: 4
def read_only, do: @read_only
def read_only, do: 4
@doc """
Returns the numeric code for 'general error' status.
A general error occurred that doesn't fit other categories.
## Examples
iex> SnmpKit.SnmpLib.Error.gen_err()
5
"""
@spec gen_err() :: 5
def gen_err, do: @gen_err
def gen_err, do: 5
## Error Utilities
@doc """
Returns the human-readable name for an error status code.
## Parameters
- `code`: Numeric error status code or atom
## Returns
- String name of the error status
- "unknown_error" for unrecognized codes
## Examples
iex> SnmpKit.SnmpLib.Error.error_name(0)
"no_error"
iex> SnmpKit.SnmpLib.Error.error_name(:too_big)
"too_big"
iex> SnmpKit.SnmpLib.Error.error_name(999)
"unknown_error"
"""
@spec error_name(error_status()) :: String.t()
def error_name(0), do: "no_error"
def error_name(:no_error), do: "no_error"
def error_name(code) when is_integer(code) do
code |> @gleam.from_code() |> @gleam.error_name()
end
def error_name(1), do: "too_big"
def error_name(:too_big), do: "too_big"
def error_name(atom) when is_atom(atom) do
atom |> @ffi.atom_to_error_status() |> @gleam.error_name()
end
def error_name(2), do: "no_such_name"
def error_name(:no_such_name), do: "no_such_name"
def error_name(3), do: "bad_value"
def error_name(:bad_value), do: "bad_value"
def error_name(4), do: "read_only"
def error_name(:read_only), do: "read_only"
def error_name(5), do: "gen_err"
def error_name(:gen_err), do: "gen_err"
# SNMPv2c additional error codes
def error_name(6), do: "no_access"
def error_name(:no_access), do: "no_access"
def error_name(7), do: "wrong_type"
def error_name(:wrong_type), do: "wrong_type"
def error_name(8), do: "wrong_length"
def error_name(:wrong_length), do: "wrong_length"
def error_name(9), do: "wrong_encoding"
def error_name(:wrong_encoding), do: "wrong_encoding"
def error_name(10), do: "wrong_value"
def error_name(:wrong_value), do: "wrong_value"
def error_name(11), do: "no_creation"
def error_name(:no_creation), do: "no_creation"
def error_name(12), do: "inconsistent_value"
def error_name(:inconsistent_value), do: "inconsistent_value"
def error_name(13), do: "resource_unavailable"
def error_name(:resource_unavailable), do: "resource_unavailable"
def error_name(14), do: "commit_failed"
def error_name(:commit_failed), do: "commit_failed"
def error_name(15), do: "undo_failed"
def error_name(:undo_failed), do: "undo_failed"
def error_name(16), do: "authorization_error"
def error_name(:authorization_error), do: "authorization_error"
def error_name(17), do: "not_writable"
def error_name(:not_writable), do: "not_writable"
def error_name(18), do: "inconsistent_name"
def error_name(:inconsistent_name), do: "inconsistent_name"
def error_name(_), do: "unknown_error"
@doc """
Converts error status code to atom representation.
## Examples
iex> SnmpKit.SnmpLib.Error.error_atom(2)
:no_such_name
iex> SnmpKit.SnmpLib.Error.error_atom(999)
:unknown_error
"""
@spec error_atom(error_status()) :: atom()
def error_atom(code) when is_integer(code) do
code |> error_name() |> String.to_atom()
code |> @gleam.from_code() |> @ffi.error_status_to_atom()
end
def error_atom(atom) when is_atom(atom), do: atom
@doc """
Converts error atom or name to numeric code.
## Examples
iex> SnmpKit.SnmpLib.Error.error_code(:no_such_name)
2
iex> SnmpKit.SnmpLib.Error.error_code("bad_value")
3
"""
@spec error_code(atom() | String.t()) :: non_neg_integer()
def error_code(:no_error), do: @no_error
def error_code(:too_big), do: @too_big
def error_code(:no_such_name), do: @no_such_name
def error_code(:bad_value), do: @bad_value
def error_code(:read_only), do: @read_only
def error_code(:gen_err), do: @gen_err
def error_code(:no_access), do: @no_access
def error_code(:wrong_type), do: @wrong_type
def error_code(:wrong_length), do: @wrong_length
def error_code(:wrong_encoding), do: @wrong_encoding
def error_code(:wrong_value), do: @wrong_value
def error_code(:no_creation), do: @no_creation
def error_code(:inconsistent_value), do: @inconsistent_value
def error_code(:resource_unavailable), do: @resource_unavailable
def error_code(:commit_failed), do: @commit_failed
def error_code(:undo_failed), do: @undo_failed
def error_code(:authorization_error), do: @authorization_error
def error_code(:not_writable), do: @not_writable
def error_code(:inconsistent_name), do: @inconsistent_name
def error_code(atom) when is_atom(atom) do
status = @ffi.atom_to_error_status(atom)
code = @gleam.error_code(status)
# UnknownError returns -1 from Gleam; map to gen_err (5) for compatibility
if code == -1, do: 5, else: code
end
def error_code(name) when is_binary(name) do
name |> String.to_atom() |> error_code()
status = @gleam.from_name(name)
code = @gleam.error_code(status)
if code == -1, do: 5, else: code
end
def error_code(_), do: @gen_err
def error_code(_), do: 5
@doc """
Formats an SNMP error for human-readable display.
## Parameters
- `error_status`: Error status code (integer or atom)
- `error_index`: Index of the varbind that caused the error (1-based)
- `varbinds`: List of varbinds from the request (optional)
## Returns
Formatted error string suitable for logging or display.
## Examples
iex> SnmpKit.SnmpLib.Error.format_error(2, 1, [])
"SNMP Error: no_such_name (2) at index 1"
iex> varbinds = [{[1,3,6,1,2,1,1,1,0], "test"}]
iex> SnmpKit.SnmpLib.Error.format_error(:bad_value, 1, varbinds)
"SNMP Error: bad_value (3) at index 1 - OID: 1.3.6.1.2.1.1.1.0"
"""
@spec format_error(error_status(), error_index(), varbinds()) :: String.t()
def format_error(error_status, error_index, varbinds \\ []) do
error_name_str = error_name(error_status)
error_code_num = if is_integer(error_status), do: error_status, else: error_code(error_status)
base_msg = "SNMP Error: #{error_name_str} (#{error_code_num}) at index #{error_index}"
case get_error_varbind(varbinds, error_index) do
nil ->
base_msg
{oid, _value} ->
oid_str = Enum.join(oid, ".")
"#{base_msg} - OID: #{oid_str}"
end
status = to_gleam_status(error_status)
@gleam.format_error(status, error_index, varbinds)
end
@doc """
Determines if an error status indicates a retriable condition.
Some SNMP errors are temporary and operations can be retried, while others
indicate permanent failures.
## Retriable Errors
- `too_big` - Can retry with smaller request
- `gen_err` - General error, may be temporary
- `resource_unavailable` - Temporary resource constraint
## Non-Retriable Errors
- `no_such_name` - OID doesn't exist
- `bad_value` - Invalid value provided
- `read_only` - Attempted to write read-only variable
- `no_access` - Access denied
- Most SNMPv2c specific errors
## Examples
iex> SnmpKit.SnmpLib.Error.retriable_error?(:too_big)
true
iex> SnmpKit.SnmpLib.Error.retriable_error?(:no_such_name)
false
"""
@spec retriable_error?(error_status()) :: boolean()
def retriable_error?(error_status) do
error_status
|> error_atom()
|> then(&MapSet.member?(@retriable_errors, &1))
error_status |> to_gleam_status() |> @gleam.is_retriable()
end
@doc """
Creates an SNMP error response PDU.
Generates a properly formatted error response based on the original request
and the error condition that occurred.
## Parameters
- `request_pdu`: Original request PDU
- `error_status`: Error status code or atom
- `error_index`: Index of the varbind that caused the error (1-based)
## Returns
- `{:ok, error_pdu}`: Successfully created error response
- `{:error, reason}`: Failed to create error response
## Examples
request = %{type: :get_request, request_id: 123, varbinds: [...]}
{:ok, error_response} = SnmpKit.SnmpLib.Error.create_error_response(
request,
:no_such_name,
1
)
"""
@spec create_error_response(map(), error_status(), error_index()) ::
{:ok, map()} | {:error, atom()}
def create_error_response(request_pdu, error_status, error_index) do
@ -408,23 +111,9 @@ defmodule SnmpKit.SnmpLib.Error do
end
end
@doc """
Validates an error status code.
## Examples
iex> SnmpKit.SnmpLib.Error.valid_error_status?(2)
true
iex> SnmpKit.SnmpLib.Error.valid_error_status?(:no_such_name)
true
iex> SnmpKit.SnmpLib.Error.valid_error_status?(999)
false
"""
@spec valid_error_status?(any()) :: boolean()
def valid_error_status?(status) when is_integer(status) do
status >= 0 and status <= 18
@gleam.is_valid_code(status)
end
def valid_error_status?(status) when is_atom(status) do
@ -433,54 +122,10 @@ defmodule SnmpKit.SnmpLib.Error do
def valid_error_status?(_), do: false
@doc """
Returns a list of all standard SNMP error codes.
## Examples
iex> codes = SnmpKit.SnmpLib.Error.all_error_codes()
iex> 0 in codes
true
iex> 5 in codes
true
"""
@spec all_error_codes() :: [non_neg_integer()]
def all_error_codes do
Enum.to_list(0..18)
end
def all_error_codes, do: @gleam.all_codes()
@doc """
Returns a list of all standard SNMP error atoms.
## Examples
iex> atoms = SnmpKit.SnmpLib.Error.all_error_atoms()
iex> :no_error in atoms
true
iex> :gen_err in atoms
true
"""
@spec all_error_atoms() :: [
:no_error
| :too_big
| :no_such_name
| :bad_value
| :read_only
| :gen_err
| :no_access
| :wrong_type
| :wrong_length
| :wrong_encoding
| :wrong_value
| :no_creation
| :inconsistent_value
| :resource_unavailable
| :commit_failed
| :undo_failed
| :authorization_error
| :not_writable
| :inconsistent_name
]
@spec all_error_atoms() :: [atom()]
def all_error_atoms do
[
:no_error,
@ -505,49 +150,16 @@ defmodule SnmpKit.SnmpLib.Error do
]
end
@doc """
Categorizes error by severity level.
## Returns
- `:info` - No error
- `:warning` - Retriable errors
- `:error` - Non-retriable errors
## Examples
iex> SnmpKit.SnmpLib.Error.error_severity(:no_error)
:info
iex> SnmpKit.SnmpLib.Error.error_severity(:too_big)
:warning
iex> SnmpKit.SnmpLib.Error.error_severity(:no_such_name)
:error
"""
@spec error_severity(error_status()) :: :info | :warning | :error
def error_severity(error_status) do
case error_atom(error_status) do
:no_error -> :info
status when status in [:too_big, :gen_err, :resource_unavailable] -> :warning
_ -> :error
end
error_status |> to_gleam_status() |> @gleam.severity() |> String.to_atom()
end
## Private Helper Functions
## Private Helpers
defp validate_request_pdu(request_pdu) when is_map(request_pdu) do
:ok
end
defp to_gleam_status(code) when is_integer(code), do: @gleam.from_code(code)
defp to_gleam_status(atom) when is_atom(atom), do: @ffi.atom_to_error_status(atom)
defp validate_request_pdu(request_pdu) when is_map(request_pdu), do: :ok
defp validate_request_pdu(_), do: {:error, :invalid_request_pdu}
defp get_error_varbind(varbinds, error_index) when is_list(varbinds) do
# SNMP error_index is 1-based
if error_index > 0 and error_index <= length(varbinds) do
Enum.at(varbinds, error_index - 1)
end
end
defp get_error_varbind(_, _), do: nil
end

View file

@ -2,8 +2,8 @@ defmodule SnmpKit.SnmpLib.OID do
@moduledoc """
Comprehensive OID (Object Identifier) manipulation utilities for SNMP operations.
Provides string/list conversions, tree operations, table utilities, and validation
functions needed by both SNMP managers and simulators.
Core logic implemented in Gleam (`src/snmpkit/snmp_lib/oid.gleam`).
This module provides the Elixir API with type guards and polymorphic dispatch.
## Features
@ -13,84 +13,41 @@ defmodule SnmpKit.SnmpLib.OID do
- SNMP table index parsing and construction
- OID comparison and sorting
- Enterprise OID utilities
- Performance-optimized operations
## OID Format Support
SnmpKit supports both common OID string formats:
- **Traditional format**: "1.3.6.1.2.1.1.1.0" (Elixir/Erlang style)
- **Standard format**: ".1.3.6.1.2.1.1.1.0" (RFC standard with leading dot)
Both formats parse to identical internal representations and can be used
interchangeably throughout the library.
## Examples
# Basic conversions - both formats supported
{:ok, oid_list} = SnmpKit.SnmpLib.OID.string_to_list("1.3.6.1.2.1.1.1.0")
{:ok, same_list} = SnmpKit.SnmpLib.OID.string_to_list(".1.3.6.1.2.1.1.1.0")
{:ok, oid_string} = SnmpKit.SnmpLib.OID.list_to_string([1, 3, 6, 1, 2, 1, 1, 1, 0])
# Tree operations
true = SnmpKit.SnmpLib.OID.child_of?([1, 3, 6, 1, 2, 1, 1, 1, 0], [1, 3, 6, 1, 2, 1])
{:ok, parent} = SnmpKit.SnmpLib.OID.get_parent([1, 3, 6, 1, 2, 1, 1, 1, 0])
# Comparison
:lt = SnmpKit.SnmpLib.OID.compare([1, 3, 6, 1], [1, 3, 6, 2])
# Table operations
{:ok, index} = SnmpKit.SnmpLib.OID.extract_table_index([1, 3, 6, 1, 2, 1, 2, 2, 1, 1], [1, 3, 6, 1, 2, 1, 2, 2, 1, 1, 1])
"""
@gleam :snmpkit@snmp_lib@oid
@type oid :: [non_neg_integer()]
@type oid_string :: String.t()
@type table_oid :: oid()
@type index :: [non_neg_integer()]
# Standard SNMP OID prefixes
@iso_org_dod_internet [1, 3, 6, 1]
@mgmt [1, 3, 6, 1, 2]
@mib_2 [1, 3, 6, 1, 2, 1]
@enterprises [1, 3, 6, 1, 4, 1]
@experimental [1, 3, 6, 1, 3]
@private [1, 3, 6, 1, 4]
## String/List Conversions
@doc """
Converts an OID string to a list of integers.
Parses a dot-separated OID string into a list of non-negative integers.
Validates each component and ensures the OID format is correct.
## Parameters
- `oid_string`: Dot-separated OID string (e.g., "1.3.6.1.2.1.1.1.0" or ".1.3.6.1.2.1.1.1.0")
## Returns
- `{:ok, oid_list}` on success
- `{:error, reason}` on failure
## Examples
# Standard SNMP OIDs
iex> SnmpKit.SnmpLib.OID.string_to_list("1.3.6.1.2.1.1.1.0")
{:ok, [1, 3, 6, 1, 2, 1, 1, 1, 0]}
# OIDs with leading dot (standard format)
iex> SnmpKit.SnmpLib.OID.string_to_list(".1.3.6.1.2.1.1.1.0")
{:ok, [1, 3, 6, 1, 2, 1, 1, 1, 0]}
# Short OIDs
iex> SnmpKit.SnmpLib.OID.string_to_list("1.3.6")
{:ok, [1, 3, 6]}
# Short OIDs with leading dot
iex> SnmpKit.SnmpLib.OID.string_to_list(".1.3.6")
{:ok, [1, 3, 6]}
# Error cases
iex> SnmpKit.SnmpLib.OID.string_to_list("")
{:error, :empty_oid}
@ -102,60 +59,22 @@ defmodule SnmpKit.SnmpLib.OID do
"""
@spec string_to_list(oid_string()) :: {:ok, oid()} | {:error, atom()}
def string_to_list(oid_string) when is_binary(oid_string) do
with {:ok, normalized} <- normalize_oid_string(oid_string),
parts = String.split(normalized, "."),
{:ok, oid_list} <- parse_oid_components(parts),
:ok <- validate_oid_list(oid_list) do
{:ok, oid_list}
end
@gleam.string_to_list(oid_string)
end
def string_to_list(_), do: {:error, :invalid_input}
defp normalize_oid_string(oid_string) do
trimmed = String.trim(oid_string)
cond do
trimmed == "" ->
{:error, :empty_oid}
String.starts_with?(trimmed, ".") ->
normalized = String.slice(trimmed, 1..-1//1)
if normalized == "", do: {:error, :empty_oid}, else: {:ok, normalized}
true ->
{:ok, trimmed}
end
end
@doc """
Converts an OID list to a dot-separated string.
## Parameters
- `oid_list`: List of non-negative integers
## Returns
- `{:ok, oid_string}` on success
- `{:error, reason}` on failure
## Examples
{:ok, "1.3.6.1.2.1.1.1.0"} = SnmpKit.SnmpLib.OID.list_to_string([1, 3, 6, 1, 2, 1, 1, 1, 0])
{:error, :invalid_oid_list} = SnmpKit.SnmpLib.OID.list_to_string([1, 3, -1, 4])
{:error, :empty_oid} = SnmpKit.SnmpLib.OID.list_to_string([])
iex> SnmpKit.SnmpLib.OID.list_to_string([1, 3, 6, 1, 2, 1, 1, 1, 0])
{:ok, "1.3.6.1.2.1.1.1.0"}
"""
@spec list_to_string(oid()) :: {:ok, oid_string()} | {:error, atom()}
def list_to_string(oid_list) when is_list(oid_list) do
case validate_oid_list(oid_list) do
:ok ->
oid_string = Enum.join(oid_list, ".")
{:ok, oid_string}
{:error, reason} ->
{:error, reason}
end
@gleam.list_to_string(oid_list)
end
def list_to_string(_), do: {:error, :invalid_input}
@ -165,29 +84,14 @@ defmodule SnmpKit.SnmpLib.OID do
@doc """
Checks if one OID is a child of another.
## Parameters
- `child_oid`: Potential child OID
- `parent_oid`: Potential parent OID
## Returns
- `true` if child_oid is a child of parent_oid
- `false` otherwise
## Examples
true = SnmpKit.SnmpLib.OID.child_of?([1, 3, 6, 1, 2, 1, 1, 1, 0], [1, 3, 6, 1, 2, 1])
false = SnmpKit.SnmpLib.OID.child_of?([1, 3, 6, 1], [1, 3, 6, 1, 2, 1])
false = SnmpKit.SnmpLib.OID.child_of?([1, 3, 6, 2], [1, 3, 6, 1])
iex> SnmpKit.SnmpLib.OID.child_of?([1, 3, 6, 1, 2, 1, 1, 1, 0], [1, 3, 6, 1, 2, 1])
true
"""
@spec child_of?(oid(), oid()) :: boolean()
def child_of?(child_oid, parent_oid) when is_list(child_oid) and is_list(parent_oid) do
child_length = length(child_oid)
parent_length = length(parent_oid)
child_length > parent_length and
Enum.take(child_oid, parent_length) == parent_oid
@gleam.child_of(child_oid, parent_oid)
end
def child_of?(_, _), do: false
@ -203,44 +107,25 @@ defmodule SnmpKit.SnmpLib.OID do
## Examples
{:ok, [1, 3, 6, 1, 2, 1, 1, 1]} = SnmpKit.SnmpLib.OID.get_parent([1, 3, 6, 1, 2, 1, 1, 1, 0])
{:error, :root_oid} = SnmpKit.SnmpLib.OID.get_parent([])
{:error, :root_oid} = SnmpKit.SnmpLib.OID.get_parent([1])
iex> SnmpKit.SnmpLib.OID.get_parent([1, 3, 6, 1, 2, 1, 1, 1, 0])
{:ok, [1, 3, 6, 1, 2, 1, 1, 1]}
iex> SnmpKit.SnmpLib.OID.get_parent([])
{:error, :root_oid}
iex> SnmpKit.SnmpLib.OID.get_parent([1])
{:error, :root_oid}
"""
@spec get_parent(oid()) :: {:ok, oid()} | {:error, atom()}
def get_parent(oid) when is_list(oid) and length(oid) > 1 do
parent = Enum.drop(oid, -1)
{:ok, parent}
end
def get_parent(oid) when is_list(oid), do: {:error, :root_oid}
def get_parent(oid) when is_list(oid), do: @gleam.get_parent(oid)
def get_parent(_), do: {:error, :invalid_input}
@doc """
Gets the immediate children prefix for an OID in a given set.
## Parameters
- `parent_oid`: The parent OID
- `oid_set`: Set of OIDs to search
## Returns
- List of immediate child OIDs
## Examples
children = SnmpKit.SnmpLib.OID.get_children([1, 3, 6, 1], [[1, 3, 6, 1, 2], [1, 3, 6, 1, 4], [1, 3, 6, 1, 2, 1]])
# Returns [[1, 3, 6, 1, 2], [1, 3, 6, 1, 4]]
"""
@spec get_children(oid(), [oid()]) :: [oid()]
def get_children(parent_oid, oid_set) when is_list(parent_oid) and is_list(oid_set) do
parent_length = length(parent_oid)
oid_set
|> Enum.filter(&child_of?(&1, parent_oid))
|> Enum.map(&Enum.take(&1, parent_length + 1))
|> Enum.uniq()
@gleam.get_children(parent_oid, oid_set)
end
def get_children(_, _), do: []
@ -257,38 +142,20 @@ defmodule SnmpKit.SnmpLib.OID do
[1, 3, 6, 1, 2]
"""
@spec standard_prefix(atom()) :: oid() | nil
def standard_prefix(:internet), do: @iso_org_dod_internet
def standard_prefix(:mgmt), do: @mgmt
def standard_prefix(:mib_2), do: @mib_2
def standard_prefix(:enterprises), do: @enterprises
def standard_prefix(:experimental), do: @experimental
def standard_prefix(:private), do: @private
def standard_prefix(:internet), do: [1, 3, 6, 1]
def standard_prefix(:mgmt), do: [1, 3, 6, 1, 2]
def standard_prefix(:mib_2), do: [1, 3, 6, 1, 2, 1]
def standard_prefix(:enterprises), do: [1, 3, 6, 1, 4, 1]
def standard_prefix(:experimental), do: [1, 3, 6, 1, 3]
def standard_prefix(:private), do: [1, 3, 6, 1, 4]
def standard_prefix(_), do: nil
@doc """
Gets the next OID in lexicographic order from a given set.
## Parameters
- `current_oid`: The current OID
- `oid_set`: Set of OIDs to search (must be sorted)
## Returns
- `{:ok, next_oid}` if found
- `{:error, :end_of_mib}` if no next OID exists
## Examples
oid_set = [[1, 3, 6, 1, 2, 1, 1, 1, 0], [1, 3, 6, 1, 2, 1, 1, 2, 0], [1, 3, 6, 1, 2, 1, 1, 3, 0]]
{:ok, [1, 3, 6, 1, 2, 1, 1, 2, 0]} = SnmpKit.SnmpLib.OID.get_next_oid([1, 3, 6, 1, 2, 1, 1, 1, 0], oid_set)
"""
@spec get_next_oid(oid(), [oid()]) :: {:ok, oid()} | {:error, atom()}
def get_next_oid(current_oid, oid_set) when is_list(current_oid) and is_list(oid_set) do
case Enum.find(oid_set, &(compare(&1, current_oid) == :gt)) do
nil -> {:error, :end_of_mib}
next_oid -> {:ok, next_oid}
end
@gleam.get_next_oid(current_oid, oid_set)
end
def get_next_oid(_, _), do: {:error, :invalid_input}
@ -298,36 +165,27 @@ defmodule SnmpKit.SnmpLib.OID do
@doc """
Compares two OIDs lexicographically.
## Returns
- `:lt` if oid1 < oid2
- `:eq` if oid1 == oid2
- `:gt` if oid1 > oid2
## Examples
:lt = SnmpKit.SnmpLib.OID.compare([1, 3, 6, 1], [1, 3, 6, 2])
:eq = SnmpKit.SnmpLib.OID.compare([1, 3, 6, 1], [1, 3, 6, 1])
:gt = SnmpKit.SnmpLib.OID.compare([1, 3, 6, 2], [1, 3, 6, 1])
iex> SnmpKit.SnmpLib.OID.compare([1, 3, 6, 1], [1, 3, 6, 2])
:lt
iex> SnmpKit.SnmpLib.OID.compare([1, 3, 6, 1], [1, 3, 6, 1])
:eq
iex> SnmpKit.SnmpLib.OID.compare([1, 3, 6, 2], [1, 3, 6, 1])
:gt
"""
@spec compare(oid(), oid()) :: :lt | :eq | :gt
def compare(oid1, oid2) when is_list(oid1) and is_list(oid2) do
compare_components(oid1, oid2)
@gleam.compare_oids(oid1, oid2)
end
@doc """
Sorts a list of OIDs in lexicographic order.
## Examples
sorted = SnmpKit.SnmpLib.OID.sort([[1, 3, 6, 2], [1, 3, 6, 1, 2], [1, 3, 6, 1]])
# Returns [[1, 3, 6, 1], [1, 3, 6, 1, 2], [1, 3, 6, 2]]
"""
@spec sort([oid()]) :: [oid()]
def sort(oid_list) when is_list(oid_list) do
Enum.sort(oid_list, &(compare(&1, &2) != :gt))
end
def sort(oid_list) when is_list(oid_list), do: @gleam.sort_oids(oid_list)
def sort(_), do: []
## Table Operations
@ -335,70 +193,24 @@ defmodule SnmpKit.SnmpLib.OID do
@doc """
Extracts table index from an instance OID given the table column OID.
## Parameters
- `table_oid`: Base table column OID
- `instance_oid`: Full instance OID including index
## Returns
- `{:ok, index}` if successful
- `{:error, reason}` if extraction fails
## Examples
table_oid = [1, 3, 6, 1, 2, 1, 2, 2, 1, 1]
instance_oid = [1, 3, 6, 1, 2, 1, 2, 2, 1, 1, 1]
{:ok, [1]} = SnmpKit.SnmpLib.OID.extract_table_index(table_oid, instance_oid)
iex> SnmpKit.SnmpLib.OID.extract_table_index([1, 3, 6, 1, 2, 1, 2, 2, 1, 1], [1, 3, 6, 1, 2, 1, 2, 2, 1, 1, 1])
{:ok, [1]}
"""
@spec extract_table_index(table_oid(), oid()) :: {:ok, index()} | {:error, atom()}
def extract_table_index(table_oid, instance_oid) when is_list(table_oid) and is_list(instance_oid) do
table_length = length(table_oid)
instance_length = length(instance_oid)
if instance_length > table_length and
Enum.take(instance_oid, table_length) == table_oid do
index = Enum.drop(instance_oid, table_length)
{:ok, index}
else
{:error, :invalid_table_instance}
end
@gleam.extract_table_index(table_oid, instance_oid)
end
def extract_table_index(_, _), do: {:error, :invalid_input}
@doc """
Builds a table instance OID from table OID and index.
## Parameters
- `table_oid`: Base table column OID
- `index`: Table index as list of integers
## Returns
- `{:ok, instance_oid}` if successful
- `{:error, reason}` if construction fails
## Examples
table_oid = [1, 3, 6, 1, 2, 1, 2, 2, 1, 1]
index = [1]
{:ok, [1, 3, 6, 1, 2, 1, 2, 2, 1, 1, 1]} = SnmpKit.SnmpLib.OID.build_table_instance(table_oid, index)
"""
@spec build_table_instance(table_oid(), index()) :: {:ok, oid()} | {:error, atom()}
def build_table_instance(table_oid, index) when is_list(table_oid) and is_list(index) do
case {validate_oid_list(table_oid), validate_oid_list(index)} do
{:ok, :ok} ->
instance_oid = table_oid ++ index
{:ok, instance_oid}
{{:error, reason}, _} ->
{:error, reason}
{_, {:error, reason}} ->
{:error, reason}
end
@gleam.build_table_instance(table_oid, index)
end
def build_table_instance(_, _), do: {:error, :invalid_input}
@ -406,48 +218,28 @@ defmodule SnmpKit.SnmpLib.OID do
@doc """
Parses a table index according to index syntax definition.
## Parameters
- `index`: Raw index from OID
- `syntax`: Index syntax specification
## Returns
- `{:ok, parsed_index}` if successful
- `{:error, reason}` if parsing fails
## Index Syntax Examples
- `:integer` - Single integer index
- `{:string, length}` - Fixed-length string
- `{:variable_string}` - Length-prefixed string
- `[:integer, :integer]` - Multiple integer indices
## Examples
{:ok, 42} = SnmpKit.SnmpLib.OID.parse_table_index([42], :integer)
{:ok, "test"} = SnmpKit.SnmpLib.OID.parse_table_index([4, 116, 101, 115, 116], {:variable_string})
iex> SnmpKit.SnmpLib.OID.parse_table_index([42], :integer)
{:ok, 42}
iex> SnmpKit.SnmpLib.OID.parse_table_index([4, 116, 101, 115, 116], {:variable_string})
{:ok, "test"}
"""
@spec parse_table_index(index(), term()) :: {:ok, term()} | {:error, atom()}
def parse_table_index(index, :integer) when is_list(index) and length(index) == 1 do
{:ok, hd(index)}
def parse_table_index(index, :integer) when is_list(index) do
@gleam.parse_integer_index(index)
end
def parse_table_index(index, {:string, length}) when is_list(index) and length(index) == length do
case build_string_from_bytes(index) do
{:ok, string} -> {:ok, string}
{:error, reason} -> {:error, reason}
end
def parse_table_index(index, {:string, length}) when is_list(index) do
@gleam.parse_fixed_string_index(index, length)
end
def parse_table_index([length | rest], {:variable_string}) when length(rest) == length do
case build_string_from_bytes(rest) do
{:ok, string} -> {:ok, string}
{:error, reason} -> {:error, reason}
end
def parse_table_index(index, {:variable_string}) when is_list(index) do
@gleam.parse_variable_string_index(index)
end
def parse_table_index(index, syntax_list) when is_list(syntax_list) do
def parse_table_index(index, syntax_list) when is_list(index) and is_list(syntax_list) do
case parse_index_components(index, syntax_list, []) do
{:ok, {parsed, _remaining}} -> {:ok, parsed}
{:error, reason} -> {:error, reason}
@ -461,38 +253,31 @@ defmodule SnmpKit.SnmpLib.OID do
## Examples
{:ok, [42]} = SnmpKit.SnmpLib.OID.build_table_index(42, :integer)
{:ok, [4, 116, 101, 115, 116]} = SnmpKit.SnmpLib.OID.build_table_index("test", {:variable_string})
iex> SnmpKit.SnmpLib.OID.build_table_index(42, :integer)
{:ok, [42]}
iex> SnmpKit.SnmpLib.OID.build_table_index("test", {:variable_string})
{:ok, [4, 116, 101, 115, 116]}
"""
@spec build_table_index(term(), term()) :: {:ok, index()} | {:error, atom()}
def build_table_index(values, syntax_list) when is_list(values) and is_list(syntax_list) do
if length(values) == length(syntax_list) do
case build_compound_index(values, syntax_list) do
{:ok, index} -> {:ok, index}
{:error, reason} -> {:error, reason}
end
build_compound_index(values, syntax_list)
else
{:error, :syntax_value_mismatch}
end
end
def build_table_index(value, :integer) when is_integer(value) and value >= 0 do
{:ok, [value]}
@gleam.build_integer_index(value)
end
def build_table_index(value, {:string, length}) when is_binary(value) do
if String.length(value) == length do
index = String.to_charlist(value)
{:ok, index}
else
{:error, :invalid_string_length}
end
@gleam.build_fixed_string_index(value, length)
end
def build_table_index(value, {:variable_string}) when is_binary(value) do
char_list = String.to_charlist(value)
index = [length(char_list) | char_list]
{:ok, index}
@gleam.build_variable_string_index(value)
end
def build_table_index(_, _), do: {:error, :unsupported_syntax}
@ -502,148 +287,79 @@ defmodule SnmpKit.SnmpLib.OID do
@doc """
Validates an OID list for correctness.
## Returns
- `:ok` if valid
- `{:error, reason}` if invalid
## Examples
:ok = SnmpKit.SnmpLib.OID.valid_oid?([1, 3, 6, 1, 2, 1, 1, 1, 0])
{:error, :empty_oid} = SnmpKit.SnmpLib.OID.valid_oid?([])
{:error, :invalid_component} = SnmpKit.SnmpLib.OID.valid_oid?([1, 3, -1, 4])
iex> SnmpKit.SnmpLib.OID.valid_oid?([1, 3, 6, 1, 2, 1, 1, 1, 0])
:ok
iex> SnmpKit.SnmpLib.OID.valid_oid?([])
{:error, :empty_oid}
"""
@spec valid_oid?(oid()) :: :ok | {:error, atom()}
def valid_oid?(oid) when is_list(oid), do: validate_oid_list(oid)
def valid_oid?(_), do: {:error, :invalid_input}
@doc """
Normalizes an OID to a consistent format.
Accepts either string or list format and returns a list.
## Examples
{:ok, [1, 3, 6, 1]} = SnmpKit.SnmpLib.OID.normalize("1.3.6.1")
{:ok, [1, 3, 6, 1]} = SnmpKit.SnmpLib.OID.normalize([1, 3, 6, 1])
"""
@spec normalize(oid() | oid_string()) :: {:ok, oid()} | {:error, atom()}
def normalize(oid) when is_list(oid) do
case valid_oid?(oid) do
:ok -> {:ok, oid}
def valid_oid?(oid) when is_list(oid) do
case @gleam.validate(oid) do
{:ok, _} -> :ok
error -> error
end
end
def normalize(oid) when is_binary(oid), do: string_to_list(oid)
def normalize(_), do: {:error, :invalid_input}
## Utility Functions
def valid_oid?(_), do: {:error, :invalid_input}
@doc """
Returns standard SNMP OID prefixes.
"""
@spec mib_2() :: oid()
def mib_2, do: @mib_2
@spec enterprises() :: oid()
def enterprises, do: @enterprises
@spec experimental() :: oid()
def experimental, do: @experimental
@spec private() :: oid()
def private, do: @private
@doc """
Checks if an OID is under a specific standard tree.
Normalizes an OID to a consistent format (list).
## Examples
true = SnmpKit.SnmpLib.OID.mib_2?([1, 3, 6, 1, 2, 1, 1, 1, 0])
true = SnmpKit.SnmpLib.OID.enterprise?([1, 3, 6, 1, 4, 1, 9, 1, 1])
iex> SnmpKit.SnmpLib.OID.normalize("1.3.6.1")
{:ok, [1, 3, 6, 1]}
iex> SnmpKit.SnmpLib.OID.normalize([1, 3, 6, 1])
{:ok, [1, 3, 6, 1]}
"""
@spec normalize(oid() | oid_string()) :: {:ok, oid()} | {:error, atom()}
def normalize(oid) when is_list(oid), do: @gleam.normalize_list(oid)
def normalize(oid) when is_binary(oid), do: @gleam.normalize_string(oid)
def normalize(_), do: {:error, :invalid_input}
## Standard OID Utilities
@spec mib_2() :: oid()
def mib_2, do: @gleam.mib_2()
@spec enterprises() :: oid()
def enterprises, do: @gleam.enterprises()
@spec experimental() :: oid()
def experimental, do: @gleam.experimental()
@spec private() :: oid()
def private, do: @gleam.private()
@spec mib_2?(oid()) :: boolean()
def mib_2?(oid), do: child_of?(oid, @mib_2) or oid == @mib_2
def mib_2?(oid), do: @gleam.is_mib_2(oid)
@spec enterprise?(oid()) :: boolean()
def enterprise?(oid), do: child_of?(oid, @enterprises)
def enterprise?(oid), do: @gleam.is_enterprise(oid)
@spec experimental?(oid()) :: boolean()
def experimental?(oid), do: child_of?(oid, @experimental)
def experimental?(oid), do: @gleam.is_experimental(oid)
@spec private?(oid()) :: boolean()
def private?(oid), do: child_of?(oid, @private)
def private?(oid), do: @gleam.is_private(oid)
@doc """
Gets the enterprise number from an enterprise OID.
## Examples
{:ok, 9} = SnmpKit.SnmpLib.OID.get_enterprise_number([1, 3, 6, 1, 4, 1, 9, 1, 1])
{:error, :not_enterprise_oid} = SnmpKit.SnmpLib.OID.get_enterprise_number([1, 3, 6, 1, 2, 1])
iex> SnmpKit.SnmpLib.OID.get_enterprise_number([1, 3, 6, 1, 4, 1, 9, 1, 1])
{:ok, 9}
"""
@spec get_enterprise_number(oid()) :: {:ok, non_neg_integer()} | {:error, atom()}
def get_enterprise_number(oid) when is_list(oid) do
if enterprise?(oid) and length(oid) > length(@enterprises) do
enterprise_num = Enum.at(oid, length(@enterprises))
{:ok, enterprise_num}
else
{:error, :not_enterprise_oid}
end
end
def get_enterprise_number(oid) when is_list(oid), do: @gleam.get_enterprise_number(oid)
def get_enterprise_number(_), do: {:error, :invalid_input}
## Private Helper Functions
defp parse_oid_components(parts) do
oid_list =
Enum.map(parts, fn part ->
case parse_oid_component(part) do
{:error, reason} -> throw(reason)
num -> num
end
end)
{:ok, oid_list}
catch
reason -> {:error, reason}
end
defp parse_oid_component(component) when is_binary(component) do
case Integer.parse(component) do
{num, ""} when num >= 0 -> num
_ -> {:error, :invalid_oid_string}
end
end
defp validate_oid_list([]), do: {:error, :empty_oid}
defp validate_oid_list(oid_list) when is_list(oid_list) do
if Enum.all?(oid_list, &valid_oid_component?/1) do
:ok
else
{:error, :invalid_component}
end
end
defp validate_oid_list(_), do: {:error, :invalid_input}
defp valid_oid_component?(component) when is_integer(component) and component >= 0, do: true
defp valid_oid_component?(_), do: false
defp compare_components([], []), do: :eq
defp compare_components([], _), do: :lt
defp compare_components(_, []), do: :gt
defp compare_components([h1 | t1], [h2 | t2]) do
cond do
h1 < h2 -> :lt
h1 > h2 -> :gt
true -> compare_components(t1, t2)
end
end
## Private Helpers for compound table index operations
defp parse_index_components(index, [], acc) do
{:ok, {Enum.reverse(acc), index}}
@ -652,7 +368,6 @@ defmodule SnmpKit.SnmpLib.OID do
defp parse_index_components(index, [syntax | rest_syntax], acc) do
case parse_table_index(index, syntax) do
{:ok, value} ->
# Calculate how many components were consumed
case build_table_index(value, syntax) do
{:ok, consumed_index} ->
consumed_length = length(consumed_index)
@ -668,13 +383,6 @@ defmodule SnmpKit.SnmpLib.OID do
end
end
defp build_string_from_bytes(bytes) do
string = Enum.map_join(bytes, "", &<<&1>>)
{:ok, string}
rescue
_ -> {:error, :invalid_string_index}
end
defp build_compound_index(values, syntax_list) do
index_parts =
values

View file

@ -2,11 +2,12 @@ defmodule SnmpKit.SnmpLib.PDU.Constants do
@moduledoc """
Constants and type definitions for SNMP PDU operations.
This module contains all ASN.1 tags, error codes, type definitions,
and utility functions used throughout the SNMP PDU system.
Pure computation (version normalization, OID parsing, message flag encoding)
is implemented in Gleam. This module provides the Elixir API with type
definitions, atom-based lookups, and binary encodings.
"""
import Bitwise
@gleam :snmpkit@snmp_lib@pdu@constants
# Type definitions
@type snmp_type ::
@ -152,31 +153,11 @@ defmodule SnmpKit.SnmpLib.PDU.Constants do
def no_such_instance, do: @no_such_instance
def end_of_mib_view, do: @end_of_mib_view
# Utility functions moved from main module
def normalize_version(:v1), do: 0
def normalize_version(:v2c), do: 1
def normalize_version(:v2), do: 1
def normalize_version(:v3), do: 3
def normalize_version(v) when is_integer(v), do: v
def normalize_version(_), do: 0
# Delegate computation functions to Gleam
defdelegate normalize_version(version), to: @gleam
def normalize_oid(oid) when is_list(oid), do: oid
def normalize_oid(oid) when is_binary(oid) do
oid
|> String.split(".")
|> Enum.map(fn part ->
case Integer.parse(part) do
{num, ""} when num >= 0 -> num
_ -> throw(:invalid_oid)
end
end)
catch
# Safe default for invalid OID strings
:invalid_oid -> [1, 3, 6, 1]
end
# Safe default for invalid types
def normalize_oid(oid) when is_binary(oid), do: @gleam.normalize_oid_string(oid)
def normalize_oid(_), do: [1, 3, 6, 1]
# New utility functions for type conversion
@ -258,11 +239,7 @@ defmodule SnmpKit.SnmpLib.PDU.Constants do
"""
@spec encode_msg_flags(msg_flags()) :: binary()
def encode_msg_flags(%{auth: auth, priv: priv, reportable: reportable}) do
flags = 0
flags = if auth, do: flags ||| 0x01, else: flags
flags = if priv, do: flags ||| 0x02, else: flags
flags = if reportable, do: flags ||| 0x04, else: flags
<<flags>>
<<@gleam.encode_msg_flags(auth, priv, reportable)>>
end
@doc """
@ -270,11 +247,8 @@ defmodule SnmpKit.SnmpLib.PDU.Constants do
"""
@spec decode_msg_flags(binary()) :: msg_flags()
def decode_msg_flags(<<flags::8>>) do
%{
auth: (flags &&& 0x01) != 0,
priv: (flags &&& 0x02) != 0,
reportable: (flags &&& 0x04) != 0
}
{auth, priv, reportable} = @gleam.decode_msg_flags(flags)
%{auth: auth, priv: priv, reportable: reportable}
end
@doc """

View file

@ -59,6 +59,8 @@ defmodule SnmpKit.SnmpLib.Types do
alias SnmpKit.SnmpLib.OID
@formatting :snmpkit@formatting
@type snmp_type ::
:integer
| :string
@ -515,10 +517,7 @@ defmodule SnmpKit.SnmpLib.Types do
"""
@spec format_timeticks_uptime(non_neg_integer()) :: binary()
def format_timeticks_uptime(centiseconds) when is_integer(centiseconds) and centiseconds >= 0 do
total_seconds = div(centiseconds, 100)
remaining_centiseconds = rem(centiseconds, 100)
format_time_components(total_seconds, remaining_centiseconds)
@formatting.format_timeticks_uptime(centiseconds)
end
@doc """
@ -531,7 +530,7 @@ defmodule SnmpKit.SnmpLib.Types do
"""
@spec format_counter64(non_neg_integer()) :: binary()
def format_counter64(value) when is_integer(value) and value >= 0 do
format_large_number(value)
@formatting.format_number(value)
end
@doc """
@ -559,22 +558,7 @@ defmodule SnmpKit.SnmpLib.Types do
"""
@spec format_bytes(non_neg_integer()) :: binary()
def format_bytes(bytes) when is_integer(bytes) and bytes >= 0 do
cond do
bytes < 1024 ->
"#{bytes} B"
bytes < 1024 * 1024 ->
kb = Float.round(bytes / 1024, 1)
"#{kb} KB"
bytes < 1024 * 1024 * 1024 ->
mb = Float.round(bytes / (1024 * 1024), 1)
"#{mb} MB"
true ->
gb = Float.round(bytes / (1024 * 1024 * 1024), 1)
"#{gb} GB"
end
@formatting.format_bytes(bytes)
end
@doc """
@ -586,22 +570,16 @@ defmodule SnmpKit.SnmpLib.Types do
"1.5 Mbps" = SnmpKit.SnmpLib.Types.format_rate(1500000, "bps")
"""
@spec format_rate(number(), binary()) :: binary()
def format_rate(value, unit) when is_number(value) and is_binary(unit) do
def format_rate(value, unit) when is_integer(value) and is_binary(unit) do
@formatting.format_rate(value, unit)
end
def format_rate(value, unit) when is_float(value) and is_binary(unit) do
cond do
value < 1_000 ->
"#{value} #{unit}"
value < 1_000_000 ->
k_value = Float.round(value / 1_000, 1)
"#{k_value} K#{unit}"
value < 1_000_000_000 ->
m_value = Float.round(value / 1_000_000, 1)
"#{m_value} M#{unit}"
true ->
g_value = Float.round(value / 1_000_000_000, 1)
"#{g_value} G#{unit}"
value < 1_000 -> "#{value} #{unit}"
value < 1_000_000 -> "#{Float.round(value / 1_000, 1)} K#{unit}"
value < 1_000_000_000 -> "#{Float.round(value / 1_000_000, 1)} M#{unit}"
true -> "#{Float.round(value / 1_000_000_000, 1)} G#{unit}"
end
end
@ -614,17 +592,8 @@ defmodule SnmpKit.SnmpLib.Types do
"hello..." = SnmpKit.SnmpLib.Types.truncate_string("hello world", 8)
"""
@spec truncate_string(binary(), pos_integer()) :: binary()
def truncate_string(string, max_length) when is_binary(string) and is_integer(max_length) and max_length > 3 do
if String.length(string) <= max_length do
string
else
truncated = String.slice(string, 0, max_length - 3)
"#{truncated}..."
end
end
def truncate_string(string, max_length) when is_binary(string) and is_integer(max_length) do
String.slice(string, 0, max(max_length, 0))
@formatting.truncate_string(string, max_length)
end
@doc """
@ -1055,48 +1024,4 @@ defmodule SnmpKit.SnmpLib.Types do
end
defp oid_list?(_), do: false
## Private Helper Functions
defp format_time_components(0, 0), do: "0 centiseconds"
defp format_time_components(0, centiseconds), do: "#{centiseconds} centiseconds"
defp format_time_components(total_seconds, centiseconds) do
days = div(total_seconds, 86_400)
remaining_seconds = rem(total_seconds, 86_400)
hours = div(remaining_seconds, 3600)
remaining_seconds = rem(remaining_seconds, 3600)
minutes = div(remaining_seconds, 60)
seconds = rem(remaining_seconds, 60)
parts = []
parts = if days > 0, do: ["#{days} day#{plural(days)}" | parts], else: parts
parts = if hours > 0, do: ["#{hours} hour#{plural(hours)}" | parts], else: parts
parts = if minutes > 0, do: ["#{minutes} minute#{plural(minutes)}" | parts], else: parts
parts = if seconds > 0, do: ["#{seconds} second#{plural(seconds)}" | parts], else: parts
parts =
if centiseconds > 0,
do: ["#{centiseconds} centisecond#{plural(centiseconds)}" | parts],
else: parts
case Enum.reverse(parts) do
[] -> "0 centiseconds"
[single] -> single
parts -> Enum.join(parts, " ")
end
end
defp plural(1), do: ""
defp plural(_), do: "s"
defp format_large_number(number) when is_integer(number) do
number
|> Integer.to_string()
|> String.graphemes()
|> Enum.reverse()
|> Enum.chunk_every(3)
|> Enum.map_join(",", &Enum.join(&1, ""))
|> String.reverse()
end
end

View file

@ -61,6 +61,8 @@ defmodule SnmpKit.SnmpLib.Utils do
require Logger
@formatting :snmpkit@formatting
@type oid :: [non_neg_integer()]
@type snmp_value :: any()
@type varbind :: {oid(), snmp_value()}
@ -225,12 +227,7 @@ defmodule SnmpKit.SnmpLib.Utils do
"""
@spec format_bytes(non_neg_integer()) :: String.t()
def format_bytes(bytes) when is_integer(bytes) and bytes >= 0 do
cond do
bytes >= 1_073_741_824 -> "#{Float.round(bytes / 1_073_741_824, 1)} GB"
bytes >= 1_048_576 -> "#{Float.round(bytes / 1_048_576, 1)} MB"
bytes >= 1_024 -> "#{Float.round(bytes / 1_024, 1)} KB"
true -> "#{bytes} B"
end
@formatting.format_bytes(bytes)
end
def format_bytes(_), do: "Invalid byte count"
@ -247,7 +244,11 @@ defmodule SnmpKit.SnmpLib.Utils do
"45 pps"
"""
@spec format_rate(number(), String.t()) :: String.t()
def format_rate(value, unit) when is_number(value) and is_binary(unit) do
def format_rate(value, unit) when is_integer(value) and is_binary(unit) do
@formatting.format_rate(value, unit)
end
def format_rate(value, unit) when is_float(value) and is_binary(unit) do
cond do
value >= 1_000_000_000 -> "#{Float.round(value / 1_000_000_000, 1)} G#{unit}"
value >= 1_000_000 -> "#{Float.round(value / 1_000_000, 1)} M#{unit}"
@ -271,11 +272,7 @@ defmodule SnmpKit.SnmpLib.Utils do
"""
@spec truncate_string(String.t(), pos_integer()) :: String.t()
def truncate_string(string, max_length) when is_binary(string) and is_integer(max_length) and max_length > 3 do
if String.length(string) <= max_length do
string
else
String.slice(string, 0, max_length - 3) <> "..."
end
@formatting.truncate_string(string, max_length)
end
def truncate_string(string, _) when is_binary(string), do: string
@ -322,11 +319,7 @@ defmodule SnmpKit.SnmpLib.Utils do
"""
@spec format_number(integer()) :: String.t()
def format_number(number) when is_integer(number) do
number
|> Integer.to_string()
|> String.reverse()
|> String.replace(~r/(\d{3})(?=\d)/, "\\1,")
|> String.reverse()
@formatting.format_number(number)
end
def format_number(_), do: "Invalid number"
@ -381,16 +374,7 @@ defmodule SnmpKit.SnmpLib.Utils do
"""
@spec format_response_time(non_neg_integer()) :: String.t()
def format_response_time(microseconds) when is_integer(microseconds) and microseconds >= 0 do
cond do
microseconds >= 1_000_000 ->
"#{:erlang.float_to_binary(microseconds / 1_000_000, [{:decimals, 2}])}s"
microseconds >= 1_000 ->
"#{:erlang.float_to_binary(microseconds / 1_000, [{:decimals, 2}])}ms"
true ->
"#{microseconds}μs"
end
@formatting.format_response_time(microseconds)
end
def format_response_time(_), do: "Invalid time"

View file

@ -9,7 +9,6 @@ defmodule Towerops.Accounts do
alias Towerops.Accounts.LoginAttempt
alias Towerops.Accounts.PolicyVersion
alias Towerops.Accounts.User
alias Towerops.Accounts.UserAgentParser
alias Towerops.Accounts.UserConsent
alias Towerops.Accounts.UserNotifier
alias Towerops.Accounts.UserRecoveryCode
@ -1396,7 +1395,7 @@ defmodule Towerops.Accounts do
def create_browser_session(attrs) do
# Parse User-Agent for device metadata
user_agent = Map.get(attrs, :user_agent)
device_info = UserAgentParser.parse(user_agent)
device_info = :towerops@accounts@user_agent_parser.parse(user_agent)
# Enrich with GeoIP data
ip_address = Map.get(attrs, :ip_address)

View file

@ -1,195 +0,0 @@
defmodule Towerops.Accounts.UserAgentParser do
@moduledoc """
Simple regex-based User-Agent parser for extracting browser, OS, and device information.
This module provides basic device identification without external dependencies.
It covers ~95% of common user agents but may not detect obscure browsers or devices.
## Examples
iex> Towerops.Accounts.UserAgentParser.parse("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36")
%{
browser_name: "Chrome",
browser_version: "120.0",
os_name: "macOS",
os_version: "10.15.7",
device_type: "desktop",
device_name: "Chrome on macOS"
}
iex> Towerops.Accounts.UserAgentParser.parse("Mozilla/5.0 (iPhone; CPU iPhone OS 17_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Mobile/15E148 Safari/604.1")
%{
browser_name: "Safari",
browser_version: "17.2",
os_name: "iOS",
os_version: "17.2",
device_type: "mobile",
device_name: "Safari on iOS"
}
"""
@doc """
Parses a User-Agent string and returns device metadata.
Returns a map with the following fields:
- `:browser_name` - Browser name (Chrome, Safari, Firefox, Edge, Opera, or "Unknown")
- `:browser_version` - Browser version string (or nil)
- `:os_name` - Operating system name (macOS, Windows, Linux, iOS, Android, or "Unknown")
- `:os_version` - OS version string (or nil)
- `:device_type` - "desktop", "mobile", or "tablet"
- `:device_name` - Formatted string like "Chrome on macOS"
If the User-Agent is nil or cannot be parsed, returns a map with "Unknown" values.
"""
def parse(nil), do: unknown_device()
def parse(""), do: unknown_device()
def parse(user_agent) when is_binary(user_agent) do
browser = detect_browser(user_agent)
os = detect_os(user_agent)
device_type = detect_device_type(user_agent, os)
%{
browser_name: browser.name,
browser_version: browser.version,
os_name: os.name,
os_version: os.version,
device_type: device_type,
device_name: format_device_name(browser.name, os.name)
}
end
# Browser detection
defp detect_browser(ua) do
cond do
# Opera must be checked before Chrome (Opera includes Chrome in UA)
Regex.match?(~r/OPR\/(\d+\.\d+)/, ua) || Regex.match?(~r/Opera\/(\d+\.\d+)/, ua) ->
version = extract_version(ua, ~r/(?:OPR|Opera)\/(\d+\.\d+)/)
%{name: "Opera", version: version}
# Edge must be checked before Chrome (Edge includes Chrome in UA)
Regex.match?(~r/Edg(e|A|iOS)?\/(\d+\.\d+)/, ua) ->
version = extract_version(ua, ~r/Edg(?:e|A|iOS)?\/(\d+\.\d+)/)
%{name: "Edge", version: version}
# Chrome must be checked before Safari (Chrome includes Safari in UA)
Regex.match?(~r/Chrome\/(\d+\.\d+)/, ua) ->
version = extract_version(ua, ~r/Chrome\/(\d+\.\d+)/)
%{name: "Chrome", version: version}
# Firefox
Regex.match?(~r/Firefox\/(\d+\.\d+)/, ua) ->
version = extract_version(ua, ~r/Firefox\/(\d+\.\d+)/)
%{name: "Firefox", version: version}
# Safari (check last because many browsers include Safari in UA)
Regex.match?(~r/Safari\//, ua) && Regex.match?(~r/Version\/(\d+\.\d+)/, ua) ->
version = extract_version(ua, ~r/Version\/(\d+\.\d+)/)
%{name: "Safari", version: version}
true ->
%{name: "Unknown", version: nil}
end
end
# OS detection
defp detect_os(ua) do
cond do
# iOS (must check before macOS because iOS UA contains "Mac OS X")
Regex.match?(~r/iPhone|iPad|iPod/, ua) ->
version = extract_version(ua, ~r/OS (\d+[_\d]*)/)
%{name: "iOS", version: format_ios_version(version)}
# macOS
Regex.match?(~r/Macintosh|Mac OS X/, ua) ->
version = extract_version(ua, ~r/Mac OS X (\d+[_\d]*)/)
%{name: "macOS", version: format_mac_version(version)}
# Android
Regex.match?(~r/Android/, ua) ->
version = extract_version(ua, ~r/Android (\d+\.\d+)/)
%{name: "Android", version: version}
# Windows
Regex.match?(~r/Windows/, ua) ->
version = detect_windows_version(ua)
%{name: "Windows", version: version}
# Linux
Regex.match?(~r/Linux/, ua) ->
%{name: "Linux", version: nil}
true ->
%{name: "Unknown", version: nil}
end
end
# Device type detection
defp detect_device_type(ua, os) do
cond do
# Tablets
Regex.match?(~r/iPad/, ua) || Regex.match?(~r/Android.*Tablet/, ua) ->
"tablet"
# Mobile devices
Regex.match?(~r/iPhone|iPod|Android.*Mobile|Mobile/, ua) || os.name == "iOS" ->
"mobile"
# Default to desktop
true ->
"desktop"
end
end
# Helper functions
defp extract_version(ua, regex) do
case Regex.run(regex, ua) do
[_, version] -> version
_ -> nil
end
end
defp format_ios_version(nil), do: nil
defp format_ios_version(version) do
# Convert iOS version from "17_2_1" to "17.2.1"
String.replace(version, "_", ".")
end
defp format_mac_version(nil), do: nil
defp format_mac_version(version) do
# Convert macOS version from "10_15_7" to "10.15.7"
String.replace(version, "_", ".")
end
defp detect_windows_version(ua) do
cond do
Regex.match?(~r/Windows NT 10\.0/, ua) -> "10"
Regex.match?(~r/Windows NT 6\.3/, ua) -> "8.1"
Regex.match?(~r/Windows NT 6\.2/, ua) -> "8"
Regex.match?(~r/Windows NT 6\.1/, ua) -> "7"
Regex.match?(~r/Windows NT/, ua) -> extract_version(ua, ~r/Windows NT (\d+\.\d+)/)
true -> nil
end
end
defp format_device_name(browser, os) do
"#{browser} on #{os}"
end
defp unknown_device do
%{
browser_name: "Unknown",
browser_version: nil,
os_name: "Unknown",
os_version: nil,
device_type: "desktop",
device_name: "Unknown on Unknown"
}
end
end

View file

@ -2,8 +2,13 @@ defmodule Towerops.Devices.MikrotikBackups.Differ do
@moduledoc """
Provides functionality for generating diffs between MikroTik configuration backups
and masking sensitive data in configurations.
Sensitive-data masking and diff statistics are implemented in Gleam.
Unified diff generation (I/O-bound) remains in Elixir.
"""
@gleam :towerops@devices@mikrotik_backups@differ
@doc """
Masks sensitive data in a MikroTik configuration.
@ -21,13 +26,7 @@ defmodule Towerops.Devices.MikrotikBackups.Differ do
iex> Differ.mask_sensitive_data("/user set admin password=secret123")
"/user set admin password=***MASKED:a1b2c3d4***"
"""
def mask_sensitive_data(config_text) when is_binary(config_text) do
config_text
|> mask_passwords()
|> mask_snmp_communities()
|> mask_ipsec_secrets()
|> mask_wireless_keys()
end
defdelegate mask_sensitive_data(config_text), to: @gleam
@doc """
Generates a unified diff between two configurations.
@ -92,111 +91,7 @@ defmodule Towerops.Devices.MikrotikBackups.Differ do
%{additions: 1, deletions: 1, changes: 1}
"""
def calculate_diff_stats(diff) when is_binary(diff) do
lines = String.split(diff, "\n")
additions = Enum.count(lines, &(String.starts_with?(&1, "+") && !String.starts_with?(&1, "+++")))
deletions = Enum.count(lines, &(String.starts_with?(&1, "-") && !String.starts_with?(&1, "---")))
# Changes represent modified lines (paired additions/deletions)
changes = min(additions, deletions)
%{
additions: additions,
deletions: deletions,
changes: changes
}
end
# Private functions for masking different types of sensitive data
defp mask_passwords(config) do
# Match password= followed by non-whitespace characters
# Supports both "password=" and variations with quotes
Regex.replace(
~r/(password=)([^\s]+)/i,
config,
fn _, prefix, value ->
hash = hash_value(value)
"#{prefix}***MASKED:#{hash}***"
end
)
end
defp mask_snmp_communities(config) do
# SNMP community strings in RouterOS
# Mask both the community identifier and name= parameter
config
|> mask_snmp_set_identifier()
|> mask_snmp_name_parameter()
end
defp mask_snmp_set_identifier(config) do
# Pattern: /snmp community set [name] ...
Regex.replace(
~r/(\/snmp\s+community\s+set\s+)([^\s]+)/,
config,
fn _, prefix, value ->
# Don't mask common default names, but mask custom ones
if value in ["public", "private"] do
prefix <> value
else
hash = hash_value(value)
"#{prefix}***MASKED:#{hash}***"
end
end
)
end
defp mask_snmp_name_parameter(config) do
# Pattern: name=xxx in SNMP community commands
Regex.replace(
~r/(\/snmp\s+community.*?name=)([^\s]+)/,
config,
fn _, prefix, value ->
# Don't mask common default names
if value in ["public", "private"] do
prefix <> value
else
hash = hash_value(value)
"#{prefix}***MASKED:#{hash}***"
end
end
)
end
defp mask_ipsec_secrets(config) do
# IPsec secrets: secret=xxx
Regex.replace(
~r/(secret=)([^\s]+)/i,
config,
fn _, prefix, value ->
hash = hash_value(value)
"#{prefix}***MASKED:#{hash}***"
end
)
end
defp mask_wireless_keys(config) do
# Wireless WPA/WPA2 pre-shared keys
config
|> mask_wpa_key("wpa-pre-shared-key")
|> mask_wpa_key("wpa2-pre-shared-key")
end
defp mask_wpa_key(config, key_name) do
pattern = ~r/(#{key_name}=)([^\s]+)/i
Regex.replace(pattern, config, fn _, prefix, value ->
hash = hash_value(value)
"#{prefix}***MASKED:#{hash}***"
end)
end
# Generate a short hash of a value for change detection
defp hash_value(value) do
:sha256
|> :crypto.hash(value)
|> Base.encode16(case: :lower)
|> String.slice(0..7)
{additions, deletions, changes} = @gleam.calculate_diff_stats(diff)
%{additions: additions, deletions: deletions, changes: changes}
end
end

View file

@ -1,148 +0,0 @@
defmodule Towerops.Devices.VersionComparator do
@moduledoc """
Compares semantic version strings.
Supports versions with 0-3 parts (e.g., "7", "7.14", "7.14.1").
Versions with 4+ parts are considered invalid and fall back to {0, 0, 0}.
## Parsing Rules
- Strips "v" or "V" prefix (case-insensitive)
- Trims whitespace
- Splits on "-" or " " and ignores suffixes (e.g., "7.14.1-beta" "7.14.1")
- Pads missing parts with 0 (e.g., "7.14" {7, 14, 0})
- 0-3 parts: VALID
- 4+ parts: INVALID fallback to {0, 0, 0}
- Non-numeric parts: INVALID
- Negative numbers: INVALID
- Leading zeros: VALID (e.g., "007.014.001" {7, 14, 1})
## Examples
iex> VersionComparator.compare("7.14.1", "7.14.2")
:lt
iex> VersionComparator.compare("8.0.0", "7.14.1")
:gt
iex> VersionComparator.compare("7.14.1", "7.14.1")
:eq
iex> VersionComparator.newer?("7.14.1", "7.14.2")
true
iex> VersionComparator.newer?("7.14.2", "7.14.1")
false
"""
@doc """
Compares two version strings.
Returns `:lt` if the first version is older, `:gt` if newer, or `:eq` if equal.
## Examples
iex> VersionComparator.compare("7.14.1", "7.14.2")
:lt
iex> VersionComparator.compare("8.0.0", "7.14.1")
:gt
iex> VersionComparator.compare("v7.14.1", "V7.14.1")
:eq
"""
def compare(version1, version2) do
v1_tuple = parse_version(version1)
v2_tuple = parse_version(version2)
cond do
v1_tuple < v2_tuple -> :lt
v1_tuple > v2_tuple -> :gt
true -> :eq
end
end
@doc """
Returns true if the second version is newer than the first.
## Examples
iex> VersionComparator.newer?("7.14.1", "7.14.2")
true
iex> VersionComparator.newer?("7.14.2", "7.14.1")
false
iex> VersionComparator.newer?("7.14.1", "7.14.1")
false
"""
def newer?(current_version, new_version) do
compare(current_version, new_version) == :lt
end
# Private helper to parse version string into {major, minor, patch} tuple
defp parse_version(version) when is_binary(version) do
version
|> String.trim()
|> strip_prefix()
|> strip_suffix()
|> parse_parts()
end
# Strip "v" or "V" prefix
defp strip_prefix("v" <> rest), do: rest
defp strip_prefix("V" <> rest), do: rest
defp strip_prefix(version), do: version
# Strip suffixes after "-" or " "
defp strip_suffix(version) do
version
|> String.split(["-", " "], parts: 2)
|> List.first()
end
# Parse version parts into {major, minor, patch} tuple
defp parse_parts(""), do: {0, 0, 0}
defp parse_parts(version) do
parts = String.split(version, ".")
# 4+ parts is invalid
if length(parts) > 3 do
{0, 0, 0}
else
parse_numeric_parts(parts)
end
end
# Convert parts to integers, pad with 0s, validate
defp parse_numeric_parts(parts) do
case parse_and_validate(parts) do
{:ok, numbers} -> pad_to_three(numbers)
:error -> {0, 0, 0}
end
end
# Parse each part as integer and validate (no negatives, must be numeric)
defp parse_and_validate(parts) do
results =
Enum.map(parts, fn part ->
case Integer.parse(part) do
{num, ""} when num >= 0 -> {:ok, num}
_ -> :error
end
end)
if Enum.all?(results, &match?({:ok, _}, &1)) do
{:ok, Enum.map(results, fn {:ok, num} -> num end)}
else
:error
end
end
# Pad list to 3 elements with 0s, then convert to tuple
defp pad_to_three(numbers) do
padded = numbers ++ List.duplicate(0, 3 - length(numbers))
List.to_tuple(Enum.take(padded, 3))
end
end

View file

@ -224,9 +224,8 @@ defmodule Towerops.Monitoring do
"""
def schedule_check(%Check{} = check) do
alias Towerops.Workers.CheckExecutorWorker
alias Towerops.Workers.PollingOffset
offset = PollingOffset.calculate_offset(check.id, check.interval_seconds)
offset = :towerops@workers@polling_offset.calculate_offset(check.id, check.interval_seconds)
%{check_id: check.id}
|> CheckExecutorWorker.new(schedule_in: offset)

View file

@ -2,22 +2,14 @@ defmodule Towerops.Snmp.MibParser do
@moduledoc """
Simple MIB file parser for extracting OID definitions.
This parser extracts OID assignments from MIB files for validation purposes.
It's not a full ASN.1 parser - just enough to validate our hardcoded OIDs
against the official MIB definitions.
## Usage
iex> MibParser.parse_mib_file("priv/mibs/standard/IF-MIB")
{:ok, %{
"ifIndex" => "1.3.6.1.2.1.2.2.1.1",
"ifDescr" => "1.3.6.1.2.1.2.2.1.2",
...
}}
Core parsing logic implemented in Gleam (`src/towerops/snmp/mib_parser.gleam`).
This module handles file I/O and delegates content parsing to the Gleam implementation.
"""
require Logger
@gleam :towerops@snmp@mib_parser
@doc """
Parse a MIB file and return a map of object names to OID strings.
"""
@ -39,11 +31,7 @@ defmodule Towerops.Snmp.MibParser do
"""
@spec parse_mib_content(String.t()) :: %{String.t() => String.t()}
def parse_mib_content(content) do
# First, build a map of object assignments (e.g., "ifTable ::= { interfaces 2 }")
assignments = extract_assignments(content)
# Then resolve all OIDs by following the assignment chain
resolve_oids(assignments)
@gleam.parse_mib_content(content)
end
@doc """
@ -69,77 +57,6 @@ defmodule Towerops.Snmp.MibParser do
end
end
# Private functions
defp extract_assignments(content) do
# Match patterns like:
# objectName OBJECT-TYPE ::= { parent index }
# objectName OBJECT IDENTIFIER ::= { parent index }
# We'll extract: objectName, parent, index
# Remove comments (lines starting with --)
content = Regex.replace(~r/--.*$/m, content, "")
# Match assignment patterns
# This regex captures: name, parent, and index
~r/(\w+)\s+(?:OBJECT-TYPE|OBJECT\s+IDENTIFIER)[^:]*::=\s*\{\s*(\w+)\s+(\d+)\s*\}/
|> Regex.scan(content)
|> Map.new(fn [_, name, parent, index] ->
{name, {parent, index}}
end)
end
defp resolve_oids(assignments) do
# Well-known root OIDs
roots = %{
"iso" => "1",
"org" => "1.3",
"dod" => "1.3.6",
"internet" => "1.3.6.1",
"directory" => "1.3.6.1.1",
"mgmt" => "1.3.6.1.2",
"mib-2" => "1.3.6.1.2.1",
"experimental" => "1.3.6.1.3",
"private" => "1.3.6.1.4",
"enterprises" => "1.3.6.1.4.1",
"security" => "1.3.6.1.5",
"snmpV2" => "1.3.6.1.6",
"snmpModules" => "1.3.6.1.6.3"
}
# Build OID map by recursively resolving assignments
Enum.reduce(assignments, roots, fn {name, {parent, index}}, acc ->
case resolve_oid(parent, index, acc, assignments) do
{:ok, oid} -> Map.put(acc, name, oid)
:error -> acc
end
end)
end
defp resolve_oid(parent, index, resolved, assignments) do
cond do
# Parent already resolved
Map.has_key?(resolved, parent) ->
{:ok, "#{resolved[parent]}.#{index}"}
# Parent needs to be resolved from assignments
Map.has_key?(assignments, parent) ->
{grandparent, parent_index} = assignments[parent]
case resolve_oid(grandparent, parent_index, resolved, assignments) do
{:ok, parent_oid} ->
{:ok, "#{parent_oid}.#{index}"}
:error ->
:error
end
# Can't resolve
true ->
:error
end
end
@doc """
List all MIB files in the priv/mibs directory.
"""

View file

@ -2,165 +2,17 @@ defmodule Towerops.Snmp.Sanitizer do
@moduledoc """
Sanitizes SNMP data to ensure it's safe to store in the database.
SNMP devices may return:
- Non-UTF8 binary data
- Non-printable characters
- Malformed strings
- Raw binary values (MAC addresses, IP addresses)
This module provides sanitization functions to convert these to safe,
printable strings suitable for PostgreSQL text/varchar columns.
Core sanitization logic is implemented in Gleam (`:towerops@snmp@sanitizer`).
This module provides `sanitize_map/1` which must remain in Elixir for
recursive map traversal with arbitrary keys.
"""
@doc """
Sanitizes a string field from SNMP data.
@gleam :towerops@snmp@sanitizer
- Returns nil for nil input
- Returns printable strings as-is (trimmed)
- Converts non-printable binaries to colon-separated hex format
- Converts other values to strings
## Examples
iex> Sanitizer.sanitize_string(nil)
nil
iex> Sanitizer.sanitize_string("Hello World")
"Hello World"
iex> Sanitizer.sanitize_string(<<255, 255, 255, 0>>)
"ff:ff:ff:00"
"""
@spec sanitize_string(any()) :: String.t() | nil
def sanitize_string(nil), do: nil
def sanitize_string(value) when is_binary(value) do
if String.valid?(value) and String.printable?(value) do
String.trim(value)
else
# Convert non-printable binary to hex string
value
|> :binary.bin_to_list()
|> Enum.map_join(":", &String.pad_leading(Integer.to_string(&1, 16), 2, "0"))
|> String.downcase()
end
end
def sanitize_string(value) when is_list(value) do
# Erlang charlists from SNMP
value
|> List.to_string()
|> sanitize_string()
rescue
_ -> inspect(value)
end
def sanitize_string(value), do: to_string(value)
@doc """
Sanitizes an IPv4 address from various SNMP formats.
Handles:
- Tuple format: {a, b, c, d}
- Raw 4-byte binary: <<a, b, c, d>>
- String format: "a.b.c.d"
- Longer binary (takes first 4 bytes as IP)
Returns dotted-decimal string or nil if invalid.
"""
@spec sanitize_ipv4(any()) :: String.t() | nil
def sanitize_ipv4({a, b, c, d}) when is_integer(a) and is_integer(b) and is_integer(c) and is_integer(d) do
"#{a}.#{b}.#{c}.#{d}"
end
def sanitize_ipv4(<<a, b, c, d>>) do
"#{a}.#{b}.#{c}.#{d}"
end
def sanitize_ipv4(value) when is_binary(value) do
cond do
# Already formatted string - valid IP
String.printable?(value) and String.match?(value, ~r/^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/) ->
value
# Printable string that doesn't match IP format - invalid
String.printable?(value) ->
nil
# Raw bytes - try to convert (only for non-printable binary data)
byte_size(value) >= 4 ->
<<a, b, c, d, _rest::binary>> = value
"#{a}.#{b}.#{c}.#{d}"
true ->
nil
end
end
def sanitize_ipv4(_), do: nil
@doc """
Sanitizes an IPv6 address from various SNMP formats.
Handles:
- Raw 16-byte binary
- String format (already formatted)
- Tuple format (8 16-bit values)
Returns formatted IPv6 string or nil if invalid.
"""
@spec sanitize_ipv6(any()) :: String.t() | nil
def sanitize_ipv6(value) when is_binary(value) and byte_size(value) == 16 do
# Raw 16-byte IPv6 address
bytes = :binary.bin_to_list(value)
bytes
|> Enum.chunk_every(2)
|> Enum.map_join(":", fn [high, low] ->
(high * 256 + low)
|> Integer.to_string(16)
|> String.downcase()
end)
end
def sanitize_ipv6(value) when is_binary(value) do
# Already formatted - validate and return
if String.printable?(value) and String.contains?(value, ":") do
String.trim(value)
end
end
def sanitize_ipv6(_), do: nil
@doc """
Sanitizes a MAC address from various SNMP formats.
Handles:
- Raw 6-byte binary
- Colon-separated hex string
- Dash-separated hex string
Returns colon-separated lowercase hex format or nil if invalid.
"""
@spec sanitize_mac(any()) :: String.t() | nil
def sanitize_mac(value) when is_binary(value) and byte_size(value) == 6 do
value
|> :binary.bin_to_list()
|> Enum.map_join(":", &String.pad_leading(Integer.to_string(&1, 16), 2, "0"))
|> String.downcase()
end
def sanitize_mac(value) when is_binary(value) do
# Already formatted - validate pattern
cleaned = String.replace(value, "-", ":")
if String.match?(cleaned, ~r/^([0-9a-fA-F]{2}:){5}[0-9a-fA-F]{2}$/) do
String.downcase(cleaned)
end
end
def sanitize_mac(_), do: nil
defdelegate sanitize_string(value), to: @gleam
defdelegate sanitize_ipv4(value), to: @gleam
defdelegate sanitize_ipv6(value), to: @gleam
defdelegate sanitize_mac(value), to: @gleam
@doc """
Sanitizes all string fields in a map recursively.

View file

@ -1,28 +1,25 @@
defmodule Towerops.Topology.Identifier do
@moduledoc """
Identifier normalization helpers for topology discovery and matching.
Core logic (MAC formatting, name normalization, candidate names) is
implemented in Gleam. This module provides the Elixir API with nil
handling and binary-size dispatch.
"""
@gleam :towerops@topology@identifier
@spec normalize_mac(term()) :: String.t() | nil
def normalize_mac(nil), do: nil
def normalize_mac(<<a, b, c, d, e, f>>) when is_integer(a) do
[a, b, c, d, e, f]
|> Enum.map_join(":", &(&1 |> Integer.to_string(16) |> String.pad_leading(2, "0")))
|> String.downcase()
@gleam.format_mac_bytes([a, b, c, d, e, f])
end
def normalize_mac(mac) when is_binary(mac) do
mac
|> String.trim()
|> String.downcase()
|> String.replace(~r/[^0-9a-f]/, "")
|> case do
<<a1, a2, b1, b2, c1, c2, d1, d2, e1, e2, f1, f2>> ->
"#{<<a1, a2>>}:#{<<b1, b2>>}:#{<<c1, c2>>}:#{<<d1, d2>>}:#{<<e1, e2>>}:#{<<f1, f2>>}"
_ ->
nil
case @gleam.normalize_mac_string(mac) do
{:ok, formatted} -> formatted
{:error, _} -> nil
end
end
@ -32,24 +29,9 @@ defmodule Towerops.Topology.Identifier do
def normalize_ip(nil), do: nil
def normalize_ip(ip) when is_binary(ip) do
ip
|> String.trim()
|> strip_ipv6_zone()
|> case do
"" ->
nil
candidate ->
case :inet.parse_address(String.to_charlist(candidate)) do
{:ok, tuple} ->
tuple
|> :inet.ntoa()
|> to_string()
|> String.downcase()
_ ->
String.downcase(candidate)
end
case @gleam.normalize_ip_string(ip) do
{:ok, normalized} -> normalized
{:error, _} -> nil
end
end
@ -59,9 +41,9 @@ defmodule Towerops.Topology.Identifier do
def normalize_name(nil), do: nil
def normalize_name(name) when is_binary(name) do
case name |> String.trim() |> String.trim_trailing(".") |> String.downcase() do
"" -> nil
normalized -> normalized
case @gleam.normalize_name_string(name) do
{:ok, normalized} -> normalized
{:error, _} -> nil
end
end
@ -70,24 +52,8 @@ defmodule Towerops.Topology.Identifier do
@spec candidate_names(term()) :: [String.t()]
def candidate_names(name) do
case normalize_name(name) do
nil ->
[]
normalized ->
short_name =
normalized
|> String.split(".", parts: 2)
|> List.first()
[normalized, short_name]
|> Enum.reject(&is_nil/1)
|> Enum.uniq()
nil -> []
normalized -> @gleam.candidate_names_from_normalized(normalized)
end
end
defp strip_ipv6_zone(ip) do
ip
|> String.split("%", parts: 2)
|> List.first()
end
end

View file

@ -35,7 +35,6 @@ defmodule Towerops.Workers.CheckExecutorWorker do
alias Towerops.Monitoring.Executors.SslExecutor
alias Towerops.Monitoring.Executors.TcpExecutor
alias Towerops.Snmp.Client
alias Towerops.Workers.PollingOffset
require Logger
@ -219,7 +218,7 @@ defmodule Towerops.Workers.CheckExecutorWorker do
defp schedule_next_check(check) do
# Calculate staggered offset based on check ID
offset = PollingOffset.calculate_offset(check.id, check.interval_seconds)
offset = :towerops@workers@polling_offset.calculate_offset(check.id, check.interval_seconds)
%{check_id: check.id}
|> new(schedule_in: offset)

View file

@ -18,7 +18,6 @@ defmodule Towerops.Workers.CheckWorker do
alias Towerops.Monitoring
alias Towerops.Monitoring.Executor
alias Towerops.Snmp.Client
alias Towerops.Workers.PollingOffset
require Logger
@ -103,7 +102,7 @@ defmodule Towerops.Workers.CheckWorker do
Starts executing a check (schedules first execution).
"""
def start_check(check_id, interval_seconds \\ 60) do
offset = PollingOffset.calculate_offset(check_id, interval_seconds)
offset = :towerops@workers@polling_offset.calculate_offset(check_id, interval_seconds)
%{check_id: check_id}
|> new(schedule_in: offset)
@ -239,7 +238,7 @@ defmodule Towerops.Workers.CheckWorker do
end
defp schedule_next_check(check_id, interval_seconds) do
offset = PollingOffset.calculate_offset(check_id, interval_seconds)
offset = :towerops@workers@polling_offset.calculate_offset(check_id, interval_seconds)
%{check_id: check_id}
|> new(schedule_in: offset)

View file

@ -8,7 +8,6 @@ defmodule Towerops.Workers.CnMaestroSyncWorker do
alias Towerops.CnMaestro.Sync
alias Towerops.Integrations
alias Towerops.Workers.PollingOffset
require Logger
@ -22,7 +21,7 @@ defmodule Towerops.Workers.CnMaestroSyncWorker do
enqueued =
Enum.count(integrations, fn integration ->
offset = PollingOffset.calculate_offset(integration.organization_id, @window_seconds)
offset = :towerops@workers@polling_offset.calculate_offset(integration.organization_id, @window_seconds)
scheduled_at = DateTime.add(now, offset, :second)
case %{"integration_id" => integration.id}

View file

@ -26,7 +26,6 @@ defmodule Towerops.Workers.DeviceMonitorWorker do
alias Towerops.Monitoring
alias Towerops.Snmp.Client
alias Towerops.Workers.AlertNotificationWorker
alias Towerops.Workers.PollingOffset
require Logger
@ -94,7 +93,7 @@ defmodule Towerops.Workers.DeviceMonitorWorker do
Starts monitoring for a device.
"""
def start_monitoring(device_id) do
offset = PollingOffset.calculate_offset(device_id, @monitor_interval)
offset = :towerops@workers@polling_offset.calculate_offset(device_id, @monitor_interval)
%{device_id: device_id}
|> new(schedule_in: offset)
@ -253,7 +252,7 @@ defmodule Towerops.Workers.DeviceMonitorWorker do
end
defp schedule_next_check(device_id) do
offset = PollingOffset.calculate_offset(device_id, @monitor_interval)
offset = :towerops@workers@polling_offset.calculate_offset(device_id, @monitor_interval)
%{device_id: device_id}
|> new(schedule_in: @monitor_interval + offset)

View file

@ -36,7 +36,6 @@ defmodule Towerops.Workers.DevicePollerWorker do
alias Towerops.Snmp.NeighborDiscovery
alias Towerops.Snmp.SensorChangeDetector
alias Towerops.Snmp.WirelessClientDiscovery
alias Towerops.Workers.PollingOffset
require Logger
@ -116,7 +115,7 @@ defmodule Towerops.Workers.DevicePollerWorker do
def start_polling(device_id) do
device = Devices.get_device!(device_id)
interval = get_poll_interval(device)
offset = PollingOffset.calculate_offset(device_id, interval)
offset = :towerops@workers@polling_offset.calculate_offset(device_id, interval)
%{device_id: device_id}
|> new(schedule_in: offset)
@ -1432,7 +1431,7 @@ defmodule Towerops.Workers.DevicePollerWorker do
# Use same offset calculation as start_polling to maintain consistent intervals
# Offset is deterministic hash-based value from 0 to interval_seconds
# This spreads polls evenly across the interval window
offset = PollingOffset.calculate_offset(device_id, interval_seconds)
offset = :towerops@workers@polling_offset.calculate_offset(device_id, interval_seconds)
%{device_id: device_id}
|> new(schedule_in: offset)

View file

@ -1,58 +0,0 @@
defmodule Towerops.Workers.PollingOffset do
@moduledoc """
Calculates deterministic polling offsets to distribute load across time.
Uses erlang's phash2 to generate stable offsets from device IDs,
ensuring each device polls at a different time within the interval.
This prevents the "thundering herd" problem where all devices poll
at synchronized clock intervals (e.g., 1:00, 1:05, 1:10), creating
load spikes on the server.
## Examples
# Device with ID "abc-123" and 300-second interval
iex> calculate_offset("abc-123", 300)
94 # Device polls at :01:34, :06:34, :11:34, etc.
# Same device always gets same offset
iex> calculate_offset("abc-123", 300)
94
# Different device gets different offset
iex> calculate_offset("def-456", 300)
171 # Device polls at :02:51, :07:51, :12:51, etc.
"""
@doc """
Calculate polling offset for a device.
Returns an offset in seconds between 0 and interval_seconds-1.
Same device_id always returns same offset for a given interval.
## Parameters
* `device_id` - The device UUID (binary or string format)
* `interval_seconds` - The polling interval in seconds
## Returns
An integer offset in the range [0, interval_seconds)
## Examples
iex> calculate_offset("abc-123", 300)
94
iex> calculate_offset("abc-123", 300)
94 # Always returns same value for same device
iex> offset = calculate_offset("some-uuid", 60)
iex> offset >= 0 and offset < 60
true
"""
@spec calculate_offset(binary(), pos_integer()) :: non_neg_integer()
def calculate_offset(device_id, interval_seconds) do
device_id |> :erlang.phash2() |> rem(interval_seconds)
end
end

View file

@ -14,7 +14,6 @@ defmodule Towerops.Workers.PreseemSyncWorker do
alias Towerops.Integrations
alias Towerops.Preseem.Sync
alias Towerops.Workers.PollingOffset
require Logger
@ -31,7 +30,7 @@ defmodule Towerops.Workers.PreseemSyncWorker do
enqueued =
Enum.count(eligible, fn integration ->
offset = PollingOffset.calculate_offset(integration.organization_id, @window_seconds)
offset = :towerops@workers@polling_offset.calculate_offset(integration.organization_id, @window_seconds)
scheduled_at = DateTime.add(now, offset, :second)
case %{"integration_id" => integration.id}

View file

@ -14,7 +14,6 @@ defmodule Towerops.Workers.UispSyncWorker do
alias Towerops.Integrations
alias Towerops.Uisp.Sync
alias Towerops.Workers.PollingOffset
require Logger
@ -31,7 +30,7 @@ defmodule Towerops.Workers.UispSyncWorker do
enqueued =
Enum.count(eligible, fn integration ->
offset = PollingOffset.calculate_offset(integration.organization_id, @window_seconds)
offset = :towerops@workers@polling_offset.calculate_offset(integration.organization_id, @window_seconds)
scheduled_at = DateTime.add(now, offset, :second)
case %{"integration_id" => integration.id}

View file

@ -1,46 +1,33 @@
defmodule ToweropsWeb.ChangelogParser do
@moduledoc "Parses priv/static/changelog.txt into structured entries."
@moduledoc """
Reads and parses priv/static/changelog.txt into structured entries.
@doc "Returns a list of %{date: Date.t(), title: String.t(), items: [String.t()]}"
File I/O and Date conversion happen here; pure parsing logic is in Gleam.
"""
@doc "Returns a list of %{date: Date.t(), title: String.t() | nil, items: [String.t()]}"
def parse do
path = Application.app_dir(:towerops, "priv/static/changelog.txt")
case File.read(path) do
{:ok, content} -> parse_content(content)
{:error, _} -> []
{:ok, content} ->
content
|> :towerops_web@changelog_parser.parse_content()
|> Enum.map(&convert_entry/1)
{:error, _} ->
[]
end
end
defp parse_content(content) do
content
|> String.split("\n")
|> Enum.reduce([], fn line, acc ->
cond do
# Date header with title: "2026-02-13 — Title"
Regex.match?(~r/^\d{4}-\d{2}-\d{2}\s+[—–]\s+.+/, line) ->
[date_str | rest] = String.split(line, ~r/\s+[—–]\s+/, parts: 2)
title = List.first(rest, "")
entry = %{date: Date.from_iso8601!(String.trim(date_str)), title: String.trim(title), items: []}
[entry | acc]
# Date header without title: "2026-02-13"
Regex.match?(~r/^\d{4}-\d{2}-\d{2}\s*$/, line) ->
date = Date.from_iso8601!(String.trim(line))
entry = %{date: date, title: nil, items: []}
[entry | acc]
# Bullet point
String.starts_with?(String.trim(line), "* ") ->
add_item_to_current(acc, line |> String.trim() |> String.trim_leading("* "))
true ->
acc
end
end)
|> Enum.reverse()
defp convert_entry({:changelog_entry, date_str, title_opt, items}) do
%{
date: Date.from_iso8601!(date_str),
title: unwrap_option(title_opt),
items: items
}
end
defp add_item_to_current([current | rest], item), do: [%{current | items: current.items ++ [item]} | rest]
defp add_item_to_current([], _item), do: []
defp unwrap_option({:some, value}), do: value
defp unwrap_option(:none), do: nil
end

View file

@ -8,7 +8,6 @@ defmodule ToweropsWeb.DeviceLive.Show do
alias Towerops.Devices
alias Towerops.Devices.Firmware
alias Towerops.Devices.MikrotikBackups
alias Towerops.Devices.VersionComparator
alias Towerops.Gaiia
alias Towerops.Monitoring
alias Towerops.Repo
@ -718,7 +717,8 @@ defmodule ToweropsWeb.DeviceLive.Show do
current_clean = extract_version_number(current)
available_clean = extract_version_number(available)
current_clean && available_clean && VersionComparator.newer?(current_clean, available_clean)
current_clean && available_clean &&
:towerops@devices@version_comparator.newer(current_clean, available_clean)
end
# Extract semantic version number from version string

13
manifest.toml Normal file
View file

@ -0,0 +1,13 @@
# This file was generated by Gleam
# You typically do not need to edit this file
packages = [
{ name = "gleam_regexp", version = "1.1.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_regexp", source = "hex", outer_checksum = "9C215C6CA84A5B35BB934A9B61A9A306EC743153BE2B0425A0D032E477B062A9" },
{ name = "gleam_stdlib", version = "0.70.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "86949BF5D1F0E4AC0AB5B06F235D8A5CC11A2DFC33BF22F752156ED61CA7D0FF" },
{ name = "gleeunit", version = "1.9.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleeunit", source = "hex", outer_checksum = "DA9553CE58B67924B3C631F96FE3370C49EB6D6DC6B384EC4862CC4AAA718F3C" },
]
[requirements]
gleam_regexp = { version = ">= 1.0.0 and < 2.0.0" }
gleam_stdlib = { version = ">= 0.34.0 and < 2.0.0" }
gleeunit = { version = ">= 1.0.0 and < 2.0.0" }

View file

@ -105,6 +105,7 @@ defmodule Towerops.MixProject do
{:logger_backends, "~> 1.0", only: :dev},
{:tidewave, "~> 0.5", only: :dev},
{:gleam_stdlib, "~> 0.34 or ~> 1.0"},
{:gleam_regexp, "~> 1.0"},
{:gleeunit, "~> 1.0", only: [:dev, :test], runtime: false}
]
end

View file

@ -33,6 +33,7 @@
"fine": {:hex, :fine, "0.1.4", "b19a89c1476c7c57afb5f9314aed5960b5bc95d5277de4cb5ee8e1d1616ce379", [:mix], [], "hexpm", "be3324cc454a42d80951cf6023b9954e9ff27c6daa255483b3e8d608670303f5"},
"gen_smtp": {:hex, :gen_smtp, "1.3.0", "62c3d91f0dcf6ce9db71bcb6881d7ad0d1d834c7f38c13fa8e952f4104a8442e", [:rebar3], [{:ranch, ">= 1.8.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "0b73fbf069864ecbce02fe653b16d3f35fd889d0fdd4e14527675565c39d84e6"},
"gettext": {:hex, :gettext, "1.0.2", "5457e1fd3f4abe47b0e13ff85086aabae760497a3497909b8473e0acee57673b", [:mix], [{:expo, "~> 0.5.1 or ~> 1.0", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "eab805501886802071ad290714515c8c4a17196ea76e5afc9d06ca85fb1bfeb3"},
"gleam_regexp": {:hex, :gleam_regexp, "1.1.1", "3d69d7e3dd2833aaf2b14971b1e1fb74b3874d54c4a7bb2ccde3ec4f20c59151", [:gleam], [{:gleam_stdlib, ">= 0.34.0 and < 2.0.0", [hex: :gleam_stdlib, repo: "hexpm", optional: false]}], "hexpm", "9c215c6ca84a5b35bb934a9b61a9a306ec743153be2b0425a0d032e477b062a9"},
"gleam_stdlib": {:hex, :gleam_stdlib, "0.70.0", "e4875abd417a1a58949047dbefe60c9ef65235dae7ae697edba831689998f74e", [:gleam], [], "hexpm", "86949bf5d1f0e4ac0ab5b06f235d8a5cc11a2dfc33bf22f752156ed61ca7d0ff"},
"gleeunit": {:hex, :gleeunit, "1.9.0", "643b5099e5891d67c806a3228c846b1e4f600a733358fb3adf45eb616bb29d6f", [:gleam], [{:gleam_stdlib, ">= 0.60.0 and < 1.0.0", [hex: :gleam_stdlib, repo: "hexpm", optional: false]}], "hexpm", "da9553ce58b67924b3c631f96fe3370c49eb6d6dc6b384ec4862cc4aaa718f3c"},
"hackney": {:hex, :hackney, "1.25.0", "390e9b83f31e5b325b9f43b76e1a785cbdb69b5b6cd4e079aa67835ded046867", [:rebar3], [{:certifi, "~> 2.15.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~> 6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~> 1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~> 1.4", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.4.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~> 1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~> 0.7.1", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "7209bfd75fd1f42467211ff8f59ea74d6f2a9e81cbcee95a56711ee79fd6b1d4"},

View file

@ -0,0 +1,174 @@
/// Pure formatting functions shared by SnmpKit.SnmpLib.Types and Utils.
///
/// Provides number formatting, byte size formatting, rate formatting,
/// response time formatting, string truncation, and SNMP TimeTicks uptime.
///
/// Compiles to `:snmpkit@formatting`.
import gleam/int
import gleam/list
import gleam/string
// ---------------------------------------------------------------------------
// FFI declarations
// ---------------------------------------------------------------------------
/// Format a float to a string with N decimal places.
@external(erlang, "snmpkit_formatting_ffi", "format_float")
fn format_float(value: Float, decimals: Int) -> String
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/// Format a non-negative integer with comma thousand-separators.
///
/// Example: `1234567` `"1,234,567"`
pub fn format_number(number: Int) -> String {
case number < 0 {
True -> "-" <> format_positive_number(0 - number)
False -> format_positive_number(number)
}
}
/// Format bytes as a human-readable size string.
///
/// Uses binary units (1024-based): B, KB, MB, GB.
pub fn format_bytes(bytes: Int) -> String {
case bytes >= 1_073_741_824 {
True ->
format_float(int.to_float(bytes) /. 1_073_741_824.0, 1) <> " GB"
False ->
case bytes >= 1_048_576 {
True ->
format_float(int.to_float(bytes) /. 1_048_576.0, 1) <> " MB"
False ->
case bytes >= 1024 {
True ->
format_float(int.to_float(bytes) /. 1024.0, 1) <> " KB"
False -> int.to_string(bytes) <> " B"
}
}
}
}
/// Format a rate with SI-prefix units (K, M, G).
///
/// Example: `format_rate(1500000, "bps")` `"1.5 Mbps"`
pub fn format_rate(value: Int, unit: String) -> String {
case value >= 1_000_000_000 {
True ->
format_float(int.to_float(value) /. 1_000_000_000.0, 1) <> " G" <> unit
False ->
case value >= 1_000_000 {
True ->
format_float(int.to_float(value) /. 1_000_000.0, 1) <> " M" <> unit
False ->
case value >= 1_000 {
True ->
format_float(int.to_float(value) /. 1_000.0, 1) <> " K" <> unit
False -> int.to_string(value) <> " " <> unit
}
}
}
}
/// Format microseconds as a human-readable response time.
///
/// Example: `1500` `"1.50ms"`, `2500000` `"2.50s"`, `500` `"500μs"`
pub fn format_response_time(microseconds: Int) -> String {
case microseconds >= 1_000_000 {
True ->
format_float(int.to_float(microseconds) /. 1_000_000.0, 2) <> "s"
False ->
case microseconds >= 1_000 {
True ->
format_float(int.to_float(microseconds) /. 1_000.0, 2) <> "ms"
False -> int.to_string(microseconds) <> "μs"
}
}
}
/// Truncate a string to a maximum length, appending "..." if truncated.
///
/// For max_length <= 3, returns a plain slice with no ellipsis.
pub fn truncate_string(s: String, max_length: Int) -> String {
case max_length > 3 {
True ->
case string.length(s) <= max_length {
True -> s
False -> string.slice(s, 0, max_length - 3) <> "..."
}
False -> string.slice(s, 0, int.max(max_length, 0))
}
}
/// Format SNMP TimeTicks (centiseconds) as a human-readable uptime string.
///
/// Example: `9000` `"1 minute 30 seconds"`, `42` `"42 centiseconds"`
pub fn format_timeticks_uptime(centiseconds: Int) -> String {
let total_seconds = centiseconds / 100
let remaining_cs = centiseconds % 100
case total_seconds, remaining_cs {
0, 0 -> "0 centiseconds"
0, cs -> int.to_string(cs) <> " centiseconds"
_, _ -> build_time_parts(total_seconds, remaining_cs)
}
}
// ---------------------------------------------------------------------------
// Internal helpers
// ---------------------------------------------------------------------------
fn format_positive_number(number: Int) -> String {
number
|> int.to_string()
|> string.to_graphemes()
|> list.reverse()
|> list.sized_chunk(3)
|> list.map(fn(chunk) { chunk |> list.reverse() |> string.join("") })
|> list.reverse()
|> string.join(",")
}
fn build_time_parts(total_seconds: Int, centiseconds: Int) -> String {
let days = total_seconds / 86_400
let remaining = total_seconds % 86_400
let hours = remaining / 3600
let remaining = remaining % 3600
let minutes = remaining / 60
let seconds = remaining % 60
let parts = []
let parts = append_if_positive(parts, days, "day")
let parts = append_if_positive(parts, hours, "hour")
let parts = append_if_positive(parts, minutes, "minute")
let parts = append_if_positive(parts, seconds, "second")
let parts = append_if_positive(parts, centiseconds, "centisecond")
case parts {
[] -> "0 centiseconds"
_ -> string.join(parts, " ")
}
}
fn append_if_positive(
parts: List(String),
value: Int,
label: String,
) -> List(String) {
case value > 0 {
True ->
list.append(parts, [
int.to_string(value) <> " " <> label <> plural(value),
])
False -> parts
}
}
fn plural(n: Int) -> String {
case n {
1 -> ""
_ -> "s"
}
}

View file

@ -0,0 +1,221 @@
/// SNMP error handling and error code utilities.
///
/// Provides standardized error codes, retriability classification, severity levels,
/// and error formatting for SNMP operations (RFC 1157, RFC 3416).
import gleam/int
import gleam/list
import gleam/string
/// SNMP error status codes (RFC 1157, RFC 3416).
pub type ErrorStatus {
NoError
TooBig
NoSuchName
BadValue
ReadOnly
GenErr
NoAccess
WrongType
WrongLength
WrongEncoding
WrongValue
NoCreation
InconsistentValue
ResourceUnavailable
CommitFailed
UndoFailed
AuthorizationError
NotWritable
InconsistentName
UnknownError
}
/// Returns the human-readable name for an error status.
pub fn error_name(status: ErrorStatus) -> String {
case status {
NoError -> "no_error"
TooBig -> "too_big"
NoSuchName -> "no_such_name"
BadValue -> "bad_value"
ReadOnly -> "read_only"
GenErr -> "gen_err"
NoAccess -> "no_access"
WrongType -> "wrong_type"
WrongLength -> "wrong_length"
WrongEncoding -> "wrong_encoding"
WrongValue -> "wrong_value"
NoCreation -> "no_creation"
InconsistentValue -> "inconsistent_value"
ResourceUnavailable -> "resource_unavailable"
CommitFailed -> "commit_failed"
UndoFailed -> "undo_failed"
AuthorizationError -> "authorization_error"
NotWritable -> "not_writable"
InconsistentName -> "inconsistent_name"
UnknownError -> "unknown_error"
}
}
/// Returns the numeric code for an error status.
pub fn error_code(status: ErrorStatus) -> Int {
case status {
NoError -> 0
TooBig -> 1
NoSuchName -> 2
BadValue -> 3
ReadOnly -> 4
GenErr -> 5
NoAccess -> 6
WrongType -> 7
WrongLength -> 8
WrongEncoding -> 9
WrongValue -> 10
NoCreation -> 11
InconsistentValue -> 12
ResourceUnavailable -> 13
CommitFailed -> 14
UndoFailed -> 15
AuthorizationError -> 16
NotWritable -> 17
InconsistentName -> 18
UnknownError -> -1
}
}
/// Converts a numeric code to an ErrorStatus.
/// Unknown codes return UnknownError.
pub fn from_code(code: Int) -> ErrorStatus {
case code {
0 -> NoError
1 -> TooBig
2 -> NoSuchName
3 -> BadValue
4 -> ReadOnly
5 -> GenErr
6 -> NoAccess
7 -> WrongType
8 -> WrongLength
9 -> WrongEncoding
10 -> WrongValue
11 -> NoCreation
12 -> InconsistentValue
13 -> ResourceUnavailable
14 -> CommitFailed
15 -> UndoFailed
16 -> AuthorizationError
17 -> NotWritable
18 -> InconsistentName
_ -> UnknownError
}
}
/// Converts a string name to an ErrorStatus.
/// Unknown names return UnknownError.
pub fn from_name(name: String) -> ErrorStatus {
case name {
"no_error" -> NoError
"too_big" -> TooBig
"no_such_name" -> NoSuchName
"bad_value" -> BadValue
"read_only" -> ReadOnly
"gen_err" -> GenErr
"no_access" -> NoAccess
"wrong_type" -> WrongType
"wrong_length" -> WrongLength
"wrong_encoding" -> WrongEncoding
"wrong_value" -> WrongValue
"no_creation" -> NoCreation
"inconsistent_value" -> InconsistentValue
"resource_unavailable" -> ResourceUnavailable
"commit_failed" -> CommitFailed
"undo_failed" -> UndoFailed
"authorization_error" -> AuthorizationError
"not_writable" -> NotWritable
"inconsistent_name" -> InconsistentName
_ -> UnknownError
}
}
/// Returns True if the error is typically transient and worth retrying.
/// Retriable: TooBig, GenErr, ResourceUnavailable.
pub fn is_retriable(status: ErrorStatus) -> Bool {
case status {
TooBig -> True
GenErr -> True
ResourceUnavailable -> True
_ -> False
}
}
/// Categorizes error by severity level.
/// NoError -> "info", retriable errors -> "warning", everything else -> "error".
pub fn severity(status: ErrorStatus) -> String {
case status {
NoError -> "info"
TooBig -> "warning"
GenErr -> "warning"
ResourceUnavailable -> "warning"
_ -> "error"
}
}
/// Returns True if the code is a valid SNMP error code (0-18 inclusive).
pub fn is_valid_code(code: Int) -> Bool {
code >= 0 && code <= 18
}
/// Returns all standard SNMP error codes (0 through 18).
pub fn all_codes() -> List(Int) {
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18]
}
/// Formats an SNMP error for human-readable display.
///
/// Produces a string like "SNMP Error: no_such_name (2) at index 1".
/// If the varbind at error_index exists, appends " - OID: 1.3.6.1.2.1.1.1.0".
///
/// The varbinds parameter is a list of #(List(Int), Dynamic) tuples where
/// the first element is the OID as a list of integers.
pub fn format_error(
status: ErrorStatus,
error_index: Int,
varbinds: List(#(List(Int), a)),
) -> String {
let name = error_name(status)
let code = error_code(status)
let base_msg =
"SNMP Error: "
<> name
<> " ("
<> int.to_string(code)
<> ") at index "
<> int.to_string(error_index)
case get_varbind_at(varbinds, error_index) {
Ok(#(oid, _value)) -> {
let oid_str =
oid
|> list.map(int.to_string)
|> string.join(".")
base_msg <> " - OID: " <> oid_str
}
Error(Nil) -> base_msg
}
}
/// Retrieves the varbind at a 1-based error_index from the list.
fn get_varbind_at(
varbinds: List(#(List(Int), a)),
error_index: Int,
) -> Result(#(List(Int), a), Nil) {
case error_index > 0 {
True -> {
let zero_index = error_index - 1
case list.drop(varbinds, zero_index) {
[varbind, ..] -> Ok(varbind)
[] -> Error(Nil)
}
}
False -> Error(Nil)
}
}

View file

@ -0,0 +1,394 @@
/// Comprehensive OID (Object Identifier) manipulation utilities for SNMP operations.
///
/// Provides string/list conversions, tree operations, table utilities, and validation
/// functions needed by both SNMP managers and simulators.
import gleam/int
import gleam/list
import gleam/order.{type Order}
import gleam/result
import gleam/string
/// Error types for OID operations.
pub type OidError {
EmptyOid
InvalidOidString
InvalidComponent
InvalidInput
RootOid
EndOfMib
InvalidTableInstance
UnsupportedSyntax
InvalidStringLength
InvalidStringIndex
SyntaxValueMismatch
NotEnterpriseOid
}
// Standard SNMP OID prefixes
const iso_org_dod_internet = [1, 3, 6, 1]
const mgmt = [1, 3, 6, 1, 2]
const mib_2_prefix = [1, 3, 6, 1, 2, 1]
const enterprises_prefix = [1, 3, 6, 1, 4, 1]
const experimental_prefix = [1, 3, 6, 1, 3]
const private_prefix = [1, 3, 6, 1, 4]
// ---- String/List Conversions ----
/// Converts an OID string to a list of integers.
/// Supports both "1.3.6.1" and ".1.3.6.1" formats.
pub fn string_to_list(oid_string: String) -> Result(List(Int), OidError) {
use normalized <- result.try(normalize_oid_string(oid_string))
let parts = string.split(normalized, ".")
use oid_list <- result.try(parse_oid_components(parts))
use _ <- result.try(validate_oid_list(oid_list))
Ok(oid_list)
}
/// Converts an OID list to a dot-separated string.
pub fn list_to_string(oid_list: List(Int)) -> Result(String, OidError) {
use _ <- result.try(validate_oid_list(oid_list))
let oid_string =
oid_list
|> list.map(safe_int_to_string)
|> string.join(".")
Ok(oid_string)
}
// ---- Tree Operations ----
/// Checks if one OID is a child of another.
pub fn child_of(
child_oid: List(Int),
parent_oid: List(Int),
) -> Bool {
let child_length = list.length(child_oid)
let parent_length = list.length(parent_oid)
child_length > parent_length
&& list.take(child_oid, parent_length) == parent_oid
}
/// Checks if one OID is a parent of another.
pub fn parent_of(
parent_oid: List(Int),
child_oid: List(Int),
) -> Bool {
child_of(child_oid, parent_oid)
}
/// Gets the parent OID by removing the last component.
pub fn get_parent(oid: List(Int)) -> Result(List(Int), OidError) {
case list.length(oid) > 1 {
True -> {
let parent = list.take(oid, list.length(oid) - 1)
Ok(parent)
}
False -> Error(RootOid)
}
}
/// Gets the immediate children prefix for an OID in a given set.
pub fn get_children(
parent_oid: List(Int),
oid_set: List(List(Int)),
) -> List(List(Int)) {
let parent_length = list.length(parent_oid)
oid_set
|> list.filter(fn(oid) { child_of(oid, parent_oid) })
|> list.map(fn(oid) { list.take(oid, parent_length + 1) })
|> list.unique()
}
/// Gets the next OID in lexicographic order from a given set.
pub fn get_next_oid(
current_oid: List(Int),
oid_set: List(List(Int)),
) -> Result(List(Int), OidError) {
case list.find(oid_set, fn(oid) { compare_oids(oid, current_oid) == order.Gt }) {
Ok(next_oid) -> Ok(next_oid)
Error(_) -> Error(EndOfMib)
}
}
// ---- Comparison Operations ----
/// Compares two OIDs lexicographically.
pub fn compare_oids(oid1: List(Int), oid2: List(Int)) -> Order {
compare_components(oid1, oid2)
}
/// Sorts a list of OIDs in lexicographic order.
pub fn sort_oids(oid_list: List(List(Int))) -> List(List(Int)) {
list.sort(oid_list, fn(a, b) { compare_oids(a, b) })
}
// ---- Table Operations ----
/// Extracts table index from an instance OID given the table column OID.
pub fn extract_table_index(
table_oid: List(Int),
instance_oid: List(Int),
) -> Result(List(Int), OidError) {
let table_length = list.length(table_oid)
let instance_length = list.length(instance_oid)
case
instance_length > table_length
&& list.take(instance_oid, table_length) == table_oid
{
True -> {
let index = list.drop(instance_oid, table_length)
Ok(index)
}
False -> Error(InvalidTableInstance)
}
}
/// Builds a table instance OID from table OID and index.
pub fn build_table_instance(
table_oid: List(Int),
index: List(Int),
) -> Result(List(Int), OidError) {
use _ <- result.try(validate_oid_list(table_oid))
use _ <- result.try(validate_oid_list(index))
Ok(list.append(table_oid, index))
}
/// Parse an integer table index (single element list).
pub fn parse_integer_index(index: List(Int)) -> Result(Int, OidError) {
case index {
[value] -> Ok(value)
_ -> Error(UnsupportedSyntax)
}
}
/// Parse a fixed-length string table index.
pub fn parse_fixed_string_index(
index: List(Int),
length: Int,
) -> Result(String, OidError) {
case list.length(index) == length {
True -> build_string_from_bytes(index)
False -> Error(UnsupportedSyntax)
}
}
/// Parse a variable-length (length-prefixed) string table index.
pub fn parse_variable_string_index(
index: List(Int),
) -> Result(String, OidError) {
case index {
[length, ..rest] ->
case list.length(rest) == length {
True -> build_string_from_bytes(rest)
False -> Error(UnsupportedSyntax)
}
_ -> Error(UnsupportedSyntax)
}
}
/// Build an integer table index.
pub fn build_integer_index(value: Int) -> Result(List(Int), OidError) {
case value >= 0 {
True -> Ok([value])
False -> Error(UnsupportedSyntax)
}
}
/// Build a fixed-length string table index.
pub fn build_fixed_string_index(
value: String,
length: Int,
) -> Result(List(Int), OidError) {
let chars = string_to_charlist(value)
case list.length(chars) == length {
True -> Ok(chars)
False -> Error(InvalidStringLength)
}
}
/// Build a variable-length string table index.
pub fn build_variable_string_index(
value: String,
) -> Result(List(Int), OidError) {
let chars = string_to_charlist(value)
let length = list.length(chars)
Ok([length, ..chars])
}
// ---- Validation ----
/// Validates an OID list for correctness.
pub fn validate(oid: List(Int)) -> Result(Nil, OidError) {
validate_oid_list(oid)
}
/// Normalizes a string OID to a list.
pub fn normalize_string(oid: String) -> Result(List(Int), OidError) {
string_to_list(oid)
}
/// Normalizes a list OID (validates it).
pub fn normalize_list(oid: List(Int)) -> Result(List(Int), OidError) {
use _ <- result.try(validate_oid_list(oid))
Ok(oid)
}
// ---- Standard Prefix Accessors ----
pub fn internet() -> List(Int) {
iso_org_dod_internet
}
pub fn mgmt_prefix() -> List(Int) {
mgmt
}
pub fn mib_2() -> List(Int) {
mib_2_prefix
}
pub fn enterprises() -> List(Int) {
enterprises_prefix
}
pub fn experimental() -> List(Int) {
experimental_prefix
}
pub fn private() -> List(Int) {
private_prefix
}
/// Returns a standard prefix by name atom (for Elixir interop).
pub fn standard_prefix(name: String) -> Result(List(Int), Nil) {
case name {
"internet" -> Ok(iso_org_dod_internet)
"mgmt" -> Ok(mgmt)
"mib_2" -> Ok(mib_2_prefix)
"enterprises" -> Ok(enterprises_prefix)
"experimental" -> Ok(experimental_prefix)
"private" -> Ok(private_prefix)
_ -> Error(Nil)
}
}
// ---- Tree Predicates ----
pub fn is_mib_2(oid: List(Int)) -> Bool {
child_of(oid, mib_2_prefix) || oid == mib_2_prefix
}
pub fn is_enterprise(oid: List(Int)) -> Bool {
child_of(oid, enterprises_prefix)
}
pub fn is_experimental(oid: List(Int)) -> Bool {
child_of(oid, experimental_prefix)
}
pub fn is_private(oid: List(Int)) -> Bool {
child_of(oid, private_prefix)
}
/// Gets the enterprise number from an enterprise OID.
pub fn get_enterprise_number(oid: List(Int)) -> Result(Int, OidError) {
let enterprises_len = list.length(enterprises_prefix)
case is_enterprise(oid) && list.length(oid) > enterprises_len {
True -> {
// The element at index `enterprises_len` is the enterprise number
case list.drop(oid, enterprises_len) {
[num, ..] -> Ok(num)
_ -> Error(NotEnterpriseOid)
}
}
False -> Error(NotEnterpriseOid)
}
}
// ---- Private Helpers ----
fn normalize_oid_string(oid_string: String) -> Result(String, OidError) {
let trimmed = string.trim(oid_string)
case trimmed {
"" -> Error(EmptyOid)
_ ->
case string.starts_with(trimmed, ".") {
True -> {
let normalized = string.drop_start(trimmed, 1)
case normalized {
"" -> Error(EmptyOid)
_ -> Ok(normalized)
}
}
False -> Ok(trimmed)
}
}
}
fn parse_oid_components(
parts: List(String),
) -> Result(List(Int), OidError) {
list.try_map(parts, fn(part) {
case int.parse(part) {
Ok(num) if num >= 0 -> Ok(num)
_ -> Error(InvalidOidString)
}
})
}
fn validate_oid_list(oid_list: List(Int)) -> Result(Nil, OidError) {
case oid_list {
[] -> Error(EmptyOid)
_ ->
case list.all(oid_list, fn(c) { is_non_neg_integer(c) }) {
True -> Ok(Nil)
False -> Error(InvalidComponent)
}
}
}
/// Runtime check that a value is a non-negative integer.
/// Needed because Elixir callers may pass non-integer values in lists.
@external(erlang, "snmpkit_oid_ffi", "is_non_neg_integer")
fn is_non_neg_integer(value: Int) -> Bool
fn compare_components(oid1: List(Int), oid2: List(Int)) -> Order {
case oid1, oid2 {
[], [] -> order.Eq
[], _ -> order.Lt
_, [] -> order.Gt
[h1, ..t1], [h2, ..t2] ->
case int.compare(h1, h2) {
order.Eq -> compare_components(t1, t2)
other -> other
}
}
}
fn build_string_from_bytes(bytes: List(Int)) -> Result(String, OidError) {
bytes
|> list.try_map(fn(b) {
string.utf_codepoint(b)
|> result.replace_error(InvalidStringIndex)
})
|> result.map(string.from_utf_codepoints)
}
fn string_to_charlist(s: String) -> List(Int) {
s
|> string.to_utf_codepoints()
|> list.map(string.utf_codepoint_to_int)
}
/// Safe int.to_string that won't crash on non-integer BEAM values.
/// After validation passes, this is guaranteed to receive integers.
@external(erlang, "erlang", "integer_to_binary")
fn safe_int_to_string(value: Int) -> String

View file

@ -0,0 +1,203 @@
/// Constants and utility functions for SNMP PDU operations.
///
/// Provides ASN.1 tag values for PDU types and data types, SNMPv3 constants,
/// and conversion functions for version normalization, OID parsing, and
/// message flag encoding/decoding.
import gleam/int
import gleam/list
import gleam/string
// ---- PDU Type Tags ----
/// GET request PDU tag (0xA0).
pub fn get_request_tag() -> Int {
160
}
/// GETNEXT request PDU tag (0xA1).
pub fn getnext_request_tag() -> Int {
161
}
/// GET response PDU tag (0xA2).
pub fn get_response_tag() -> Int {
162
}
/// SET request PDU tag (0xA3).
pub fn set_request_tag() -> Int {
163
}
/// GETBULK request PDU tag (0xA5).
pub fn getbulk_request_tag() -> Int {
165
}
// ---- Data Type Tags ----
/// ASN.1 INTEGER tag (0x02).
pub fn integer_tag() -> Int {
2
}
/// ASN.1 OCTET STRING tag (0x04).
pub fn octet_string_tag() -> Int {
4
}
/// ASN.1 NULL tag (0x05).
pub fn null_tag() -> Int {
5
}
/// ASN.1 OBJECT IDENTIFIER tag (0x06).
pub fn object_identifier_tag() -> Int {
6
}
/// SNMP Counter32 tag (0x41).
pub fn counter32_tag() -> Int {
65
}
/// SNMP Gauge32 tag (0x42).
pub fn gauge32_tag() -> Int {
66
}
/// SNMP TimeTicks tag (0x43).
pub fn timeticks_tag() -> Int {
67
}
/// SNMP Counter64 tag (0x46).
pub fn counter64_tag() -> Int {
70
}
/// SNMP IpAddress tag (0x40).
pub fn ip_address_tag() -> Int {
64
}
/// SNMP Opaque type tag (0x44).
pub fn opaque_type_tag() -> Int {
68
}
/// SNMP noSuchObject exception tag (0x80).
pub fn no_such_object_tag() -> Int {
128
}
/// SNMP noSuchInstance exception tag (0x81).
pub fn no_such_instance_tag() -> Int {
129
}
/// SNMP endOfMibView exception tag (0x82).
pub fn end_of_mib_view_tag() -> Int {
130
}
// ---- SNMPv3 Constants ----
/// USM security model identifier.
pub fn usm_security_model() -> Int {
3
}
/// Default maximum SNMP message size (65507 bytes).
pub fn default_max_message_size() -> Int {
65_507
}
// ---- Conversion Functions ----
/// Normalizes an SNMP version from atom or integer to its integer wire value.
///
/// Accepts Dynamic input since Elixir callers pass atoms (:v1, :v2c, :v2, :v3)
/// or integer values. Returns the integer version number used in SNMP messages.
///
/// - :v1 -> 0
/// - :v2c -> 1
/// - :v2 -> 1
/// - :v3 -> 3
/// - integers pass through unchanged
/// - anything else -> 0
@external(erlang, "snmpkit_pdu_constants_ffi", "normalize_version")
pub fn normalize_version(version: dynamic) -> Int
/// Parses a dotted-decimal OID string into a list of integers.
///
/// For example, "1.3.6.1.2.1" becomes [1, 3, 6, 1, 2, 1].
/// Returns [1, 3, 6, 1] as a safe default for invalid strings
/// (containing non-numeric parts or negative numbers).
pub fn normalize_oid_string(oid: String) -> List(Int) {
let default_oid = [1, 3, 6, 1]
let parts = string.split(oid, ".")
case parts {
[] -> default_oid
_ ->
case parse_all_parts(parts) {
Ok(parsed) -> parsed
Error(Nil) -> default_oid
}
}
}
/// Encodes SNMPv3 message flags into a single byte value.
///
/// - auth: bit 0 (0x01)
/// - priv: bit 1 (0x02)
/// - reportable: bit 2 (0x04)
///
/// The Elixir wrapper is responsible for creating the binary <<flags>>.
pub fn encode_msg_flags(auth: Bool, priv: Bool, reportable: Bool) -> Int {
let flags = 0
let flags = case auth {
True -> bor(flags, 1)
False -> flags
}
let flags = case priv {
True -> bor(flags, 2)
False -> flags
}
case reportable {
True -> bor(flags, 4)
False -> flags
}
}
/// Decodes a single byte into SNMPv3 message flags.
///
/// Returns a tuple of (auth, priv, reportable) booleans.
pub fn decode_msg_flags(flags: Int) -> #(Bool, Bool, Bool) {
let auth = band(flags, 1) != 0
let priv = band(flags, 2) != 0
let reportable = band(flags, 4) != 0
#(auth, priv, reportable)
}
// ---- Private Helpers ----
/// Parse all parts of a dotted OID string into integers.
/// Returns Error if any part is not a valid non-negative integer.
fn parse_all_parts(parts: List(String)) -> Result(List(Int), Nil) {
list.try_map(parts, fn(part) {
case int.parse(part) {
Ok(num) if num >= 0 -> Ok(num)
_ -> Error(Nil)
}
})
}
// Bitwise operations via Erlang BIFs
@external(erlang, "erlang", "band")
fn band(a: Int, b: Int) -> Int
@external(erlang, "erlang", "bor")
fn bor(a: Int, b: Int) -> Int

29
src/snmpkit_error_ffi.erl Normal file
View file

@ -0,0 +1,29 @@
-module(snmpkit_error_ffi).
-export([atom_to_error_status/1, error_status_to_atom/1]).
%% Convert Elixir atom to Gleam ErrorStatus.
%% Gleam zero-field variants compile to bare atoms.
atom_to_error_status(no_error) -> no_error;
atom_to_error_status(too_big) -> too_big;
atom_to_error_status(no_such_name) -> no_such_name;
atom_to_error_status(bad_value) -> bad_value;
atom_to_error_status(read_only) -> read_only;
atom_to_error_status(gen_err) -> gen_err;
atom_to_error_status(no_access) -> no_access;
atom_to_error_status(wrong_type) -> wrong_type;
atom_to_error_status(wrong_length) -> wrong_length;
atom_to_error_status(wrong_encoding) -> wrong_encoding;
atom_to_error_status(wrong_value) -> wrong_value;
atom_to_error_status(no_creation) -> no_creation;
atom_to_error_status(inconsistent_value) -> inconsistent_value;
atom_to_error_status(resource_unavailable) -> resource_unavailable;
atom_to_error_status(commit_failed) -> commit_failed;
atom_to_error_status(undo_failed) -> undo_failed;
atom_to_error_status(authorization_error) -> authorization_error;
atom_to_error_status(not_writable) -> not_writable;
atom_to_error_status(inconsistent_name) -> inconsistent_name;
atom_to_error_status(_) -> unknown_error.
%% Convert Gleam ErrorStatus back to Elixir atom.
%% Identity since both are bare atoms.
error_status_to_atom(Status) -> Status.

View file

@ -0,0 +1,6 @@
-module(snmpkit_formatting_ffi).
-export([format_float/2]).
%% Format a float to a string with the specified number of decimal places.
format_float(Value, Decimals) when is_float(Value), is_integer(Decimals) ->
erlang:float_to_binary(Value, [{decimals, Decimals}]).

5
src/snmpkit_oid_ffi.erl Normal file
View file

@ -0,0 +1,5 @@
-module(snmpkit_oid_ffi).
-export([is_non_neg_integer/1]).
is_non_neg_integer(Value) when is_integer(Value), Value >= 0 -> true;
is_non_neg_integer(_) -> false.

View file

@ -0,0 +1,11 @@
-module(snmpkit_pdu_constants_ffi).
-export([normalize_version/1]).
%% Normalize SNMP version atoms/integers to wire-format integer values.
%% Handles the atom/integer polymorphism that Gleam cannot express.
normalize_version(v1) -> 0;
normalize_version(v2c) -> 1;
normalize_version(v2) -> 1;
normalize_version(v3) -> 3;
normalize_version(V) when is_integer(V) -> V;
normalize_version(_) -> 0.

View file

@ -0,0 +1,218 @@
/// Simple regex-based User-Agent parser for extracting browser, OS, and device info.
import gleam/dynamic.{type Dynamic}
import gleam/dynamic/decode
import gleam/option.{type Option, None, Some}
import gleam/regexp.{type Regexp, Match}
import gleam/string
/// Parsed user agent result.
pub type ParseResult {
ParseResult(
browser_name: String,
browser_version: Option(String),
os_name: String,
os_version: Option(String),
device_type: String,
device_name: String,
)
}
/// Parse a user agent (Dynamic to handle nil from Elixir).
/// Returns an Elixir-compatible atom-keyed map via FFI.
pub fn parse(value: Dynamic) -> Dynamic {
let result = case decode.run(value, decode.string) {
Error(_) -> unknown()
Ok(ua) ->
case ua {
"" -> unknown()
_ -> parse_ua(ua)
}
}
to_elixir_map(result)
}
fn parse_ua(ua: String) -> ParseResult {
let browser = detect_browser(ua)
let os = detect_os(ua)
let device_type = detect_device_type(ua, os.os_name)
ParseResult(
browser_name: browser.browser_name,
browser_version: browser.browser_version,
os_name: os.os_name,
os_version: os.os_version,
device_type: device_type,
device_name: browser.browser_name <> " on " <> os.os_name,
)
}
fn unknown() -> ParseResult {
ParseResult(
browser_name: "Unknown",
browser_version: None,
os_name: "Unknown",
os_version: None,
device_type: "desktop",
device_name: "Unknown on Unknown",
)
}
// --- Browser detection ---
type BrowserInfo {
BrowserInfo(browser_name: String, browser_version: Option(String))
}
fn detect_browser(ua: String) -> BrowserInfo {
let assert Ok(opera_re) = regexp.from_string("OPR\\/(\\d+\\.\\d+)")
let assert Ok(opera_legacy_re) = regexp.from_string("Opera\\/(\\d+\\.\\d+)")
let assert Ok(edge_re) = regexp.from_string("Edg(?:e|A|iOS)?\\/(\\d+\\.\\d+)")
let assert Ok(chrome_re) = regexp.from_string("Chrome\\/(\\d+\\.\\d+)")
let assert Ok(firefox_re) = regexp.from_string("Firefox\\/(\\d+\\.\\d+)")
let assert Ok(safari_check_re) = regexp.from_string("Safari\\/")
let assert Ok(safari_ver_re) = regexp.from_string("Version\\/(\\d+\\.\\d+)")
case regexp.check(opera_re, ua) || regexp.check(opera_legacy_re, ua) {
True -> {
let assert Ok(combined_re) =
regexp.from_string("(?:OPR|Opera)\\/(\\d+\\.\\d+)")
BrowserInfo("Opera", extract_version(ua, combined_re))
}
False ->
case regexp.check(edge_re, ua) {
True -> BrowserInfo("Edge", extract_version(ua, edge_re))
False ->
case regexp.check(chrome_re, ua) {
True -> BrowserInfo("Chrome", extract_version(ua, chrome_re))
False ->
case regexp.check(firefox_re, ua) {
True ->
BrowserInfo("Firefox", extract_version(ua, firefox_re))
False ->
case
regexp.check(safari_check_re, ua)
&& regexp.check(safari_ver_re, ua)
{
True ->
BrowserInfo("Safari", extract_version(ua, safari_ver_re))
False -> BrowserInfo("Unknown", None)
}
}
}
}
}
}
// --- OS detection ---
type OsInfo {
OsInfo(os_name: String, os_version: Option(String))
}
fn detect_os(ua: String) -> OsInfo {
let assert Ok(ios_re) = regexp.from_string("iPhone|iPad|iPod")
let assert Ok(mac_re) = regexp.from_string("Macintosh|Mac OS X")
let assert Ok(android_re) = regexp.from_string("Android")
let assert Ok(windows_re) = regexp.from_string("Windows")
let assert Ok(linux_re) = regexp.from_string("Linux")
case regexp.check(ios_re, ua) {
True -> {
let assert Ok(ios_ver_re) = regexp.from_string("OS (\\d+[_\\d]*)")
let version = extract_version(ua, ios_ver_re)
OsInfo("iOS", format_underscored_version(version))
}
False ->
case regexp.check(mac_re, ua) {
True -> {
let assert Ok(mac_ver_re) =
regexp.from_string("Mac OS X (\\d+[_\\d]*)")
let version = extract_version(ua, mac_ver_re)
OsInfo("macOS", format_underscored_version(version))
}
False ->
case regexp.check(android_re, ua) {
True -> {
let assert Ok(android_ver_re) =
regexp.from_string("Android (\\d+\\.\\d+)")
OsInfo("Android", extract_version(ua, android_ver_re))
}
False ->
case regexp.check(windows_re, ua) {
True -> OsInfo("Windows", detect_windows_version(ua))
False ->
case regexp.check(linux_re, ua) {
True -> OsInfo("Linux", None)
False -> OsInfo("Unknown", None)
}
}
}
}
}
}
fn detect_windows_version(ua: String) -> Option(String) {
let assert Ok(win10_re) = regexp.from_string("Windows NT 10\\.0")
let assert Ok(win81_re) = regexp.from_string("Windows NT 6\\.3")
let assert Ok(win8_re) = regexp.from_string("Windows NT 6\\.2")
let assert Ok(win7_re) = regexp.from_string("Windows NT 6\\.1")
let assert Ok(win_nt_re) = regexp.from_string("Windows NT (\\d+\\.\\d+)")
case regexp.check(win10_re, ua) {
True -> Some("10")
False ->
case regexp.check(win81_re, ua) {
True -> Some("8.1")
False ->
case regexp.check(win8_re, ua) {
True -> Some("8")
False ->
case regexp.check(win7_re, ua) {
True -> Some("7")
False ->
case regexp.check(win_nt_re, ua) {
True -> extract_version(ua, win_nt_re)
False -> None
}
}
}
}
}
}
// --- Device type detection ---
fn detect_device_type(ua: String, os_name: String) -> String {
let assert Ok(ipad_re) = regexp.from_string("iPad")
let assert Ok(android_tablet_re) = regexp.from_string("Android.*Tablet")
let assert Ok(mobile_re) =
regexp.from_string("iPhone|iPod|Android.*Mobile|Mobile")
case regexp.check(ipad_re, ua) || regexp.check(android_tablet_re, ua) {
True -> "tablet"
False ->
case regexp.check(mobile_re, ua) || os_name == "iOS" {
True -> "mobile"
False -> "desktop"
}
}
}
// --- Helpers ---
fn extract_version(ua: String, re: Regexp) -> Option(String) {
case regexp.scan(re, ua) {
[Match(submatches: [Some(version), ..], ..)] -> Some(version)
_ -> None
}
}
fn format_underscored_version(version: Option(String)) -> Option(String) {
case version {
None -> None
Some(v) -> Some(string.replace(v, "_", "."))
}
}
@external(erlang, "towerops_user_agent_parser_ffi", "to_map")
fn to_elixir_map(result: ParseResult) -> Dynamic

View file

@ -0,0 +1,139 @@
/// Pure-function portions of the MikroTik backup Differ.
///
/// Handles sensitive-data masking and diff statistics calculation.
/// Impure operations (generate_unified_diff) remain in the Elixir module.
///
/// Compiles to `:towerops@devices@mikrotik_backups@differ`.
import gleam/int
import gleam/list
import gleam/string
// ---------------------------------------------------------------------------
// FFI declarations
// ---------------------------------------------------------------------------
/// Compute the first 8 lowercase hex characters of the SHA256 hash of a value.
@external(erlang, "towerops_differ_ffi", "sha256_short")
pub fn sha256_short(value: String) -> String
/// Replace regex matches where capture group 1 is a prefix to keep and
/// capture group 2 is the sensitive value to mask. The `caseless` flag
/// controls case-insensitive matching.
@external(erlang, "towerops_differ_ffi", "mask_pattern")
fn mask_pattern(pattern: String, config: String, caseless: Bool) -> String
/// Same as `mask_pattern` but values present in `skip_values` are left
/// unmasked.
@external(erlang, "towerops_differ_ffi", "mask_pattern_skip")
fn mask_pattern_skip(
pattern: String,
config: String,
caseless: Bool,
skip_values: List(String),
) -> String
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/// Masks sensitive data in a MikroTik configuration export.
///
/// Chains through several masking operations in order:
/// 1. Passwords (`password=VALUE`, case-insensitive)
/// 2. SNMP communities (set identifier and name parameter)
/// 3. IPsec secrets (`secret=VALUE`, case-insensitive)
/// 4. Wireless keys (`wpa-pre-shared-key=VALUE`, `wpa2-pre-shared-key=VALUE`,
/// case-insensitive)
///
/// Each sensitive value is replaced with `***MASKED:HASH***` where HASH is the
/// first 8 hex characters (lowercase) of the SHA256 hash of the original value.
pub fn mask_sensitive_data(config_text: String) -> String {
config_text
|> mask_passwords
|> mask_snmp_communities
|> mask_ipsec_secrets
|> mask_wireless_keys
}
/// Parse a unified diff string and return `#(additions, deletions, changes)`.
///
/// - `additions` = lines starting with "+" but NOT "+++"
/// - `deletions` = lines starting with "-" but NOT "---"
/// - `changes` = `min(additions, deletions)`
pub fn calculate_diff_stats(diff: String) -> #(Int, Int, Int) {
let lines = string.split(diff, "\n")
let additions =
lines
|> list.filter(fn(line) {
string.starts_with(line, "+") && !string.starts_with(line, "+++")
})
|> list.length
let deletions =
lines
|> list.filter(fn(line) {
string.starts_with(line, "-") && !string.starts_with(line, "---")
})
|> list.length
let changes = int.min(additions, deletions)
#(additions, deletions, changes)
}
// ---------------------------------------------------------------------------
// Internal masking functions
// ---------------------------------------------------------------------------
/// Mask `password=VALUE` patterns (case-insensitive).
fn mask_passwords(config: String) -> String {
mask_pattern("(password=)(\\S+)", config, True)
}
/// Mask SNMP community strings via two sub-patterns.
fn mask_snmp_communities(config: String) -> String {
config
|> mask_snmp_set_identifier
|> mask_snmp_name_parameter
}
/// Mask the identifier in `/snmp community set IDENTIFIER ...`.
/// Skips "public" and "private" (common defaults).
fn mask_snmp_set_identifier(config: String) -> String {
mask_pattern_skip(
"(\\/snmp\\s+community\\s+set\\s+)(\\S+)",
config,
False,
["public", "private"],
)
}
/// Mask the `name=VALUE` parameter in `/snmp community ... name=VALUE`.
/// Skips "public" and "private" (common defaults).
fn mask_snmp_name_parameter(config: String) -> String {
mask_pattern_skip(
"(\\/snmp\\s+community.*?name=)(\\S+)",
config,
False,
["public", "private"],
)
}
/// Mask `secret=VALUE` patterns (case-insensitive).
fn mask_ipsec_secrets(config: String) -> String {
mask_pattern("(secret=)(\\S+)", config, True)
}
/// Mask wireless WPA and WPA2 pre-shared key patterns (case-insensitive).
fn mask_wireless_keys(config: String) -> String {
config
|> mask_wpa_key("wpa-pre-shared-key")
|> mask_wpa_key("wpa2-pre-shared-key")
}
/// Mask a single wireless key pattern: `KEY_NAME=VALUE` (case-insensitive).
fn mask_wpa_key(config: String, key_name: String) -> String {
let pattern = "(" <> key_name <> "=)(\\S+)"
mask_pattern(pattern, config, True)
}

View file

@ -0,0 +1,130 @@
import gleam/int
import gleam/list
import gleam/order.{type Order}
import gleam/string
/// Compares semantic version strings.
///
/// Supports versions with 0-3 parts (e.g., "7", "7.14", "7.14.1").
/// Versions with 4+ parts are considered invalid and fall back to #(0, 0, 0).
///
/// ## Parsing Rules
///
/// - Strips "v" or "V" prefix
/// - Trims whitespace
/// - Splits on "-" or " " and ignores suffixes (e.g., "7.14.1-beta" -> "7.14.1")
/// - Pads missing parts with 0 (e.g., "7.14" -> #(7, 14, 0))
/// - 0-3 parts: VALID
/// - 4+ parts: INVALID -> fallback to #(0, 0, 0)
/// - Non-numeric parts: INVALID
/// - Negative numbers: INVALID
/// - Leading zeros: VALID (e.g., "007.014.001" -> #(7, 14, 1))
/// Compares two version strings.
///
/// Returns `order.Lt` if the first version is older,
/// `order.Gt` if newer, or `order.Eq` if equal.
pub fn compare(version1: String, version2: String) -> Order {
let v1 = parse_version(version1)
let v2 = parse_version(version2)
compare_tuples(v1, v2)
}
/// Returns True if the second version is newer than the first.
pub fn newer(current_version: String, new_version: String) -> Bool {
compare(current_version, new_version) == order.Lt
}
fn compare_tuples(
v1: #(Int, Int, Int),
v2: #(Int, Int, Int),
) -> Order {
case int.compare(v1.0, v2.0) {
order.Eq ->
case int.compare(v1.1, v2.1) {
order.Eq -> int.compare(v1.2, v2.2)
other -> other
}
other -> other
}
}
fn parse_version(version: String) -> #(Int, Int, Int) {
let invalid = #(0, 0, 0)
let trimmed = string.trim(version)
let stripped = strip_prefix(trimmed)
let base = strip_suffix(stripped)
case base {
"" -> invalid
_ -> {
let parts = string.split(base, ".")
case list.length(parts) > 3 {
True -> invalid
False -> parse_parts(parts)
}
}
}
}
fn strip_prefix(version: String) -> String {
case version {
"v" <> rest -> rest
"V" <> rest -> rest
_ -> version
}
}
/// Strip suffixes after "-" or " ".
/// Gleam's string.split only takes a single delimiter, so we split
/// on "-" first, take the first part, then split on " " and take the first.
fn strip_suffix(version: String) -> String {
let after_dash = case string.split_once(version, "-") {
Ok(#(before, _)) -> before
Error(_) -> version
}
case string.split_once(after_dash, " ") {
Ok(#(before, _)) -> before
Error(_) -> after_dash
}
}
fn parse_parts(parts: List(String)) -> #(Int, Int, Int) {
let invalid = #(0, 0, 0)
case parse_all_parts(parts) {
Error(_) -> invalid
Ok(numbers) -> pad_to_three(numbers)
}
}
fn parse_all_parts(parts: List(String)) -> Result(List(Int), Nil) {
case parts {
[] -> Ok([])
[first, ..rest] ->
case int.parse(first) {
Error(_) -> Error(Nil)
Ok(n) ->
case n < 0 {
True -> Error(Nil)
False ->
case parse_all_parts(rest) {
Error(_) -> Error(Nil)
Ok(parsed) -> Ok([n, ..parsed])
}
}
}
}
}
fn pad_to_three(numbers: List(Int)) -> #(Int, Int, Int) {
case numbers {
[a, b, c] -> #(a, b, c)
[a, b] -> #(a, b, 0)
[a] -> #(a, 0, 0)
// Should never happen given we validated length <= 3 and > 0,
// but handle gracefully
_ -> #(0, 0, 0)
}
}

View file

@ -0,0 +1,96 @@
/// Simple MIB file parser for extracting OID definitions.
///
/// Extracts OID assignments from MIB file content for validation purposes.
/// Not a full ASN.1 parser - just enough to validate hardcoded OIDs
/// against official MIB definitions.
import gleam/dict.{type Dict}
import gleam/list
import gleam/option.{Some}
import gleam/regexp
/// Parse MIB content string and extract OID definitions.
/// Returns a map of object names to OID strings.
pub fn parse_mib_content(content: String) -> Dict(String, String) {
let assignments = extract_assignments(content)
resolve_oids(assignments)
}
fn extract_assignments(content: String) -> Dict(String, #(String, String)) {
// Remove comments (lines starting with --)
let assert Ok(comment_re) =
regexp.compile(
"--.*$",
regexp.Options(case_insensitive: False, multi_line: True),
)
let cleaned = regexp.replace(in: content, each: comment_re, with: "")
// Match assignment patterns: name OBJECT-TYPE|OBJECT IDENTIFIER ::= { parent index }
let assert Ok(assignment_re) =
regexp.from_string(
"(\\w+)\\s+(?:OBJECT-TYPE|OBJECT\\s+IDENTIFIER)[^:]*::=\\s*\\{\\s*(\\w+)\\s+(\\d+)\\s*\\}",
)
regexp.scan(assignment_re, cleaned)
|> list.filter_map(fn(m) {
case m.submatches {
[Some(name), Some(parent), Some(index)] -> Ok(#(name, #(parent, index)))
_ -> Error(Nil)
}
})
|> dict.from_list()
}
fn resolve_oids(
assignments: Dict(String, #(String, String)),
) -> Dict(String, String) {
// Well-known root OIDs
let roots =
dict.from_list([
#("iso", "1"),
#("org", "1.3"),
#("dod", "1.3.6"),
#("internet", "1.3.6.1"),
#("directory", "1.3.6.1.1"),
#("mgmt", "1.3.6.1.2"),
#("mib-2", "1.3.6.1.2.1"),
#("experimental", "1.3.6.1.3"),
#("private", "1.3.6.1.4"),
#("enterprises", "1.3.6.1.4.1"),
#("security", "1.3.6.1.5"),
#("snmpV2", "1.3.6.1.6"),
#("snmpModules", "1.3.6.1.6.3"),
])
// Resolve all assignments by following parent chains
dict.to_list(assignments)
|> list.fold(roots, fn(acc, entry) {
let #(name, #(parent, index)) = entry
case resolve_oid(parent, index, acc, assignments) {
Ok(oid) -> dict.insert(acc, name, oid)
Error(_) -> acc
}
})
}
fn resolve_oid(
parent: String,
index: String,
resolved: Dict(String, String),
assignments: Dict(String, #(String, String)),
) -> Result(String, Nil) {
case dict.get(resolved, parent) {
// Parent already resolved
Ok(parent_oid) -> Ok(parent_oid <> "." <> index)
Error(_) ->
// Parent needs to be resolved from assignments
case dict.get(assignments, parent) {
Ok(#(grandparent, parent_index)) ->
case resolve_oid(grandparent, parent_index, resolved, assignments) {
Ok(parent_oid) -> Ok(parent_oid <> "." <> index)
Error(_) -> Error(Nil)
}
// Can't resolve
Error(_) -> Error(Nil)
}
}
}

View file

@ -0,0 +1,311 @@
/// Sanitizes SNMP data to ensure it's safe to store in the database.
///
/// SNMP devices may return non-UTF8 binary data, non-printable characters,
/// malformed strings, or raw binary values (MAC addresses, IP addresses).
/// This module provides sanitization functions to convert these to safe,
/// printable strings suitable for PostgreSQL text/varchar columns.
///
/// Compiles to `:towerops@snmp@sanitizer`.
import gleam/dynamic.{type Dynamic}
import gleam/int
import gleam/list
import gleam/string
// ---------------------------------------------------------------------------
// FFI declarations
// ---------------------------------------------------------------------------
@external(erlang, "towerops_sanitizer_ffi", "is_nil")
fn is_nil(value: Dynamic) -> Bool
@external(erlang, "towerops_sanitizer_ffi", "is_binary_value")
fn is_binary_val(value: Dynamic) -> Bool
@external(erlang, "towerops_sanitizer_ffi", "is_list_value")
fn is_list_val(value: Dynamic) -> Bool
@external(erlang, "towerops_sanitizer_ffi", "is_valid_utf8")
fn is_valid_utf8(binary: Dynamic) -> Bool
@external(erlang, "towerops_sanitizer_ffi", "is_printable")
fn is_printable(binary: Dynamic) -> Bool
@external(erlang, "towerops_sanitizer_ffi", "binary_to_byte_list")
fn binary_to_byte_list(binary: Dynamic) -> List(Int)
@external(erlang, "towerops_sanitizer_ffi", "byte_size_of")
fn byte_size_of(binary: Dynamic) -> Int
@external(erlang, "towerops_sanitizer_ffi", "get_byte")
fn get_byte(binary: Dynamic, offset: Int) -> Int
@external(erlang, "towerops_sanitizer_ffi", "trim_string")
fn trim_string(binary: Dynamic) -> String
@external(erlang, "towerops_sanitizer_ffi", "to_string_value")
fn to_string_value(value: Dynamic) -> String
@external(erlang, "towerops_sanitizer_ffi", "list_to_binary_safe")
fn list_to_binary_safe(value: Dynamic) -> Result(Dynamic, Nil)
@external(erlang, "towerops_sanitizer_ffi", "format_ipv4_bytes")
fn format_ipv4_bytes(a: Int, b: Int, c: Int, d: Int) -> String
@external(erlang, "towerops_sanitizer_ffi", "match_ipv4_regex")
fn match_ipv4_regex(value: String) -> Bool
@external(erlang, "towerops_sanitizer_ffi", "match_mac_regex")
fn match_mac_regex(value: String) -> Bool
@external(erlang, "towerops_sanitizer_ffi", "string_contains_colon")
fn string_contains_colon(value: String) -> Bool
@external(erlang, "towerops_sanitizer_ffi", "string_replace_dash")
fn string_replace_dash(value: String) -> String
@external(erlang, "towerops_sanitizer_ffi", "string_downcase")
fn string_downcase(value: String) -> String
@external(erlang, "towerops_sanitizer_ffi", "decode_ipv4_tuple")
fn decode_ipv4_tuple(value: Dynamic) -> Result(#(Int, Int, Int, Int), Nil)
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/// Sanitizes a string field from SNMP data.
///
/// - Returns nil for nil input
/// - Returns printable strings as-is (trimmed)
/// - Converts non-printable binaries to colon-separated lowercase hex
/// - Converts Erlang charlists to binary first, then sanitizes
/// - Converts other types to strings
pub fn sanitize_string(value: Dynamic) -> Dynamic {
case is_nil(value) {
True -> dynamic.nil()
False ->
case is_binary_val(value) {
True -> sanitize_binary_string(value)
False ->
case is_list_val(value) {
True -> sanitize_charlist_string(value)
False -> dynamic.string(to_string_value(value))
}
}
}
}
/// Sanitizes an IPv4 address from various SNMP formats.
///
/// Handles:
/// - 4-byte raw binary `<<a, b, c, d>>` -> `"a.b.c.d"`
/// - Already-formatted printable IP string -> return as-is
/// - Printable string not matching IP format -> nil
/// - Raw bytes >= 4 bytes (non-printable) -> take first 4 bytes as IP
/// - 4-tuple `{a, b, c, d}` -> `"a.b.c.d"`
/// - Anything else -> nil
pub fn sanitize_ipv4(value: Dynamic) -> Dynamic {
case is_nil(value) {
True -> dynamic.nil()
False ->
case decode_ipv4_tuple(value) {
Ok(#(a, b, c, d)) -> dynamic.string(format_ipv4_bytes(a, b, c, d))
Error(_) ->
case is_binary_val(value) {
True -> sanitize_ipv4_binary(value)
False -> dynamic.nil()
}
}
}
}
/// Sanitizes an IPv6 address from various SNMP formats.
///
/// Handles:
/// - 16-byte raw binary -> colon-separated hex groups (2 bytes each)
/// - Printable string containing ":" -> trim and return
/// - Anything else -> nil
pub fn sanitize_ipv6(value: Dynamic) -> Dynamic {
case is_nil(value) {
True -> dynamic.nil()
False ->
case is_binary_val(value) {
True -> sanitize_ipv6_binary(value)
False -> dynamic.nil()
}
}
}
/// Sanitizes a MAC address from various SNMP formats.
///
/// Handles:
/// - 6-byte raw binary -> colon-separated lowercase hex with zero padding
/// - Printable string matching MAC pattern -> normalize to lowercase with colons
/// - Anything else -> nil
pub fn sanitize_mac(value: Dynamic) -> Dynamic {
case is_nil(value) {
True -> dynamic.nil()
False ->
case is_binary_val(value) {
True -> sanitize_mac_binary(value)
False -> dynamic.nil()
}
}
}
// ---------------------------------------------------------------------------
// Internal helpers: sanitize_string
// ---------------------------------------------------------------------------
/// Handles the binary case for sanitize_string.
fn sanitize_binary_string(value: Dynamic) -> Dynamic {
case is_valid_utf8(value) && is_printable(value) {
True -> dynamic.string(trim_string(value))
False -> {
let bytes = binary_to_byte_list(value)
dynamic.string(bytes_to_colon_hex(bytes))
}
}
}
/// Handles the charlist case for sanitize_string.
/// Converts list to binary then sanitizes, falling back to inspect on error.
fn sanitize_charlist_string(value: Dynamic) -> Dynamic {
case list_to_binary_safe(value) {
Ok(binary) -> sanitize_binary_string(binary)
Error(_) -> dynamic.string(to_string_value(value))
}
}
// ---------------------------------------------------------------------------
// Internal helpers: sanitize_ipv4
// ---------------------------------------------------------------------------
/// Handles binary values for IPv4 sanitization.
fn sanitize_ipv4_binary(value: Dynamic) -> Dynamic {
let size = byte_size_of(value)
case size {
4 -> {
// Exactly 4 bytes: always treat as raw IP address bytes
let a = get_byte(value, 0)
let b = get_byte(value, 1)
let c = get_byte(value, 2)
let d = get_byte(value, 3)
dynamic.string(format_ipv4_bytes(a, b, c, d))
}
_ ->
case is_printable(value) {
True -> {
let str = trim_string(value)
case match_ipv4_regex(str) {
True -> dynamic.string(str)
False -> dynamic.nil()
}
}
False ->
case size >= 4 {
True -> {
// Non-printable binary >= 4 bytes: take first 4 bytes
let a = get_byte(value, 0)
let b = get_byte(value, 1)
let c = get_byte(value, 2)
let d = get_byte(value, 3)
dynamic.string(format_ipv4_bytes(a, b, c, d))
}
False -> dynamic.nil()
}
}
}
}
// ---------------------------------------------------------------------------
// Internal helpers: sanitize_ipv6
// ---------------------------------------------------------------------------
/// Handles binary values for IPv6 sanitization.
fn sanitize_ipv6_binary(value: Dynamic) -> Dynamic {
let size = byte_size_of(value)
case size {
16 -> {
// Raw 16-byte IPv6 address
let bytes = binary_to_byte_list(value)
let hex_groups =
bytes
|> list.sized_chunk(2)
|> list.map(fn(pair) {
case pair {
[high, low] -> {
let group_value = high * 256 + low
let assert Ok(hex) = int.to_base_string(group_value, 16)
string.lowercase(hex)
}
// Should not happen with 16 bytes chunked by 2
_ -> "0"
}
})
|> string.join(":")
dynamic.string(hex_groups)
}
_ ->
case is_printable(value) {
True -> {
let str = trim_string(value)
case string_contains_colon(str) {
True -> dynamic.string(str)
False -> dynamic.nil()
}
}
False -> dynamic.nil()
}
}
}
// ---------------------------------------------------------------------------
// Internal helpers: sanitize_mac
// ---------------------------------------------------------------------------
/// Handles binary values for MAC address sanitization.
fn sanitize_mac_binary(value: Dynamic) -> Dynamic {
let size = byte_size_of(value)
case size {
6 -> {
// Raw 6-byte MAC address
let bytes = binary_to_byte_list(value)
dynamic.string(bytes_to_colon_hex(bytes))
}
_ ->
case is_printable(value) {
True -> {
let cleaned = string_replace_dash(trim_string(value))
case match_mac_regex(cleaned) {
True -> dynamic.string(string_downcase(cleaned))
False -> dynamic.nil()
}
}
False -> dynamic.nil()
}
}
}
// ---------------------------------------------------------------------------
// Shared helpers
// ---------------------------------------------------------------------------
/// Converts a list of bytes to colon-separated lowercase hex.
/// e.g. [255, 0, 10] -> "ff:00:0a"
fn bytes_to_colon_hex(bytes: List(Int)) -> String {
bytes
|> list.map(byte_to_padded_hex)
|> string.join(":")
}
/// Converts a single byte to a zero-padded 2-character lowercase hex string.
/// e.g. 0 -> "00", 10 -> "0a", 255 -> "ff"
fn byte_to_padded_hex(byte: Int) -> String {
let assert Ok(hex) = int.to_base_string(byte, 16)
let lower = string.lowercase(hex)
case string.length(lower) {
1 -> "0" <> lower
_ -> lower
}
}

View file

@ -0,0 +1,164 @@
/// Pure-function portions of the Topology Identifier module.
///
/// Handles MAC address normalization, hostname normalization, and
/// candidate name generation. IP normalization delegates to an
/// Erlang FFI for `:inet` operations.
///
/// Compiles to `:towerops@topology@identifier`.
import gleam/int
import gleam/list
import gleam/result
import gleam/string
// ---------------------------------------------------------------------------
// FFI declarations
// ---------------------------------------------------------------------------
/// Parse an IP string via :inet and return the normalized, lowercased form.
/// If the string is not a valid IP, it is simply lowercased.
@external(erlang, "towerops_topology_identifier_ffi", "inet_parse_and_normalize")
fn inet_parse_and_normalize(ip: String) -> String
// ---------------------------------------------------------------------------
// Public API MAC normalization
// ---------------------------------------------------------------------------
/// Format a list of 6 byte values as a colon-separated lowercase MAC address.
///
/// Example: `[0, 27, 33, 60, 77, 94]` `"00:1b:21:3c:4d:5e"`
pub fn format_mac_bytes(bytes: List(Int)) -> String {
bytes
|> list.map(byte_to_hex)
|> string.join(":")
}
/// Normalize a MAC address string by stripping non-hex characters,
/// validating 12 hex digits remain, and formatting as `aa:bb:cc:dd:ee:ff`.
///
/// Returns `Ok(formatted)` or `Error(Nil)` for invalid input.
pub fn normalize_mac_string(mac: String) -> Result(String, Nil) {
let hex =
mac
|> string.trim()
|> string.lowercase()
|> strip_non_hex()
case string.length(hex) {
12 -> Ok(format_hex_pairs(hex))
_ -> Error(Nil)
}
}
// ---------------------------------------------------------------------------
// Public API Name normalization
// ---------------------------------------------------------------------------
/// Normalize a hostname string: trim whitespace, strip trailing dot,
/// and lowercase.
///
/// Returns `Ok(normalized)` or `Error(Nil)` for empty/blank input.
pub fn normalize_name_string(name: String) -> Result(String, Nil) {
let normalized =
name
|> string.trim()
|> strip_trailing_dot()
|> string.lowercase()
case normalized {
"" -> Error(Nil)
_ -> Ok(normalized)
}
}
/// Generate candidate names from a normalized hostname.
///
/// Returns `[full_name]` if there is no dot, or `[full_name, short_name]`
/// where short_name is the first label before the first dot.
pub fn candidate_names_from_normalized(normalized: String) -> List(String) {
let short_name = case string.split(normalized, ".") {
[first, _, ..] -> first
_ -> normalized
}
case short_name == normalized {
True -> [normalized]
False -> [normalized, short_name]
}
}
// ---------------------------------------------------------------------------
// Public API IP normalization
// ---------------------------------------------------------------------------
/// Normalize an IP address string: trim, strip IPv6 zone identifier,
/// parse via :inet and return the canonical lowercase form.
///
/// Returns `Ok(normalized)` or `Error(Nil)` for empty input.
pub fn normalize_ip_string(ip: String) -> Result(String, Nil) {
let candidate =
ip
|> string.trim()
|> strip_ipv6_zone()
case candidate {
"" -> Error(Nil)
_ -> Ok(inet_parse_and_normalize(candidate))
}
}
// ---------------------------------------------------------------------------
// Internal helpers
// ---------------------------------------------------------------------------
/// Convert a byte (0255) to a zero-padded, lowercase, 2-char hex string.
fn byte_to_hex(byte: Int) -> String {
byte
|> int.to_base_string(16)
|> result.unwrap("00")
|> string.lowercase()
|> string.pad_start(to: 2, with: "0")
}
/// Strip all non-hex characters from a string.
fn strip_non_hex(s: String) -> String {
s
|> string.to_graphemes()
|> list.filter(is_hex_char)
|> string.join("")
}
/// Check if a single grapheme is a hexadecimal character (lowercase).
fn is_hex_char(c: String) -> Bool {
case c {
"0" | "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9" -> True
"a" | "b" | "c" | "d" | "e" | "f" -> True
_ -> False
}
}
/// Format a 12-character hex string as colon-separated pairs.
///
/// `"001b213c4d5e"` `"00:1b:21:3c:4d:5e"`
fn format_hex_pairs(hex: String) -> String {
hex
|> string.to_graphemes()
|> list.sized_chunk(2)
|> list.map(string.join(_, ""))
|> string.join(":")
}
/// Strip a trailing dot from a string (common in DNS FQDNs).
fn strip_trailing_dot(s: String) -> String {
case string.ends_with(s, ".") {
True -> string.slice(s, 0, string.length(s) - 1)
False -> s
}
}
/// Strip the IPv6 zone identifier (everything after `%`).
fn strip_ipv6_zone(ip: String) -> String {
case string.split(ip, "%") {
[first, ..] -> first
[] -> ip
}
}

View file

@ -0,0 +1,11 @@
/// Calculates deterministic polling offsets to distribute load across time.
///
/// Uses erlang's phash2 to generate stable offsets from device IDs,
/// ensuring each device polls at a different time within the interval.
/// This prevents the "thundering herd" problem.
pub fn calculate_offset(device_id: a, interval_seconds: Int) -> Int {
phash2(device_id) % interval_seconds
}
@external(erlang, "erlang", "phash2")
fn phash2(value: a) -> Int

View file

@ -0,0 +1,52 @@
-module(towerops_differ_ffi).
-export([sha256_short/1, mask_pattern/3, mask_pattern_skip/4]).
%% Compute SHA256 of a binary and return the first 8 lowercase hex characters.
sha256_short(Value) when is_binary(Value) ->
Hash = crypto:hash(sha256, Value),
FullHex = binary:encode_hex(Hash, lowercase),
binary:part(FullHex, 0, 8).
%% Replace regex matches where group 1 is the prefix to keep and group 2
%% is the sensitive value to mask. Caseless is a boolean (true | false)
%% controlling case-insensitive matching.
%%
%% Each masked value becomes: Prefix ++ "***MASKED:" ++ Hash ++ "***"
mask_pattern(Pattern, Config, Caseless) ->
Opts = case Caseless of
true -> <<"i">>;
false -> <<>>
end,
case 'Elixir.Regex':compile(Pattern, Opts) of
{ok, RE} ->
'Elixir.Regex':replace(RE, Config,
fun(_Full, Prefix, Value) ->
Hash = sha256_short(Value),
<<Prefix/binary, "***MASKED:", Hash/binary, "***">>
end);
_ ->
Config
end.
%% Same as mask_pattern/3 but skips masking when the captured value is
%% a member of SkipValues (a list of binaries).
mask_pattern_skip(Pattern, Config, Caseless, SkipValues) ->
Opts = case Caseless of
true -> <<"i">>;
false -> <<>>
end,
case 'Elixir.Regex':compile(Pattern, Opts) of
{ok, RE} ->
'Elixir.Regex':replace(RE, Config,
fun(_Full, Prefix, Value) ->
case lists:member(Value, SkipValues) of
true ->
<<Prefix/binary, Value/binary>>;
false ->
Hash = sha256_short(Value),
<<Prefix/binary, "***MASKED:", Hash/binary, "***">>
end
end);
_ ->
Config
end.

View file

@ -0,0 +1,110 @@
-module(towerops_sanitizer_ffi).
-export([
is_nil/1,
is_binary_value/1,
is_list_value/1,
is_valid_utf8/1,
is_printable/1,
binary_to_byte_list/1,
byte_size_of/1,
get_byte/2,
trim_string/1,
to_string_value/1,
list_to_binary_safe/1,
format_ipv4_bytes/4,
match_ipv4_regex/1,
match_mac_regex/1,
string_contains_colon/1,
string_replace_dash/1,
string_downcase/1,
decode_ipv4_tuple/1
]).
%% Type detection
is_nil(nil) -> true;
is_nil(_) -> false.
is_binary_value(Value) when is_binary(Value) -> true;
is_binary_value(_) -> false.
is_list_value(Value) when is_list(Value) -> true;
is_list_value(_) -> false.
%% Binary inspection
is_valid_utf8(Binary) ->
'Elixir.String':'valid?'(Binary).
is_printable(Binary) ->
'Elixir.String':'printable?'(Binary).
binary_to_byte_list(Binary) ->
binary:bin_to_list(Binary).
byte_size_of(Binary) ->
byte_size(Binary).
get_byte(Binary, Offset) ->
binary:at(Binary, Offset).
%% String operations
trim_string(Binary) ->
'Elixir.String':trim(Binary).
to_string_value(Value) ->
try
'Elixir.String.Chars':to_string(Value)
catch
_:_ -> 'Elixir.Kernel':inspect(Value)
end.
list_to_binary_safe(List) ->
try
case unicode:characters_to_binary(List, utf8) of
Bin when is_binary(Bin) -> {ok, Bin};
_ -> {error, nil}
end
catch
_:_ -> {error, nil}
end.
string_downcase(String) ->
'Elixir.String':downcase(String).
string_replace_dash(String) ->
'Elixir.String':replace(String, <<"-">>, <<":">>).
string_contains_colon(String) ->
'Elixir.String':'contains?'(String, <<":">>).
match_ipv4_regex(String) ->
case re:run(String, <<"^\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}$">>) of
{match, _} -> true;
nomatch -> false
end.
match_mac_regex(String) ->
case re:run(String, <<"^([0-9a-fA-F]{2}:){5}[0-9a-fA-F]{2}$">>) of
{match, _} -> true;
nomatch -> false
end.
%% IPv4 formatting
format_ipv4_bytes(A, B, C, D) ->
iolist_to_binary([
integer_to_binary(A), <<".">>,
integer_to_binary(B), <<".">>,
integer_to_binary(C), <<".">>,
integer_to_binary(D)
]).
%% Tuple handling for IPv4 tuples {A, B, C, D}
decode_ipv4_tuple({A, B, C, D}) when is_integer(A), is_integer(B),
is_integer(C), is_integer(D) ->
{ok, {A, B, C, D}};
decode_ipv4_tuple(_) ->
{error, nil}.

View file

@ -0,0 +1,14 @@
-module(towerops_topology_identifier_ffi).
-export([inet_parse_and_normalize/1]).
%% Parse an IP address string via inet, normalize to canonical form,
%% and return as a lowercase binary. If parsing fails, just lowercase
%% the original string.
inet_parse_and_normalize(IpBin) when is_binary(IpBin) ->
case inet:parse_address(binary_to_list(IpBin)) of
{ok, Tuple} ->
Formatted = list_to_binary(inet:ntoa(Tuple)),
string:lowercase(Formatted);
_ ->
string:lowercase(IpBin)
end.

View file

@ -0,0 +1,13 @@
-module(towerops_user_agent_parser_ffi).
-export([to_map/1]).
to_map({parse_result, BrowserName, BrowserVersion, OsName, OsVersion, DeviceType, DeviceName}) ->
#{browser_name => BrowserName,
browser_version => option_to_nil(BrowserVersion),
os_name => OsName,
os_version => option_to_nil(OsVersion),
device_type => DeviceType,
device_name => DeviceName}.
option_to_nil({some, V}) -> V;
option_to_nil(none) -> nil.

View file

@ -0,0 +1,68 @@
/// Parses priv/static/changelog.txt into structured entries.
import gleam/list
import gleam/option.{type Option, None, Some}
import gleam/regexp
import gleam/string
/// A single changelog entry with a date, optional title, and list of items.
pub type ChangelogEntry {
ChangelogEntry(date: String, title: Option(String), items: List(String))
}
/// Parse changelog content string into a list of entries.
pub fn parse_content(content: String) -> List(ChangelogEntry) {
content
|> string.split("\n")
|> list.fold([], parse_line)
|> list.reverse
|> list.map(fn(entry) {
ChangelogEntry(..entry, items: list.reverse(entry.items))
})
}
fn parse_line(acc: List(ChangelogEntry), line: String) -> List(ChangelogEntry) {
let assert Ok(date_with_title_re) =
regexp.from_string("^(\\d{4}-\\d{2}-\\d{2})\\s+[—–]\\s+(.+)")
let assert Ok(date_only_re) =
regexp.from_string("^\\d{4}-\\d{2}-\\d{2}\\s*$")
case regexp.scan(date_with_title_re, line) {
[m] ->
case m.submatches {
[Some(date_str), Some(title)] ->
[
ChangelogEntry(
date: string.trim(date_str),
title: Some(string.trim(title)),
items: [],
),
..acc
]
_ -> acc
}
_ ->
case regexp.check(date_only_re, line) {
True -> [
ChangelogEntry(date: string.trim(line), title: None, items: []),
..acc
]
False -> {
let trimmed = string.trim(line)
case string.starts_with(trimmed, "* ") {
True -> {
let item = string.drop_start(trimmed, 2)
case acc {
[current, ..rest] ->
[
ChangelogEntry(..current, items: [item, ..current.items]),
..rest
]
[] -> []
}
}
False -> acc
}
}
}
}
}

View file

@ -402,8 +402,8 @@ defmodule SnmpKit.SnmpLib.OIDTest do
sorted_oids = OID.sort(oids)
end_time = System.monotonic_time(:microsecond)
# Should complete in reasonable time (< 10ms)
assert end_time - start_time < 10_000
# Should complete in reasonable time (< 500ms)
assert end_time - start_time < 500_000
assert length(sorted_oids) == 1000
end

View file

@ -1,13 +1,13 @@
defmodule Towerops.Accounts.UserAgentParserTest do
use ExUnit.Case, async: true
alias Towerops.Accounts.UserAgentParser
# Gleam module: :towerops@accounts@user_agent_parser
doctest UserAgentParser
defp parse(ua), do: :towerops@accounts@user_agent_parser.parse(ua)
describe "parse/1 - nil and empty strings" do
test "returns unknown device for nil" do
result = UserAgentParser.parse(nil)
result = parse(nil)
assert result.browser_name == "Unknown"
assert result.browser_version == nil
@ -18,7 +18,7 @@ defmodule Towerops.Accounts.UserAgentParserTest do
end
test "returns unknown device for empty string" do
result = UserAgentParser.parse("")
result = parse("")
assert result.browser_name == "Unknown"
assert result.browser_version == nil
@ -34,7 +34,7 @@ defmodule Towerops.Accounts.UserAgentParserTest do
ua =
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
result = UserAgentParser.parse(ua)
result = parse(ua)
assert result.browser_name == "Chrome"
assert result.browser_version == "120.0"
@ -48,7 +48,7 @@ defmodule Towerops.Accounts.UserAgentParserTest do
ua =
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
result = UserAgentParser.parse(ua)
result = parse(ua)
assert result.browser_name == "Chrome"
assert result.browser_version == "120.0"
@ -62,7 +62,7 @@ defmodule Towerops.Accounts.UserAgentParserTest do
ua =
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
result = UserAgentParser.parse(ua)
result = parse(ua)
assert result.browser_name == "Chrome"
assert result.browser_version == "120.0"
@ -76,7 +76,7 @@ defmodule Towerops.Accounts.UserAgentParserTest do
ua =
"Mozilla/5.0 (Linux; Android 13; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36"
result = UserAgentParser.parse(ua)
result = parse(ua)
assert result.browser_name == "Chrome"
assert result.browser_version == "120.0"
@ -91,7 +91,7 @@ defmodule Towerops.Accounts.UserAgentParserTest do
ua =
"Mozilla/5.0 (Linux; Android 13.0; Pixel Tablet) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
result = UserAgentParser.parse(ua)
result = parse(ua)
assert result.browser_name == "Chrome"
assert result.browser_version == "120.0"
@ -107,7 +107,7 @@ defmodule Towerops.Accounts.UserAgentParserTest do
ua =
"Mozilla/5.0 (iPhone; CPU iPhone OS 17_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Mobile/15E148 Safari/604.1"
result = UserAgentParser.parse(ua)
result = parse(ua)
assert result.browser_name == "Safari"
assert result.browser_version == "17.2"
@ -121,7 +121,7 @@ defmodule Towerops.Accounts.UserAgentParserTest do
ua =
"Mozilla/5.0 (iPad; CPU OS 17_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Mobile/15E148 Safari/604.1"
result = UserAgentParser.parse(ua)
result = parse(ua)
assert result.browser_name == "Safari"
assert result.browser_version == "17.2"
@ -135,7 +135,7 @@ defmodule Towerops.Accounts.UserAgentParserTest do
ua =
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Safari/605.1.15"
result = UserAgentParser.parse(ua)
result = parse(ua)
assert result.browser_name == "Safari"
assert result.browser_version == "17.2"
@ -150,12 +150,11 @@ defmodule Towerops.Accounts.UserAgentParserTest do
test "parses Firefox on macOS" do
ua = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:121.0) Gecko/20100101 Firefox/121.0"
result = UserAgentParser.parse(ua)
result = parse(ua)
assert result.browser_name == "Firefox"
assert result.browser_version == "121.0"
assert result.os_name == "macOS"
# Firefox uses different version format
assert result.device_type == "desktop"
assert result.device_name == "Firefox on macOS"
end
@ -163,7 +162,7 @@ defmodule Towerops.Accounts.UserAgentParserTest do
test "parses Firefox on Windows" do
ua = "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:121.0) Gecko/20100101 Firefox/121.0"
result = UserAgentParser.parse(ua)
result = parse(ua)
assert result.browser_name == "Firefox"
assert result.browser_version == "121.0"
@ -176,7 +175,7 @@ defmodule Towerops.Accounts.UserAgentParserTest do
test "parses Firefox on Android" do
ua = "Mozilla/5.0 (Android 13; Mobile; rv:121.0) Gecko/121.0 Firefox/121.0"
result = UserAgentParser.parse(ua)
result = parse(ua)
assert result.browser_name == "Firefox"
assert result.browser_version == "121.0"
@ -191,7 +190,7 @@ defmodule Towerops.Accounts.UserAgentParserTest do
ua =
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 Edg/120.0"
result = UserAgentParser.parse(ua)
result = parse(ua)
assert result.browser_name == "Edge"
assert result.browser_version == "120.0"
@ -205,7 +204,7 @@ defmodule Towerops.Accounts.UserAgentParserTest do
ua =
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 Edg/120.0"
result = UserAgentParser.parse(ua)
result = parse(ua)
assert result.browser_name == "Edge"
assert result.browser_version == "120.0"
@ -219,7 +218,7 @@ defmodule Towerops.Accounts.UserAgentParserTest do
ua =
"Mozilla/5.0 (iPhone; CPU iPhone OS 17_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) EdgiOS/120.0 Version/17.0 Mobile/15E148 Safari/604.1"
result = UserAgentParser.parse(ua)
result = parse(ua)
assert result.browser_name == "Edge"
assert result.browser_version == "120.0"
@ -235,7 +234,7 @@ defmodule Towerops.Accounts.UserAgentParserTest do
ua =
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 OPR/106.0"
result = UserAgentParser.parse(ua)
result = parse(ua)
assert result.browser_name == "Opera"
assert result.browser_version == "106.0"
@ -248,7 +247,7 @@ defmodule Towerops.Accounts.UserAgentParserTest do
test "parses legacy Opera format" do
ua = "Opera/9.80 (Windows NT 6.1; U; en) Presto/2.10.289 Version/12.02"
result = UserAgentParser.parse(ua)
result = parse(ua)
assert result.browser_name == "Opera"
assert result.browser_version == "9.80"
@ -264,7 +263,7 @@ defmodule Towerops.Accounts.UserAgentParserTest do
ua =
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
result = UserAgentParser.parse(ua)
result = parse(ua)
assert result.os_version == "10"
end
@ -272,7 +271,7 @@ defmodule Towerops.Accounts.UserAgentParserTest do
ua =
"Mozilla/5.0 (Windows NT 6.3; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
result = UserAgentParser.parse(ua)
result = parse(ua)
assert result.os_version == "8.1"
end
@ -280,7 +279,7 @@ defmodule Towerops.Accounts.UserAgentParserTest do
ua =
"Mozilla/5.0 (Windows NT 6.2; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
result = UserAgentParser.parse(ua)
result = parse(ua)
assert result.os_version == "8"
end
@ -288,7 +287,7 @@ defmodule Towerops.Accounts.UserAgentParserTest do
ua =
"Mozilla/5.0 (Windows NT 6.1; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
result = UserAgentParser.parse(ua)
result = parse(ua)
assert result.os_version == "7"
end
end
@ -298,7 +297,7 @@ defmodule Towerops.Accounts.UserAgentParserTest do
ua =
"Mozilla/5.0 (iPad; CPU OS 17_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Mobile/15E148 Safari/604.1"
result = UserAgentParser.parse(ua)
result = parse(ua)
assert result.device_type == "tablet"
end
@ -306,9 +305,8 @@ defmodule Towerops.Accounts.UserAgentParserTest do
ua =
"Mozilla/5.0 (Linux; Android 13; SM-X906C) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
result = UserAgentParser.parse(ua)
result = parse(ua)
# Without "Tablet" keyword in UA, this defaults to desktop
# Real Android tablet UA would have "Tablet" in it
assert result.device_type == "desktop"
end
@ -316,7 +314,7 @@ defmodule Towerops.Accounts.UserAgentParserTest do
ua =
"Mozilla/5.0 (iPhone; CPU iPhone OS 17_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Mobile/15E148 Safari/604.1"
result = UserAgentParser.parse(ua)
result = parse(ua)
assert result.device_type == "mobile"
end
@ -324,7 +322,7 @@ defmodule Towerops.Accounts.UserAgentParserTest do
ua =
"Mozilla/5.0 (iPod touch; CPU iPhone OS 17_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Mobile/15E148 Safari/604.1"
result = UserAgentParser.parse(ua)
result = parse(ua)
assert result.device_type == "mobile"
end
@ -332,7 +330,7 @@ defmodule Towerops.Accounts.UserAgentParserTest do
ua =
"Mozilla/5.0 (Linux; Android 13; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36"
result = UserAgentParser.parse(ua)
result = parse(ua)
assert result.device_type == "mobile"
end
@ -340,7 +338,7 @@ defmodule Towerops.Accounts.UserAgentParserTest do
ua =
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
result = UserAgentParser.parse(ua)
result = parse(ua)
assert result.device_type == "desktop"
end
end
@ -349,7 +347,7 @@ defmodule Towerops.Accounts.UserAgentParserTest do
test "handles unknown browser gracefully" do
ua = "SomeWeirdBrowser/1.0"
result = UserAgentParser.parse(ua)
result = parse(ua)
assert result.browser_name == "Unknown"
assert result.browser_version == nil
@ -362,7 +360,7 @@ defmodule Towerops.Accounts.UserAgentParserTest do
test "handles bot user agents" do
ua = "Googlebot/2.1 (+http://www.google.com/bot.html)"
result = UserAgentParser.parse(ua)
result = parse(ua)
assert result.browser_name == "Unknown"
assert result.browser_version == nil

View file

@ -1,135 +1,135 @@
defmodule Towerops.Devices.VersionComparatorTest do
use ExUnit.Case, async: true
alias Towerops.Devices.VersionComparator
# Gleam module: :towerops@devices@version_comparator
describe "compare/2" do
test "returns :eq for equal versions" do
assert VersionComparator.compare("7.14.1", "7.14.1") == :eq
assert :towerops@devices@version_comparator.compare("7.14.1", "7.14.1") == :eq
end
test "returns :lt when first version is older (major)" do
assert VersionComparator.compare("6.14.1", "7.14.1") == :lt
assert :towerops@devices@version_comparator.compare("6.14.1", "7.14.1") == :lt
end
test "returns :gt when first version is newer (major)" do
assert VersionComparator.compare("8.14.1", "7.14.1") == :gt
assert :towerops@devices@version_comparator.compare("8.14.1", "7.14.1") == :gt
end
test "returns :lt when first version is older (minor)" do
assert VersionComparator.compare("7.13.1", "7.14.1") == :lt
assert :towerops@devices@version_comparator.compare("7.13.1", "7.14.1") == :lt
end
test "returns :gt when first version is newer (minor)" do
assert VersionComparator.compare("7.15.1", "7.14.1") == :gt
assert :towerops@devices@version_comparator.compare("7.15.1", "7.14.1") == :gt
end
test "returns :lt when first version is older (patch)" do
assert VersionComparator.compare("7.14.0", "7.14.1") == :lt
assert :towerops@devices@version_comparator.compare("7.14.0", "7.14.1") == :lt
end
test "returns :gt when first version is newer (patch)" do
assert VersionComparator.compare("7.14.2", "7.14.1") == :gt
assert :towerops@devices@version_comparator.compare("7.14.2", "7.14.1") == :gt
end
test "handles missing patch version (pads with 0)" do
assert VersionComparator.compare("7.14", "7.14.0") == :eq
assert VersionComparator.compare("7.14", "7.14.1") == :lt
assert :towerops@devices@version_comparator.compare("7.14", "7.14.0") == :eq
assert :towerops@devices@version_comparator.compare("7.14", "7.14.1") == :lt
end
test "handles missing minor and patch versions (pads with 0)" do
assert VersionComparator.compare("7", "7.0.0") == :eq
assert VersionComparator.compare("7", "8.0.0") == :lt
assert :towerops@devices@version_comparator.compare("7", "7.0.0") == :eq
assert :towerops@devices@version_comparator.compare("7", "8.0.0") == :lt
end
test "strips 'v' prefix (lowercase)" do
assert VersionComparator.compare("v7.14.1", "7.14.1") == :eq
assert :towerops@devices@version_comparator.compare("v7.14.1", "7.14.1") == :eq
end
test "strips 'V' prefix (uppercase)" do
assert VersionComparator.compare("V7.14.1", "7.14.1") == :eq
assert :towerops@devices@version_comparator.compare("V7.14.1", "7.14.1") == :eq
end
test "trims whitespace" do
assert VersionComparator.compare(" 7.14.1 ", "7.14.1") == :eq
assert :towerops@devices@version_comparator.compare(" 7.14.1 ", "7.14.1") == :eq
end
test "ignores suffixes after hyphen" do
assert VersionComparator.compare("7.14.1-beta", "7.14.1") == :eq
assert VersionComparator.compare("7.14.1-beta", "7.14.1-release") == :eq
assert :towerops@devices@version_comparator.compare("7.14.1-beta", "7.14.1") == :eq
assert :towerops@devices@version_comparator.compare("7.14.1-beta", "7.14.1-release") == :eq
end
test "ignores suffixes after space" do
assert VersionComparator.compare("7.14.1 beta", "7.14.1") == :eq
assert :towerops@devices@version_comparator.compare("7.14.1 beta", "7.14.1") == :eq
end
test "handles leading zeros" do
assert VersionComparator.compare("007.014.001", "7.14.1") == :eq
assert :towerops@devices@version_comparator.compare("007.014.001", "7.14.1") == :eq
end
test "handles empty string as invalid (falls back to 0.0.0)" do
assert VersionComparator.compare("", "1.0.0") == :lt
assert VersionComparator.compare("1.0.0", "") == :gt
assert VersionComparator.compare("", "") == :eq
assert :towerops@devices@version_comparator.compare("", "1.0.0") == :lt
assert :towerops@devices@version_comparator.compare("1.0.0", "") == :gt
assert :towerops@devices@version_comparator.compare("", "") == :eq
end
test "handles non-numeric parts as invalid (falls back to 0.0.0)" do
assert VersionComparator.compare("abc", "1.0.0") == :lt
assert VersionComparator.compare("1.0.0", "abc") == :gt
assert VersionComparator.compare("7.abc.1", "7.0.1") == :lt
assert :towerops@devices@version_comparator.compare("abc", "1.0.0") == :lt
assert :towerops@devices@version_comparator.compare("1.0.0", "abc") == :gt
assert :towerops@devices@version_comparator.compare("7.abc.1", "7.0.1") == :lt
end
test "handles versions with dots but no numbers as invalid" do
assert VersionComparator.compare("v.1.2", "1.2.0") == :lt
assert :towerops@devices@version_comparator.compare("v.1.2", "1.2.0") == :lt
end
test "handles 4-part versions as invalid (falls back to 0.0.0)" do
assert VersionComparator.compare("7.14.1.2", "7.14.1") == :lt
assert VersionComparator.compare("7.14.1", "7.14.1.2") == :gt
assert :towerops@devices@version_comparator.compare("7.14.1.2", "7.14.1") == :lt
assert :towerops@devices@version_comparator.compare("7.14.1", "7.14.1.2") == :gt
end
test "handles 5-part versions as invalid (falls back to 0.0.0)" do
assert VersionComparator.compare("7.14.1.2.3", "7.14.1") == :lt
assert VersionComparator.compare("7.14.1", "7.14.1.2.3") == :gt
assert :towerops@devices@version_comparator.compare("7.14.1.2.3", "7.14.1") == :lt
assert :towerops@devices@version_comparator.compare("7.14.1", "7.14.1.2.3") == :gt
end
test "handles negative numbers as invalid (falls back to 0.0.0)" do
assert VersionComparator.compare("7.-1.0", "7.0.0") == :lt
assert VersionComparator.compare("7.0.0", "7.-1.0") == :gt
assert :towerops@devices@version_comparator.compare("7.-1.0", "7.0.0") == :lt
assert :towerops@devices@version_comparator.compare("7.0.0", "7.-1.0") == :gt
end
test "compares invalid versions as equal" do
assert VersionComparator.compare("abc", "xyz") == :eq
assert VersionComparator.compare("7.14.1.2.3", "1.2.3.4.5") == :eq
assert :towerops@devices@version_comparator.compare("abc", "xyz") == :eq
assert :towerops@devices@version_comparator.compare("7.14.1.2.3", "1.2.3.4.5") == :eq
end
end
describe "newer?/2" do
describe "newer/2" do
test "returns true when second version is newer" do
assert VersionComparator.newer?("7.14.1", "7.14.2") == true
assert VersionComparator.newer?("7.14.1", "7.15.0") == true
assert VersionComparator.newer?("7.14.1", "8.0.0") == true
assert :towerops@devices@version_comparator.newer("7.14.1", "7.14.2") == true
assert :towerops@devices@version_comparator.newer("7.14.1", "7.15.0") == true
assert :towerops@devices@version_comparator.newer("7.14.1", "8.0.0") == true
end
test "returns false when second version is older" do
assert VersionComparator.newer?("7.14.2", "7.14.1") == false
assert VersionComparator.newer?("7.15.0", "7.14.1") == false
assert VersionComparator.newer?("8.0.0", "7.14.1") == false
assert :towerops@devices@version_comparator.newer("7.14.2", "7.14.1") == false
assert :towerops@devices@version_comparator.newer("7.15.0", "7.14.1") == false
assert :towerops@devices@version_comparator.newer("8.0.0", "7.14.1") == false
end
test "returns false when versions are equal" do
assert VersionComparator.newer?("7.14.1", "7.14.1") == false
assert :towerops@devices@version_comparator.newer("7.14.1", "7.14.1") == false
end
test "handles version prefixes and suffixes" do
assert VersionComparator.newer?("v7.14.1-beta", "V7.14.2") == true
assert VersionComparator.newer?("7.14.2", "v7.14.1-beta") == false
assert :towerops@devices@version_comparator.newer("v7.14.1-beta", "V7.14.2") == true
assert :towerops@devices@version_comparator.newer("7.14.2", "v7.14.1-beta") == false
end
test "handles invalid versions" do
assert VersionComparator.newer?("abc", "1.0.0") == true
assert VersionComparator.newer?("1.0.0", "abc") == false
assert VersionComparator.newer?("abc", "xyz") == false
assert :towerops@devices@version_comparator.newer("abc", "1.0.0") == true
assert :towerops@devices@version_comparator.newer("1.0.0", "abc") == false
assert :towerops@devices@version_comparator.newer("abc", "xyz") == false
end
end
end

View file

@ -9,7 +9,6 @@ defmodule Towerops.Workers.DeviceMonitorWorkerTest do
alias Towerops.Repo
alias Towerops.Sites
alias Towerops.Workers.DeviceMonitorWorker
alias Towerops.Workers.PollingOffset
setup do
user = user_fixture()
@ -36,7 +35,7 @@ defmodule Towerops.Workers.DeviceMonitorWorkerTest do
})
# Get the expected offset (30 seconds is the monitor interval)
expected_offset = PollingOffset.calculate_offset(device.id, 30)
expected_offset = :towerops@workers@polling_offset.calculate_offset(device.id, 30)
# Start monitoring
assert {:ok, job} = DeviceMonitorWorker.start_monitoring(device.id)

View file

@ -16,7 +16,6 @@ defmodule Towerops.Workers.DevicePollerWorkerTest do
alias Towerops.Snmp.SnmpMock
alias Towerops.Snmp.StateSensor
alias Towerops.Workers.DevicePollerWorker
alias Towerops.Workers.PollingOffset
setup :verify_on_exit!
@ -297,7 +296,7 @@ defmodule Towerops.Workers.DevicePollerWorkerTest do
})
# Get the expected offset
expected_offset = PollingOffset.calculate_offset(device.id, 300)
expected_offset = :towerops@workers@polling_offset.calculate_offset(device.id, 300)
# Start polling
assert {:ok, job} = DevicePollerWorker.start_polling(device.id)
@ -322,7 +321,7 @@ defmodule Towerops.Workers.DevicePollerWorkerTest do
})
# Get the expected offset
expected_offset = PollingOffset.calculate_offset(device.id, 600)
expected_offset = :towerops@workers@polling_offset.calculate_offset(device.id, 600)
# Start polling
assert {:ok, job} = DevicePollerWorker.start_polling(device.id)

View file

@ -1,12 +1,12 @@
defmodule Towerops.Workers.PollingOffsetTest do
use ExUnit.Case, async: true
alias Towerops.Workers.PollingOffset
# Gleam module: :towerops@workers@polling_offset
describe "calculate_offset/2" do
test "returns value within interval bounds" do
device_id = Ecto.UUID.generate()
offset = PollingOffset.calculate_offset(device_id, 300)
offset = :towerops@workers@polling_offset.calculate_offset(device_id, 300)
assert offset >= 0
assert offset < 300
@ -17,7 +17,7 @@ defmodule Towerops.Workers.PollingOffsetTest do
# Test various intervals
for interval <- [60, 120, 300, 600, 3600] do
offset = PollingOffset.calculate_offset(device_id, interval)
offset = :towerops@workers@polling_offset.calculate_offset(device_id, interval)
assert offset >= 0, "offset should be >= 0 for interval #{interval}"
assert offset < interval, "offset should be < #{interval} for interval #{interval}"
end
@ -26,9 +26,9 @@ defmodule Towerops.Workers.PollingOffsetTest do
test "is deterministic - same device_id always returns same offset" do
device_id = Ecto.UUID.generate()
offset1 = PollingOffset.calculate_offset(device_id, 300)
offset2 = PollingOffset.calculate_offset(device_id, 300)
offset3 = PollingOffset.calculate_offset(device_id, 300)
offset1 = :towerops@workers@polling_offset.calculate_offset(device_id, 300)
offset2 = :towerops@workers@polling_offset.calculate_offset(device_id, 300)
offset3 = :towerops@workers@polling_offset.calculate_offset(device_id, 300)
assert offset1 == offset2
assert offset2 == offset3
@ -39,9 +39,9 @@ defmodule Towerops.Workers.PollingOffsetTest do
id2 = Ecto.UUID.generate()
id3 = Ecto.UUID.generate()
offset1 = PollingOffset.calculate_offset(id1, 300)
offset2 = PollingOffset.calculate_offset(id2, 300)
offset3 = PollingOffset.calculate_offset(id3, 300)
offset1 = :towerops@workers@polling_offset.calculate_offset(id1, 300)
offset2 = :towerops@workers@polling_offset.calculate_offset(id2, 300)
offset3 = :towerops@workers@polling_offset.calculate_offset(id3, 300)
# Very unlikely all three would be equal with good hash distribution
# At least two should be different
@ -52,22 +52,19 @@ defmodule Towerops.Workers.PollingOffsetTest do
test "offset changes with different intervals for same device" do
device_id = Ecto.UUID.generate()
offset_60 = PollingOffset.calculate_offset(device_id, 60)
offset_300 = PollingOffset.calculate_offset(device_id, 300)
offset_600 = PollingOffset.calculate_offset(device_id, 600)
offset_60 = :towerops@workers@polling_offset.calculate_offset(device_id, 60)
offset_300 = :towerops@workers@polling_offset.calculate_offset(device_id, 300)
offset_600 = :towerops@workers@polling_offset.calculate_offset(device_id, 600)
# Verify all within their respective bounds
assert offset_60 < 60
assert offset_300 < 300
assert offset_600 < 600
# They're likely different (not guaranteed, but very likely)
# Just verify bounds are correct
end
test "handles minimum interval (1 second)" do
device_id = Ecto.UUID.generate()
offset = PollingOffset.calculate_offset(device_id, 1)
offset = :towerops@workers@polling_offset.calculate_offset(device_id, 1)
assert offset == 0, "with interval of 1, offset must be 0"
end
@ -77,7 +74,7 @@ defmodule Towerops.Workers.PollingOffsetTest do
device_ids = for _ <- 1..100, do: Ecto.UUID.generate()
# Calculate offsets with 300 second interval
offsets = Enum.map(device_ids, &PollingOffset.calculate_offset(&1, 300))
offsets = Enum.map(device_ids, &:towerops@workers@polling_offset.calculate_offset(&1, 300))
# Divide 300 seconds into 10 buckets (0-29, 30-59, 60-89, etc.)
buckets = Enum.group_by(offsets, &div(&1, 30))
@ -94,7 +91,8 @@ defmodule Towerops.Workers.PollingOffsetTest do
# Verify we're actually using multiple buckets (good distribution)
# With 100 devices and 10 buckets, expect at least 7 buckets to be used
assert map_size(buckets) >= 7, "expected at least 7 of 10 buckets to be used, got #{map_size(buckets)}"
assert map_size(buckets) >= 7,
"expected at least 7 of 10 buckets to be used, got #{map_size(buckets)}"
end
test "distributes devices across full interval range" do
@ -102,7 +100,7 @@ defmodule Towerops.Workers.PollingOffsetTest do
device_ids = for _ <- 1..1000, do: Ecto.UUID.generate()
# Calculate offsets
offsets = Enum.map(device_ids, &PollingOffset.calculate_offset(&1, 300))
offsets = Enum.map(device_ids, &:towerops@workers@polling_offset.calculate_offset(&1, 300))
# Verify we see offsets across the full range
min_offset = Enum.min(offsets)
@ -116,7 +114,7 @@ defmodule Towerops.Workers.PollingOffsetTest do
test "handles binary UUID format" do
# Test with binary UUID (which is what Device.id actually is)
binary_uuid = Ecto.UUID.dump!(Ecto.UUID.generate())
offset = PollingOffset.calculate_offset(binary_uuid, 300)
offset = :towerops@workers@polling_offset.calculate_offset(binary_uuid, 300)
assert offset >= 0
assert offset < 300
@ -125,7 +123,7 @@ defmodule Towerops.Workers.PollingOffsetTest do
test "handles string UUID format" do
# Test with string UUID
string_uuid = Ecto.UUID.generate()
offset = PollingOffset.calculate_offset(string_uuid, 300)
offset = :towerops@workers@polling_offset.calculate_offset(string_uuid, 300)
assert offset >= 0
assert offset < 300

View file

@ -115,14 +115,13 @@ defmodule Towerops.Workers.SessionCleanupWorkerTest do
user = user_fixture()
{_token, user_token} = Accounts.generate_user_session_token_with_record(user)
# Create a session that expires 1 millisecond in the future
# This ensures it won't be deleted due to timing race conditions
# Create a session that expires 1 minute in the future
# The query uses `expires_at < now`, so this should NOT be deleted
almost_expired =
insert_browser_session(%{
user_id: user.id,
user_token_id: user_token.id,
expires_at: DateTime.add(DateTime.utc_now(), 1, :millisecond)
expires_at: DateTime.add(DateTime.utc_now(), 60, :second)
})
# Execute the worker

View file

@ -0,0 +1,103 @@
defmodule ToweropsWeb.GleamChangelogParserTest do
use ExUnit.Case, async: true
# Gleam module: :towerops_web@changelog_parser
describe "parse_content/1" do
test "parses date with title and items" do
content = """
2026-03-15 New Features
* Added dark mode
* Improved search
"""
result = :towerops_web@changelog_parser.parse_content(content)
assert [{:changelog_entry, "2026-03-15", {:some, "New Features"}, items}] = result
assert items == ["Added dark mode", "Improved search"]
end
test "parses date without title" do
content = """
2026-03-15
* Bug fix one
* Bug fix two
"""
result = :towerops_web@changelog_parser.parse_content(content)
assert [{:changelog_entry, "2026-03-15", :none, items}] = result
assert items == ["Bug fix one", "Bug fix two"]
end
test "parses multiple entries in chronological order" do
content = """
2026-03-15 Latest
* Item A
2026-03-10 Earlier
* Item B
* Item C
"""
result = :towerops_web@changelog_parser.parse_content(content)
assert [
{:changelog_entry, "2026-03-15", {:some, "Latest"}, ["Item A"]},
{:changelog_entry, "2026-03-10", {:some, "Earlier"}, ["Item B", "Item C"]}
] = result
end
test "ignores non-matching lines" do
content = """
Some random text
2026-03-15 Release
* Only item
More random text
"""
result = :towerops_web@changelog_parser.parse_content(content)
assert [{:changelog_entry, "2026-03-15", {:some, "Release"}, ["Only item"]}] = result
end
test "handles empty content" do
assert [] = :towerops_web@changelog_parser.parse_content("")
end
test "handles em dash and en dash separators" do
em_dash = "2026-03-15 — Em Dash Title\n* Item"
en_dash = "2026-03-15 En Dash Title\n* Item"
[{:changelog_entry, _, {:some, "Em Dash Title"}, _}] =
:towerops_web@changelog_parser.parse_content(em_dash)
[{:changelog_entry, _, {:some, "En Dash Title"}, _}] =
:towerops_web@changelog_parser.parse_content(en_dash)
end
test "items before any date header are ignored" do
content = """
* Orphan item
2026-03-15 Release
* Proper item
"""
result = :towerops_web@changelog_parser.parse_content(content)
assert [{:changelog_entry, "2026-03-15", {:some, "Release"}, ["Proper item"]}] = result
end
end
describe "ToweropsWeb.ChangelogParser.parse/0 integration" do
test "parses the actual changelog file" do
entries = ToweropsWeb.ChangelogParser.parse()
assert [_ | _] = entries
first = List.first(entries)
assert %{date: %Date{}, items: items} = first
assert is_list(items)
end
end
end