diff --git a/e2e-docker/README.md b/e2e-docker/README.md index f2c9092..ca96aab 100644 --- a/e2e-docker/README.md +++ b/e2e-docker/README.md @@ -30,7 +30,8 @@ already-running stack. index), rpm (real package + **automatic repodata** generation), npm (stock `node:22` client: `npm publish` unscoped + scoped, `npm install` + `require()`, second version, republish rejected), gems (stock `ruby:3.3`: `gem push`, - `gem install`, `bundle install` + `require`, republish rejected, `gem yank`). + `gem install`, `bundle install` + `require`, republish rejected, `gem yank`, + yanked re-push rejected). - **Virtual repositories** — pypi simple-index merge and helm `index.yaml` merge across two members; npm packument merge over two locals via a stock `node:22` client (merged versions, install through the member redirect). diff --git a/e2e-docker/gems_local_test.go b/e2e-docker/gems_local_test.go index b5f5134..17d1068 100644 --- a/e2e-docker/gems_local_test.go +++ b/e2e-docker/gems_local_test.go @@ -13,7 +13,8 @@ import ( // TestLocalGemsPushInstall drives stock gem and bundler clients against a // local gems repo: push two versions, reject a republish, gem install and -// bundle install the latest via the compact index, then yank it. +// bundle install the latest via the compact index, then yank it and check a +// re-push of the yanked version with different bytes is rejected. func TestLocalGemsPushInstall(t *testing.T) { network := os.Getenv("COMPOSE_NETWORK") internal := os.Getenv("ARTIFACTAPI_INTERNAL") @@ -31,8 +32,8 @@ func TestLocalGemsPushInstall(t *testing.T) { src := strings.TrimRight(internal, "/") + "/api/v1/local/" + name script := `set -euo pipefail export GEM_HOST_API_KEY=unused -mk() { d=/w/src-$1; mkdir -p $d/lib; cd $d - echo "module UnkinE2eHello; VERSION = '$1'; end" > lib/unkin-e2e-hello.rb +mk() { d=/w/src-$1${2:+-b}; mkdir -p $d/lib; cd $d + echo "module UnkinE2eHello; VERSION = '$1'; end${2:-}" > lib/unkin-e2e-hello.rb printf 'Gem::Specification.new { |s| s.name = "unkin-e2e-hello"; s.version = "%s"; s.summary = "e2e"; s.authors = ["e2e"]; s.files = ["lib/unkin-e2e-hello.rb"] }' "$1" > g.gemspec gem build g.gemspec >/dev/null 2>&1; } mk 0.1.0 && gem push --host "$SRC" unkin-e2e-hello-0.1.0.gem @@ -47,14 +48,18 @@ printf 'source "%s/"\ngem "unkin-e2e-hello"\n' "$SRC" > Gemfile bundle install bundle exec ruby -e 'require "unkin-e2e-hello"; puts "BUNDLE #{UnkinE2eHello::VERSION}"' gem yank unkin-e2e-hello -v 0.2.0 --host "$SRC" -echo "INFO $(curl -s "$SRC/info/unkin-e2e-hello" | cut -d' ' -f1 | tr '\n' ' ')"` +echo "INFO $(curl -s "$SRC/info/unkin-e2e-hello" | cut -d' ' -f1 | tr '\n' ' ')" +mk 0.2.0 '# changed' && if gem push --host "$SRC" unkin-e2e-hello-0.2.0.gem; then echo "yanked re-push accepted"; exit 1; fi +cd /w/app && gem uninstall -a -x unkin-e2e-hello >/dev/null +if gem install --remote --no-document unkin-e2e-hello -v 0.2.0; then echo "yanked version installed"; exit 1; fi +echo "VERSIONS $(curl -s "$SRC/versions" | grep '^unkin-e2e-hello ' | cut -d' ' -f2)"` out, err := exec.Command("docker", "run", "--rm", "--network", network, "-e", "SRC="+src, "ruby:3.3", "bash", "-c", script).CombinedOutput() if err != nil { t.Fatalf("gem client run failed: %v\n%s", err, out) } - for _, want := range []string{"GEM 0.2.0", "BUNDLE 0.2.0", "INFO --- 0.1.0 "} { + for _, want := range []string{"GEM 0.2.0", "BUNDLE 0.2.0", "INFO --- 0.1.0 ", "re-pushing a yanked version is not allowed", "VERSIONS 0.1.0\n"} { if !strings.Contains(string(out), want) { t.Fatalf("missing %q in gem output:\n%s", want, out) } diff --git a/internal/api/v1/gems.go b/internal/api/v1/gems.go index ba64927..3ce02b6 100644 --- a/internal/api/v1/gems.go +++ b/internal/api/v1/gems.go @@ -26,10 +26,14 @@ import ( // gems/.gem package (immutable) // quick/Marshal.4.8/.gemspec.rz marshalled spec (immutable) // _index// compact index /info line; gates visibility +// _yanked// yank tombstone; blocks re-push of the version var gemsMaxPushBytes int64 = 256 << 20 -const gemsIndexPrefix = "_index/" +const ( + gemsIndexPrefix = "_index/" + gemsYankedPrefix = "_yanked/" +) func gemsText(w http.ResponseWriter, status int, msg string) { w.Header().Set("Content-Type", "text/plain; charset=utf-8") @@ -75,6 +79,15 @@ func (h *ProxyHandler) handleLocalPost(w http.ResponseWriter, r *http.Request) { conflict := func() { gemsText(w, http.StatusConflict, fmt.Sprintf("%s already exists; overwrites are not allowed", full)) } + yanked, err := h.db.GetLocalFile(r.Context(), remote.Name, gemsYankedPrefix+spec.Name+"/"+full) + if err != nil { + gemsText(w, http.StatusInternalServerError, err.Error()) + return + } + if yanked != nil { + gemsText(w, http.StatusConflict, fmt.Sprintf("%s was yanked; re-pushing a yanked version is not allowed", full)) + return + } // The index line gates visibility, so it is written last. for _, f := range []struct { path string @@ -151,6 +164,16 @@ func (h *ProxyHandler) handleLocalDelete(w http.ResponseWriter, r *http.Request) gemsText(w, http.StatusNotFound, "The version "+spec.Version+" does not exist") return } + // The tombstone keeps the yanked line and is written before any delete. + line, err := h.readLocalBlob(r.Context(), existing.ContentHash) + if err != nil { + gemsText(w, http.StatusInternalServerError, err.Error()) + return + } + if _, err := h.npmStore(r.Context(), remote.Name, gemsYankedPrefix+spec.Name+"/"+full, line, "text/plain", false); err != nil && !errors.Is(err, database.ErrAlreadyExists) { + gemsText(w, http.StatusInternalServerError, err.Error()) + return + } for _, p := range []string{index, "gems/" + full + ".gem", "quick/Marshal.4.8/" + full + ".gemspec.rz"} { if err := h.db.DeleteLocalFile(r.Context(), remote.Name, p); err != nil { gemsText(w, http.StatusInternalServerError, err.Error()) diff --git a/internal/api/v1/gems_handler_test.go b/internal/api/v1/gems_handler_test.go index 095db07..fcbd6d6 100644 --- a/internal/api/v1/gems_handler_test.go +++ b/internal/api/v1/gems_handler_test.go @@ -134,8 +134,26 @@ func TestGemsPushIndexYank(t *testing.T) { if code, _ := npmDo(t, http.MethodGet, base+"gems/hello_gem-0.2.0-java.gem", ""); code != http.StatusNotFound { t.Fatalf("yanked download = %d, want 404", code) } - if code, _ := npmDo(t, http.MethodPost, base+"api/v1/gems", string(gj)); code != http.StatusOK { - t.Fatalf("push after yank = %d", code) + if _, v := npmDo(t, http.MethodGet, base+"versions", ""); strings.Contains(v, "java") || !strings.Contains(v, "hello_gem 0.1.0,0.2.0 ") { + t.Fatalf("versions after yank = %q", v) + } + for _, g := range []string{string(gj), string(gj) + "x"} { + if code, body := npmDo(t, http.MethodPost, base+"api/v1/gems", g); code != http.StatusConflict || !strings.Contains(body, "yanked") { + t.Fatalf("push after yank = %d %q, want 409", code, body) + } + } + if _, info := npmDo(t, http.MethodGet, base+"info/hello_gem", ""); strings.Contains(info, "java") { + t.Fatalf("info after rejected re-push = %q", info) + } + + if c := yank("gem_name=hello-gem&version=1.0.0"); c != http.StatusOK { + t.Fatalf("yank last version = %d", c) + } + if code, _ := npmDo(t, http.MethodGet, base+"info/hello-gem", ""); code != http.StatusNotFound { + t.Fatalf("info for fully yanked gem = %d, want 404", code) + } + if _, names := npmDo(t, http.MethodGet, base+"names", ""); names != "---\nhello_gem\n" { + t.Fatalf("names after yanking every version = %q", names) } } diff --git a/internal/provider/gems/local.go b/internal/provider/gems/local.go index 6422e8f..0cbb54f 100644 --- a/internal/provider/gems/local.go +++ b/internal/provider/gems/local.go @@ -445,10 +445,11 @@ func (m *rbMarshal) requirement(r Requirement) { // time writes a UTC Time with day resolution, as Time#_dump lays it out. func (m *rbMarshal) time(t time.Time) { - if t.IsZero() { - t = time.Unix(0, 0) - } t = t.UTC() + // Time#_dump holds year-1900 in 16 bits. + if t.IsZero() || t.Year() < 1900 || t.Year() > 1900+0xffff { + t = time.Unix(0, 0).UTC() + } p := uint32(1)<<31 | uint32(1)<<30 | uint32(t.Year()-1900)<<14 | uint32(t.Month()-1)<<10 | uint32(t.Day())<<5 | uint32(t.Hour()) s := uint32(t.Minute())<<26 | uint32(t.Second())<<20 m.WriteString("Iu") diff --git a/internal/provider/gems/local_test.go b/internal/provider/gems/local_test.go index 9bcf685..4df4f54 100644 --- a/internal/provider/gems/local_test.go +++ b/internal/provider/gems/local_test.go @@ -175,6 +175,15 @@ func TestRubyMarshalEncoding(t *testing.T) { if want := "Iu:\x09Time\x0d\x20\xa6\x1f\xc0\x00\x00\x00\x00\x06:\x09zoneI\"\x08UTC\x06:\x06EF"; m.String() != want { t.Errorf("time = %q, want %q", m.String(), want) } + var epoch rbMarshal + epoch.time(time.Time{}) + for _, y := range []int{1, 1899, 1900 + 0x10000} { + m.Reset() + m.time(time.Date(y, 1, 1, 0, 0, 0, 0, time.UTC)) + if m.String() != epoch.String() { + t.Errorf("time(year %d) = %q, want the epoch encoding %q", y, m.String(), epoch.String()) + } + } } func TestQuickSpec(t *testing.T) {