add kerio format support

This commit is contained in:
2026-07-17 11:42:34 +07:00
parent 5d296c39b1
commit b352cda166
8 changed files with 393 additions and 2 deletions
+109
View File
@@ -1,9 +1,11 @@
package csvimport
import (
"bufio"
"encoding/csv"
"fmt"
"io"
"regexp"
"strings"
)
@@ -52,3 +54,110 @@ func Parse(r io.Reader) ([]Row, error) {
}
return rows, nil
}
// Kerio Connect exports users as a semicolon-separated file whose first line is
// a header. Only these columns matter; the rest (quotas, last login, …) is
// ignored. The file carries no domain — the caller supplies it.
const (
kerioColName = 0 // login without the domain part
kerioColDescription = 2 // Kerio keeps the plaintext password here
kerioColEnable = 3 // "Yes" / "No"
kerioMinColumns = 4
)
// domainRe accepts a bare DNS domain: labels of alphanumerics/hyphens with at
// least one dot and no scheme, user part, or whitespace.
var domainRe = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$`)
func normalizeDomain(domain string) (string, error) {
d := strings.ToLower(strings.TrimSpace(domain))
if d == "" {
return "", fmt.Errorf("domain is required")
}
if !domainRe.MatchString(d) {
return "", fmt.Errorf("invalid domain %q", domain)
}
return d, nil
}
// skipBOM consumes a leading UTF-8 byte-order mark, which Kerio writes into its
// exports and encoding/csv would otherwise glue onto the first header field.
func skipBOM(br *bufio.Reader) error {
b, err := br.Peek(3)
if err != nil && err != io.EOF {
return err
}
if len(b) == 3 && b[0] == 0xEF && b[1] == 0xBB && b[2] == 0xBF {
_, _ = br.Discard(3)
}
return nil
}
// ParseKerio reads a Kerio Connect user export and maps every enabled, non-admin
// account onto both sides of a migration: the login and password are identical
// on source and destination, only the server differs. Rows for disabled accounts
// and the built-in admin are skipped.
func ParseKerio(r io.Reader, domain string) ([]Row, error) {
d, err := normalizeDomain(domain)
if err != nil {
return nil, err
}
br := bufio.NewReader(r)
if err := skipBOM(br); err != nil {
return nil, err
}
cr := csv.NewReader(br)
cr.Comma = ';'
cr.FieldsPerRecord = -1 // проверяем сами
cr.LazyQuotes = true // FullName нередко содержит одиночную кавычку
header, err := cr.Read()
if err != nil {
return nil, fmt.Errorf("cannot read header: %w", err)
}
if len(header) < kerioMinColumns || !strings.EqualFold(strings.TrimSpace(header[kerioColName]), "Name") {
return nil, fmt.Errorf("not a Kerio export: expected a header starting with Name;FullName;Description;Enable")
}
var rows []Row
seen := map[string]bool{}
for {
rec, err := cr.Read()
if err == io.EOF {
break
}
if err != nil {
return nil, err
}
line, _ := cr.FieldPos(0)
if len(rec) == 1 && strings.TrimSpace(rec[0]) == "" {
continue
}
if len(rec) < kerioMinColumns {
return nil, fmt.Errorf("line %d: expected at least %d columns, got %d", line, kerioMinColumns, len(rec))
}
name := strings.ToLower(strings.TrimSpace(rec[kerioColName]))
if name == "" {
return nil, fmt.Errorf("line %d: Name is empty", line)
}
if !strings.EqualFold(strings.TrimSpace(rec[kerioColEnable]), "Yes") || name == "admin" {
continue
}
pass := strings.TrimSpace(rec[kerioColDescription])
if pass == "" {
return nil, fmt.Errorf("line %d: no password in the Description column for %q", line, name)
}
if seen[name] {
return nil, fmt.Errorf("line %d: duplicate Name %q", line, name)
}
seen[name] = true
login := name + "@" + d
rows = append(rows, Row{SrcLogin: login, SrcPass: pass, DstLogin: login, DstPass: pass})
}
if len(rows) == 0 {
return nil, fmt.Errorf("no enabled accounts found in the export")
}
return rows, nil
}
+97
View File
@@ -49,3 +49,100 @@ func TestParseZeroRowsErrors(t *testing.T) {
t.Fatal("expected error when no rows parsed")
}
}
const kerioHeader = "Name;FullName;Description;Enable;DataSource;Authentication;Role;Groups;MailAddress\n"
func TestParseKerioOK(t *testing.T) {
in := kerioHeader +
"j.doe;Jane Doe;SrcPass11;Yes;Internal;Internal;No rights;all;j.doe\n" +
"k.smith;Kim Smith;SrcPass22;Yes;Internal;Internal;No rights;all;k.smith\n"
rows, err := ParseKerio(strings.NewReader(in), "example.test")
if err != nil {
t.Fatalf("parse: %v", err)
}
if len(rows) != 2 {
t.Fatalf("want 2 rows, got %d: %+v", len(rows), rows)
}
want := Row{
SrcLogin: "j.doe@example.test", SrcPass: "SrcPass11",
DstLogin: "j.doe@example.test", DstPass: "SrcPass11",
}
if rows[0] != want {
t.Fatalf("row 0: got %+v, want %+v", rows[0], want)
}
}
func TestParseKerioSkipsDisabledAndAdmin(t *testing.T) {
in := kerioHeader +
"admin;;AdminPass1;Yes;Internal;Internal;Account admin;;admin\n" +
"o.disabled;;OffPass111;No;Internal;Internal;No rights;all;o.disabled\n" +
"info;;InfoPass11;Yes;Internal;Internal;No rights;all;info\n"
rows, err := ParseKerio(strings.NewReader(in), "example.test")
if err != nil {
t.Fatalf("parse: %v", err)
}
if len(rows) != 1 || rows[0].SrcLogin != "info@example.test" {
t.Fatalf("only the enabled non-admin row must survive, got %+v", rows)
}
}
func TestParseKerioStripsBOM(t *testing.T) {
in := "\ufeff" + kerioHeader + "info;;InfoPass11;Yes;Internal;Internal;No rights;all;info\n"
rows, err := ParseKerio(strings.NewReader(in), "example.test")
if err != nil {
t.Fatalf("parse: %v", err)
}
if len(rows) != 1 {
t.Fatalf("want 1 row, got %+v", rows)
}
}
func TestParseKerioRejectsMissingHeader(t *testing.T) {
in := "j.doe;Jane Doe;SrcPass11;Yes;Internal;Internal;No rights;all;j.doe\n"
if _, err := ParseKerio(strings.NewReader(in), "example.test"); err == nil {
t.Fatal("a file without the Kerio header must error")
}
}
func TestParseKerioRejectsBadDomain(t *testing.T) {
in := kerioHeader + "info;;InfoPass11;Yes;Internal;Internal;No rights;all;info\n"
for _, domain := range []string{"", " ", "@example.test", "exam ple.test", "example", "info@example.test"} {
if _, err := ParseKerio(strings.NewReader(in), domain); err == nil {
t.Fatalf("domain %q must be rejected", domain)
}
}
}
func TestParseKerioTrimsAndLowercasesDomain(t *testing.T) {
in := kerioHeader + "info;;InfoPass11;Yes;Internal;Internal;No rights;all;info\n"
rows, err := ParseKerio(strings.NewReader(in), " Example.TEST ")
if err != nil {
t.Fatalf("parse: %v", err)
}
if rows[0].SrcLogin != "info@example.test" {
t.Fatalf("domain must be trimmed and lowercased, got %q", rows[0].SrcLogin)
}
}
func TestParseKerioRejectsEmptyPassword(t *testing.T) {
in := kerioHeader + "info;;;Yes;Internal;Internal;No rights;all;info\n"
if _, err := ParseKerio(strings.NewReader(in), "example.test"); err == nil {
t.Fatal("an enabled account without a password must error")
}
}
func TestParseKerioRejectsDuplicateName(t *testing.T) {
in := kerioHeader +
"info;;InfoPass11;Yes;Internal;Internal;No rights;all;info\n" +
"info;;aaaaaaAa1;Yes;Internal;Internal;No rights;all;info\n"
if _, err := ParseKerio(strings.NewReader(in), "example.test"); err == nil {
t.Fatal("duplicate Name must error")
}
}
func TestParseKerioZeroRowsErrors(t *testing.T) {
in := kerioHeader + "admin;;AdminPass1;Yes;Internal;Internal;Account admin;;admin\n"
if _, err := ParseKerio(strings.NewReader(in), "example.test"); err == nil {
t.Fatal("expected error when every row is filtered out")
}
}