Skip to content

api: revalidate JSON API caches with ETag, not the bumped mtime - #24193

Open
xrl wants to merge 3 commits into
Homebrew:mainfrom
xrl:api-revalidate-stale-cache
Open

xrl wants to merge 3 commits into
Homebrew:mainfrom
xrl:api-revalidate-stale-cache

Conversation

@xrl

@xrl xrl commented Oct 7, 2026

Copy link
Copy Markdown

fetch_json_api_file bumps the cache file's mtime to now after every successful check (it doubles as the stale_seconds clock), then revalidates with curl --time-cond <mtime> (an If-Modified-Since). If the first fetch comes from a stale CDN edge, the stale body ends up with an mtime newer than the real object's Last-Modified. The server then answers 304 indefinitely, and brew upgrade keeps saying "already installed" until the next publish.

This saves the response's ETag beside the cache file and revalidates with --etag-compare when present. It falls back to --time-cond otherwise (mirrors, first run after upgrading). curl blanks the ETag file on a 304, so the new ETag is saved to a temp path and only replaces the stored one when non-empty. Specs cover all three paths.

Repro (the mechanism, without needing a stale edge):

u=https://formulae.brew.sh/api/internal/packages.arm64_tahoe.jws.json
curl -sI $u | grep -i last-modified
# If-Modified-Since later than Last-Modified, which is what a stale body touched to "now" sends:
curl -s -o /dev/null -w '%{http_code}\n' -H "If-Modified-Since: $(date -u -v+1H '+%a, %d %b %Y %H:%M:%S GMT')" $u   # 304

Seen for real on 2026-10-07: claude-code@latest per-cask JSON said 2.1.293, the local internal index (fetched ~16 min after the server's Last-Modified) said 2.1.292. brew update and brew upgrade --cask claude-code@latest both kept 2.1.292 until I deleted ~/Library/Caches/Homebrew/api/internal/packages.arm64_tahoe.jws.json*.


  • Have you followed our Contributing guidelines?
  • Have you checked for other open Pull Requests for the same change?
  • Have you explained what your changes do? Performance claims (e.g. "this is faster") must include brew benchmark results.
  • Have you explained why you'd like these changes included, not just what they do?
  • For bug fixes, have you given step-by-step brew commands to reproduce the bug?
  • Have you written new tests (excluding integration tests)? Here's an example.
  • Have you successfully run brew lgtm (style, typechecking and tests) locally?

  • I did not use AI/LLM to create this PR, or I disclosed the tool/model below and reviewed its output; I did not attribute commits to AI and will answer maintainer questions and review comments myself without AI/LLM.

Disclosure: diagnosed and drafted with Claude Code (Sonnet 5.5). I reviewed the diff, the root-cause analysis and the curl behaviour (the 304 blanking the --etag-save file) by hand before opening this.

🤖 Generated with Claude Code

https://claude.ai/code/session_01XdswxjrNLFURYY68A6ZUSi

@coderabbitai

coderabbitai Bot commented Oct 7, 2026 •

Copy link
Copy Markdown

Review in Change Stack →

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration
  • Configuration used: Organization UI
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 745e7779-b210-49ab-a18d-567cb002e634
📥 Commits

Reviewing files that changed from the base of the PR and between fc83866 and 8f60294.

📒 Files selected for processing (7)
  • Library/Homebrew/api.rb
  • Library/Homebrew/cleanup.rb
  • Library/Homebrew/test/api_spec.rb
  • Library/Homebrew/test/cleanup_spec.rb
  • Library/Homebrew/test/spec_helper.rb
  • Library/Homebrew/test/utils/curl_spec.rb
  • Library/Homebrew/utils/curl.rb

Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 8 remain after this review.

📜 Recent review details
⏰ Context from checks skipped due to timeout. (30)
  • GitHub Check: test-bot (Linux arm64, padded prefix)
  • GitHub Check: test-bot (no Sorbet)
  • GitHub Check: test-bot (Linux arm64)
  • GitHub Check: test-bot (Linux Homebrew glibc)
  • GitHub Check: test-bot (macOS arm64)
  • GitHub Check: test-bot (Linux containerless)
  • GitHub Check: test-bot (macOS arm64, padded prefix)
  • GitHub Check: test-bot (Linux x86_64)
  • GitHub Check: test-bot (Linux x86_64, padded prefix)
  • GitHub Check: bundle and services (macOS)
  • GitHub Check: formula audit
  • GitHub Check: bundle and services (Linux)
  • GitHub Check: tap syntax
  • GitHub Check: cask audit
  • GitHub Check: docker (x86_64 Ubuntu 24.04)
  • GitHub Check: docker (x86_64 Ubuntu 26.04)
  • GitHub Check: docker (arm64 Ubuntu 24.04)
  • GitHub Check: docker (arm64 Ubuntu 26.04)
  • GitHub Check: tests (generic OS, 1/2)
  • GitHub Check: tests (Linux, 1/2)
  • GitHub Check: tests (macOS, 1/2)
  • GitHub Check: tests (Linux, 2/2)
  • GitHub Check: tests (load-only Linux)
  • GitHub Check: tests (online, 2/2)
  • GitHub Check: tests (macOS, 2/2)
  • GitHub Check: tests (no Sorbet)
  • GitHub Check: tests (load-only macOS)
  • GitHub Check: tests (online, 1/2)
  • GitHub Check: tests (generic OS, 2/2)
  • GitHub Check: docs
🧰 Additional context used
🪛 ast-grep (0.45.3)
Library/Homebrew/api.rb

[warning] 98-98: Found the use of an hardcoded passphrase for RSA. The passphrase can be easily discovered, and therefore should not be stored in source-code. It is recommended to remove the passphrase from source-code, and use system environment variables or a restricted configuration file.
Context: args.prepend("--etag-compare", etag_path.to_s)
Note: [CWE-798]: Use of Hard-coded Credentials [OWASP A07:2021]: Identification and Authentication Failures

(hardcoded-secret-rsa-passphrase-ruby)


[warning] 100-100: Found the use of an hardcoded passphrase for RSA. The passphrase can be easily discovered, and therefore should not be stored in source-code. It is recommended to remove the passphrase from source-code, and use system environment variables or a restricted configuration file.
Context: args.prepend("--time-cond", target.to_s)
Note: [CWE-798]: Use of Hard-coded Credentials [OWASP A07:2021]: Identification and Authentication Failures

(hardcoded-secret-rsa-passphrase-ruby)


[warning] 104-104: Found the use of an hardcoded passphrase for RSA. The passphrase can be easily discovered, and therefore should not be stored in source-code. It is recommended to remove the passphrase from source-code, and use system environment variables or a restricted configuration file.
Context: args.prepend("--etag-save", new_etag_path.to_s)
Note: [CWE-798]: Use of Hard-coded Credentials [OWASP A07:2021]: Identification and Authentication Failures

(hardcoded-secret-rsa-passphrase-ruby)

Library/Homebrew/test/api_spec.rb

[warning] 135-135: Found the use of an hardcoded passphrase for RSA. The passphrase can be easily discovered, and therefore should not be stored in source-code. It is recommended to remove the passphrase from source-code, and use system environment variables or a restricted configuration file.
Context: expect(curl_args).to include("--etag-compare", etag_path.to_s)
Note: [CWE-798]: Use of Hard-coded Credentials [OWASP A07:2021]: Identification and Authentication Failures

(hardcoded-secret-rsa-passphrase-ruby)


[warning] 143-143: Found the use of an hardcoded passphrase for RSA. The passphrase can be easily discovered, and therefore should not be stored in source-code. It is recommended to remove the passphrase from source-code, and use system environment variables or a restricted configuration file.
Context: expect(curl_args).to include("--time-cond", target.to_s)
Note: [CWE-798]: Use of Hard-coded Credentials [OWASP A07:2021]: Identification and Authentication Failures

(hardcoded-secret-rsa-passphrase-ruby)


[warning] 152-152: Found the use of an hardcoded passphrase for RSA. The passphrase can be easily discovered, and therefore should not be stored in source-code. It is recommended to remove the passphrase from source-code, and use system environment variables or a restricted configuration file.
Context: expect(curl_args).to include("--time-cond", target.to_s)
Note: [CWE-798]: Use of Hard-coded Credentials [OWASP A07:2021]: Identification and Authentication Failures

(hardcoded-secret-rsa-passphrase-ruby)


[warning] 196-196: Found the use of an hardcoded passphrase for RSA. The passphrase can be easily discovered, and therefore should not be stored in source-code. It is recommended to remove the passphrase from source-code, and use system environment variables or a restricted configuration file.
Context: expect(curl_args).to include("--time-cond", target.to_s)
Note: [CWE-798]: Use of Hard-coded Credentials [OWASP A07:2021]: Identification and Authentication Failures

(hardcoded-secret-rsa-passphrase-ruby)

🔇 Additional comments (1)
Library/Homebrew/api.rb (1)

116-117: 🚀 Performance & Scalability

The required redirect sequence is not established for the supported default API endpoints. Representative formula, cask, migration, and internal package endpoints returned direct 200 responses with ETags. A configurable custom API domain could behave differently, but that possibility alone does not support this finding.


📝 Summary

Summary by CodeRabbit

  • Bug Fixes
    • Cached API downloads now use ETag-based revalidation when supported, falling back to last-modified checks when ETags are unavailable.
    • Not-modified responses refresh cache freshness without replacing the saved ETag. Successful responses save replacement ETags, while responses without an ETag clear the outdated one.
    • Cleanup retains ETag data for the current API cache while removing stale cache artifacts.

Walkthrough

fetch_json_api_file now uses ETag revalidation when curl supports it and a saved ETag is available. It retains timestamp-based revalidation as a fallback and manages ETag sidecars based on the response. Scrub cleanup preserves the current API package’s ETag sidecar.

Changes

API cache revalidation

Layer / File(s) Summary
Curl ETag support
Library/Homebrew/utils/curl.rb, Library/Homebrew/test/utils/curl_spec.rb, Library/Homebrew/test/spec_helper.rb
Utils::Curl.curl_supports_etag? caches whether the installed curl version is at least 7.68.0. Tests cover supported and unsupported versions. The per-example setup stubs ETag support.
API cache revalidation and sidecar lifecycle
Library/Homebrew/api.rb, Library/Homebrew/test/api_spec.rb, Library/Homebrew/cleanup.rb, Library/Homebrew/test/cleanup_spec.rb
When curl supports ETags and the cache and saved ETag are nonempty, the fetch uses ETag comparison. Otherwise, a nonempty cache uses timestamp-based revalidation. The fetch updates or removes the saved ETag based on the response and removes the temporary sidecar. Scrub cleanup preserves the current package’s ETag sidecar. Tests cover these paths.

Priority: ➖ Normal

Estimated code review effort: 3 (Moderate) | ~20 minutes

Change: Bug fix

Sequence Diagram(s)

sequenceDiagram
  participant FetchJsonApiFile
  participant CachedAPIFile
  participant Curl
  FetchJsonApiFile->>CachedAPIFile: Read cached response and saved ETag
  FetchJsonApiFile->>Curl: Send conditional request with ETag or timestamp
  Curl-->>FetchJsonApiFile: Return response status and temporary ETag
  FetchJsonApiFile->>CachedAPIFile: Update cached ETag and clean up temporary sidecar
Loading

Suggested reviewers: mikemcquaid

Merge Risk: ⚪ Minimal · up to 8f602

The previously identified stale-ETag case is addressed; the change is mergeable after normal checks.

Security Architecture Review

Security architecture risk: 🔵 Low · up to 8f602

Signed package metadata remains verified, with no new download origins or permissions. However, routine updates discard the new validators, and overlapping cache operations can interfere with their publication. These are bounded freshness and recovery risks that could delay package updates.

Retained concerns

  • Medium · reliability · observed: The new current-package ETag does not survive the normal update lifecycle. Ruby cleanup preserves it, but the update command still performs timestamp-based retrieval and subsequently deletes every matching package artifact except the envelope and payload caches, including the ETag. The next Ruby revalidation therefore falls back to the touched mtime. This breaks the intended persistence contract and leaves package-metadata freshness dependent on the preexisting timestamp behavior, potentially delaying security-relevant package updates. The timestamp behavior itself predates this PR.
  • Low · reliability · inferred: Every fetch uses the same temporary validator path and removes it before checking whether a download is needed. A cache-only call can therefore delete another operation’s pending validator. Overlapping callers can also interfere with the separate existence checks, promotion, and deletion operations, losing the validator or raising filesystem errors outside the download-error recovery path. This introduces shared-state recovery risk affecting metadata freshness; persistent body-validator mismatch or a signature bypass was not established. Queue-level deduplication limits overlap within one queue but does not coordinate direct callers or separate processes.
Security review details

Security Blast Radius

  • inferred — The identified lifecycle risks affect Homebrew operations sharing the same API cache and propagate to package metadata consumers. Exploiting operation overlap would require concurrent activity against that cache; manipulating remote content would require influence over an already selected origin or transport. No new service identity, credential authority, or independently reachable endpoint was identified in the changed retrieval path.

Security Findings and Attack Paths

  • inferred — The supported concerns involve freshness and failure containment, not verified acceptance of forged signed package data. Signature and parse failures provide counterevidence against treating validator-state errors as an authentication bypass. Persistent stale metadata remains security-relevant because it can delay package updates, but the original timestamp starvation condition predates this PR.

Trust Boundaries and Controls

  • observed — Configured-mirror selection and default-origin fallback remain unchanged. The validator is keyed only by the local target and reused across that fallback, rather than being associated with an issuing origin. The mirror configuration describes fallback behavior but supplies no guarantee that different origins use interchangeable ETags.

Resilience and Maintainability Implications

  • observed — Successful revalidation refreshes the freshness clock, while the retrieval function avoids explicitly refreshing it after a download error. Scrub cleanup now retains the current package’s validator. Queue deduplication and the update-command lock constrain some concurrency, but neither supplies a shared per-target lifecycle across all inspected API callers and writers.

Hardening Proposals

  • proposed — Define one body-validator generation contract across Ruby retrieval, the shell update writer, cleanup, and curl-version transitions. Coordinate per-target publication and recovery, use operation-owned temporary files, and avoid cache-only calls deleting another operation’s state. Associate validators with their origin or explicitly invalidate them during origin changes. Merely retaining the ETag in shell cleanup is insufficient if the shell writer can replace its body independently.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely describes the main change: using ETag revalidation for JSON API caches instead of the bumped modification time.
Description check ✅ Passed The description directly explains the cache-revalidation bug, the ETag-based fix, fallback behavior, tests, reproduction steps, and reported impact.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
  • Autopilot · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1


ℹ️ Review info
⚙️ Run configuration
  • Configuration used: Organization UI
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 925b6dfc-a6b9-40ef-8a7e-ecdf5826df44
📥 Commits

Reviewing files that changed from the base of the PR and between 8d49321 and fc83866.

📒 Files selected for processing (2)
  • Library/Homebrew/api.rb
  • Library/Homebrew/test/api_spec.rb

Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 9 remain after this review.

📜 Review details
⏰ Context from checks skipped due to timeout. (20)
  • GitHub Check: zizmor
  • GitHub Check: upload_sarif
  • GitHub Check: docker (arm64 Ubuntu 24.04)
  • GitHub Check: docker (x86_64 Ubuntu 26.04)
  • GitHub Check: docker (arm64 Ubuntu 26.04)
  • GitHub Check: docker (x86_64 Ubuntu 24.04)
  • GitHub Check: docs
  • GitHub Check: tests (macOS, 1/2)
  • GitHub Check: tests (load-only Linux)
  • GitHub Check: tests (load-only macOS)
  • GitHub Check: tests (macOS, 2/2)
  • GitHub Check: tests (Linux, 1/2)
  • GitHub Check: tests (no Sorbet)
  • GitHub Check: tests (Linux, 2/2)
  • GitHub Check: tests (generic OS, 2/2)
  • GitHub Check: tests (online, 1/2)
  • GitHub Check: Analyze
  • GitHub Check: tests (online, 2/2)
  • GitHub Check: tests (generic OS, 1/2)
  • GitHub Check: syntax
🧰 Additional context used
🪛 ast-grep (0.45.3)
Library/Homebrew/test/api_spec.rb

[warning] 122-122: Found the use of an hardcoded passphrase for RSA. The passphrase can be easily discovered, and therefore should not be stored in source-code. It is recommended to remove the passphrase from source-code, and use system environment variables or a restricted configuration file.
Context: expect(args).to include("--etag-compare", etag_path.to_s)
Note: [CWE-798]: Use of Hard-coded Credentials [OWASP A07:2021]: Identification and Authentication Failures

(hardcoded-secret-rsa-passphrase-ruby)


[warning] 153-153: Found the use of an hardcoded passphrase for RSA. The passphrase can be easily discovered, and therefore should not be stored in source-code. It is recommended to remove the passphrase from source-code, and use system environment variables or a restricted configuration file.
Context: expect(args).to include("--time-cond", target.to_s)
Note: [CWE-798]: Use of Hard-coded Credentials [OWASP A07:2021]: Identification and Authentication Failures

(hardcoded-secret-rsa-passphrase-ruby)

Library/Homebrew/api.rb

[warning] 101-101: Found the use of an hardcoded passphrase for RSA. The passphrase can be easily discovered, and therefore should not be stored in source-code. It is recommended to remove the passphrase from source-code, and use system environment variables or a restricted configuration file.
Context: args.prepend("--etag-compare", etag_path.to_s)
Note: [CWE-798]: Use of Hard-coded Credentials [OWASP A07:2021]: Identification and Authentication Failures

(hardcoded-secret-rsa-passphrase-ruby)


[warning] 103-103: Found the use of an hardcoded passphrase for RSA. The passphrase can be easily discovered, and therefore should not be stored in source-code. It is recommended to remove the passphrase from source-code, and use system environment variables or a restricted configuration file.
Context: args.prepend("--time-cond", target.to_s)
Note: [CWE-798]: Use of Hard-coded Credentials [OWASP A07:2021]: Identification and Authentication Failures

(hardcoded-secret-rsa-passphrase-ruby)


[warning] 108-108: Found the use of an hardcoded passphrase for RSA. The passphrase can be easily discovered, and therefore should not be stored in source-code. It is recommended to remove the passphrase from source-code, and use system environment variables or a restricted configuration file.
Context: args.prepend("--etag-save", new_etag_path.to_s)
Note: [CWE-798]: Use of Hard-coded Credentials [OWASP A07:2021]: Identification and Authentication Failures

(hardcoded-secret-rsa-passphrase-ruby)

Comment thread Library/Homebrew/api.rb Outdated
fetch_json_api_file touches the cache file to now after every successful
check (it doubles as the staleness clock), then sends --time-cond <mtime>
next time. A copy fetched from a stale CDN edge therefore sends an
If-Modified-Since newer than the real object's Last-Modified, gets 304
forever, and `brew upgrade` keeps reporting "already installed" until the
next publish.

Save the ETag beside the cache file and send it with --etag-compare when
present; fall back to --time-cond otherwise (mirrors, first run after
upgrading). curl blanks the ETag file on a 304, so save to a temp path and
only replace the stored ETag on a non-empty result.
@xrl
xrl force-pushed the api-revalidate-stale-cache branch from fc83866 to d1c0f1f Compare October 7, 2026 20:12
xrl added 2 commits October 7, 2026 16:16
CodeRabbit on Homebrew#24193: curl creates an empty --etag-save file for an untagged
200 as well as for a 304, so the old ETag survived a body it no longer
describes. Tell them apart by the HTTP status already written to stderr.
--etag-save and --etag-compare need curl 7.68.0 while Homebrew only requires
7.41, so on older curl every API fetch failed. Add curl_supports_etag? and
fall back to --time-cond when it is false.

brew cleanup --scrub removed the .etag beside the current packages file, so
the next fetch lost its validator; keep it. Unlink the empty .etag.new curl
leaves after a 304, and rework the specs around a shared stale-cache context
with real SystemCommand::Result stubs.
@xrl

xrl commented Oct 7, 2026

Copy link
Copy Markdown
Author

Pushed 8f60294032: ETag revalidation is now gated on curl >= 7.68 (curl_supports_etag?, falling back to --time-cond), brew cleanup --scrub keeps the .etag, and the stale .etag.new is unlinked after a 304. Specs reworked around one stale-cache context. The brew update bash path is deliberately left for a follow-up PR.

xrl added a commit to xrl/brew that referenced this pull request Oct 7, 2026
- `fetch_api_file` touches the cached `*.jws.json` to now after every
  check and revalidates with `curl --time-cond`. A body fetched from a
  stale CDN edge ends up with an mtime newer than the real object's
  `Last-Modified`, so the server answers `304` until the next publish.
- Save the ETag to `<file>.etag` and send `--etag-compare` when one is
  saved, as `Homebrew::API.fetch_json_api_file` does in Homebrew#24193. Fall
  back to `--time-cond` without a saved ETag or on curl older than
  7.68.0, which lacks `--etag-save` and `--etag-compare`.
- curl blanks the `--etag-save` file on a `304` and on an untagged
  `200`, so save to `<file>.etag.new`, replace `<file>.etag` only when
  it is non-empty and delete it after a non-`304` without an ETag.
  This keeps `.etag` in sync with whatever body `brew update` wrote.
- Keep the current OS's `.etag` when removing other OS versions' API
  files.
@xrl

xrl commented Oct 7, 2026

Copy link
Copy Markdown
Author

Stacked follow-up for the brew update bash path (same mtime/If-Modified-Since problem in cmd/update.sh and utils/api.sh): xrl#1. It sits on this branch and will move to Homebrew/brew after this merges.

@MikeMcQuaid MikeMcQuaid left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks! Might be a nice change. Struggling to understand the cases in which this solves problems though.

brew upgrade keeps saying "already installed" until the next publish

Why is this a problem? It is already installed and we don't want to redownload until the next publish? Missing something here.

Can you provide a step-by-step example of how/if/when the existing flow breaks down and what the worst case scenario is and how this fixes it?

I also think it may be worth considering including the update.sh changes in here too so it's rolled out consistently rather than different parts of the code using different ways to verify it "up to date" or not.

sig { returns(T::Boolean) }
def curl_supports_etag?
@curl_supports_etag ||= T.let(Hash.new do |h, key|
h[key] = curl_version >= Version.new("7.68.0")

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What's the minimum version we already require in the entire application? I'm wondering when/if we allow using older versions.

Also, looking at your other PR: we should avoid repeating this logic between Ruby and Bash.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants