defmodule SnmpKit.SnmpLib.OID do @moduledoc """ Comprehensive OID (Object Identifier) manipulation utilities for SNMP operations. ## Features - String/list format conversions with validation - Support for both OID formats: "1.3.6.1.2.1.1" and ".1.3.6.1.2.1.1" - OID tree operations (parent/child relationships) - SNMP table index parsing and construction - OID comparison and sorting - Enterprise OID utilities ## Examples {:ok, oid_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]) true = SnmpKit.SnmpLib.OID.child_of?([1, 3, 6, 1, 2, 1, 1, 1, 0], [1, 3, 6, 1, 2, 1]) :lt = SnmpKit.SnmpLib.OID.compare([1, 3, 6, 1], [1, 3, 6, 2]) """ @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_prefix [1, 3, 6, 1, 2, 1] @enterprises_prefix [1, 3, 6, 1, 4, 1] @experimental_prefix [1, 3, 6, 1, 3] @private_prefix [1, 3, 6, 1, 4] ## String/List Conversions @doc """ Converts an OID string to a list of integers. ## Examples 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]} 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]} iex> SnmpKit.SnmpLib.OID.string_to_list("1.3.6") {:ok, [1, 3, 6]} iex> SnmpKit.SnmpLib.OID.string_to_list(".1.3.6") {:ok, [1, 3, 6]} iex> SnmpKit.SnmpLib.OID.string_to_list("") {:error, :empty_oid} iex> SnmpKit.SnmpLib.OID.string_to_list("1.3.6.1.a.2") {:error, :invalid_oid_string} iex> SnmpKit.SnmpLib.OID.string_to_list("1.3.6.1.2.-1") {:error, :invalid_oid_string} """ @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), {:ok, oid_list} <- parse_oid_components(normalized), :ok <- validate_oid_list(oid_list) do {:ok, oid_list} end end def string_to_list(_), do: {:error, :invalid_input} @doc """ Converts an OID list to a dot-separated string. ## Examples 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 with :ok <- validate_oid_list(oid_list) do oid_string = Enum.map_join(oid_list, ".", &Integer.to_string/1) {:ok, oid_string} end end def list_to_string(_), do: {:error, :invalid_input} ## Tree Operations @doc """ Checks if one OID is a child of another. ## Examples 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 end def child_of?(_, _), do: false @doc """ Checks if one OID is a parent of another. """ @spec parent_of?(oid(), oid()) :: boolean() def parent_of?(parent_oid, child_oid), do: child_of?(child_oid, parent_oid) @doc """ Gets the parent OID by removing the last component. ## Examples 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.take(oid, length(oid) - 1) {:ok, parent} end def get_parent(oid) when is_list(oid), do: {:error, :root_oid} def get_parent(_), do: {:error, :invalid_input} @doc """ Gets the immediate children prefix for an OID in a given set. """ @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() end def get_children(_, _), do: [] @doc """ Get standard SNMP OID prefixes. ## Examples iex> SnmpKit.SnmpLib.OID.standard_prefix(:internet) [1, 3, 6, 1] iex> SnmpKit.SnmpLib.OID.standard_prefix(:mgmt) [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_prefix def standard_prefix(:enterprises), do: @enterprises_prefix def standard_prefix(:experimental), do: @experimental_prefix def standard_prefix(:private), do: @private_prefix def standard_prefix(_), do: nil @doc """ Gets the next OID in lexicographic order from a given 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, fn oid -> compare(oid, current_oid) == :gt end) do nil -> {:error, :end_of_mib} next_oid -> {:ok, next_oid} end end def get_next_oid(_, _), do: {:error, :invalid_input} ## Comparison Operations @doc """ Compares two OIDs lexicographically. ## Examples 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) end @doc """ Sorts a list of OIDs in lexicographic order. """ @spec sort([oid()]) :: [oid()] def sort(oid_list) when is_list(oid_list) do Enum.sort(oid_list, fn a, b -> compare(a, b) != :gt end) end def sort(_), do: [] ## Table Operations @doc """ Extracts table index from an instance OID given the table column OID. ## Examples 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 end def extract_table_index(_, _), do: {:error, :invalid_input} @doc """ Builds a table instance OID from table OID and 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 with :ok <- validate_oid_list(table_oid), :ok <- validate_oid_list(index) do {:ok, table_oid ++ index} end end def build_table_instance(_, _), do: {:error, :invalid_input} @doc """ Parses a table index according to index syntax definition. ## Examples 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([value], :integer) do {:ok, value} end def parse_table_index(index, {:string, length}) when is_list(index) and length(index) == length do build_string_from_bytes(index) end def parse_table_index([length | rest], {:variable_string}) when length(rest) == length do build_string_from_bytes(rest) end 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} end end def parse_table_index(_, _), do: {:error, :unsupported_syntax} @doc """ Builds a table index from parsed components according to syntax. ## Examples 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 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]} end def build_table_index(value, {:string, length}) when is_binary(value) do chars = string_to_charlist(value) if length(chars) == length do {:ok, chars} else {:error, :invalid_string_length} end end def build_table_index(value, {:variable_string}) when is_binary(value) do chars = string_to_charlist(value) {:ok, [length(chars) | chars]} end def build_table_index(_, _), do: {:error, :unsupported_syntax} ## Validation @doc """ Validates an OID list for correctness. ## Examples 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) end def valid_oid?(_), do: {:error, :invalid_input} @doc """ Normalizes an OID to a consistent format (list). ## Examples 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 with :ok <- validate_oid_list(oid) do {:ok, oid} end end def normalize(oid) when is_binary(oid), do: string_to_list(oid) def normalize(_), do: {:error, :invalid_input} ## Standard OID Utilities @spec mib_2() :: oid() def mib_2, do: @mib_2_prefix @spec enterprises() :: oid() def enterprises, do: @enterprises_prefix @spec experimental() :: oid() def experimental, do: @experimental_prefix @spec private() :: oid() def private, do: @private_prefix @spec mib_2?(oid()) :: boolean() def mib_2?(oid), do: child_of?(oid, @mib_2_prefix) or oid == @mib_2_prefix @spec enterprise?(oid()) :: boolean() def enterprise?(oid), do: child_of?(oid, @enterprises_prefix) @spec experimental?(oid()) :: boolean() def experimental?(oid), do: child_of?(oid, @experimental_prefix) @spec private?(oid()) :: boolean() def private?(oid), do: child_of?(oid, @private_prefix) @doc """ Gets the enterprise number from an enterprise OID. ## Examples 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 enterprises_len = length(@enterprises_prefix) if enterprise?(oid) and length(oid) > enterprises_len do case Enum.drop(oid, enterprises_len) do [num | _] -> {:ok, num} _ -> {:error, :not_enterprise_oid} end else {:error, :not_enterprise_oid} end end def get_enterprise_number(_), do: {:error, :invalid_input} ## Private Helpers defp normalize_oid_string(oid_string) do trimmed = String.trim(oid_string) cond do trimmed == "" -> {:error, :empty_oid} String.starts_with?(trimmed, ".") -> normalized = String.trim_leading(trimmed, ".") if normalized == "", do: {:error, :empty_oid}, else: {:ok, normalized} true -> {:ok, trimmed} end end defp parse_oid_components(oid_string) do parts = String.split(oid_string, ".") try do oid_list = Enum.map(parts, fn part -> case Integer.parse(part) do {num, ""} when num >= 0 -> num _ -> throw(:invalid_oid_string) end end) {:ok, oid_list} catch :invalid_oid_string -> {:error, :invalid_oid_string} end end defp validate_oid_list([]), do: {:error, :empty_oid} defp validate_oid_list(oid_list) do if Enum.all?(oid_list, &non_neg_integer?/1) do :ok else {:error, :invalid_component} end end defp non_neg_integer?(value) when is_integer(value) and value >= 0, do: true defp non_neg_integer?(_), 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 defp build_string_from_bytes(bytes) do string = Enum.map_join(bytes, fn b -> case b do b when is_integer(b) and b >= 0 and b <= 1_114_111 -> <> _ -> throw(:invalid_string_index) end end) {:ok, string} catch :invalid_string_index -> {:error, :invalid_string_index} end defp string_to_charlist(s) do String.to_charlist(s) end ## Private Helpers for compound table index operations defp parse_index_components(index, [], acc) do {:ok, {Enum.reverse(acc), index}} end defp parse_index_components(index, [syntax | rest_syntax], acc) do case parse_table_index(index, syntax) do {:ok, value} -> case build_table_index(value, syntax) do {:ok, consumed_index} -> consumed_length = length(consumed_index) remaining_index = Enum.drop(index, consumed_length) parse_index_components(remaining_index, rest_syntax, [value | acc]) {:error, reason} -> {:error, reason} end {:error, reason} -> {:error, reason} end end defp build_compound_index(values, syntax_list) do index_parts = values |> Enum.zip(syntax_list) |> Enum.map(fn {val, syntax} -> case build_table_index(val, syntax) do {:ok, idx} -> idx {:error, reason} -> {:error, reason} end end) case Enum.find(index_parts, &match?({:error, _}, &1)) do nil -> index = List.flatten(index_parts) {:ok, index} {:error, reason} -> {:error, reason} end end end