towerops/lib/snmpkit/snmp_lib/mib/parser.ex
2026-01-23 14:01:52 -06:00

754 lines
23 KiB
Elixir

defmodule SnmpKit.SnmpLib.MIB.Parser do
@moduledoc """
Pure native SNMP MIB parser using custom Elixir grammar.
This module provides complete native parsing of SNMP MIB files with 100%
compatibility. All MIBs are parsed natively ensuring complete data integrity.
"""
alias SnmpKit.SnmpLib.MIB.SnmpTokenizer
require Logger
@doc """
Initialize the parser by compiling the grammar file.
This creates a proper yacc-generated parser for native MIB parsing.
"""
def init_parser do
module_name = :mib_grammar_elixir
# Check if the parser module is already loaded
case Code.ensure_loaded(module_name) do
{:module, ^module_name} ->
# Module is already loaded, no need to recompile
{:ok, module_name}
{:error, :nofile} ->
# Module not found, need to compile
compile_grammar()
end
end
defp compile_grammar do
# Get the path to our Elixir-compatible grammar file
grammar_file = Path.join([__DIR__, "..", "..", "..", "src", "mib_grammar_elixir.yrl"])
# Ensure the output directory exists
output_dir = Path.join([__DIR__, "..", "..", "..", "src"])
File.mkdir_p!(output_dir)
# Compile the grammar using Erlang's yecc (if available)
if Code.ensure_loaded?(:yecc) and function_exported?(:yecc, :file, 1) do
result = apply(:yecc, :file, [to_charlist(grammar_file)])
case result do
{:ok, _generated_file} ->
module_name = :mib_grammar_elixir
Logger.debug("Successfully compiled MIB grammar. Generated module: #{inspect(module_name)}")
{:ok, module_name}
{:error, reason} ->
Logger.error("Failed to compile MIB grammar: #{inspect(reason)}")
{:error, {:grammar_compilation_failed, reason}}
:error ->
Logger.error("Grammar compilation failed with error")
{:error, :compilation_failed}
end
else
Logger.warning("yecc module not available, MIB grammar compilation disabled")
{:error, :yecc_not_available}
end
end
@doc """
Parse all MIB files in a list of directories using pure native parsing.
Returns a map with directory paths as keys and results as values.
Each result contains successful compilations and failures.
## Examples
# Parse MIBs in multiple directories
dirs = [
"/path/to/mibs/working",
"/path/to/mibs/docsis"
]
results = SnmpKit.SnmpLib.MIB.Parser.mibdirs(dirs)
# Access results by directory
working_results = results["/path/to/mibs/working"]
IO.puts("Success: \#{length(working_results.success)}/\#{working_results.total}")
# Get all successful MIBs across directories
all_mibs = Enum.flat_map(results, fn {_dir, result} -> result.success end)
"""
def mibdirs(directories) when is_list(directories) do
Logger.info("Compiling MIBs in #{length(directories)} directories")
results =
Map.new(directories, fn dir ->
{dir, compile_directory(dir)}
end)
# Log summary
total_success =
Enum.reduce(results, 0, fn {_dir, result}, acc ->
acc + length(result.success)
end)
total_files =
Enum.reduce(results, 0, fn {_dir, result}, acc ->
acc + result.total
end)
Logger.info(
"OVERALL RESULTS: Total MIBs compiled: #{total_success}/#{total_files} (#{Float.round(total_success / max(total_files, 1) * 100, 1)}%)"
)
Enum.each(results, fn {dir, result} ->
dir_name = Path.basename(dir)
success_rate = Float.round(length(result.success) / max(result.total, 1) * 100, 1)
Logger.info(" #{dir_name}: #{length(result.success)}/#{result.total} (#{success_rate}%)")
end)
results
end
@doc """
Parse a MIB file using pure native grammar parsing.
This is the production MIB parser with 100% native compatibility.
"""
def parse(mib_content) when is_binary(mib_content) do
with {:ok, parser_module} <- init_parser(),
{:ok, tokens} <- tokenize(mib_content),
{:ok, parse_tree} <- parse_with_module(parser_module, tokens) do
{:ok, convert_to_elixir_format(parse_tree)}
else
{:error, {:tokenize, reason}} ->
Logger.debug("Tokenize failed: #{inspect(reason)}")
{:error, reason}
{:error, {:parse, reason}} ->
Logger.debug("Parse failed: #{inspect(reason)}")
{:error, convert_error_to_string(reason)}
{:error, reason} ->
{:error, reason}
end
end
defp parse_with_module(parser_module, tokens) do
case apply(parser_module, :parse, [tokens]) do
{:ok, parse_tree} -> {:ok, parse_tree}
{:error, reason} -> {:error, {:parse, reason}}
end
end
@doc """
Parse pre-tokenized MIB tokens using native grammar parsing.
This function takes tokens directly without tokenizing.
"""
def parse_tokens(tokens) when is_list(tokens) do
with {:ok, parser_module} <- init_parser(),
{:ok, parse_tree} <- parse_with_module(parser_module, tokens) do
{:ok, convert_to_elixir_format(parse_tree)}
else
{:error, {:parse, reason}} ->
Logger.debug("Parse failed: #{inspect(reason)}")
{:error, convert_error_to_string(reason)}
{:error, reason} ->
{:error, reason}
end
end
@doc """
Tokenize MIB content using the native SNMP tokenizer.
Uses the SnmpKit.SnmpLib.MIB.SnmpTokenizer module for complete MIB tokenization.
"""
def tokenize(mib_content) when is_binary(mib_content) do
# Convert to charlist for Erlang compatibility
char_content = to_charlist(mib_content)
# Use the native SNMP tokenizer
case SnmpTokenizer.tokenize(
char_content,
&SnmpTokenizer.null_get_line/0
) do
{:ok, tokens} ->
Logger.debug("Tokenized MIB content successfully")
# Apply hex atom conversion to tokens from the tokenizer
converted_tokens = apply_hex_conversion(tokens)
{:ok, converted_tokens}
{:error, reason} ->
Logger.error("Tokenization failed: #{inspect(reason)}")
{:error, reason}
end
end
# Apply hex atom conversion to tokens from the tokenizer.
# Converts long hex atoms like :"07fffffff" to integers for grammar compatibility.
defp apply_hex_conversion(tokens) do
Enum.map(tokens, &convert_hex_atom/1)
end
# Convert hex atoms that look like integers to actual integers
defp convert_hex_atom({:atom, line, atom_value}) when is_atom(atom_value) do
atom_string = Atom.to_string(atom_value)
# Check if it looks like a hex number (only convert long hex strings, not short identifiers like d1, d2)
if String.match?(atom_string, ~r/^[0-9a-fA-F]{8,}$/) do
try do
# Try to convert from hex to integer
hex_value = String.to_integer(atom_string, 16)
{:integer, line, hex_value}
rescue
_ ->
# If conversion fails, keep as atom
{:atom, line, atom_value}
end
else
# Not a hex pattern, keep as atom
{:atom, line, atom_value}
end
end
# Pass through all other tokens unchanged
defp convert_hex_atom(token), do: token
# Convert the Erlang parse tree to Elixir-friendly format.
defp convert_to_elixir_format(result) do
case result do
{:pdata, version, mib_name, exports, imports, definitions} ->
%{
__type__: :mib,
name: to_string(mib_name),
version: version,
exports: convert_exports(exports),
imports: convert_imports(imports),
definitions: convert_definitions(definitions)
}
{:pdata, version, mib_name, imports, definitions} ->
%{
__type__: :mib,
name: to_string(mib_name),
version: version,
exports: [],
imports: convert_imports(imports),
definitions: convert_definitions(definitions)
}
other ->
%{__type__: :unknown, raw: other}
end
end
defp convert_exports(exports) when is_list(exports) do
Enum.map(exports, fn export ->
case export do
{type, name} -> %{type: type, name: to_string(name)}
name -> %{name: to_string(name)}
end
end)
end
defp convert_imports(imports) when is_list(imports) do
Enum.map(imports, fn
{{module_name, symbols}, _line} ->
%{
__type__: :import,
from_module: to_string(module_name),
symbols:
Enum.map(symbols, fn
{:builtin, symbol} -> to_string(symbol)
{:node, symbol} -> to_string(symbol)
{:type, symbol} -> to_string(symbol)
symbol -> to_string(symbol)
end)
}
{{module_name, symbols}} ->
%{
__type__: :import,
from_module: to_string(module_name),
symbols:
Enum.map(symbols, fn
{:builtin, symbol} -> to_string(symbol)
{:node, symbol} -> to_string(symbol)
{:type, symbol} -> to_string(symbol)
symbol -> to_string(symbol)
end)
}
other ->
%{__type__: :import, raw: other}
end)
end
defp convert_imports(_imports) do
[]
end
defp convert_definitions(definitions) when is_list(definitions) do
Enum.map(definitions, &convert_definition/1)
end
# Handle the actual Erlang SNMP record format from the grammar
defp convert_definition({{record_type, name, macro, parent, sub_index}, line}) when record_type == :mc_internal do
%{
__type__: :object_identifier,
name: to_string(name),
macro: macro,
parent: if(is_binary(parent), do: parent, else: to_string(parent)),
sub_index: convert_sub_index(sub_index),
line: line
}
end
defp convert_definition({{record_type, name, syntax, units, max_acc, status, desc, ref, kind, oid}, line})
when record_type == :mc_object_type do
%{
__type__: :object_type,
name: to_string(name),
syntax: syntax,
units: units,
max_access: max_acc,
status: status,
description: clean_description(desc),
reference: ref,
kind: kind,
oid: convert_oid(oid),
line: line
}
end
# Handle module identity record
defp convert_definition(
{{:mc_module_identity, name, last_updated, organization, contact_info, description, revisions, oid}, line}
) do
%{
__type__: :module_identity,
name: to_string(name),
last_updated: to_string(last_updated),
organization: clean_description(to_string(organization)),
contact_info: clean_description(to_string(contact_info)),
description: clean_description(to_string(description)),
revisions: convert_revisions(revisions),
oid: convert_oid(oid),
line: line
}
end
# Handle textual convention record
defp convert_definition({{:mc_new_type, name, macro, status, description, reference, display_hint, syntax}, line}) do
%{
__type__: :textual_convention,
name: to_string(name),
macro: macro,
status: status,
description: clean_description(to_string(description)),
reference: if(reference == :undefined, do: nil, else: to_string(reference)),
display_hint: if(display_hint == :undefined, do: nil, else: to_string(display_hint)),
syntax: syntax,
line: line
}
end
# Handle legacy format first (more specific pattern)
defp convert_definition({:ok, {type, name, rest}}) do
base = %{
__type__: type,
name: to_string(name)
}
case type do
:objectidentifier ->
Map.put(base, :oid, convert_oid(rest))
:objectType ->
convert_object_type(base, rest)
:moduleIdentity ->
convert_module_identity(base, rest)
:textualConvention ->
convert_textual_convention(base, rest)
:objectGroup ->
convert_object_group(base, rest)
_ ->
Map.put(base, :data, rest)
end
end
# Handle other record types as catch-all
defp convert_definition({record_tuple, line}) do
%{
__type__: :unknown,
record: record_tuple,
line: line
}
end
defp convert_object_type(base, {syntax, access, status, description, reference, index, defval, oid}) do
base
|> Map.put(:syntax, convert_syntax(syntax))
|> Map.put(:max_access, convert_atom(access))
|> Map.put(:status, convert_atom(status))
|> Map.put(:description, clean_description(to_string(description)))
|> Map.put(:reference, if(reference == :undefined, do: nil, else: to_string(reference)))
|> Map.put(:index, convert_index(index))
|> Map.put(:defval, convert_defval(defval))
|> Map.put(:oid, convert_oid(oid))
end
defp convert_module_identity(base, {last_updated, organization, contact_info, description, revisions, oid}) do
base
|> Map.put(:last_updated, to_string(last_updated))
|> Map.put(:organization, to_string(organization))
|> Map.put(:contact_info, to_string(contact_info))
|> Map.put(:description, clean_description(to_string(description)))
|> Map.put(:revisions, convert_revisions(revisions))
|> Map.put(:oid, convert_oid(oid))
end
defp convert_textual_convention(base, {display_hint, status, description, reference, syntax}) do
base
|> Map.put(
:display_hint,
if(display_hint == :undefined, do: nil, else: to_string(display_hint))
)
|> Map.put(:status, convert_atom(status))
|> Map.put(:description, clean_description(to_string(description)))
|> Map.put(:reference, if(reference == :undefined, do: nil, else: to_string(reference)))
|> Map.put(:syntax, convert_syntax(syntax))
end
defp convert_object_group(base, {objects, status, description, reference, oid}) do
base
|> Map.put(:objects, Enum.map(objects, &to_string/1))
|> Map.put(:status, convert_atom(status))
|> Map.put(:description, clean_description(to_string(description)))
|> Map.put(:reference, if(reference == :undefined, do: nil, else: to_string(reference)))
|> Map.put(:oid, convert_oid(oid))
end
defp convert_oid(oid_list) when is_list(oid_list) do
Enum.map(oid_list, fn
{name, value} when is_atom(name) and is_integer(value) ->
%{name: to_string(name), value: value}
{name, value} when is_atom(name) and is_list(value) ->
# Handle charlists in tuple values
%{name: to_string(name), value: convert_oid_value(value)}
value when is_integer(value) ->
%{value: value}
value when is_list(value) ->
# Handle charlists
%{value: convert_oid_value(value)}
name when is_atom(name) ->
%{name: to_string(name)}
end)
end
# Handle tuple OIDs like {:"mib-2", ~c"4"}
defp convert_oid({name, value}) when is_atom(name) do
{name, convert_oid_value(value)}
end
# Handle other OID formats
defp convert_oid(oid), do: oid
# Convert OID values, handling charlists
defp convert_oid_value(value) when is_list(value) do
# Check if it's a charlist that can be converted to string
if Enum.all?(value, fn
i when is_integer(i) -> i >= 0 and i <= 1_114_111
_ -> false
end) do
# Convert charlist to string, then try to parse as integer if possible
str_value = List.to_string(value)
case Integer.parse(str_value) do
# Pure integer string
{int_value, ""} -> int_value
# Keep as string if not pure integer
_ -> str_value
end
else
# Not a charlist, return as-is
value
end
rescue
# If conversion fails, return original
_ -> value
end
defp convert_oid_value(value), do: value
defp convert_syntax(:integer), do: :integer
defp convert_syntax({:integer, constraints}), do: {:integer, convert_constraints(constraints)}
defp convert_syntax(:"octet string"), do: :octet_string
defp convert_syntax({:"octet string", size}), do: {:octet_string, convert_constraints(size)}
defp convert_syntax(:"object identifier"), do: :object_identifier
defp convert_syntax(atom) when is_atom(atom), do: atom
defp convert_constraints(constraints), do: constraints
defp convert_index(:undefined), do: nil
defp convert_index(index_list) when is_list(index_list) do
Enum.map(index_list, fn
{:implied, name} -> {:implied, to_string(name)}
name when is_atom(name) -> to_string(name)
end)
end
defp convert_defval(:undefined), do: nil
defp convert_defval(value), do: value
defp convert_revisions(revisions) when is_list(revisions) do
Enum.map(revisions, fn
{:mc_revision, date, description} ->
%{date: to_string(date), description: clean_description(to_string(description))}
{date, description} ->
%{date: to_string(date), description: clean_description(to_string(description))}
end)
end
defp convert_revisions(_revisions), do: []
defp convert_atom(atom) when is_atom(atom) do
atom |> to_string() |> String.replace("-", "_") |> String.to_atom()
end
# Helper function to compile all MIB files in a directory
defp compile_directory(directory) do
case File.ls(directory) do
{:ok, files} ->
mib_files =
files
|> Enum.filter(&mib_file?/1)
|> Enum.sort()
Logger.debug("Processing #{Path.basename(directory)}: #{length(mib_files)} files")
results = Enum.map(mib_files, &process_mib_file(directory, &1))
successes = Enum.filter(results, &(elem(&1, 0) == :success))
failures = Enum.filter(results, &(elem(&1, 0) == :error))
# Extract MIB data from successes
success_mibs =
Enum.map(successes, fn {:success, file, mib_data} ->
Map.put(mib_data, :source_file, file)
end)
# Extract error info from failures
error_info =
Enum.map(failures, fn {:error, file, reason} ->
%{file: file, error: reason}
end)
%{
directory: directory,
total: length(mib_files),
success: success_mibs,
failures: error_info,
success_count: length(successes),
failure_count: length(failures)
}
{:error, reason} ->
Logger.error("Cannot read directory #{directory}: #{inspect(reason)}")
%{
directory: directory,
total: 0,
success: [],
failures: [%{file: "directory", error: reason}],
success_count: 0,
failure_count: 1
}
end
end
# Process a single MIB file
defp process_mib_file(directory, file) do
file_path = Path.join(directory, file)
with {:ok, content} <- File.read(file_path),
{:ok, mib_data} <- parse(content) do
{:success, file, mib_data}
else
{:error, {:file_read_error, _}} = error ->
error
{:error, reason} ->
{:error, file, reason}
end
end
# Helper to identify MIB files
defp mib_file?(filename) do
String.ends_with?(filename, ".mib") or
(not String.contains?(filename, ".") and not String.ends_with?(filename, ".bin"))
end
# Clean up description strings by trimming lines and removing excessive whitespace
defp clean_description(desc) when is_binary(desc) do
# Early exit for very large descriptions to prevent timeout
if byte_size(desc) > 50_000 do
# For very large descriptions, just do basic cleanup
desc
|> String.trim()
# Truncate to reasonable size
|> String.slice(0, 1000)
|> Kernel.<>(" [truncated]")
else
desc
|> String.split("\n")
|> Enum.map(&String.trim/1)
|> Enum.reject(&(&1 == ""))
|> Enum.join(" ")
|> collapse_whitespace()
|> String.trim()
end
end
defp clean_description(desc), do: desc
# Efficiently collapse multiple whitespace characters into single spaces
defp collapse_whitespace(text) do
text
|> String.split()
|> Enum.join(" ")
end
# Convert charlist error messages to binary strings
defp convert_error_to_string({mib_name, module, message}) when is_binary(mib_name) and is_atom(module) do
# Handle Erlang parser errors like {"MIB-NAME", :parser_module, "error message"}
message_str =
if is_list(message), do: convert_deep_charlist(message), else: to_string(message)
"#{mib_name}: #{message_str}"
end
defp convert_error_to_string({line, module, message}) when is_list(message) do
{line, module, convert_deep_charlist(message)}
end
defp convert_error_to_string(message), do: message
defp convert_deep_charlist(list) when is_list(list) do
# Handle lists of charlists like [[115, 121, 110, ...], [39, 84, 69, ...]]
if list_of_charlists?(list) do
Enum.map_join(list, "", &charlist_to_string/1)
else
# Try to convert as a single charlist
charlist_to_string(list)
end
rescue
# If conversion fails, return original
_ -> list
end
defp convert_deep_charlist(other), do: other
defp list_of_charlists?(list) do
Enum.all?(list, fn
sublist when is_list(sublist) -> charlist?(sublist)
_ -> false
end)
end
defp charlist?(list) do
Enum.all?(list, fn
i when is_integer(i) -> i >= 0 and i <= 1_114_111
# Allow mixed content for improper lists like [115, 121 | ""]
_ -> true
end)
rescue
_ -> false
end
defp charlist_to_string(charlist) when is_list(charlist) do
# Handle improper lists like [115, 121, 110 | ""]
case charlist do
[] ->
""
[head | _tail] when is_integer(head) and head >= 0 and head <= 1_114_111 ->
try do
# Try to convert the whole thing, handling improper lists
convert_improper_charlist(charlist, [])
rescue
# Fallback to inspect if conversion fails
_ -> inspect(charlist)
end
_ ->
inspect(charlist)
end
rescue
_ -> inspect(charlist)
end
defp charlist_to_string(other), do: inspect(other)
defp convert_improper_charlist([], acc) do
acc |> Enum.reverse() |> List.to_string()
end
defp convert_improper_charlist([head | tail], acc) when is_integer(head) do
convert_improper_charlist(tail, [head | acc])
end
defp convert_improper_charlist(other, acc) when is_binary(other) do
# Handle case where tail is a string (like in [115, 121 | ""])
(acc |> Enum.reverse() |> List.to_string()) <> other
end
defp convert_improper_charlist(_, acc) do
# For any other tail, just convert what we have
acc |> Enum.reverse() |> List.to_string()
end
# Convert sub_index from charlist to appropriate format
defp convert_sub_index(sub_index) when is_list(sub_index) do
# Check if it's a charlist that can be converted to string
if Enum.all?(sub_index, fn
i when is_integer(i) -> i >= 0 and i <= 1_114_111
_ -> false
end) do
case List.to_string(sub_index) do
# Handle common cases
# Convert newline to nil (often used as placeholder)
"\n" -> nil
# Convert empty string to nil
"" -> nil
# Keep as string
str -> str
end
else
# If not a pure charlist, return as-is (might be list of integers)
sub_index
end
rescue
# If conversion fails, return original
_ -> sub_index
end
defp convert_sub_index(sub_index), do: sub_index
end