package main // How the five settings of ADR 0329 reach the profile (§7–§9). // // Identities, calendars, address books and tags are matched as accounts are (ADR 0317): an entry the module // recorded on this node is kept by its recorded key; else an entry under the module's own key is its creation; // else a key a node's layer names is adopted when the profile holds it and it matches; else the profile's // entries that match (an identity by its address within its account, a calendar and an address book by their // address, a tag by its key): one is adopted, none means the entry is created under the module's own key, more // than one is refused with the remedy. Their declared fields are written into the user.js the module places, // where they win at every start. An entry leaving the setting releases what was adopted and removes the // preferences of what was created; never a calendar's events, an address book's contacts or a cache. // // What Betterbird rewrites itself is written only while it does not run (§9): the lists in prefs.js (an adopted // account's identities, the calendars' order) and each account's msgFilterRules.dat. Until then the module's // check counts, per kind, the changes that wait. import ( "bufio" "bytes" "encoding/json" "errors" "fmt" "os" "path/filepath" "regexp" "strconv" "strings" ) // configRecordFile is where the module records, per profile, what it adopted or created of the five kinds and // which filter files it wrote (ADR 0329 §7, §8, ADR 0317 §3). const configRecordFile = ".config/betterbird/configuration-record.json" // resolvedEntry is one declared entry as it resolved on this node. type resolvedEntry struct { Kind string `json:"kind"` Name string `json:"name"` How string `json:"how"` Key string `json:"key,omitempty"` Why string `json:"why,omitempty"` } type recordedEntry struct { How string `json:"how"` Key string `json:"key"` } // filterRecord is what the module did with one account's filter file on a profile. type filterRecord struct { // File is the account's filter file as the module last wrote it, and Kept the file it replaced at its first write. File string `json:"file"` Kept string `json:"kept,omitempty"` // Released says the account left the setting filters: the module no longer writes its file (§8). Released bool `json:"released,omitempty"` } type configRecord struct { Entries map[string]map[string]recordedEntry `json:"entries,omitempty"` Filters map[string]filterRecord `json:"filters,omitempty"` } func (m *Machine) readConfigRecord(profileDir string) configRecord { all := map[string]configRecord{} if raw, err := readBounded(m.home(configRecordFile)); err == nil { _ = json.Unmarshal(raw, &all) } r := all[profileDir] if r.Entries == nil { r.Entries = map[string]map[string]recordedEntry{} } if r.Filters == nil { r.Filters = map[string]filterRecord{} } return r } func (m *Machine) writeConfigRecord(profileDir string, r configRecord) error { all := map[string]configRecord{} if raw, err := readBounded(m.home(configRecordFile)); err == nil { _ = json.Unmarshal(raw, &all) } all[profileDir] = r body, err := json.MarshalIndent(all, "", " ") if err != nil { return err } return writeAtomic(m.home(configRecordFile), append(body, '\n'), 0o600) } // writeAtomic writes a file beside itself and renames it into place. func writeAtomic(path string, data []byte, mode os.FileMode) error { if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { return err } tmp := path + ".mesh-new" if err := writeSynced(tmp, data, mode); err != nil { os.Remove(tmp) return err } if err := os.Rename(tmp, path); err != nil { os.Remove(tmp) return err } return nil } // The module's own keys for what it creates: each names the entry. func ownIdentityKey(name string) string { return "ident_mesh_" + name } func ownCalendarKey(name string) string { return "mesh_" + name } func ownBookKey(name string) string { return "mesh_" + name } var ( ownIdentity = regexp.MustCompile(`^ident_mesh_[a-z][a-z0-9]{0,30}$`) ownMeshKey = regexp.MustCompile(`^mesh_[a-z][a-z0-9]{0,30}$`) ) // configPlan is what an apply does for the five kinds on this profile. type configPlan struct { Entries []resolvedEntry // lines are the user.js statements, a comment before each entry's. lines []string // identityLists are, per adopted account key, the identity keys of the module's own it wants in its list. identityLists map[string][]string // calendarOrder are the calendar keys the module created, for calendar.list.sortOrder. calendarOrder []string // removals are the preference prefixes of what the module created and the setting no longer holds. removals []string filters []filterPlan // released are the accounts that left the setting filters since the module last wrote their file. released []string record configRecord problem string } // filterPlan is one declared account's filter file on this node. type filterPlan struct { Account string `json:"account"` File string `json:"file,omitempty"` // Waiting says why the file cannot be written yet, when it cannot. Waiting string `json:"waiting,omitempty"` Refused string `json:"refused,omitempty"` want string have string exists bool path string } func (p filterPlan) changes() bool { return p.Refused == "" && p.path != "" && p.want != p.have } // planConfiguration resolves every declared entry against the profile and says what an apply writes. func (m *Machine) planConfiguration(profile Profile, c Configuration, accounts map[string]Account, ra []resolvedAccount, prefs map[string]string) configPlan { rec := m.readConfigRecord(profile.Dir) plan := configPlan{identityLists: map[string][]string{}, record: configRecord{Entries: map[string]map[string]recordedEntry{}, Filters: map[string]filterRecord{}}} byName := map[string]resolvedAccount{} for _, r := range ra { byName[r.name] = r } have := profileAccounts(prefs) listOfAccount := map[string][]string{} for _, a := range have { listOfAccount[a.Key] = a.Identities } identityKeyOf := map[string]string{} // Identities. taken := map[string]bool{} for _, name := range sortedKeys(c.Identities) { id := c.Identities[name] e := resolvedEntry{Kind: kindIdentities, Name: name} acct, declared := byName[id.Account] switch { case !declared: e.How, e.Why = refused, fmt.Sprintf("its account %s is not in the setting accounts", id.Account) case acct.How == refused: e.How, e.Why = refused, fmt.Sprintf("its account %s is refused: betterbird_check says why", id.Account) case id.Address == "": e.How, e.Key = acct.How, acct.Keys.Identity if taken[e.Key] { e.How, e.Key, e.Why = refused, "", fmt.Sprintf("account %s has its default identity declared twice", id.Account) } default: list := listOfAccount[acct.Keys.Account] var others []string if len(list) > 1 { others = list[1:] } present := func(k string) bool { for _, x := range others { if x == k { return true } } return false } matches := func(k string) bool { return strings.EqualFold(unquote(prefs["mail.identity."+k+".useremail"]), id.Address) } e = resolveEntry(kindIdentities, name, id.Key, ownIdentityKey(name), rec.Entries[kindIdentities][name], others, present, matches, taken, "an identity of its account with its address", "give the one to adopt as key in this node's layer of the setting identities") if e.How == created { plan.identityLists[acct.Keys.Account] = append(plan.identityLists[acct.Keys.Account], e.Key) } } if e.How != refused { taken[e.Key] = true identityKeyOf[name] = e.Key plan.lines = append(plan.lines, fmt.Sprintf("// identity %s: %s under %s", name, e.How, e.Key)) plan.lines = append(plan.lines, identityLines(e, id, acct)...) } plan.Entries = append(plan.Entries, e) } // A created account's identity list is the module's whole: its default and the identities it created. for _, name := range sortedKeys(byName) { r := byName[name] if r.How == created && len(plan.identityLists[r.Keys.Account]) > 0 { list := append([]string{r.Keys.Identity}, plan.identityLists[r.Keys.Account]...) plan.lines = append(plan.lines, fmt.Sprintf("user_pref(%s, %s);", quoted("mail.account."+r.Keys.Account+".identities"), quoted(strings.Join(list, ",")))) delete(plan.identityLists, r.Keys.Account) } } // Adopted accounts keep their list in prefs.js: the module's own keys are written into it while Betterbird is // closed. An adopted account whose list holds none of them and wants none is left out. for _, a := range have { if _, wanted := plan.identityLists[a.Key]; wanted { continue } for _, k := range a.Identities { if ownIdentity.MatchString(k) { plan.identityLists[a.Key] = []string{} break } } } // Calendars. cals := profileCalendars(prefs) calKeys := map[string]profileCalendar{} var caldav []string for _, pc := range cals { calKeys[pc.Key] = pc if pc.Kind == "caldav" { caldav = append(caldav, pc.Key) } } taken = map[string]bool{} for _, name := range sortedKeys(c.Calendars) { cal := c.Calendars[name] present := func(k string) bool { pc, ok := calKeys[k]; return ok && pc.Kind == "caldav" } matches := func(k string) bool { return sameURL(calKeys[k].Value.URL, cal.URL) } e := resolveEntry(kindCalendars, name, cal.Key, ownCalendarKey(name), rec.Entries[kindCalendars][name], caldav, present, matches, taken, "a CalDAV calendar at its address", "remove the duplicate in Betterbird, or give the one to adopt as key in this node's layer of the setting calendars") if e.How != refused { taken[e.Key] = true if e.How == created { plan.calendarOrder = append(plan.calendarOrder, e.Key) } plan.lines = append(plan.lines, fmt.Sprintf("// calendar %s: %s under %s", name, e.How, e.Key)) plan.lines = append(plan.lines, calendarLines(e, cal, identityKeyOf[cal.Invitations])...) } plan.Entries = append(plan.Entries, e) } // Address books. books := profileAddressBooks(prefs) bookKeys := map[string]profileAddressBook{} var carddav []string for _, b := range books { bookKeys[b.Key] = b if b.CardDAV { carddav = append(carddav, b.Key) } } taken = map[string]bool{} for _, name := range sortedKeys(c.AddressBooks) { ab := c.AddressBooks[name] present := func(k string) bool { b, ok := bookKeys[k]; return ok && b.CardDAV } matches := func(k string) bool { return sameURL(bookKeys[k].Value.URL, ab.URL) } e := resolveEntry(kindAddressBooks, name, ab.Key, ownBookKey(name), rec.Entries[kindAddressBooks][name], carddav, present, matches, taken, "a CardDAV address book at its address", "remove the duplicate in Betterbird, or give the one to adopt as key in this node's layer of the setting address-books") if e.How != refused { taken[e.Key] = true plan.lines = append(plan.lines, fmt.Sprintf("// address book %s: %s under %s", name, e.How, e.Key)) plan.lines = append(plan.lines, addressBookLines(e, ab, name)...) } plan.Entries = append(plan.Entries, e) } // Tags: a tag's key is its own, unique in a profile, so a match is never ambiguous. tagKeys := map[string]bool{} for _, t := range profileTags(prefs) { tagKeys[t.Key] = true } for _, key := range sortedKeys(c.Tags) { t := c.Tags[key] e := resolvedEntry{Kind: kindTags, Name: key, Key: key, How: adopted} if was, ok := rec.Entries[kindTags][key]; ok && was.How == created { e.How = created } else if !tagKeys[key] { e.How = created } plan.lines = append(plan.lines, fmt.Sprintf("// tag %s: %s", key, e.How)) plan.lines = append(plan.lines, tagLines(key, t)...) plan.Entries = append(plan.Entries, e) } // What the setting no longer holds: an adopted entry is released (its lines go), a created one's preferences // are removed from prefs.js while Betterbird is closed. declaredNow := map[string]map[string]bool{} for _, e := range plan.Entries { if declaredNow[e.Kind] == nil { declaredNow[e.Kind] = map[string]bool{} } declaredNow[e.Kind][e.Name] = true if e.How != refused { if plan.record.Entries[e.Kind] == nil { plan.record.Entries[e.Kind] = map[string]recordedEntry{} } plan.record.Entries[e.Kind][e.Name] = recordedEntry{How: e.How, Key: e.Key} } } for _, kind := range []string{kindIdentities, kindCalendars, kindAddressBooks, kindTags} { for _, name := range sortedKeys(rec.Entries[kind]) { was := rec.Entries[kind][name] if declaredNow[kind][name] || was.How != created { continue } switch kind { case kindIdentities: plan.removals = append(plan.removals, "mail.identity."+was.Key+".") case kindCalendars: plan.removals = append(plan.removals, "calendar.registry."+was.Key+".") case kindAddressBooks: plan.removals = append(plan.removals, "ldap_2.servers."+was.Key+".") case kindTags: plan.removals = append(plan.removals, "mailnews.tags."+was.Key+".") } } } // Filters, owned whole per declared account (§8). for _, account := range sortedAccountsOf(c.Filters) { fp := filterPlan{Account: account} r, ok := byName[account] switch { case !ok: fp.Refused = fmt.Sprintf("its account %s is not in the setting accounts", account) case r.How == refused: fp.Refused = fmt.Sprintf("its account %s is refused: betterbird_check says why", account) default: dir := m.accountDirectory(profile.path, prefs, r.Keys.Server) if dir == "" { fp.Waiting = "Betterbird has not made the account's directory yet: it does at its first start with the account" break } fp.path = filepath.Join(dir, filterFileName) fp.File = m.tilde(strings.TrimPrefix(fp.path, m.Root)) fp.want = renderFilters(c.Filters[account], folderBase(prefs, r.Keys.Server)) raw, err := readBounded(fp.path) fp.exists = err == nil fp.have = string(raw) } was := rec.Filters[account] was.Released = false if fp.path != "" { was.File = fp.File } if was.File != "" || was.Kept != "" { plan.record.Filters[account] = was } plan.filters = append(plan.filters, fp) } for _, account := range sortedKeys(rec.Filters) { if _, still := c.Filters[account]; still { continue } was := rec.Filters[account] if !was.Released { plan.released = append(plan.released, account) } was.Released = true plan.record.Filters[account] = was } return plan } // resolveEntry resolves an identity, a calendar or an address book as ADR 0317 resolves an account (§7). func resolveEntry(kind, name, layerKey, own string, was recordedEntry, candidates []string, present, matches func(string) bool, taken map[string]bool, what, remedy string) resolvedEntry { e := resolvedEntry{Kind: kind, Name: name} refuse := func(format string, args ...any) resolvedEntry { e.How, e.Key, e.Why = refused, "", fmt.Sprintf(format, args...) return e } // 1. Recorded, while the profile still holds it. if was.Key != "" && !taken[was.Key] && (present(was.Key) || was.How == created) && (layerKey == "" || layerKey == was.Key) { e.How, e.Key = was.How, was.Key return e } // 2. Under the module's own key: its creation. if present(own) { e.How, e.Key = created, own return e } // 3. The key a node's layer names. if layerKey != "" { switch { case !present(layerKey): return refuse("the key %s this node's layer names is not in the profile", layerKey) case taken[layerKey]: return refuse("the key %s is already another declared entry's", layerKey) case !matches(layerKey): return refuse("the key %s this node's layer names is not %s", layerKey, what) } e.How, e.Key = adopted, layerKey return e } // 4. By the match. var found []string for _, k := range candidates { if !taken[k] && !ownMeshKey.MatchString(k) && !ownIdentity.MatchString(k) && matches(k) { found = append(found, k) } } switch len(found) { case 0: e.How, e.Key = created, own case 1: e.How, e.Key = adopted, found[0] default: return refuse("%d entries in the profile are %s (%s): %s (novox/hq ADR 0329 §7)", len(found), what, strings.Join(found, ", "), remedy) } return e } // sameURL compares two addresses without a trailing slash's difference. func sameURL(a, b string) bool { return a != "" && strings.EqualFold(strings.TrimRight(a, "/"), strings.TrimRight(b, "/")) } func pref(name, value string) string { return fmt.Sprintf("user_pref(%s, %s);", quoted(name), value) } func boolWord(b bool) string { return strconv.FormatBool(b) } // identityLines are an identity's declared fields under its key. The default identity's name and address are // the setting accounts' (§4), so only another identity carries them here. func identityLines(e resolvedEntry, id *Identity, acct resolvedAccount) []string { p := "mail.identity." + e.Key + "." var out []string if id.Address != "" { out = append(out, pref(p+"fullName", jsQuoted(id.Name)), pref(p+"useremail", jsQuoted(id.Address))) if e.How == created { out = append(out, pref(p+"valid", "true"), pref(p+"smtpServer", quoted(acct.Keys.SMTP))) } } above := 0 if id.SignatureAboveQuote { above = 1 } out = append(out, pref(p+"reply_to", jsQuoted(id.ReplyTo)), pref(p+"organization", jsQuoted(id.Organization)), pref(p+"htmlSigText", jsQuoted(strings.Join(id.Signature, "\n"))), pref(p+"htmlSigFormat", boolWord(id.SignatureHTML)), pref(p+"attach_signature", "false"), pref(p+"reply_on_top", strconv.Itoa(above)), pref(p+"sig_bottom", boolWord(!id.SignatureAboveQuote))) return out } func calendarLines(e resolvedEntry, cal *Calendar, identityKey string) []string { p := "calendar.registry." + e.Key + "." var out []string if e.How == created { out = append(out, pref(p+"type", quoted("caldav")), pref(p+"cache.enabled", "true"), pref(p+"calendar-main-in-composite", "true")) } reminders := cal.Reminders == nil || *cal.Reminders out = append(out, pref(p+"uri", jsQuoted(cal.URL)), pref(p+"name", jsQuoted(cal.Name))) if cal.Color != "" { out = append(out, pref(p+"color", jsQuoted(strings.ToLower(cal.Color)))) } out = append(out, pref(p+"readOnly", boolWord(cal.ReadOnly)), pref(p+"suppressAlarms", boolWord(!reminders)), pref(p+"username", jsQuoted(cal.User)), pref(p+"imip.identity.key", jsQuoted(identityKey))) return out } func addressBookLines(e resolvedEntry, ab *AddressBook, name string) []string { p := "ldap_2.servers." + e.Key + "." var out []string if e.How == created { out = append(out, pref(p+"dirType", "102"), pref(p+"filename", quoted("abook-mesh_"+name+".sqlite"))) } return append(out, pref(p+"description", jsQuoted(ab.Name)), pref(p+"carddav.url", jsQuoted(ab.URL)), pref(p+"carddav.username", jsQuoted(ab.User))) } func tagLines(key string, t *Tag) []string { p := "mailnews.tags." + key + "." out := []string{pref(p+"tag", jsQuoted(t.Name))} if t.Color != "" { out = append(out, pref(p+"color", jsQuoted(strings.ToUpper(t.Color)))) } if t.Order > 0 { out = append(out, pref(p+"ordinal", quoted(tagOrdinal(t.Order)))) } return out } // configBlock is the user.js block of the five kinds. func (p configPlan) configBlock() string { if len(p.lines) == 0 { return "" } return "\n// The identities, calendars, address books and tags the settings declare (novox/hq ADR 0329), under the\n" + "// keys each was adopted or created under. No password, token or cookie.\n" + strings.Join(p.lines, "\n") + "\n" } // closedFor says why the profile's own files may not be written now, or "": Betterbird runs, or the profile's // lock says one holds it (§9). func (m *Machine) closedFor(profile string) string { if ps := m.mainProcs(); len(ps) > 0 { return fmt.Sprintf("Betterbird is running (pid %d), and it rewrites these files when it quits", ps[0].PID) } if _, err := os.Lstat(filepath.Join(profile, "lock")); err == nil { return "the profile is locked (its lock file is there): a Betterbird holds it, or one ended without quitting" } return "" } // prefsChanges is prefs.js as the plan wants it, and how many changes each kind has in it. func (p configPlan) prefsChanges(text string) (string, map[string]int) { counts := map[string]int{} var lines []string done := map[string]bool{} s := bufio.NewScanner(strings.NewReader(text)) s.Buffer(make([]byte, 64<<10), 4<<20) for s.Scan() { line := s.Text() mm := prefLine.FindStringSubmatch(line) if mm == nil { lines = append(lines, line) continue } name := mm[1] removed := false for _, prefix := range p.removals { if strings.HasPrefix(name, prefix) { removed = true counts[kindOfPrefix(prefix)]++ break } } if removed { continue } if acct, ok := strings.CutPrefix(name, "mail.account."); ok && strings.HasSuffix(acct, ".identities") { key := strings.TrimSuffix(acct, ".identities") if want, ok := p.identityLists[key]; ok { done[name] = true next := listedWithOwn(listOf(mm[2]), want, ownIdentity.MatchString, ",") if next != strings.Join(listOf(mm[2]), ",") { line = pref(name, quoted(next)) counts[kindIdentities]++ } } } if name == "calendar.list.sortOrder" { done[name] = true have := strings.Fields(unquote(mm[2])) next := listedWithOwn(have, p.calendarOrder, ownMeshKey.MatchString, " ") if next != strings.Join(have, " ") { line = pref(name, quoted(next)) counts[kindCalendars]++ } } lines = append(lines, line) } if !done["calendar.list.sortOrder"] && len(p.calendarOrder) > 0 { lines = append(lines, pref("calendar.list.sortOrder", quoted(strings.Join(p.calendarOrder, " ")))) counts[kindCalendars]++ } out := strings.Join(lines, "\n") if text == "" || strings.HasSuffix(text, "\n") { out += "\n" } return out, counts } func kindOfPrefix(prefix string) string { switch { case strings.HasPrefix(prefix, "mail.identity."): return kindIdentities case strings.HasPrefix(prefix, "calendar."): return kindCalendars case strings.HasPrefix(prefix, "ldap_2."): return kindAddressBooks } return kindTags } // listedWithOwn is a list with the module's own keys made the wanted ones, every other key in its place. func listedWithOwn(have, wanted []string, own func(string) bool, sep string) string { want := map[string]bool{} for _, k := range wanted { want[k] = true } var out []string there := map[string]bool{} for _, k := range have { if own(k) && !want[k] { continue } out = append(out, k) there[k] = true } for _, k := range wanted { if !there[k] { out = append(out, k) } } return strings.Join(out, sep) } // Waiting counts, per kind, the changes of what Betterbird rewrites that wait for it to close (§9). func (m *Machine) waiting(profile string, p configPlan, ra []resolvedAccount) map[string]int { raw, _ := readBounded(filepath.Join(profile, "prefs.js")) _, counts := p.prefsChanges(string(raw)) var wantAccounts, wantSMTP []string for _, r := range ra { if r.How == created { wantAccounts, wantSMTP = append(wantAccounts, r.Keys.Account), append(wantSMTP, r.Keys.SMTP) } } if _, changed := withListed(string(raw), map[string][]string{ "mail.accountmanager.accounts": wantAccounts, "mail.smtpservers": wantSMTP}); changed { counts["accounts"]++ } for _, f := range p.filters { if f.changes() { counts[kindFilters]++ } } for k, v := range counts { if v == 0 { delete(counts, k) } } return counts } // writeClosed writes what Betterbird rewrites itself, only while it is closed: prefs.js's lists and the // removals, and each declared account's filter file, keeping the file it replaces at its first write. It // answers what it wrote, or why it waits. func (m *Machine) writeClosed(profile Profile, p *configPlan) (string, error) { var said []string path := filepath.Join(profile.path, "prefs.js") raw, err := readBounded(path) if err != nil && !errors.Is(err, os.ErrNotExist) { return "", err } next, counts := p.prefsChanges(string(raw)) pending := next != string(raw) for _, f := range p.filters { pending = pending || f.changes() } if !pending { return "unchanged", nil } if why := m.closedFor(profile.path); why != "" { return "", errors.New(why + ": the identities' and calendars' lists, the removals and the filter files are " + "written at its next start from the launcher, or by betterbird_restart") } if next != string(raw) { mode := os.FileMode(0o600) if st, err := os.Stat(path); err == nil { mode = st.Mode().Perm() } if err := writeAtomic(path, []byte(next), mode); err != nil { return "", err } said = append(said, fmt.Sprintf("prefs.js: %d change(s) to the lists and removals", sum(counts))) } for i, f := range p.filters { if !f.changes() { continue } was := p.record.Filters[f.Account] if was.Kept == "" && f.exists { kept := m.keptFilterFile(profile.Dir, f.Account) if err := writeAtomic(kept, []byte(f.have), 0o600); err != nil { return strings.Join(said, "; "), fmt.Errorf("the filter file of %s was not written: the file it replaces could not be kept: %v", f.Account, err) } was.Kept = m.tilde(strings.TrimPrefix(kept, m.Root)) } mode := os.FileMode(0o644) if st, err := os.Stat(f.path); err == nil { mode = st.Mode().Perm() } if err := writeAtomic(f.path, []byte(f.want), mode); err != nil { return strings.Join(said, "; "), err } was.File = f.File p.record.Filters[f.Account] = was p.filters[i].have, p.filters[i].exists = f.want, true said = append(said, "the filters of "+f.Account) } if len(said) == 0 { return "unchanged", nil } return "written: " + strings.Join(said, "; "), nil } func sum(m map[string]int) int { n := 0 for _, v := range m { n += v } return n } // accountDirectory is an account's directory in the profile, as its server's preferences name it, or "" when // they name none within the profile. func (m *Machine) accountDirectory(profile string, prefs map[string]string, server string) string { if dir := accountDirectory(profile, prefs, server); dir != "" { return dir } abs := unquote(prefs["mail.server."+server+".directory"]) if abs == "" { return "" } dir := m.path(abs) if strings.HasPrefix(filepath.Clean(dir), filepath.Clean(profile)+string(filepath.Separator)) { return dir } return "" } func mustJSON(v any) string { b, _ := json.Marshal(v) return string(bytes.TrimSpace(b)) }