Skip to content

Commit f630082

Browse files
authored
Merge pull request #26 from mendsec/feature/m2-contracts
feat: implement M2 contracts (issues 5, 6, 7, 8)
2 parents 98e8764 + 523a0b5 commit f630082

9 files changed

Lines changed: 141 additions & 21 deletions

File tree

‎README.md‎

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ Before adopting `catnet-core`, please review our [API Stability Policy](docs/con
1515
| `pkg/targets` | `ParseRange` | Target parsing and CIDR utilities. |
1616
| `pkg/discovery` | `Ping`, `ReverseDNS`, `GetMAC` | Host liveness and resolution primitives. |
1717
| `pkg/ports` | `ScanPorts` | Port scanning utilities. |
18-
| `pkg/exporter` | `ExportJSON`, `ExportXML`, `ExportCSV` | Safe result export functions (with CSV injection prevention). |
18+
| `pkg/exporter` | `ExportJSON`, `ExportXML`, `ExportCSV` | Safe result export functions. JSON is the canonical schema reference format. |
1919
| `pkg/scanner` | *Deprecated* | Temporary compatibility wrapper. Do not use for new code. |
2020

2121
## Quickstart
@@ -39,16 +39,25 @@ func main() {
3939
ctx, cancel := context.WithCancel(context.Background())
4040
defer cancel()
4141

42-
err := engine.StartScan(ctx, ips, cfg, func(d results.DeviceInfo) {
43-
if d.IsAlive {
44-
fmt.Printf("Found: %s (%s)\n", d.IP, d.MAC)
42+
report, err := engine.StartScan(ctx, ips, cfg, func(event engine.ScanEvent) {
43+
switch event.Type {
44+
case engine.EventResult:
45+
if event.Device != nil && event.Device.IsAlive {
46+
fmt.Printf("Found: %s (%s)\n", event.Device.IP, event.Device.MAC)
47+
}
48+
case engine.EventProgress:
49+
fmt.Printf("Progress: %.2f%%\n", event.Progress*100)
50+
case engine.EventLifecycleStart:
51+
fmt.Println("Scan started...")
52+
case engine.EventLifecycleComplete:
53+
fmt.Println("Scan completed!")
4554
}
46-
}, func(progress float64) {
47-
fmt.Printf("Progress: %.2f%%\n", progress*100)
4855
})
4956

5057
if err != nil {
5158
fmt.Printf("Scan failed: %v\n", err)
59+
} else {
60+
fmt.Printf("Total Scanned: %d, Alive: %d\n", report.Total, report.Alive)
5261
}
5362
}
5463
```

‎docs/contracts/events.md‎

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
# Scan Lifecycle Events
2+
3+
The `catnet-core` engine exposes a stream of typed events during scan execution to allow consumers (CLI, TUI, GUI) to react to progress and lifecycle changes without coupling to internal logic.
4+
5+
## Event Signature
6+
7+
Events are dispatched synchronously via the `engine.EventCallback` function:
8+
```go
9+
type EventCallback func(event ScanEvent)
10+
```
11+
12+
## ScanEvent Struct
13+
14+
```go
15+
type ScanEvent struct {
16+
Type ScanEventType
17+
Device *results.DeviceInfo
18+
Progress float64
19+
Message string
20+
}
21+
```
22+
23+
## Event Types
24+
25+
| Type | Description | Included Data |
26+
|------|-------------|---------------|
27+
| `EventLifecycleStart` | Fired once when `StartScan` is invoked. | `Message` |
28+
| `EventLifecycleComplete` | Fired once when `StartScan` completes successfully. | `Message` |
29+
| `EventLifecycleCancel` | Fired once when the scan is cancelled or times out. | `Message` |
30+
| `EventWarning` | Fired when a non-fatal error occurs (e.g. permission issues). | `Message` |
31+
| `EventProgress` | Fired periodically to indicate scan progress. | `Progress` (0.0 to 1.0) |
32+
| `EventResult` | Fired when a device scan completes (whether alive or dead). | `Device` |
33+
34+
## Consumer Responsibilities
35+
36+
1. **Non-blocking callbacks:** Event callbacks are executed synchronously by the engine's worker goroutines. Consumers **must not** perform long-blocking operations in the callback. If processing requires heavy I/O, dispatch it to a channel or separate goroutine.
37+
2. **Thread safety:** The `Device` pointer in `EventResult` points to a copied value, but consumers should avoid mutating it.

‎pkg/coreerr/errors.go‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
package coreerr
2+
3+
import "errors"
4+
5+
// Sentinel errors representing the structured taxonomy for core operations.
6+
var (
7+
ErrInvalidInput = errors.New("invalid input")
8+
ErrTimeout = errors.New("operation timed out")
9+
ErrCancelled = errors.New("operation cancelled")
10+
ErrPermission = errors.New("permission denied")
11+
ErrExport = errors.New("export failed")
12+
ErrPartial = errors.New("partial failure")
13+
)

‎pkg/coreerr/errors_test.go‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
package coreerr
2+
3+
import (
4+
"errors"
5+
"fmt"
6+
"testing"
7+
)
8+
9+
func TestErrorWrapping(t *testing.T) {
10+
err := fmt.Errorf("scan failed: %w", ErrTimeout)
11+
12+
if !errors.Is(err, ErrTimeout) {
13+
t.Errorf("Expected err to wrap ErrTimeout, but errors.Is returned false")
14+
}
15+
16+
if errors.Is(err, ErrCancelled) {
17+
t.Errorf("Expected err to not wrap ErrCancelled")
18+
}
19+
}

‎pkg/engine/events.go‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,11 @@ import "github.com/mendsec/catnet-core/pkg/results"
66
type ScanEventType int
77

88
const (
9-
EventResult ScanEventType = iota
9+
EventLifecycleStart ScanEventType = iota
10+
EventLifecycleComplete
11+
EventLifecycleCancel
12+
EventWarning
13+
EventResult
1014
EventProgress
1115
)
1216

@@ -15,6 +19,7 @@ type ScanEvent struct {
1519
Type ScanEventType
1620
Device *results.DeviceInfo
1721
Progress float64
22+
Message string
1823
}
1924

2025
// EventCallback é a assinatura da função de callback síncrono.

‎pkg/engine/scan.go‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,12 @@ package engine
22

33
import (
44
"context"
5+
"fmt"
56
"sync"
67
"sync/atomic"
78
"time"
89

10+
"github.com/mendsec/catnet-core/pkg/coreerr"
911
"github.com/mendsec/catnet-core/pkg/discovery"
1012
"github.com/mendsec/catnet-core/pkg/ports"
1113
"github.com/mendsec/catnet-core/pkg/results"
@@ -17,8 +19,22 @@ func StartScan(ctx context.Context, ips []string, cfg ScanConfig, onEvent EventC
1719
total := len(ips)
1820
report.Total = total
1921
report.Devices = make([]results.DeviceInfo, 0, total)
22+
23+
if onEvent != nil {
24+
onEvent(ScanEvent{
25+
Type: EventLifecycleStart,
26+
Message: "Scan started",
27+
})
28+
}
29+
2030
if total == 0 {
2131
report.EndTime = time.Now()
32+
if onEvent != nil {
33+
onEvent(ScanEvent{
34+
Type: EventLifecycleComplete,
35+
Message: "Scan completed (empty)",
36+
})
37+
}
2238
return report, nil
2339
}
2440

@@ -104,5 +120,21 @@ func StartScan(ctx context.Context, ips []string, cfg ScanConfig, onEvent EventC
104120
}
105121
wg.Wait()
106122
report.EndTime = time.Now()
123+
124+
if ctx.Err() == context.DeadlineExceeded {
125+
if onEvent != nil {
126+
onEvent(ScanEvent{Type: EventLifecycleCancel, Message: "Scan timeout"})
127+
}
128+
return report, fmt.Errorf("%w: scan reached timeout", coreerr.ErrTimeout)
129+
} else if ctx.Err() == context.Canceled {
130+
if onEvent != nil {
131+
onEvent(ScanEvent{Type: EventLifecycleCancel, Message: "Scan cancelled"})
132+
}
133+
return report, fmt.Errorf("%w: scan was cancelled", coreerr.ErrCancelled)
134+
}
135+
136+
if onEvent != nil {
137+
onEvent(ScanEvent{Type: EventLifecycleComplete, Message: "Scan completed successfully"})
138+
}
107139
return report, nil
108140
}

‎pkg/exporter/exporter.go‎

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"encoding/json"
77
"encoding/xml"
88
"fmt"
9+
"github.com/mendsec/catnet-core/pkg/coreerr"
910
"github.com/mendsec/catnet-core/pkg/results"
1011
"strconv"
1112
"strings"
@@ -15,7 +16,7 @@ import (
1516
func ExportJSON(report *results.ScanReport) ([]byte, error) {
1617
out, err := json.MarshalIndent(report, "", " ")
1718
if err != nil {
18-
return nil, fmt.Errorf("failed to encode JSON: %w", err)
19+
return nil, fmt.Errorf("%w: failed to encode JSON: %v", coreerr.ErrExport, err)
1920
}
2021
return out, nil
2122
}
@@ -44,7 +45,7 @@ func ExportXML(report *results.ScanReport) ([]byte, error) {
4445
}
4546
out, err := xml.MarshalIndent(res, "", "\t")
4647
if err != nil {
47-
return nil, fmt.Errorf("failed to encode XML: %w", err)
48+
return nil, fmt.Errorf("%w: failed to encode XML: %v", coreerr.ErrExport, err)
4849
}
4950
return append([]byte("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n"), out...), nil
5051
}
@@ -65,7 +66,7 @@ func ExportCSV(report *results.ScanReport) ([]byte, error) {
6566
var buf bytes.Buffer
6667
writer := csv.NewWriter(&buf)
6768
if err := writer.Write([]string{"IP", "Hostname", "MAC", "Status", "Open Ports"}); err != nil {
68-
return nil, err
69+
return nil, fmt.Errorf("%w: failed to write CSV header: %v", coreerr.ErrExport, err)
6970
}
7071
for _, d := range report.Devices {
7172
status := "Dead"
@@ -79,7 +80,7 @@ func ExportCSV(report *results.ScanReport) ([]byte, error) {
7980
if err := writer.Write([]string{
8081
sanitizeCSVField(d.IP), sanitizeCSVField(d.Hostname), sanitizeCSVField(d.MAC), status, strings.Join(strPorts, ";"),
8182
}); err != nil {
82-
return nil, err
83+
return nil, fmt.Errorf("%w: failed to write CSV record: %v", coreerr.ErrExport, err)
8384
}
8485
}
8586
writer.Flush()

‎pkg/results/report.go‎

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,9 @@ import "time"
44

55
// ScanReport encapsula o resultado completo de uma varredura.
66
type ScanReport struct {
7-
StartTime time.Time `json:"startTime"`
8-
EndTime time.Time `json:"endTime"`
7+
SchemaVersion string `json:"schemaVersion"`
8+
StartTime time.Time `json:"startTime"`
9+
EndTime time.Time `json:"endTime"`
910
Total int `json:"total"`
1011
Alive int `json:"alive"`
1112
Devices []DeviceInfo `json:"devices"`
@@ -14,7 +15,8 @@ type ScanReport struct {
1415
// NewScanReport cria um novo relatório de varredura.
1516
func NewScanReport() *ScanReport {
1617
return &ScanReport{
17-
StartTime: time.Now(),
18-
Devices: []DeviceInfo{},
18+
SchemaVersion: "1.0.0",
19+
StartTime: time.Now(),
20+
Devices: []DeviceInfo{},
1921
}
2022
}

‎pkg/targets/parse.go‎

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,15 @@ import (
55
"fmt"
66
"net"
77
"strings"
8+
9+
"github.com/mendsec/catnet-core/pkg/coreerr"
810
)
911

1012
// ParseRange interpreta uma string e retorna uma lista de endereços IP correspondentes.
1113
func ParseRange(input string) ([]string, error) {
1214
input = strings.TrimSpace(input)
1315
if input == "" {
14-
return nil, fmt.Errorf("empty input")
16+
return nil, fmt.Errorf("%w: empty input", coreerr.ErrInvalidInput)
1517
}
1618
if strings.Contains(input, "/") {
1719
return parseCIDR(input)
@@ -21,7 +23,7 @@ func ParseRange(input string) ([]string, error) {
2123
}
2224
parsed := net.ParseIP(input)
2325
if parsed == nil {
24-
return nil, fmt.Errorf("invalid IP format")
26+
return nil, fmt.Errorf("%w: invalid IP format", coreerr.ErrInvalidInput)
2527
}
2628
return []string{parsed.String()}, nil
2729
}
@@ -53,7 +55,7 @@ func parseCIDR(cidr string) ([]string, error) {
5355
func parseDashRange(dashStr string) ([]string, error) {
5456
parts := strings.Split(dashStr, "-")
5557
if len(parts) != 2 {
56-
return nil, fmt.Errorf("invalid range format, expected start-end")
58+
return nil, fmt.Errorf("%w: invalid range format, expected start-end", coreerr.ErrInvalidInput)
5759
}
5860
startStr := strings.TrimSpace(parts[0])
5961
endStr := strings.TrimSpace(parts[1])
@@ -66,15 +68,15 @@ func parseDashRange(dashStr string) ([]string, error) {
6668
startIP := net.ParseIP(startStr).To4()
6769
endIP := net.ParseIP(endStr).To4()
6870
if startIP == nil || endIP == nil {
69-
return nil, fmt.Errorf("invalid IP in range")
71+
return nil, fmt.Errorf("%w: invalid IP in range", coreerr.ErrInvalidInput)
7072
}
7173
start := binary.BigEndian.Uint32(startIP)
7274
end := binary.BigEndian.Uint32(endIP)
7375
if start > end {
74-
return nil, fmt.Errorf("start IP is greater than end IP")
76+
return nil, fmt.Errorf("%w: start IP is greater than end IP", coreerr.ErrInvalidInput)
7577
}
7678
if end-start > 65536 {
77-
return nil, fmt.Errorf("range too large (max 65536)")
79+
return nil, fmt.Errorf("%w: range too large (max 65536)", coreerr.ErrInvalidInput)
7880
}
7981
var ips []string
8082
for i := start; i <= end; i++ {

0 commit comments

Comments
 (0)