1package remote
2
3import (
4 "encoding/json"
5 "errors"
6 "fmt"
7 "log/slog"
8 "os"
9 "path/filepath"
10 "slices"
11 "sort"
12 "syscall"
13 "unicode/utf8"
14
15 "github.com/bnema/vev/internal/domain"
16 "github.com/bnema/vev/internal/ports"
17 "github.com/bnema/vev/pkg/safedir"
18)
19
20const (
21 hostsFileName = "hosts.json"
22 hostsFileVersion = 2
23)
24
25// HostStorePath returns the canonical location of the remote host store in stateDir.
26func HostStorePath(stateDir string) string { return filepath.Join(stateDir, hostsFileName) }
27
28type hostsFile struct {
29 Version int `json:"version"`
30 Pinned []string `json:"pinned"`
31 Learned []string `json:"learned"`
32}
33
34type legacyHostsFile struct {
35 Version int `json:"version"`
36 Hosts []string `json:"hosts"`
37}
38
39type hostState struct {
40 pinned []string
41 learned map[string]struct{}
42}
43
44type fileHostStore struct {
45 path string
46}
47
48// NewFileHostStore returns a unified pinned/learned host store backed by path.
49func NewFileHostStore(path string) ports.RemoteHostStore {
50 return &fileHostStore{path: path}
51}
52
53var _ ports.RemoteHostStore = (*fileHostStore)(nil)
54
55func (s *fileHostStore) Hosts() (pinned, learned []string, err error) {
56 err = s.withLock(func() error {
57 state, loadErr := s.loadLocked()
58 if loadErr != nil {
59 return loadErr
60 }
61 pinned = append([]string(nil), state.pinned...)
62 learned = sortedHosts(state.learned)
63 return nil
64 })
65 if err != nil {
66 slog.Debug("remote host store hosts failed", "path", s.path, "err", err)
67 return nil, nil, err
68 }
69 return pinned, learned, nil
70}
71
72func (s *fileHostStore) AddPinned(target string) error {
73 if err := validateHostTarget(target); err != nil {
74 return err
75 }
76 err := s.withLock(func() error {
77 state, err := s.loadLocked()
78 if err != nil {
79 return err
80 }
81 if slices.Contains(state.pinned, target) {
82 return nil
83 }
84 state.pinned = append(state.pinned, target)
85 return s.saveLocked(state)
86 })
87 if err != nil {
88 slog.Debug("remote host store add pinned failed", "path", s.path, "target", target, "err", err)
89 }
90 return err
91}
92
93func (s *fileHostStore) RemovePinned(target string) error {
94 if err := validateHostTarget(target); err != nil {
95 return err
96 }
97 err := s.withLock(func() error {
98 state, err := s.loadLocked()
99 if err != nil {
100 return err
101 }
102 next, changed := removeTarget(state.pinned, target)
103 if !changed {
104 return nil
105 }
106 state.pinned = next
107 return s.saveLocked(state)
108 })
109 if err != nil {
110 slog.Debug("remote host store remove pinned failed", "path", s.path, "target", target, "err", err)
111 }
112 return err
113}
114
115func (s *fileHostStore) Remember(target string) error {
116 if err := validateHostTarget(target); err != nil {
117 return err
118 }
119 err := s.withLock(func() error {
120 state, err := s.loadLocked()
121 if err != nil {
122 return err
123 }
124 if _, ok := state.learned[target]; ok {
125 return nil
126 }
127 state.learned[target] = struct{}{}
128 return s.saveLocked(state)
129 })
130 if err != nil {
131 slog.Debug("remote host store remember failed", "path", s.path, "target", target, "err", err)
132 }
133 return err
134}
135
136func (s *fileHostStore) Forget(target string) error {
137 if err := validateHostTarget(target); err != nil {
138 return err
139 }
140 err := s.withLock(func() error {
141 state, err := s.loadLocked()
142 if err != nil {
143 return err
144 }
145 if _, ok := state.learned[target]; !ok {
146 return nil
147 }
148 delete(state.learned, target)
149 return s.saveLocked(state)
150 })
151 if err != nil {
152 slog.Debug("remote host store forget failed", "path", s.path, "target", target, "err", err)
153 }
154 return err
155}
156
157func (s *fileHostStore) Remove(target string) (deleted bool, err error) {
158 if err := validateHostTarget(target); err != nil {
159 return false, err
160 }
161 err = s.withLock(func() error {
162 state, err := s.loadLocked()
163 if err != nil {
164 return err
165 }
166 nextPinned, pinnedChanged := removeTarget(state.pinned, target)
167 _, learnedPresent := state.learned[target]
168 if !pinnedChanged && !learnedPresent {
169 return nil
170 }
171 state.pinned = nextPinned
172 delete(state.learned, target)
173 if err := s.saveLocked(state); err != nil {
174 return err
175 }
176 deleted = true
177 return nil
178 })
179 if err != nil {
180 slog.Debug("remote host store remove failed", "path", s.path, "target", target, "err", err)
181 return false, err
182 }
183 return deleted, nil
184}
185
186func (s *fileHostStore) withLock(fn func() error) error {
187 if err := safedir.EnsurePrivate(filepath.Dir(s.path)); err != nil {
188 return err
189 }
190 lock, err := os.OpenFile(s.path+".lock", os.O_CREATE|os.O_RDWR, 0o600)
191 if err != nil {
192 return err
193 }
194 defer func() { _ = lock.Close() }()
195 if err := lock.Chmod(0o600); err != nil {
196 return err
197 }
198 if err := syscall.Flock(int(lock.Fd()), syscall.LOCK_EX); err != nil {
199 return err
200 }
201 defer func() { _ = syscall.Flock(int(lock.Fd()), syscall.LOCK_UN) }()
202 return fn()
203}
204
205func (s *fileHostStore) loadLocked() (hostState, error) {
206 raw, err := os.ReadFile(s.path)
207 if errors.Is(err, os.ErrNotExist) {
208 return hostState{
209 pinned: make([]string, 0),
210 learned: make(map[string]struct{}),
211 }, nil
212 }
213 if err != nil {
214 return hostState{}, err
215 }
216 if !utf8.Valid(raw) {
217 return hostState{}, fmt.Errorf("remote host store: malformed hosts file: invalid UTF-8")
218 }
219 var header struct {
220 Version int `json:"version"`
221 }
222 if err := json.Unmarshal(raw, &header); err != nil {
223 return hostState{}, fmt.Errorf("remote host store: malformed hosts file: %w", err)
224 }
225
226 switch header.Version {
227 case 1:
228 var legacy legacyHostsFile
229 if err := json.Unmarshal(raw, &legacy); err != nil {
230 return hostState{}, fmt.Errorf("remote host store: malformed hosts file: %w", err)
231 }
232 if legacy.Hosts == nil {
233 return hostState{}, fmt.Errorf("remote host store: malformed hosts file: missing hosts")
234 }
235 learned, err := validateLearnedHosts(legacy.Hosts)
236 if err != nil {
237 return hostState{}, err
238 }
239 return hostState{pinned: []string{}, learned: learned}, nil
240 case hostsFileVersion:
241 var decoded hostsFile
242 if err := json.Unmarshal(raw, &decoded); err != nil {
243 return hostState{}, fmt.Errorf("remote host store: malformed hosts file: %w", err)
244 }
245 if decoded.Pinned == nil {
246 return hostState{}, fmt.Errorf("remote host store: malformed hosts file: missing pinned")
247 }
248 if decoded.Learned == nil {
249 return hostState{}, fmt.Errorf("remote host store: malformed hosts file: missing learned")
250 }
251 pinned, err := validatePinnedHosts(decoded.Pinned)
252 if err != nil {
253 return hostState{}, err
254 }
255 learned, err := validateLearnedHosts(decoded.Learned)
256 if err != nil {
257 return hostState{}, err
258 }
259 return hostState{pinned: pinned, learned: learned}, nil
260 default:
261 return hostState{}, fmt.Errorf("remote host store: unsupported hosts file version %d", header.Version)
262 }
263}
264
265func (s *fileHostStore) saveLocked(state hostState) error {
266 pinned := append([]string{}, state.pinned...)
267 if pinned == nil {
268 pinned = []string{}
269 }
270 learned := sortedHosts(state.learned)
271 if learned == nil {
272 learned = []string{}
273 }
274 payload, err := json.Marshal(hostsFile{
275 Version: hostsFileVersion,
276 Pinned: pinned,
277 Learned: learned,
278 })
279 if err != nil {
280 return err
281 }
282 return atomicReplacePrivateFile(s.path, append(payload, '\n'))
283}
284
285func atomicReplacePrivateFile(path string, payload []byte) error {
286 dir := filepath.Dir(path)
287 if err := safedir.EnsurePrivate(dir); err != nil {
288 return err
289 }
290 tmp, err := os.CreateTemp(dir, ".vev-*.tmp")
291 if err != nil {
292 return err
293 }
294 tmpName := tmp.Name()
295 committed := false
296 defer func() {
297 if !committed {
298 _ = tmp.Close()
299 _ = os.Remove(tmpName)
300 }
301 }()
302 if err := tmp.Chmod(0o600); err != nil {
303 return err
304 }
305 if _, err := tmp.Write(payload); err != nil {
306 return err
307 }
308 if err := tmp.Sync(); err != nil {
309 return err
310 }
311 if err := tmp.Close(); err != nil {
312 return err
313 }
314 if err := os.Rename(tmpName, path); err != nil {
315 return err
316 }
317 committed = true
318 return syncDir(dir)
319}
320
321func validatePinnedHosts(hosts []string) ([]string, error) {
322 pinned := make([]string, 0, len(hosts))
323 seen := make(map[string]struct{}, len(hosts))
324 for _, host := range hosts {
325 if err := validateHostTarget(host); err != nil {
326 return nil, fmt.Errorf("remote host store: malformed hosts file: %w", err)
327 }
328 if _, dup := seen[host]; dup {
329 return nil, fmt.Errorf("remote host store: malformed hosts file: duplicate pinned host %q", host)
330 }
331 seen[host] = struct{}{}
332 pinned = append(pinned, host)
333 }
334 return pinned, nil
335}
336
337func validateLearnedHosts(hosts []string) (map[string]struct{}, error) {
338 learned := make(map[string]struct{}, len(hosts))
339 for _, host := range hosts {
340 if err := validateHostTarget(host); err != nil {
341 return nil, fmt.Errorf("remote host store: malformed hosts file: %w", err)
342 }
343 if _, dup := learned[host]; dup {
344 return nil, fmt.Errorf("remote host store: malformed hosts file: duplicate learned host %q", host)
345 }
346 learned[host] = struct{}{}
347 }
348 return learned, nil
349}
350
351func validateHostTarget(target string) error {
352 if !utf8.ValidString(target) {
353 return fmt.Errorf("remote host target is not valid UTF-8")
354 }
355 return domain.ValidateRemoteHostTarget(target)
356}
357
358func removeTarget(hosts []string, target string) ([]string, bool) {
359 out := make([]string, 0, len(hosts))
360 changed := false
361 for _, host := range hosts {
362 if host == target {
363 changed = true
364 continue
365 }
366 out = append(out, host)
367 }
368 return out, changed
369}
370
371func sortedHosts(hosts map[string]struct{}) []string {
372 out := make([]string, 0, len(hosts))
373 for host := range hosts {
374 out = append(out, host)
375 }
376 sort.Strings(out)
377 return out
378}
379
380func syncDir(dir string) error {
381 file, err := os.Open(dir)
382 if err != nil {
383 return err
384 }
385 return errors.Join(file.Sync(), file.Close())
386}