1package backend
2
3import (
4 "bufio"
5 "context"
6 "errors"
7 "fmt"
8 "io/fs"
9 "os"
10 "path"
11 "path/filepath"
12 "strconv"
13 "strings"
14 "time"
15
16 "github.com/charmbracelet/soft-serve/git"
17 "github.com/charmbracelet/soft-serve/pkg/db"
18 "github.com/charmbracelet/soft-serve/pkg/db/models"
19 "github.com/charmbracelet/soft-serve/pkg/hooks"
20 "github.com/charmbracelet/soft-serve/pkg/lfs"
21 "github.com/charmbracelet/soft-serve/pkg/proto"
22 "github.com/charmbracelet/soft-serve/pkg/storage"
23 "github.com/charmbracelet/soft-serve/pkg/task"
24 "github.com/charmbracelet/soft-serve/pkg/utils"
25 "github.com/charmbracelet/soft-serve/pkg/webhook"
26)
27
28func validateImportRemote(remote string) error {
29 endpoint, err := lfs.NewEndpoint(remote)
30 if err != nil || endpoint.Host == "" {
31 return proto.ErrInvalidRemote
32 }
33
34 return nil
35}
36
37// CreateRepository creates a new repository.
38//
39// It implements backend.Backend.
40func (d *Backend) CreateRepository(ctx context.Context, name string, user proto.User, opts proto.RepositoryOptions) (proto.Repository, error) {
41 name = utils.SanitizeRepo(name)
42 if err := utils.ValidateRepo(name); err != nil {
43 return nil, err
44 }
45
46 rp := filepath.Join(d.repoPath(name))
47
48 var userID int64
49 if user != nil {
50 userID = user.ID()
51 }
52
53 if err := d.db.TransactionContext(ctx, func(tx *db.Tx) error {
54 if err := d.store.CreateRepo(
55 ctx,
56 tx,
57 name,
58 userID,
59 opts.ProjectName,
60 opts.Description,
61 opts.Private,
62 opts.Hidden,
63 opts.Mirror,
64 ); err != nil {
65 return err
66 }
67
68 _, err := git.Init(rp, true)
69 if err != nil {
70 d.logger.Debug("failed to create repository", "err", err)
71 return err
72 }
73
74 if err := os.WriteFile(filepath.Join(rp, "description"), []byte(opts.Description), fs.ModePerm); err != nil {
75 d.logger.Error("failed to write description", "repo", name, "err", err)
76 return err
77 }
78
79 if !opts.Private {
80 if err := os.WriteFile(filepath.Join(rp, "git-daemon-export-ok"), []byte{}, fs.ModePerm); err != nil {
81 d.logger.Error("failed to write git-daemon-export-ok", "repo", name, "err", err)
82 return err
83 }
84 }
85
86 return hooks.GenerateHooks(ctx, d.cfg, name)
87 }); err != nil {
88 d.logger.Debug("failed to create repository in database", "err", err)
89 err = db.WrapError(err)
90 if errors.Is(err, db.ErrDuplicateKey) {
91 return nil, proto.ErrRepoExist
92 }
93
94 return nil, err
95 }
96
97 return d.Repository(ctx, name)
98}
99
100// ImportRepository imports a repository from remote.
101// XXX: This a expensive operation and should be run in a goroutine.
102func (d *Backend) ImportRepository(_ context.Context, name string, user proto.User, remote string, opts proto.RepositoryOptions) (proto.Repository, error) {
103 name = utils.SanitizeRepo(name)
104 if err := utils.ValidateRepo(name); err != nil {
105 return nil, err
106 }
107
108 remote = utils.Sanitize(remote)
109 if err := validateImportRemote(remote); err != nil {
110 return nil, err
111 }
112
113 rp := filepath.Join(d.repoPath(name))
114
115 tid := "import:" + name
116 if d.manager.Exists(tid) {
117 return nil, task.ErrAlreadyStarted
118 }
119
120 if _, err := os.Stat(rp); err == nil || os.IsExist(err) {
121 return nil, proto.ErrRepoExist
122 }
123
124 done := make(chan error, 1)
125 repoc := make(chan proto.Repository, 1)
126 d.logger.Info("importing repository", "name", name, "remote", remote, "path", rp)
127 d.manager.Add(tid, func(ctx context.Context) (err error) {
128 ctx = proto.WithUserContext(ctx, user)
129
130 copts := git.CloneOptions{
131 Bare: true,
132 Mirror: opts.Mirror,
133 Quiet: true,
134 CommandOptions: git.CommandOptions{
135 Timeout: -1,
136 Context: ctx,
137 Envs: []string{
138 fmt.Sprintf(`GIT_SSH_COMMAND=ssh -o UserKnownHostsFile="%s" -o StrictHostKeyChecking=no -i "%s"`,
139 filepath.Join(d.cfg.DataPath, "ssh", "known_hosts"),
140 d.cfg.SSH.ClientKeyPath,
141 ),
142 },
143 },
144 }
145
146 if err := git.Clone(remote, rp, copts); err != nil {
147 d.logger.Error("failed to clone repository", "err", err, "mirror", opts.Mirror, "remote", remote, "path", rp)
148 // Cleanup the mess!
149 if rerr := os.RemoveAll(rp); rerr != nil {
150 err = errors.Join(err, rerr)
151 }
152
153 return err
154 }
155
156 r, err := d.CreateRepository(ctx, name, user, opts)
157 if err != nil {
158 d.logger.Error("failed to create repository", "err", err, "name", name)
159 return err
160 }
161
162 defer func() {
163 if err != nil {
164 if rerr := d.DeleteRepository(ctx, name); rerr != nil {
165 d.logger.Error("failed to delete repository", "err", rerr, "name", name)
166 }
167 }
168 }()
169
170 rr, err := r.Open()
171 if err != nil {
172 d.logger.Error("failed to open repository", "err", err, "path", rp)
173 return err
174 }
175
176 repoc <- r
177
178 rcfg, err := rr.Config()
179 if err != nil {
180 d.logger.Error("failed to get repository config", "err", err, "path", rp)
181 return err
182 }
183
184 endpoint := remote
185 if opts.LFSEndpoint != "" {
186 endpoint = opts.LFSEndpoint
187 }
188
189 rcfg.Section("lfs").SetOption("url", endpoint)
190
191 if err := rr.SetConfig(rcfg); err != nil {
192 d.logger.Error("failed to set repository config", "err", err, "path", rp)
193 return err
194 }
195
196 ep, err := lfs.NewEndpoint(endpoint)
197 if err != nil {
198 d.logger.Error("failed to create lfs endpoint", "err", err, "path", rp)
199 return err
200 }
201
202 client := lfs.NewClient(ep)
203 if client == nil {
204 d.logger.Warn("failed to create lfs client: unsupported endpoint", "endpoint", endpoint)
205 return nil
206 }
207
208 if err := StoreRepoMissingLFSObjects(ctx, r, d.db, d.store, client); err != nil {
209 d.logger.Error("failed to store missing lfs objects", "err", err, "path", rp)
210 return err
211 }
212
213 return nil
214 })
215
216 go func() {
217 d.logger.Info("running import", "name", name)
218 d.manager.Run(tid, done)
219 }()
220
221 return <-repoc, <-done
222}
223
224// DeleteRepository deletes a repository.
225//
226// It implements backend.Backend.
227func (d *Backend) DeleteRepository(ctx context.Context, name string) error {
228 name = utils.SanitizeRepo(name)
229 rp := filepath.Join(d.repoPath(name))
230
231 user := proto.UserFromContext(ctx)
232 r, err := d.Repository(ctx, name)
233 if err != nil {
234 return err
235 }
236
237 // We create the webhook event before deleting the repository so we can
238 // send the event after deleting the repository.
239 wh, err := webhook.NewRepositoryEvent(ctx, user, r, webhook.RepositoryEventActionDelete)
240 if err != nil {
241 return err
242 }
243
244 if err := d.db.TransactionContext(ctx, func(tx *db.Tx) error {
245 // Delete repo from cache
246 defer d.cache.Delete(name)
247
248 repom, dberr := d.store.GetRepoByName(ctx, tx, name)
249 _, ferr := os.Stat(rp)
250 if dberr != nil && ferr != nil {
251 return proto.ErrRepoNotFound
252 }
253
254 // If the repo is not in the database but the directory exists, remove it
255 if dberr != nil && ferr == nil {
256 return os.RemoveAll(rp)
257 } else if dberr != nil {
258 return db.WrapError(dberr)
259 }
260
261 repoID := strconv.FormatInt(repom.ID, 10)
262 strg := storage.NewLocalStorage(filepath.Join(d.cfg.DataPath, "lfs", repoID))
263 objs, err := d.store.GetLFSObjectsByName(ctx, tx, name)
264 if err != nil {
265 return db.WrapError(err)
266 }
267
268 for _, obj := range objs {
269 p := lfs.Pointer{
270 Oid: obj.Oid,
271 Size: obj.Size,
272 }
273
274 d.logger.Debug("deleting lfs object", "repo", name, "oid", obj.Oid)
275 if err := strg.Delete(path.Join("objects", p.RelativePath())); err != nil {
276 d.logger.Error("failed to delete lfs object", "repo", name, "err", err, "oid", obj.Oid)
277 }
278 }
279
280 if err := d.store.DeleteRepoByName(ctx, tx, name); err != nil {
281 return db.WrapError(err)
282 }
283
284 return os.RemoveAll(rp)
285 }); err != nil {
286 if errors.Is(err, db.ErrRecordNotFound) {
287 return proto.ErrRepoNotFound
288 }
289
290 return db.WrapError(err)
291 }
292
293 return webhook.SendEvent(ctx, wh)
294}
295
296// DeleteUserRepositories deletes all user repositories.
297func (d *Backend) DeleteUserRepositories(ctx context.Context, username string) error {
298 if err := d.db.TransactionContext(ctx, func(tx *db.Tx) error {
299 user, err := d.store.FindUserByUsername(ctx, tx, username)
300 if err != nil {
301 return err
302 }
303
304 repos, err := d.store.GetUserRepos(ctx, tx, user.ID)
305 if err != nil {
306 return err
307 }
308
309 for _, repo := range repos {
310 if err := d.DeleteRepository(ctx, repo.Name); err != nil {
311 return err
312 }
313 }
314
315 return nil
316 }); err != nil {
317 return db.WrapError(err)
318 }
319
320 return nil
321}
322
323// RenameRepository renames a repository.
324//
325// It implements backend.Backend.
326func (d *Backend) RenameRepository(ctx context.Context, oldName string, newName string) error {
327 oldName = utils.SanitizeRepo(oldName)
328 if err := utils.ValidateRepo(oldName); err != nil {
329 return err
330 }
331
332 newName = utils.SanitizeRepo(newName)
333 if err := utils.ValidateRepo(newName); err != nil {
334 return err
335 }
336
337 if oldName == newName {
338 return nil
339 }
340
341 op := filepath.Join(d.repoPath(oldName))
342 np := filepath.Join(d.repoPath(newName))
343 if _, err := os.Stat(op); err != nil {
344 return proto.ErrRepoNotFound
345 }
346
347 if _, err := os.Stat(np); err == nil {
348 return proto.ErrRepoExist
349 }
350
351 if err := d.db.TransactionContext(ctx, func(tx *db.Tx) error {
352 // Delete cache
353 defer d.cache.Delete(oldName)
354
355 if err := d.store.SetRepoNameByName(ctx, tx, oldName, newName); err != nil {
356 return err
357 }
358
359 // Make sure the new repository parent directory exists.
360 if err := os.MkdirAll(filepath.Dir(np), os.ModePerm); err != nil {
361 return err
362 }
363
364 return os.Rename(op, np)
365 }); err != nil {
366 return db.WrapError(err)
367 }
368
369 user := proto.UserFromContext(ctx)
370 repo, err := d.Repository(ctx, newName)
371 if err != nil {
372 return err
373 }
374
375 wh, err := webhook.NewRepositoryEvent(ctx, user, repo, webhook.RepositoryEventActionRename)
376 if err != nil {
377 return err
378 }
379
380 return webhook.SendEvent(ctx, wh)
381}
382
383// Repositories returns a list of repositories per page.
384//
385// It implements backend.Backend.
386func (d *Backend) Repositories(ctx context.Context) ([]proto.Repository, error) {
387 repos := make([]proto.Repository, 0)
388
389 if err := d.db.TransactionContext(ctx, func(tx *db.Tx) error {
390 ms, err := d.store.GetAllRepos(ctx, tx)
391 if err != nil {
392 return err
393 }
394
395 for _, m := range ms {
396 r := &repo{
397 name: m.Name,
398 path: filepath.Join(d.repoPath(m.Name)),
399 repo: m,
400 }
401
402 // Cache repositories
403 d.cache.Set(m.Name, r)
404
405 repos = append(repos, r)
406 }
407
408 return nil
409 }); err != nil {
410 return nil, db.WrapError(err)
411 }
412
413 return repos, nil
414}
415
416// Repository returns a repository by name.
417//
418// It implements backend.Backend.
419func (d *Backend) Repository(ctx context.Context, name string) (proto.Repository, error) {
420 var m models.Repo
421 name = utils.SanitizeRepo(name)
422
423 if r, ok := d.cache.Get(name); ok && r != nil {
424 return r, nil
425 }
426
427 rp := filepath.Join(d.repoPath(name))
428 if _, err := os.Stat(rp); err != nil {
429 if !errors.Is(err, fs.ErrNotExist) {
430 d.logger.Errorf("failed to stat repository path: %v", err)
431 }
432 return nil, proto.ErrRepoNotFound
433 }
434
435 if err := d.db.TransactionContext(ctx, func(tx *db.Tx) error {
436 var err error
437 m, err = d.store.GetRepoByName(ctx, tx, name)
438 return db.WrapError(err)
439 }); err != nil {
440 if errors.Is(err, db.ErrRecordNotFound) {
441 return nil, proto.ErrRepoNotFound
442 }
443 return nil, db.WrapError(err)
444 }
445
446 r := &repo{
447 name: name,
448 path: rp,
449 repo: m,
450 }
451
452 // Add to cache
453 d.cache.Set(name, r)
454
455 return r, nil
456}
457
458// Description returns the description of a repository.
459//
460// It implements backend.Backend.
461func (d *Backend) Description(ctx context.Context, name string) (string, error) {
462 name = utils.SanitizeRepo(name)
463 var desc string
464 if err := d.db.TransactionContext(ctx, func(tx *db.Tx) error {
465 var err error
466 desc, err = d.store.GetRepoDescriptionByName(ctx, tx, name)
467 return err
468 }); err != nil {
469 return "", db.WrapError(err)
470 }
471
472 return desc, nil
473}
474
475// IsMirror returns true if the repository is a mirror.
476//
477// It implements backend.Backend.
478func (d *Backend) IsMirror(ctx context.Context, name string) (bool, error) {
479 name = utils.SanitizeRepo(name)
480 var mirror bool
481 if err := d.db.TransactionContext(ctx, func(tx *db.Tx) error {
482 var err error
483 mirror, err = d.store.GetRepoIsMirrorByName(ctx, tx, name)
484 return err
485 }); err != nil {
486 return false, db.WrapError(err)
487 }
488 return mirror, nil
489}
490
491// IsPrivate returns true if the repository is private.
492//
493// It implements backend.Backend.
494func (d *Backend) IsPrivate(ctx context.Context, name string) (bool, error) {
495 name = utils.SanitizeRepo(name)
496 var private bool
497 if err := d.db.TransactionContext(ctx, func(tx *db.Tx) error {
498 var err error
499 private, err = d.store.GetRepoIsPrivateByName(ctx, tx, name)
500 return err
501 }); err != nil {
502 return false, db.WrapError(err)
503 }
504
505 return private, nil
506}
507
508// IsHidden returns true if the repository is hidden.
509//
510// It implements backend.Backend.
511func (d *Backend) IsHidden(ctx context.Context, name string) (bool, error) {
512 name = utils.SanitizeRepo(name)
513 var hidden bool
514 if err := d.db.TransactionContext(ctx, func(tx *db.Tx) error {
515 var err error
516 hidden, err = d.store.GetRepoIsHiddenByName(ctx, tx, name)
517 return err
518 }); err != nil {
519 return false, db.WrapError(err)
520 }
521
522 return hidden, nil
523}
524
525// ProjectName returns the project name of a repository.
526//
527// It implements backend.Backend.
528func (d *Backend) ProjectName(ctx context.Context, name string) (string, error) {
529 name = utils.SanitizeRepo(name)
530 var pname string
531 if err := d.db.TransactionContext(ctx, func(tx *db.Tx) error {
532 var err error
533 pname, err = d.store.GetRepoProjectNameByName(ctx, tx, name)
534 return err
535 }); err != nil {
536 return "", db.WrapError(err)
537 }
538
539 return pname, nil
540}
541
542// SetHidden sets the hidden flag of a repository.
543//
544// It implements backend.Backend.
545func (d *Backend) SetHidden(ctx context.Context, name string, hidden bool) error {
546 name = utils.SanitizeRepo(name)
547
548 // Delete cache
549 d.cache.Delete(name)
550
551 return db.WrapError(d.db.TransactionContext(ctx, func(tx *db.Tx) error {
552 return d.store.SetRepoIsHiddenByName(ctx, tx, name, hidden)
553 }))
554}
555
556// SetMirror sets the mirror flag of a repository.
557// Note: enabling mirror mode requires the repository to have been imported
558// with a remote URL. Use ImportRepository to create a new mirror.
559func (d *Backend) SetMirror(ctx context.Context, name string, mirror bool) error {
560 name = utils.SanitizeRepo(name)
561 rp := filepath.Join(d.repoPath(name))
562
563 // Delete cache
564 d.cache.Delete(name)
565
566 return db.WrapError(d.db.TransactionContext(ctx, func(tx *db.Tx) error {
567 // Update git config
568 r, err := git.Open(rp)
569 if err != nil {
570 return err
571 }
572
573 rcfg, err := r.Config()
574 if err != nil {
575 return err
576 }
577
578 // Update mirror option for all remotes
579 remoteSection := rcfg.Section("remote")
580 hasRemote := false
581 for _, sub := range remoteSection.Subsections {
582 // Check if this remote has a URL
583 for _, opt := range sub.Options {
584 if opt.Key == "url" && opt.Value != "" {
585 hasRemote = true
586 found := false
587 for i, opt := range sub.Options {
588 if opt.Key == "mirror" {
589 found = true
590 if mirror {
591 sub.Options[i].Value = "true"
592 } else {
593 sub.Options = append(sub.Options[:i], sub.Options[i+1:]...)
594 }
595 break
596 }
597 }
598 if !found && mirror {
599 sub.SetOption("mirror", "true")
600 }
601 break
602 }
603 }
604 }
605
606 if mirror && !hasRemote {
607 return errors.New("cannot enable mirror mode: repository has no remote URL configured")
608 }
609
610 if err := r.SetConfig(rcfg); err != nil {
611 d.logger.Error("failed to set repository config", "err", err, "path", rp)
612 return err
613 }
614
615 return d.store.SetRepoIsMirrorByName(ctx, tx, name, mirror)
616 }))
617}
618
619// SetDescription sets the description of a repository.
620//
621// It implements backend.Backend.
622func (d *Backend) SetDescription(ctx context.Context, name string, desc string) error {
623 name = utils.SanitizeRepo(name)
624 desc = utils.Sanitize(desc)
625 rp := filepath.Join(d.repoPath(name))
626
627 // Delete cache
628 d.cache.Delete(name)
629
630 return d.db.TransactionContext(ctx, func(tx *db.Tx) error {
631 if err := os.WriteFile(filepath.Join(rp, "description"), []byte(desc), fs.ModePerm); err != nil {
632 d.logger.Error("failed to write description", "repo", name, "err", err)
633 return err
634 }
635
636 return d.store.SetRepoDescriptionByName(ctx, tx, name, desc)
637 })
638}
639
640// SetPrivate sets the private flag of a repository.
641//
642// It implements backend.Backend.
643func (d *Backend) SetPrivate(ctx context.Context, name string, private bool) error {
644 name = utils.SanitizeRepo(name)
645 rp := filepath.Join(d.repoPath(name))
646
647 // Delete cache
648 d.cache.Delete(name)
649
650 if err := db.WrapError(
651 d.db.TransactionContext(ctx, func(tx *db.Tx) error {
652 fp := filepath.Join(rp, "git-daemon-export-ok")
653 if !private {
654 if err := os.WriteFile(fp, []byte{}, fs.ModePerm); err != nil {
655 d.logger.Error("failed to write git-daemon-export-ok", "repo", name, "err", err)
656 return err
657 }
658 } else {
659 if _, err := os.Stat(fp); err == nil {
660 if err := os.Remove(fp); err != nil {
661 d.logger.Error("failed to remove git-daemon-export-ok", "repo", name, "err", err)
662 return err
663 }
664 }
665 }
666
667 return d.store.SetRepoIsPrivateByName(ctx, tx, name, private)
668 }),
669 ); err != nil {
670 return err
671 }
672
673 user := proto.UserFromContext(ctx)
674 repo, err := d.Repository(ctx, name)
675 if err != nil {
676 return err
677 }
678
679 if repo.IsPrivate() != !private {
680 wh, err := webhook.NewRepositoryEvent(ctx, user, repo, webhook.RepositoryEventActionVisibilityChange)
681 if err != nil {
682 return err
683 }
684
685 if err := webhook.SendEvent(ctx, wh); err != nil {
686 return err
687 }
688 }
689
690 return nil
691}
692
693// SetProjectName sets the project name of a repository.
694//
695// It implements backend.Backend.
696func (d *Backend) SetProjectName(ctx context.Context, repo string, name string) error {
697 repo = utils.SanitizeRepo(repo)
698 name = utils.Sanitize(name)
699
700 // Delete cache
701 d.cache.Delete(repo)
702
703 return db.WrapError(
704 d.db.TransactionContext(ctx, func(tx *db.Tx) error {
705 return d.store.SetRepoProjectNameByName(ctx, tx, repo, name)
706 }),
707 )
708}
709
710// repoPath returns the path to a repository.
711func (d *Backend) repoPath(name string) string {
712 name = utils.SanitizeRepo(name)
713 rn := strings.ReplaceAll(name, "/", string(os.PathSeparator))
714 return filepath.Join(filepath.Join(d.cfg.DataPath, "repos"), rn+".git")
715}
716
717var _ proto.Repository = (*repo)(nil)
718
719// repo is a Git repository with metadata stored in a SQLite database.
720type repo struct {
721 name string
722 path string
723 repo models.Repo
724}
725
726// ID returns the repository's ID.
727//
728// It implements proto.Repository.
729func (r *repo) ID() int64 {
730 return r.repo.ID
731}
732
733// UserID returns the repository's owner's user ID.
734// If the repository is not owned by anyone, it returns 0.
735//
736// It implements proto.Repository.
737func (r *repo) UserID() int64 {
738 if r.repo.UserID.Valid {
739 return r.repo.UserID.Int64
740 }
741 return 0
742}
743
744// Description returns the repository's description.
745//
746// It implements backend.Repository.
747func (r *repo) Description() string {
748 return r.repo.Description
749}
750
751// IsMirror returns whether the repository is a mirror.
752//
753// It implements backend.Repository.
754func (r *repo) IsMirror() bool {
755 return r.repo.Mirror
756}
757
758// IsPrivate returns whether the repository is private.
759//
760// It implements backend.Repository.
761func (r *repo) IsPrivate() bool {
762 return r.repo.Private
763}
764
765// Name returns the repository's name.
766//
767// It implements backend.Repository.
768func (r *repo) Name() string {
769 return r.name
770}
771
772// Open opens the repository.
773//
774// It implements backend.Repository.
775func (r *repo) Open() (*git.Repository, error) {
776 return git.Open(r.path)
777}
778
779// ProjectName returns the repository's project name.
780//
781// It implements backend.Repository.
782func (r *repo) ProjectName() string {
783 return r.repo.ProjectName
784}
785
786// IsHidden returns whether the repository is hidden.
787//
788// It implements backend.Repository.
789func (r *repo) IsHidden() bool {
790 return r.repo.Hidden
791}
792
793// CreatedAt returns the repository's creation time.
794func (r *repo) CreatedAt() time.Time {
795 return r.repo.CreatedAt
796}
797
798// UpdatedAt returns the repository's last update time.
799func (r *repo) UpdatedAt() time.Time {
800 // Try to read the last modified time from the info directory.
801 if t, err := readOneline(filepath.Join(r.path, "info", "last-modified")); err == nil {
802 if t, err := time.Parse(time.RFC3339, t); err == nil {
803 return t
804 }
805 }
806
807 rr, err := git.Open(r.path)
808 if err == nil {
809 t, err := rr.LatestCommitTime()
810 if err == nil {
811 return t
812 }
813 }
814
815 return r.repo.UpdatedAt
816}
817
818func (r *repo) writeLastModified(t time.Time) error {
819 fp := filepath.Join(r.path, "info", "last-modified")
820 if err := os.MkdirAll(filepath.Dir(fp), os.ModePerm); err != nil {
821 return err
822 }
823
824 return os.WriteFile(fp, []byte(t.Format(time.RFC3339)), os.ModePerm) //nolint:gosec
825}
826
827func readOneline(path string) (string, error) {
828 f, err := os.Open(path)
829 if err != nil {
830 return "", err
831 }
832
833 defer f.Close() //nolint: errcheck
834 s := bufio.NewScanner(f)
835 s.Scan()
836 return s.Text(), s.Err()
837}