Adds Radicle destination (#413)

* Adds Radicle destination

This destination allows mirroring repositories into the storage of a local
[Radicle](https://radicle.network) node.

Requires `git` + Radicle Command Line Interface (CLI) tools installed,
i.e. `rad` and `git-remote-rad` binaries. See https://radicle.network/install .

The repository is initialized with `rad init` on the first run and pushed
through the `git-remote-rad` helper afterwards, so the refs are signed with
the key of the Radicle profile.

Because every backup run works on a fresh temporary clone, each run has to
find the Repository ID (RID) created for an upstream on an earlier run:
every mirror records its upstream URL in a "dev.gickup" extension of the
signed Radicle identity document `payload`, and the storage is scanned for a
repository whose recorded source matches and whose refs are signed by this
profile's key — comparable to the existence lookups the forge destinations
make against their APIs, just against local storage. (Local-first, For The Win!)

Unlike other forge destinations we cannot use go-git for pushing,
because Radicle repositories are only reachable through the `git-remote-rad`
remote helper, which go-git does not support. All storage-touching
operations therefore shell out to the system `git` and `rad` binaries, so
identity creation and signed-refs handling stay inside the Radicle tooling.

Signed-off-by: Yorgos Saslis <yorgos.work@proton.me>

* fixup! Adds Radicle destination

Signed-off-by: Yorgos Saslis <yorgos.work@proton.me>

* fixup! Adds Radicle destination

Signed-off-by: Yorgos Saslis <yorgos.work@proton.me>

---------

Signed-off-by: Yorgos Saslis <yorgos.work@proton.me>
Co-authored-by: Andreas Wachter <andreas.wachter@buddyspencer.monster>
This commit is contained in:
Yorgos Saslis
2026-07-29 15:41:13 +02:00
committed by GitHub
co-authored by Andreas Wachter
parent dfee42bf30
commit 66eaf7bd2c
8 changed files with 788 additions and 1 deletions
+3
View File
@@ -67,6 +67,9 @@ linters:
- linters: [gosec]
path: github/github_test.go
text: "G101:"
- linters: [gosec]
path: radicle/radicle_test.go
text: "G101:"
# Global exclusions for staticcheck style suggestions
- linters: [staticcheck]
text: "SA1019:"
+1
View File
@@ -52,6 +52,7 @@ You can clone/mirror repositories to:
- Gogs
- OneDev
- Sourcehut
- Radicle
- Local
- S3
+8
View File
@@ -294,6 +294,14 @@ destination:
bare: true # clone the repositories as bare
mirror: true # create mirror clones
lfs: true # clone lfs repos, !! ATTENTION !! git and git-lfs must be installed on the system!
radicle:
# rad reads the environment itself: RAD_HOME selects which radicle profile to
# mirror into (defaults to ~/.radicle), and RAD_PASSPHRASE unlocks its key if
# it has a passphrase. Export them before running gickup.
- force: true # if set to true, refs that diverged from upstream are overwritten (non-fast-forward updates)
prune: true # if set to true, refs that no longer exist upstream are deleted from the mirror
visibility: source # public, private or source, source follows the visibility of the source repository. default: source
issues: false # [COMING SOON] recreate the source repo's issues as radicle issues (the source must also have issues: true).
s3:
- endpoint: somewhere:9000 # whatever your s3 endpoint is
structured: true # checks repos out like hostersite/user|organization/repo
+31
View File
@@ -859,6 +859,37 @@
},
"additionalProperties": false
}
},
"radicle": {
"type": "array",
"description": "Mirror repositories into the storage of a local Radicle node. Requires the `git`, `rad` and `git-remote-rad` binaries.",
"items": {
"type": "object",
"additionalProperties": false,
"properties": {
"force": {
"type": "boolean",
"description": "overwrite refs on the mirror that have diverged from upstream (non-fast-forward updates)"
},
"prune": {
"type": "boolean",
"description": "delete refs on the mirror that no longer exist upstream"
},
"visibility": {
"type": "string",
"enum": [
"public",
"private",
"source"
],
"description": "visibility of the mirrored repository, `source` follows the source repository. default: source"
},
"issues": {
"type": "boolean",
"description": "[COMING SOON] recreate the source repository's issues."
}
}
}
}
}
}
+64
View File
@@ -28,6 +28,7 @@ import (
"github.com/cooperspencer/gickup/metrics/ntfy"
"github.com/cooperspencer/gickup/metrics/prometheus"
"github.com/cooperspencer/gickup/onedev"
"github.com/cooperspencer/gickup/radicle"
"github.com/cooperspencer/gickup/s3"
"github.com/cooperspencer/gickup/sourcehut"
"github.com/cooperspencer/gickup/types"
@@ -912,6 +913,69 @@ func backup(repos []types.Repo, conf *types.Conf) {
}
}
for _, d := range conf.Destination.Radicle {
repotime := time.Now()
status := 0
radhome, _ := radicle.Home()
log.Info().
Str("stage", "radicle").
Str("home", radhome).
Msgf("mirroring %s to %s", types.Blue(r.Name), radhome)
if !cli.Dry {
func() {
tempdir, err := os.MkdirTemp(os.TempDir(), fmt.Sprintf("radicle-%x", repotime))
if err != nil {
log.Error().
Str("stage", "tempclone").
Str("url", r.URL).
Msg(err.Error())
return
}
defer os.RemoveAll(tempdir)
_, err = local.TempClone(r, tempdir)
if err != nil {
if errors.Is(err, git.NoErrAlreadyUpToDate) {
log.Info().
Str("stage", "radicle").
Str("url", r.URL).
Msg(err.Error())
} else {
log.Error().
Str("stage", "tempclone").
Str("url", r.URL).
Str("git", "clone").
Msg(err.Error())
return
}
}
rid, err := radicle.Mirror(r, d, tempdir)
if err != nil {
log.Error().
Str("stage", "radicle").
Str("url", r.URL).
Msg(err.Error())
return
}
log.Info().
Str("stage", "radicle").
Str("rid", fmt.Sprintf("rad:%s", rid)).
Msgf("mirrored %s", types.Green(r.Name))
prometheus.RepoTime.WithLabelValues(r.Hoster, r.Name, r.Owner, "radicle", radhome).Set(time.Since(repotime).Seconds())
status = 1
}()
prometheus.RepoSuccess.WithLabelValues(r.Hoster, r.Name, r.Owner, "radicle", radhome).Set(float64(status))
prometheus.DestinationBackupsComplete.WithLabelValues("radicle").Inc()
}
}
prometheus.SourceBackupsComplete.WithLabelValues(r.Name).Inc()
}
}
+429
View File
@@ -0,0 +1,429 @@
// Package radicle mirrors repositories into the local storage of a Radicle
// node.
package radicle
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/url"
"os"
"os/exec"
"path/filepath"
"strings"
"github.com/cooperspencer/gickup/logger"
"github.com/cooperspencer/gickup/types"
"github.com/rs/zerolog"
)
var sub zerolog.Logger
// payloadCache memoizes the storage scan so scanStorage inspects the storage
// only once per RAD_HOME, instead of once per mirrored repository. It is
// populated on the first scan and kept in sync as initRepo creates mirrors,
// so this process never re-inspects storage it already knows.
var payloadCache = map[string]map[string]payload{}
// payload is the identity document `payload` section, as returned by `rad inspect --payload`.
type payload struct {
Project struct {
Name string `json:"name"`
Description string `json:"description"`
DefaultBranch string `json:"defaultBranch"`
} `json:"xyz.radicle.project"`
// Gickup is the provenance extension this package adds to the identity
// document of every mirror it initializes.
Gickup struct {
Source string `json:"source"`
} `json:"dev.gickup"`
}
// Mirror pushes the repository cloned at tempdir into the Radicle storage
// configured by d, initializing it with `rad init` on first sight. It returns
// the RID of the mirror.
func Mirror(repo types.Repo, dest types.Radicle, tempdir string) (string, error) {
home, _ := Home()
sub = logger.CreateSubLogger("stage", "radicle", "home", home)
if err := probe(); err != nil {
return "", err
}
// rad and git-remote-rad read RAD_HOME and RAD_PASSPHRASE from the
// environment themselves, so the ambient environment is all they need.
env := os.Environ()
nid, err := nodeID(env)
if err != nil {
return "", err
}
rid, err := scanStorage(repo, nid, env)
if err != nil {
return "", err
}
if rid == "" {
rid, err = initRepo(repo, dest, tempdir, env)
if err != nil {
return "", err
}
sub.Info().
Str("rid", fmt.Sprintf("rad:%s", rid)).
Msgf("initialized %s", types.Green(repo.Name))
} else if dest.Force || dest.Prune {
if err := fetchMirrorState(tempdir, rid, nid, env); err != nil {
return "", err
}
}
if err := push(dest, tempdir, rid, nid, env); err != nil {
return "", err
}
// Issues coming soon
if dest.Issues {
sub.Warn().Msg("Issue migration to Radicle not yet supported.")
}
return rid, nil
}
// probe checks that all binaries required for mirroring to Radicle are available.
func probe() error {
for _, bin := range []string{"git", "rad", "git-remote-rad"} {
if _, err := exec.LookPath(bin); err != nil {
return fmt.Errorf("%s is not installed", bin)
}
}
return nil
}
// fetchMirrorState fetches the current refs of the mirror into the temporary
// clone. When upstream history was rewritten or a branch was deleted, the old
// ref targets no longer exist in a fresh clone, and the remote helper then
// fails to re-sign rad/sigrefs after the ref update, leaving the signed refs
// pointing at the old state. Fetching first keeps the old objects resolvable
// so refs and signature update together. Pushes without force and prune are
// fast-forward only, where the old targets are always present, so the fetch
// is skipped.
func fetchMirrorState(tempdir, rid, nid string, env []string) error {
_, err := run(tempdir, env, "git", "fetch", "--no-tags",
fmt.Sprintf("rad://%s/%s", rid, nid),
"+refs/heads/*:refs/gickup/heads/*",
"+refs/tags/*:refs/gickup/tags/*")
return err
}
// Home returns the profile's Radicle home as reported by `rad self --home`.
// Propagates error accordingly, so it can be handled at call sites.
func Home() (string, error) {
return run("", os.Environ(), "rad", "self", "--home")
}
// run executes name with args in dir, returning trimmed stdout. Stderr is
// folded into the returned error.
func run(dir string, env []string, name string, args ...string) (string, error) {
cmd := exec.CommandContext(context.Background(), name, args...)
cmd.Dir = dir
cmd.Env = env
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
msg := strings.TrimSpace(stderr.String())
if msg == "" {
msg = strings.TrimSpace(stdout.String())
}
return "", fmt.Errorf("%s %s: %w: %s", name, strings.Join(args, " "), err, msg)
}
return strings.TrimSpace(stdout.String()), nil
}
// nodeID returns the node ID of the configured profile, which namespaces the
// push URL. `rad self --nid` is deprecated but still works without a running
// node, so it is used as the fallback.
func nodeID(env []string) (string, error) {
nid, err := run("", env, "rad", "node", "status", "--only", "nid")
if err != nil {
nid, err = run("", env, "rad", "self", "--nid")
if err != nil {
return "", err
}
}
if nid == "" {
return "", fmt.Errorf("couldn't determine the node id of the radicle profile")
}
return nid, nil
}
// marker returns the human-readable provenance note that initRepo appends to
// the mirror's description. Any credentials embedded in the URL are stripped, because
// the description becomes part of the signed identity payload that is
// replicated to the network.
func marker(upstream string) string {
return fmt.Sprintf("(mirror of %s)", stripCredentials(upstream))
}
// stripCredentials removes the userinfo part from a URL, so tokens or
// passwords configured inline (https://user:token@host/...) never leave the
// local configuration. Non-URL remotes (e.g. scp-style ssh) pass through. This
// is useful because the identity document, where this URL is stored, is signed
// and published to the network.
func stripCredentials(upstream string) string {
if !strings.Contains(upstream, "://") {
return upstream
}
parsed, err := url.Parse(upstream)
if err != nil || parsed.User == nil {
return upstream
}
parsed.User = nil
return parsed.String()
}
// scanStorage searches the Radicle storage for the repository that mirrors
// the upstream URL. A candidate must record the URL as its source in the
// dev.gickup `payload` of its identity document, and list this profile among
// that identity's delegates. The second check is what makes adoption
// unforgeable: the source claim lives in the identity document, whose contents
// are only authoritative because its delegates signed them.
func scanStorage(repo types.Repo, nid string, env []string) (string, error) {
inspected, err := payloadsFor(env)
if err != nil {
return "", err
}
matches := []string{}
source := stripCredentials(repo.URL)
for rid, p := range inspected {
if p.Gickup.Source == source && isDelegate(rid, nid, env) {
matches = append(matches, rid)
}
}
switch len(matches) {
case 0:
return "", nil
case 1:
return matches[0], nil
default:
return "", fmt.Errorf(
"found %d repositories in storage mirroring %s - remove the duplicates so only one remains",
len(matches), repo.URL)
}
}
// isDelegate reports whether this profile's key (nid) is a delegate of the
// stored repository's identity. We consider this enough evidence that the
// information we read from the identity doc can be trusted.
func isDelegate(rid, nid string, env []string) bool {
out, err := run("", env, "rad", "inspect", fmt.Sprintf("rad:%s", rid), "--delegates")
if err != nil {
return false
}
// Each line looks like "did:key:<nid> (alias)".
did := "did:key:" + nid
for _, line := range strings.Split(out, "\n") {
if fields := strings.Fields(line); len(fields) > 0 && fields[0] == did {
return true
}
}
return false
}
// payloadsFor returns the identity payloads of all repositories in the Radicle
// profile storage. The storage is inspected the first time a given RAD_HOME is asked
// about and the result is cached, so the repeated scanStorage calls across a
// run's repositories don't each re-inspect it.
func payloadsFor(env []string) (map[string]payload, error) {
home, err := Home()
if err != nil {
return nil, err
}
if cached, ok := payloadCache[home]; ok {
return cached, nil
}
entries, err := os.ReadDir(filepath.Join(home, "storage"))
if err != nil {
if os.IsNotExist(err) {
payloadCache[home] = map[string]payload{}
return payloadCache[home], nil
}
return nil, err
}
current := map[string]payload{}
for _, entry := range entries {
if !entry.IsDir() {
continue
}
out, err := run("", env, "rad", "inspect", fmt.Sprintf("rad:%s", entry.Name()), "--payload")
if err != nil {
sub.Debug().
Str("rid", fmt.Sprintf("rad:%s", entry.Name())).
Msg(err.Error())
continue
}
var p payload
if err := json.Unmarshal([]byte(out), &p); err != nil {
continue
}
current[entry.Name()] = p
}
payloadCache[home] = current
return current, nil
}
// initRepo runs `rad init` on the temporary clone, creating the repository
// identity in storage, and returns the new RID. The upstream URL is recorded
// in a dev.gickup extension of the identity document `payload`, which is what
// scanStorage matches on, on the next run.
func initRepo(repo types.Repo, dest types.Radicle, tempdir string, env []string) (string, error) {
branch, err := run(tempdir, env, "git", "symbolic-ref", "--short", "HEAD")
if err != nil {
return "", err
}
description := strings.TrimSpace(fmt.Sprintf("%s %s", repo.Description, marker(repo.URL)))
visibility, err := visibilityFlag(dest, repo)
if err != nil {
return "", err
}
_, err = run(tempdir, env, "rad", "init",
fmt.Sprintf("--name=%s", repo.Name),
fmt.Sprintf("--description=%s", description),
fmt.Sprintf("--default-branch=%s", branch),
visibility,
"--no-confirm")
if err != nil {
return "", err
}
rid, err := run(tempdir, env, "rad", ".")
if err != nil {
return "", err
}
rid = strings.TrimPrefix(rid, "rad:")
source, err := json.Marshal(stripCredentials(repo.URL))
if err != nil {
return rid, err
}
if err := recordMirrorSource(tempdir, env, source, rid); err != nil {
return rid, err
}
updatePayloadCache(repo, rid)
return rid, nil
}
// updatePayloadCache Remember the new mirror so a later run in this process finds it without
// re-inspecting storage — and never tries to initialize it a second time.
func updatePayloadCache(repo types.Repo, rid string) {
home, err := Home()
if err != nil {
return
}
if payloadCache[home] == nil {
payloadCache[home] = map[string]payload{}
}
var newPayload payload
newPayload.Gickup.Source = stripCredentials(repo.URL)
payloadCache[home][rid] = newPayload
}
// recordMirrorSource Captures the upstream source, so an existing mirror can be reused,
// avoiding duplicates. A failure here fails the overall
// backup, and the error names the repository because the storage offers
// no rollback for a created identity: the operator has to record the
// source or remove the repository by hand.
func recordMirrorSource(tempdir string, env []string, source []byte, rid string) error {
if _, err := run(tempdir, env, "rad", "id", "update",
"--payload", "dev.gickup", "source", string(source),
"--title", "Record mirror source",
"--no-confirm"); err != nil {
return fmt.Errorf(
"repository rad:%s was created but recording its mirror source failed - record dev.gickup.source manually or remove the repository: %w",
rid, err)
}
return nil
}
// visibilityFlag maps the configured visibility to the rad init flag.
func visibilityFlag(dest types.Radicle, repo types.Repo) (string, error) {
switch dest.Visibility {
case "public":
return "--public", nil
case "private":
return "--private", nil
case "", "source":
if repo.Private {
return "--private", nil
}
return "--public", nil
default:
return "", fmt.Errorf("invalid radicle visibility %s, must be public, private or source", dest.Visibility)
}
}
// pushArgs builds the git arguments that mirror all branches and tags into
// the profile's namespace of the stored repository.
func pushArgs(dest types.Radicle, rid, nid string) []string {
prefix := ""
if dest.Force {
prefix = "+"
}
args := []string{"push"}
if dest.Prune {
args = append(args, "--prune")
}
return append(args,
fmt.Sprintf("rad://%s/%s", rid, nid),
fmt.Sprintf("%srefs/heads/*:refs/heads/*", prefix),
fmt.Sprintf("%srefs/tags/*:refs/tags/*", prefix))
}
// push mirrors all branches and tags of the temporary clone into the profile's
// namespace of the stored repository. The remote helper updates and signs
// rad/sigrefs and announces to the node, so nothing else has to touch storage.
func push(dest types.Radicle, tempdir, rid, nid string, env []string) error {
_, err := run(tempdir, env, "git", pushArgs(dest, rid, nid)...)
return err
}
+237
View File
@@ -0,0 +1,237 @@
package radicle
import (
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"github.com/cooperspencer/gickup/types"
)
func TestHome(t *testing.T) {
// Home delegates to `rad self --home`; stub rad and check we return what
// it reports, verbatim.
bin := t.TempDir()
script := "#!/bin/sh\n[ \"$1 $2\" = \"self --home\" ] && echo /stub/radhome\n"
if err := os.WriteFile(filepath.Join(bin, "rad"), []byte(script), 0o755); err != nil {
t.Fatal(err)
}
t.Setenv("PATH", fmt.Sprintf("%s%c%s", bin, os.PathListSeparator, os.Getenv("PATH")))
home, err := Home()
if err != nil {
t.Fatalf("Home() error: %v", err)
}
if home != "/stub/radhome" {
t.Errorf("expected /stub/radhome from rad self --home, got %q", home)
}
}
func TestVisibilityFlag(t *testing.T) {
t.Parallel()
private := types.Repo{Private: true}
public := types.Repo{Private: false}
for _, tc := range []struct {
visibility string
repo types.Repo
expected string
}{
{"public", private, "--public"},
{"private", public, "--private"},
{"", private, "--private"},
{"", public, "--public"},
{"source", private, "--private"},
{"source", public, "--public"},
} {
flag, err := visibilityFlag(types.Radicle{Visibility: tc.visibility}, tc.repo)
if err != nil {
t.Errorf("visibility %q: %v", tc.visibility, err)
}
if flag != tc.expected {
t.Errorf("visibility %q private=%v: expected %s, got %s", tc.visibility, tc.repo.Private, tc.expected, flag)
}
}
if _, err := visibilityFlag(types.Radicle{Visibility: "invisible"}, public); err == nil {
t.Error("expected an error for an invalid visibility")
}
}
func TestPushArgs(t *testing.T) {
t.Parallel()
args := pushArgs(types.Radicle{}, "zRid", "zNid")
expected := "push rad://zRid/zNid refs/heads/*:refs/heads/* refs/tags/*:refs/tags/*"
if strings.Join(args, " ") != expected {
t.Errorf("expected %q, got %q", expected, strings.Join(args, " "))
}
args = pushArgs(types.Radicle{Force: true, Prune: true}, "zRid", "zNid")
expected = "push --prune rad://zRid/zNid +refs/heads/*:refs/heads/* +refs/tags/*:refs/tags/*"
if strings.Join(args, " ") != expected {
t.Errorf("expected %q, got %q", expected, strings.Join(args, " "))
}
}
const testNID = "zTestNodeId"
type fakePayload struct {
name string
// source is the upstream URL in the dev.gickup payload; empty simulates
// a repository that was not created by gickup.
source string
// owned repos list this profile among the identity delegates; unowned ones
// list a foreign delegate, simulating a repo merely seeded from the network.
owned bool
}
// fakeRad puts a rad stub on PATH answering `rad self --home`, `rad inspect
// <rid> --payload` and `rad inspect <rid> --delegates` per configured rid, and
// creates an (empty) storage directory per rid so payloadsFor can enumerate
// them — letting scanStorage be tested without a radicle installation.
func fakeRad(t *testing.T, radhome string, repos map[string]fakePayload) {
t.Helper()
bin := t.TempDir()
ourDID := "did:key:" + testNID
var payloadCases, delegateCases string
for rid, p := range repos {
gickup := ""
if p.source != "" {
gickup = fmt.Sprintf("\"dev.gickup\": {\"source\": \"%s\"}, ", p.source)
}
payloadCases += fmt.Sprintf("rad:%s) echo '{%s\"xyz.radicle.project\": {\"name\": \"%s\", \"defaultBranch\": \"main\"}}' ;;\n", rid, gickup, p.name)
delegate := "did:key:zForeignNode"
if p.owned {
delegate = ourDID
}
delegateCases += fmt.Sprintf("rad:%s) echo '%s (alias)' ;;\n", rid, delegate)
}
script := "#!/bin/sh\n" +
"if [ \"$1 $2\" = \"self --home\" ]; then echo \"$RAD_HOME\"; exit 0; fi\n" +
"case \"$3\" in\n" +
"--payload) case \"$2\" in\n" + payloadCases + "*) echo 'unknown repository' >&2; exit 1 ;;\nesac ;;\n" +
"--delegates) case \"$2\" in\n" + delegateCases + "*) echo 'unknown repository' >&2; exit 1 ;;\nesac ;;\n" +
"esac\n"
if err := os.WriteFile(filepath.Join(bin, "rad"), []byte(script), 0o755); err != nil {
t.Fatal(err)
}
t.Setenv("PATH", fmt.Sprintf("%s%c%s", bin, os.PathListSeparator, os.Getenv("PATH")))
for rid := range repos {
if err := os.MkdirAll(filepath.Join(radhome, "storage", rid), 0o755); err != nil {
t.Fatal(err)
}
}
}
func TestScanStorage(t *testing.T) {
radhome := t.TempDir()
fakeRad(t, radhome, map[string]fakePayload{
// same repo name upstream, distinguished purely by recorded source
"zRepoOne": {"website", "https://github.com/alice/website", true},
"zRepoTwo": {"website", "https://gitea.local/bob/website", true},
"zOther": {"other", "https://github.com/alice/other", true},
// not created by gickup: no dev.gickup payload
"zForeign": {"website", "", true},
})
t.Setenv("RAD_HOME", radhome)
rid, err := scanStorage(types.Repo{Name: "website", URL: "https://gitea.local/bob/website"}, testNID, os.Environ())
if err != nil {
t.Fatalf("scan storage: %v", err)
}
if rid != "zRepoTwo" {
t.Errorf("expected zRepoTwo, got %s", rid)
}
rid, err = scanStorage(types.Repo{Name: "website", URL: "https://github.com/carol/website"}, testNID, os.Environ())
if err != nil {
t.Fatalf("scan storage: %v", err)
}
if rid != "" {
t.Errorf("expected no match for an unmirrored upstream, got %s", rid)
}
// credentials in the configured URL don't prevent matching the
// credential-free recorded source
rid, err = scanStorage(types.Repo{Name: "other", URL: "https://user:token@github.com/alice/other"}, testNID, os.Environ())
if err != nil {
t.Fatalf("scan storage: %v", err)
}
if rid != "zOther" {
t.Errorf("expected zOther, got %s", rid)
}
}
func TestScanStorageAmbiguous(t *testing.T) {
radhome := t.TempDir()
url := "https://github.com/alice/same"
fakeRad(t, radhome, map[string]fakePayload{
"zRepoOne": {"same", url, true},
"zRepoTwo": {"same", url, true},
})
t.Setenv("RAD_HOME", radhome)
if _, err := scanStorage(types.Repo{Name: "same", URL: url}, testNID, os.Environ()); err == nil {
t.Error("expected an error for an ambiguous source match")
}
}
func TestScanStorageRejectsForgedPayloads(t *testing.T) {
radhome := t.TempDir()
victim := "https://github.com/victim/foo"
fakeRad(t, radhome, map[string]fakePayload{
// a repo seeded from the network whose crafted identity document
// claims the victim's source, but whose delegates don't include this
// profile — the delegate check must reject it
"zSeeded": {"foo", victim, false},
})
t.Setenv("RAD_HOME", radhome)
rid, err := scanStorage(types.Repo{Name: "foo", URL: victim}, testNID, os.Environ())
if err != nil {
t.Fatalf("scan storage: %v", err)
}
if rid != "" {
t.Errorf("forged payload was adopted: %s", rid)
}
}
func TestMarkerStripsCredentials(t *testing.T) {
t.Parallel()
m := marker("https://user:secret-token@github.com/alice/repo.git")
if strings.Contains(m, "secret-token") || strings.Contains(m, "user") {
t.Errorf("marker leaks credentials: %s", m)
}
if m != "(mirror of https://github.com/alice/repo.git)" {
t.Errorf("unexpected marker: %s", m)
}
// non-URL remotes pass through untouched
if marker("git@github.com:alice/repo.git") != "(mirror of git@github.com:alice/repo.git)" {
t.Error("scp-style remote was altered")
}
}
+15 -1
View File
@@ -31,6 +31,7 @@ type Destination struct {
Sourcehut []GenRepo `yaml:"sourcehut"`
S3 []S3Repo `yaml:"s3"`
AzureBlob []AzureBlob `yaml:"azureblob"`
Radicle []Radicle `yaml:"radicle"`
}
// Count TODO.
@@ -43,7 +44,20 @@ func (dest Destination) Count() int {
len(dest.OneDev) +
len(dest.Sourcehut) +
len(dest.S3) +
len(dest.AzureBlob)
len(dest.AzureBlob) +
len(dest.Radicle)
}
// Radicle : The configuration for mirroring to Radicle.
// Repositories are initialized with `rad init` and pushed into the local
// Radicle storage through the git-remote-rad helper, signed with the key of
// the profile in RAD_HOME. When that key has a passphrase, export it in the
// RAD_PASSPHRASE environment variable, which rad reads itself.
type Radicle struct {
Force bool `yaml:"force"` // overwrite diverged refs on the mirror (non-fast-forward updates)
Prune bool `yaml:"prune"` // delete refs on the mirror that no longer exist upstream
Visibility string `yaml:"visibility"` // public, private or source, default: source
Issues bool `yaml:"issues"` // [NOT YET SUPPORTED] recreate upstream issues as radicle issues (source must also fetch them with issues: true)
}
// Local TODO.