package useCases import ( "RenewCMS/internal/domain/update" "errors" "fmt" "sync" "time" ) type StartUpdateDownloadUseCase struct { store update.StatusStore jobs update.JobStore helper update.HelperDetector results update.ResultStore status updateStatusBuilder fetcher update.AssetFetcher verifier update.SignatureVerifier runner update.StagedBinaryRunner backup update.DatabaseBackup staging update.StagingArea logger update.Logger currentVersion string platform update.Platform feedHost string now func() time.Time mu sync.Mutex } type StartUpdateDownloadDependencies struct { Store update.StatusStore Jobs update.JobStore Helper update.HelperDetector Results update.ResultStore Requests update.RestartRequestStore Fetcher update.AssetFetcher Verifier update.SignatureVerifier Runner update.StagedBinaryRunner Backup update.DatabaseBackup Staging update.StagingArea Logger update.Logger CurrentVersion string Platform update.Platform FeedURL string Now func() time.Time } type jobFailure struct { kind string reason error } func (f jobFailure) Error() string { return f.reason.Error() } func NewStartUpdateDownloadUseCase(dependencies StartUpdateDownloadDependencies) *StartUpdateDownloadUseCase { return &StartUpdateDownloadUseCase{ store: dependencies.Store, jobs: dependencies.Jobs, helper: dependencies.Helper, results: dependencies.Results, status: updateStatusBuilder{ store: dependencies.Store, jobs: dependencies.Jobs, results: dependencies.Results, requests: dependencies.Requests, currentVersion: dependencies.CurrentVersion, now: dependencies.Now, }, fetcher: dependencies.Fetcher, verifier: dependencies.Verifier, runner: dependencies.Runner, backup: dependencies.Backup, staging: dependencies.Staging, logger: dependencies.Logger, currentVersion: dependencies.CurrentVersion, platform: dependencies.Platform, feedHost: update.HostOf(dependencies.FeedURL), now: dependencies.Now, } } func (s *StartUpdateDownloadUseCase) Start() (update.UpdateStatus, error) { if !s.helper.Installed() { return update.UpdateStatus{}, update.ErrNotPrepared } s.mu.Lock() defer s.mu.Unlock() if job, found := s.jobs.Load(); found && job.Occupies() { return update.UpdateStatus{}, update.ErrJobInProgress } if s.status.build(true).State == update.StateRestarting { return update.UpdateStatus{}, update.ErrRestartPending } result, found := s.store.Load() if !found || !update.IsNewer(s.currentVersion, result.Latest) { return update.UpdateStatus{}, update.ErrNothingToUpdate } binary, checksums, signature, err := s.preflight(result) if err != nil { s.logger.Printf("update download refused: %v", err) return update.UpdateStatus{}, update.ErrPreflightRefused } job := update.Job{ Tag: result.Latest, State: update.StateDownloading, StartedAt: s.now().UTC(), TotalBytes: binary.Size, } if err := s.results.Delete(); err != nil { return update.UpdateStatus{}, err } if err := s.jobs.Save(job); err != nil { return update.UpdateStatus{}, err } status := s.status.build(true) go s.run(job, binary, checksums, signature) return status, nil } func (s *StartUpdateDownloadUseCase) preflight(result update.CheckResult) (binary, checksums, signature update.Asset, err error) { names := []string{update.AssetName(result.Latest, s.platform), update.ChecksumsAssetName, update.SignatureAssetName} found := make([]update.Asset, 0, len(names)) for _, name := range names { asset, ok := update.FindAsset(result.Assets, name) if !ok { return binary, checksums, signature, fmt.Errorf("asset %q is not listed for %s", name, result.Latest) } if !update.IsAllowedAssetURL(asset.URL, s.feedHost) { return binary, checksums, signature, fmt.Errorf("asset %q has a url outside the feed host or without https", name) } found = append(found, asset) } return found[0], found[1], found[2], nil } func (s *StartUpdateDownloadUseCase) run(job update.Job, binary, checksums, signature update.Asset) { defer func() { if recovered := recover(); recovered != nil { s.fail(job, downloadFailure(fmt.Errorf("panic: %v", recovered))) } }() if err := s.execute(&job, binary, checksums, signature); err != nil { s.fail(job, err) } } func (s *StartUpdateDownloadUseCase) execute(job *update.Job, binary, checksums, signature update.Asset) error { binaryPath := s.staging.PathFor(job.Tag, binary.Name) checksumsPath := s.staging.PathFor(job.Tag, checksums.Name) signaturePath := s.staging.PathFor(job.Tag, signature.Name) if err := s.staging.Prepare(job.Tag); err != nil { return downloadFailure(err) } if err := s.fetchSmall(checksums, checksumsPath); err != nil { return downloadFailure(err) } if err := s.fetchSmall(signature, signaturePath); err != nil { return downloadFailure(err) } if err := s.fetchBinary(job, binary, binaryPath); err != nil { return downloadFailure(err) } job.State = update.StateVerifying if err := s.jobs.Save(*job); err != nil { return downloadFailure(err) } if err := s.verify(job.Tag, binary.Name, binaryPath, checksumsPath, signaturePath); err != nil { return jobFailure{kind: update.ErrorKindVerification, reason: err} } if _, err := s.backup.Backup(); err != nil { return downloadFailure(err) } job.State = update.StateReady job.StagedBinary = binaryPath job.DownloadedBytes = 0 job.TotalBytes = 0 return s.jobs.Save(*job) } func (s *StartUpdateDownloadUseCase) fetchSmall(asset update.Asset, destination string) error { if err := s.fetcher.Fetch(asset, destination, ignoreProgress); err != nil { return err } return s.assertDeclaredSize(asset, destination) } func (s *StartUpdateDownloadUseCase) fetchBinary(job *update.Job, asset update.Asset, destination string) error { lastPercent := -1 report := func(done int64, _ int64) { current := *job current.DownloadedBytes = done percent := 0 if progress := current.Progress(); progress != nil { percent = progress.Percent } if percent == lastPercent && done < asset.Size { return } lastPercent = percent _ = s.jobs.Save(current) } if err := s.fetcher.Fetch(asset, destination, report); err != nil { return err } return s.assertDeclaredSize(asset, destination) } func ignoreProgress(int64, int64) {} func (s *StartUpdateDownloadUseCase) assertDeclaredSize(asset update.Asset, path string) error { size, err := s.staging.Size(path) if err != nil { return err } if asset.Size > 0 && size != asset.Size { return fmt.Errorf("received %d bytes for %q, the release declares %d", size, asset.Name, asset.Size) } return nil } func (s *StartUpdateDownloadUseCase) verify(tag, assetName, binaryPath, checksumsPath, signaturePath string) error { if err := s.verifier.Verify(checksumsPath, signaturePath); err != nil { return fmt.Errorf("signature check: %w", err) } content, err := s.staging.ReadChecksums(checksumsPath) if err != nil { return err } expected, found := update.ChecksumFor(content, assetName) if !found { return fmt.Errorf("checksums.txt has no line for %q", assetName) } actual, err := update.FileSHA256(binaryPath) if err != nil { return err } if actual != expected { return fmt.Errorf("sha-256 mismatch for %q", assetName) } if err := s.staging.MakeExecutable(binaryPath); err != nil { return err } output, err := s.runner.Version(binaryPath) if err != nil { return fmt.Errorf("running the staged binary: %w", err) } if output != tag { return fmt.Errorf("the staged binary reports version %q, expected %q", output, tag) } return nil } func downloadFailure(err error) error { return jobFailure{kind: update.ErrorKindDownload, reason: err} } func (s *StartUpdateDownloadUseCase) fail(job update.Job, err error) { kind := update.ErrorKindDownload var failure jobFailure if errors.As(err, &failure) { kind = failure.kind } s.logger.Printf("update to %s failed (%s): %v", job.Tag, kind, err) _ = s.staging.Remove(job.Tag) job.State = update.StateFailed job.Error = kind job.DownloadedBytes = 0 job.TotalBytes = 0 job.StagedBinary = "" if saveErr := s.jobs.Save(job); saveErr != nil { s.logger.Printf("update job record could not be saved: %v", saveErr) } }