From 6bccce1db4a83ce609a6f3532a2f3d33668ebb4f Mon Sep 17 00:00:00 2001 From: Maxim Slipenko Date: Fri, 20 Jun 2025 19:35:22 +0300 Subject: [PATCH] feat: add checksum for git downloader --- assets/coverage-badge.svg | 4 +- internal/build/source_downloader.go | 6 +- internal/translations/default.pot | 48 +++--- internal/translations/po/ru/default.po | 48 +++--- {internal => pkg}/dl/dl.go | 2 +- {internal => pkg}/dl/dl_test.go | 4 +- {internal => pkg}/dl/file.go | 0 {internal => pkg}/dl/git.go | 56 +++++-- pkg/dl/git_test.go | 183 ++++++++++++++++++++++ {internal => pkg}/dl/progress_tui.go | 0 {internal => pkg}/dl/torrent.go | 0 pkg/dl/utils.go | 55 +++++++ {internal => pkg}/dlcache/dlcache.go | 0 {internal => pkg}/dlcache/dlcache_test.go | 2 +- {internal => pkg}/dlcache/utils.go | 0 15 files changed, 343 insertions(+), 65 deletions(-) rename {internal => pkg}/dl/dl.go (99%) rename {internal => pkg}/dl/dl_test.go (97%) rename {internal => pkg}/dl/file.go (100%) rename {internal => pkg}/dl/git.go (80%) create mode 100644 pkg/dl/git_test.go rename {internal => pkg}/dl/progress_tui.go (100%) rename {internal => pkg}/dl/torrent.go (100%) create mode 100644 pkg/dl/utils.go rename {internal => pkg}/dlcache/dlcache.go (100%) rename {internal => pkg}/dlcache/dlcache_test.go (97%) rename {internal => pkg}/dlcache/utils.go (100%) diff --git a/assets/coverage-badge.svg b/assets/coverage-badge.svg index 259d4b3..665bcb6 100644 --- a/assets/coverage-badge.svg +++ b/assets/coverage-badge.svg @@ -11,7 +11,7 @@ coverage coverage - 18.9% - 18.9% + 20.2% + 20.2% diff --git a/internal/build/source_downloader.go b/internal/build/source_downloader.go index ef287fd..f5ac2d6 100644 --- a/internal/build/source_downloader.go +++ b/internal/build/source_downloader.go @@ -20,11 +20,12 @@ import ( "context" "encoding/hex" "fmt" + "log/slog" "os" "strings" - "gitea.plemya-x.ru/Plemya-x/ALR/internal/dl" - "gitea.plemya-x.ru/Plemya-x/ALR/internal/dlcache" + "gitea.plemya-x.ru/Plemya-x/ALR/pkg/dl" + "gitea.plemya-x.ru/Plemya-x/ALR/pkg/dlcache" ) type SourceDownloader struct { @@ -76,6 +77,7 @@ func (s *SourceDownloader) DownloadSources( opts.DlCache = dlcache.New(s.cfg) + slog.Warn("opts", "opts", opts) err := dl.Download(ctx, opts) if err != nil { return err diff --git a/internal/translations/default.pot b/internal/translations/default.pot index ca81861..cce76d6 100644 --- a/internal/translations/default.pot +++ b/internal/translations/default.pot @@ -355,30 +355,6 @@ msgid "" "Database version does not exist. Run alr fix if something isn't working." msgstr "" -#: internal/dl/dl.go:170 -msgid "Source can be updated, updating if required" -msgstr "" - -#: internal/dl/dl.go:201 -msgid "Source found in cache and linked to destination" -msgstr "" - -#: internal/dl/dl.go:208 -msgid "Source updated and linked to destination" -msgstr "" - -#: internal/dl/dl.go:222 -msgid "Downloading source" -msgstr "" - -#: internal/dl/progress_tui.go:100 -msgid "%s: done!\n" -msgstr "" - -#: internal/dl/progress_tui.go:104 -msgid "%s %s downloading at %s/s\n" -msgstr "" - #: internal/logger/log.go:41 msgid "ERROR" msgstr "" @@ -465,6 +441,30 @@ msgstr "" msgid "Error while running app" msgstr "" +#: pkg/dl/dl.go:170 +msgid "Source can be updated, updating if required" +msgstr "" + +#: pkg/dl/dl.go:201 +msgid "Source found in cache and linked to destination" +msgstr "" + +#: pkg/dl/dl.go:208 +msgid "Source updated and linked to destination" +msgstr "" + +#: pkg/dl/dl.go:222 +msgid "Downloading source" +msgstr "" + +#: pkg/dl/progress_tui.go:100 +msgid "%s: done!\n" +msgstr "" + +#: pkg/dl/progress_tui.go:104 +msgid "%s %s downloading at %s/s\n" +msgstr "" + #: refresh.go:30 msgid "Pull all repositories that have changed" msgstr "" diff --git a/internal/translations/po/ru/default.po b/internal/translations/po/ru/default.po index b109c78..9866102 100644 --- a/internal/translations/po/ru/default.po +++ b/internal/translations/po/ru/default.po @@ -369,30 +369,6 @@ msgid "" msgstr "" "Версия базы данных не существует. Запустите alr fix, если что-то не работает." -#: internal/dl/dl.go:170 -msgid "Source can be updated, updating if required" -msgstr "Исходный код можно обновлять, обновляя при необходимости" - -#: internal/dl/dl.go:201 -msgid "Source found in cache and linked to destination" -msgstr "Источник найден в кэше и связан с пунктом назначения" - -#: internal/dl/dl.go:208 -msgid "Source updated and linked to destination" -msgstr "Источник обновлён и связан с пунктом назначения" - -#: internal/dl/dl.go:222 -msgid "Downloading source" -msgstr "Скачивание источника" - -#: internal/dl/progress_tui.go:100 -msgid "%s: done!\n" -msgstr "%s: выполнено!\n" - -#: internal/dl/progress_tui.go:104 -msgid "%s %s downloading at %s/s\n" -msgstr "%s %s загружается — %s/с\n" - #: internal/logger/log.go:41 msgid "ERROR" msgstr "ОШИБКА" @@ -481,6 +457,30 @@ msgstr "Показать справку" msgid "Error while running app" msgstr "Ошибка при запуске приложения" +#: pkg/dl/dl.go:170 +msgid "Source can be updated, updating if required" +msgstr "Исходный код можно обновлять, обновляя при необходимости" + +#: pkg/dl/dl.go:201 +msgid "Source found in cache and linked to destination" +msgstr "Источник найден в кэше и связан с пунктом назначения" + +#: pkg/dl/dl.go:208 +msgid "Source updated and linked to destination" +msgstr "Источник обновлён и связан с пунктом назначения" + +#: pkg/dl/dl.go:222 +msgid "Downloading source" +msgstr "Скачивание источника" + +#: pkg/dl/progress_tui.go:100 +msgid "%s: done!\n" +msgstr "%s: выполнено!\n" + +#: pkg/dl/progress_tui.go:104 +msgid "%s %s downloading at %s/s\n" +msgstr "%s %s загружается — %s/с\n" + #: refresh.go:30 msgid "Pull all repositories that have changed" msgstr "Скачать все изменённые репозитории" diff --git a/internal/dl/dl.go b/pkg/dl/dl.go similarity index 99% rename from internal/dl/dl.go rename to pkg/dl/dl.go index a1c0fff..6c349c1 100644 --- a/internal/dl/dl.go +++ b/pkg/dl/dl.go @@ -55,7 +55,7 @@ var ( // Массив доступных загрузчиков в порядке их проверки var Downloaders = []Downloader{ - GitDownloader{}, + &GitDownloader{}, TorrentDownloader{}, FileDownloader{}, } diff --git a/internal/dl/dl_test.go b/pkg/dl/dl_test.go similarity index 97% rename from internal/dl/dl_test.go rename to pkg/dl/dl_test.go index 07dc817..c7326b8 100644 --- a/internal/dl/dl_test.go +++ b/pkg/dl/dl_test.go @@ -32,8 +32,8 @@ import ( "github.com/stretchr/testify/assert" "gitea.plemya-x.ru/Plemya-x/ALR/internal/config" - "gitea.plemya-x.ru/Plemya-x/ALR/internal/dl" - "gitea.plemya-x.ru/Plemya-x/ALR/internal/dlcache" + "gitea.plemya-x.ru/Plemya-x/ALR/pkg/dl" + "gitea.plemya-x.ru/Plemya-x/ALR/pkg/dlcache" ) type TestALRConfig struct{} diff --git a/internal/dl/file.go b/pkg/dl/file.go similarity index 100% rename from internal/dl/file.go rename to pkg/dl/file.go diff --git a/internal/dl/git.go b/pkg/dl/git.go similarity index 80% rename from internal/dl/git.go rename to pkg/dl/git.go index c1ea822..0de751f 100644 --- a/internal/dl/git.go +++ b/pkg/dl/git.go @@ -20,8 +20,11 @@ package dl import ( + "bytes" "context" + "encoding/hex" "errors" + "log/slog" "net/url" "path" "strconv" @@ -48,7 +51,7 @@ func (GitDownloader) MatchURL(u string) bool { // Download uses git to clone the repository from the specified URL. // It allows specifying the revision, depth and recursion options // via query string -func (GitDownloader) Download(ctx context.Context, opts Options) (Type, string, error) { +func (d *GitDownloader) Download(ctx context.Context, opts Options) (Type, string, error) { u, err := url.Parse(opts.URL) if err != nil { return 0, "", err @@ -60,6 +63,9 @@ func (GitDownloader) Download(ctx context.Context, opts Options) (Type, string, rev := query.Get("~rev") query.Del("~rev") + // Right now, this only affects the return value of name, + // which will be used by dl_cache. + // It seems wrong, but for now it's better to leave it as it is. name := query.Get("~name") query.Del("~name") @@ -121,6 +127,11 @@ func (GitDownloader) Download(ctx context.Context, opts Options) (Type, string, } } + err = d.verifyHash(opts) + if err != nil { + return 0, "", err + } + if name == "" { name = strings.TrimSuffix(path.Base(u.Path), ".git") } @@ -128,12 +139,36 @@ func (GitDownloader) Download(ctx context.Context, opts Options) (Type, string, return TypeDir, name, nil } +func (GitDownloader) verifyHash(opts Options) error { + if opts.Hash != nil { + h, err := opts.NewHash() + if err != nil { + return err + } + + err = HashDir(opts.Destination, h) + if err != nil { + return err + } + + sum := h.Sum(nil) + + slog.Warn("validate checksum", "real", hex.EncodeToString(sum), "expected", hex.EncodeToString(opts.Hash)) + + if !bytes.Equal(sum, opts.Hash) { + return ErrChecksumMismatch + } + } + + return nil +} + // Update uses git to pull the repository and update it // to the latest revision. It allows specifying the depth // and recursion options via query string. It returns // true if update was successful and false if the // repository is already up-to-date -func (GitDownloader) Update(opts Options) (bool, error) { +func (d *GitDownloader) Update(opts Options) (bool, error) { u, err := url.Parse(opts.URL) if err != nil { return false, err @@ -183,18 +218,21 @@ func (GitDownloader) Update(opts Options) (bool, error) { manifestOK := err == nil err = w.Pull(po) - if errors.Is(err, git.NoErrAlreadyUpToDate) { - return false, nil - } else if err != nil { + if err != nil { + if errors.Is(err, git.NoErrAlreadyUpToDate) { + return false, nil + } + return false, err + } + + err = d.verifyHash(opts) + if err != nil { return false, err } if manifestOK { err = writeManifest(opts.Destination, m) - if err != nil { - return true, err - } } - return true, nil + return true, err } diff --git a/pkg/dl/git_test.go b/pkg/dl/git_test.go new file mode 100644 index 0000000..1c65db4 --- /dev/null +++ b/pkg/dl/git_test.go @@ -0,0 +1,183 @@ +// ALR - Any Linux Repository +// Copyright (C) 2025 The ALR Authors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +package dl_test + +import ( + "context" + "encoding/hex" + "os" + "os/exec" + "testing" + + "github.com/stretchr/testify/assert" + + "gitea.plemya-x.ru/Plemya-x/ALR/pkg/dl" +) + +func TestGitDownloaderMatchUrl(t *testing.T) { + d := dl.GitDownloader{} + assert.True(t, d.MatchURL("git+https://example.com/org/project.git")) + assert.False(t, d.MatchURL("https://example.com/org/project.git")) +} + +func TestGitDownloaderDownload(t *testing.T) { + d := dl.GitDownloader{} + + createTempDir := func(t *testing.T, name string) string { + t.Helper() + dir, err := os.MkdirTemp("", "test-"+name) + assert.NoError(t, err) + t.Cleanup(func() { + _ = os.RemoveAll(dir) + }) + return dir + } + + t.Run("simple", func(t *testing.T) { + dest := createTempDir(t, "simple") + + dlType, name, err := d.Download(context.Background(), dl.Options{ + URL: "git+https://gitea.plemya-x.ru/Plemya-x/repo-for-tests.git", + Destination: dest, + }) + + assert.NoError(t, err) + assert.Equal(t, dl.TypeDir, dlType) + assert.Equal(t, "repo-for-tests", name) + }) + + t.Run("with hash", func(t *testing.T) { + dest := createTempDir(t, "with-hash") + + hsh, err := hex.DecodeString("33c912b855352663550003ca6b948ae3df1f38e2c036f5a85775df5967e143bf") + assert.NoError(t, err) + + dlType, name, err := d.Download(context.Background(), dl.Options{ + URL: "git+https://gitea.plemya-x.ru/Plemya-x/repo-for-tests.git?~rev=init&~name=test", + Destination: dest, + Hash: hsh, + HashAlgorithm: "sha256", + }) + + assert.NoError(t, err) + assert.Equal(t, dl.TypeDir, dlType) + assert.Equal(t, "test", name) + }) + + t.Run("with hash (checksum mismatch)", func(t *testing.T) { + dest := createTempDir(t, "with-hash-checksum-mismatch") + + hsh, err := hex.DecodeString("33c912b855352663550003ca6b948ae3df1f38e2c036f5a85775df5967e143bf") + assert.NoError(t, err) + + _, _, err = d.Download(context.Background(), dl.Options{ + URL: "git+https://gitea.plemya-x.ru/Plemya-x/repo-for-tests.git", + Destination: dest, + Hash: hsh, + HashAlgorithm: "sha256", + }) + + assert.ErrorIs(t, err, dl.ErrChecksumMismatch) + }) +} + +func TestGitDownloaderUpdate(t *testing.T) { + d := dl.GitDownloader{} + + createTempDir := func(t *testing.T, name string) string { + t.Helper() + dir, err := os.MkdirTemp("", "test-"+name) + + assert.NoError(t, err) + t.Cleanup(func() { + _ = os.RemoveAll(dir) + }) + return dir + } + + setupOldRepo := func(t *testing.T, dest string) { + t.Helper() + + cmd := exec.Command("git", "clone", "https://gitea.plemya-x.ru/Plemya-x/repo-for-tests.git", dest) + err := cmd.Run() + assert.NoError(t, err) + + cmd = exec.Command("git", "-C", dest, "reset", "--hard", "init") + err = cmd.Run() + assert.NoError(t, err) + } + + t.Run("simple", func(t *testing.T) { + dest := createTempDir(t, "update") + + setupOldRepo(t, dest) + + cmd := exec.Command("git", "-C", dest, "rev-parse", "HEAD") + oldHash, err := cmd.Output() + assert.NoError(t, err) + + updated, err := d.Update(dl.Options{ + URL: "git+https://gitea.plemya-x.ru/Plemya-x/repo-for-tests.git", + Destination: dest, + }) + + assert.NoError(t, err) + assert.True(t, updated) + + cmd = exec.Command("git", "-C", dest, "rev-parse", "HEAD") + newHash, err := cmd.Output() + assert.NoError(t, err) + assert.NotEqual(t, string(oldHash), string(newHash), "Repository should be updated") + }) + + t.Run("with hash", func(t *testing.T) { + dest := createTempDir(t, "update") + + setupOldRepo(t, dest) + + hsh, err := hex.DecodeString("0dc4f3c68c435d0cd7a5ee960f965815fa9c4ee0571839cdb8f9de56e06f91eb") + assert.NoError(t, err) + + updated, err := d.Update(dl.Options{ + URL: "git+https://gitea.plemya-x.ru/Plemya-x/repo-for-tests.git~rev=test-update-git-downloader", + Destination: dest, + Hash: hsh, + HashAlgorithm: "sha256", + }) + + assert.NoError(t, err) + assert.True(t, updated) + }) + + t.Run("with hash (checksum mismatch)", func(t *testing.T) { + dest := createTempDir(t, "update") + + setupOldRepo(t, dest) + + hsh, err := hex.DecodeString("33c912b855352663550003ca6b948ae3df1f38e2c036f5a85775df5967e143bf") + assert.NoError(t, err) + + _, err = d.Update(dl.Options{ + URL: "git+https://gitea.plemya-x.ru/Plemya-x/repo-for-tests.git?~rev=test-update-git-downloader", + Destination: dest, + Hash: hsh, + HashAlgorithm: "sha256", + }) + + assert.ErrorIs(t, err, dl.ErrChecksumMismatch) + }) +} diff --git a/internal/dl/progress_tui.go b/pkg/dl/progress_tui.go similarity index 100% rename from internal/dl/progress_tui.go rename to pkg/dl/progress_tui.go diff --git a/internal/dl/torrent.go b/pkg/dl/torrent.go similarity index 100% rename from internal/dl/torrent.go rename to pkg/dl/torrent.go diff --git a/pkg/dl/utils.go b/pkg/dl/utils.go new file mode 100644 index 0000000..a4e4a62 --- /dev/null +++ b/pkg/dl/utils.go @@ -0,0 +1,55 @@ +// ALR - Any Linux Repository +// Copyright (C) 2025 The ALR Authors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +package dl + +import ( + "hash" + "io" + "os" + "path/filepath" +) + +func HashDir(dirPath string, h hash.Hash) error { + err := filepath.Walk(dirPath, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + // Skip .git directory + if info.IsDir() && info.Name() == ".git" { + return filepath.SkipDir + } + // Skip directories (only process files) + if !info.Mode().IsRegular() { + return nil + } + // Open file + f, err := os.Open(path) + if err != nil { + return err + } + defer f.Close() + // Write file content to hasher + if _, err := io.Copy(h, f); err != nil { + return err + } + return nil + }) + if err != nil { + return err + } + return nil +} diff --git a/internal/dlcache/dlcache.go b/pkg/dlcache/dlcache.go similarity index 100% rename from internal/dlcache/dlcache.go rename to pkg/dlcache/dlcache.go diff --git a/internal/dlcache/dlcache_test.go b/pkg/dlcache/dlcache_test.go similarity index 97% rename from internal/dlcache/dlcache_test.go rename to pkg/dlcache/dlcache_test.go index d189a0e..555cdba 100644 --- a/internal/dlcache/dlcache_test.go +++ b/pkg/dlcache/dlcache_test.go @@ -29,7 +29,7 @@ import ( "testing" "gitea.plemya-x.ru/Plemya-x/ALR/internal/config" - "gitea.plemya-x.ru/Plemya-x/ALR/internal/dlcache" + "gitea.plemya-x.ru/Plemya-x/ALR/pkg/dlcache" ) type TestALRConfig struct { diff --git a/internal/dlcache/utils.go b/pkg/dlcache/utils.go similarity index 100% rename from internal/dlcache/utils.go rename to pkg/dlcache/utils.go