diff --git a/.golangci.yml b/.golangci.yml index 2f307f1..b6b8f78 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -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:" diff --git a/README.md b/README.md index 6daa7a1..2b9c8b7 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,7 @@ You can clone/mirror repositories to: - Gogs - OneDev - Sourcehut +- Radicle - Local - S3 diff --git a/conf.example.yml b/conf.example.yml index bd71fc8..ca3cfc1 100644 --- a/conf.example.yml +++ b/conf.example.yml @@ -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 diff --git a/gickup_spec.json b/gickup_spec.json index e877771..3ba7042 100644 --- a/gickup_spec.json +++ b/gickup_spec.json @@ -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." + } + } + } } } } diff --git a/main.go b/main.go index 32685c1..09be599 100644 --- a/main.go +++ b/main.go @@ -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() } } diff --git a/radicle/radicle.go b/radicle/radicle.go new file mode 100644 index 0000000..2ba351a --- /dev/null +++ b/radicle/radicle.go @@ -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: (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 +} diff --git a/radicle/radicle_test.go b/radicle/radicle_test.go new file mode 100644 index 0000000..6f1464f --- /dev/null +++ b/radicle/radicle_test.go @@ -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 +// --payload` and `rad inspect --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") + } +} diff --git a/types/types.go b/types/types.go index 01a12f0..44046ce 100644 --- a/types/types.go +++ b/types/types.go @@ -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.