Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 71d5106777 | |||
| 8b1b229539 | |||
| ff1ab31327 | |||
| 85e14bc8af | |||
| 3f5175b482 | |||
| 04d1c6c368 | |||
| 523a9a4782 | |||
| 8e09c89e24 | |||
| a2ac13d317 | |||
| 5d006ac44f | |||
| acd006d4a2 | |||
| 30a695458e | |||
| e8bcf0ddbd | |||
| f497e71ef0 | |||
| 0198e8990b | |||
| 2a2cd8d393 | |||
| 81b3a7df53 | |||
| 8e8e877533 |
@@ -7,6 +7,8 @@ on:
|
|||||||
jobs:
|
jobs:
|
||||||
release:
|
release:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@main
|
- uses: actions/checkout@main
|
||||||
with:
|
with:
|
||||||
@@ -21,4 +23,6 @@ jobs:
|
|||||||
version: 'latest'
|
version: 'latest'
|
||||||
args: release
|
args: release
|
||||||
env:
|
env:
|
||||||
GITEA_TOKEN: ${{secrets.RELEASE_TOKEN}}
|
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||||
|
GORELEASER_FORCE_TOKEN: gitea
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ on:
|
|||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
|
paths-ignore:
|
||||||
|
- '**.md'
|
||||||
|
- 'CONTRIBUTING.md'
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
check-and-test:
|
check-and-test:
|
||||||
@@ -12,14 +15,14 @@ jobs:
|
|||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- uses: actions/setup-go@v5
|
- uses: actions/setup-go@v5
|
||||||
with:
|
with:
|
||||||
go-version: '1.26.7'
|
go-version-file: 'go.mod'
|
||||||
- run: go mod tidy
|
- run: go mod tidy
|
||||||
- run: go build ./...
|
- run: go build ./...
|
||||||
- run: go vet ./...
|
- run: go vet ./...
|
||||||
- name: golangci-lint
|
- name: golangci-lint
|
||||||
uses: golangci/golangci-lint-action@v8
|
uses: golangci/golangci-lint-action@v8
|
||||||
with:
|
with:
|
||||||
version: v2.12
|
version: v2.13.2
|
||||||
args: --timeout=5m
|
args: --timeout=5m
|
||||||
- run: go test -race -v -shuffle=on -coverprofile=coverage.out -timeout=5m ./...
|
- run: go test -race -v -shuffle=on -coverprofile=coverage.out -timeout=5m ./...
|
||||||
- run: go tool cover -func=coverage.out | tail -10 # basic coverage report
|
- run: go tool cover -func=coverage.out | tail -10 # basic coverage report
|
||||||
@@ -30,6 +33,6 @@ jobs:
|
|||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- uses: actions/setup-go@v5
|
- uses: actions/setup-go@v5
|
||||||
with:
|
with:
|
||||||
go-version: '1.27.0'
|
go-version-file: 'go.mod'
|
||||||
- run: go install golang.org/x/vuln/cmd/govulncheck@latest
|
- run: go install golang.org/x/vuln/cmd/govulncheck@latest
|
||||||
- run: govulncheck ./...
|
- run: govulncheck ./...
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
# Contributing
|
||||||
|
|
||||||
|
## Propose changes
|
||||||
|
|
||||||
|
Open a pull request against `develop`. Keep the default branch for releases and
|
||||||
|
stable tips; land work on `develop` first.
|
||||||
|
|
||||||
|
Point at an existing issue when one fits. Prefer a short issue that states the
|
||||||
|
symptom or request before a large PR.
|
||||||
|
|
||||||
|
## Commits
|
||||||
|
|
||||||
|
Subject form:
|
||||||
|
|
||||||
|
```
|
||||||
|
area: Imperative summary
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Area** is a real package, directory, or subsystem token (`ci:`, `docs:`,
|
||||||
|
Go package name). Not a lone filename.
|
||||||
|
- **Imperative** mood: Fix, Add, Remove — not "Fixed" or "This patch…".
|
||||||
|
- No trailing period. Aim ≤ ~70–75 characters for the whole subject.
|
||||||
|
- Not conventional-commits (`feat:` / `fix:` / `chore:` as types).
|
||||||
|
|
||||||
|
Body explains **why**. Establish the problem, then say what you are doing.
|
||||||
|
One logical change per commit; split fix and cleanup.
|
||||||
|
|
||||||
|
## Pull requests
|
||||||
|
|
||||||
|
Title matches the primary commit subject.
|
||||||
|
|
||||||
|
- **What** changed
|
||||||
|
- **Why** (problem and impact)
|
||||||
|
- **Test** (concrete steps; "CI green" alone is weak)
|
||||||
|
|
||||||
|
## Issues and closing
|
||||||
|
|
||||||
|
Cite leftover issues by **full URL**. Gitea closes issues when `#N` appears in
|
||||||
|
merge text, so do not put `#N` in the merge message unless that issue is actually
|
||||||
|
done. Use `Fixes #N` / `Closes #N` only when the leftover work is finished.
|
||||||
@@ -62,6 +62,30 @@ validate run-validation: build clean-disk ## Start steamcache2 on :80 with small
|
|||||||
fi; \
|
fi; \
|
||||||
exec "$$BINARY" --config docs/examples/validate-config.yaml --log-level info
|
exec "$$BINARY" --config docs/examples/validate-config.yaml --log-level info
|
||||||
|
|
||||||
|
validate-check: ## Curl local /metrics (full dump + hit/miss fields) and /lancache-heartbeat (default :80)
|
||||||
|
@echo "=== http://localhost/metrics ==="
|
||||||
|
@metrics=$$(curl -sf --max-time 5 http://localhost/metrics) || { \
|
||||||
|
echo "ERROR: could not fetch http://localhost/metrics"; \
|
||||||
|
echo "Is steamcache2 running on the default listen address :80?"; \
|
||||||
|
exit 1; \
|
||||||
|
}; \
|
||||||
|
printf '%s\n' "$$metrics"; \
|
||||||
|
echo ""; \
|
||||||
|
echo "=== hit/miss fields ==="; \
|
||||||
|
printf '%s\n' "$$metrics" | grep -E '^(total_requests|cache_hits|cache_misses|hit_rate|memory_cache_hits|disk_cache_hits|errors) ' || true; \
|
||||||
|
echo ""; \
|
||||||
|
echo "=== http://localhost/lancache-heartbeat (GET; expect 204 + X-LanCache-Processed-By: SteamCache2) ==="; \
|
||||||
|
hb=$$(curl -sD - -o /dev/null --max-time 5 http://localhost/lancache-heartbeat) || { \
|
||||||
|
echo "ERROR: could not fetch http://localhost/lancache-heartbeat"; \
|
||||||
|
echo "Is steamcache2 running on the default listen address :80?"; \
|
||||||
|
exit 1; \
|
||||||
|
}; \
|
||||||
|
printf '%s\n' "$$hb"; \
|
||||||
|
echo "$$hb" | grep -q '204' && echo "$$hb" | grep -qi 'X-LanCache-Processed-By' || { \
|
||||||
|
echo "ERROR: expected HTTP 204 and X-LanCache-Processed-By on /lancache-heartbeat"; \
|
||||||
|
exit 1; \
|
||||||
|
}
|
||||||
|
|
||||||
validate-kill: ## Kill leftover steamcache2 processes (safer, checks process name)
|
validate-kill: ## Kill leftover steamcache2 processes (safer, checks process name)
|
||||||
@echo "Looking for steamcache2 processes on common validation ports (80 is primary)..."
|
@echo "Looking for steamcache2 processes on common validation ports (80 is primary)..."
|
||||||
@for port in 80 8040 8080; do \
|
@for port in 80 8040 8080; do \
|
||||||
@@ -109,6 +133,7 @@ help: ## Show this help message
|
|||||||
@echo " clean-disk Remove disk cache"
|
@echo " clean-disk Remove disk cache"
|
||||||
@echo " bench Run low-level VFS microbenchmarks"
|
@echo " bench Run low-level VFS microbenchmarks"
|
||||||
@echo " validate / run-validation Start server on :80 (builds, auto-setcaps fresh binary, then runs as normal user, cleans disk cache first)"
|
@echo " validate / run-validation Start server on :80 (builds, auto-setcaps fresh binary, then runs as normal user, cleans disk cache first)"
|
||||||
|
@echo " validate-check Curl local /metrics (full dump + hit/miss fields) and /lancache-heartbeat (default :80)"
|
||||||
@echo " setcap Explicitly set cap on current build (for port 80 use outside validate)"
|
@echo " setcap Explicitly set cap on current build (for port 80 use outside validate)"
|
||||||
@echo " validate-kill Kill leftover steamcache2 processes (safer)"
|
@echo " validate-kill Kill leftover steamcache2 processes (safer)"
|
||||||
@echo " prefill Download latest SteamPrefill into bin/steam-prefill/SteamPrefill (gitignored)"
|
@echo " prefill Download latest SteamPrefill into bin/steam-prefill/SteamPrefill (gitignored)"
|
||||||
|
|||||||
@@ -53,6 +53,39 @@ SteamCache2 is a blazing fast download cache for Steam, designed to reduce bandw
|
|||||||
make run # or ./steamcache2
|
make run # or ./steamcache2
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Quick check: is it caching?
|
||||||
|
|
||||||
|
After steamcache2 is running (default `listen_address: :80`) and has seen a little Steam traffic — a game download, a short SteamPrefill pass, or any cacheable request — confirm hits vs misses from the existing endpoints. You do not need a full benchmark or log diving.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make validate-check
|
||||||
|
# or, manually:
|
||||||
|
curl -s http://localhost/metrics
|
||||||
|
curl -s -i http://localhost/lancache-heartbeat
|
||||||
|
```
|
||||||
|
|
||||||
|
`make validate-check` prints the full `/metrics` dump, highlights hit/miss fields, and curls `/lancache-heartbeat`. Read these fields:
|
||||||
|
|
||||||
|
| Field | Meaning |
|
||||||
|
| --- | --- |
|
||||||
|
| `cache_hits` / `cache_misses` / `hit_rate` | Whether later requests were served from cache |
|
||||||
|
| `memory_cache_hits` / `disk_cache_hits` | Which tier served the hits |
|
||||||
|
| `total_requests` / `errors` | Volume and failures |
|
||||||
|
|
||||||
|
A first pass through new content is mostly misses (`hit_rate` near 0). Repeat the same content and `cache_hits` / `hit_rate` should rise.
|
||||||
|
|
||||||
|
To confirm the process is up (HTTP 204 and `X-LanCache-Processed-By: SteamCache2`):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -i http://localhost/lancache-heartbeat
|
||||||
|
```
|
||||||
|
|
||||||
|
Use GET (`curl -i`), not HEAD (`curl -I`): the server only accepts GET.
|
||||||
|
|
||||||
|
These are the cache process's own `/metrics` and `/lancache-heartbeat` endpoints. There is no separate metrics daemon.
|
||||||
|
|
||||||
|
If you changed `listen_address`, point curl at that host:port instead. For a full SteamPrefill validation workflow (small caches, coalescing, GC), see [Validating Full Functionality](#validating-full-functionality-with-external-tools).
|
||||||
|
|
||||||
### Development Workflow
|
### Development Workflow
|
||||||
|
|
||||||
Use `make` for the majority of common development tasks. The Makefile handles running tests, linting, hygiene checks, building, running the application, and other routine boilerplate work.
|
Use `make` for the majority of common development tasks. The Makefile handles running tests, linting, hygiene checks, building, running the application, and other routine boilerplate work.
|
||||||
@@ -96,13 +129,16 @@ When the server is running, point your external SteamPrefill (or other load gene
|
|||||||
./SteamPrefill benchmark run ...
|
./SteamPrefill benchmark run ...
|
||||||
```
|
```
|
||||||
|
|
||||||
When finished, you can get a quick metrics summary with:
|
When finished, you can get a quick metrics + heartbeat report with:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
make validate-check
|
make validate-check
|
||||||
|
# or, manually:
|
||||||
|
curl -s http://localhost/metrics
|
||||||
|
curl -s -i http://localhost/lancache-heartbeat
|
||||||
```
|
```
|
||||||
|
|
||||||
This is the recommended simple workflow. No automatic downloading or running of external tools.
|
See [Quick check: is it caching?](#quick-check-is-it-caching) for which fields to read. This is the recommended simple workflow. No automatic downloading or running of external tools.
|
||||||
|
|
||||||
#### Inspecting the Result
|
#### Inspecting the Result
|
||||||
|
|
||||||
@@ -110,14 +146,17 @@ After a benchmark run you can ask for a quick report:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
make validate-check
|
make validate-check
|
||||||
# or manually:
|
# or, manually:
|
||||||
curl -s http://localhost/metrics
|
curl -s http://localhost/metrics
|
||||||
|
curl -s -i http://localhost/lancache-heartbeat
|
||||||
```
|
```
|
||||||
|
|
||||||
Look for:
|
`make validate-check` prints the full `/metrics` dump, highlights hit/miss fields, and curls `/lancache-heartbeat`. Look for:
|
||||||
- High cache hit rate after the warmup pass
|
- High cache hit rate after the warmup pass (`cache_hits`, `hit_rate`, plus `memory_cache_hits` / `disk_cache_hits`)
|
||||||
- Non-zero `coalesced` and `disk` activity
|
- Non-zero `coalesced` and `disk` activity
|
||||||
- Zero unexpected errors
|
- Zero unexpected `errors`
|
||||||
|
|
||||||
|
Heartbeat should be HTTP 204 with `X-LanCache-Processed-By: SteamCache2`. Use GET (`curl -i`), not HEAD (`curl -I`).
|
||||||
|
|
||||||
#### The Validation Config
|
#### The Validation Config
|
||||||
|
|
||||||
@@ -160,8 +199,9 @@ While most configuration is done via the YAML file, some runtime options are sti
|
|||||||
# Set logging level
|
# Set logging level
|
||||||
./steamcache2 --log-level debug --log-format json
|
./steamcache2 --log-level debug --log-format json
|
||||||
|
|
||||||
# Set number of worker threads
|
# Override concurrency from the CLI (0 = use config.yaml)
|
||||||
./steamcache2 --threads 8
|
./steamcache2 --max-concurrent-requests 8
|
||||||
|
./steamcache2 --max-requests-per-client 4
|
||||||
|
|
||||||
# Show help
|
# Show help
|
||||||
./steamcache2 --help
|
./steamcache2 --help
|
||||||
@@ -311,7 +351,7 @@ This will direct any requests to `lancache.steamcontent.com` to your SteamCache2
|
|||||||
|
|
||||||
### Prerequisites
|
### Prerequisites
|
||||||
|
|
||||||
- Go 1.19 or later
|
- Go 1.27.0 or later
|
||||||
- Make (optional, but recommended)
|
- Make (optional, but recommended)
|
||||||
|
|
||||||
### Build Commands
|
### Build Commands
|
||||||
@@ -319,7 +359,7 @@ This will direct any requests to `lancache.steamcontent.com` to your SteamCache2
|
|||||||
```bash
|
```bash
|
||||||
# Clone the repository
|
# Clone the repository
|
||||||
git clone <repository-url>
|
git clone <repository-url>
|
||||||
cd SteamCache2
|
cd steamcache2
|
||||||
|
|
||||||
# Download dependencies
|
# Download dependencies
|
||||||
make deps
|
make deps
|
||||||
@@ -371,6 +411,11 @@ make
|
|||||||
- Consider using a different GC algorithm like `hybrid`
|
- Consider using a different GC algorithm like `hybrid`
|
||||||
- Adjust the disk cache size to match available storage
|
- Adjust the disk cache size to match available storage
|
||||||
|
|
||||||
|
6. **Not sure if it is caching**
|
||||||
|
- Do not start with the full SteamPrefill chapter. Use [Quick check: is it caching?](#quick-check-is-it-caching): `make validate-check` (full `/metrics`, hit/miss fields, and `/lancache-heartbeat`)
|
||||||
|
- A first pass is mostly `cache_misses`; repeating the same content should raise `cache_hits` / `hit_rate`
|
||||||
|
- Confirm the process is up with `curl -s -i http://localhost/lancache-heartbeat` (GET, not HEAD)
|
||||||
|
|
||||||
### Getting Help
|
### Getting Help
|
||||||
|
|
||||||
- Check the logs for detailed error messages
|
- Check the logs for detailed error messages
|
||||||
|
|||||||
@@ -27,7 +27,10 @@
|
|||||||
# SteamPrefill benchmark run -c 20 ...
|
# SteamPrefill benchmark run -c 20 ...
|
||||||
#
|
#
|
||||||
# After the benchmark run, inspect with:
|
# After the benchmark run, inspect with:
|
||||||
|
# make validate-check # full /metrics + hit/miss fields + /lancache-heartbeat
|
||||||
|
# # or, manually:
|
||||||
# curl -s http://localhost/metrics
|
# curl -s http://localhost/metrics
|
||||||
|
# curl -s -i http://localhost/lancache-heartbeat # GET, not HEAD
|
||||||
#
|
#
|
||||||
# Tweak sizes upward if you want to run very large workloads while still
|
# Tweak sizes upward if you want to run very large workloads while still
|
||||||
# exercising the disk tier (workload >> RAM is ideal for real disk testing).
|
# exercising the disk tier (workload >> RAM is ideal for real disk testing).
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
module s1d3sw1ped/steamcache2
|
module s1d3sw1ped/steamcache2
|
||||||
|
|
||||||
go 1.26.0
|
go 1.27.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/docker/go-units v0.5.0
|
github.com/docker/go-units v0.5.0
|
||||||
|
|||||||
@@ -345,6 +345,18 @@ func (sc *SteamCache) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||||||
req.Host = r.Host
|
req.Host = r.Host
|
||||||
} else { // if no upstream server is configured, proxy the request to the host specified in the request
|
} else { // if no upstream server is configured, proxy the request to the host specified in the request
|
||||||
host := r.Host
|
host := r.Host
|
||||||
|
if !hostAllowedForDirectFetch(host) {
|
||||||
|
logger.Logger.Warn().
|
||||||
|
Str("host", host).
|
||||||
|
Str("client_ip", clientIP).
|
||||||
|
Msg("Rejecting direct-fetch Host (not a Steam CDN name)")
|
||||||
|
sc.metrics.IncrementErrors()
|
||||||
|
if isNew {
|
||||||
|
coalescedReq.complete(nil, fmt.Errorf("host not allowed for direct fetch"))
|
||||||
|
}
|
||||||
|
http.Error(w, "Invalid URL", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
if r.Header.Get("X-Sls-Https") == "enable" {
|
if r.Header.Get("X-Sls-Https") == "enable" {
|
||||||
host = "https://" + host
|
host = "https://" + host
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -163,3 +164,44 @@ func generateServiceCacheKey(urlPath string, servicePrefix string) (string, erro
|
|||||||
}
|
}
|
||||||
return servicePrefix + "/" + hash, nil
|
return servicePrefix + "/" + hash, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// requestHostName strips a port and brackets from an HTTP Host header.
|
||||||
|
func requestHostName(host string) string {
|
||||||
|
host = strings.TrimSpace(host)
|
||||||
|
if host == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if h, _, err := net.SplitHostPort(host); err == nil {
|
||||||
|
host = h
|
||||||
|
}
|
||||||
|
return strings.Trim(host, "[]")
|
||||||
|
}
|
||||||
|
|
||||||
|
func hostIsLiteralIP(host string) bool {
|
||||||
|
return net.ParseIP(requestHostName(host)) != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// defaultDirectFetchSuffixes are CDN names Steam actually uses. Applied only when
|
||||||
|
// no configured upstream is set and the request Host is used as the fetch target.
|
||||||
|
var defaultDirectFetchSuffixes = []string{
|
||||||
|
"steamcontent.com",
|
||||||
|
"steampowered.com",
|
||||||
|
"steamstatic.com",
|
||||||
|
}
|
||||||
|
|
||||||
|
// hostAllowedForDirectFetch reports whether Host may be used as an origin when
|
||||||
|
// upstream is empty. Literal IPs are rejected (LAN/metadata SSRF). Names must
|
||||||
|
// be Steam CDN suffixes so a spoofed User-Agent cannot turn the cache into an
|
||||||
|
// open reverse proxy.
|
||||||
|
func hostAllowedForDirectFetch(host string) bool {
|
||||||
|
name := strings.ToLower(requestHostName(host))
|
||||||
|
if name == "" || hostIsLiteralIP(host) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, suf := range defaultDirectFetchSuffixes {
|
||||||
|
if name == suf || strings.HasSuffix(name, "."+suf) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|||||||
@@ -357,7 +357,7 @@ func newHTTPTransport() *http.Transport {
|
|||||||
DialContext: (&net.Dialer{
|
DialContext: (&net.Dialer{
|
||||||
Timeout: 10 * time.Second, // Faster connection timeout
|
Timeout: 10 * time.Second, // Faster connection timeout
|
||||||
KeepAlive: 60 * time.Second, // Longer keep-alive
|
KeepAlive: 60 * time.Second, // Longer keep-alive
|
||||||
DualStack: true, // Enable dual-stack (IPv4/IPv6)
|
// Dual-stack Happy Eyeballs is the default since Go 1.12 (DualStack is deprecated).
|
||||||
}).DialContext,
|
}).DialContext,
|
||||||
|
|
||||||
// Timeout optimizations
|
// Timeout optimizations
|
||||||
@@ -387,11 +387,10 @@ func newHTTPClient(transport *http.Transport) *http.Client {
|
|||||||
Timeout: 60 * time.Second, // Optimized timeout for better responsiveness
|
Timeout: 60 * time.Second, // Optimized timeout for better responsiveness
|
||||||
// Add redirect policy for better performance
|
// Add redirect policy for better performance
|
||||||
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||||
// Limit redirects to prevent infinite loops
|
// Do not follow redirects. Steam CDN chunk/manifest fetches are
|
||||||
if len(via) >= 10 {
|
// expected to be 200; following Location would let an origin send
|
||||||
return http.ErrUseLastResponse
|
// the cache at an arbitrary internal URL.
|
||||||
}
|
return http.ErrUseLastResponse
|
||||||
return nil
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1165,3 +1165,61 @@ func TestClientRateLimiter_BlackBox(t *testing.T) {
|
|||||||
t.Error("different clients must have distinct limiters")
|
t.Error("different clients must have distinct limiters")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestHostAllowedForDirectFetch(t *testing.T) {
|
||||||
|
allowed := []string{
|
||||||
|
"lancache.steamcontent.com",
|
||||||
|
"cache1-iad1.steamcontent.com:443",
|
||||||
|
"steamcontent.com",
|
||||||
|
"content.steampowered.com",
|
||||||
|
"cdn.steamstatic.com",
|
||||||
|
}
|
||||||
|
denied := []string{
|
||||||
|
"",
|
||||||
|
"127.0.0.1",
|
||||||
|
"127.0.0.1:80",
|
||||||
|
"[::1]:80",
|
||||||
|
"192.168.1.1",
|
||||||
|
"169.254.169.254",
|
||||||
|
"evil.example",
|
||||||
|
"example.com",
|
||||||
|
"notsteamcontent.com",
|
||||||
|
}
|
||||||
|
for _, h := range allowed {
|
||||||
|
if !hostAllowedForDirectFetch(h) {
|
||||||
|
t.Errorf("expected allowed: %q", h)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, h := range denied {
|
||||||
|
if hostAllowedForDirectFetch(h) {
|
||||||
|
t.Errorf("expected denied: %q", h)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDirectFetchRejectsNonSteamHost(t *testing.T) {
|
||||||
|
td := t.TempDir()
|
||||||
|
sc, err := New("127.0.0.1:0", "1MB", "0", td, "", "lru", "lru", 200, 5, "0", nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("New: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { sc.Shutdown() })
|
||||||
|
|
||||||
|
req := httptest.NewRequest("GET", "/depot/ssrf/chunk", nil)
|
||||||
|
req.Host = "127.0.0.1"
|
||||||
|
req.Header.Set("User-Agent", "Valve/Steam HTTP Client 1.0")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
sc.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusBadRequest {
|
||||||
|
t.Errorf("IP Host: expected 400, got %d", rec.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
req2 := httptest.NewRequest("GET", "/depot/ssrf/chunk2", nil)
|
||||||
|
req2.Host = "evil.example"
|
||||||
|
req2.Header.Set("User-Agent", "Valve/Steam HTTP Client 1.0")
|
||||||
|
rec2 := httptest.NewRecorder()
|
||||||
|
sc.ServeHTTP(rec2, req2)
|
||||||
|
if rec2.Code != http.StatusBadRequest {
|
||||||
|
t.Errorf("non-CDN Host: expected 400, got %d", rec2.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Vendored
+4
-2
@@ -186,7 +186,7 @@ func (tc *TieredCache) Capacity() int64 {
|
|||||||
func (tc *TieredCache) promoteToFast(key string, reader io.ReadCloser) {
|
func (tc *TieredCache) promoteToFast(key string, reader io.ReadCloser) {
|
||||||
defer func() { _ = reader.Close() }() // best-effort close; error secondary to promotion attempt (async best-effort path)
|
defer func() { _ = reader.Close() }() // best-effort close; error secondary to promotion attempt (async best-effort path)
|
||||||
|
|
||||||
// Get file info from slow tier to determine size
|
// Size for the space/ReadAll guards comes from a Stat snapshot, not the live in-map FileInfo.
|
||||||
var size int64
|
var size int64
|
||||||
if slow := tc.slow.Load(); slow != nil {
|
if slow := tc.slow.Load(); slow != nil {
|
||||||
if vfs, ok := slow.(vfs.VFS); ok {
|
if vfs, ok := slow.(vfs.VFS); ok {
|
||||||
@@ -210,7 +210,7 @@ func (tc *TieredCache) promoteToFast(key string, reader io.ReadCloser) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Guard promotion ReadAll using already-fetched size (in addition to space check above)
|
// Guard promotion ReadAll using already-fetched size (in addition to space check above)
|
||||||
if size > 0 && size > (1<<30) { // conservative 1GB hard limit on promotion reads (aligns with typical max_object_size)
|
if size > (1 << 30) { // conservative 1GB hard limit on promotion reads (aligns with typical max_object_size)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Read the entire file content
|
// Read the entire file content
|
||||||
@@ -218,6 +218,8 @@ func (tc *TieredCache) promoteToFast(key string, reader io.ReadCloser) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return // Skip promotion if read fails
|
return // Skip promotion if read fails
|
||||||
}
|
}
|
||||||
|
// Create with the bytes we actually hold so we never reuse a live FileInfo.Size.
|
||||||
|
size = int64(len(content))
|
||||||
|
|
||||||
// Create the file in fast tier
|
// Create the file in fast tier
|
||||||
if fast := tc.fast.Load(); fast != nil {
|
if fast := tc.fast.Load(); fast != nil {
|
||||||
|
|||||||
+24
-9
@@ -248,15 +248,25 @@ func (d *DiskFS) calculateSizeAndPopulateIndex() {
|
|||||||
|
|
||||||
// insertBatch populates info/LRU under lock for a bounded batch (follows maxEvictBatch pattern for short critical sections).
|
// insertBatch populates info/LRU under lock for a bounded batch (follows maxEvictBatch pattern for short critical sections).
|
||||||
// Size is incremented here only for files actually added (prevents double-count vs. concurrent Create during window).
|
// Size is incremented here only for files actually added (prevents double-count vs. concurrent Create during window).
|
||||||
|
// Fail-closed: re-stat each path under d.mu and skip if the file is gone. Create does not wait on
|
||||||
|
// initDone, so a file the scanner observed can be Evict/Delete'd (info + os.Remove) before this
|
||||||
|
// insert runs. Inserting without a live-file check would resurrect the key in d.info and make
|
||||||
|
// Stat succeed while os.Stat fails.
|
||||||
func (d *DiskFS) insertBatch(batch []discoveredFile) {
|
func (d *DiskFS) insertBatch(batch []discoveredFile) {
|
||||||
d.mu.Lock()
|
d.mu.Lock()
|
||||||
for _, df := range batch {
|
for _, df := range batch {
|
||||||
if _, exists := d.info[df.key]; !exists {
|
if _, exists := d.info[df.key]; exists {
|
||||||
fi := vfs.NewFileInfoFromOS(df.osInfo, df.key)
|
continue
|
||||||
d.info[df.key] = fi
|
|
||||||
d.LRU.Add(df.key, fi)
|
|
||||||
d.size += df.size
|
|
||||||
}
|
}
|
||||||
|
path := d.pathForKey(df.key)
|
||||||
|
st, err := os.Stat(path)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fi := vfs.NewFileInfoFromOS(st, df.key)
|
||||||
|
d.info[df.key] = fi
|
||||||
|
d.LRU.Add(df.key, fi)
|
||||||
|
d.size += st.Size()
|
||||||
}
|
}
|
||||||
d.mu.Unlock()
|
d.mu.Unlock()
|
||||||
}
|
}
|
||||||
@@ -602,7 +612,9 @@ func (d *DiskFS) Delete(key string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stat returns file information with lazy discovery
|
// Stat returns a snapshot of file information with lazy discovery.
|
||||||
|
// The returned *FileInfo is not the live cache entry; Close may update Size
|
||||||
|
// on the in-map object under d.mu.
|
||||||
func (d *DiskFS) Stat(key string) (*vfs.FileInfo, error) {
|
func (d *DiskFS) Stat(key string) (*vfs.FileInfo, error) {
|
||||||
if key == "" {
|
if key == "" {
|
||||||
return nil, vfserror.ErrInvalidKey
|
return nil, vfserror.ErrInvalidKey
|
||||||
@@ -617,9 +629,10 @@ func (d *DiskFS) Stat(key string) (*vfs.FileInfo, error) {
|
|||||||
keyMu.RLock()
|
keyMu.RLock()
|
||||||
d.mu.RLock()
|
d.mu.RLock()
|
||||||
if fi, ok := d.info[key]; ok {
|
if fi, ok := d.info[key]; ok {
|
||||||
|
snap := fi.Clone()
|
||||||
d.mu.RUnlock()
|
d.mu.RUnlock()
|
||||||
keyMu.RUnlock()
|
keyMu.RUnlock()
|
||||||
return fi, nil
|
return snap, nil
|
||||||
}
|
}
|
||||||
d.mu.RUnlock()
|
d.mu.RUnlock()
|
||||||
keyMu.RUnlock()
|
keyMu.RUnlock()
|
||||||
@@ -639,8 +652,9 @@ func (d *DiskFS) Stat(key string) (*vfs.FileInfo, error) {
|
|||||||
// Double-check after acquiring write lock
|
// Double-check after acquiring write lock
|
||||||
d.mu.Lock()
|
d.mu.Lock()
|
||||||
if fi, ok := d.info[key]; ok {
|
if fi, ok := d.info[key]; ok {
|
||||||
|
snap := fi.Clone()
|
||||||
d.mu.Unlock()
|
d.mu.Unlock()
|
||||||
return fi, nil
|
return snap, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Re-verify the file still exists on disk under the lock before inserting.
|
// Re-verify the file still exists on disk under the lock before inserting.
|
||||||
@@ -659,9 +673,10 @@ func (d *DiskFS) Stat(key string) (*vfs.FileInfo, error) {
|
|||||||
fi.UpdateAccessBatched(d.timeUpdater)
|
fi.UpdateAccessBatched(d.timeUpdater)
|
||||||
// Note: size not updated on lazy discovery (preserves prior behavior; initial on-disk accounted via bg populate at New time,
|
// Note: size not updated on lazy discovery (preserves prior behavior; initial on-disk accounted via bg populate at New time,
|
||||||
// subsequent files come via Create which accounts size).
|
// subsequent files come via Create which accounts size).
|
||||||
|
snap := fi.Clone()
|
||||||
d.mu.Unlock()
|
d.mu.Unlock()
|
||||||
|
|
||||||
return fi, nil
|
return snap, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// EvictLRU evicts the least recently used files to free up space
|
// EvictLRU evicts the least recently used files to free up space
|
||||||
|
|||||||
+74
-41
@@ -371,7 +371,8 @@ func testKey(i int) string {
|
|||||||
// artifacts for victims are immediately gone (no resurrection via lazy discovery in Stat/Open),
|
// artifacts for victims are immediately gone (no resurrection via lazy discovery in Stat/Open),
|
||||||
// and that recreating the same key produces independent content that is not subject to any
|
// and that recreating the same key produces independent content that is not subject to any
|
||||||
// stale eviction unlinks. This exercises the coordinated WLock remove path for DiskFS.
|
// stale eviction unlinks. This exercises the coordinated WLock remove path for DiskFS.
|
||||||
// Uses tolerant checks suitable for raw DiskFS lazy discovery + bg size.
|
// Create does not wait on initDone, so this also covers insertBatch racing with eviction:
|
||||||
|
// gone files must not be re-indexed (Stat present / disk missing).
|
||||||
func TestDiskFS_EvictDiskVisibilityAndRecreateSafety(t *testing.T) {
|
func TestDiskFS_EvictDiskVisibilityAndRecreateSafety(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
td := t.TempDir()
|
td := t.TempDir()
|
||||||
@@ -400,47 +401,21 @@ func TestDiskFS_EvictDiskVisibilityAndRecreateSafety(t *testing.T) {
|
|||||||
_ = d.EvictBySize(1024*1024, true)
|
_ = d.EvictBySize(1024*1024, true)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Consistency check: never have a key absent from Stat but with a file on disk (would indicate
|
// Drain bg population so insertBatch cannot still be in flight when we audit.
|
||||||
// either resurrection risk or orphan). If Stat succeeds, file should exist.
|
_ = d.Size()
|
||||||
// A few retries tolerate the documented lazy discovery + eviction coordination windows under
|
|
||||||
// artificial "force massive eviction then immediate audit" load (especially visible under -race).
|
// Consistency: Stat success iff the file exists on disk. insertBatch must not resurrect
|
||||||
for attempt := 0; attempt < 3; attempt++ {
|
// keys whose backing files were already evicted.
|
||||||
bad := false
|
for _, k := range created {
|
||||||
for _, k := range created {
|
p := d.pathForKey(k)
|
||||||
p := d.pathForKey(k)
|
_, statErr := d.Stat(k)
|
||||||
_, statErr := d.Stat(k)
|
_, diskErr := os.Stat(p)
|
||||||
_, diskErr := os.Stat(p)
|
if statErr != nil {
|
||||||
if statErr != nil {
|
if !os.IsNotExist(diskErr) {
|
||||||
if !os.IsNotExist(diskErr) {
|
t.Errorf("key %s absent via Stat but file lingers on disk at %s (resurrection risk)", k, p)
|
||||||
bad = true
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if diskErr != nil {
|
|
||||||
bad = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !bad {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
if attempt < 2 {
|
|
||||||
time.Sleep(10 * time.Millisecond)
|
|
||||||
} else {
|
|
||||||
// On final attempt, report the last observed state for the keys
|
|
||||||
for _, k := range created {
|
|
||||||
p := d.pathForKey(k)
|
|
||||||
_, statErr := d.Stat(k)
|
|
||||||
_, diskErr := os.Stat(p)
|
|
||||||
if statErr != nil {
|
|
||||||
if !os.IsNotExist(diskErr) {
|
|
||||||
t.Errorf("key %s absent via Stat but file lingers on disk at %s (resurrection risk)", k, p)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if diskErr != nil {
|
|
||||||
t.Errorf("key %s present via Stat but missing on disk: %v", k, diskErr)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
} else if diskErr != nil {
|
||||||
|
t.Errorf("key %s present via Stat but missing on disk: %v", k, diskErr)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -464,6 +439,64 @@ func TestDiskFS_EvictDiskVisibilityAndRecreateSafety(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestDiskFS_InsertBatchSkipsGoneFiles is the fail-closed contract for bg/lazy index
|
||||||
|
// insert: a discoveredFile whose path was removed (evicted) must not be re-inserted
|
||||||
|
// into d.info. That resurrection is what made Stat succeed while os.Stat failed.
|
||||||
|
func TestDiskFS_InsertBatchSkipsGoneFiles(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
td := t.TempDir()
|
||||||
|
d, err := New(td, 10*1024*1024, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
_ = d.Size() // finish constructor scan so it cannot also index these keys
|
||||||
|
|
||||||
|
liveKey := "live"
|
||||||
|
goneKey := "gone"
|
||||||
|
writeKey := func(key, body string) os.FileInfo {
|
||||||
|
t.Helper()
|
||||||
|
p := d.pathForKey(key)
|
||||||
|
if err := os.MkdirAll(filepath.Dir(p), 0700); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(p, []byte(body), 0600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
st, err := os.Stat(p)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return st
|
||||||
|
}
|
||||||
|
liveInfo := writeKey(liveKey, "still-here")
|
||||||
|
goneInfo := writeKey(goneKey, "about-to-vanish")
|
||||||
|
if err := os.Remove(d.pathForKey(goneKey)); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
d.insertBatch([]discoveredFile{
|
||||||
|
{key: liveKey, size: liveInfo.Size(), osInfo: liveInfo},
|
||||||
|
{key: goneKey, size: goneInfo.Size(), osInfo: goneInfo},
|
||||||
|
})
|
||||||
|
|
||||||
|
d.mu.RLock()
|
||||||
|
_, liveExists := d.info[liveKey]
|
||||||
|
_, goneExists := d.info[goneKey]
|
||||||
|
d.mu.RUnlock()
|
||||||
|
if !liveExists {
|
||||||
|
t.Errorf("insertBatch skipped live key %s", liveKey)
|
||||||
|
}
|
||||||
|
if goneExists {
|
||||||
|
t.Errorf("insertBatch resurrected gone key %s", goneKey)
|
||||||
|
}
|
||||||
|
if _, err := d.Stat(goneKey); err == nil {
|
||||||
|
t.Errorf("Stat succeeded for gone key %s", goneKey)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(d.pathForKey(liveKey)); err != nil {
|
||||||
|
t.Errorf("live key %s missing on disk: %v", liveKey, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TestDiskFS_EvictBoundedLargeN exercises the maxEvictBatch early-break logic (Idea #2)
|
// TestDiskFS_EvictBoundedLargeN exercises the maxEvictBatch early-break logic (Idea #2)
|
||||||
// under a map size >> batch limit. Forces repeated eviction rounds via GC-style pressure
|
// under a map size >> batch limit. Forces repeated eviction rounds via GC-style pressure
|
||||||
// and asserts progress + consistency (no resurrection/orphans). Covers bounded collection
|
// and asserts progress + consistency (no resurrection/orphans). Covers bounded collection
|
||||||
|
|||||||
@@ -289,7 +289,8 @@ func (m *MemoryFS) Delete(key string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stat returns file information
|
// Stat returns a snapshot of file information. The returned *FileInfo is not
|
||||||
|
// the live cache entry; Close may update Size on the in-map object under m.mu.
|
||||||
func (m *MemoryFS) Stat(key string) (*types.FileInfo, error) {
|
func (m *MemoryFS) Stat(key string) (*types.FileInfo, error) {
|
||||||
if key == "" {
|
if key == "" {
|
||||||
return nil, vfserror.ErrInvalidKey
|
return nil, vfserror.ErrInvalidKey
|
||||||
@@ -310,7 +311,7 @@ func (m *MemoryFS) Stat(key string) (*types.FileInfo, error) {
|
|||||||
defer m.mu.RUnlock()
|
defer m.mu.RUnlock()
|
||||||
|
|
||||||
if fi, ok := m.info[key]; ok {
|
if fi, ok := m.info[key]; ok {
|
||||||
return fi, nil
|
return fi.Clone(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil, vfserror.ErrNotFound
|
return nil, vfserror.ErrNotFound
|
||||||
|
|||||||
@@ -346,6 +346,45 @@ func TestMemoryFS_ConcurrentCloseAndEvict_RaceFree(t *testing.T) {
|
|||||||
_ = m.LRU.Len()
|
_ = m.LRU.Len()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestMemoryFS_StatReturnsSnapshot(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
m, err := New(1024)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
w, err := m.Create("k", 10)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := w.Write([]byte("hello")); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := w.Close(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fi, err := m.Stat("k")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if fi.Size != 5 {
|
||||||
|
t.Fatalf("size %d want 5", fi.Size)
|
||||||
|
}
|
||||||
|
fi.Size = 999
|
||||||
|
fi.AccessCount = 0
|
||||||
|
|
||||||
|
fi2, err := m.Stat("k")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if fi2.Size != 5 {
|
||||||
|
t.Errorf("Stat returned live FileInfo; store size became %d", fi2.Size)
|
||||||
|
}
|
||||||
|
if fi2.AccessCount == 0 {
|
||||||
|
t.Error("Stat returned live FileInfo; AccessCount mutation leaked")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestMemoryFS_EvictVariantsAndErrors(t *testing.T) {
|
func TestMemoryFS_EvictVariantsAndErrors(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
m, err := New(800)
|
m, err := New(800)
|
||||||
|
|||||||
@@ -27,6 +27,17 @@ func NewFileInfo(key string, size int64) *FileInfo {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Clone returns a snapshot copy of fi. Stat returns Clone() so callers can
|
||||||
|
// read Size and other fields without racing Close/Open mutations of the
|
||||||
|
// in-map FileInfo.
|
||||||
|
func (fi *FileInfo) Clone() *FileInfo {
|
||||||
|
if fi == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
cp := *fi
|
||||||
|
return &cp
|
||||||
|
}
|
||||||
|
|
||||||
// NewFileInfoFromOS creates a FileInfo from os.FileInfo
|
// NewFileInfoFromOS creates a FileInfo from os.FileInfo
|
||||||
func NewFileInfoFromOS(info os.FileInfo, key string) *FileInfo {
|
func NewFileInfoFromOS(info os.FileInfo, key string) *FileInfo {
|
||||||
return &FileInfo{
|
return &FileInfo{
|
||||||
|
|||||||
@@ -16,6 +16,34 @@ func TestNewFileInfo(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestFileInfoClone(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
fi := NewFileInfo("k", 42)
|
||||||
|
fi.AccessCount = 7
|
||||||
|
cp := fi.Clone()
|
||||||
|
if cp == fi {
|
||||||
|
t.Fatal("Clone returned the same pointer")
|
||||||
|
}
|
||||||
|
if cp.Key != fi.Key || cp.Size != fi.Size || cp.AccessCount != fi.AccessCount {
|
||||||
|
t.Errorf("Clone mismatch: %+v vs %+v", cp, fi)
|
||||||
|
}
|
||||||
|
if !cp.ATime.Equal(fi.ATime) || !cp.CTime.Equal(fi.CTime) {
|
||||||
|
t.Error("Clone timestamps mismatch")
|
||||||
|
}
|
||||||
|
cp.Size = 99
|
||||||
|
cp.AccessCount = 1
|
||||||
|
if fi.Size != 42 || fi.AccessCount != 7 {
|
||||||
|
t.Error("mutating Clone affected original")
|
||||||
|
}
|
||||||
|
if NewFileInfo("x", 1).Clone() == nil {
|
||||||
|
t.Error("Clone of non-nil was nil")
|
||||||
|
}
|
||||||
|
var none *FileInfo
|
||||||
|
if none.Clone() != nil {
|
||||||
|
t.Error("Clone of nil was non-nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestUpdateAccess(t *testing.T) {
|
func TestUpdateAccess(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
fi := NewFileInfo("k", 1)
|
fi := NewFileInfo("k", 1)
|
||||||
|
|||||||
Reference in New Issue
Block a user