package user import ( "fmt" "os" "path/filepath" "strings" "gopkg.in/yaml.v3" ) // SaveOwnerProfile writes p to path as YAML, atomically (temp file + rename) with // mode 0600 and lenient parent-directory creation, matching the runtime config // convention (SaveChannels). The whole struct is marshaled, so curated fields // (notes, identityFile, displayName, ...) survive a round-trip. YAML comments in // the existing file are not part of the struct and are dropped by the encode, // consistent with the project's other config writers. func SaveOwnerProfile(path string, p *OwnerProfile) error { if p == nil { return fmt.Errorf("user: save owner profile: empty profile") } if strings.TrimSpace(path) == "" { return fmt.Errorf("user: save owner profile: empty path") } data, err := yaml.Marshal(p) if err != nil { return fmt.Errorf("user: encode %s: %w", path, err) } dir := filepath.Dir(path) if err := os.MkdirAll(dir, 0o755); err != nil { return fmt.Errorf("user: create %s: %w", dir, err) } tmp, err := os.CreateTemp(dir, ".owner-*.yaml") if err != nil { return fmt.Errorf("user: temp file in %s: %w", dir, err) } tmpName := tmp.Name() closed := false defer func() { _ = tmp.Close() if !closed { _ = os.Remove(tmpName) } }() // CreateTemp opens with 0600; chmod explicitly so the on-disk file never // carries a broader mode even if the umask changes. if err := tmp.Chmod(0o600); err != nil { return fmt.Errorf("user: chmod %s: %w", tmpName, err) } if _, err := tmp.Write(data); err != nil { return fmt.Errorf("user: write %s: %w", tmpName, err) } if err := tmp.Sync(); err != nil { return fmt.Errorf("user: sync %s: %w", tmpName, err) } if err := tmp.Close(); err != nil { return fmt.Errorf("user: close %s: %w", tmpName, err) } closed = true if err := os.Rename(tmpName, path); err != nil { return fmt.Errorf("user: rename to %s: %w", path, err) } // Best-effort dir fsync so the rename is durable across a crash. if d, derr := os.Open(dir); derr == nil { _ = d.Sync() _ = d.Close() } return nil } // RegisterOwnerAccount loads the owner whitelist at path, appends (platform, // accountID) when it is not already present (idempotent), and writes the updated // profile back. It returns an error when the profile cannot be loaded or saved. // The on-disk replace is atomic, so a concurrent reader never sees a partial // file. Hot-reload of a running owner profile is the caller's concern (see // cmd/cyrene's ownerRegistrar), which applies RegisterAccount to the live profile // the channel bridge already holds. func RegisterOwnerAccount(path, platform, accountID string) error { if strings.TrimSpace(platform) == "" { return fmt.Errorf("user: register owner account: empty platform") } if strings.TrimSpace(accountID) == "" { return fmt.Errorf("user: register owner account: empty account id") } p, err := LoadOwnerProfile(path) if err != nil { return fmt.Errorf("user: load %s: %w", path, err) } p.RegisterAccount(platform, accountID) return SaveOwnerProfile(path, p) }