package useCases import ( "RenewCMS/internal/domain/update" "errors" "fmt" "sync" "time" ) type StartUpdateDownloadUseCase struct { store update.StatusStore jobs update.JobStore helper update.HelperDetector 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 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, 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 } 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.jobs.Save(job); err != nil { return update.UpdateStatus{}, err } status := buildUpdateStatus(s.store, s.jobs, s.currentVersion, 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) } }