From 5ecbb9bc1b9bdfbb7521a5b2baa7c5f1e96e7afa Mon Sep 17 00:00:00 2001 From: David Rotermund Date: Sat, 8 Feb 2025 23:38:29 +0100 Subject: [PATCH] Annex v10.0.0-git-annex1 --- .../apt-install-from/action.yaml | 3 + .../workflows-composite/setup-env/action.yaml | 4 +- .forgejo/workflows/build-oci-image.yml | 41 + .forgejo/workflows/testing.yml | 17 +- Dockerfile | 1 + Dockerfile.rootless | 1 + Makefile | 4 +- cmd/serv.go | 76 +- cmd/web.go | 11 + modules/annex/annex.go | 192 ++ modules/base/tool.go | 7 + modules/git/blob.go | 4 + modules/git/command.go | 3 +- modules/markup/external/external.go | 25 +- modules/markup/renderer.go | 20 +- modules/private/serv.go | 1 + modules/setting/annex.go | 25 + modules/setting/setting.go | 1 + modules/util/remove.go | 42 +- options/locale/locale_cs-CZ.ini | 2 + options/locale/locale_de-DE.ini | 3 + options/locale/locale_el-GR.ini | 2 + options/locale/locale_en-US.ini | 3 + options/locale/locale_es-ES.ini | 2 + options/locale/locale_fa-IR.ini | 2 + options/locale/locale_fr-FR.ini | 2 + options/locale/locale_hu-HU.ini | 2 + options/locale/locale_id-ID.ini | 2 + options/locale/locale_is-IS.ini | 1 + options/locale/locale_it-IT.ini | 2 + options/locale/locale_ja-JP.ini | 2 + options/locale/locale_ko-KR.ini | 1 + options/locale/locale_lv-LV.ini | 4 +- options/locale/locale_nl-NL.ini | 2 + options/locale/locale_pl-PL.ini | 2 + options/locale/locale_pt-BR.ini | 2 + options/locale/locale_pt-PT.ini | 2 + options/locale/locale_ru-RU.ini | 2 + options/locale/locale_si-LK.ini | 2 + options/locale/locale_sk-SK.ini | 1 + options/locale/locale_sv-SE.ini | 2 + options/locale/locale_tr-TR.ini | 2 + options/locale/locale_uk-UA.ini | 2 + options/locale/locale_zh-CN.ini | 2 + options/locale/locale_zh-HK.ini | 3 +- options/locale/locale_zh-TW.ini | 2 + package-lock.json | 2 +- routers/init.go | 3 + routers/private/serv.go | 12 +- routers/web/repo/annex.go | 146 + routers/web/repo/compare.go | 27 +- routers/web/repo/download.go | 21 + routers/web/repo/githttp.go | 87 +- routers/web/repo/view.go | 91 +- routers/web/web.go | 23 + services/auth/auth.go | 11 + services/auth/basic.go | 4 +- services/repository/files/temp_repo.go | 20 + services/repository/files/upload.go | 83 +- templates/repo/file_info.tmpl | 1 + .../api_helper_for_declarative_test.go | 25 + tests/integration/git_annex_test.go | 2925 +++++++++++++++++ .../git_helper_for_declarative_test.go | 29 + tests/mysql.ini.tmpl | 3 + tests/pgsql.ini.tmpl | 3 + tests/sqlite.ini.tmpl | 3 + tests/test_utils.go | 79 + web_src/css/repo.css | 1 + web_src/js/features/imagediff.js | 12 +- 69 files changed, 4077 insertions(+), 68 deletions(-) create mode 100644 .forgejo/workflows/build-oci-image.yml create mode 100644 modules/annex/annex.go create mode 100644 modules/setting/annex.go create mode 100644 routers/web/repo/annex.go create mode 100644 tests/integration/git_annex_test.go diff --git a/.forgejo/workflows-composite/apt-install-from/action.yaml b/.forgejo/workflows-composite/apt-install-from/action.yaml index 615e7cb..ab55883 100644 --- a/.forgejo/workflows-composite/apt-install-from/action.yaml +++ b/.forgejo/workflows-composite/apt-install-from/action.yaml @@ -13,6 +13,8 @@ runs: run: | export DEBIAN_FRONTEND=noninteractive echo "deb http://deb.debian.org/debian/ ${RELEASE} main" > "/etc/apt/sources.list.d/${RELEASE}.list" + wget -O- http://neuro.debian.net/lists/bookworm.de-fzj.libre | tee /etc/apt/sources.list.d/neurodebian.sources.list + apt-key adv --recv-keys --keyserver hkps://keyserver.ubuntu.com 0xA5D32F012649A5A9 env: RELEASE: ${{inputs.release}} - name: install packages @@ -24,6 +26,7 @@ runs: - name: remove temporary package list to prevent using it in other steps run: | rm "/etc/apt/sources.list.d/${RELEASE}.list" + rm "/etc/apt/sources.list.d/neurodebian.sources.list" apt-get update -qq env: RELEASE: ${{inputs.release}} diff --git a/.forgejo/workflows-composite/setup-env/action.yaml b/.forgejo/workflows-composite/setup-env/action.yaml index 28216e9..f19569a 100644 --- a/.forgejo/workflows-composite/setup-env/action.yaml +++ b/.forgejo/workflows-composite/setup-env/action.yaml @@ -19,7 +19,7 @@ runs: set -ex toolchain=$(grep -oP '(?<=toolchain ).+' go.mod) version=$(go version | cut -d' ' -f3) - if [ "$toolchain" != "$version" ]; then - echo "go version mismatch: $toolchain <> $version" + if dpkg --compare-versions ${version#go} lt ${toolchain#go}; then + echo "go version too low: $toolchain >= $version" exit 1 fi diff --git a/.forgejo/workflows/build-oci-image.yml b/.forgejo/workflows/build-oci-image.yml new file mode 100644 index 0000000..8e843b4 --- /dev/null +++ b/.forgejo/workflows/build-oci-image.yml @@ -0,0 +1,41 @@ +on: + push: + branches: + - 'forgejo' + tags: + - '*-git-annex*' + +jobs: + build-oci-image: + runs-on: docker + strategy: + matrix: + type: ["rootful", "rootless"] + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 # fetch the full history so that the Forgejo version is determined properly + - name: Determine registry and username + id: determine-registry-and-username + run: | + echo "registry=${GITHUB_SERVER_URL#https://}" >> "$GITHUB_OUTPUT" + echo "username=${GITHUB_REPOSITORY%/*}" >> "$GITHUB_OUTPUT" + - name: Install Docker + run: curl -fsSL https://get.docker.com | sh + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + - name: Login to Docker Hub + uses: docker/login-action@v3 + with: + registry: ${{ steps.determine-registry-and-username.outputs.registry }} + username: ${{ steps.determine-registry-and-username.outputs.username }} + password: ${{ secrets.REGISTRY_TOKEN }} + - name: Build and push + uses: docker/build-push-action@v6 + with: + context: . + file: ${{ (matrix.type == 'rootful' && 'Dockerfile') || (matrix.type == 'rootless' && 'Dockerfile.rootless') }} + push: true + tags: ${{ steps.determine-registry-and-username.outputs.registry }}/${{ github.repository }}:${{ github.ref_name }}${{ (matrix.type == 'rootful' && ' ') || (matrix.type == 'rootless' && '-rootless') }} diff --git a/.forgejo/workflows/testing.yml b/.forgejo/workflows/testing.yml index eb3163d..c4972c9 100644 --- a/.forgejo/workflows/testing.yml +++ b/.forgejo/workflows/testing.yml @@ -10,7 +10,6 @@ on: jobs: backend-checks: - if: vars.ROLE == 'forgejo-coding' || vars.ROLE == 'forgejo-testing' runs-on: docker container: image: 'data.forgejo.org/oci/node:20-bookworm' @@ -27,7 +26,6 @@ jobs: - run: su forgejo -c 'make --always-make -j$(nproc) lint-backend tidy-check swagger-check fmt-check swagger-validate' # ensure the "go-licenses" make target runs - uses: ./.forgejo/workflows-composite/build-backend frontend-checks: - if: vars.ROLE == 'forgejo-coding' || vars.ROLE == 'forgejo-testing' runs-on: docker container: image: 'data.forgejo.org/oci/node:20-bookworm' @@ -176,7 +174,6 @@ jobs: TAGS: bindata TEST_REDIS_SERVER: cacher:${{ matrix.cacher.port }} test-mysql: - if: vars.ROLE == 'forgejo-coding' || vars.ROLE == 'forgejo-testing' runs-on: docker needs: [backend-checks, frontend-checks] container: @@ -199,15 +196,13 @@ jobs: - name: install dependencies & git >= 2.42 uses: ./.forgejo/workflows-composite/apt-install-from with: - packages: git git-lfs + packages: git git-annex-standalone git-lfs - uses: ./.forgejo/workflows-composite/build-backend - run: | su forgejo -c 'make test-mysql-migration test-mysql' - timeout-minutes: 120 env: USE_REPO_TEST_DIR: 1 test-pgsql: - if: vars.ROLE == 'forgejo-coding' || vars.ROLE == 'forgejo-testing' runs-on: docker needs: [backend-checks, frontend-checks] container: @@ -236,17 +231,15 @@ jobs: - name: install dependencies & git >= 2.42 uses: ./.forgejo/workflows-composite/apt-install-from with: - packages: git git-lfs + packages: git git-annex-standalone git-lfs - uses: ./.forgejo/workflows-composite/build-backend - run: | su forgejo -c 'make test-pgsql-migration test-pgsql' - timeout-minutes: 120 env: RACE_ENABLED: true USE_REPO_TEST_DIR: 1 TEST_LDAP: 1 test-sqlite: - if: vars.ROLE == 'forgejo-coding' || vars.ROLE == 'forgejo-testing' runs-on: docker needs: [backend-checks, frontend-checks] container: @@ -258,25 +251,21 @@ jobs: - name: install dependencies & git >= 2.42 uses: ./.forgejo/workflows-composite/apt-install-from with: - packages: git git-lfs + packages: git git-annex-standalone git-lfs - uses: ./.forgejo/workflows-composite/build-backend - run: | su forgejo -c 'make test-sqlite-migration test-sqlite' - timeout-minutes: 120 env: TAGS: sqlite sqlite_unlock_notify RACE_ENABLED: true TEST_TAGS: sqlite sqlite_unlock_notify USE_REPO_TEST_DIR: 1 security-check: - if: vars.ROLE == 'forgejo-coding' || vars.ROLE == 'forgejo-testing' runs-on: docker needs: - test-sqlite - test-pgsql - test-mysql - - test-remote-cacher - - test-unit container: image: 'data.forgejo.org/oci/node:20-bookworm' options: --tmpfs /tmp:exec,noatime diff --git a/Dockerfile b/Dockerfile index ae21a08..d39de78 100644 --- a/Dockerfile +++ b/Dockerfile @@ -78,6 +78,7 @@ RUN apk --no-cache add \ sqlite \ su-exec \ gnupg \ + git-annex \ && rm -rf /var/cache/apk/* RUN addgroup \ diff --git a/Dockerfile.rootless b/Dockerfile.rootless index c5d6a13..d636e10 100644 --- a/Dockerfile.rootless +++ b/Dockerfile.rootless @@ -71,6 +71,7 @@ RUN apk --no-cache add \ git \ curl \ gnupg \ + git-annex \ && rm -rf /var/cache/apk/* RUN addgroup \ diff --git a/Makefile b/Makefile index a9de57e..561d674 100644 --- a/Makefile +++ b/Makefile @@ -8,7 +8,7 @@ self := $(location) @tmpdir=`mktemp --tmpdir -d` ; \ echo Using temporary directory $$tmpdir for test repositories ; \ USE_REPO_TEST_DIR= $(MAKE) -f $(self) --no-print-directory REPO_TEST_DIR=$$tmpdir/ $@ ; \ - STATUS=$$? ; rm -r "$$tmpdir" ; exit $$STATUS + STATUS=$$? ; chmod -R +w "$$tmpdir" && rm -r "$$tmpdir" ; exit $$STATUS else @@ -104,7 +104,7 @@ else FORGEJO_VERSION_API ?= $(GITEA_VERSION)+${GITEA_COMPATIBILITY} else # drop the "g" prefix prepended by git describe to the commit hash - FORGEJO_VERSION ?= $(shell git describe --exclude '*-test' --tags --always | sed 's/^v//' | sed 's/\-g/-/')+${GITEA_COMPATIBILITY} + FORGEJO_VERSION ?= $(shell git describe --exclude '*-test' --tags --always | sed 's/^v//' | sed 's/\-g/-/2')+${GITEA_COMPATIBILITY} endif endif FORGEJO_VERSION_MAJOR=$(shell echo $(FORGEJO_VERSION) | sed -e 's/\..*//') diff --git a/cmd/serv.go b/cmd/serv.go index db67e36..5780403 100644 --- a/cmd/serv.go +++ b/cmd/serv.go @@ -38,6 +38,7 @@ import ( const ( lfsAuthenticateVerb = "git-lfs-authenticate" + gitAnnexShellVerb = "git-annex-shell" ) // CmdServ represents the available serv sub-command. @@ -79,6 +80,7 @@ var ( "git-upload-archive": perm.AccessModeRead, "git-receive-pack": perm.AccessModeWrite, lfsAuthenticateVerb: perm.AccessModeNone, + gitAnnexShellVerb: perm.AccessModeNone, // annex permissions are enforced by GIT_ANNEX_SHELL_READONLY, rather than the Gitea API } alphaDashDotPattern = regexp.MustCompile(`[^\w-\.]`) ) @@ -212,6 +214,28 @@ func runServ(c *cli.Context) error { } } + if verb == gitAnnexShellVerb { + if !setting.Annex.Enabled { + return fail(ctx, "Unknown git command", "git-annex request over SSH denied, git-annex support is disabled") + } + + if len(words) < 3 { + return fail(ctx, "Too few arguments", "Too few arguments in cmd: %s", cmd) + } + + // git-annex always puts the repo in words[2], unlike most other + // git subcommands; and it sometimes names repos like /~/, as if + // $HOME should get expanded while also being rooted. e.g.: + // git-annex-shell 'configlist' '/~/user/repo' + // git-annex-shell 'sendkey' '/user/repo 'key' + repoPath = words[2] + repoPath = strings.TrimPrefix(repoPath, "/") + repoPath = strings.TrimPrefix(repoPath, "~/") + } + + // prevent directory traversal attacks + repoPath = filepath.Clean("/" + repoPath)[1:] + rr := strings.SplitN(repoPath, "/", 2) if len(rr) != 2 { return fail(ctx, "Invalid repository path", "Invalid repository path: %v", repoPath) @@ -225,6 +249,18 @@ func runServ(c *cli.Context) error { // so that username and reponame are not affected. repoPath = strings.ToLower(strings.TrimSpace(repoPath)) + // put the sanitized repoPath back into the argument list for later + if verb == gitAnnexShellVerb { + // git-annex-shell demands an absolute path + absRepoPath, err := filepath.Abs(filepath.Join(setting.RepoRootPath, repoPath)) + if err != nil { + return fail(ctx, "Error locating repoPath", "%v", err) + } + words[2] = absRepoPath + } else { + words[1] = repoPath + } + if alphaDashDotPattern.MatchString(reponame) { return fail(ctx, "Invalid repo name", "Invalid repo name: %s", reponame) } @@ -303,21 +339,45 @@ func runServ(c *cli.Context) error { return nil } - var gitcmd *exec.Cmd - gitBinPath := filepath.Dir(git.GitExecutable) // e.g. /usr/bin - gitBinVerb := filepath.Join(gitBinPath, verb) // e.g. /usr/bin/git-upload-pack - if _, err := os.Stat(gitBinVerb); err != nil { + gitBinVerb, err := exec.LookPath(verb) + if err != nil { // if the command "git-upload-pack" doesn't exist, try to split "git-upload-pack" to use the sub-command with git // ps: Windows only has "git.exe" in the bin path, so Windows always uses this way + // ps: git-annex-shell and other extensions may not necessarily be in gitBinPath, + // but '{gitBinPath}/git annex-shell' should be able to find them on $PATH. verbFields := strings.SplitN(verb, "-", 2) if len(verbFields) == 2 { // use git binary with the sub-command part: "C:\...\bin\git.exe", "upload-pack", ... - gitcmd = exec.CommandContext(ctx, git.GitExecutable, verbFields[1], repoPath) + gitBinVerb = git.GitExecutable + words = append([]string{verbFields[1]}, words...) } } - if gitcmd == nil { - // by default, use the verb (it has been checked above by allowedCommands) - gitcmd = exec.CommandContext(ctx, gitBinVerb, repoPath) + + // by default, use the verb (it has been checked above by allowedCommands) + gitcmd := exec.CommandContext(ctx, gitBinVerb, words[1:]...) + + if verb == gitAnnexShellVerb { + // This doesn't get its own isolated section like LFS does, because LFS + // is handled by internal Gitea routines, but git-annex has to be shelled out + // to like other git subcommands, so we need to build up gitcmd. + + // TODO: does this work on Windows? + gitcmd.Env = append(gitcmd.Env, + // "If set, disallows running git-shell to handle unknown commands." + // - git-annex-shell(1) + "GIT_ANNEX_SHELL_LIMITED=True", + // "If set, git-annex-shell will refuse to run commands + // that do not operate on the specified directory." + // - git-annex-shell(1) + fmt.Sprintf("GIT_ANNEX_SHELL_DIRECTORY=%s", words[2]), + ) + if results.UserMode < perm.AccessModeWrite { + // "If set, disallows any action that could modify the git-annex repository." + // - git-annex-shell(1) + // We set this when the backend API has told us that we don't have write permission to this repo. + log.Debug("Setting GIT_ANNEX_SHELL_READONLY=True") + gitcmd.Env = append(gitcmd.Env, "GIT_ANNEX_SHELL_READONLY=True") + } } process.SetSysProcAttribute(gitcmd) diff --git a/cmd/web.go b/cmd/web.go index 44babd5..661e6d1 100644 --- a/cmd/web.go +++ b/cmd/web.go @@ -9,6 +9,7 @@ import ( "net" "net/http" "os" + "os/exec" "path/filepath" "strconv" "strings" @@ -247,6 +248,12 @@ func runWeb(ctx *cli.Context) error { createPIDFile(ctx.String("pid")) } + if setting.Annex.Enabled { + if _, err := exec.LookPath("git-annex"); err != nil { + log.Fatal("You have enabled git-annex support but git-annex is not installed. Please make sure that Forgejo's PATH contains the git-annex executable.") + } + } + if !setting.InstallLock { if err := serveInstall(ctx); err != nil { return err @@ -311,6 +318,10 @@ func listen(m http.Handler, handleRedirector bool) error { log.Info("LFS server enabled") } + if setting.Annex.Enabled { + log.Info("git-annex enabled") + } + var err error switch setting.Protocol { case setting.HTTP: diff --git a/modules/annex/annex.go b/modules/annex/annex.go new file mode 100644 index 0000000..dee24d2 --- /dev/null +++ b/modules/annex/annex.go @@ -0,0 +1,192 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +// Unlike modules/lfs, which operates mainly on git.Blobs, this operates on git.TreeEntrys. +// The motivation for this is that TreeEntrys have an easy pointer to the on-disk repo path, +// while blobs do not (in fact, if building with TAGS=gogit, blobs might exist only in a mock +// filesystem, living only in process RAM). We must have the on-disk path to do anything +// useful with git-annex because all of its interesting data is on-disk under .git/annex/. + +package annex + +import ( + "errors" + "fmt" + "io/fs" + "os" + "path" + "path/filepath" + "regexp" + "strings" + + "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/typesniffer" +) + +// ErrBlobIsNotAnnexed occurs if a blob does not contain a valid annex key +var ErrBlobIsNotAnnexed = errors.New("not a git-annex pointer") + +func LookupKey(blob *git.Blob) (string, error) { + stdout, _, err := git.NewCommand(git.DefaultContext, "annex", "lookupkey", "--ref").AddDynamicArguments(blob.ID.String()).RunStdString(&git.RunOpts{Dir: blob.Repo().Path}) + if err != nil { + return "", ErrBlobIsNotAnnexed + } + key := strings.TrimSpace(stdout) + return key, nil +} + +func ContentLocationFromKey(repoPath, key string) (string, error) { + contentLocation, _, err := git.NewCommandContextNoGlobals(git.DefaultContext, "annex", "contentlocation").AddDynamicArguments(key).RunStdString(&git.RunOpts{Dir: repoPath}) + if err != nil { + return "", fmt.Errorf("in %s: %s does not seem to be a valid annexed file: %w", repoPath, key, err) + } + contentLocation = strings.TrimSpace(contentLocation) + contentLocation = path.Clean("/" + contentLocation)[1:] // prevent directory traversals + contentLocation = path.Join(repoPath, contentLocation) + + return contentLocation, nil +} + +// return the absolute path of the content pointed to by the annex pointer stored in the git object +// errors if the content is not found in this repo +func ContentLocation(blob *git.Blob) (string, error) { + key, err := LookupKey(blob) + if err != nil { + return "", err + } + return ContentLocationFromKey(blob.Repo().Path, key) +} + +// returns a stream open to the annex content +func Content(blob *git.Blob) (*os.File, error) { + contentLocation, err := ContentLocation(blob) + if err != nil { + return nil, err + } + + return os.Open(contentLocation) +} + +// whether the object appears to be a valid annex pointer +// does *not* verify if the content is actually in this repo; +// for that, use ContentLocation() +func IsAnnexed(blob *git.Blob) (bool, error) { + if !setting.Annex.Enabled { + return false, nil + } + + // LookupKey is written to only return well-formed keys + // so the test is just to see if it errors + _, err := LookupKey(blob) + if err != nil { + if errors.Is(err, ErrBlobIsNotAnnexed) { + return false, nil + } + return false, err + } + return true, nil +} + +// IsAnnexRepo determines if repo is a git-annex enabled repository +func IsAnnexRepo(repo *git.Repository) bool { + _, _, err := git.NewCommand(repo.Ctx, "config", "annex.uuid").RunStdString(&git.RunOpts{Dir: repo.Path}) + return err == nil +} + +var repoConfigFileRe = regexp.MustCompile("[^/]+/[^/]+.git/config$") + +var ( + uuid2repoPathCache = make(map[string]string) + repoPath2uuidCache = make(map[string]string) +) + +func Init() error { + if !setting.Annex.Enabled { + return nil + } + log.Info("Populating the git-annex UUID cache with existing repositories") + return updateUUID2RepoPathCache() +} + +func updateUUID2RepoPathCache() error { + return filepath.WalkDir(setting.RepoRootPath, func(path string, d fs.DirEntry, err error) error { + if err == nil && repoConfigFileRe.MatchString(path) { + thisRepoPath := strings.TrimSuffix(path, "/config") + _, ok := repoPath2uuidCache[thisRepoPath] + if ok { + return nil + } + stdout, _, err := git.NewCommand(git.DefaultContext, "config", "annex.uuid").RunStdString(&git.RunOpts{Dir: thisRepoPath}) + if err != nil { + return nil + } + repoUUID := strings.TrimSpace(stdout) + if repoUUID != "" { + uuid2repoPathCache[repoUUID] = thisRepoPath + repoPath2uuidCache[thisRepoPath] = repoUUID + } + } + return nil + }) +} + +func repoPathFromUUIDCache(uuid string) (string, error) { + if repoPath, ok := uuid2repoPathCache[uuid]; ok { + return repoPath, nil + } + // If the cache didn't contain an entry for the UUID then update the cache and try again + if err := updateUUID2RepoPathCache(); err != nil { + return "", err + } + if repoPath, ok := uuid2repoPathCache[uuid]; ok { + return repoPath, nil + } + return "", fmt.Errorf("no repository known for UUID '%s'", uuid) +} + +func checkValidity(uuid, repoPath string) (bool, error) { + stdout, _, err := git.NewCommand(git.DefaultContext, "config", "annex.uuid").RunStdString(&git.RunOpts{Dir: repoPath}) + if err != nil { + return false, err + } + repoUUID := strings.TrimSpace(stdout) + return uuid == repoUUID, nil +} + +func removeCachedEntries(uuid, repoPath string) { + delete(uuid2repoPathCache, uuid) + delete(repoPath2uuidCache, repoPath) +} + +func UUID2RepoPath(uuid string) (string, error) { + // Get the current cache entry for the UUID + repoPath, err := repoPathFromUUIDCache(uuid) + if err != nil { + return "", err + } + // Check if it is still up-to-date + valid, err := checkValidity(uuid, repoPath) + if err != nil { + return "", err + } + if !valid { + // If it isn't, remove the cache entry and try again + removeCachedEntries(uuid, repoPath) + return UUID2RepoPath(uuid) + } + // Otherwise just return the cached entry + return repoPath, nil +} + +// GuessContentType guesses the content type of the annexed blob. +func GuessContentType(blob *git.Blob) (typesniffer.SniffedType, error) { + r, err := Content(blob) + if err != nil { + return typesniffer.SniffedType{}, err + } + defer r.Close() + + return typesniffer.DetectContentTypeFromReader(r) +} diff --git a/modules/base/tool.go b/modules/base/tool.go index 02f1db5..a885546 100644 --- a/modules/base/tool.go +++ b/modules/base/tool.go @@ -16,6 +16,7 @@ import ( "strings" "unicode/utf8" + "code.gitea.io/gitea/modules/annex" "code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/log" @@ -101,6 +102,12 @@ func Int64sToStrings(ints []int64) []string { // EntryIcon returns the octicon class for displaying files/directories func EntryIcon(entry *git.TreeEntry) string { + isAnnexed, _ := annex.IsAnnexed(entry.Blob()) + if isAnnexed { + // Show git-annex files as binary files to differentiate them from non-annexed files + // TODO: find a more suitable icon, maybe something related to git-annex + return "file-binary" + } switch { case entry.IsLink(): te, _, err := entry.FollowLink() diff --git a/modules/git/blob.go b/modules/git/blob.go index 2f02693..bbfab7d 100644 --- a/modules/git/blob.go +++ b/modules/git/blob.go @@ -126,6 +126,10 @@ func (b *blobReader) Close() error { return nil } +func (b *Blob) Repo() *Repository { + return b.repo +} + // Name returns name of the tree entry this blob object was created from (or empty string) func (b *Blob) Name() string { return b.name diff --git a/modules/git/command.go b/modules/git/command.go index a3d43aa..d3e6b7b 100644 --- a/modules/git/command.go +++ b/modules/git/command.go @@ -457,12 +457,13 @@ func (c *Command) RunStdBytes(opts *RunOpts) (stdout, stderr []byte, runErr RunS } // AllowLFSFiltersArgs return globalCommandArgs with lfs filter, it should only be used for tests +// It also re-enables git-credential(1), which is used to test git-annex's HTTP support func AllowLFSFiltersArgs() TrustedCmdArgs { // Now here we should explicitly allow lfs filters to run filteredLFSGlobalArgs := make(TrustedCmdArgs, len(globalCommandArgs)) j := 0 for _, arg := range globalCommandArgs { - if strings.Contains(string(arg), "lfs") { + if strings.Contains(string(arg), "lfs") || strings.Contains(string(arg), "credential") { j-- } else { filteredLFSGlobalArgs[j] = arg diff --git a/modules/markup/external/external.go b/modules/markup/external/external.go index 122517e..b976077 100644 --- a/modules/markup/external/external.go +++ b/modules/markup/external/external.go @@ -12,6 +12,7 @@ import ( "runtime" "strings" + "code.gitea.io/gitea/modules/annex" "code.gitea.io/gitea/modules/graceful" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/markup" @@ -86,8 +87,22 @@ func (p *Renderer) Render(ctx *markup.RenderContext, input io.Reader, output io. commands = strings.Fields(command) args = commands[1:] ) - - if p.IsInputFile { + isAnnexed, _ := annex.IsAnnexed(ctx.Blob) + // if a renderer wants to read a file, and we have annexed content, we can + // provide the annex key file location directly to the renderer. git-annex + // takes care of having that location be read-only, so no critical + // protection layer is needed. Moreover, the file readily exists, and + // expensive temporary files can be avoided, also allowing an operator + // to raise MAX_DISPLAY_FILE_SIZE without much negative impact. + if p.IsInputFile && isAnnexed { + // look for annexed content, will be empty, if there is none + annexContentLocation, _ := annex.ContentLocation(ctx.Blob) + // we call the renderer, even if there is no annex content present. + // showing the pointer file content is not much use, and a topical + // renderer might be able to produce something useful from the + // filename alone (present in ENV) + args = append(args, annexContentLocation) + } else if p.IsInputFile { // write to temp file f, err := os.CreateTemp("", "gitea_input") if err != nil { @@ -130,6 +145,12 @@ func (p *Renderer) Render(ctx *markup.RenderContext, input io.Reader, output io. os.Environ(), "GITEA_PREFIX_SRC="+ctx.Links.SrcLink(), "GITEA_PREFIX_RAW="+ctx.Links.RawLink(), + // also communicate the relative path of the to-be-rendered item. + // this enables the renderer to make use of the original file name + // and path, e.g., to make rendering or dtype-detection decisions + // that go beyond the originally matched extension. Even if the + // content is directly streamed to STDIN + "GITEA_RELATIVE_PATH="+ctx.RelativePath, ) if !p.IsInputFile { cmd.Stdin = input diff --git a/modules/markup/renderer.go b/modules/markup/renderer.go index 2137302..c00bd2b 100644 --- a/modules/markup/renderer.go +++ b/modules/markup/renderer.go @@ -67,14 +67,18 @@ type Header struct { // RenderContext represents a render context type RenderContext struct { - Ctx context.Context - RelativePath string // relative path from tree root of the branch - Type string - IsWiki bool - Links Links - Metas map[string]string - DefaultLink string - GitRepo *git.Repository + Ctx context.Context + RelativePath string // relative path from tree root of the branch + Type string + IsWiki bool + Links Links + Metas map[string]string + DefaultLink string + GitRepo *git.Repository + // reporting the target blob that is to-be-rendered enables + // deeper inspection in the handler for external renderer + // (i.e., more targeted handling of annexed files) + Blob *git.Blob ShaExistCache map[string]bool cancelFn func() SidebarTocNode ast.Node diff --git a/modules/private/serv.go b/modules/private/serv.go index 480a446..6c7c753 100644 --- a/modules/private/serv.go +++ b/modules/private/serv.go @@ -40,6 +40,7 @@ type ServCommandResults struct { UserName string UserEmail string UserID int64 + UserMode perm.AccessMode OwnerName string RepoName string RepoID int64 diff --git a/modules/setting/annex.go b/modules/setting/annex.go new file mode 100644 index 0000000..35e9e55 --- /dev/null +++ b/modules/setting/annex.go @@ -0,0 +1,25 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package setting + +import ( + "code.gitea.io/gitea/modules/log" +) + +// Annex represents the configuration for git-annex +var Annex = struct { + Enabled bool `ini:"ENABLED"` + DisableP2PHTTP bool `ini:"DISABLE_P2PHTTP"` +}{} + +func loadAnnexFrom(rootCfg ConfigProvider) { + sec := rootCfg.Section("annex") + if err := sec.MapTo(&Annex); err != nil { + log.Fatal("Failed to map Annex settings: %v", err) + } + if !sec.HasKey("DISABLE_P2PHTTP") { + // If DisableP2PHTTP is not explicitly set then use DisableHTTPGit as its default + Annex.DisableP2PHTTP = Repository.DisableHTTPGit + } +} diff --git a/modules/setting/setting.go b/modules/setting/setting.go index c9d3083..9710fb2 100644 --- a/modules/setting/setting.go +++ b/modules/setting/setting.go @@ -153,6 +153,7 @@ func loadCommonSettingsFrom(cfg ConfigProvider) error { loadCamoFrom(cfg) loadI18nFrom(cfg) loadGitFrom(cfg) + loadAnnexFrom(cfg) loadMirrorFrom(cfg) loadMarkupFrom(cfg) loadQuotaFrom(cfg) diff --git a/modules/util/remove.go b/modules/util/remove.go index d1e38fa..39556e5 100644 --- a/modules/util/remove.go +++ b/modules/util/remove.go @@ -4,7 +4,9 @@ package util import ( + "io/fs" "os" + "path/filepath" "runtime" "syscall" "time" @@ -41,10 +43,48 @@ func Remove(name string) error { return err } -// RemoveAll removes the named file or (empty) directory with at most 5 attempts. +// MakeWritable recursively makes the named directory writable. +func MakeWritable(name string) error { + return filepath.WalkDir(name, func(path string, d fs.DirEntry, err error) error { + // NB: this is called WalkDir but it works on a single file too + if err == nil { + info, err := d.Info() + if err != nil { + return err + } + + // Don't try chmod'ing symlinks (will fail with broken symlinks) + if info.Mode()&os.ModeSymlink != os.ModeSymlink { + // 0200 == u+w, in octal unix permission notation + err = os.Chmod(path, info.Mode()|0o200) + if err != nil { + return err + } + } + } + return nil + }) +} + +// RemoveAll removes the named file or directory with at most 5 attempts. func RemoveAll(name string) error { var err error + for i := 0; i < 5; i++ { + // Do chmod -R +w to help ensure the removal succeeds. + // In particular, in the git-annex case, this handles + // https://git-annex.branchable.com/internals/lockdown/ : + // + // > (The only bad consequence of this is that rm -rf .git + // > doesn't work unless you first run chmod -R +w .git) + + err = MakeWritable(name) + if err != nil { + // try again + <-time.After(100 * time.Millisecond) + continue + } + err = os.RemoveAll(name) if err == nil { break diff --git a/options/locale/locale_cs-CZ.ini b/options/locale/locale_cs-CZ.ini index 7d8fdee..e050e0d 100644 --- a/options/locale/locale_cs-CZ.ini +++ b/options/locale/locale_cs-CZ.ini @@ -1315,6 +1315,7 @@ view_git_blame=Zobrazit git blame video_not_supported_in_browser=Váš prohlížeč nepodporuje značku HTML5 „video“. audio_not_supported_in_browser=Váš prohlížeč nepodporuje značku HTML5 „audio“. stored_lfs=Uloženo pomocí Git LFS +stored_annex=Uloženo pomocí Git Annex symbolic_link=Symbolický odkaz executable_file=Spustitelný soubor vendored = Vendorováno @@ -1340,6 +1341,7 @@ editor.upload_file=Nahrát soubor editor.edit_file=Upravit soubor editor.preview_changes=Náhled změn editor.cannot_edit_lfs_files=LFS soubory nemohou být upravovány přes webové rozhraní. +editor.cannot_edit_annex_files=Annex soubory nemohou být upravovány přes webové rozhraní. editor.cannot_edit_non_text_files=Binární soubory nemohou být upravovány přes webové rozhraní. editor.edit_this_file=Upravit soubor editor.this_file_locked=Soubor je uzamčen diff --git a/options/locale/locale_de-DE.ini b/options/locale/locale_de-DE.ini index 6a479b7..22dd5f3 100644 --- a/options/locale/locale_de-DE.ini +++ b/options/locale/locale_de-DE.ini @@ -1315,6 +1315,8 @@ view_git_blame=„git blame“ ansehen video_not_supported_in_browser=Dein Browser unterstützt das HTML5-„video“-Tag nicht. audio_not_supported_in_browser=Dein Browser unterstützt das HTML5-„audio“-Tag nicht. stored_lfs=Gespeichert mit Git LFS +stored_annex=Gespeichert mit Git Annex +stored_annex_not_present = hier nicht vorhanden, versuche git annex whereis symbolic_link=Softlink executable_file=Ausführbare Datei commit_graph=Commit-Graph @@ -1338,6 +1340,7 @@ editor.upload_file=Datei hochladen editor.edit_file=Datei bearbeiten editor.preview_changes=Vorschau der Änderungen editor.cannot_edit_lfs_files=LFS-Dateien können im Webinterface nicht bearbeitet werden. +editor.cannot_edit_annex_files=Annex-Dateien können im Webinterface nicht bearbeitet werden. editor.cannot_edit_non_text_files=Binärdateien können nicht im Webinterface bearbeitet werden. editor.edit_this_file=Datei bearbeiten editor.this_file_locked=Datei ist gesperrt diff --git a/options/locale/locale_el-GR.ini b/options/locale/locale_el-GR.ini index cb99563..3cbf24c 100644 --- a/options/locale/locale_el-GR.ini +++ b/options/locale/locale_el-GR.ini @@ -1314,6 +1314,7 @@ view_git_blame=Προβολή git blame video_not_supported_in_browser=Το πρόγραμμα περιήγησής σας δεν υποστηρίζει την ετικέτα HTML5 «video». audio_not_supported_in_browser=Το πρόγραμμα περιήγησής σας δεν υποστηρίζει την ετικέτα HTML5 «audio». stored_lfs=Αποθηκεύτηκε με το Git LFS +stored_annex=Αποθηκεύτηκε με το Git Annex symbolic_link=Symbolic link executable_file=Εκτελέσιμο αρχείο commit_graph=Γράφημα υποβολών @@ -1337,6 +1338,7 @@ editor.upload_file=Ανέβασμα αρχείου editor.edit_file=Επεξεργασία αρχείου editor.preview_changes=Προεπισκόπηση αλλαγών editor.cannot_edit_lfs_files=Τα αρχεία LFS δεν μπορούν να επεξεργαστούν στη διεπαφή web. +editor.cannot_edit_annex_files=Τα αρχεία Annex δεν μπορούν να επεξεργαστούν στη διεπαφή web. editor.cannot_edit_non_text_files=Τα δυαδικά αρχεία δεν μπορούν να επεξεργαστούν στη διεπαφή web. editor.edit_this_file=Επεξεργασία αρχείου editor.this_file_locked=Το αρχείο είναι κλειδωμένο diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini index d2f47ad..45188fe 100644 --- a/options/locale/locale_en-US.ini +++ b/options/locale/locale_en-US.ini @@ -1336,6 +1336,8 @@ view_git_blame = View git blame video_not_supported_in_browser = Your browser does not support the HTML5 "video" tag. audio_not_supported_in_browser = Your browser does not support the HTML5 "audio" tag. stored_lfs = Stored with Git LFS +stored_annex = Stored with Git Annex +stored_annex_not_present = not present here, try using git annex whereis symbolic_link = Symbolic link executable_file = Executable file vendored = Vendored @@ -1363,6 +1365,7 @@ editor.upload_file = Upload file editor.edit_file = Edit file editor.preview_changes = Preview changes editor.cannot_edit_lfs_files = LFS files cannot be edited in the web interface. +editor.cannot_edit_annex_files = Annex files cannot be edited in the web interface. editor.cannot_edit_non_text_files = Binary files cannot be edited in the web interface. editor.edit_this_file = Edit file editor.this_file_locked = File is locked diff --git a/options/locale/locale_es-ES.ini b/options/locale/locale_es-ES.ini index 4f9d141..b55ff31 100644 --- a/options/locale/locale_es-ES.ini +++ b/options/locale/locale_es-ES.ini @@ -1310,6 +1310,7 @@ view_git_blame=Ver Git blame video_not_supported_in_browser=Su navegador no soporta el tag "video" de HTML5. audio_not_supported_in_browser=Su navegador no soporta el tag "audio" de HTML5. stored_lfs=Almacenados con Git LFS +stored_annex=Almacenados con Git Annex symbolic_link=Enlace simbólico executable_file=Archivo ejecutable commit_graph=Gráfico de commits @@ -1333,6 +1334,7 @@ editor.upload_file=Subir archivo editor.edit_file=Editar archivo editor.preview_changes=Vista previa de los cambios editor.cannot_edit_lfs_files=Los archivos LFS no se pueden editar en la interfaz web. +editor.cannot_edit_annex_files=Los archivos Annex no se pueden editar en la interfaz web. editor.cannot_edit_non_text_files=Los archivos binarios no se pueden editar en la interfaz web. editor.edit_this_file=Editar archivo editor.this_file_locked=El archivo está bloqueado diff --git a/options/locale/locale_fa-IR.ini b/options/locale/locale_fa-IR.ini index 8960be8..3d5be27 100644 --- a/options/locale/locale_fa-IR.ini +++ b/options/locale/locale_fa-IR.ini @@ -951,6 +951,7 @@ file_copy_permalink=پرمالینک را کپی کنید video_not_supported_in_browser=مرورگر شما از تگ video که در HTML5 تعریف شده است، پشتیبانی نمی کند. audio_not_supported_in_browser=مرورگر شما از تگ audio که در HTML5 تعریف شده است، پشتیبانی نمی کند. stored_lfs=ذخیره شده با GIT LFS +stored_annex=ذخیره شده با GIT Annex symbolic_link=پیوند نمادین commit_graph=نمودار کامیت commit_graph.select=انتخاب برنچها @@ -968,6 +969,7 @@ editor.upload_file=بارگذاری پرونده editor.edit_file=ویرایش پرونده editor.preview_changes=پیش نمایش تغییرات editor.cannot_edit_lfs_files=پرونده های LFS در صحفه وب قابل تغییر نیست. +editor.cannot_edit_annex_files=پرونده های Annex در صحفه وب قابل تغییر نیست. editor.cannot_edit_non_text_files=پرونده‎های دودویی در صفحه وب قابل تغییر نیست. editor.edit_this_file=ویرایش پرونده editor.this_file_locked=پرونده قفل شده است diff --git a/options/locale/locale_fr-FR.ini b/options/locale/locale_fr-FR.ini index 2585eb1..06d5945 100644 --- a/options/locale/locale_fr-FR.ini +++ b/options/locale/locale_fr-FR.ini @@ -1316,6 +1316,7 @@ view_git_blame=Voir Git blame video_not_supported_in_browser=Votre navigateur ne supporte pas la balise « vidéo » HTML5. audio_not_supported_in_browser=Votre navigateur ne supporte pas la balise « audio » HTML5. stored_lfs=Stocké avec Git LFS +stored_annex=Stocké avec Git Annex symbolic_link=Lien symbolique executable_file=Fichier exécutable vendored = Vendored @@ -1341,6 +1342,7 @@ editor.upload_file=Téléverser un fichier editor.edit_file=Modifier le fichier editor.preview_changes=Aperçu des modifications editor.cannot_edit_lfs_files=Les fichiers LFS ne peuvent pas être modifiés dans l'interface web. +editor.cannot_edit_annex_files=Les fichiers Annex ne peuvent pas être modifiés dans l'interface web. editor.cannot_edit_non_text_files=Les fichiers binaires ne peuvent pas être édités dans l'interface web. editor.edit_this_file=Modifier le fichier editor.this_file_locked=Le fichier est verrouillé diff --git a/options/locale/locale_hu-HU.ini b/options/locale/locale_hu-HU.ini index 57555b9..ef94d2d 100644 --- a/options/locale/locale_hu-HU.ini +++ b/options/locale/locale_hu-HU.ini @@ -787,6 +787,7 @@ file_too_large=Ez a fájl túl nagy ahhoz, hogy megjelenítsük. video_not_supported_in_browser=A böngésző nem támogatja a HTML5 video tag-et. audio_not_supported_in_browser=A böngésző nem támogatja a HTML5 audio tag-et. stored_lfs=Git LFS-el eltárolva +stored_annex=Git Annex-el eltárolva symbolic_link=Szimbolikus hivatkozás commit_graph=Commit gráf commit_graph.hide_pr_refs=Pull request-ek elrejtése @@ -799,6 +800,7 @@ editor.upload_file=Fájl feltöltése editor.edit_file=Fájl szerkesztése editor.preview_changes=Változások előnézete editor.cannot_edit_lfs_files=LFS fájlok nem szerkeszthetőek a webes felületen. +editor.cannot_edit_annex_files=Annex fájlok nem szerkeszthetőek a webes felületen. editor.cannot_edit_non_text_files=Bináris fájlok nem szerkeszthetőek a webes felületen. editor.edit_this_file=Fájl szerkesztése editor.this_file_locked=Zárolt állomány diff --git a/options/locale/locale_id-ID.ini b/options/locale/locale_id-ID.ini index 1e0044e..8cf3457 100644 --- a/options/locale/locale_id-ID.ini +++ b/options/locale/locale_id-ID.ini @@ -596,6 +596,7 @@ file_permalink=Permalink file_too_large=Berkas terlalu besar untuk ditampilkan. stored_lfs=Tersimpan dengan GIT LFS +stored_annex=Tersimpan dengan GIT Annex commit_graph=Grafik Komit blame=Salahkan normal_view=Pandangan Normal @@ -607,6 +608,7 @@ editor.upload_file=Unggah Berkas editor.edit_file=Sunting Berkas editor.preview_changes=Tinjau Perubahan editor.cannot_edit_lfs_files=Berkas LFS tidak dapat disunting dalam antarmuka web. +editor.cannot_edit_annex_files=Berkas Annex tidak dapat disunting dalam antarmuka web. editor.cannot_edit_non_text_files=Berkas biner tidak dapat disunting dalam antarmuka web. editor.edit_this_file=Sunting Berkas editor.this_file_locked=Berkas terkunci diff --git a/options/locale/locale_is-IS.ini b/options/locale/locale_is-IS.ini index 3a6e844..b3cf17f 100644 --- a/options/locale/locale_is-IS.ini +++ b/options/locale/locale_is-IS.ini @@ -680,6 +680,7 @@ file_view_rendered=Skoða Unnið file_copy_permalink=Afrita Varanlega Slóð stored_lfs=Geymt með Git LFS +stored_annex=Geymt með Git Annex commit_graph.hide_pr_refs=Fela Sameiningarbeiðnir commit_graph.monochrome=Einlitað commit_graph.color=Litað diff --git a/options/locale/locale_it-IT.ini b/options/locale/locale_it-IT.ini index 2f5bf6f..1e4aeab 100644 --- a/options/locale/locale_it-IT.ini +++ b/options/locale/locale_it-IT.ini @@ -1267,6 +1267,7 @@ view_git_blame=Visualizza git incolpa video_not_supported_in_browser=Il tuo browser non supporta le etichette "video" di HTML5. audio_not_supported_in_browser=Il tuo browser non supporta le etichette "audio" di HTML5. stored_lfs=Memorizzati con Git LFS +stored_annex=Memorizzati con Git Annex symbolic_link=Link Simbolico commit_graph=Grafico dei commit commit_graph.select=Seleziona rami @@ -1285,6 +1286,7 @@ editor.upload_file=Carica file editor.edit_file=Modifica file editor.preview_changes=Anteprima modifiche editor.cannot_edit_lfs_files=I file LFS non possono essere modificati nell'interfaccia web. +editor.cannot_edit_annex_files=I file Annex non possono essere modificati nell'interfaccia web. editor.cannot_edit_non_text_files=I file binari non possono essere modificati tramite interfaccia web. editor.edit_this_file=Modifica file editor.this_file_locked=Il file è bloccato diff --git a/options/locale/locale_ja-JP.ini b/options/locale/locale_ja-JP.ini index b0fc38d..4ea4331 100644 --- a/options/locale/locale_ja-JP.ini +++ b/options/locale/locale_ja-JP.ini @@ -1305,6 +1305,7 @@ view_git_blame=Git Blameを表示 video_not_supported_in_browser=このブラウザはHTML5のvideoタグをサポートしていません。 audio_not_supported_in_browser=このブラウザーはHTML5のaudioタグをサポートしていません。 stored_lfs=Git LFSで保管されています +stored_annex=Git Annexで保管されています symbolic_link=シンボリック リンク executable_file=実行ファイル commit_graph=コミットグラフ @@ -1328,6 +1329,7 @@ editor.upload_file=ファイルをアップロード editor.edit_file=ファイルを編集 editor.preview_changes=変更をプレビュー editor.cannot_edit_lfs_files=LFSのファイルはWebインターフェースで編集できません。 +editor.cannot_edit_annex_files=AnnexのファイルはWebインターフェースで編集できません。 editor.cannot_edit_non_text_files=バイナリファイルはWebインターフェースで編集できません。 editor.edit_this_file=ファイルを編集 editor.this_file_locked=ファイルはロックされています diff --git a/options/locale/locale_ko-KR.ini b/options/locale/locale_ko-KR.ini index a2a50d7..72b4ed3 100644 --- a/options/locale/locale_ko-KR.ini +++ b/options/locale/locale_ko-KR.ini @@ -821,6 +821,7 @@ file_too_large=보여주기에는 파일이 너무 큽니다. video_not_supported_in_browser=당신의 브라우저가 HTML5의 "video" 태그를 지원하지 않습니다. audio_not_supported_in_browser=당신의 브라우저가 HTML5의 "audio" 태그를 지원하지 않습니다. stored_lfs=Git LFS에 저장되어 있습니다 +stored_annex=Git Annex에 저장되어 있습니다 commit_graph=커밋 그래프 editor.new_file=새 파일 diff --git a/options/locale/locale_lv-LV.ini b/options/locale/locale_lv-LV.ini index c179158..9610867 100644 --- a/options/locale/locale_lv-LV.ini +++ b/options/locale/locale_lv-LV.ini @@ -1314,6 +1314,7 @@ view_git_blame=Apskatīt Git izmaiņu veicējus video_not_supported_in_browser=Pārlūks neatbalsta HTML5 tagu "video". audio_not_supported_in_browser=Pārlūks neatbalsta HTML5 tagu "audio". stored_lfs=Saglabāts Git LFS +stored_annex=Saglabāts Git Annex symbolic_link=Simboliska saite executable_file=Izpildāma datne commit_graph=Iesūtījumu karte @@ -1337,6 +1338,7 @@ editor.upload_file=Augšupielādēt datni editor.edit_file=Labot datni editor.preview_changes=Priekšskatīt izmaiņas editor.cannot_edit_lfs_files=LFS datnes tīmekļa saskarnē nevar labot. +editor.cannot_edit_annex_files=Annex datnes tīmekļa saskarnē nevar labot. editor.cannot_edit_non_text_files=Binārās datnes tīmekļa saskarnē nevar labot. editor.edit_this_file=Labot datni editor.this_file_locked=Datne ir slēgta @@ -4023,4 +4025,4 @@ filepreview.lines = %[1]d. līdz %[2]d. rinda %[3]s filepreview.truncated = Priekšskatījums tika saīsināts [translation_meta] -test = Šī ir pārbaudes virkne. Tā netiek attēlota Forgejo saskarnē, bet tiek izmantota pārbaudes nolūkiem. Droši var ievadīt "ok", lai ietaupītu laiku (vai kādu jautru faktu pēc izvēles), lai sasniegtu to saldo 100% pabeigšanas atzīmi. \ No newline at end of file +test = Šī ir pārbaudes virkne. Tā netiek attēlota Forgejo saskarnē, bet tiek izmantota pārbaudes nolūkiem. Droši var ievadīt "ok", lai ietaupītu laiku (vai kādu jautru faktu pēc izvēles), lai sasniegtu to saldo 100% pabeigšanas atzīmi. diff --git a/options/locale/locale_nl-NL.ini b/options/locale/locale_nl-NL.ini index c884160..3f89d43 100644 --- a/options/locale/locale_nl-NL.ini +++ b/options/locale/locale_nl-NL.ini @@ -1283,6 +1283,7 @@ view_git_blame=Bekijk git blame video_not_supported_in_browser=Uw browser ondersteunt de HTML5 "video" element niet. audio_not_supported_in_browser=Uw browser ondersteunt de HTML5 "audio" element niet. stored_lfs=Opgeslagen met Git LFS +stored_annex=Opgeslagen met Git Annex symbolic_link=Symbolische link commit_graph=Commit grafiek commit_graph.select=Selecteer branches @@ -1301,6 +1302,7 @@ editor.upload_file=Upload bestand editor.edit_file=Bewerk bestand editor.preview_changes=Voorbeeld tonen editor.cannot_edit_lfs_files=LFS-bestanden kunnen niet worden bewerkt in de webinterface. +editor.cannot_edit_annex_files=Annex-bestanden kunnen niet worden bewerkt in de webinterface. editor.cannot_edit_non_text_files=Binaire bestanden kunnen niet worden bewerkt in de webinterface. editor.edit_this_file=Bewerk bestand editor.this_file_locked=Bestand is vergrendeld diff --git a/options/locale/locale_pl-PL.ini b/options/locale/locale_pl-PL.ini index 2af7648..51bdca6 100644 --- a/options/locale/locale_pl-PL.ini +++ b/options/locale/locale_pl-PL.ini @@ -1148,6 +1148,7 @@ file_copy_permalink=Kopiuj bezpośredni odnośnik video_not_supported_in_browser=Twoja przeglądarka nie obsługuje znacznika HTML5 "video". audio_not_supported_in_browser=Twoja przeglądarka nie obsługuje znacznika HTML5 "audio". stored_lfs=Przechowane za pomocą Git LFS +stored_annex=Przechowane za pomocą Git Annex symbolic_link=Dowiązanie symboliczne commit_graph=Wykres commitów commit_graph.select=Wybierz gałęzie @@ -1165,6 +1166,7 @@ editor.upload_file=Wyślij plik editor.edit_file=Edytuj plik editor.preview_changes=Podgląd zmian editor.cannot_edit_lfs_files=Pliki LFS nie mogą być edytowane poprzez interfejs przeglądarkowy. +editor.cannot_edit_annex_files=Pliki Annex nie mogą być edytowane poprzez interfejs przeglądarkowy. editor.cannot_edit_non_text_files=Pliki binarne nie mogą być edytowane poprzez interfejs przeglądarkowy. editor.edit_this_file=Edytuj plik editor.this_file_locked=Plik jest zablokowany diff --git a/options/locale/locale_pt-BR.ini b/options/locale/locale_pt-BR.ini index d826e60..d85e002 100644 --- a/options/locale/locale_pt-BR.ini +++ b/options/locale/locale_pt-BR.ini @@ -1308,6 +1308,7 @@ view_git_blame=Ver git blame video_not_supported_in_browser=Seu navegador não tem suporte para a tag "video" do HTML5. audio_not_supported_in_browser=Seu navegador não tem suporte para a tag "audio" do HTML5. stored_lfs=Armazenado com Git LFS +stored_annex=Armazenado com Git Annex symbolic_link=Link simbólico executable_file=Arquivo executável commit_graph=Gráfico de commits @@ -1331,6 +1332,7 @@ editor.upload_file=Enviar arquivo editor.edit_file=Editar arquivo editor.preview_changes=Pré-visualizar alterações editor.cannot_edit_lfs_files=Arquivos LFS não podem ser editados na interface web. +editor.cannot_edit_annex_files=Arquivos Annex não podem ser editados na interface web. editor.cannot_edit_non_text_files=Arquivos binários não podem ser editados na interface web. editor.edit_this_file=Editar arquivo editor.this_file_locked=Arquivo está bloqueado diff --git a/options/locale/locale_pt-PT.ini b/options/locale/locale_pt-PT.ini index 309fdbe..23158b8 100644 --- a/options/locale/locale_pt-PT.ini +++ b/options/locale/locale_pt-PT.ini @@ -1319,6 +1319,7 @@ view_git_blame=Ver git blame video_not_supported_in_browser=O seu navegador não suporta a etiqueta "video" do HTML5. audio_not_supported_in_browser=O seu navegador não suporta a etiqueta "audio" do HTML5. stored_lfs=Armazenado com Git LFS +stored_annex=Armazenado com Git Annex symbolic_link=Ligação simbólica executable_file=Ficheiro executável vendored=Externo @@ -1344,6 +1345,7 @@ editor.upload_file=Carregar ficheiro editor.edit_file=Editar ficheiro editor.preview_changes=Pré-visualizar modificações editor.cannot_edit_lfs_files=Ficheiros LFS não podem ser editados na interface web. +editor.cannot_edit_annex_files=Ficheiros Annex não podem ser editados na interface web. editor.cannot_edit_non_text_files=Ficheiros binários não podem ser editados na interface da web. editor.edit_this_file=Editar ficheiro editor.this_file_locked=Ficheiro bloqueado diff --git a/options/locale/locale_ru-RU.ini b/options/locale/locale_ru-RU.ini index ec844fa..18c30e4 100644 --- a/options/locale/locale_ru-RU.ini +++ b/options/locale/locale_ru-RU.ini @@ -1302,6 +1302,7 @@ view_git_blame=Показать git blame video_not_supported_in_browser=Ваш браузер не поддерживает тэг HTML5 «video». audio_not_supported_in_browser=Ваш браузер не поддерживает тэг HTML5 «audio». stored_lfs=Хранится Git LFS +stored_annex=Хранится Git Annex symbolic_link=Символическая ссылка executable_file=Исполняемый файл commit_graph=Граф коммитов @@ -1325,6 +1326,7 @@ editor.upload_file=Загрузить файл editor.edit_file=Редактировать файл editor.preview_changes=Просмотр изменений editor.cannot_edit_lfs_files=LFS файлы невозможно редактировать в веб-интерфейсе. +editor.cannot_edit_annex_files=Annex файлы невозможно редактировать в веб-интерфейсе. editor.cannot_edit_non_text_files=Двоичные файлы нельзя редактировать в веб-интерфейсе. editor.edit_this_file=Редактировать файл editor.this_file_locked=Файл заблокирован diff --git a/options/locale/locale_si-LK.ini b/options/locale/locale_si-LK.ini index ac7627c..63c728c 100644 --- a/options/locale/locale_si-LK.ini +++ b/options/locale/locale_si-LK.ini @@ -889,6 +889,7 @@ file_copy_permalink=පිටපත් මාමලින්ක් video_not_supported_in_browser=ඔබගේ බ්රව්සරය HTML5 'වීඩියෝ' ටැගය සඳහා සහය නොදක්වයි. audio_not_supported_in_browser=ඔබගේ බ්රව්සරය HTML5 'ශ්රව්ය' ටැගය සඳහා සහය නොදක්වයි. stored_lfs=Git LFS සමඟ ගබඩා +stored_annex=Git Annex සමඟ ගබඩා symbolic_link=සංකේතාත්මක සබැඳිය commit_graph=ප්රස්තාරය කැප commit_graph.select=ශාඛා තෝරන්න @@ -906,6 +907,7 @@ editor.upload_file=ගොනුව උඩුගත කරන්න editor.edit_file=ගොනුව සංස්කරණය editor.preview_changes=වෙනස්කම් පෙරදසුන editor.cannot_edit_lfs_files=LFS ගොනු වෙබ් අතුරු මුහුණත තුළ සංස්කරණය කළ නොහැක. +editor.cannot_edit_annex_files=Annex ගොනු වෙබ් අතුරු මුහුණත තුළ සංස්කරණය කළ නොහැක. editor.cannot_edit_non_text_files=ද්විමය ගොනු වෙබ් අතුරු මුහුණත තුළ සංස්කරණය කළ නොහැක. editor.edit_this_file=ගොනුව සංස්කරණය editor.this_file_locked=ගොනුවට අගුළු ලා ඇත diff --git a/options/locale/locale_sk-SK.ini b/options/locale/locale_sk-SK.ini index bd2ce20..cfb3d76 100644 --- a/options/locale/locale_sk-SK.ini +++ b/options/locale/locale_sk-SK.ini @@ -1010,6 +1010,7 @@ view_git_blame=Zobraziť Git Blame video_not_supported_in_browser=Váš prehliadač nepodporuje HTML5 tag 'video'. audio_not_supported_in_browser=Váš prehliadač nepodporuje HTML5 tag 'audio'. stored_lfs=Uložené pomocou Git LFS +stored_annex=Uložené pomocou Git Annex symbolic_link=Symbolický odkaz commit_graph=Graf commitov line=riadok diff --git a/options/locale/locale_sv-SE.ini b/options/locale/locale_sv-SE.ini index 928d4a3..7358e5a 100644 --- a/options/locale/locale_sv-SE.ini +++ b/options/locale/locale_sv-SE.ini @@ -891,6 +891,7 @@ file_too_large=Filen är för stor för att visas. video_not_supported_in_browser=Din webbläsare stödjer ej HTML5-taggen "video". audio_not_supported_in_browser=Din webbläsare stödjer ej HTML5-taggen "audio". stored_lfs=Sparad med Git LFS +stored_annex=Sparad med Git Annex symbolic_link=Symbolisk länk commit_graph=Commitgraf commit_graph.monochrome=Mono @@ -904,6 +905,7 @@ editor.upload_file=Ladda upp fil editor.edit_file=Redigera fil editor.preview_changes=Förhandsgranska ändringar editor.cannot_edit_lfs_files=LFS-filer kan inte redigeras i webbgränssnittet. +editor.cannot_edit_annex_files=Annex-filer kan inte redigeras i webbgränssnittet. editor.cannot_edit_non_text_files=Binära filer kan inte redigeras genom webbgränssnittet. editor.edit_this_file=Redigera fil editor.this_file_locked=Filen är låst diff --git a/options/locale/locale_tr-TR.ini b/options/locale/locale_tr-TR.ini index 82476d4..8890f6d 100644 --- a/options/locale/locale_tr-TR.ini +++ b/options/locale/locale_tr-TR.ini @@ -1290,6 +1290,7 @@ view_git_blame=Git Suç Görüntüle video_not_supported_in_browser=Tarayıcınız HTML5 'video' etiketini desteklemiyor. audio_not_supported_in_browser=Tarayıcınız HTML5 'audio' etiketini desteklemiyor. stored_lfs=Git LFS ile depolandı +stored_annex=Git Annex ile depolandı symbolic_link=Sembolik Bağlantı executable_file=Çalıştırılabilir Dosya commit_graph=İşleme Grafiği @@ -1313,6 +1314,7 @@ editor.upload_file=Dosya Yükle editor.edit_file=Dosyayı Düzenle editor.preview_changes=Değişiklikleri Önizle editor.cannot_edit_lfs_files=LFS dosyaları web arayüzünde düzenlenemez. +editor.cannot_edit_annex_files=Annex dosyaları web arayüzünde düzenlenemez. editor.cannot_edit_non_text_files=Bu tür dosyalar web arayüzünden düzenlenemez. editor.edit_this_file=Dosyayı Düzenle editor.this_file_locked=Dosya kilitlendi diff --git a/options/locale/locale_uk-UA.ini b/options/locale/locale_uk-UA.ini index 2186f44..c92ac9d 100644 --- a/options/locale/locale_uk-UA.ini +++ b/options/locale/locale_uk-UA.ini @@ -1218,6 +1218,7 @@ file_copy_permalink=Копіювати постійне посилання video_not_supported_in_browser=Ваш браузер не підтримує тег HTML5 «video». audio_not_supported_in_browser=Ваш браузер не підтримує тег HTML5 «audio». stored_lfs=Збережено з Git LFS +stored_annex=Збережено з Git Annex symbolic_link=Символічне посилання commit_graph=Графік комітів commit_graph.select=Виберіть гілки @@ -1235,6 +1236,7 @@ editor.upload_file=Завантажити файл editor.edit_file=Редагувати файл editor.preview_changes=Попередній перегляд змін editor.cannot_edit_lfs_files=Файли LFS не можна редагувати в веб-інтерфейсі. +editor.cannot_edit_annex_files=Файли Annex не можна редагувати в веб-інтерфейсі. editor.cannot_edit_non_text_files=Бінарні файли не можливо редагувати у веб-інтерфейсі. editor.edit_this_file=Редагувати файл editor.this_file_locked=Файл заблоковано diff --git a/options/locale/locale_zh-CN.ini b/options/locale/locale_zh-CN.ini index 7354a4d..62021be 100644 --- a/options/locale/locale_zh-CN.ini +++ b/options/locale/locale_zh-CN.ini @@ -1317,6 +1317,7 @@ view_git_blame=查看 Git Blame video_not_supported_in_browser=您的浏览器不支持 HTML5 “video” 标签。 audio_not_supported_in_browser=您的浏览器不支持 HTML5 “audio” 标签。 stored_lfs=存储到Git LFS +stored_annex=存储到Git Annex symbolic_link=符号链接 executable_file=可执行文件 vendored = Vendored @@ -1342,6 +1343,7 @@ editor.upload_file=上传文件 editor.edit_file=编辑文件 editor.preview_changes=预览变更 editor.cannot_edit_lfs_files=无法在 web 界面中编辑 lfs 文件。 +editor.cannot_edit_annex_files=无法在 web 界面中编辑 lfs 文件。 editor.cannot_edit_non_text_files=网页不能编辑二进制文件。 editor.edit_this_file=编辑文件 editor.this_file_locked=文件已锁定 diff --git a/options/locale/locale_zh-HK.ini b/options/locale/locale_zh-HK.ini index e5080e6..25dfcdf 100644 --- a/options/locale/locale_zh-HK.ini +++ b/options/locale/locale_zh-HK.ini @@ -472,6 +472,7 @@ file_view_raw=查看原始文件 file_permalink=永久連結 stored_lfs=儲存到到 Git LFS +stored_annex=儲存到到 Git Annex editor.preview_changes=預覽更改 editor.or=或 @@ -1132,4 +1133,4 @@ runners.labels = 標籤 [projects] -[git.filemode] \ No newline at end of file +[git.filemode] diff --git a/options/locale/locale_zh-TW.ini b/options/locale/locale_zh-TW.ini index 5caafac..3dcce0b 100644 --- a/options/locale/locale_zh-TW.ini +++ b/options/locale/locale_zh-TW.ini @@ -1263,6 +1263,7 @@ view_git_blame=檢視 Git Blame video_not_supported_in_browser=您的瀏覽器不支援 HTML5 的「video」標籤。 audio_not_supported_in_browser=您的瀏覽器不支援 HTML5 的「audio」標籤。 stored_lfs=已使用 Git LFS 儲存 +stored_annex=已使用 Git Annex 儲存 symbolic_link=符號連結 commit_graph=提交線圖 commit_graph.select=選擇分支 @@ -1282,6 +1283,7 @@ editor.upload_file=上傳檔案 editor.edit_file=編輯檔案 editor.preview_changes=預覽變更 editor.cannot_edit_lfs_files=無法在 web 介面中編輯 LFS 檔。 +editor.cannot_edit_annex_files=無法在 web 介面中編輯 Annex 檔。 editor.cannot_edit_non_text_files=網站介面不能編輯二進位檔案。 editor.edit_this_file=編輯檔案 editor.this_file_locked=檔案已被鎖定 diff --git a/package-lock.json b/package-lock.json index e081796..c5f1950 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,5 +1,5 @@ { - "name": "forgejo", + "name": "forgejo-aneksajo", "lockfileVersion": 3, "requires": true, "packages": { diff --git a/routers/init.go b/routers/init.go index 821a0ef..d881edd 100644 --- a/routers/init.go +++ b/routers/init.go @@ -11,6 +11,7 @@ import ( "code.gitea.io/gitea/models" asymkey_model "code.gitea.io/gitea/models/asymkey" authmodel "code.gitea.io/gitea/models/auth" + "code.gitea.io/gitea/modules/annex" "code.gitea.io/gitea/modules/cache" "code.gitea.io/gitea/modules/eventsource" "code.gitea.io/gitea/modules/git" @@ -167,6 +168,8 @@ func InitWebInstalled(ctx context.Context) { actions_service.Init() + mustInit(annex.Init) + // Finally start up the cron cron.NewContext(ctx) } diff --git a/routers/private/serv.go b/routers/private/serv.go index ef3920d..a3a0c69 100644 --- a/routers/private/serv.go +++ b/routers/private/serv.go @@ -81,12 +81,14 @@ func ServCommand(ctx *context.PrivateContext) { ownerName := ctx.Params(":owner") repoName := ctx.Params(":repo") mode := perm.AccessMode(ctx.FormInt("mode")) + verbs := ctx.FormStrings("verb") // Set the basic parts of the results to return results := private.ServCommandResults{ RepoName: repoName, OwnerName: ownerName, KeyID: keyID, + UserMode: perm.AccessModeNone, } // Now because we're not translating things properly let's just default some English strings here @@ -287,8 +289,10 @@ func ServCommand(ctx *context.PrivateContext) { repo.IsPrivate || owner.Visibility.IsPrivate() || (user != nil && user.IsRestricted) || // user will be nil if the key is a deploykey + (setting.Annex.Enabled && len(verbs) > 0 && verbs[0] == "git-annex-shell") || // git-annex has its own permission enforcement, for which we expose results.UserMode setting.Service.RequireSignInView) { if key.Type == asymkey_model.KeyTypeDeploy { + results.UserMode = deployKey.Mode if deployKey.Mode < mode { ctx.JSON(http.StatusUnauthorized, private.Response{ UserMsg: fmt.Sprintf("Deploy Key: %d:%s is not authorized to %s %s/%s.", key.ID, key.Name, modeString, results.OwnerName, results.RepoName), @@ -310,9 +314,9 @@ func ServCommand(ctx *context.PrivateContext) { return } - userMode := perm.UnitAccessMode(unitType) + results.UserMode = perm.UnitAccessMode(unitType) - if userMode < mode { + if results.UserMode < mode { log.Warn("Failed authentication attempt for %s with key %s (not authorized to %s %s/%s) from %s", user.Name, key.Name, modeString, ownerName, repoName, ctx.RemoteAddr()) ctx.JSON(http.StatusUnauthorized, private.Response{ UserMsg: fmt.Sprintf("User: %d:%s with Key: %d:%s is not authorized to %s %s/%s.", user.ID, user.Name, key.ID, key.Name, modeString, ownerName, repoName), @@ -353,6 +357,7 @@ func ServCommand(ctx *context.PrivateContext) { }) return } + results.UserMode = perm.AccessModeWrite results.RepoID = repo.ID } @@ -381,13 +386,14 @@ func ServCommand(ctx *context.PrivateContext) { return } } - log.Debug("Serv Results:\nIsWiki: %t\nDeployKeyID: %d\nKeyID: %d\tKeyName: %s\nUserName: %s\nUserID: %d\nOwnerName: %s\nRepoName: %s\nRepoID: %d", + log.Debug("Serv Results:\nIsWiki: %t\nDeployKeyID: %d\nKeyID: %d\tKeyName: %s\nUserName: %s\nUserID: %d\nUserMode: %d\nOwnerName: %s\nRepoName: %s\nRepoID: %d", results.IsWiki, results.DeployKeyID, results.KeyID, results.KeyName, results.UserName, results.UserID, + results.UserMode, results.OwnerName, results.RepoName, results.RepoID) diff --git a/routers/web/repo/annex.go b/routers/web/repo/annex.go new file mode 100644 index 0000000..852b5a1 --- /dev/null +++ b/routers/web/repo/annex.go @@ -0,0 +1,146 @@ +package repo + +import ( + "context" + "net" + "net/http" + "net/http/httputil" + "net/url" + "os" + "os/exec" + "strings" + "syscall" + "time" + + "code.gitea.io/gitea/models/perm" + access_model "code.gitea.io/gitea/models/perm/access" + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/models/unit" + "code.gitea.io/gitea/modules/annex" + "code.gitea.io/gitea/modules/graceful" + "code.gitea.io/gitea/modules/log" + services_context "code.gitea.io/gitea/services/context" +) + +type p2phttpRecordType struct { + CancelFunc func() + LastUsed time.Time + Port string +} + +var p2phttpRecords = make(map[string]*p2phttpRecordType) + +// AnnexP2PHTTP implements git-annex smart HTTP support by delegating to git annex p2phttp +func AnnexP2PHTTP(ctx *services_context.Context) { + uuid := ctx.Params(":uuid") + repoPath, err := annex.UUID2RepoPath(uuid) + if err != nil { + ctx.PlainText(http.StatusNotFound, "Repository not found") + return + } + + parts := strings.Split(repoPath, "/") + repoName := strings.TrimSuffix(parts[len(parts)-1], ".git") + owner := parts[len(parts)-2] + repo, err := repo_model.GetRepositoryByOwnerAndName(ctx, owner, repoName) + if err != nil { + ctx.PlainText(http.StatusNotFound, "Repository not found") + return + } + + p, err := access_model.GetUserRepoPermission(ctx, repo, ctx.Doer) + if err != nil { + ctx.ServerError("GetUserRepoPermission", err) + return + } + + if !(ctx.Req.Method == "GET" && p.CanAccess(perm.AccessModeRead, unit.TypeCode) || + ctx.Req.Method == "POST" && p.CanAccess(perm.AccessModeWrite, unit.TypeCode) || + ctx.Req.Method == "POST" && strings.HasSuffix(ctx.Req.URL.Path, "/checkpresent") && p.CanAccess(perm.AccessModeRead, unit.TypeCode) || + ctx.Req.Method == "POST" && strings.HasSuffix(ctx.Req.URL.Path, "/keeplocked") || + ctx.Req.Method == "POST" && strings.HasSuffix(ctx.Req.URL.Path, "/lockcontent")) { + // GET requests require at least read access; POST requests for + // anything but checkpresent, lockcontent, and keeplocked + // require write permissions; POST requests for checkpresent + // only require read permissions, as it really is just a read. + // POST requests for lockcontent and keeplocked require no + // authentication at all, as is also the case for the + // authentication in the git-annex-p2phttp server. See + // https://git-annex.branchable.com/bugs/p2phttp__58___drop_difference_wideopen_unauth-readonly/ + // for reasoning. + ctx.Resp.WriteHeader(http.StatusUnauthorized) + return + } + + p2phttpRecord, p2phttpProcessExists := p2phttpRecords[uuid] + if p2phttpProcessExists { + p2phttpRecord.LastUsed = time.Now() + } else { + // Start a new p2phttp process for the requested repository + // There is a race condition here with the port selection, ideally git annex p2phttp could just listen on a unix socket... + lis, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + log.Error("Failed to listen on a free port: %v", err) + ctx.Resp.WriteHeader(http.StatusInternalServerError) + return + } + hopefullyFreePort := strings.SplitN(lis.Addr().String(), ":", 2)[1] + lis.Close() + p2phttpCtx, p2phttpCtxCancel := context.WithCancel(context.Background()) + go func(ctx context.Context) { + cmd := exec.CommandContext(ctx, "git", "-C", repoPath, "annex", "p2phttp", "-J2", "--bind", "127.0.0.1", "--wideopen", "--port", hopefullyFreePort) + cmd.SysProcAttr = &syscall.SysProcAttr{ + Pdeathsig: syscall.SIGINT, + } + cmd.Cancel = func() error { return cmd.Process.Signal(os.Interrupt) } + _ = cmd.Run() + }(p2phttpCtx) + graceful.GetManager().RunAtTerminate(p2phttpCtxCancel) + + // Wait for the p2phttp server to get ready + start := time.Now() + sleepDuration := 1 * time.Millisecond + for { + if time.Since(start) > 5*time.Second { + p2phttpCtxCancel() + log.Error("Failed to start the p2phttp server in a reasonable amount of time") + ctx.Resp.WriteHeader(http.StatusInternalServerError) + return + } + conn, err := net.Dial("tcp", "127.0.0.1:"+hopefullyFreePort) + if err == nil { + conn.Close() + break + } + time.Sleep(sleepDuration) + sleepDuration *= 2 + if sleepDuration > 1*time.Second { + sleepDuration = 1 * time.Second + } + } + + p2phttpRecord = &p2phttpRecordType{CancelFunc: p2phttpCtxCancel, LastUsed: time.Now(), Port: hopefullyFreePort} + p2phttpRecords[uuid] = p2phttpRecord + } + + // Cleanup p2phttp processes that haven't been used for a while + for uuid, record := range p2phttpRecords { + if time.Since(record.LastUsed) > 5*time.Minute { + record.CancelFunc() + delete(p2phttpRecords, uuid) + } + } + + url, err := url.Parse("http://127.0.0.1:" + p2phttpRecord.Port + strings.TrimPrefix(ctx.Req.RequestURI, "/git-annex-p2phttp")) + if err != nil { + log.Error("Failed to parse URL: %v", err) + ctx.Resp.WriteHeader(http.StatusInternalServerError) + return + } + proxy := httputil.ReverseProxy{ + Rewrite: func(r *httputil.ProxyRequest) { + r.Out.URL = url + }, + } + proxy.ServeHTTP(ctx.Resp, ctx.Req) +} diff --git a/routers/web/repo/compare.go b/routers/web/repo/compare.go index 03d49fa..104655e 100644 --- a/routers/web/repo/compare.go +++ b/routers/web/repo/compare.go @@ -23,6 +23,7 @@ import ( repo_model "code.gitea.io/gitea/models/repo" "code.gitea.io/gitea/models/unit" user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/annex" "code.gitea.io/gitea/modules/base" "code.gitea.io/gitea/modules/charset" csv_module "code.gitea.io/gitea/modules/csv" @@ -72,7 +73,21 @@ func setCompareContext(ctx *context.Context, before, head *git.Commit, headOwner return st } - st, err := blob.GuessContentType() + isAnnexed, err := annex.IsAnnexed(blob) + if err != nil { + log.Error("IsAnnexed failed: %v", err) + return st + } + if isAnnexed { + st, err = annex.GuessContentType(blob) + if err != nil { + log.Error("GuessContentType failed: %v", err) + return st + } + return st + } + + st, err = blob.GuessContentType() if err != nil { log.Error("GuessContentType failed: %v", err) return st @@ -90,18 +105,18 @@ func SourceCommitURL(owner, name string, commit *git.Commit) string { return setting.AppSubURL + "/" + url.PathEscape(owner) + "/" + url.PathEscape(name) + "/src/commit/" + url.PathEscape(commit.ID.String()) } -// RawCommitURL creates a relative URL for the raw commit in the given repository -func RawCommitURL(owner, name string, commit *git.Commit) string { - return setting.AppSubURL + "/" + url.PathEscape(owner) + "/" + url.PathEscape(name) + "/raw/commit/" + url.PathEscape(commit.ID.String()) +// MediaCommitURL creates a relative URL for the commit media (plain git, LFS, or annex content) in the given repository +func MediaCommitURL(owner, name string, commit *git.Commit) string { + return setting.AppSubURL + "/" + url.PathEscape(owner) + "/" + url.PathEscape(name) + "/media/commit/" + url.PathEscape(commit.ID.String()) } // setPathsCompareContext sets context data for source and raw paths func setPathsCompareContext(ctx *context.Context, base, head *git.Commit, headOwner, headName string) { ctx.Data["SourcePath"] = SourceCommitURL(headOwner, headName, head) - ctx.Data["RawPath"] = RawCommitURL(headOwner, headName, head) + ctx.Data["RawPath"] = MediaCommitURL(headOwner, headName, head) if base != nil { ctx.Data["BeforeSourcePath"] = SourceCommitURL(headOwner, headName, base) - ctx.Data["BeforeRawPath"] = RawCommitURL(headOwner, headName, base) + ctx.Data["BeforeRawPath"] = MediaCommitURL(headOwner, headName, base) } } diff --git a/routers/web/repo/download.go b/routers/web/repo/download.go index 1e87bbf..aefaa79 100644 --- a/routers/web/repo/download.go +++ b/routers/web/repo/download.go @@ -9,6 +9,7 @@ import ( "time" git_model "code.gitea.io/gitea/models/git" + "code.gitea.io/gitea/modules/annex" "code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/httpcache" "code.gitea.io/gitea/modules/lfs" @@ -79,6 +80,26 @@ func ServeBlobOrLFS(ctx *context.Context, blob *git.Blob, lastModified *time.Tim } closed = true + // check for git-annex files + // (this code is weirdly redundant because I'm trying not to delete any lines in order to make merges easier) + isAnnexed, err := annex.IsAnnexed(blob) + if err != nil { + ctx.ServerError("annex.IsAnnexed", err) + return err + } + if isAnnexed { + content, err := annex.Content(blob) + if err != nil { + // XXX are there any other possible failure cases here? + // there are, there could be unrelated io errors; those should be ctx.ServerError()s + ctx.NotFound("annex.Content", err) + return err + } + defer content.Close() + common.ServeContentByReadSeeker(ctx.Base, ctx.Repo.TreePath, lastModified, content) + return nil + } + return common.ServeBlob(ctx.Base, ctx.Repo.TreePath, blob, lastModified) } diff --git a/routers/web/repo/githttp.go b/routers/web/repo/githttp.go index c1adca1..53f18f9 100644 --- a/routers/web/repo/githttp.go +++ b/routers/web/repo/githttp.go @@ -10,6 +10,7 @@ import ( gocontext "context" "fmt" "net/http" + "net/url" "os" "path/filepath" "regexp" @@ -78,7 +79,24 @@ func httpBase(ctx *context.Context) *serviceHandler { strings.HasSuffix(ctx.Req.URL.Path, "git-upload-archive") { isPull = true } else { - isPull = ctx.Req.Method == "GET" + // In addition to GET requests, HEAD requests are also "pull" + // operations (reads), so they should also not require + // authentication. This is necessary for git-annex to operate + // properly, as it emits HEAD requests to check for the + // existence of keys, e.g. before dropping locally, and asking + // for authentication would break unauthenticated http usage in + // this situation. + // It should be safe to make all HEAD requests require no + // authentication, but as it is only necessary for the + // annex/objects endpoints to fix git-annex' drop operations it + // is limited to those for now. + r, err := regexp.Compile("^/?" + username + "/" + reponame + "(.git)?/annex/objects") + if err != nil { + ctx.ServerError("failed to create URL path regex", err) + return nil + } + isPull = ctx.Req.Method == "GET" || + r.MatchString(ctx.Req.URL.Path) && ctx.Req.Method == "HEAD" } var accessMode perm.AccessMode @@ -545,6 +563,42 @@ func GetInfoRefs(ctx *context.Context) { } } +// GetConfig implements fetching the git config of a repository +func GetConfig(ctx *context.Context) { + h := httpBase(ctx) + if h != nil { + setHeaderNoCache(ctx) + config, err := os.ReadFile(filepath.Join(h.getRepoDir(), "config")) + if err != nil { + log.Error("Failed to read git config file: %v", err) + ctx.Resp.WriteHeader(http.StatusInternalServerError) + return + } + if !setting.Annex.DisableP2PHTTP { + appURL, err := url.Parse(setting.AppURL) + if err != nil { + log.Error("Could not parse 'setting.AppURL': %v", err) + ctx.Resp.WriteHeader(http.StatusInternalServerError) + return + } + if appURL.Port() == "" { + // If there is no port set then set the http(s) default ports. + // Without this, git-annex would try its own default port (9417) and fail. + if appURL.Scheme == "http" { + appURL.Host += ":80" + } + if appURL.Scheme == "https" { + appURL.Host += ":443" + } + } + config = append(config, []byte("[annex]\n\turl = annex+"+appURL.String()+"git-annex-p2phttp\n")...) + } + ctx.Resp.Header().Set("Content-Type", "text/plain") + ctx.Resp.Header().Set("Content-Length", fmt.Sprintf("%d", len(config))) + http.ServeContent(ctx.Resp, ctx.Req, "config", time.Now(), bytes.NewReader(config)) + } +} + // GetTextFile implements Git dumb HTTP func GetTextFile(p string) func(*context.Context) { return func(ctx *context.Context) { @@ -597,3 +651,34 @@ func GetIdxFile(ctx *context.Context) { h.sendFile(ctx, "application/x-git-packed-objects-toc", "objects/pack/pack-"+ctx.Params("file")+".idx") } } + +// GetAnnexObject implements git-annex dumb HTTP +func GetAnnexObject(ctx *context.Context) { + h := httpBase(ctx) + if h != nil { + // git-annex objects are stored in .git/annex/objects/{hash1}/{hash2}/{key}/{key} + // where key is a string containing the size and (usually SHA256) checksum of the file, + // and hash1+hash2 are the first few bits of the md5sum of key itself. + // ({hash1}/{hash2}/ is just there to avoid putting too many files in one directory) + // ref: https://git-annex.branchable.com/internals/hashing/ + + // keyDir should = key, but we don't enforce that + object := filepath.Join(ctx.Params("hash1"), ctx.Params("hash2"), ctx.Params("keyDir"), ctx.Params("key")) + + // Sanitize the input against directory traversals. + // + // This works because at the filesystem root, "/.." = "/"; + // So if a path starts rooted ("/"), path.Clean(), which + // path.Join() calls internally, removes all '..' prefixes. + // After, this unroots the path unconditionally ([1:]), which + // works because we know the input is never supposed to be rooted. + // + // The router code probably also disallows "..", so this + // should be redundant, but it's defensive to keep it + // whenever touching filesystem paths with user input. + object = filepath.Join(string(filepath.Separator), object)[1:] + + setHeaderCacheForever(ctx) + h.sendFile(ctx, "application/octet-stream", "annex/objects/"+object) + } +} diff --git a/routers/web/repo/view.go b/routers/web/repo/view.go index fd8c1da..6329f5d 100644 --- a/routers/web/repo/view.go +++ b/routers/web/repo/view.go @@ -34,6 +34,7 @@ import ( unit_model "code.gitea.io/gitea/models/unit" user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/actions" + "code.gitea.io/gitea/modules/annex" "code.gitea.io/gitea/modules/base" "code.gitea.io/gitea/modules/charset" "code.gitea.io/gitea/modules/git" @@ -209,14 +210,59 @@ func localizedExtensions(ext, languageCode string) (localizedExts []string) { } type fileInfo struct { - isTextFile bool - isLFSFile bool - fileSize int64 - lfsMeta *lfs.Pointer - st typesniffer.SniffedType + isTextFile bool + isLFSFile bool + isAnnexFile bool + isAnnexFilePresent bool + fileSize int64 + lfsMeta *lfs.Pointer + st typesniffer.SniffedType } func getFileReader(ctx gocontext.Context, repoID int64, blob *git.Blob) ([]byte, io.ReadCloser, *fileInfo, error) { + isAnnexed, err := annex.IsAnnexed(blob) + if err != nil { + return nil, nil, nil, err + } + if isAnnexed { + // TODO: this code could be merged with the LFS case, especially the redundant type sniffer, + // but it is *currently* written this way to make merging with the non-annex upstream easier: + // this way, the git-annex patch is (mostly) pure additions. + + annexContent, err := annex.Content(blob) + if err != nil { + // If annex.Content returns an error it can mean that the blob does not + // refer to an annexed file or that it is not present here. Since we already + // checked that it is annexed the latter must be the case. So we return the + // content of the blob instead and indicate that the file is indeed annexed, + // but not present here. The template can then communicate the situation. + dataRc, err := blob.DataAsync() + if err != nil { + return nil, nil, nil, err + } + + buf := make([]byte, 1024) + n, _ := util.ReadAtMost(dataRc, buf) + buf = buf[:n] + + st := typesniffer.DetectContentType(buf) + return buf, dataRc, &fileInfo{st.IsText(), false, true, false, blob.Size(), nil, st}, nil + } + + stat, err := annexContent.Stat() + if err != nil { + return nil, nil, nil, err + } + + buf := make([]byte, 1024) + n, _ := util.ReadAtMost(annexContent, buf) + buf = buf[:n] + + st := typesniffer.DetectContentType(buf) + + return buf, annexContent, &fileInfo{st.IsText(), false, true, true, stat.Size(), nil, st}, nil + } + dataRc, err := blob.DataAsync() if err != nil { return nil, nil, nil, err @@ -231,18 +277,18 @@ func getFileReader(ctx gocontext.Context, repoID int64, blob *git.Blob) ([]byte, // FIXME: what happens when README file is an image? if !isTextFile || !setting.LFS.StartServer { - return buf, dataRc, &fileInfo{isTextFile, false, blob.Size(), nil, st}, nil + return buf, dataRc, &fileInfo{isTextFile, false, false, false, blob.Size(), nil, st}, nil } pointer, _ := lfs.ReadPointerFromBuffer(buf) if !pointer.IsValid() { // fallback to plain file - return buf, dataRc, &fileInfo{isTextFile, false, blob.Size(), nil, st}, nil + return buf, dataRc, &fileInfo{isTextFile, false, false, false, blob.Size(), nil, st}, nil } meta, err := git_model.GetLFSMetaObjectByOid(ctx, repoID, pointer.Oid) if err != nil { // fallback to plain file log.Warn("Unable to access LFS pointer %s in repo %d: %v", pointer.Oid, repoID, err) - return buf, dataRc, &fileInfo{isTextFile, false, blob.Size(), nil, st}, nil + return buf, dataRc, &fileInfo{isTextFile, false, false, false, blob.Size(), nil, st}, nil } dataRc.Close() @@ -262,7 +308,7 @@ func getFileReader(ctx gocontext.Context, repoID int64, blob *git.Blob) ([]byte, st = typesniffer.DetectContentType(buf) - return buf, dataRc, &fileInfo{st.IsText(), true, meta.Size, &meta.Pointer, st}, nil + return buf, dataRc, &fileInfo{st.IsText(), true, false, false, meta.Size, &meta.Pointer, st}, nil } func renderReadmeFile(ctx *context.Context, subfolder string, readmeFile *git.TreeEntry) { @@ -325,6 +371,7 @@ func renderReadmeFile(ctx *context.Context, subfolder string, readmeFile *git.Tr }, Metas: ctx.Repo.Repository.ComposeDocumentMetas(ctx), GitRepo: ctx.Repo.GitRepo, + Blob: target.Blob(), }, rd) if err != nil { log.Error("Render failed for %s in %-v: %v Falling back to rendering source", readmeFile.Name(), ctx.Repo.Repository, err) @@ -447,10 +494,17 @@ func renderFile(ctx *context.Context, entry *git.TreeEntry) { isDisplayingSource := ctx.FormString("display") == "source" isDisplayingRendered := !isDisplayingSource - if fInfo.isLFSFile { + if fInfo.isLFSFile || fInfo.isAnnexFile { ctx.Data["RawFileLink"] = ctx.Repo.RepoLink + "/media/" + ctx.Repo.BranchNameSubURL() + "/" + util.PathEscapeSegments(ctx.Repo.TreePath) } + if fInfo.isAnnexFile { + // pre-git-annex v7, all annexed files were represented in-repo as symlinks; + // but we pretend they aren't, since that's a distracting quirk of git-annex + // and not a meaningful choice on the user's part + ctx.Data["FileIsSymlink"] = false + } + isRepresentableAsText := fInfo.st.IsRepresentableAsText() if !isRepresentableAsText { // If we can't show plain text, always try to render. @@ -458,6 +512,8 @@ func renderFile(ctx *context.Context, entry *git.TreeEntry) { isDisplayingRendered = true } ctx.Data["IsLFSFile"] = fInfo.isLFSFile + ctx.Data["IsAnnexFile"] = fInfo.isAnnexFile + ctx.Data["IsAnnexFilePresent"] = fInfo.isAnnexFilePresent ctx.Data["FileSize"] = fInfo.fileSize ctx.Data["IsTextFile"] = fInfo.isTextFile ctx.Data["IsRepresentableAsText"] = isRepresentableAsText @@ -492,6 +548,8 @@ func renderFile(ctx *context.Context, entry *git.TreeEntry) { // Assume file is not editable first. if fInfo.isLFSFile { ctx.Data["EditFileTooltip"] = ctx.Tr("repo.editor.cannot_edit_lfs_files") + } else if fInfo.isAnnexFile { + ctx.Data["EditFileTooltip"] = ctx.Tr("repo.editor.cannot_edit_annex_files") } else if !isRepresentableAsText { ctx.Data["EditFileTooltip"] = ctx.Tr("repo.editor.cannot_edit_non_text_files") } @@ -546,6 +604,7 @@ func renderFile(ctx *context.Context, entry *git.TreeEntry) { }, Metas: metas, GitRepo: ctx.Repo.GitRepo, + Blob: entry.Blob(), }, rd) if err != nil { ctx.ServerError("Render", err) @@ -599,7 +658,7 @@ func renderFile(ctx *context.Context, entry *git.TreeEntry) { ctx.Data["FileContent"] = fileContent ctx.Data["LineEscapeStatus"] = statuses } - if !fInfo.isLFSFile { + if !fInfo.isLFSFile && !fInfo.isAnnexFile { if ctx.Repo.CanEnableEditor(ctx, ctx.Doer) { if lfsLock != nil && lfsLock.OwnerID != ctx.Doer.ID { ctx.Data["CanEditFile"] = false @@ -644,6 +703,7 @@ func renderFile(ctx *context.Context, entry *git.TreeEntry) { }, Metas: ctx.Repo.Repository.ComposeDocumentMetas(ctx), GitRepo: ctx.Repo.GitRepo, + Blob: entry.Blob(), }, rd) if err != nil { ctx.ServerError("Render", err) @@ -1159,6 +1219,15 @@ PostRecentBranchCheck: } else { ctx.Data["CodeSearchOptions"] = git.GrepSearchOptions } + isAnnexFile, okAnnexFile := ctx.Data["IsAnnexFile"] + isAnnexFilePresent, okAnnexFilePresent := ctx.Data["IsAnnexFilePresent"] + if okAnnexFile && okAnnexFilePresent && isAnnexFile.(bool) && !isAnnexFilePresent.(bool) { + // If the file to be viewed is annexed but not present then render it normally + // (which will show the plain git blob content, i.e. the symlink or pointer target) + // but make the status code a 404. + ctx.HTML(http.StatusNotFound, tplRepoHome) + return + } ctx.HTML(http.StatusOK, tplRepoHome) } diff --git a/routers/web/web.go b/routers/web/web.go index 4d8d280..db0015f 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -356,6 +356,20 @@ func registerRoutes(m *web.Route) { } } + annexEnabled := func(ctx *context.Context) { + if !setting.Annex.Enabled { + ctx.Error(http.StatusNotFound) + return + } + } + + annexP2PHTTPEnabled := func(ctx *context.Context) { + if setting.Annex.DisableP2PHTTP { + ctx.Error(http.StatusNotFound) + return + } + } + federationEnabled := func(ctx *context.Context) { if !setting.Federation.Enabled { ctx.Error(http.StatusNotFound) @@ -955,6 +969,9 @@ func registerRoutes(m *web.Route) { // ***** END: Organization ***** // ***** START: Repository ***** + m.Group("", func() { + m.Methods("GET,POST", "/git-annex-p2phttp/git-annex/{uuid}/*", repo.AnnexP2PHTTP) + }, ignSignInAndCsrf, annexEnabled, annexP2PHTTPEnabled) m.Group("/repo", func() { m.Get("/create", repo.Create) m.Post("/create", web.Bind(forms.CreateRepoForm{}), repo.CreatePost) @@ -1635,6 +1652,12 @@ func registerRoutes(m *web.Route) { }) }, ignSignInAndCsrf, lfsServerEnabled) + m.Group("", func() { + // for git-annex + m.Methods("GET,OPTIONS", "/config", repo.GetConfig) // needed by clients reading annex.uuid during `git annex initremote` + m.Methods("GET,OPTIONS", "/annex/objects/{hash1}/{hash2}/{keyDir}/{key}", repo.GetAnnexObject) + }, ignSignInAndCsrf, annexEnabled, context.UserAssignmentWeb()) + gitHTTPRouters(m) }) }) diff --git a/services/auth/auth.go b/services/auth/auth.go index c108723..ddd3191 100644 --- a/services/auth/auth.go +++ b/services/auth/auth.go @@ -61,6 +61,17 @@ func isArchivePath(req *http.Request) bool { return archivePathRe.MatchString(req.URL.Path) } +var annexPathRe = regexp.MustCompile(`^(/git-annex-p2phttp/|/[a-zA-Z0-9_.-]+/[a-zA-Z0-9_.-]+/annex/)`) + +func isAnnexPath(req *http.Request) bool { + if setting.Annex.Enabled { + // "/config" is git's config, not specifically git-annex's; but the only current + // user of it is when git-annex downloads the annex.uuid during 'git annex init'. + return strings.HasSuffix(req.URL.Path, "/config") || annexPathRe.MatchString(req.URL.Path) + } + return false +} + // handleSignIn clears existing session variables and stores new ones for the specified user object func handleSignIn(resp http.ResponseWriter, req *http.Request, sess SessionStore, user *user_model.User) { // We need to regenerate the session... diff --git a/services/auth/basic.go b/services/auth/basic.go index d489164..8e8fbfc 100644 --- a/services/auth/basic.go +++ b/services/auth/basic.go @@ -43,8 +43,8 @@ func (b *Basic) Name() string { // name/token on successful validation. // Returns nil if header is empty or validation fails. func (b *Basic) Verify(req *http.Request, w http.ResponseWriter, store DataStore, sess SessionStore) (*user_model.User, error) { - // Basic authentication should only fire on API, Download or on Git or LFSPaths - if !middleware.IsAPIPath(req) && !isContainerPath(req) && !isAttachmentDownload(req) && !isGitRawOrAttachOrLFSPath(req) { + // Basic authentication should only fire on API, Download or on Git, LFSPaths or Git-Annex paths + if !middleware.IsAPIPath(req) && !isContainerPath(req) && !isAttachmentDownload(req) && !isGitRawOrAttachOrLFSPath(req) && !isAnnexPath(req) { return nil, nil } diff --git a/services/repository/files/temp_repo.go b/services/repository/files/temp_repo.go index 6e7570b..566ae5f 100644 --- a/services/repository/files/temp_repo.go +++ b/services/repository/files/temp_repo.go @@ -202,6 +202,26 @@ func (t *TemporaryUploadRepository) AddObjectToIndex(mode, objectHash, objectPat return nil } +// InitPrivateAnnex initializes a private annex in the repository +func (t *TemporaryUploadRepository) InitPrivateAnnex() error { + if _, _, err := git.NewCommand(t.ctx, "config", "annex.private", "true").RunStdString(&git.RunOpts{Dir: t.basePath}); err != nil { + return err + } + if _, _, err := git.NewCommand(t.ctx, "annex", "init").RunStdString(&git.RunOpts{Dir: t.basePath}); err != nil { + return err + } + return nil +} + +// AddAnnex adds the file at path to the repository using git annex add +// This requires a non-bare repository +func (t *TemporaryUploadRepository) AddAnnex(path string) error { + if _, _, err := git.NewCommand(t.ctx, "annex", "add").AddDynamicArguments(path).RunStdString(&git.RunOpts{Dir: t.basePath}); err != nil { + return err + } + return nil +} + // WriteTree writes the current index as a tree to the object db and returns its hash func (t *TemporaryUploadRepository) WriteTree() (string, error) { stdout, _, err := git.NewCommand(t.ctx, "write-tree").RunStdString(&git.RunOpts{Dir: t.basePath}) diff --git a/services/repository/files/upload.go b/services/repository/files/upload.go index 1330116..21cd5a8 100644 --- a/services/repository/files/upload.go +++ b/services/repository/files/upload.go @@ -6,13 +6,16 @@ package files import ( "context" "fmt" + "io" "os" "path" + "path/filepath" "strings" git_model "code.gitea.io/gitea/models/git" repo_model "code.gitea.io/gitea/models/repo" user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/annex" "code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/lfs" "code.gitea.io/gitea/modules/setting" @@ -89,7 +92,7 @@ func UploadRepoFiles(ctx context.Context, repo *repo_model.Repository, doer *use defer t.Close() hasOldBranch := true - if err = t.Clone(opts.OldBranch, true); err != nil { + if err = t.Clone(opts.OldBranch, false); err != nil { if !git.IsErrBranchNotExist(err) || !repo.IsEmpty { return err } @@ -105,10 +108,30 @@ func UploadRepoFiles(ctx context.Context, repo *repo_model.Repository, doer *use } } - // Copy uploaded files into repository. - if err := copyUploadedLFSFilesIntoRepository(infos, t, opts.TreePath); err != nil { + r, err := git.OpenRepository(ctx, repo.RepoPath()) + if err != nil { return err } + if annex.IsAnnexRepo(r) { + // Initialize annex privately in temporary clone + if err := t.InitPrivateAnnex(); err != nil { + return err + } + // Copy uploaded files into git-annex repository + if err := copyUploadedFilesIntoAnnexRepository(infos, t, opts.TreePath); err != nil { + return err + } + // Move all annexed content in the temporary repository, i.e. everything we have just added, to the origin + author, committer := GetAuthorAndCommitterUsers(opts.Author, opts.Committer, doer) + if err := moveAnnexedFilesToOrigin(t, author, committer); err != nil { + return err + } + } else { + // Copy uploaded files into repository. + if err := copyUploadedLFSFilesIntoRepository(infos, t, opts.TreePath); err != nil { + return err + } + } // Now write the tree treeHash, err := t.WriteTree() @@ -246,3 +269,57 @@ func uploadToLFSContentStore(info uploadInfo, contentStore *lfs.ContentStore) er } return nil } + +func copyUploadedFilesIntoAnnexRepository(infos []uploadInfo, t *TemporaryUploadRepository, treePath string) error { + for i := range len(infos) { + if err := copyUploadedFileIntoAnnexRepository(&infos[i], t, treePath); err != nil { + return err + } + } + return nil +} + +func copyUploadedFileIntoAnnexRepository(info *uploadInfo, t *TemporaryUploadRepository, treePath string) error { + pathInRepo := path.Join(t.basePath, treePath, info.upload.Name) + if err := os.MkdirAll(filepath.Dir(pathInRepo), 0o700); err != nil { + return err + } + if err := os.Rename(info.upload.LocalPath(), pathInRepo); err != nil { + // Rename didn't work, try copy and remove + inputFile, err := os.Open(info.upload.LocalPath()) + if err != nil { + return fmt.Errorf("could not open source file: %v", err) + } + defer inputFile.Close() + outputFile, err := os.Create(pathInRepo) + if err != nil { + return fmt.Errorf("could not open dest file: %v", err) + } + defer outputFile.Close() + _, err = io.Copy(outputFile, inputFile) + if err != nil { + return fmt.Errorf("could not copy to dest from source: %v", err) + } + inputFile.Close() + err = os.Remove(info.upload.LocalPath()) + if err != nil { + return fmt.Errorf("could not remove source file: %v", err) + } + } + return t.AddAnnex(pathInRepo) +} + +func moveAnnexedFilesToOrigin(t *TemporaryUploadRepository, author, committer *user_model.User) error { + authorSig := author.NewGitSig() + committerSig := committer.NewGitSig() + env := append(os.Environ(), + "GIT_AUTHOR_NAME="+authorSig.Name, + "GIT_AUTHOR_EMAIL="+authorSig.Email, + "GIT_COMMITTER_NAME="+committerSig.Name, + "GIT_COMMITTER_EMAIL="+committerSig.Email, + ) + if _, _, err := git.NewCommand(t.ctx, "annex", "move", "--to", "origin").RunStdString(&git.RunOpts{Dir: t.basePath, Env: env}); err != nil { + return err + } + return nil +} diff --git a/templates/repo/file_info.tmpl b/templates/repo/file_info.tmpl index 6ae7c15..8655404 100644 --- a/templates/repo/file_info.tmpl +++ b/templates/repo/file_info.tmpl @@ -17,6 +17,7 @@ {{if .FileSize}}
{{ctx.Locale.TrSize .FileSize}}{{if .IsLFSFile}} ({{ctx.Locale.Tr "repo.stored_lfs"}}){{end}} + {{if .IsAnnexFile}} ({{ctx.Locale.Tr "repo.stored_annex"}}{{if not .IsAnnexFilePresent}} - {{ctx.Locale.Tr "repo.stored_annex_not_present"}}{{end}}){{end}}
{{end}} {{if .LFSLock}} diff --git a/tests/integration/api_helper_for_declarative_test.go b/tests/integration/api_helper_for_declarative_test.go index dae71ca..5bd8dc5 100644 --- a/tests/integration/api_helper_for_declarative_test.go +++ b/tests/integration/api_helper_for_declarative_test.go @@ -22,6 +22,7 @@ import ( api "code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/services/forms" + "github.com/google/uuid" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -461,3 +462,27 @@ func doAPIAddRepoToOrganizationTeam(ctx APITestContext, teamID int64, orgName, r ctx.Session.MakeRequest(t, req, http.StatusNoContent) } } + +// generate and activate an ssh key for the user attached to the APITestContext +// TODO: pick a better name; golang doesn't do method overriding. +func withCtxKeyFile(t *testing.T, ctx APITestContext, callback func()) { + // we need to have write:public_key to do this step + // the easiest way is to create a throwaway ctx that is identical but only has that permission + ctxKeyWriter := ctx + ctxKeyWriter.Token = getTokenForLoggedInUser(t, ctx.Session, auth.AccessTokenScopeWriteUser) + + keyName := "One of " + ctx.Username + "'s keys: #" + uuid.New().String() + withKeyFile(t, keyName, func(keyFile string) { + var key api.PublicKey + + doAPICreateUserKey(ctxKeyWriter, keyName, keyFile, + func(t *testing.T, _key api.PublicKey) { + // save the key ID so we can delete it at the end + key = _key + })(t) + + defer doAPIDeleteUserKey(ctxKeyWriter, key.ID)(t) + + callback() + }) +} diff --git a/tests/integration/git_annex_test.go b/tests/integration/git_annex_test.go new file mode 100644 index 0000000..efcb571 --- /dev/null +++ b/tests/integration/git_annex_test.go @@ -0,0 +1,2925 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package integration + +import ( + "bytes" + "errors" + "fmt" + "io" + "math/rand" + "mime/multipart" + "net/http" + "net/url" + "os" + "path" + "path/filepath" + "regexp" + "strings" + "testing" + + auth_model "code.gitea.io/gitea/models/auth" + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/models/perm" + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/modules/annex" + "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/setting" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/tests" + + "github.com/stretchr/testify/require" +) + +// Some guidelines: +// +// * a APITestContext is an awkward union of session credential + username + target repo +// which is assumed to be owned by that username; if you want to target a different +// repo, you need to edit its .Reponame or just ignore it and write "username/reponame.git" + +func doCreateRemoteAnnexRepository(t *testing.T, u *url.URL, ctx APITestContext, private bool, objectFormat git.ObjectFormat) (err error) { + // creating a repo counts as editing the user's profile (is done by POSTing + // to /api/v1/user/repos/) -- which means it needs a User-scoped token and + // both that and editing need a Repo-scoped token because they edit repositories. + rescopedCtx := ctx + rescopedCtx.Token = getTokenForLoggedInUser(t, ctx.Session, auth_model.AccessTokenScopeWriteUser, auth_model.AccessTokenScopeWriteRepository) + doAPICreateRepository(rescopedCtx, false, objectFormat)(t) + t.Cleanup(func() { util.MakeWritable(setting.RepoRootPath) }) + doAPIEditRepository(rescopedCtx, &api.EditRepoOption{Private: &private})(t) + + repoURL := createSSHUrl(ctx.GitPath(), u) + + // Fill in fixture data + withAnnexCtxKeyFile(t, ctx, func() { + err = doInitRemoteAnnexRepository(t, repoURL) + }) + if err != nil { + return fmt.Errorf("Unable to initialize remote repo with git-annex fixture: %w", err) + } + return nil +} + +func TestGitAnnexWebUpload(t *testing.T) { + if !setting.Annex.Enabled { + t.Skip("Skipping since annex support is disabled.") + } + + onGiteaRun(t, func(t *testing.T, u *url.URL) { + forEachObjectFormat(t, func(t *testing.T, objectFormat git.ObjectFormat) { + ctx := NewAPITestContext(t, "user2", "annex-web-upload-test"+objectFormat.Name(), auth_model.AccessTokenScopeWriteRepository) + require.NoError(t, doCreateRemoteAnnexRepository(t, u, ctx, false, objectFormat)) + + uploadFile := func(t *testing.T, path string) string { + t.Helper() + + body := &bytes.Buffer{} + mpForm := multipart.NewWriter(body) + err := mpForm.WriteField("_csrf", GetCSRF(t, ctx.Session, ctx.Username+"/"+ctx.Reponame+"/_upload/"+setting.Repository.DefaultBranch)) + require.NoError(t, err) + + file, err := mpForm.CreateFormFile("file", filepath.Base(path)) + require.NoError(t, err) + + srcFile, err := os.Open(path) + require.NoError(t, err) + + io.Copy(file, srcFile) + require.NoError(t, mpForm.Close()) + + req := NewRequestWithBody(t, "POST", "/"+ctx.Username+"/"+ctx.Reponame+"/upload-file", body) + req.Header.Add("Content-Type", mpForm.FormDataContentType()) + resp := ctx.Session.MakeRequest(t, req, http.StatusOK) + + respMap := map[string]string{} + DecodeJSON(t, resp, &respMap) + return respMap["uuid"] + } + + // Generate random file + tmpFile := path.Join(t.TempDir(), "web-upload-test-file.bin") + require.NoError(t, generateRandomFile(1024*1024/4, tmpFile)) + expectedContent, err := os.ReadFile(tmpFile) + require.NoError(t, err) + + // Upload generated file + fileUUID := uploadFile(t, tmpFile) + req := NewRequestWithValues(t, "POST", ctx.Username+"/"+ctx.Reponame+"/_upload/"+setting.Repository.DefaultBranch, map[string]string{ + "commit_choice": "direct", + "files": fileUUID, + "_csrf": GetCSRF(t, ctx.Session, ctx.Username+"/"+ctx.Reponame+"/_upload/"+setting.Repository.DefaultBranch), + "commit_mail_id": "-1", + }) + ctx.Session.MakeRequest(t, req, http.StatusSeeOther) + + // Get some handles on the target repository and file + remoteRepoPath := path.Join(setting.RepoRootPath, ctx.GitPath()) + repo, err := git.OpenRepository(git.DefaultContext, remoteRepoPath) + require.NoError(t, err) + defer repo.Close() + tree, err := repo.GetTree(setting.Repository.DefaultBranch) + require.NoError(t, err) + treeEntry, err := tree.GetTreeEntryByPath(filepath.Base(tmpFile)) + require.NoError(t, err) + blob := treeEntry.Blob() + + // Check that the uploaded file is annexed + isAnnexed, err := annex.IsAnnexed(blob) + require.NoError(t, err) + require.True(t, isAnnexed) + + // Check that the uploaded file has the correct content + annexedFile, err := annex.Content(blob) + require.NoError(t, err) + actualContent, err := io.ReadAll(annexedFile) + require.NoError(t, err) + require.Equal(t, expectedContent, actualContent) + }) + }) +} + +func TestGitAnnexMedia(t *testing.T) { + if !setting.Annex.Enabled { + t.Skip("Skipping since annex support is disabled.") + } + + onGiteaRun(t, func(t *testing.T, u *url.URL) { + forEachObjectFormat(t, func(t *testing.T, objectFormat git.ObjectFormat) { + ctx := NewAPITestContext(t, "user2", "annex-media-test"+objectFormat.Name(), auth_model.AccessTokenScopeWriteRepository) + + // create a public repo + require.NoError(t, doCreateRemoteAnnexRepository(t, u, ctx, false, objectFormat)) + + // the filenames here correspond to specific cases defined in doInitAnnexRepository() + t.Run("AnnexSymlink", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + doAnnexMediaTest(t, ctx, "annexed.tiff") + }) + t.Run("AnnexPointer", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + doAnnexMediaTest(t, ctx, "annexed.bin") + }) + }) + }) +} + +func doAnnexMediaTest(t *testing.T, ctx APITestContext, file string) { + // Make sure that downloading via /media on the website recognizes it should give the annexed content + + // TODO: + // - [ ] roll this into TestGitAnnexPermissions to ensure that permission enforcement works correctly even on /media? + + session := loginUser(t, ctx.Username) // logs in to the http:// site/API, storing a cookie; + // this is a different auth method than the git+ssh:// or git+http:// protocols TestGitAnnexPermissions uses! + + // compute server-side path of the annexed file + remoteRepoPath := path.Join(setting.RepoRootPath, ctx.GitPath()) + remoteObjectPath, err := contentLocation(remoteRepoPath, file) + require.NoError(t, err) + + // download annexed file + localObjectPath := path.Join(t.TempDir(), file) + fd, err := os.OpenFile(localObjectPath, os.O_CREATE|os.O_WRONLY, 0o777) + defer fd.Close() + require.NoError(t, err) + + mediaLink := path.Join("/", ctx.Username, ctx.Reponame, "/media/branch/master", file) + req := NewRequest(t, "GET", mediaLink) + resp := session.MakeRequest(t, req, http.StatusOK) + + _, err = io.Copy(fd, resp.Body) + require.NoError(t, err) + fd.Close() + + // verify the download + match, err := tests.FileCmp(localObjectPath, remoteObjectPath, 0) + require.NoError(t, err) + require.True(t, match, "Annexed files should be the same") +} + +func TestGitAnnexViews(t *testing.T) { + if !setting.Annex.Enabled { + t.Skip("Skipping since annex support is disabled.") + } + + onGiteaRun(t, func(t *testing.T, u *url.URL) { + forEachObjectFormat(t, func(t *testing.T, objectFormat git.ObjectFormat) { + ctx := NewAPITestContext(t, "user2", "annex-template-render-test"+objectFormat.Name(), auth_model.AccessTokenScopeWriteRepository) + + // create a public repo + require.NoError(t, doCreateRemoteAnnexRepository(t, u, ctx, false, objectFormat)) + + session := loginUser(t, ctx.Username) + + t.Run("Index", func(t *testing.T) { + // test that annexed files render with the binary file icon on the main list + defer tests.PrintCurrentTest(t)() + + repoLink := path.Join("/", ctx.Username, ctx.Reponame) + req := NewRequest(t, "GET", repoLink) + resp := session.MakeRequest(t, req, http.StatusOK) + + htmlDoc := NewHTMLParser(t, resp.Body) + isFileBinaryIconLocked := htmlDoc.Find("tr[data-entryname='annexed.tiff'] > td.name svg").HasClass("octicon-file-binary") + require.True(t, isFileBinaryIconLocked, "locked annexed files should render a binary file icon") + isFileBinaryIconUnlocked := htmlDoc.Find("tr[data-entryname='annexed.bin'] > td.name svg").HasClass("octicon-file-binary") + require.True(t, isFileBinaryIconUnlocked, "unlocked annexed files should render a binary file icon") + }) + + t.Run("View", func(t *testing.T) { + // test how routers/web/repo/view.go + templates/repo/view_file.tmpl handle annexed files + defer tests.PrintCurrentTest(t)() + + doViewTest := func(file string) (htmlDoc *HTMLDoc, viewLink, mediaLink string) { + viewLink = path.Join("/", ctx.Username, ctx.Reponame, "/src/branch/master", file) + // rawLink := strings.Replace(viewLink, "/src/", "/raw/", 1) // TODO: do something with this? + mediaLink = strings.Replace(viewLink, "/src/", "/media/", 1) + + req := NewRequest(t, "GET", viewLink) + resp := session.MakeRequest(t, req, http.StatusOK) + + htmlDoc = NewHTMLParser(t, resp.Body) + // the first button on the toolbar on the view template is the "Raw" button + // this CSS selector is the most precise I can think to use + buttonLink, exists := htmlDoc.Find(".file-header").Find("a[download]").Attr("href") + require.True(t, exists, "Download button should exist on the file header") + require.EqualValues(t, mediaLink, buttonLink, "Download link should use /media URL for annex files") + + return htmlDoc, viewLink, mediaLink + } + + t.Run("Binary", func(t *testing.T) { + // test that annexing a file renders the /media link in /src and NOT the /raw link + defer tests.PrintCurrentTest(t)() + + doBinaryViewTest := func(file string) { + htmlDoc, _, mediaLink := doViewTest(file) + + rawLink, exists := htmlDoc.Find("div.file-view > div.view-raw > a").Attr("href") + require.True(t, exists, "Download link should render instead of content because this is a binary file") + require.EqualValues(t, mediaLink, rawLink) + } + + t.Run("AnnexSymlink", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + doBinaryViewTest("annexed.tiff") + }) + t.Run("AnnexPointer", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + doBinaryViewTest("annexed.bin") + }) + }) + + t.Run("Text", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + doTextViewTest := func(file string) { + htmlDoc, _, _ := doViewTest(file) + require.True(t, htmlDoc.Find("div.file-view").Is(".code-view"), "should render as code") + } + + t.Run("AnnexSymlink", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + doTextViewTest("annexed.txt") + + t.Run("Markdown", func(t *testing.T) { + // special case: check that markdown can be pulled out of the annex and rendered, too + defer tests.PrintCurrentTest(t)() + htmlDoc, _, _ := doViewTest("annexed.md") + require.True(t, htmlDoc.Find("div.file-view").Is(".markdown"), "should render as markdown") + }) + }) + t.Run("AnnexPointer", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + doTextViewTest("annexed.rst") + + t.Run("Markdown", func(t *testing.T) { + // special case: check that markdown can be pulled out of the annex and rendered, too + defer tests.PrintCurrentTest(t)() + htmlDoc, _, _ := doViewTest("annexed.markdown") + require.True(t, htmlDoc.Find("div.file-view").Is(".markdown"), "should render as markdown") + }) + }) + }) + }) + }) + }) +} + +/* +Test that permissions are enforced on git-annex-shell commands. + + Along the way, this also tests that uploading, downloading, and deleting all work, + so we haven't written separate tests for those. +*/ +func TestGitAnnexPermissions(t *testing.T) { + if !setting.Annex.Enabled { + t.Skip("Skipping since annex support is disabled.") + } + + // Each case below is split so that 'clone' is done as + // the repo owner, but 'copy' as the user under test. + // + // Otherwise, in cases where permissions block the + // initial 'clone', the test would simply end there + // and never verify if permissions apply properly to + // 'annex copy' -- potentially leaving a security gap. + + onGiteaRun(t, func(t *testing.T, u *url.URL) { + // Tell git-annex to allow http://127.0.0.1, http://localhost and http://::1. Without + // this, all `git annex` commands will silently fail when run against http:// remotes + // without explaining what's wrong. + // + // Note: onGiteaRun() sets up an alternate HOME so this actually edits + // tests/integration/gitea-integration-*/data/home/.gitconfig and + // if you're debugging you need to remember to match that. + _, _, err := git.NewCommandContextNoGlobals(git.DefaultContext, "config").AddOptionValues("--global").AddArguments("annex.security.allowed-ip-addresses", "all").RunStdString(&git.RunOpts{}) + require.NoError(t, err) + + forEachObjectFormat(t, func(t *testing.T, objectFormat git.ObjectFormat) { + t.Run("Public", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + ownerCtx := NewAPITestContext(t, "user2", "annex-public"+objectFormat.Name(), auth_model.AccessTokenScopeWriteRepository) + + // create a public repo + require.NoError(t, doCreateRemoteAnnexRepository(t, u, ownerCtx, false, objectFormat)) + + // double-check it's public + repo, err := repo_model.GetRepositoryByOwnerAndName(db.DefaultContext, ownerCtx.Username, ownerCtx.Reponame) + require.NoError(t, err) + require.False(t, repo.IsPrivate) + + remoteRepoPath := path.Join(setting.RepoRootPath, ownerCtx.GitPath()) // path on disk -- which can be examined directly because we're testing from localhost + + // Different sessions, so we can test different permissions. + // We leave Reponame blank because we don't actually then later add it according to each case if needed + // + // NB: these usernames need to match appropriate entries in models/fixtures/user.yml + writerCtx := NewAPITestContext(t, "user5", "", auth_model.AccessTokenScopeWriteRepository) + readerCtx := NewAPITestContext(t, "user4", "", auth_model.AccessTokenScopeReadRepository) + outsiderCtx := NewAPITestContext(t, "user8", "", auth_model.AccessTokenScopeReadRepository) // a user with no specific access + + // set up collaborators + doAPIAddCollaborator(ownerCtx, readerCtx.Username, perm.AccessModeRead)(t) + doAPIAddCollaborator(ownerCtx, writerCtx.Username, perm.AccessModeWrite)(t) + + // tests + t.Run("Owner", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + t.Run("SSH", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + repoURL := createSSHUrl(ownerCtx.GitPath(), u) + + repoPath := path.Join(t.TempDir(), ownerCtx.Reponame) + defer util.RemoveAll(repoPath) // cleans out git-annex lockdown permissions + + withAnnexCtxKeyFile(t, ownerCtx, func() { + doGitClone(repoPath, repoURL)(t) + }) + + t.Run("Init", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + withAnnexCtxKeyFile(t, ownerCtx, func() { + require.NoError(t, doAnnexInitTest(remoteRepoPath, repoPath)) + }) + }) + + t.Run("Download", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + withAnnexCtxKeyFile(t, ownerCtx, func() { + require.NoError(t, doAnnexDownloadTest(remoteRepoPath, repoPath)) + }) + }) + + t.Run("LocalDrop", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + withAnnexCtxKeyFile(t, ownerCtx, func() { + require.NoError(t, doAnnexLocalDropTest(repoPath)) + }) + }) + + t.Run("Download", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + withAnnexCtxKeyFile(t, ownerCtx, func() { + require.NoError(t, doAnnexDownloadTest(remoteRepoPath, repoPath)) + }) + }) + + t.Run("RemoteDrop", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + withAnnexCtxKeyFile(t, ownerCtx, func() { + require.NoError(t, doAnnexRemoteDropTest(remoteRepoPath, repoPath)) + }) + }) + + t.Run("Upload", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + withAnnexCtxKeyFile(t, ownerCtx, func() { + require.NoError(t, doAnnexUploadTest(remoteRepoPath, repoPath)) + }) + }) + + t.Run("TestremoteReadOnly", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + withAnnexCtxKeyFile(t, ownerCtx, func() { + require.NoError(t, doAnnexTestremoteReadOnlyTest(repoPath)) + }) + }) + + t.Run("TestremoteReadWrite", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + withAnnexCtxKeyFile(t, ownerCtx, func() { + require.NoError(t, doAnnexTestremoteReadWriteTest(repoPath)) + }) + }) + }) + + t.Run("HTTP", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + repoURL := createHTTPUrl(ownerCtx.GitPath(), u) + + repoPath := path.Join(t.TempDir(), ownerCtx.Reponame) + defer util.RemoveAll(repoPath) // cleans out git-annex lockdown permissions + + withAnnexCtxHTTPPassword(t, u, ownerCtx, func() { + doGitClone(repoPath, repoURL)(t) + }) + + t.Run("Init", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + withAnnexCtxHTTPPassword(t, u, ownerCtx, func() { + require.NoError(t, doAnnexInitTest(remoteRepoPath, repoPath)) + }) + }) + + // Unset annexurl so that git-annex uses the dumb http support + _, _, err := git.NewCommand(git.DefaultContext, "config", "--unset", "remote.origin.annexurl").RunStdString(&git.RunOpts{Dir: repoPath}) + require.NoError(t, err) + + t.Run("Download", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + withAnnexCtxHTTPPassword(t, u, ownerCtx, func() { + require.NoError(t, doAnnexDownloadTest(remoteRepoPath, repoPath)) + }) + }) + + t.Run("LocalDrop", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + withAnnexCtxHTTPPassword(t, u, ownerCtx, func() { + require.NoError(t, doAnnexLocalDropTest(repoPath)) + }) + }) + + t.Run("Download", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + withAnnexCtxHTTPPassword(t, u, ownerCtx, func() { + require.NoError(t, doAnnexDownloadTest(remoteRepoPath, repoPath)) + }) + }) + + t.Run("RemoteDrop", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + withAnnexCtxHTTPPassword(t, u, ownerCtx, func() { + require.Error(t, doAnnexRemoteDropTest(remoteRepoPath, repoPath)) + }) + }) + + t.Run("Upload", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + withAnnexCtxHTTPPassword(t, u, ownerCtx, func() { + require.Error(t, doAnnexUploadTest(remoteRepoPath, repoPath)) + }) + }) + + t.Run("TestremoteReadOnly", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + withAnnexCtxHTTPPassword(t, u, ownerCtx, func() { + require.NoError(t, doAnnexTestremoteReadOnlyTest(repoPath)) + }) + }) + + t.Run("TestremoteReadWrite", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + withAnnexCtxHTTPPassword(t, u, ownerCtx, func() { + require.Error(t, doAnnexTestremoteReadWriteTest(repoPath)) + }) + }) + }) + + t.Run("P2PHTTP", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + repoURL := createHTTPUrl(ownerCtx.GitPath(), u) + + repoPath := path.Join(t.TempDir(), ownerCtx.Reponame) + defer util.RemoveAll(repoPath) // cleans out git-annex lockdown permissions + + withAnnexCtxHTTPPassword(t, u, ownerCtx, func() { + doGitClone(repoPath, repoURL)(t) + }) + + t.Run("Init", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + withAnnexCtxHTTPPassword(t, u, ownerCtx, func() { + require.NoError(t, doAnnexInitTest(remoteRepoPath, repoPath)) + }) + }) + + t.Run("Download", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + withAnnexCtxHTTPPassword(t, u, ownerCtx, func() { + require.NoError(t, doAnnexDownloadTest(remoteRepoPath, repoPath)) + }) + }) + + t.Run("LocalDrop", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + withAnnexCtxHTTPPassword(t, u, ownerCtx, func() { + require.NoError(t, doAnnexLocalDropTest(repoPath)) + }) + }) + + t.Run("Download", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + withAnnexCtxHTTPPassword(t, u, ownerCtx, func() { + require.NoError(t, doAnnexDownloadTest(remoteRepoPath, repoPath)) + }) + }) + + t.Run("RemoteDrop", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + withAnnexCtxHTTPPassword(t, u, ownerCtx, func() { + require.NoError(t, doAnnexRemoteDropTest(remoteRepoPath, repoPath)) + }) + }) + + t.Run("Upload", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + withAnnexCtxHTTPPassword(t, u, ownerCtx, func() { + require.NoError(t, doAnnexUploadTest(remoteRepoPath, repoPath)) + }) + }) + + t.Run("TestremoteReadOnly", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + withAnnexCtxHTTPPassword(t, u, ownerCtx, func() { + require.NoError(t, doAnnexTestremoteReadOnlyTest(repoPath)) + }) + }) + + t.Run("TestremoteReadWrite", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + withAnnexCtxHTTPPassword(t, u, ownerCtx, func() { + require.NoError(t, doAnnexTestremoteReadWriteTest(repoPath)) + }) + }) + }) + }) + + t.Run("Writer", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + t.Run("SSH", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + repoURL := createSSHUrl(ownerCtx.GitPath(), u) + + repoPath := path.Join(t.TempDir(), ownerCtx.Reponame) + defer util.RemoveAll(repoPath) // cleans out git-annex lockdown permissions + + withAnnexCtxKeyFile(t, ownerCtx, func() { + doGitClone(repoPath, repoURL)(t) + }) + + t.Run("Init", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + withAnnexCtxKeyFile(t, writerCtx, func() { + require.NoError(t, doAnnexInitTest(remoteRepoPath, repoPath)) + }) + }) + + t.Run("Download", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + withAnnexCtxKeyFile(t, writerCtx, func() { + require.NoError(t, doAnnexDownloadTest(remoteRepoPath, repoPath)) + }) + }) + + t.Run("LocalDrop", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + withAnnexCtxKeyFile(t, writerCtx, func() { + require.NoError(t, doAnnexLocalDropTest(repoPath)) + }) + }) + + t.Run("Download", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + withAnnexCtxKeyFile(t, writerCtx, func() { + require.NoError(t, doAnnexDownloadTest(remoteRepoPath, repoPath)) + }) + }) + + t.Run("RemoteDrop", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + withAnnexCtxKeyFile(t, writerCtx, func() { + require.NoError(t, doAnnexRemoteDropTest(remoteRepoPath, repoPath)) + }) + }) + + t.Run("Upload", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + withAnnexCtxKeyFile(t, writerCtx, func() { + require.NoError(t, doAnnexUploadTest(remoteRepoPath, repoPath)) + }) + }) + + t.Run("TestremoteReadOnly", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + withAnnexCtxKeyFile(t, writerCtx, func() { + require.NoError(t, doAnnexTestremoteReadOnlyTest(repoPath)) + }) + }) + + t.Run("TestremoteReadWrite", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + withAnnexCtxKeyFile(t, writerCtx, func() { + require.NoError(t, doAnnexTestremoteReadWriteTest(repoPath)) + }) + }) + }) + + t.Run("HTTP", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + repoURL := createHTTPUrl(ownerCtx.GitPath(), u) + + repoPath := path.Join(t.TempDir(), ownerCtx.Reponame) + defer util.RemoveAll(repoPath) // cleans out git-annex lockdown permissions + + withAnnexCtxHTTPPassword(t, u, ownerCtx, func() { + doGitClone(repoPath, repoURL)(t) + }) + + t.Run("Init", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + withAnnexCtxHTTPPassword(t, u, writerCtx, func() { + require.NoError(t, doAnnexInitTest(remoteRepoPath, repoPath)) + }) + }) + + // Unset annexurl so that git-annex uses the dumb http support + _, _, err := git.NewCommand(git.DefaultContext, "config", "--unset", "remote.origin.annexurl").RunStdString(&git.RunOpts{Dir: repoPath}) + require.NoError(t, err) + + t.Run("Download", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + withAnnexCtxHTTPPassword(t, u, writerCtx, func() { + require.NoError(t, doAnnexDownloadTest(remoteRepoPath, repoPath)) + }) + }) + + t.Run("LocalDrop", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + withAnnexCtxHTTPPassword(t, u, writerCtx, func() { + require.NoError(t, doAnnexLocalDropTest(repoPath)) + }) + }) + + t.Run("Download", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + withAnnexCtxHTTPPassword(t, u, writerCtx, func() { + require.NoError(t, doAnnexDownloadTest(remoteRepoPath, repoPath)) + }) + }) + + t.Run("RemoteDrop", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + withAnnexCtxHTTPPassword(t, u, writerCtx, func() { + require.Error(t, doAnnexRemoteDropTest(remoteRepoPath, repoPath)) + }) + }) + + t.Run("Upload", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + withAnnexCtxHTTPPassword(t, u, writerCtx, func() { + require.Error(t, doAnnexUploadTest(remoteRepoPath, repoPath)) + }) + }) + + t.Run("TestremoteReadOnly", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + withAnnexCtxHTTPPassword(t, u, writerCtx, func() { + require.NoError(t, doAnnexTestremoteReadOnlyTest(repoPath)) + }) + }) + + t.Run("TestremoteReadWrite", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + withAnnexCtxHTTPPassword(t, u, writerCtx, func() { + require.Error(t, doAnnexTestremoteReadWriteTest(repoPath)) + }) + }) + }) + + t.Run("P2PHTTP", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + repoURL := createHTTPUrl(ownerCtx.GitPath(), u) + + repoPath := path.Join(t.TempDir(), ownerCtx.Reponame) + defer util.RemoveAll(repoPath) // cleans out git-annex lockdown permissions + + withAnnexCtxHTTPPassword(t, u, ownerCtx, func() { + doGitClone(repoPath, repoURL)(t) + }) + + t.Run("Init", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + withAnnexCtxHTTPPassword(t, u, writerCtx, func() { + require.NoError(t, doAnnexInitTest(remoteRepoPath, repoPath)) + }) + }) + + t.Run("Download", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + withAnnexCtxHTTPPassword(t, u, writerCtx, func() { + require.NoError(t, doAnnexDownloadTest(remoteRepoPath, repoPath)) + }) + }) + + t.Run("LocalDrop", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + withAnnexCtxHTTPPassword(t, u, writerCtx, func() { + require.NoError(t, doAnnexLocalDropTest(repoPath)) + }) + }) + + t.Run("Download", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + withAnnexCtxHTTPPassword(t, u, writerCtx, func() { + require.NoError(t, doAnnexDownloadTest(remoteRepoPath, repoPath)) + }) + }) + + t.Run("RemoteDrop", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + withAnnexCtxHTTPPassword(t, u, writerCtx, func() { + require.NoError(t, doAnnexRemoteDropTest(remoteRepoPath, repoPath)) + }) + }) + + t.Run("Upload", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + withAnnexCtxHTTPPassword(t, u, writerCtx, func() { + require.NoError(t, doAnnexUploadTest(remoteRepoPath, repoPath)) + }) + }) + + t.Run("TestremoteReadOnly", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + withAnnexCtxHTTPPassword(t, u, writerCtx, func() { + require.NoError(t, doAnnexTestremoteReadOnlyTest(repoPath)) + }) + }) + + t.Run("TestremoteReadWrite", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + withAnnexCtxHTTPPassword(t, u, writerCtx, func() { + require.NoError(t, doAnnexTestremoteReadWriteTest(repoPath)) + }) + }) + }) + }) + + t.Run("Reader", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + t.Run("SSH", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + repoURL := createSSHUrl(ownerCtx.GitPath(), u) + + repoPath := path.Join(t.TempDir(), ownerCtx.Reponame) + defer util.RemoveAll(repoPath) // cleans out git-annex lockdown permissions + + withAnnexCtxKeyFile(t, ownerCtx, func() { + doGitClone(repoPath, repoURL)(t) + }) + + t.Run("Init", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + withAnnexCtxKeyFile(t, readerCtx, func() { + require.NoError(t, doAnnexInitTest(remoteRepoPath, repoPath)) + }) + }) + + t.Run("Download", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + withAnnexCtxKeyFile(t, readerCtx, func() { + require.NoError(t, doAnnexDownloadTest(remoteRepoPath, repoPath)) + }) + }) + + t.Run("LocalDrop", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + withAnnexCtxKeyFile(t, readerCtx, func() { + require.NoError(t, doAnnexLocalDropTest(repoPath)) + }) + }) + + t.Run("Download", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + withAnnexCtxKeyFile(t, readerCtx, func() { + require.NoError(t, doAnnexDownloadTest(remoteRepoPath, repoPath)) + }) + }) + + t.Run("RemoteDrop", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + withAnnexCtxKeyFile(t, readerCtx, func() { + require.Error(t, doAnnexRemoteDropTest(remoteRepoPath, repoPath)) + }) + }) + + t.Run("Upload", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + withAnnexCtxKeyFile(t, readerCtx, func() { + require.Error(t, doAnnexUploadTest(remoteRepoPath, repoPath), "Uploading should fail due to permissions") + }) + }) + + t.Run("TestremoteReadOnly", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + withAnnexCtxKeyFile(t, readerCtx, func() { + require.NoError(t, doAnnexTestremoteReadOnlyTest(repoPath)) + }) + }) + + t.Run("TestremoteReadWrite", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + withAnnexCtxKeyFile(t, readerCtx, func() { + require.Error(t, doAnnexTestremoteReadWriteTest(repoPath)) + }) + }) + }) + + t.Run("HTTP", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + repoURL := createHTTPUrl(ownerCtx.GitPath(), u) + + repoPath := path.Join(t.TempDir(), ownerCtx.Reponame) + defer util.RemoveAll(repoPath) // cleans out git-annex lockdown permissions + + withAnnexCtxHTTPPassword(t, u, ownerCtx, func() { + doGitClone(repoPath, repoURL)(t) + }) + + t.Run("Init", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + withAnnexCtxHTTPPassword(t, u, readerCtx, func() { + require.NoError(t, doAnnexInitTest(remoteRepoPath, repoPath)) + }) + }) + + // Unset annexurl so that git-annex uses the dumb http support + _, _, err := git.NewCommand(git.DefaultContext, "config", "--unset", "remote.origin.annexurl").RunStdString(&git.RunOpts{Dir: repoPath}) + require.NoError(t, err) + + t.Run("Download", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + withAnnexCtxHTTPPassword(t, u, readerCtx, func() { + require.NoError(t, doAnnexDownloadTest(remoteRepoPath, repoPath)) + }) + }) + + t.Run("LocalDrop", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + withAnnexCtxHTTPPassword(t, u, readerCtx, func() { + require.NoError(t, doAnnexLocalDropTest(repoPath)) + }) + }) + + t.Run("Download", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + withAnnexCtxHTTPPassword(t, u, readerCtx, func() { + require.NoError(t, doAnnexDownloadTest(remoteRepoPath, repoPath)) + }) + }) + + t.Run("RemoteDrop", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + withAnnexCtxHTTPPassword(t, u, readerCtx, func() { + require.Error(t, doAnnexRemoteDropTest(remoteRepoPath, repoPath)) + }) + }) + + t.Run("Upload", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + withAnnexCtxHTTPPassword(t, u, readerCtx, func() { + require.Error(t, doAnnexUploadTest(remoteRepoPath, repoPath)) + }) + }) + + t.Run("TestremoteReadOnly", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + withAnnexCtxHTTPPassword(t, u, readerCtx, func() { + require.NoError(t, doAnnexTestremoteReadOnlyTest(repoPath)) + }) + }) + + t.Run("TestremoteReadWrite", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + withAnnexCtxHTTPPassword(t, u, readerCtx, func() { + require.Error(t, doAnnexTestremoteReadWriteTest(repoPath)) + }) + }) + }) + + t.Run("P2PHTTP", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + repoURL := createHTTPUrl(ownerCtx.GitPath(), u) + + repoPath := path.Join(t.TempDir(), ownerCtx.Reponame) + defer util.RemoveAll(repoPath) // cleans out git-annex lockdown permissions + + withAnnexCtxHTTPPassword(t, u, ownerCtx, func() { + doGitClone(repoPath, repoURL)(t) + }) + + t.Run("Init", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + withAnnexCtxHTTPPassword(t, u, readerCtx, func() { + require.NoError(t, doAnnexInitTest(remoteRepoPath, repoPath)) + }) + }) + + t.Run("Download", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + withAnnexCtxHTTPPassword(t, u, readerCtx, func() { + require.NoError(t, doAnnexDownloadTest(remoteRepoPath, repoPath)) + }) + }) + + t.Run("LocalDrop", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + withAnnexCtxHTTPPassword(t, u, readerCtx, func() { + require.NoError(t, doAnnexLocalDropTest(repoPath)) + }) + }) + + t.Run("Download", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + withAnnexCtxHTTPPassword(t, u, readerCtx, func() { + require.NoError(t, doAnnexDownloadTest(remoteRepoPath, repoPath)) + }) + }) + + t.Run("RemoteDrop", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + withAnnexCtxHTTPPassword(t, u, readerCtx, func() { + require.Error(t, doAnnexRemoteDropTest(remoteRepoPath, repoPath)) + }) + }) + + t.Run("Upload", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + withAnnexCtxHTTPPassword(t, u, readerCtx, func() { + require.Error(t, doAnnexUploadTest(remoteRepoPath, repoPath)) + }) + }) + + t.Run("TestremoteReadOnly", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + withAnnexCtxHTTPPassword(t, u, readerCtx, func() { + require.NoError(t, doAnnexTestremoteReadOnlyTest(repoPath)) + }) + }) + + t.Run("TestremoteReadWrite", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + withAnnexCtxHTTPPassword(t, u, readerCtx, func() { + require.Error(t, doAnnexTestremoteReadWriteTest(repoPath)) + }) + }) + }) + }) + + t.Run("Outsider", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + t.Run("SSH", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + repoURL := createSSHUrl(ownerCtx.GitPath(), u) + + repoPath := path.Join(t.TempDir(), ownerCtx.Reponame) + defer util.RemoveAll(repoPath) // cleans out git-annex lockdown permissions + + withAnnexCtxKeyFile(t, ownerCtx, func() { + doGitClone(repoPath, repoURL)(t) + }) + + t.Run("Init", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + withAnnexCtxKeyFile(t, outsiderCtx, func() { + require.NoError(t, doAnnexInitTest(remoteRepoPath, repoPath)) + }) + }) + + t.Run("Download", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + withAnnexCtxKeyFile(t, outsiderCtx, func() { + require.NoError(t, doAnnexDownloadTest(remoteRepoPath, repoPath)) + }) + }) + + t.Run("LocalDrop", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + withAnnexCtxKeyFile(t, outsiderCtx, func() { + require.NoError(t, doAnnexLocalDropTest(repoPath)) + }) + }) + + t.Run("Download", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + withAnnexCtxKeyFile(t, outsiderCtx, func() { + require.NoError(t, doAnnexDownloadTest(remoteRepoPath, repoPath)) + }) + }) + + t.Run("RemoteDrop", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + withAnnexCtxKeyFile(t, outsiderCtx, func() { + require.Error(t, doAnnexRemoteDropTest(remoteRepoPath, repoPath)) + }) + }) + + t.Run("Upload", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + withAnnexCtxKeyFile(t, outsiderCtx, func() { + require.Error(t, doAnnexUploadTest(remoteRepoPath, repoPath), "Uploading should fail due to permissions") + }) + }) + + t.Run("TestremoteReadOnly", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + withAnnexCtxKeyFile(t, outsiderCtx, func() { + require.NoError(t, doAnnexTestremoteReadOnlyTest(repoPath)) + }) + }) + + t.Run("TestremoteReadWrite", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + withAnnexCtxKeyFile(t, outsiderCtx, func() { + require.Error(t, doAnnexTestremoteReadWriteTest(repoPath)) + }) + }) + }) + + t.Run("HTTP", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + repoURL := createHTTPUrl(ownerCtx.GitPath(), u) + + repoPath := path.Join(t.TempDir(), ownerCtx.Reponame) + defer util.RemoveAll(repoPath) // cleans out git-annex lockdown permissions + + withAnnexCtxHTTPPassword(t, u, ownerCtx, func() { + doGitClone(repoPath, repoURL)(t) + }) + + t.Run("Init", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + withAnnexCtxHTTPPassword(t, u, outsiderCtx, func() { + require.NoError(t, doAnnexInitTest(remoteRepoPath, repoPath)) + }) + }) + + // Unset annexurl so that git-annex uses the dumb http support + _, _, err = git.NewCommand(git.DefaultContext, "config", "--unset", "remote.origin.annexurl").RunStdString(&git.RunOpts{Dir: repoPath}) + require.NoError(t, err) + + t.Run("Download", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + withAnnexCtxHTTPPassword(t, u, outsiderCtx, func() { + require.NoError(t, doAnnexDownloadTest(remoteRepoPath, repoPath)) + }) + }) + + t.Run("LocalDrop", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + withAnnexCtxHTTPPassword(t, u, outsiderCtx, func() { + require.NoError(t, doAnnexLocalDropTest(repoPath)) + }) + }) + + t.Run("Download", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + withAnnexCtxHTTPPassword(t, u, outsiderCtx, func() { + require.NoError(t, doAnnexDownloadTest(remoteRepoPath, repoPath)) + }) + }) + + t.Run("RemoteDrop", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + withAnnexCtxHTTPPassword(t, u, outsiderCtx, func() { + require.Error(t, doAnnexRemoteDropTest(remoteRepoPath, repoPath)) + }) + }) + + t.Run("Upload", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + withAnnexCtxHTTPPassword(t, u, outsiderCtx, func() { + require.Error(t, doAnnexUploadTest(remoteRepoPath, repoPath)) + }) + }) + + t.Run("TestremoteReadOnly", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + withAnnexCtxHTTPPassword(t, u, outsiderCtx, func() { + require.NoError(t, doAnnexTestremoteReadOnlyTest(repoPath)) + }) + }) + + t.Run("TestremoteReadWrite", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + withAnnexCtxHTTPPassword(t, u, outsiderCtx, func() { + require.Error(t, doAnnexTestremoteReadWriteTest(repoPath)) + }) + }) + }) + + t.Run("P2PHTTP", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + repoURL := createHTTPUrl(ownerCtx.GitPath(), u) + + repoPath := path.Join(t.TempDir(), ownerCtx.Reponame) + defer util.RemoveAll(repoPath) // cleans out git-annex lockdown permissions + + withAnnexCtxHTTPPassword(t, u, ownerCtx, func() { + doGitClone(repoPath, repoURL)(t) + }) + + t.Run("Init", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + withAnnexCtxHTTPPassword(t, u, outsiderCtx, func() { + require.NoError(t, doAnnexInitTest(remoteRepoPath, repoPath)) + }) + }) + + t.Run("Download", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + withAnnexCtxHTTPPassword(t, u, outsiderCtx, func() { + require.NoError(t, doAnnexDownloadTest(remoteRepoPath, repoPath)) + }) + }) + + t.Run("LocalDrop", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + withAnnexCtxHTTPPassword(t, u, outsiderCtx, func() { + require.NoError(t, doAnnexLocalDropTest(repoPath)) + }) + }) + + t.Run("Download", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + withAnnexCtxHTTPPassword(t, u, outsiderCtx, func() { + require.NoError(t, doAnnexDownloadTest(remoteRepoPath, repoPath)) + }) + }) + + t.Run("RemoteDrop", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + withAnnexCtxHTTPPassword(t, u, outsiderCtx, func() { + require.Error(t, doAnnexRemoteDropTest(remoteRepoPath, repoPath)) + }) + }) + + t.Run("Upload", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + withAnnexCtxHTTPPassword(t, u, outsiderCtx, func() { + require.Error(t, doAnnexUploadTest(remoteRepoPath, repoPath)) + }) + }) + + t.Run("TestremoteReadOnly", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + withAnnexCtxHTTPPassword(t, u, outsiderCtx, func() { + require.NoError(t, doAnnexTestremoteReadOnlyTest(repoPath)) + }) + }) + + t.Run("TestremoteReadWrite", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + withAnnexCtxHTTPPassword(t, u, outsiderCtx, func() { + require.Error(t, doAnnexTestremoteReadWriteTest(repoPath)) + }) + }) + }) + }) + + t.Run("Anonymous", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + // Only HTTP and P2PHTTP have an anonymous mode + t.Run("HTTP", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + repoURL := createHTTPUrl(ownerCtx.GitPath(), u) + + repoPath := path.Join(t.TempDir(), ownerCtx.Reponame) + defer util.RemoveAll(repoPath) // cleans out git-annex lockdown permissions + + withAnnexCtxHTTPPassword(t, u, ownerCtx, func() { + doGitClone(repoPath, repoURL)(t) + }) + + // unlike the other tests, at this step we *do not* define credentials: + + t.Run("Init", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + require.NoError(t, doAnnexInitTest(remoteRepoPath, repoPath)) + }) + + // Unset annexurl so that git-annex uses the dumb http support + _, _, err := git.NewCommand(git.DefaultContext, "config", "--unset", "remote.origin.annexurl").RunStdString(&git.RunOpts{Dir: repoPath}) + require.NoError(t, err) + + t.Run("Download", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + require.NoError(t, doAnnexDownloadTest(remoteRepoPath, repoPath)) + }) + + t.Run("LocalDrop", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + require.NoError(t, doAnnexLocalDropTest(repoPath)) + }) + + t.Run("Download", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + require.NoError(t, doAnnexDownloadTest(remoteRepoPath, repoPath)) + }) + + t.Run("RemoteDrop", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + require.Error(t, doAnnexRemoteDropTest(remoteRepoPath, repoPath)) + }) + + t.Run("Upload", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + require.Error(t, doAnnexUploadTest(remoteRepoPath, repoPath)) + }) + + t.Run("TestremoteReadOnly", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + require.NoError(t, doAnnexTestremoteReadOnlyTest(repoPath)) + }) + + t.Run("TestremoteReadWrite", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + require.Error(t, doAnnexTestremoteReadWriteTest(repoPath)) + }) + }) + + t.Run("P2PHTTP", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + repoURL := createHTTPUrl(ownerCtx.GitPath(), u) + + repoPath := path.Join(t.TempDir(), ownerCtx.Reponame) + defer util.RemoveAll(repoPath) // cleans out git-annex lockdown permissions + + withAnnexCtxHTTPPassword(t, u, ownerCtx, func() { + doGitClone(repoPath, repoURL)(t) + }) + + // unlike the other tests, at this step we *do not* define credentials: + + t.Run("Init", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + require.NoError(t, doAnnexInitTest(remoteRepoPath, repoPath)) + }) + + t.Run("Download", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + require.NoError(t, doAnnexDownloadTest(remoteRepoPath, repoPath)) + }) + + t.Run("LocalDrop", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + require.NoError(t, doAnnexLocalDropTest(repoPath)) + }) + + t.Run("Download", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + require.NoError(t, doAnnexDownloadTest(remoteRepoPath, repoPath)) + }) + + t.Run("RemoteDrop", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + require.Error(t, doAnnexRemoteDropTest(remoteRepoPath, repoPath)) + }) + + t.Run("Upload", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + require.Error(t, doAnnexUploadTest(remoteRepoPath, repoPath)) + }) + + t.Run("TestremoteReadOnly", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + require.NoError(t, doAnnexTestremoteReadOnlyTest(repoPath)) + }) + + t.Run("TestremoteReadWrite", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + require.Error(t, doAnnexTestremoteReadWriteTest(repoPath)) + }) + }) + }) + + t.Run("Delete", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + // Delete the repo, make sure it's fully gone + doAPIDeleteRepository(ownerCtx)(t) + _, statErr := os.Stat(remoteRepoPath) + require.True(t, os.IsNotExist(statErr), "Remote annex repo should be removed from disk") + }) + }) + + t.Run("Private", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + ownerCtx := NewAPITestContext(t, "user2", "annex-private"+objectFormat.Name(), auth_model.AccessTokenScopeWriteRepository) + + // create a private repo + require.NoError(t, doCreateRemoteAnnexRepository(t, u, ownerCtx, true, objectFormat)) + + // double-check it's private + repo, err := repo_model.GetRepositoryByOwnerAndName(db.DefaultContext, ownerCtx.Username, ownerCtx.Reponame) + require.NoError(t, err) + require.True(t, repo.IsPrivate) + + remoteRepoPath := path.Join(setting.RepoRootPath, ownerCtx.GitPath()) // path on disk -- which can be examined directly because we're testing from localhost + + // Different sessions, so we can test different permissions. + // We leave Reponame blank because we don't actually then later add it according to each case if needed + // + // NB: these usernames need to match appropriate entries in models/fixtures/user.yml + writerCtx := NewAPITestContext(t, "user5", "", auth_model.AccessTokenScopeWriteRepository) + readerCtx := NewAPITestContext(t, "user4", "", auth_model.AccessTokenScopeReadRepository) + outsiderCtx := NewAPITestContext(t, "user8", "", auth_model.AccessTokenScopeReadRepository) // a user with no specific access + // Note: there's also full anonymous access, which is only available for public HTTP repos; + // it should behave the same as 'outsider' but we (will) test it separately below anyway + + // set up collaborators + doAPIAddCollaborator(ownerCtx, readerCtx.Username, perm.AccessModeRead)(t) + doAPIAddCollaborator(ownerCtx, writerCtx.Username, perm.AccessModeWrite)(t) + + // tests + t.Run("Owner", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + t.Run("SSH", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + repoURL := createSSHUrl(ownerCtx.GitPath(), u) + + repoPath := path.Join(t.TempDir(), ownerCtx.Reponame) + defer util.RemoveAll(repoPath) // cleans out git-annex lockdown permissions + + withAnnexCtxKeyFile(t, ownerCtx, func() { + doGitClone(repoPath, repoURL)(t) + }) + + t.Run("Init", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + withAnnexCtxKeyFile(t, ownerCtx, func() { + require.NoError(t, doAnnexInitTest(remoteRepoPath, repoPath)) + }) + }) + + t.Run("Download", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + withAnnexCtxKeyFile(t, ownerCtx, func() { + require.NoError(t, doAnnexDownloadTest(remoteRepoPath, repoPath)) + }) + }) + + t.Run("LocalDrop", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + withAnnexCtxKeyFile(t, ownerCtx, func() { + require.NoError(t, doAnnexLocalDropTest(repoPath)) + }) + }) + + t.Run("Download", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + withAnnexCtxKeyFile(t, ownerCtx, func() { + require.NoError(t, doAnnexDownloadTest(remoteRepoPath, repoPath)) + }) + }) + + t.Run("RemoteDrop", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + withAnnexCtxKeyFile(t, ownerCtx, func() { + require.NoError(t, doAnnexRemoteDropTest(remoteRepoPath, repoPath)) + }) + }) + + t.Run("Upload", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + withAnnexCtxKeyFile(t, ownerCtx, func() { + require.NoError(t, doAnnexUploadTest(remoteRepoPath, repoPath)) + }) + }) + + t.Run("TestremoteReadOnly", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + withAnnexCtxKeyFile(t, ownerCtx, func() { + require.NoError(t, doAnnexTestremoteReadOnlyTest(repoPath)) + }) + }) + + t.Run("TestremoteReadWrite", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + withAnnexCtxKeyFile(t, ownerCtx, func() { + require.NoError(t, doAnnexTestremoteReadWriteTest(repoPath)) + }) + }) + }) + + t.Run("HTTP", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + repoURL := createHTTPUrl(ownerCtx.GitPath(), u) + + repoPath := path.Join(t.TempDir(), ownerCtx.Reponame) + defer util.RemoveAll(repoPath) // cleans out git-annex lockdown permissions + + withAnnexCtxHTTPPassword(t, u, ownerCtx, func() { + doGitClone(repoPath, repoURL)(t) + }) + + t.Run("Init", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + withAnnexCtxHTTPPassword(t, u, ownerCtx, func() { + require.NoError(t, doAnnexInitTest(remoteRepoPath, repoPath)) + }) + }) + + // Unset annexurl so that git-annex uses the dumb http support + _, _, err := git.NewCommand(git.DefaultContext, "config", "--unset", "remote.origin.annexurl").RunStdString(&git.RunOpts{Dir: repoPath}) + require.NoError(t, err) + + t.Run("Download", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + withAnnexCtxHTTPPassword(t, u, ownerCtx, func() { + require.NoError(t, doAnnexDownloadTest(remoteRepoPath, repoPath)) + }) + }) + + t.Run("LocalDrop", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + withAnnexCtxHTTPPassword(t, u, ownerCtx, func() { + require.NoError(t, doAnnexLocalDropTest(repoPath)) + }) + }) + + t.Run("Download", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + withAnnexCtxHTTPPassword(t, u, ownerCtx, func() { + require.NoError(t, doAnnexDownloadTest(remoteRepoPath, repoPath)) + }) + }) + + t.Run("RemoteDrop", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + withAnnexCtxHTTPPassword(t, u, ownerCtx, func() { + require.Error(t, doAnnexRemoteDropTest(remoteRepoPath, repoPath)) + }) + }) + + t.Run("Upload", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + withAnnexCtxHTTPPassword(t, u, ownerCtx, func() { + require.Error(t, doAnnexUploadTest(remoteRepoPath, repoPath)) + }) + }) + + t.Run("TestremoteReadOnly", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + withAnnexCtxHTTPPassword(t, u, ownerCtx, func() { + require.NoError(t, doAnnexTestremoteReadOnlyTest(repoPath)) + }) + }) + + t.Run("TestremoteReadWrite", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + withAnnexCtxHTTPPassword(t, u, ownerCtx, func() { + require.Error(t, doAnnexTestremoteReadWriteTest(repoPath)) + }) + }) + }) + + t.Run("P2PHTTP", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + repoURL := createHTTPUrl(ownerCtx.GitPath(), u) + + repoPath := path.Join(t.TempDir(), ownerCtx.Reponame) + defer util.RemoveAll(repoPath) // cleans out git-annex lockdown permissions + + withAnnexCtxHTTPPassword(t, u, ownerCtx, func() { + doGitClone(repoPath, repoURL)(t) + }) + + t.Run("Init", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + withAnnexCtxHTTPPassword(t, u, ownerCtx, func() { + require.NoError(t, doAnnexInitTest(remoteRepoPath, repoPath)) + }) + }) + + t.Run("Download", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + withAnnexCtxHTTPPassword(t, u, ownerCtx, func() { + require.NoError(t, doAnnexDownloadTest(remoteRepoPath, repoPath)) + }) + }) + + t.Run("LocalDrop", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + withAnnexCtxHTTPPassword(t, u, ownerCtx, func() { + require.NoError(t, doAnnexLocalDropTest(repoPath)) + }) + }) + + t.Run("Download", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + withAnnexCtxHTTPPassword(t, u, ownerCtx, func() { + require.NoError(t, doAnnexDownloadTest(remoteRepoPath, repoPath)) + }) + }) + + t.Run("RemoteDrop", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + withAnnexCtxHTTPPassword(t, u, ownerCtx, func() { + require.NoError(t, doAnnexRemoteDropTest(remoteRepoPath, repoPath)) + }) + }) + + t.Run("Upload", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + withAnnexCtxHTTPPassword(t, u, ownerCtx, func() { + require.NoError(t, doAnnexUploadTest(remoteRepoPath, repoPath)) + }) + }) + + t.Run("TestremoteReadOnly", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + withAnnexCtxHTTPPassword(t, u, ownerCtx, func() { + require.NoError(t, doAnnexTestremoteReadOnlyTest(repoPath)) + }) + }) + + t.Run("TestremoteReadWrite", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + withAnnexCtxHTTPPassword(t, u, ownerCtx, func() { + require.NoError(t, doAnnexTestremoteReadWriteTest(repoPath)) + }) + }) + }) + }) + + t.Run("Writer", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + t.Run("SSH", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + repoURL := createSSHUrl(ownerCtx.GitPath(), u) + + repoPath := path.Join(t.TempDir(), ownerCtx.Reponame) + defer util.RemoveAll(repoPath) // cleans out git-annex lockdown permissions + + withAnnexCtxKeyFile(t, ownerCtx, func() { + doGitClone(repoPath, repoURL)(t) + }) + + t.Run("Init", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + withAnnexCtxKeyFile(t, writerCtx, func() { + require.NoError(t, doAnnexInitTest(remoteRepoPath, repoPath)) + }) + }) + + t.Run("Download", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + withAnnexCtxKeyFile(t, writerCtx, func() { + require.NoError(t, doAnnexDownloadTest(remoteRepoPath, repoPath)) + }) + }) + + t.Run("LocalDrop", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + withAnnexCtxKeyFile(t, writerCtx, func() { + require.NoError(t, doAnnexLocalDropTest(repoPath)) + }) + }) + + t.Run("Download", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + withAnnexCtxKeyFile(t, writerCtx, func() { + require.NoError(t, doAnnexDownloadTest(remoteRepoPath, repoPath)) + }) + }) + + t.Run("RemoteDrop", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + withAnnexCtxKeyFile(t, writerCtx, func() { + require.NoError(t, doAnnexRemoteDropTest(remoteRepoPath, repoPath)) + }) + }) + + t.Run("Upload", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + withAnnexCtxKeyFile(t, writerCtx, func() { + require.NoError(t, doAnnexUploadTest(remoteRepoPath, repoPath)) + }) + }) + + t.Run("TestremoteReadOnly", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + withAnnexCtxKeyFile(t, writerCtx, func() { + require.NoError(t, doAnnexTestremoteReadOnlyTest(repoPath)) + }) + }) + + t.Run("TestremoteReadWrite", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + withAnnexCtxKeyFile(t, writerCtx, func() { + require.NoError(t, doAnnexTestremoteReadWriteTest(repoPath)) + }) + }) + }) + + t.Run("HTTP", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + repoURL := createHTTPUrl(ownerCtx.GitPath(), u) + + repoPath := path.Join(t.TempDir(), ownerCtx.Reponame) + defer util.RemoveAll(repoPath) // cleans out git-annex lockdown permissions + + withAnnexCtxHTTPPassword(t, u, ownerCtx, func() { + doGitClone(repoPath, repoURL)(t) + }) + + t.Run("Init", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + withAnnexCtxHTTPPassword(t, u, writerCtx, func() { + require.NoError(t, doAnnexInitTest(remoteRepoPath, repoPath)) + }) + }) + + // Unset annexurl so that git-annex uses the dumb http support + _, _, err := git.NewCommand(git.DefaultContext, "config", "--unset", "remote.origin.annexurl").RunStdString(&git.RunOpts{Dir: repoPath}) + require.NoError(t, err) + + t.Run("Download", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + withAnnexCtxHTTPPassword(t, u, writerCtx, func() { + require.NoError(t, doAnnexDownloadTest(remoteRepoPath, repoPath)) + }) + }) + + t.Run("LocalDrop", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + withAnnexCtxHTTPPassword(t, u, writerCtx, func() { + require.NoError(t, doAnnexLocalDropTest(repoPath)) + }) + }) + + t.Run("Download", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + withAnnexCtxHTTPPassword(t, u, writerCtx, func() { + require.NoError(t, doAnnexDownloadTest(remoteRepoPath, repoPath)) + }) + }) + + t.Run("RemoteDrop", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + withAnnexCtxHTTPPassword(t, u, writerCtx, func() { + require.Error(t, doAnnexRemoteDropTest(remoteRepoPath, repoPath)) + }) + }) + + t.Run("Upload", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + withAnnexCtxHTTPPassword(t, u, writerCtx, func() { + require.Error(t, doAnnexUploadTest(remoteRepoPath, repoPath)) + }) + }) + + t.Run("TestremoteReadOnly", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + withAnnexCtxHTTPPassword(t, u, writerCtx, func() { + require.NoError(t, doAnnexTestremoteReadOnlyTest(repoPath)) + }) + }) + + t.Run("TestremoteReadWrite", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + withAnnexCtxHTTPPassword(t, u, writerCtx, func() { + require.Error(t, doAnnexTestremoteReadWriteTest(repoPath)) + }) + }) + }) + + t.Run("P2PHTTP", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + repoURL := createHTTPUrl(ownerCtx.GitPath(), u) + + repoPath := path.Join(t.TempDir(), ownerCtx.Reponame) + defer util.RemoveAll(repoPath) // cleans out git-annex lockdown permissions + + withAnnexCtxHTTPPassword(t, u, ownerCtx, func() { + doGitClone(repoPath, repoURL)(t) + }) + + t.Run("Init", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + withAnnexCtxHTTPPassword(t, u, writerCtx, func() { + require.NoError(t, doAnnexInitTest(remoteRepoPath, repoPath)) + }) + }) + + t.Run("Download", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + withAnnexCtxHTTPPassword(t, u, writerCtx, func() { + require.NoError(t, doAnnexDownloadTest(remoteRepoPath, repoPath)) + }) + }) + + t.Run("LocalDrop", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + withAnnexCtxHTTPPassword(t, u, writerCtx, func() { + require.NoError(t, doAnnexLocalDropTest(repoPath)) + }) + }) + + t.Run("Download", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + withAnnexCtxHTTPPassword(t, u, writerCtx, func() { + require.NoError(t, doAnnexDownloadTest(remoteRepoPath, repoPath)) + }) + }) + + t.Run("RemoteDrop", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + withAnnexCtxHTTPPassword(t, u, writerCtx, func() { + require.NoError(t, doAnnexRemoteDropTest(remoteRepoPath, repoPath)) + }) + }) + + t.Run("Upload", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + withAnnexCtxHTTPPassword(t, u, writerCtx, func() { + require.NoError(t, doAnnexUploadTest(remoteRepoPath, repoPath)) + }) + }) + + t.Run("TestremoteReadOnly", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + withAnnexCtxHTTPPassword(t, u, writerCtx, func() { + require.NoError(t, doAnnexTestremoteReadOnlyTest(repoPath)) + }) + }) + + t.Run("TestremoteReadWrite", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + withAnnexCtxHTTPPassword(t, u, writerCtx, func() { + require.NoError(t, doAnnexTestremoteReadWriteTest(repoPath)) + }) + }) + }) + }) + + t.Run("Reader", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + t.Run("SSH", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + repoURL := createSSHUrl(ownerCtx.GitPath(), u) + + repoPath := path.Join(t.TempDir(), ownerCtx.Reponame) + defer util.RemoveAll(repoPath) // cleans out git-annex lockdown permissions + + withAnnexCtxKeyFile(t, ownerCtx, func() { + doGitClone(repoPath, repoURL)(t) + }) + + t.Run("Init", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + withAnnexCtxKeyFile(t, readerCtx, func() { + require.NoError(t, doAnnexInitTest(remoteRepoPath, repoPath)) + }) + }) + + t.Run("Download", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + withAnnexCtxKeyFile(t, readerCtx, func() { + require.NoError(t, doAnnexDownloadTest(remoteRepoPath, repoPath)) + }) + }) + + t.Run("LocalDrop", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + withAnnexCtxKeyFile(t, readerCtx, func() { + require.NoError(t, doAnnexLocalDropTest(repoPath)) + }) + }) + + t.Run("Download", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + withAnnexCtxKeyFile(t, readerCtx, func() { + require.NoError(t, doAnnexDownloadTest(remoteRepoPath, repoPath)) + }) + }) + + t.Run("RemoteDrop", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + withAnnexCtxKeyFile(t, readerCtx, func() { + require.Error(t, doAnnexRemoteDropTest(remoteRepoPath, repoPath)) + }) + }) + + t.Run("Upload", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + withAnnexCtxKeyFile(t, readerCtx, func() { + require.Error(t, doAnnexUploadTest(remoteRepoPath, repoPath), "Uploading should fail due to permissions") + }) + }) + + t.Run("TestremoteReadOnly", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + withAnnexCtxKeyFile(t, readerCtx, func() { + require.NoError(t, doAnnexTestremoteReadOnlyTest(repoPath)) + }) + }) + + t.Run("TestremoteReadWrite", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + withAnnexCtxKeyFile(t, readerCtx, func() { + require.Error(t, doAnnexTestremoteReadWriteTest(repoPath)) + }) + }) + }) + + t.Run("HTTP", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + repoURL := createHTTPUrl(ownerCtx.GitPath(), u) + + repoPath := path.Join(t.TempDir(), ownerCtx.Reponame) + defer util.RemoveAll(repoPath) // cleans out git-annex lockdown permissions + + withAnnexCtxHTTPPassword(t, u, ownerCtx, func() { + doGitClone(repoPath, repoURL)(t) + }) + + t.Run("Init", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + withAnnexCtxHTTPPassword(t, u, readerCtx, func() { + require.NoError(t, doAnnexInitTest(remoteRepoPath, repoPath)) + }) + }) + + // Unset annexurl so that git-annex uses the dumb http support + _, _, err := git.NewCommand(git.DefaultContext, "config", "--unset", "remote.origin.annexurl").RunStdString(&git.RunOpts{Dir: repoPath}) + require.NoError(t, err) + + t.Run("Download", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + withAnnexCtxHTTPPassword(t, u, readerCtx, func() { + require.NoError(t, doAnnexDownloadTest(remoteRepoPath, repoPath)) + }) + }) + + t.Run("LocalDrop", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + withAnnexCtxHTTPPassword(t, u, readerCtx, func() { + require.NoError(t, doAnnexLocalDropTest(repoPath)) + }) + }) + + t.Run("Download", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + withAnnexCtxHTTPPassword(t, u, readerCtx, func() { + require.NoError(t, doAnnexDownloadTest(remoteRepoPath, repoPath)) + }) + }) + + t.Run("RemoteDrop", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + withAnnexCtxHTTPPassword(t, u, readerCtx, func() { + require.Error(t, doAnnexRemoteDropTest(remoteRepoPath, repoPath)) + }) + }) + + t.Run("Upload", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + withAnnexCtxHTTPPassword(t, u, readerCtx, func() { + require.Error(t, doAnnexUploadTest(remoteRepoPath, repoPath)) + }) + }) + + t.Run("TestremoteReadOnly", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + withAnnexCtxHTTPPassword(t, u, readerCtx, func() { + require.NoError(t, doAnnexTestremoteReadOnlyTest(repoPath)) + }) + }) + + t.Run("TestremoteReadWrite", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + withAnnexCtxHTTPPassword(t, u, readerCtx, func() { + require.Error(t, doAnnexTestremoteReadWriteTest(repoPath)) + }) + }) + }) + + t.Run("P2PHTTP", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + repoURL := createHTTPUrl(ownerCtx.GitPath(), u) + + repoPath := path.Join(t.TempDir(), ownerCtx.Reponame) + defer util.RemoveAll(repoPath) // cleans out git-annex lockdown permissions + + withAnnexCtxHTTPPassword(t, u, ownerCtx, func() { + doGitClone(repoPath, repoURL)(t) + }) + + t.Run("Init", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + withAnnexCtxHTTPPassword(t, u, readerCtx, func() { + require.NoError(t, doAnnexInitTest(remoteRepoPath, repoPath)) + }) + }) + + t.Run("Download", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + withAnnexCtxHTTPPassword(t, u, readerCtx, func() { + require.NoError(t, doAnnexDownloadTest(remoteRepoPath, repoPath)) + }) + }) + + t.Run("LocalDrop", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + withAnnexCtxHTTPPassword(t, u, readerCtx, func() { + require.NoError(t, doAnnexLocalDropTest(repoPath)) + }) + }) + + t.Run("Download", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + withAnnexCtxHTTPPassword(t, u, readerCtx, func() { + require.NoError(t, doAnnexDownloadTest(remoteRepoPath, repoPath)) + }) + }) + + t.Run("RemoteDrop", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + withAnnexCtxHTTPPassword(t, u, readerCtx, func() { + require.Error(t, doAnnexRemoteDropTest(remoteRepoPath, repoPath)) + }) + }) + + t.Run("Upload", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + withAnnexCtxHTTPPassword(t, u, readerCtx, func() { + require.Error(t, doAnnexUploadTest(remoteRepoPath, repoPath)) + }) + }) + + t.Run("TestremoteReadOnly", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + withAnnexCtxHTTPPassword(t, u, readerCtx, func() { + require.NoError(t, doAnnexTestremoteReadOnlyTest(repoPath)) + }) + }) + + t.Run("TestremoteReadWrite", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + withAnnexCtxHTTPPassword(t, u, readerCtx, func() { + require.Error(t, doAnnexTestremoteReadWriteTest(repoPath)) + }) + }) + }) + + t.Run("Outsider", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + t.Run("SSH", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + repoURL := createSSHUrl(ownerCtx.GitPath(), u) + + repoPath := path.Join(t.TempDir(), ownerCtx.Reponame) + defer util.RemoveAll(repoPath) // cleans out git-annex lockdown permissions + + withAnnexCtxKeyFile(t, ownerCtx, func() { + doGitClone(repoPath, repoURL)(t) + }) + + t.Run("Init", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + withAnnexCtxKeyFile(t, outsiderCtx, func() { + require.Error(t, doAnnexInitTest(remoteRepoPath, repoPath), "annex init should fail due to permissions") + }) + }) + + t.Run("Download", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + withAnnexCtxKeyFile(t, outsiderCtx, func() { + require.Error(t, doAnnexDownloadTest(remoteRepoPath, repoPath), "annex copy --from should fail due to permissions") + }) + }) + + t.Run("LocalDrop", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + withAnnexCtxKeyFile(t, outsiderCtx, func() { + require.Error(t, doAnnexLocalDropTest(repoPath)) + }) + }) + + t.Run("Download", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + withAnnexCtxKeyFile(t, outsiderCtx, func() { + require.Error(t, doAnnexDownloadTest(remoteRepoPath, repoPath)) + }) + }) + + t.Run("RemoteDrop", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + withAnnexCtxKeyFile(t, outsiderCtx, func() { + require.Error(t, doAnnexRemoteDropTest(remoteRepoPath, repoPath)) + }) + }) + + t.Run("Upload", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + withAnnexCtxKeyFile(t, outsiderCtx, func() { + require.Error(t, doAnnexUploadTest(remoteRepoPath, repoPath), "annex copy --to should fail due to permissions") + }) + }) + + t.Run("TestremoteReadOnly", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + withAnnexCtxKeyFile(t, outsiderCtx, func() { + require.Error(t, doAnnexTestremoteReadOnlyTest(repoPath)) + }) + }) + + t.Run("TestremoteReadWrite", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + withAnnexCtxKeyFile(t, outsiderCtx, func() { + require.Error(t, doAnnexTestremoteReadWriteTest(repoPath)) + }) + }) + }) + + t.Run("HTTP", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + repoURL := createHTTPUrl(ownerCtx.GitPath(), u) + + repoPath := path.Join(t.TempDir(), ownerCtx.Reponame) + defer util.RemoveAll(repoPath) // cleans out git-annex lockdown permissions + + withAnnexCtxHTTPPassword(t, u, ownerCtx, func() { + doGitClone(repoPath, repoURL)(t) + }) + + t.Run("Init", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + withAnnexCtxHTTPPassword(t, u, outsiderCtx, func() { + require.Error(t, doAnnexInitTest(remoteRepoPath, repoPath)) + }) + }) + + // Try unsetting annexurl + _, _, err := git.NewCommand(git.DefaultContext, "config", "--unset", "remote.origin.annexurl").RunStdString(&git.RunOpts{Dir: repoPath}) + require.Error(t, err) + + t.Run("Download", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + withAnnexCtxHTTPPassword(t, u, outsiderCtx, func() { + require.Error(t, doAnnexDownloadTest(remoteRepoPath, repoPath)) + }) + }) + + t.Run("LocalDrop", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + withAnnexCtxHTTPPassword(t, u, outsiderCtx, func() { + require.Error(t, doAnnexLocalDropTest(repoPath)) + }) + }) + + t.Run("Download", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + withAnnexCtxHTTPPassword(t, u, outsiderCtx, func() { + require.Error(t, doAnnexDownloadTest(remoteRepoPath, repoPath)) + }) + }) + + t.Run("RemoteDrop", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + withAnnexCtxHTTPPassword(t, u, outsiderCtx, func() { + require.Error(t, doAnnexRemoteDropTest(remoteRepoPath, repoPath)) + }) + }) + + t.Run("Upload", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + withAnnexCtxHTTPPassword(t, u, outsiderCtx, func() { + require.Error(t, doAnnexUploadTest(remoteRepoPath, repoPath)) + }) + }) + + t.Run("TestremoteReadOnly", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + withAnnexCtxHTTPPassword(t, u, outsiderCtx, func() { + require.Error(t, doAnnexTestremoteReadOnlyTest(repoPath)) + }) + }) + + t.Run("TestremoteReadWrite", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + withAnnexCtxHTTPPassword(t, u, outsiderCtx, func() { + require.Error(t, doAnnexTestremoteReadWriteTest(repoPath)) + }) + }) + }) + + t.Run("P2PHTTP", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + repoURL := createHTTPUrl(ownerCtx.GitPath(), u) + + repoPath := path.Join(t.TempDir(), ownerCtx.Reponame) + defer util.RemoveAll(repoPath) // cleans out git-annex lockdown permissions + + withAnnexCtxHTTPPassword(t, u, ownerCtx, func() { + doGitClone(repoPath, repoURL)(t) + }) + + t.Run("Init", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + withAnnexCtxHTTPPassword(t, u, outsiderCtx, func() { + require.Error(t, doAnnexInitTest(remoteRepoPath, repoPath)) + }) + }) + + t.Run("Download", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + withAnnexCtxHTTPPassword(t, u, outsiderCtx, func() { + require.Error(t, doAnnexDownloadTest(remoteRepoPath, repoPath)) + }) + }) + + t.Run("LocalDrop", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + withAnnexCtxHTTPPassword(t, u, outsiderCtx, func() { + require.Error(t, doAnnexLocalDropTest(repoPath)) + }) + }) + + t.Run("Download", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + withAnnexCtxHTTPPassword(t, u, outsiderCtx, func() { + require.Error(t, doAnnexDownloadTest(remoteRepoPath, repoPath)) + }) + }) + + t.Run("RemoteDrop", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + withAnnexCtxHTTPPassword(t, u, outsiderCtx, func() { + require.Error(t, doAnnexRemoteDropTest(remoteRepoPath, repoPath)) + }) + }) + + t.Run("Upload", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + withAnnexCtxHTTPPassword(t, u, outsiderCtx, func() { + require.Error(t, doAnnexUploadTest(remoteRepoPath, repoPath)) + }) + }) + + t.Run("TestremoteReadOnly", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + withAnnexCtxHTTPPassword(t, u, outsiderCtx, func() { + require.Error(t, doAnnexTestremoteReadOnlyTest(repoPath)) + }) + }) + + t.Run("TestremoteReadWrite", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + withAnnexCtxHTTPPassword(t, u, outsiderCtx, func() { + require.Error(t, doAnnexTestremoteReadWriteTest(repoPath)) + }) + }) + }) + }) + }) + + t.Run("Anonymous", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + // Only HTTP and P2PHTTP have an anonymous mode + t.Run("HTTP", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + repoURL := createHTTPUrl(ownerCtx.GitPath(), u) + + repoPath := path.Join(t.TempDir(), ownerCtx.Reponame) + defer util.RemoveAll(repoPath) // cleans out git-annex lockdown permissions + + withAnnexCtxHTTPPassword(t, u, ownerCtx, func() { + doGitClone(repoPath, repoURL)(t) + }) + + // unlike the other tests, at this step we *do not* define credentials: + + t.Run("Init", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + require.Error(t, doAnnexInitTest(remoteRepoPath, repoPath)) + }) + + // Try unsetting annexurl + _, _, err := git.NewCommand(git.DefaultContext, "config", "--unset", "remote.origin.annexurl").RunStdString(&git.RunOpts{Dir: repoPath}) + require.Error(t, err) + + t.Run("Download", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + require.Error(t, doAnnexDownloadTest(remoteRepoPath, repoPath)) + }) + + t.Run("LocalDrop", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + require.Error(t, doAnnexLocalDropTest(repoPath)) + }) + + t.Run("Download", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + require.Error(t, doAnnexDownloadTest(remoteRepoPath, repoPath)) + }) + + t.Run("RemoteDrop", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + require.Error(t, doAnnexRemoteDropTest(remoteRepoPath, repoPath)) + }) + + t.Run("Upload", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + require.Error(t, doAnnexUploadTest(remoteRepoPath, repoPath)) + }) + + t.Run("TestremoteReadOnly", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + require.Error(t, doAnnexTestremoteReadOnlyTest(repoPath)) + }) + + t.Run("TestremoteReadWrite", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + require.Error(t, doAnnexTestremoteReadWriteTest(repoPath)) + }) + }) + + t.Run("P2PHTTP", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + repoURL := createHTTPUrl(ownerCtx.GitPath(), u) + + repoPath := path.Join(t.TempDir(), ownerCtx.Reponame) + defer util.RemoveAll(repoPath) // cleans out git-annex lockdown permissions + + withAnnexCtxHTTPPassword(t, u, ownerCtx, func() { + doGitClone(repoPath, repoURL)(t) + }) + + // unlike the other tests, at this step we *do not* define credentials: + + t.Run("Init", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + require.Error(t, doAnnexInitTest(remoteRepoPath, repoPath)) + }) + + t.Run("Download", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + require.Error(t, doAnnexDownloadTest(remoteRepoPath, repoPath)) + }) + + t.Run("LocalDrop", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + require.Error(t, doAnnexLocalDropTest(repoPath)) + }) + + t.Run("Download", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + require.Error(t, doAnnexDownloadTest(remoteRepoPath, repoPath)) + }) + + t.Run("RemoteDrop", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + require.Error(t, doAnnexRemoteDropTest(remoteRepoPath, repoPath)) + }) + + t.Run("Upload", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + require.Error(t, doAnnexUploadTest(remoteRepoPath, repoPath)) + }) + + t.Run("TestremoteReadOnly", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + require.Error(t, doAnnexTestremoteReadOnlyTest(repoPath)) + }) + + t.Run("TestremoteReadWrite", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + require.Error(t, doAnnexTestremoteReadWriteTest(repoPath)) + }) + }) + }) + + t.Run("Delete", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + // Delete the repo, make sure it's fully gone + doAPIDeleteRepository(ownerCtx)(t) + _, statErr := os.Stat(remoteRepoPath) + require.True(t, os.IsNotExist(statErr), "Remote annex repo should be removed from disk") + }) + }) + }) + }) +} + +/* +Test that 'git annex init' works. + + precondition: repoPath contains a pre-cloned repo set up by doInitAnnexRepository(). +*/ +func doAnnexInitTest(remoteRepoPath, repoPath string) (err error) { + _, _, err = git.NewCommandContextNoGlobals(git.DefaultContext, "annex", "init", "cloned-repo").RunStdString(&git.RunOpts{Dir: repoPath}) + if err != nil { + return fmt.Errorf("Couldn't `git annex init`: %w", err) + } + + // - method 0: 'git config remote.origin.annex-uuid'. + // Demonstrates that 'git annex init' successfully contacted + // the remote git-annex and was able to learn its ID number. + readAnnexUUID, _, err := git.NewCommandContextNoGlobals(git.DefaultContext, "config", "remote.origin.annex-uuid").RunStdString(&git.RunOpts{Dir: repoPath}) + if err != nil { + return fmt.Errorf("Couldn't read remote `git config remote.origin.annex-uuid`: %w", err) + } + readAnnexUUID = strings.TrimSpace(readAnnexUUID) + + match := regexp.MustCompile("^[0-9a-f]{8}-([0-9a-f]{4}-){3}[0-9a-f]{12}$").MatchString(readAnnexUUID) + if !match { + return fmt.Errorf("'git config remote.origin.annex-uuid' should have been able to download the remote's uuid; but instead read '%s'", readAnnexUUID) + } + + remoteAnnexUUID, _, err := git.NewCommandContextNoGlobals(git.DefaultContext, "config", "annex.uuid").RunStdString(&git.RunOpts{Dir: remoteRepoPath}) + if err != nil { + return fmt.Errorf("Couldn't read local `git config annex.uuid`: %w", err) + } + + remoteAnnexUUID = strings.TrimSpace(remoteAnnexUUID) + match = regexp.MustCompile("^[0-9a-f]{8}-([0-9a-f]{4}-){3}[0-9a-f]{12}$").MatchString(remoteAnnexUUID) + if !match { + return fmt.Errorf("'git annex init' should have been able to download the remote's uuid; but instead read '%s'", remoteAnnexUUID) + } + + if readAnnexUUID != remoteAnnexUUID { + return fmt.Errorf("'git annex init' should have read the expected annex UUID '%s', but instead got '%s'", remoteAnnexUUID, readAnnexUUID) + } + + // - method 1: 'git annex whereis'. + // Demonstrates that git-annex understands annexed files can be found in the remote annex. + annexWhereis, _, err := git.NewCommandContextNoGlobals(git.DefaultContext, "annex", "whereis", "annexed.bin").RunStdString(&git.RunOpts{Dir: repoPath}) + if err != nil { + return fmt.Errorf("Couldn't `git annex whereis`: %w", err) + } + // Note: this regex is unanchored because 'whereis' outputs multiple lines containing + // headers and 1+ remotes and we just want to find one of them. + match = regexp.MustCompile(regexp.QuoteMeta(remoteAnnexUUID) + " -- .* \\[origin\\]\n").MatchString(annexWhereis) + if !match { + return errors.New("'git annex whereis' should report files are known to be in [origin]") + } + + return nil +} + +func doAnnexTestremoteReadWriteTest(repoPath string) (err error) { + _, _, err = git.NewCommandContextNoGlobals(git.DefaultContext, "annex", "testremote", "origin").RunStdString(&git.RunOpts{Dir: repoPath}) + if err != nil { + return err + } + return nil +} + +func doAnnexTestremoteReadOnlyTest(repoPath string) (err error) { + _, _, err = git.NewCommandContextNoGlobals(git.DefaultContext, "annex", "testremote", "origin", "--test-readonly", "annexed.tiff").RunStdString(&git.RunOpts{Dir: repoPath}) + if err != nil { + return err + } + return nil +} + +func doAnnexDownloadTest(remoteRepoPath, repoPath string) (err error) { + // NB: this test does something slightly different if run separately from "doAnnexInitTest()": + // "git annex copy" will notice and run "git annex init", silently. + // This shouldn't change any results, but be aware in case it does. + + _, _, err = git.NewCommandContextNoGlobals(git.DefaultContext, "annex", "copy", "--from", "origin").RunStdString(&git.RunOpts{Dir: repoPath}) + if err != nil { + return err + } + + // verify the files downloaded + + cmp := func(filename string) error { + localObjectPath, err := contentLocation(repoPath, filename) + if err != nil { + return err + } + // localObjectPath := path.Join(repoPath, filename) // or, just compare against the checked-out file + + remoteObjectPath, err := contentLocation(remoteRepoPath, filename) + if err != nil { + return err + } + + match, err := tests.FileCmp(localObjectPath, remoteObjectPath, 0) + if err != nil { + return err + } + if !match { + return errors.New("Annexed files should be the same") + } + + return nil + } + + // this is the annex-symlink file + stat, err := os.Lstat(path.Join(repoPath, "annexed.tiff")) + if err != nil { + return fmt.Errorf("Lstat: %w", err) + } + if !((stat.Mode() & os.ModeSymlink) != 0) { + // this line is really just double-checking that the text fixture is set up correctly + return errors.New("*.tiff should be a symlink") + } + if err = cmp("annexed.tiff"); err != nil { + return err + } + + // this is the annex-pointer file + stat, err = os.Lstat(path.Join(repoPath, "annexed.bin")) + if err != nil { + return fmt.Errorf("Lstat: %w", err) + } + if !((stat.Mode() & os.ModeSymlink) == 0) { + // this line is really just double-checking that the text fixture is set up correctly + return errors.New("*.bin should not be a symlink") + } + err = cmp("annexed.bin") + + return err +} + +func doAnnexLocalDropTest(repoPath string) (err error) { + // This test assumes that files are present in repoPath, i.e. it is run after doAnnexDownloadTest. + // This test drops all files from the repository clone. + binPath, err := contentLocation(repoPath, "annexed.bin") + if err != nil { + return err + } + _, err = os.Stat(binPath) + if err != nil { + return err + } + tiffPath, err := contentLocation(repoPath, "annexed.tiff") + if err != nil { + return err + } + _, err = os.Stat(tiffPath) + if err != nil { + return err + } + _, _, err = git.NewCommandContextNoGlobals(git.DefaultContext, "annex", "drop").RunStdString(&git.RunOpts{Dir: repoPath}) + if err != nil { + return err + } + _, err = os.Stat(binPath) + if !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("annexed.bin wasn't dropped properly: %w", err) + } + _, err = os.Stat(tiffPath) + if !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("annexed.tiff wasn't dropped properly: %w", err) + } + return nil +} + +func doAnnexUploadTest(remoteRepoPath, repoPath string) (err error) { + // NB: this test does something slightly different if run separately from "Init": + // it first runs "git annex init" silently in the background. + // This shouldn't change any results, but be aware in case it does. + + err = generateRandomFile(1024*1024/4, path.Join(repoPath, "contribution.bin")) + if err != nil { + return err + } + + err = git.AddChanges(repoPath, false, ".") + if err != nil { + return err + } + + err = git.CommitChanges(repoPath, git.CommitChangesOptions{Message: "Annex another file"}) + if err != nil { + return err + } + + _, _, err = git.NewCommandContextNoGlobals(git.DefaultContext, "annex", "copy", "--to", "origin").RunStdString(&git.RunOpts{Dir: repoPath}) + if err != nil { + return err + } + + // verify the file was uploaded + blob, err := blobForFile(repoPath, "contribution.bin") + if err != nil { + return err + } + key, err := annex.LookupKey(blob) + if err != nil { + return err + } + localObjectPath, err := annex.ContentLocationFromKey(repoPath, key) + if err != nil { + return err + } + + remoteObjectPath, err := annex.ContentLocationFromKey(remoteRepoPath, key) + if err != nil { + return err + } + + match, err := tests.FileCmp(localObjectPath, remoteObjectPath, 0) + if err != nil { + return err + } + if !match { + return errors.New("Annexed files should be the same") + } + + return nil +} + +func doAnnexRemoteDropTest(remoteRepoPath, repoPath string) (err error) { + // This test assumes that files are present in repoPath, i.e. it is run after doAnnexDownloadTest. + // This test drops all files from the remote repository. + binPath, err := contentLocation(remoteRepoPath, "annexed.bin") + if err != nil { + return err + } + _, err = os.Stat(binPath) + if err != nil { + return err + } + tiffPath, err := contentLocation(remoteRepoPath, "annexed.tiff") + if err != nil { + return err + } + _, err = os.Stat(tiffPath) + if err != nil { + return err + } + _, _, err = git.NewCommandContextNoGlobals(git.DefaultContext, "annex", "drop", "--from", "origin").RunStdString(&git.RunOpts{Dir: repoPath}) + if err != nil { + return err + } + _, err = os.Stat(binPath) + if !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("annexed.bin wasn't dropped properly: %w", err) + } + _, err = os.Stat(tiffPath) + if !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("annexed.tiff wasn't dropped properly: %w", err) + } + return nil +} + +// ---- Helpers ---- + +func generateRandomFile(size int, path string) (err error) { + // Generate random file + + // XXX TODO: maybe this should not be random, but instead a predictable pattern, so that the test is deterministic + bufSize := 4 * 1024 + if bufSize > size { + bufSize = size + } + + buffer := make([]byte, bufSize) + + f, err := os.Create(path) + if err != nil { + return err + } + defer f.Close() + + written := 0 + for written < size { + n := size - written + if n > bufSize { + n = bufSize + } + _, err := rand.Read(buffer[:n]) + if err != nil { + return err + } + n, err = f.Write(buffer[:n]) + if err != nil { + return err + } + written += n + } + if err != nil { + return err + } + + return nil +} + +// ---- Annex-specific helpers ---- + +/* +Initialize a repo with some baseline annexed and non-annexed files. + + TODO: perhaps this generator could be replaced with a fixture (see + integrations/gitea-repositories-meta/ and models/fixtures/repository.yml). + However we reuse this template for -different- repos, so maybe not. +*/ +func doInitAnnexRepository(repoPath string) error { + // set up what files should be annexed + // in this case, all *.bin files will be annexed + // without this, git-annex's default config annexes every file larger than some number of megabytes + f, err := os.Create(path.Join(repoPath, ".gitattributes")) + if err != nil { + return err + } + defer f.Close() + + // set up git-annex to store certain filetypes via *annex* pointers + // (https://git-annex.branchable.com/internals/pointer_file/). + // but only when run via 'git add' (see git-annex-smudge(1)) + _, err = f.WriteString("* annex.largefiles=anything\n") + if err != nil { + return err + } + _, err = f.WriteString("*.bin filter=annex\n") + if err != nil { + return err + } + _, err = f.WriteString("*.rst filter=annex\n") + if err != nil { + return err + } + _, err = f.WriteString("*.markdown filter=annex\n") + if err != nil { + return err + } + f.Close() + + err = git.AddChanges(repoPath, false, ".") + if err != nil { + return err + } + err = git.CommitChanges(repoPath, git.CommitChangesOptions{Message: "Configure git-annex settings"}) + if err != nil { + return err + } + + // 'git annex init' + err = git.NewCommandContextNoGlobals(git.DefaultContext, "annex", "init", "test-repo").Run(&git.RunOpts{Dir: repoPath}) + if err != nil { + return err + } + + // add files to the annex, stored via annex symlinks + // // a binary file + err = generateRandomFile(1024*1024/4, path.Join(repoPath, "annexed.tiff")) + if err != nil { + return err + } + + // // a text file + err = os.WriteFile(path.Join(repoPath, "annexed.md"), []byte("Overview\n=====\n\n1. Profit\n2. ???\n3. Review Life Activations\n"), 0o777) + if err != nil { + return err + } + + // // a markdown file + err = os.WriteFile(path.Join(repoPath, "annexed.txt"), []byte("We're going to see the wizard\nThe wonderful\nMonkey of\nBoz\n"), 0o777) + if err != nil { + return err + } + + err = git.NewCommandContextNoGlobals(git.DefaultContext, "annex", "add", ".").Run(&git.RunOpts{Dir: repoPath}) + if err != nil { + return err + } + + // add files to the annex, stored via git-annex-smudge + // // a binary file + err = generateRandomFile(1024*1024/4, path.Join(repoPath, "annexed.bin")) + if err != nil { + return err + } + + // // a text file + err = os.WriteFile(path.Join(repoPath, "annexed.rst"), []byte("Title\n=====\n\n- this is to test annexing a text file\n- lists are fun\n"), 0o777) + if err != nil { + return err + } + + // // a markdown file + err = os.WriteFile(path.Join(repoPath, "annexed.markdown"), []byte("Overview\n=====\n\n1. Profit\n2. ???\n3. Review Life Activations\n"), 0o777) + if err != nil { + return err + } + + err = git.AddChanges(repoPath, false, ".") + if err != nil { + return err + } + + // save everything + err = git.CommitChanges(repoPath, git.CommitChangesOptions{Message: "Annex files"}) + if err != nil { + return err + } + + return nil +} + +/* +Initialize a remote repo with some baseline annexed and non-annexed files. +*/ +func doInitRemoteAnnexRepository(t *testing.T, repoURL *url.URL) error { + repoPath := path.Join(t.TempDir(), path.Base(repoURL.Path)) + // This clone is immediately thrown away, which + // helps force the tests to be end-to-end. + defer util.RemoveAll(repoPath) + + doGitClone(repoPath, repoURL)(t) // TODO: this call is the only reason for the testing.T; can it be removed? + + err := doInitAnnexRepository(repoPath) + if err != nil { + return err + } + + _, _, err = git.NewCommandContextNoGlobals(git.DefaultContext, "annex", "sync", "--content").RunStdString(&git.RunOpts{Dir: repoPath}) + if err != nil { + return err + } + + return nil +} + +func blobForFile(repoPath, file string) (*git.Blob, error) { + repo, err := git.OpenRepository(git.DefaultContext, repoPath) + if err != nil { + return nil, err + } + defer repo.Close() + + commitID, err := repo.GetRefCommitID("HEAD") // NB: to examine a *branch*, prefix with "refs/branch/", or call repo.GetBranchCommitID(); ditto for tags + if err != nil { + return nil, err + } + + commit, err := repo.GetCommit(commitID) + if err != nil { + return nil, err + } + + treeEntry, err := commit.GetTreeEntryByPath(file) + if err != nil { + return nil, err + } + + return treeEntry.Blob(), nil +} + +/* +Find the path in .git/annex/objects/ of the contents for a given annexed file. + + repoPath: the git repository to examine + file: the path (in the repo's current HEAD) of the annex pointer + + TODO: pass a parameter to allow examining non-HEAD branches +*/ +func contentLocation(repoPath, file string) (path string, err error) { + blob, err := blobForFile(repoPath, file) + if err != nil { + return "", err + } + return annex.ContentLocation(blob) +} + +/* like withKeyFile(), but automatically sets it the account given in ctx for use by git-annex */ +func withAnnexCtxKeyFile(t *testing.T, ctx APITestContext, callback func()) { + _gitAnnexUseGitSSH, gitAnnexUseGitSSHExists := os.LookupEnv("GIT_ANNEX_USE_GIT_SSH") + defer func() { + // reset + if gitAnnexUseGitSSHExists { + t.Setenv("GIT_ANNEX_USE_GIT_SSH", _gitAnnexUseGitSSH) + } + }() + + t.Setenv("GIT_ANNEX_USE_GIT_SSH", "1") // withKeyFile works by setting GIT_SSH_COMMAND, but git-annex only respects that if this is set + + withCtxKeyFile(t, ctx, callback) +} + +/* +Like withKeyFile(), but sets HTTP credentials instead of SSH credentials. + + It does this by temporarily arranging through `git config --global` + to use git-credential-store(1) with the password written to a tempfile. + + This is the only reliable way to pass HTTP credentials non-interactively + to git-annex. See https://git-annex.branchable.com/bugs/http_remotes_ignore_annex.web-options_--netrc/#comment-b5a299e9826b322f2d85c96d4929a430 + for joeyh's proclamation on the subject. + + This **is only effective** when used around git.NewCommandContextNoGlobals() calls. + git.NewCommand() disables credential.helper as a precaution (see modules/git/git.go). + + In contrast, the tests in git_test.go put the password in the remote's URL like + `git config remote.origin.url http://user2:password@localhost:3003/user2/repo-name.git`, + writing the password in repoPath+"/.git/config". That would be equally good, except + that git-annex ignores it! +*/ +func withAnnexCtxHTTPPassword(t *testing.T, u *url.URL, ctx APITestContext, callback func()) { + credentialedURL := *u + credentialedURL.User = url.UserPassword(ctx.Username, userPassword) // NB: all test users use the same password + + credentialedAnnexURL := *u + credentialedAnnexURL.Host = strings.ReplaceAll(credentialedAnnexURL.Host, "127.0.0.1", "localhost") + credentialedAnnexURL.Scheme = "annex+" + credentialedAnnexURL.Scheme + credentialedAnnexURL.Path += "git-annex-p2phttp" + credentialedAnnexURL.User = url.UserPassword(ctx.Username, userPassword) // NB: all test users use the same password + + creds := path.Join(t.TempDir(), "creds") + require.NoError(t, os.WriteFile(creds, []byte(credentialedURL.String()+"\n"+credentialedAnnexURL.String()+"\n"), 0o600)) + + originalCredentialHelper, _, err := git.NewCommandContextNoGlobals(git.DefaultContext, "config").AddOptionValues("--global", "credential.helper").RunStdString(&git.RunOpts{}) + if err != nil && !git.IsErrorExitCode(err, 1) { + // ignore the 'error' thrown when credential.helper is unset (when git config returns 1) + // but catch all others + require.NoError(t, err) + } + hasOriginalCredentialHelper := (err == nil) + + _, _, err = git.NewCommandContextNoGlobals(git.DefaultContext, "config").AddOptionValues("--global", "credential.helper", fmt.Sprintf("store --file=%s", creds)).RunStdString(&git.RunOpts{}) + require.NoError(t, err) + + defer (func() { + // reset + if hasOriginalCredentialHelper { + _, _, err = git.NewCommandContextNoGlobals(git.DefaultContext, "config").AddOptionValues("--global").AddArguments("credential.helper").AddDynamicArguments(originalCredentialHelper).RunStdString(&git.RunOpts{}) + } else { + _, _, err = git.NewCommandContextNoGlobals(git.DefaultContext, "config").AddOptionValues("--global").AddOptionValues("--unset").AddArguments("credential.helper").RunStdString(&git.RunOpts{}) + } + require.NoError(t, err) + })() + + callback() +} diff --git a/tests/integration/git_helper_for_declarative_test.go b/tests/integration/git_helper_for_declarative_test.go index 83d8177..575f01d 100644 --- a/tests/integration/git_helper_for_declarative_test.go +++ b/tests/integration/git_helper_for_declarative_test.go @@ -42,6 +42,28 @@ func withKeyFile(t *testing.T, keyname string, callback func(string)) { "ssh -o \"UserKnownHostsFile=/dev/null\" -o \"StrictHostKeyChecking=no\" -o \"IdentitiesOnly=yes\" -i \""+keyFile+"\" \"$@\""), 0o700) require.NoError(t, err) + // reset ssh wrapper afterwards + _gitSSH, gitSSHExists := os.LookupEnv("GIT_SSH") + defer func() { + if gitSSHExists { + os.Setenv("GIT_SSH", _gitSSH) + } + }() + + _gitSSHCommand, gitSSHCommandExists := os.LookupEnv("GIT_SSH_COMMAND") + defer func() { + if gitSSHCommandExists { + os.Setenv("GIT_SSH_COMMAND", _gitSSHCommand) + } + }() + + _gitSSHVariant, gitSSHVariantExists := os.LookupEnv("GIT_SSH_VARIANT") + defer func() { + if gitSSHVariantExists { + os.Setenv("GIT_SSH_VARIANT", _gitSSHVariant) + } + }() + // Setup ssh wrapper t.Setenv("GIT_SSH", path.Join(tmpDir, "ssh")) t.Setenv("GIT_SSH_COMMAND", @@ -51,6 +73,13 @@ func withKeyFile(t *testing.T, keyname string, callback func(string)) { callback(keyFile) } +func createHTTPUrl(gitPath string, u *url.URL) *url.URL { + // this assumes u contains the HTTP base URL that Gitea is running on + u2 := *u + u2.Path = gitPath + return &u2 +} + func createSSHUrl(gitPath string, u *url.URL) *url.URL { u2 := *u u2.Scheme = "ssh" diff --git a/tests/mysql.ini.tmpl b/tests/mysql.ini.tmpl index e15e799..b832026 100644 --- a/tests/mysql.ini.tmpl +++ b/tests/mysql.ini.tmpl @@ -97,6 +97,9 @@ DISABLE_QUERY_AUTH_TOKEN = true [lfs] PATH = tests/{{TEST_TYPE}}/gitea-{{TEST_TYPE}}-mysql/data/lfs +[annex] +ENABLED = true + [packages] ENABLED = true diff --git a/tests/pgsql.ini.tmpl b/tests/pgsql.ini.tmpl index 340531f..781508a 100644 --- a/tests/pgsql.ini.tmpl +++ b/tests/pgsql.ini.tmpl @@ -122,6 +122,9 @@ MINIO_LOCATION = us-east-1 MINIO_USE_SSL = false MINIO_CHECKSUM_ALGORITHM = md5 +[annex] +ENABLED = true + [packages] ENABLED = true diff --git a/tests/sqlite.ini.tmpl b/tests/sqlite.ini.tmpl index 277916a..231c0d1 100644 --- a/tests/sqlite.ini.tmpl +++ b/tests/sqlite.ini.tmpl @@ -102,6 +102,9 @@ JWT_SECRET = KZb_QLUd4fYVyxetjxC4eZkrBgWM2SndOOWDNtgUUko [lfs] PATH = tests/{{TEST_TYPE}}/gitea-{{TEST_TYPE}}-sqlite/data/lfs +[annex] +ENABLED = true + [packages] ENABLED = true diff --git a/tests/test_utils.go b/tests/test_utils.go index b3c03a3..6e9374d 100644 --- a/tests/test_utils.go +++ b/tests/test_utils.go @@ -5,9 +5,11 @@ package tests import ( + "bytes" "context" "database/sql" "fmt" + "io" "os" "path" "path/filepath" @@ -488,3 +490,80 @@ func CreateDeclarativeRepo(t *testing.T, owner *user_model.User, name string, en return CreateDeclarativeRepoWithOptions(t, owner, opts) } + +// Decide if two files have the same contents or not. +// chunkSize is the size of the blocks to scan by; pass 0 to get a sensible default. +// *Follows* symlinks. +// +// May return an error if something else goes wrong; in this case, you should ignore the value of 'same'. +// +// derived from https://stackoverflow.com/a/30038571 +// under CC-BY-SA-4.0 by several contributors +func FileCmp(file1, file2 string, chunkSize int) (same bool, err error) { + if chunkSize == 0 { + chunkSize = 4 * 1024 + } + + // shortcuts: check file metadata + stat1, err := os.Stat(file1) + if err != nil { + return false, err + } + + stat2, err := os.Stat(file2) + if err != nil { + return false, err + } + + // are inputs are literally the same file? + if os.SameFile(stat1, stat2) { + return true, nil + } + + // do inputs at least have the same size? + if stat1.Size() != stat2.Size() { + return false, nil + } + + // long way: compare contents + f1, err := os.Open(file1) + if err != nil { + return false, err + } + defer f1.Close() + + f2, err := os.Open(file2) + if err != nil { + return false, err + } + defer f2.Close() + + b1 := make([]byte, chunkSize) + b2 := make([]byte, chunkSize) + for { + n1, err1 := io.ReadFull(f1, b1) + n2, err2 := io.ReadFull(f2, b2) + + // https://pkg.go.dev/io#Reader + // > Callers should always process the n > 0 bytes returned + // > before considering the error err. Doing so correctly + // > handles I/O errors that happen after reading some bytes + // > and also both of the allowed EOF behaviors. + + if !bytes.Equal(b1[:n1], b2[:n2]) { + return false, nil + } + + if (err1 == io.EOF && err2 == io.EOF) || (err1 == io.ErrUnexpectedEOF && err2 == io.ErrUnexpectedEOF) { + return true, nil + } + + // some other error, like a dropped network connection or a bad transfer + if err1 != nil { + return false, err1 + } + if err2 != nil { + return false, err2 + } + } +} diff --git a/web_src/css/repo.css b/web_src/css/repo.css index e9cfc1d..651c69c 100644 --- a/web_src/css/repo.css +++ b/web_src/css/repo.css @@ -244,6 +244,7 @@ td .commit-summary { } .repository.file.list #repo-files-table tbody .svg.octicon-file, +.repository.file.list #repo-files-table tbody .svg.octicon-file-binary, .repository.file.list #repo-files-table tbody .svg.octicon-file-symlink-file, .repository.file.list #repo-files-table tbody .svg.octicon-file-directory-symlink { color: var(--color-secondary-dark-7); diff --git a/web_src/js/features/imagediff.js b/web_src/js/features/imagediff.js index d1b139f..52c9017 100644 --- a/web_src/js/features/imagediff.js +++ b/web_src/js/features/imagediff.js @@ -92,7 +92,17 @@ export function initImageDiff() { return loadElem(img, info.path); })); // only the first images is associated with $boundsInfo - if (!success) info.$boundsInfo.text('(image error)'); + if (!success) { + const blobContent = await GET(info.path.replace('/media/', '/raw/')).then((response) => response.text()); + if (blobContent.startsWith('.git/annex/objects')) { + for (const item of document.querySelectorAll('.image-diff .overflow-menu-items .item')) { + item.style.display = 'none'; + } + info.$boundsInfo[0].parentElement.textContent = 'annexed file is not present on the server'; + } else { + info.$boundsInfo.text('(image error)'); + } + } if (info.mime === 'image/svg+xml') { const resp = await GET(info.path); const text = await resp.text();