Skip to content

Commit 29ac323

Browse files
fix: defects found by a full review of internal/ and main.go (#48)
* fix(deps): patch the high severity brace-expansion advisory The Azure DevOps extension pinned brace-expansion to 5.0.8 through an overrides entry, and 5.0.8 is the version the current advisory covers: a denial of service through unbounded intermediate arrays that bypasses the earlier CVE-2026-14257 mitigation. The pin that once fixed a problem had become the problem, and nothing would move it because an override is absolute. Bumped to 5.0.9, the first patched release and the current latest, with the lockfile regenerated to match. * chore(ci): update the actions CI exercises to their current majors Every run warned that actions/checkout and actions/setup-go target Node 20, which GitHub now force-runs on Node 24 and will stop supporting. Moved to checkout v7, setup-go v7, and upload-artifact v7 in ci.yml and action-smoke.yml, which this pull request exercises. The release, helm publish, and Azure DevOps extension workflows are left alone deliberately. They run only on a tag or a manual dispatch, so no pull request proves a bump there, and a broken publish workflow is discovered at the worst possible moment. They are worth updating in a change that can be dispatched and watched. * fix: repository paths and shared state in destination pull request sync Three defects that each made a whole path unusable. The engine handed prsync the rendered destination name, which is owner relative because CloneURL and EnsureRepo prepend the owner themselves. Every destination pull request and comment endpoint interpolates that value as a fully qualified path, so a GitHub destination built "/repos/widget/pulls" instead of "/repos/acme/widget/pulls" and answered 404 for every call. mirror_objects could not work at all against GitHub, GitLab, or Bitbucket. Remote gains QualifiedPath, each provider implements the convention its own ListRepos reports, and the engine qualifies the name before passing it on. The existing tests passed because their fake used the broken convention. Azure DevOps went wrong the other way: ListRepos reports project/name and every URL already carries the project as its own segment, so interpolating the qualified path whole produced a duplicated project and 404ed on every repository. Its pull request routes now use the repository segment alone. internal/prsync wrote pull request records into the shared GitState from inside the engine's worker pool, bypassing the lock the engine uses for its own access and documents as necessary. Two repositories finishing together was a concurrent map write, which aborts the process and cannot be recovered by the worker that caused it. GitState now guards its own maps, so no caller has to remember. * fix(registry): use the Docker Hub session token instead of basic auth Authenticate logged in and threw the response away, so the JWT the Hub API authenticates with was never kept. Every later listing fell back to HTTP basic auth, which that API does not accept: a private repository with watch_tags on answered 404 after three retries, reported against the image rather than against the credentials. The session token is stored and sent as a bearer credential, ahead of a configured personal access token, with basic auth left as a last resort for any deployment still relying on it. An anonymous registry sends nothing. * fix(prsync): close vanished pull requests, and stop losing work on failure Sources are listed open only, so a merged or closed pull request does not arrive with a finished state: it stops appearing. The close path was therefore unreachable, and a destination pull request stayed open forever while the mirror push pruned the branch under it. Records the source no longer lists are now reconciled and closed with a note that says only what is knowable from an open listing. GitHub keeps discussion comments and review comments in separate id spaces addressed through different routes, and both were updated through the issues route. Every pull request with a mirrored inline comment failed its conversation sync with a 404 on every run after the first, or edited an unrelated comment if the ids collided. The id now carries which space it belongs to, and a bare id from an older state file still resolves to the discussion route. A conversation that failed part way discarded the ids of the comments it had already posted, because the state write came after the error return, so the next run reposted all of them, forever. What succeeded is recorded before giving up. The repository fingerprint was recorded before the pull request pass ran, so a failed pass was marked done and skipped on the next run until an unrelated branch or tag happened to move. It is recorded after, and only when the pass had no failures. A pull request head that failed to fetch was dropped from the keep set, which pruned it from the cache and then deleted it at the destination. One transient network error removed a correct branch and pulled the head out from under its mirrored pull request. The previous copy is kept instead. * fix: cron overlap, signal handling, glob validation, and Slack block limits The image sync cron had no SkipIfStillRunning, which the git cron has along with a comment explaining the hazard. A run that outlives its interval overlapped itself on a Syncer that keeps per run state and a state map with no locking: the second run's report was voided by the first, and two writers on that map abort the process. Both cron loops now also register the schedule before the first run, so a typo is caught immediately instead of after a full sync of every image, and install the signal handler before the first run, so a SIGTERM during it lets the run finish and persist rather than killing the process with nothing saved. A malformed exclude glob was silently ignored, so a repository somebody wrote a rule to keep out was mirrored to the destination. Patterns are validated at config load, and a pattern that cannot be parsed now drops the repository rather than mirroring it. A Slack section block could be emitted empty, and could exceed the size limit, from a single oversized failure line such as a transport error carrying a multi kilobyte HTML body. Either one makes Slack answer 400 and the entire notification is lost, which matters most for the failure alert that is the reason the integration exists. Sections are bounded at both ends, and truncate now counts characters rather than bytes so it neither trims non-ASCII text far short of the limit nor splits a rune. A failed state save on the fail fast path was discarded, so every image copied before the abort was re-pulled and re-pushed on the next run with nothing in the output to say why. The same failure is fatal on the normal exit path; it is at least reported here. * docs: record the review fixes in the changelog --------- Co-authored-by: Anmol Nagpal <ianmolnagpal@gmail.com>
1 parent 4d7be78 commit 29ac323

32 files changed

Lines changed: 893 additions & 87 deletions

.github/workflows/action-smoke.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ jobs:
99
action:
1010
runs-on: ubuntu-latest
1111
steps:
12-
- uses: actions/checkout@v4
12+
- uses: actions/checkout@v7
1313

1414
# The uses: ./ steps below cannot assert behaviour, because every
1515
# scenario we can run without real registries or git remotes fails

.github/workflows/ci.yml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,10 @@ jobs:
1010
runs-on: ubuntu-latest
1111
steps:
1212
- name: Checkout
13-
uses: actions/checkout@v4
13+
uses: actions/checkout@v7
1414

1515
- name: Set up Go
16-
uses: actions/setup-go@v5
16+
uses: actions/setup-go@v7
1717
with:
1818
go-version: "1.26"
1919
cache: true
@@ -34,7 +34,7 @@ jobs:
3434
runs-on: ubuntu-latest
3535
steps:
3636
- name: Checkout
37-
uses: actions/checkout@v4
37+
uses: actions/checkout@v7
3838

3939
- name: Set up Helm
4040
uses: azure/setup-helm@v4

CHANGELOG.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Fixed
11+
- `pull_requests.mirror_objects` could not work against GitHub, GitLab, or Bitbucket. The engine handed the destination side the rendered repository name, which is owner relative because the clone URL and repository creation prepend the owner themselves, while every destination pull request and comment endpoint interpolates that value as a fully qualified path: a GitHub destination asked for `/repos/widget/pulls` rather than `/repos/acme/widget/pulls` and every call answered 404. Azure DevOps failed the other way, duplicating the project into a path that already carried it, so listing pull requests 404d for every repository
12+
- Mirroring several repositories at once could abort the process. Pull request records were written into the shared git state from inside the engine's worker pool without the lock the engine uses for its own access, and two repositories finishing together is a concurrent map write, which Go turns into an unrecoverable crash. The state type now guards its own maps
13+
- A private Docker Hub repository with `watch_tags` on failed with a 404 blamed on the image. The login response's session token was discarded, so every listing fell back to HTTP basic auth, which that API does not accept
14+
- A destination pull request stayed open forever once its source merged or closed. Sources are listed open only, so a finished pull request stops appearing rather than arriving with a final state, and nothing closed the mirror; the branch under it was pruned by the mirror push meanwhile. Records the source no longer lists are reconciled and closed with a note
15+
- Every pull request carrying a mirrored inline comment failed its conversation sync after the first run. GitHub keeps discussion and review comments in separate id spaces reached through different routes, and both were addressed through the discussion route
16+
- A conversation that failed part way discarded the ids of the comments it had already posted, so the next run reposted all of them, forever. A repository whose pull request pass failed was also recorded as fully mirrored, so it was skipped on the next run and never retried until an unrelated branch or tag moved
17+
- A single failed fetch of a pull request head deleted that branch from the destination, pulling the head out from under a mirrored pull request. The copy from the previous run is kept
18+
- The image sync cron could overlap itself, corrupting the run report and racing on the sync state map. Both cron loops now skip a tick rather than start a second run, validate the schedule before the first run instead of after it, and install the signal handler before the first run so a shutdown during it can still persist state
19+
- A malformed `exclude` glob was ignored entirely, mirroring a repository that the rule was written to keep out. Filter patterns are validated at config load
20+
- A Slack notification could be dropped whole. One oversized failure line, such as a transport error carrying a large HTML body, produced a block that was empty or over the size limit, and Slack rejects both
21+
- A failed state save on the fail-fast path was silent, so everything copied before the abort was copied again on the next run with no explanation
22+
- `git-sync` could not authenticate to Azure DevOps over git in `pat` mode, fixed in v0.2.1 and described there
23+
24+
### Security
25+
- Patched a high severity advisory in `brace-expansion`, which the Azure DevOps extension pinned to exactly the affected version through an `overrides` entry
26+
1027
## [0.2.1] - 2026-08-19
1128

1229
### Added

azure-devops-extension/SyncerD/package-lock.json

Lines changed: 3 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

azure-devops-extension/SyncerD/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,6 @@
1111
"azure-pipelines-task-lib": "^5.278.0"
1212
},
1313
"overrides": {
14-
"brace-expansion": "5.0.8"
14+
"brace-expansion": "5.0.9"
1515
}
1616
}

internal/config/git.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package config
33
import (
44
"fmt"
55
"os"
6+
"path"
67
"strings"
78

89
"github.com/clouddrove/syncerd/internal/vcs"
@@ -360,6 +361,17 @@ func (c *Config) ValidateGitSync() error {
360361
// A disabled block is not validated. Its fields have no effect,
361362
// and rejecting a stale value in one would block a run that does
362363
// not read it.
364+
for _, pat := range m.Filters.Include {
365+
if _, err := path.Match(pat, "probe"); err != nil {
366+
return fmt.Errorf("git.mirrors[%d].filters.include pattern %q is malformed: %w", i, pat, err)
367+
}
368+
}
369+
for _, pat := range m.Filters.Exclude {
370+
if _, err := path.Match(pat, "probe"); err != nil {
371+
return fmt.Errorf("git.mirrors[%d].filters.exclude pattern %q is malformed: %w; an exclude that cannot be parsed would otherwise be silently ignored", i, pat, err)
372+
}
373+
}
374+
363375
if m.PullRequests.MirrorObjects && !m.PullRequests.Enabled {
364376
return fmt.Errorf("git.mirrors[%d].pull_requests.mirror_objects is set but enabled is not; the destination pull request needs the head branch that enabled mirrors", i)
365377
}

internal/config/git_test.go

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -394,3 +394,27 @@ func TestPullRequestSwitchDefaults(t *testing.T) {
394394
t.Error("an explicit false must win over the default")
395395
}
396396
}
397+
398+
func TestValidateGitSyncRejectsMalformedFilterPatterns(t *testing.T) {
399+
// A malformed exclude used to be ignored entirely, so a repository
400+
// somebody wrote a rule to keep out was mirrored to the destination.
401+
cfg := &Config{Git: validGit()}
402+
cfg.Git.Mirrors[0].Filters.Exclude = []string{"internal-[secret"}
403+
cfg.Git.ApplyDefaults()
404+
405+
err := cfg.ValidateGitSync()
406+
if err == nil || !strings.Contains(err.Error(), "malformed") {
407+
t.Fatalf("expected a malformed pattern error, got %v", err)
408+
}
409+
}
410+
411+
func TestValidateGitSyncAcceptsWellFormedFilterPatterns(t *testing.T) {
412+
cfg := &Config{Git: validGit()}
413+
cfg.Git.Mirrors[0].Filters.Include = []string{"svc-*", "app-?"}
414+
cfg.Git.Mirrors[0].Filters.Exclude = []string{"*-legacy"}
415+
cfg.Git.ApplyDefaults()
416+
417+
if err := cfg.ValidateGitSync(); err != nil {
418+
t.Fatalf("validate: %v", err)
419+
}
420+
}

internal/gitsync/sync.go

Lines changed: 28 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -561,27 +561,18 @@ func (e *Engine) mirrorRepo(ctx context.Context, cancel context.CancelFunc, m Mi
561561
}
562562
}
563563

564-
if !e.opts.DryRun {
565-
e.stateMark(m.Name, repo.Path, destName, fp)
566-
567-
if setter, ok := m.Dest.(vcs.DefaultBranchSetter); ok && repo.DefaultBranch != "" && created {
568-
if err := setter.SetDefaultBranch(ctx, destName, repo.DefaultBranch); err != nil {
569-
redacted := e.redact(err.Error())
570-
logging.Warn(fmt.Sprintf("mirror %s: could not set default branch on %s: %s", m.Name, destName, redacted),
571-
"mirror", m.Name, "destination", destName, "error", redacted)
572-
}
573-
}
574-
}
575-
576564
// Pull request objects come after the push, because a destination pull
577565
// request cannot reference commits that have not arrived yet.
578566
var prRes prsync.Result
579567
if m.PullRequests.Enabled && m.PullRequests.MirrorObjects && m.DestPRs != nil {
580568
var perr error
581569
prRes, perr = prsync.Sync(ctx, prs, prsync.Options{
582-
Mirror: m.Name,
583-
SourceRepo: repo.Path,
584-
DestRepo: destName,
570+
Mirror: m.Name,
571+
SourceRepo: repo.Path,
572+
// Qualified, not the rendered name: the rendered name is owner
573+
// relative, and every destination API addresses a repository by
574+
// its full path.
575+
DestRepo: m.Dest.QualifiedPath(destName),
585576
BranchPrefix: m.PullRequests.BranchPrefix,
586577
Source: m.SourcePRs,
587578
Dest: m.DestPRs,
@@ -605,6 +596,22 @@ func (e *Engine) mirrorRepo(ctx context.Context, cancel context.CancelFunc, m Mi
605596
}
606597
}
607598

599+
// A fingerprint is the claim that this repository is fully mirrored. It
600+
// is withheld when the pull request pass failed, because recording it
601+
// would skip the repository on the next run and the failure would never
602+
// be retried until an unrelated branch or tag happened to move.
603+
if !e.opts.DryRun && len(prRes.Failures) == 0 {
604+
e.stateMark(m.Name, repo.Path, destName, fp)
605+
606+
if setter, ok := m.Dest.(vcs.DefaultBranchSetter); ok && repo.DefaultBranch != "" && created {
607+
if err := setter.SetDefaultBranch(ctx, destName, repo.DefaultBranch); err != nil {
608+
redacted := e.redact(err.Error())
609+
logging.Warn(fmt.Sprintf("mirror %s: could not set default branch on %s: %s", m.Name, destName, redacted),
610+
"mirror", m.Name, "destination", destName, "error", redacted)
611+
}
612+
}
613+
}
614+
608615
e.addEvent(MirrorEvent{
609616
Mirror: m.Name,
610617
SourceRepo: repo.Path,
@@ -689,9 +696,14 @@ func (e *Engine) syncPRBranches(ctx context.Context, m Mirror, repo vcs.Repo, pr
689696
from = srcURL
690697
}
691698
if err := e.opts.Runner.FetchPRHead(ctx, cacheDir, from, "refs/heads/"+pr.HeadBranch, branch, srcCred); err != nil {
692-
logging.Warn(fmt.Sprintf("mirror %s: %s could not fetch the head of pull request %d, skipping it: %s",
699+
logging.Warn(fmt.Sprintf("mirror %s: %s could not fetch the head of pull request %d, keeping the copy from the last run: %s",
693700
m.Name, repo.Path, pr.Number, e.redact(err.Error())),
694701
"mirror", m.Name, "source", repo.Path, "pull_request", pr.Number)
702+
// Keep it anyway. The pull request is still open at the source,
703+
// so dropping the branch here would prune it from the cache and
704+
// then delete it at the destination, pulling the head out from
705+
// under a mirrored pull request over one failed fetch.
706+
keep[branch] = true
695707
continue
696708
}
697709
keep[branch] = true

internal/gitsync/sync_test.go

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,10 @@ type fakeRemote struct {
2020
}
2121

2222
func (f *fakeRemote) CloneURL(path string) string { return filepath.Join(f.base, path+".git") }
23+
24+
// QualifiedPath mirrors what a real provider does: the rendered name is
25+
// owner relative, and the qualified form is what an API addresses.
26+
func (f *fakeRemote) QualifiedPath(name string) string { return "acme/" + name }
2327
func (f *fakeRemote) GitCredential(context.Context) (vcs.GitCredential, error) {
2428
return vcs.GitCredential{}, nil
2529
}
@@ -699,3 +703,141 @@ func TestWithoutMirrorObjectsASameRepoPullRequestStillGetsNoBranch(t *testing.T)
699703
t.Errorf("PRBranchesPushed = %d, want 0", rep.Mirrored[0].PRBranchesPushed)
700704
}
701705
}
706+
707+
// recordingWriter captures the repository path the engine hands the pull
708+
// request writer, which is the value every destination API interpolates.
709+
type recordingWriter struct {
710+
mu sync.Mutex
711+
paths []string
712+
}
713+
714+
func (r *recordingWriter) record(p string) {
715+
r.mu.Lock()
716+
r.paths = append(r.paths, p)
717+
r.mu.Unlock()
718+
}
719+
720+
func (r *recordingWriter) FindPullRequest(_ context.Context, repo, _ string) (vcs.PullRequest, bool, error) {
721+
r.record(repo)
722+
return vcs.PullRequest{}, false, nil
723+
}
724+
func (r *recordingWriter) CreatePullRequest(_ context.Context, repo string, spec vcs.PullRequestSpec) (vcs.PullRequest, error) {
725+
r.record(repo)
726+
return vcs.PullRequest{Number: 100, State: vcs.PROpen, HeadBranch: spec.HeadBranch}, nil
727+
}
728+
func (r *recordingWriter) UpdatePullRequest(_ context.Context, repo string, _ int, _ vcs.PullRequestSpec) error {
729+
r.record(repo)
730+
return nil
731+
}
732+
func (r *recordingWriter) ClosePullRequest(_ context.Context, repo string, _ int) error {
733+
r.record(repo)
734+
return nil
735+
}
736+
737+
func TestDestinationPullRequestsUseTheQualifiedRepositoryPath(t *testing.T) {
738+
eng, m, _ := newEngineFixture(t)
739+
740+
source := m.SourceRemote.CloneURL("acme/app")
741+
sha := strings.TrimSpace(git(t, source, "rev-parse", "refs/heads/main"))
742+
743+
writer := &recordingWriter{}
744+
enablePRs(&m, &fakePRLister{prs: []vcs.PullRequest{{
745+
Number: 7, State: vcs.PROpen, HeadBranch: "main", HeadSHA: sha, BaseBranch: "main",
746+
}}})
747+
m.PullRequests.MirrorObjects = true
748+
m.DestPRs = writer
749+
750+
rep, err := eng.Run(context.Background(), []Mirror{m})
751+
if err != nil {
752+
t.Fatalf("run: %v", err)
753+
}
754+
if len(rep.Failures) != 0 {
755+
t.Fatalf("unexpected failures: %+v", rep.Failures)
756+
}
757+
if len(writer.paths) == 0 {
758+
t.Fatal("the writer was never called")
759+
}
760+
761+
// The rendered destination name is owner relative because CloneURL and
762+
// EnsureRepo prepend the owner themselves. Handing that bare name to an
763+
// API that wants a full path produced "/repos/app/pulls" and 404ed on
764+
// every repository.
765+
for _, got := range writer.paths {
766+
if got != "acme/app" {
767+
t.Errorf("writer received %q, want the qualified path acme/app", got)
768+
}
769+
}
770+
}
771+
772+
// failingWriter fails every create, standing in for a destination that
773+
// rejects the pull request pass while the branches mirror fine.
774+
type failingWriter struct{}
775+
776+
func (failingWriter) FindPullRequest(context.Context, string, string) (vcs.PullRequest, bool, error) {
777+
return vcs.PullRequest{}, false, nil
778+
}
779+
func (failingWriter) CreatePullRequest(context.Context, string, vcs.PullRequestSpec) (vcs.PullRequest, error) {
780+
return vcs.PullRequest{}, errors.New("403 forbidden")
781+
}
782+
func (failingWriter) UpdatePullRequest(context.Context, string, int, vcs.PullRequestSpec) error {
783+
return nil
784+
}
785+
func (failingWriter) ClosePullRequest(context.Context, string, int) error { return nil }
786+
787+
func TestAFailedPullRequestPassIsRetriedOnTheNextRun(t *testing.T) {
788+
eng, m, _ := newEngineFixture(t)
789+
790+
source := m.SourceRemote.CloneURL("acme/app")
791+
sha := strings.TrimSpace(git(t, source, "rev-parse", "refs/heads/main"))
792+
enablePRs(&m, &fakePRLister{prs: []vcs.PullRequest{{
793+
Number: 7, State: vcs.PROpen, HeadBranch: "main", HeadSHA: sha, BaseBranch: "main",
794+
}}})
795+
m.PullRequests.MirrorObjects = true
796+
m.DestPRs = failingWriter{}
797+
798+
rep, _ := eng.Run(context.Background(), []Mirror{m})
799+
if len(rep.Failures) != 1 || rep.Failures[0].Stage != "pr-objects" {
800+
t.Fatalf("want one pr-objects failure, got %+v", rep.Failures)
801+
}
802+
803+
// The fingerprint is the claim that the repository is fully mirrored.
804+
// Recording it here would skip the repository next run and the failure
805+
// would never be retried until an unrelated ref moved.
806+
if _, ok := eng.opts.State.Get("fake", "acme/app"); ok {
807+
t.Error("a repository whose pull request pass failed must not be recorded as done")
808+
}
809+
810+
rep, _ = eng.Run(context.Background(), []Mirror{m})
811+
if rep.Skipped != 0 {
812+
t.Errorf("the repository must be retried, Skipped = %d", rep.Skipped)
813+
}
814+
}
815+
816+
func TestAnUnreachableForkHeadKeepsTheBranchAlreadyAtTheDestination(t *testing.T) {
817+
eng, m, _ := newEngineFixture(t)
818+
819+
fork := newForkRepo(t, "feature", "one")
820+
forkSHA := strings.TrimSpace(git(t, fork, "rev-parse", "refs/heads/feature"))
821+
lister := &fakePRLister{prs: []vcs.PullRequest{{
822+
Number: 7, State: vcs.PROpen, HeadBranch: "feature", HeadSHA: forkSHA,
823+
HeadRepoCloneURL: fork,
824+
}}}
825+
enablePRs(&m, lister)
826+
827+
if _, err := eng.Run(context.Background(), []Mirror{m}); err != nil {
828+
t.Fatalf("first: %v", err)
829+
}
830+
if destRef(t, m, "refs/heads/syncerd/pr/7") == "" {
831+
t.Fatal("the branch should exist after the first run")
832+
}
833+
834+
// The fork becomes unreachable while the pull request is still open.
835+
lister.prs[0].HeadRepoCloneURL = filepath.Join(t.TempDir(), "gone.git")
836+
837+
if _, err := eng.Run(context.Background(), []Mirror{m}); err != nil {
838+
t.Fatalf("second: %v", err)
839+
}
840+
if destRef(t, m, "refs/heads/syncerd/pr/7") == "" {
841+
t.Error("one failed fetch must not delete a branch the destination already has")
842+
}
843+
}

0 commit comments

Comments
 (0)