Documents
Builder File Organization
Builder File Organization
Type
Topic
Status
Published
Created
Mar 19, 2026
Updated
May 4, 2026
Created by
Dosu Bot
Updated by
Dosu Bot

Builder File Organization#

Builder File Organization is a project-specific code organization pattern used in the opnDossier project for managing large Go source files by splitting them into domain-specific files within the same package. The pattern follows the naming convention builder_<domain>.go and was established through a series of refactoring pull requests in March 2026 to enforce the project's 800-line file size guideline. This approach maintains a single package boundary while distributing methods of a shared struct across multiple files based on functional domains, improving code maintainability and reducing cognitive load without requiring any API changes.

The pattern was applied to the internal/converter/builder/ package in PR #419, where builder.go was split into three files: the core builder.go containing the MarkdownBuilder struct and system/network/security sections, builder_vpn.go containing VPN infrastructure methods, and builder_services.go containing application services methods. This split follows the same pattern successfully used in PR #415 (report.go) and PR #417 (opnsense.go).

A critical aspect of builder file organization involves markdown table escaping requirements. During the PR #419 file split, 8 fields across VPN-related tables were identified as missing proper markdown escaping, which could potentially allow markdown injection from untrusted OPNsense XML configuration files. The fix required consistent use of formatters.EscapeTableContent() for all user-controllable table content, which introduced a test assertion gotcha: since EscapeTableContent() escapes underscores (_ becomes \_), test assertions must use the escaped form (e.g., "p2p\_tls" instead of "p2p_tls") to match the actual output.

File Organization Pattern#

Naming Convention#

The opnDossier project uses the builder_<domain>.go naming pattern consistently across its codebase for domain-specific file splits. This convention is applied uniformly:

  • builder_<domain>.go - Builder pattern methods (e.g., builder_vpn.go, builder_services.go)
  • validate_<domain>.go - Validation logic (e.g., validate_system.go, validate_network.go)
  • report_<domain>.go - Report generation (e.g., report_statistics.go, report_markdown.go)

The pattern allows for clear semantic grouping while maintaining package-level cohesion. All split files remain in the same Go package, meaning no import statements need to change and no API boundaries are introduced.

Current Builder Package Structure#

The internal/converter/builder/ package is organized into three primary files plus supporting utilities:

Core Files:

  • builder.go (1,552 lines) - Contains the MarkdownBuilder struct definition, ReportBuilder interface (which composes three focused sub-interfaces: SectionBuilder, TableWriter, and ReportComposer), constructor function, and methods for system, network, security, and audit sections. Also includes table builders for firewall rules, NAT rules, interfaces, users, groups, sysctl settings, and IDS configuration.

  • builder_vpn.go (201 lines) - Contains all VPN and network infrastructure methods including IPsec tunnel configuration (BuildIPsecSection, writeIPsecSection), OpenVPN server and client configuration (BuildOpenVPNSection, writeOpenVPNSection), VLAN management (writeVLANSection), static routing (writeStaticRoutesSection), and high availability/CARP configuration (BuildHASection, writeHASection).

  • builder_services.go (218 lines) - Contains all application services methods including DHCP server configuration with summary and static lease tables (WriteDHCPSummaryTable, WriteDHCPStaticLeasesTable, BuildDHCPSummaryTableSet, BuildDHCPStaticLeasesTableSet), DNS resolver settings, SNMP configuration, NTP time synchronization, load balancer monitors, and syslog forwarding (BuildServicesSection, writeServicesSection).

Supporting Files:

  • helpers.go - Shared utility functions including EscapePipeForMarkdown() (pipe-only escaping), TruncateString(), FormatLeaseTime(), and DHCP configuration checkers
  • writer.go - SectionWriter interface for streaming section output
  • errors.go - Builder-specific sentinel errors like ErrNilDevice

Method Distribution Strategy#

All methods in the split files operate on the same *MarkdownBuilder receiver, which implements the ReportBuilder interface through interface composition:

// Defined in builder.go
type MarkdownBuilder struct {
    config *common.CommonDevice
    logger *logging.Logger
    generated time.Time
    toolVersion string
}

// ReportBuilder composes three focused sub-interfaces following the Interface Segregation Principle
type ReportBuilder interface {
    SectionBuilder // 9 methods: Build*Section + SetIncludeTunables
    TableWriter // 11 methods: Write*Table
    ReportComposer // 2 methods: BuildStandardReport, BuildComprehensiveReport
}

// Compile-time assertion ensures MarkdownBuilder satisfies ReportBuilder
var _ ReportBuilder = (*MarkdownBuilder)(nil)

// Method in builder.go
func (b *MarkdownBuilder) BuildSystemSection(data *common.CommonDevice) string { ... }

// Method in builder_vpn.go
func (b *MarkdownBuilder) BuildOpenVPNSection(data *common.CommonDevice) string { ... }

// Method in builder_services.go
func (b *MarkdownBuilder) BuildServicesSection(data *common.CommonDevice) string { ... }

The interface composition pattern (PR #431, closing issue #323) allows consumers to depend on narrower interfaces. For example, HybridGenerator uses a consumer-local reportGenerator interface that combines only auditBuilder (containing SetIncludeTunables and BuildAuditSection) and ReportComposer, deliberately excluding SectionBuilder and TableWriter since it never calls individual section or table methods. The public API continues to work with the full ReportBuilder interface for backward compatibility.

Methods follow a consistent two-tier naming pattern:

  • Build*Section() - Public methods that create a buffer, call the corresponding write*Section() method, and return a string
  • write*Section() - Private methods that accept a *markdown.Markdown parameter and write content directly to it

The comprehensive report builder orchestrates methods from all three files:

func (b *MarkdownBuilder) BuildComprehensiveReport(data *common.CommonDevice) string {
    md := markdown.NewBuffer()

    b.writeSystemSection(md, data) // builder.go
    b.writeNetworkSection(md, data) // builder.go
    b.writeVLANSection(md, data) // builder_vpn.go
    b.writeStaticRoutesSection(md, data) // builder_vpn.go
    b.writeSecuritySection(md, data) // builder.go
    b.writeIPsecSection(md, data) // builder_vpn.go
    b.writeOpenVPNSection(md, data) // builder_vpn.go
    b.writeHASection(md, data) // builder_vpn.go
    b.writeServicesSection(md, data) // builder_services.go

    return md.String()
}

Historical Context#

The File Split Evolution#

The builder file organization pattern emerged from a series of three consecutive refactoring pull requests in March 2026, each applying the same mechanical approach to different packages that had exceeded the project's 800-line file size guideline.

PR #415: report.go Split (March 18, 2026)#

PR #415 established the file split pattern by refactoring internal/processor/report.go (820 lines) into three focused files:

  • report.go (256 lines) - Core types, constructors, accessors, and JSON/YAML serialization
  • report_statistics.go (218 lines) - Statistics computation and translation logic
  • report_markdown.go (368 lines) - Markdown rendering implementation

This was a purely mechanical refactor with no logic changes, no API changes, and no test modifications required. The PR established the principle that file splits should maintain the existing public API while organizing code by functional responsibility.

PR #417: opnsense.go Split (March 19, 2026)#

PR #417 applied the pattern to internal/validator/opnsense.go (1,059 lines, 32% over the 800-line maximum), splitting it into four domain-specific files:

  • opnsense.go (~140 lines) - Main orchestrator with ValidateOpnSenseDocument() entry point and shared helper functions
  • validate_system.go (~365 lines) - System configuration, users, groups, and sysctl validation
  • validate_network.go (~260 lines) - Interface and DHCP validation
  • validate_security.go (~300 lines) - Firewall filter rules and NAT validation

This PR established the critical rule: shared helpers used across domain files stay in the orchestrator file, while domain-specific helpers move with their domain. The refactor also opportunistically fixed several pre-existing quality issues discovered during the code review (sysctl regex bugs, UID/GID deduplication, reserved network bypass, IPv4/v6 mode separation).

PR #419: builder.go Split (March 19, 2026)#

PR #419 completed the pattern by applying it to the builder package, extracting 390 lines from builder.go into two new domain files:

  • builder_vpn.go (201 lines) - VPN infrastructure (IPsec, OpenVPN, HA/CARP, VLAN, static routes)
  • builder_services.go (218 lines) - Application services (DHCP, DNS, SNMP, NTP, load balancer, syslog)

The PR's commit message explicitly cited the motivations:

File size: builder.go exceeded the project's 800-line max guideline — splitting reduces cognitive load
Cohesion: Services (DHCP/DNS/NTP) and VPN (IPsec/OpenVPN/HA) are distinct domains

Critically, this PR also fixed a security issue: 8 fields in VPN-related tables were passing raw strings to table builders instead of using formatters.EscapeTableContent(), which could allow markdown injection from untrusted OPNsense configuration XML.

Common Refactoring Pattern#

All three PRs follow identical principles:

  1. Trigger: A file exceeds the 800-line guideline (or approaches it)
  2. Scope: Split by functional domain while keeping all files in the same package
  3. API: No changes to public interfaces or method signatures
  4. Tests: No test modifications unless opportunistic fixes are included
  5. Verification: All tests must pass; golden files must remain unchanged
  6. Opportunistic improvements: Fix pre-existing issues discovered during the split

Markdown Table Escaping#

A critical aspect of the builder file organization involves proper markdown table escaping to prevent injection attacks from untrusted OPNsense XML configuration files. The opnDossier project uses two distinct escaping functions with different scopes and purposes.

EscapeTableContent() - Comprehensive Escaping#

The formatters.EscapeTableContent() function provides defense-in-depth by escaping all markdown special characters that could break table formatting or enable injection attacks:

// From internal/converter/formatters/utils.go
var escapeTableReplacer = strings.NewReplacer(
    "\\", "\\\\", // Backslash
    "*", "\\*", // Asterisk (bold/italic)
    "_", "\\_", // Underscore (bold/italic)
    "`", "\\`", // Backtick (code)
    "[", "\[", // Left bracket (links)
    "]", "\]", // Right bracket (links)
    "<", "\\<", // Less than (HTML)
    ">", "\\>", // Greater than (HTML)
    "|", "\\|", // Pipe (table delimiter)
    "\r\n", " ", // Windows newline
    "\n", " ", // Unix newline
    "\r", " ", // Mac newline
)

func EscapeTableContent(content any) string {
    // Fast path for unnamed string
    if str, ok := content.(string); ok {
        return stringEscape(str)
    }
    // Fast path for named string types
    v := reflect.ValueOf(content)
    if v.Kind() == reflect.String {
        return stringEscape(v.String())
    }
    // Fallback for other types
    return stringEscape(fmt.Sprintf("%v", content))
}

func stringEscape(str string) string {
    return strings.TrimSpace(escapeTableReplacer.Replace(str))
}

The function uses a pre-compiled strings.Replacer for performance, performing all replacements in a single pass. It handles nil values gracefully and converts all newline variants to spaces to prevent table row breaks.

The implementation includes fast paths for string types that skip fmt.Sprintf("%v", ...) reflection boxing. The unnamed string path handles common per-row formatter callers, while the reflect.Kind == String path covers named string types like FirewallRuleType, IPProtocol, and VIPMode. These fast paths reduce allocations by 41% in firewall rule table generation benchmarks.

EscapePipeForMarkdown() - Minimal Escaping#

The EscapePipeForMarkdown() function in builder/helpers.go provides pipe-only escaping for content that is already partially formatted:

// EscapePipeForMarkdown escapes pipe characters for safe display in markdown table cells.
// Unlike formatters.EscapeTableContent which escapes all markdown special characters,
// this function only escapes pipes for table cell safety when content is already
// partially formatted.
func EscapePipeForMarkdown(s string) string {
    return strings.ReplaceAll(s, "|", "\\|")
}

This lighter approach is used exclusively in the audit section where compliance findings may already contain markdown formatting that should be preserved.

Usage Patterns#

The standard pattern for building table rows with proper escaping:

// From builder_vpn.go - OpenVPN servers table
rows := make([][]string, 0, len(servers))
for _, server := range servers {
    rows = append(rows, []string{
        formatters.EscapeTableContent(server.Description), // User-provided text
        formatters.EscapeTableContent(server.Mode), // Enum value (defensive)
        formatters.EscapeTableContent(server.Protocol), // Enum value (defensive)
        formatters.EscapeTableContent(server.Interface), // Interface name
        formatters.EscapeTableContent(server.LocalPort), // Port number
        formatters.EscapeTableContent(server.TunnelNetwork),// Network CIDR
        formatters.EscapeTableContent(server.RemoteNetwork),// Network CIDR
        formatters.EscapeTableContent(server.CertRef), // Certificate reference
    })
}

When to use each function:

FunctionUse CaseExample Fields
formatters.EscapeTableContent()All user-controllable or untrusted contentDescriptions, names, ports, addresses, certificate refs, any XML-sourced data
EscapePipeForMarkdown()Pre-formatted content in audit/compliance sectionsCompliance finding descriptions that may contain markdown
NeitherPre-formatted boolean output, numeric indicesformatters.FormatBool(), strconv.Itoa() results

The PR #419 Escaping Fixes#

During the builder.go file split, 8 fields across VPN-related tables were identified as missing proper escaping:

OpenVPN Servers table (4 fields):

  • Mode - Was server.Mode, fixed to formatters.EscapeTableContent(server.Mode)
  • Protocol - Was server.Protocol, fixed to formatters.EscapeTableContent(server.Protocol)
  • Interface - Was server.Interface, fixed to formatters.EscapeTableContent(server.Interface)
  • LocalPort - Was server.LocalPort, fixed to formatters.EscapeTableContent(server.LocalPort)

OpenVPN Clients table (3 fields):

  • ServerPort - Was client.ServerPort, fixed to formatters.EscapeTableContent(client.ServerPort)
  • Mode - Was client.Mode, fixed to formatters.EscapeTableContent(client.Mode)
  • Protocol - Was client.Protocol, fixed to formatters.EscapeTableContent(client.Protocol)

Virtual IPs table (1 field):

  • Mode - Was vip.Mode, fixed to formatters.EscapeTableContent(vip.Mode)

HA Synchronization Settings (1 field):

  • PfsyncVersion - Was hasync.PfsyncVersion, fixed to formatters.EscapeTableContent(hasync.PfsyncVersion)

These fixes provide defense-in-depth against potential markdown injection attacks when processing untrusted OPNsense XML configuration files.

Test Assertion Gotchas#

A critical but easily overlooked consequence of markdown table escaping is its impact on test assertions. When content passes through formatters.EscapeTableContent(), special characters are transformed, requiring test assertions to match the escaped output rather than the original input.

The p2p_tls Problem#

The most common gotcha involves underscores in test assertions. Since EscapeTableContent() escapes underscores (_ becomes \_), any test assertion checking for content with underscores must use the escaped form.

PR #419 demonstrates this issue in the OpenVPN section tests:

Before the escaping fix:

func TestMarkdownBuilder_BuildOpenVPNSection_WithServers(t *testing.T) {
    b := builder.NewMarkdownBuilder()
    data := createTestDocumentWithOpenVPN() // Sets Mode: "p2p_tls"

    output := b.BuildOpenVPNSection(data)

    expectedContent := []string{
        "### OpenVPN Configuration",
        "Site-to-Site VPN",
        "p2p_tls", // ❌ WRONG - Won't match escaped output
        "UDP",
    }

    for _, content := range expectedContent {
        if !strings.Contains(output, content) {
            t.Errorf("Expected OpenVPN section to contain '%s'", content)
        }
    }
}

After the escaping fix:

func TestMarkdownBuilder_BuildOpenVPNSection_WithServers(t *testing.T) {
    b := builder.NewMarkdownBuilder()
    data := createTestDocumentWithOpenVPN() // Sets Mode: "p2p_tls"

    output := b.BuildOpenVPNSection(data)

    expectedContent := []string{
        "### OpenVPN Configuration",
        "Site-to-Site VPN",
        "p2p\\_tls", // ✅ CORRECT - Matches escaped output
        "UDP",
    }

    for _, content := range expectedContent {
        if !strings.Contains(output, content) {
            t.Errorf("Expected OpenVPN section to contain '%s'", content)
        }
    }
}

String Literal Gotcha#

In Go, the double backslash in "p2p\\_tls" is actually a single backslash in the resulting string. Go string literals interpret \\ as an escaped backslash character:

// Both produce the same result: "p2p\_tls" (with literal backslash)
interpreted := "p2p\\_tls" // Interpreted string with escaped backslash
raw := `p2p\_tls` // Raw string literal (backticks)

fmt.Println(interpreted == raw) // true

Common Fields Affected by Escaping#

Test assertions must account for escaped characters in these common OPNsense field types:

Field TypeExample InputEscaped OutputCommon In
OpenVPN modep2p_tls, server_tlsp2p\_tls, server\_tlsVPN configurations
Interface namesopt_interface, wan_vlanopt\_interface, wan\_vlanNetwork interfaces
Rule descriptionsblock_wan_traffic, allow_httpsblock\_wan\_traffic, allow\_httpsFirewall rules
Certificate refscert_2024_wildcardcert\_2024\_wildcardTLS/SSL configurations
User/group namesadmin_group, db_useradmin\_group, db\_userSystem accounts

Golden File Testing#

The project uses sebdah/goldie/v2 for comprehensive output validation. Golden files capture the exact escaped output:

// From golden_test.go
func TestGolden_ComprehensiveReport(t *testing.T) {
    converter := NewMarkdownConverter()
    doc := loadTestFixture(t, "complete.json")

    result, err := converter.Convert(doc)
    require.NoError(t, err)

    g := newGoldie(t)
    g.Assert(t, "comprehensive_report", []byte(result)) // Compares against .golden.md file
}

Golden files contain the literal escaped output:

| Site-to-Site VPN | p2p\_tls | UDP | wan | 1194 | 10.8.0.0/24 | 192.168.100.0/24 |

Regenerating golden files:

# Update golden files when escaping changes are intentional
go test -v ./internal/converter -run TestGolden -update

# Always review the diff
git diff internal/converter/testdata/golden/

Best Practices for Test Assertions#

  1. Use escaped strings - Always match the actual output format:

    assert.Contains(t, output, "p2p\\_tls") // ✅ Correct
    assert.Contains(t, output, "p2p_tls") // ❌ Wrong
    
  2. Test the raw input separately - Verify input data uses unescaped values:

    assert.Equal(t, "p2p_tls", server.Mode) // ✅ Input validation
    
  3. Use golden files for comprehensive checks - Golden files catch all escaping changes automatically:

    g.Assert(t, t.Name(), []byte(result)) // Validates entire output
    
  4. Document escaping in test comments - Make the escaping explicit:

    // Mode is "p2p_tls" in input, but escaped to "p2p\_tls" in output
    assert.Contains(t, output, "p2p\\_tls")
    
  5. Check edge cases - The edge_cases.json test fixture tests all escaped characters:

    {
      "name": "interface`with`backticks",
      "description": "Rule with | pipe and * asterisk and _ underscore"
    }
    

Best Practices for Builder File Organization#

File Split Checklist#

When creating new builder_<domain>.go files or splitting existing files, follow this comprehensive checklist based on documented refactoring patterns:

1. File Naming and Structure

  • ✅ Use builder_<domain>.go pattern (e.g., builder_routing.go, builder_ids.go)
  • ✅ Keep files under 800 lines (project maximum guideline)
  • ✅ Maintain package builder declaration (same package as parent file)
  • ✅ Group related functionality by functional domain, not implementation details

2. Method Organization

  • ✅ All methods use *MarkdownBuilder receiver
  • ✅ Public Build*Section() methods create buffers and return strings
  • ✅ Private write*Section() methods accept *markdown.Markdown and write directly
  • ✅ Public Write*Table() methods return *markdown.Markdown for method chaining

3. Helper Function Placement

  • Shared helpers (used across multiple domains) stay in builder.go
  • Domain-specific helpers move with their domain to the new file
  • ✅ Document helper placement decisions in code comments

4. Markdown Escaping Requirements

  • ✅ Use formatters.EscapeTableContent() for all user-controllable or XML-sourced table content
  • ✅ Use EscapePipeForMarkdown() only for pre-formatted audit/compliance content
  • ✅ Never escape pre-formatted output from formatters.FormatBool() or numeric conversions
  • ✅ Apply escaping consistently within each table (don't mix escaped and unescaped fields)

5. Test Adjustments

  • ✅ Update test assertions to use escaped forms (e.g., "p2p\\_tls" not "p2p_tls")
  • ✅ Run all tests: go test ./internal/converter/builder/
  • ✅ Regenerate and review golden files if escaping changes: go test ./internal/converter -run TestGolden -update
  • ✅ Review golden file diffs: git diff internal/converter/testdata/golden/

6. Build and CI Verification

  • ✅ Verify no duplicate declarations: go build ./internal/converter/builder/
  • ✅ Run full CI check: just ci-check (includes pre-commit, format, lint, tests)
  • ✅ Check file line counts: wc -l internal/converter/builder/*.go
  • ✅ Verify no test package imports broke (same-package tests should find functions automatically)

Refactoring Gotchas#

Documented in the project's refactoring patterns guide, these are common pitfalls when splitting builder files:

1. Pre-commit Hook Interference

The gofumpt formatter with extra-rules: true can silently rearrange helper functions back into the parent file after commit:

# Always re-verify after pre-commit hooks run
git add internal/converter/builder/*.go
git commit -m "refactor: split builder_domain.go"

# IMMEDIATELY after commit, verify:
wc -l internal/converter/builder/*.go # Check line counts match expectations
go build ./internal/converter/builder/ # Verify no duplicate declarations

2. Test Breakage on Signature Changes

Unexported function signature changes break same-package test files that call those functions directly. Before changing any unexported function:

# Search for usages in test files
grep -rn 'functionName(' internal/converter/builder/*_test.go

3. Golden File Surprises

Golden files must remain byte-for-byte identical unless rendering logic changes intentionally:

# After file split, verify NO golden file changes
go test ./internal/converter -run TestGolden
git status internal/converter/testdata/golden/ # Should show no changes

# If golden files changed unexpectedly, investigate rendering regression
git diff internal/converter/testdata/golden/

4. Shared vs. Domain Helper Confusion

Incorrectly placing helpers causes either duplicate code or broken encapsulation:

// ❌ WRONG: Domain-specific helper left in builder.go
func formatIPsecPhase(phase string) string { ... } // Only used by VPN methods

// ✅ CORRECT: Move domain helper to builder_vpn.go
// In builder_vpn.go:
func formatIPsecPhase(phase string) string { ... }

// ❌ WRONG: Shared helper moved to domain file
func EscapePipeForMarkdown(s string) string { ... } // Used by multiple domains

// ✅ CORRECT: Keep shared helper in builder.go

Code Review Checklist#

Use this checklist when reviewing builder file split PRs:

  • File names follow builder_<domain>.go convention
  • All split files under 800 lines
  • Package declaration is package builder in all files
  • No API changes (public method signatures unchanged)
  • Shared helpers stay in builder.go, domain helpers move with domain
  • All table content uses formatters.EscapeTableContent() consistently
  • Test assertions use escaped string forms where applicable
  • go build ./internal/converter/builder/ succeeds
  • just ci-check passes completely
  • Golden files either unchanged or diff is intentional and reviewed
  • PR description explains domain split rationale
  • Line count reduction documented in PR description or commit message

Code Examples#

Basic Builder Method Structure#

Methods in split files follow the same structure as the original builder.go:

// Public method - creates buffer and returns string
func (b *MarkdownBuilder) BuildOpenVPNSection(data *common.CommonDevice) string {
    if data == nil || data.VPN.OpenVPN.IsEmpty() {
        return ""
    }

    md := markdown.NewBuffer()
    b.writeOpenVPNSection(md, data)
    return md.String()
}

// Private method - writes directly to buffer
func (b *MarkdownBuilder) writeOpenVPNSection(md *markdown.Markdown, data *common.CommonDevice) {
    if data.VPN.OpenVPN.IsEmpty() {
        return
    }

    md.H3("OpenVPN Configuration")

    if len(data.VPN.OpenVPN.Servers) > 0 {
        md.H4("OpenVPN Servers")
        b.WriteOpenVPNServersTable(md, data.VPN.OpenVPN.Servers)
    }

    if len(data.VPN.OpenVPN.Clients) > 0 {
        md.H4("OpenVPN Clients")
        b.WriteOpenVPNClientsTable(md, data.VPN.OpenVPN.Clients)
    }
}

Table Building with Proper Escaping#

From builder_vpn.go:

func (b *MarkdownBuilder) WriteOpenVPNServersTable(
    md *markdown.Markdown,
    servers []common.OpenVPNServer,
) *markdown.Markdown {
    rows := make([][]string, 0, len(servers))

    for _, server := range servers {
        rows = append(rows, []string{
            formatters.EscapeTableContent(server.Description), // User-provided text
            formatters.EscapeTableContent(server.Mode), // e.g., "p2p_tls" → "p2p\_tls"
            formatters.EscapeTableContent(server.Protocol), // e.g., "UDP"
            formatters.EscapeTableContent(server.Interface), // e.g., "wan"
            formatters.EscapeTableContent(server.LocalPort), // e.g., "1194"
            formatters.EscapeTableContent(server.TunnelNetwork), // e.g., "10.8.0.0/24"
            formatters.EscapeTableContent(server.RemoteNetwork), // e.g., "192.168.100.0/24"
            formatters.EscapeTableContent(server.CertRef), // Certificate reference
        })
    }

    return md.Table(markdown.TableSet{
        Header: []string{"Description", "Mode", "Protocol", "Interface", "Port", "Tunnel Network", "Remote Network", "Certificate"},
        Rows: rows,
    })
}

Test Assertions with Escaped Characters#

From writer_test.go:

func TestMarkdownBuilder_BuildOpenVPNSection_WithServers(t *testing.T) {
    t.Parallel()

    // Arrange
    b := builder.NewMarkdownBuilder()
    data := &common.CommonDevice{
        VPN: common.VPN{
            OpenVPN: common.OpenVPN{
                Servers: []common.OpenVPNServer{
                    {
                        Description: "Site-to-Site VPN",
                        Mode: "p2p_tls", // Input: unescaped
                        Protocol: "UDP",
                        Interface: "wan",
                        LocalPort: "1194",
                        TunnelNetwork: "10.8.0.0/24",
                        RemoteNetwork: "192.168.100.0/24",
                        CertRef: "cert123",
                    },
                },
            },
        },
    }

    // Act
    output := b.BuildOpenVPNSection(data)

    // Assert - note the escaped underscore in "p2p\_tls"
    expectedContent := []string{
        "### OpenVPN Configuration",
        "#### OpenVPN Servers",
        "Site-to-Site VPN",
        "p2p\\_tls", // Output: escaped (underscore becomes \_)
        "UDP",
        "wan",
        "1194",
        "10.8.0.0/24",
        "192.168.100.0/24",
        "cert123",
    }

    for _, content := range expectedContent {
        assert.Contains(t, output, content, 
            "Expected OpenVPN section to contain '%s'", content)
    }
}

Comprehensive Report Orchestration#

From builder.go:

// BuildComprehensiveReport generates a complete markdown report with all sections.
// It orchestrates methods from builder.go, builder_vpn.go, and builder_services.go.
func (b *MarkdownBuilder) BuildComprehensiveReport(data *common.CommonDevice) string {
    md := markdown.NewBuffer()

    // Metadata and intro
    b.writeReportHeader(md, data)
    md.TOC()

    // Core sections from builder.go
    b.writeSystemSection(md, data)
    b.writeNetworkSection(md, data)

    // VPN infrastructure from builder_vpn.go
    b.writeVLANSection(md, data)
    b.writeStaticRoutesSection(md, data)

    // Security from builder.go
    b.writeSecuritySection(md, data)
    b.writeIDSSection(md, data)

    // VPN configuration from builder_vpn.go
    b.writeIPsecSection(md, data)
    b.writeOpenVPNSection(md, data)
    b.writeHASection(md, data)

    // Application services from builder_services.go
    b.writeServicesSection(md, data)

    // Compliance from builder.go
    b.writeAuditSection(md, data)

    return md.String()
}

Relevant Code Files#

FilePurposeLinesKey Contents
builder.goCore builder implementation1,552MarkdownBuilder struct, ReportBuilder interface (composed from SectionBuilder, TableWriter, ReportComposer), system/network/security/audit sections, firewall/NAT/interface/user/group/sysctl/IDS tables, compile-time assertion var _ ReportBuilder = (*MarkdownBuilder)(nil)
builder_vpn.goVPN infrastructure methods201IPsec, OpenVPN servers/clients, VLAN, static routes, HA/CARP, virtual IPs
builder_services.goApplication services methods218DHCP summary/static leases, DNS resolver, SNMP, NTP, load balancer, syslog
helpers.goShared utility functions412EscapePipeForMarkdown(), TruncateString(), FormatLeaseTime(), DHCP config checkers
formatters/utils.goMarkdown escaping utilities42EscapeTableContent() implementation, escapeTableReplacer
writer.goStreaming output interface-SectionWriter interface definition
errors.goBuilder-specific errors-ErrNilDevice, ErrInvalidConfig sentinel errors
hybrid_generator.goHybrid report generator-Consumer-local reportGenerator and auditBuilder interfaces, two-value type assertion in GetBuilder()
writer_test.goBuilder method tests-Tests for all section builders, includes p2p_tls assertion example
golden_test.goGolden file testing infrastructure-sebdah/goldie/v2 integration, custom normalization for timestamps/versions
testdata/edge_cases.jsonEscaping edge case tests-Test fixtures for backticks, pipes, asterisks, underscores, brackets
  • Report Generation Architecture - Three-tier converter architecture (formatters → builder → hybrid generator) that provides context for the builder package's role in the overall system

  • Refactoring Patterns - Project-wide file split pattern documentation covering PRs #415 (report.go), #417 (opnsense.go), and #419 (builder.go), including the 800-line guideline and shared helper placement rules

  • Markdown Builder Pattern - Detailed documentation of table generation patterns, method chaining with *markdown.Markdown, and the distinction between Build*Section() and write*Section() methods

  • CI Pipeline Gotchas - Common pitfalls in the CI/CD pipeline including pre-commit hook interference with gofumpt, golden file regeneration procedures, and test assertion patterns

  • Golden File Testing - Comprehensive guide to golden file testing with sebdah/goldie/v2, custom normalization strategies, and the three-level assertion approach (content presence, structure validation, golden comparison)

See Also#

Builder File Organization | Dosu