package csvimport import ( "bufio" "encoding/csv" "fmt" "io" "regexp" "strings" ) type Row struct { SrcLogin string SrcPass string DstLogin string DstPass string } func Parse(r io.Reader) ([]Row, error) { cr := csv.NewReader(r) cr.FieldsPerRecord = -1 // проверяем сами 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 // encoding/csv уже пропускает голые пустые строки; это ветка ловит строки из одних пробелов } if len(rec) != 4 { return nil, fmt.Errorf("line %d: expected 4 columns, got %d", line, len(rec)) } for i := range rec { rec[i] = strings.TrimSpace(rec[i]) if rec[i] == "" { return nil, fmt.Errorf("line %d: column %d is empty", line, i+1) } } if seen[rec[0]] { return nil, fmt.Errorf("line %d: duplicate src_login %q", line, rec[0]) } seen[rec[0]] = true rows = append(rows, Row{SrcLogin: rec[0], SrcPass: rec[1], DstLogin: rec[2], DstPass: rec[3]}) } if len(rows) == 0 { return nil, fmt.Errorf("no rows parsed") } 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 }