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