commit f54150c3d232ad11e59fea456f563bb3cdf19133 Author: Justin Harms Date: Sat Jan 18 20:03:40 2025 -0600 initial commit diff --git a/.gitea/workflows/pull-request-ci.yaml b/.gitea/workflows/pull-request-ci.yaml new file mode 100644 index 0000000..4793bda --- /dev/null +++ b/.gitea/workflows/pull-request-ci.yaml @@ -0,0 +1,92 @@ +name: CI +on: + pull_request: + +jobs: + lint: + name: Lint + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup go + uses: actions/setup-go@v5 + with: + go-version-file: 'go.mod' + check-latest: true + cache: true + + - name: Install go dependencies + run: go mod tidy + + - name: Lint + uses: golangci/golangci-lint-action@v3 + with: + args: -D errcheck + version: latest + + test: + name: Test + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up go + uses: actions/setup-go@v5 + with: + go-version-file: 'go.mod' + check-latest: true + cache: true + + - name: Get go dependencies + run: | + go mod tidy + + - name: Test + run: go test -race -v -shuffle=on ./... + + build: + name: Build + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up go + uses: actions/setup-go@v5 + with: + go-version-file: 'go.mod' + check-latest: true + cache: true + + - name: Get go dependencies + run: | + go mod tidy + + - name: Build for linux + run: | + go build -o bin/SteamCache2-${VERSION}-${BUILD}-${GOOS}-${GOARCH}.exe main.go + env: + VERSION: ${{github.ref_name}} + BUILD: ${{github.run_number}} + GOOS: linux + GOARCH: amd64 + EXE: "" + + - name: Build for windows + run: | + go build -o bin/SteamCache2-${VERSION}-${BUILD}-${GOOS}-${GOARCH}.exe main.go + env: + VERSION: ${{github.ref_name}} + BUILD: ${{github.run_number}} + GOOS: windows + GOARCH: amd64 + EXE: ".exe" + + - name: Upload artifact + uses: actions/upload-artifact@v3 + with: + name: SteamCache2-${{github.ref_name}}-${{github.run_number}} + path: bin/ \ No newline at end of file diff --git a/.gitea/workflows/push-ci.yaml b/.gitea/workflows/push-ci.yaml new file mode 100644 index 0000000..f5191ea --- /dev/null +++ b/.gitea/workflows/push-ci.yaml @@ -0,0 +1,91 @@ +name: CI +on: + push: + branches: [main, develop] + +jobs: + lint: + name: Lint + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup go + uses: actions/setup-go@v5 + with: + go-version-file: 'go.mod' + check-latest: true + cache: true + + - name: Install go dependencies + run: go mod tidy + + - name: Lint + uses: golangci/golangci-lint-action@v3 + with: + args: -D errcheck + version: latest + + test: + name: Test + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up go + uses: actions/setup-go@v5 + with: + go-version-file: 'go.mod' + check-latest: true + cache: true + + - name: Get go dependencies + run: | + go mod tidy + + - name: Test + run: go test -race -v -shuffle=on ./... + + build: + name: Build + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up go + uses: actions/setup-go@v5 + with: + go-version-file: 'go.mod' + check-latest: true + cache: true + + - name: Get go dependencies + run: | + go mod tidy + + - name: Build for linux + run: | + go build -o bin/SteamCache2-${VERSION}-${BUILD}-${GOOS}-${GOARCH}.exe main.go + env: + VERSION: ${{github.ref_name}} + BUILD: ${{github.run_number}} + GOOS: linux + GOARCH: amd64 + + - name: Build for windows + run: | + go build -o bin/SteamCache2-${VERSION}-${BUILD}-${GOOS}-${GOARCH}.exe main.go + env: + VERSION: ${{github.ref_name}} + BUILD: ${{github.run_number}} + GOOS: windows + GOARCH: amd64 + + - name: Upload artifact + uses: actions/upload-artifact@v3 + with: + name: SteamCache2-${{github.ref_name}}-${{github.run_number}} + path: bin/ \ No newline at end of file diff --git a/.gitea/workflows/tag-release.yaml b/.gitea/workflows/tag-release.yaml new file mode 100644 index 0000000..65a8905 --- /dev/null +++ b/.gitea/workflows/tag-release.yaml @@ -0,0 +1,63 @@ +name: Release versioned tag +on: + push: + tags: + - 'v*' + +jobs: + release: + name: Build versioned release + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup go + uses: actions/setup-go@v5 + with: + go-version-file: 'go.mod' + check-latest: true + cache: true + + - name: Install go dependencies + run: go mod tidy + + - name: Lint + uses: golangci/golangci-lint-action@v3 + with: + args: -D errcheck + version: latest + + - name: Test + run: go test -race -v -shuffle=on ./... + + # - name: Build for linux + # run: | + # go build -o bin/SteamCache2-${VERSION#v}-${BUILD}-${GOOS}-${GOARCH} main.go + # env: + # VERSION: ${{github.ref_name}} + # BUILD: ${{github.run_number}} + # GOOS: linux + # GOARCH: amd64 + # EXE: "" + + # - name: Build for windows + # run: | + # go build -o bin/SteamCache2-${VERSION#v}-${BUILD}-${GOOS}-${GOARCH} main.go + # env: + # VERSION: ${{github.ref_name}} + # BUILD: ${{github.run_number}} + # GOOS: windows + # GOARCH: amd64 + # EXE: ".exe" + + - name: Release binaries + uses: goreleaser/goreleaser-action@v6 + with: + distribution: goreleaser + version: 'latest' + args: release --clean + env: + GITEA_TOKEN: ${{secrets.RELEASE_TOKEN}} \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b476faa --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +tmp/ +__*.exe \ No newline at end of file diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..6aec7f0 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,21 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "name": "Launch Package", + "type": "go", + "request": "launch", + "mode": "auto", + "program": "${workspaceFolder}/main.go", + "args": [ + "--address", ":80", + "--memory", "1G", + "--disk", "10G", + "--disk-path", "tmp/disk", + ], + } + ] +} \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..b30ee75 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright © 2025 s1d3sw1ped + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..55b0394 --- /dev/null +++ b/README.md @@ -0,0 +1,27 @@ +# SteamCache2 + +SteamCache2 is a blazing fast download cache for Steam, designed to reduce bandwidth usage and speed up game downloads. + +## Features + +- High-speed caching for Steam downloads +- Tiered storage for getting the most out of your storage media +- Reduces bandwidth usage +- Easy to set up and configure aside from dns stuff to trick Steam into using it +- Supports multiple clients + +## Usage + +1. Start the cache server: + ```sh + ./SteamCache2 + ``` +2. Configure your Steam client to use the cache server as a proxy. + +## License + +This project is licensed. See the [LICENSE](LICENSE) file for details. + +## Acknowledgements + +- Inspired by other caching solutions for game platforms. diff --git a/cmd/root.go b/cmd/root.go new file mode 100644 index 0000000..941fa86 --- /dev/null +++ b/cmd/root.go @@ -0,0 +1,77 @@ +/* +Copyright © 2025 s1d3sw1ped + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. +*/ +package cmd + +import ( + "os" + "s1d3sw1ped/SteamCache2/steamcache" + + "github.com/spf13/cobra" +) + +var ( + address string + memory string + memorymultiplier int + disk string + diskmultiplier int + diskpath string +) + +var rootCmd = &cobra.Command{ + Use: "SteamCache2", + Short: "SteamCache2 is a caching solution for Steam game updates and installations", + Long: `SteamCache2 is a caching solution designed to optimize the delivery of Steam game updates and installations. + It reduces bandwidth usage and speeds up the download process by caching game files locally. + This tool is particularly useful for environments with multiple Steam users, such as gaming cafes or households with multiple gamers. + By caching game files, SteamCache2 ensures that subsequent downloads of the same files are served from the local cache, + significantly improving download times and reducing the load on the internet connection.`, + Run: func(cmd *cobra.Command, args []string) { + sc := steamcache.New( + address, + memory, + memorymultiplier, + disk, + diskmultiplier, + diskpath, + ) + sc.Run() + }, +} + +// Execute adds all child commands to the root command and sets flags appropriately. +// This is called by main.main(). It only needs to happen once to the rootCmd. +func Execute() { + err := rootCmd.Execute() + if err != nil { + os.Exit(1) + } +} + +func init() { + rootCmd.Flags().StringVarP(&address, "address", "a", ":80", "The address to listen on") + rootCmd.Flags().StringVarP(&memory, "memory", "m", "100MB", "The size of the memory cache") + rootCmd.Flags().IntVarP(&memorymultiplier, "memory-multiplier", "M", 10, "The multiplier for the memory cache") + rootCmd.Flags().StringVarP(&disk, "disk", "d", "10GB", "The size of the disk cache") + rootCmd.Flags().IntVarP(&diskmultiplier, "disk-multiplier", "D", 10, "The multiplier for the disk cache") + rootCmd.Flags().StringVarP(&diskpath, "disk-path", "p", "tmp/steamcache2-disk", "The path to the disk cache") +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..2fa4bb3 --- /dev/null +++ b/go.mod @@ -0,0 +1,14 @@ +module s1d3sw1ped/SteamCache2 + +go 1.23.0 + +require ( + github.com/docker/go-units v0.5.0 + github.com/spf13/cobra v1.8.1 + golang.org/x/exp v0.0.0-20250106191152-7588d65b2ba8 +) + +require ( + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/spf13/pflag v1.0.5 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..9fd6e6e --- /dev/null +++ b/go.sum @@ -0,0 +1,14 @@ +github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= +github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM= +github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +golang.org/x/exp v0.0.0-20250106191152-7588d65b2ba8 h1:yqrTHse8TCMW1M1ZCP+VAR/l0kKxwaAIqN/il7x4voA= +golang.org/x/exp v0.0.0-20250106191152-7588d65b2ba8/go.mod h1:tujkw807nyEEAamNbDrEGzRav+ilXA7PCRAd6xsmwiU= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/main.go b/main.go new file mode 100644 index 0000000..5b73e58 --- /dev/null +++ b/main.go @@ -0,0 +1,28 @@ +/* +Copyright © 2025 s1d3sw1ped + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. +*/ +package main + +import "s1d3sw1ped/SteamCache2/cmd" + +func main() { + cmd.Execute() +} diff --git a/steamcache/gc.go b/steamcache/gc.go new file mode 100644 index 0000000..c1312d1 --- /dev/null +++ b/steamcache/gc.go @@ -0,0 +1,65 @@ +package steamcache + +import ( + "log" + "s1d3sw1ped/SteamCache2/vfs" + "s1d3sw1ped/SteamCache2/vfs/cachestate" + "time" + + "github.com/docker/go-units" + "golang.org/x/exp/rand" +) + +func randomgc(vfss vfs.VFS, stats []*vfs.FileInfo) int64 { + // Pick a random file to delete + randfile := stats[rand.Intn(len(stats))] + sz := randfile.Size() + err := vfss.Delete(randfile.Name()) + if err != nil { + // If we failed to delete the file, log it and return 0 + // log.Printf("Failed to delete %s: %v", randfile.Name(), err) + return 0 + } + + return sz +} + +func memorygc(vfss vfs.VFS, size int) { + tstart := time.Now() + deletions := 0 + targetreclaim := int64(size) + var reclaimed int64 + + stats := vfss.StatAll() + for { + reclaimed += randomgc(vfss, stats) + deletions++ + if reclaimed >= targetreclaim { + break + } + } + + log.Printf("GC of %s took %v to reclaim %s by deleting %d files", vfss.Name(), time.Since(tstart), units.HumanSize(float64(reclaimed)), deletions) +} + +func diskgc(vfss vfs.VFS, size int) { + tstart := time.Now() + deletions := 0 + targetreclaim := int64(size) + var reclaimed int64 + + stats := vfss.StatAll() + for { + reclaimed += randomgc(vfss, stats) + deletions++ + if reclaimed >= targetreclaim { + break + } + } + + log.Printf("GC of %s took %v to reclaim %s by deleting %d files", vfss.Name(), time.Since(tstart), units.HumanSize(float64(reclaimed)), deletions) +} + +func cachehandler(fi *vfs.FileInfo, cs cachestate.CacheState) bool { + return time.Since(fi.AccessTime()) < time.Minute*10 // Put files in the cache if they've been accessed twice in the last 10 minutes +} diff --git a/steamcache/steamcache.go b/steamcache/steamcache.go new file mode 100644 index 0000000..ecf9c35 --- /dev/null +++ b/steamcache/steamcache.go @@ -0,0 +1,227 @@ +package steamcache + +import ( + "io" + "log" + "net/http" + "net/url" + "s1d3sw1ped/SteamCache2/vfs" + "s1d3sw1ped/SteamCache2/vfs/cache" + "s1d3sw1ped/SteamCache2/vfs/disk" + "s1d3sw1ped/SteamCache2/vfs/gc" + "s1d3sw1ped/SteamCache2/vfs/memory" + syncfs "s1d3sw1ped/SteamCache2/vfs/sync" + "strings" + "sync" + "time" + + "github.com/docker/go-units" +) + +type SteamCache struct { + address string + vfs vfs.VFS + + memory *memory.MemoryFS + disk *disk.DiskFS + + dirty bool + mu sync.Mutex +} + +func New(address string, memorySize string, memoryMultiplier int, diskSize string, diskMultiplier int, diskPath string) *SteamCache { + memorysize, err := units.FromHumanSize(memorySize) + if err != nil { + panic(err) + } + + disksize, err := units.FromHumanSize(diskSize) + if err != nil { + panic(err) + } + + m := memory.New(memorysize) + d := disk.New(diskPath, disksize) + + sc := &SteamCache{ + address: address, + vfs: syncfs.New( + cache.New( + gc.New( + m, + memoryMultiplier, + memorygc, + ), + gc.New( + d, + diskMultiplier, + diskgc, + ), + cachehandler, + ), + ), + + memory: m, + disk: d, + } + + if d.Size() > d.Capacity() { + diskgc(d, int(d.Size()-d.Capacity())) + } + + return sc +} + +func (sc *SteamCache) Run() { + log.Printf("SteamCache2 running on %s", sc.address) + + sc.mu.Lock() + sc.dirty = true + sc.mu.Unlock() + + sc.LogStats() + + t := time.NewTicker(10 * time.Second) + go func() { + for range t.C { + // log cache stats + sc.LogStats() + } + }() + + http.ListenAndServe(sc.address, sc) +} + +func (sc *SteamCache) LogStats() { + sc.mu.Lock() + defer sc.mu.Unlock() + if sc.dirty { + log.Printf( + "SteamCache2 %s: (%d) %s/%s %s: (%d) %s/%s", + sc.memory.Name(), len(sc.memory.StatAll()), units.HumanSize(float64(sc.memory.Size())), units.HumanSize(float64(sc.memory.Capacity())), + sc.disk.Name(), len(sc.disk.StatAll()), units.HumanSize(float64(sc.disk.Size())), units.HumanSize(float64(sc.disk.Capacity())), + ) + sc.dirty = false + } +} + +func (sc *SteamCache) ServeHTTP(w http.ResponseWriter, r *http.Request) { + + if r.Method != http.MethodGet { + http.Error(w, "Only GET method is supported", http.StatusMethodNotAllowed) + return + } + + if r.URL.String() == "/lancache-heartbeat" { + w.Header().Add("X-LanCache-Processed-By", "SteamCache2") + w.WriteHeader(http.StatusNoContent) + w.Write(nil) + return + } + + if r.Header.Get("User-Agent") != "Valve/Steam HTTP Client 1.0" { + http.Error(w, "Only Valve/Steam HTTP Client 1.0 is supported", http.StatusForbidden) + return + } + + if strings.Contains(r.URL.String(), "manifest") { + w.Header().Add("X-LanCache-Processed-By", "SteamCache2") + forward(w, r) + return + } + + tstart := time.Now() + defer func() { + log.Printf("%s %s %s took %s", r.Method, r.URL.String(), w.Header().Get("X-LanCache-Status"), time.Since(tstart)) + }() + + sc.mu.Lock() + sc.dirty = true + sc.mu.Unlock() + + w.Header().Add("X-LanCache-Processed-By", "SteamCache2") // SteamPrefill uses this header to determine if the request was processed by the cache maybe steam uses it too + + cacheKey := r.URL.String() + + // if vfs is also a vfs.GetSer, we can use it to get the cache state + + data, err := sc.vfs.Get(cacheKey) + if err == nil { + w.Header().Add("X-LanCache-Status", "HIT") + w.Write(data) + return + } + + htt := "http://" + if r.Header.Get("X-Sls-Https") == "enable" { + htt = "https://" + } + + base := htt + r.Host + + hosturl, err := url.JoinPath(base, cacheKey) + if err != nil { + http.Error(w, "Failed to join URL path", http.StatusInternalServerError) + return + } + + resp, err := http.Get(hosturl) + if err != nil { + http.Error(w, "Failed to fetch the requested URL", http.StatusInternalServerError) + return + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + http.Error(w, "Failed to fetch the requested URL", resp.StatusCode) + return + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + http.Error(w, "Failed to read response body", http.StatusInternalServerError) + return + } + + sc.vfs.Set(cacheKey, body) + w.Header().Add("X-LanCache-Status", "MISS") + w.Write(body) +} + +func forward(w http.ResponseWriter, r *http.Request) { + htt := "http://" + if r.Header.Get("X-Sls-Https") == "enable" { + htt = "https://" + } + + base := htt + r.Host + + cacheKey := r.URL.String() + + hosturl, err := url.JoinPath(base, cacheKey) + if err != nil { + http.Error(w, "Failed to join URL path", http.StatusInternalServerError) + return + } + + resp, err := http.Get(hosturl) + if err != nil { + http.Error(w, "Failed to fetch the requested URL", http.StatusInternalServerError) + return + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + http.Error(w, "Failed to fetch the requested URL", resp.StatusCode) + return + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + http.Error(w, "Failed to read response body", http.StatusInternalServerError) + return + } + + w.Header().Add("X-LanCache-Status", "MISS") + w.Write(body) +} diff --git a/vfs/cache/cache.go b/vfs/cache/cache.go new file mode 100644 index 0000000..1af7a0a --- /dev/null +++ b/vfs/cache/cache.go @@ -0,0 +1,151 @@ +package cache + +import ( + "fmt" + "s1d3sw1ped/SteamCache2/vfs" + "s1d3sw1ped/SteamCache2/vfs/cachestate" + "s1d3sw1ped/SteamCache2/vfs/vfserror" +) + +// Ensure CacheFS implements VFS. +var _ vfs.VFS = (*CacheFS)(nil) + +// CacheFS is a virtual file system that caches files in memory and on disk. +type CacheFS struct { + fast vfs.VFS + slow vfs.VFS + + cacheHandler CacheHandler +} + +type CacheHandler func(*vfs.FileInfo, cachestate.CacheState) bool + +// New creates a new CacheFS. fast is used for caching, and slow is used for storage. fast should obviously be faster than slow. +func New(fast, slow vfs.VFS, cacheHandler CacheHandler) *CacheFS { + if slow == nil { + panic("slow is nil") + } + + if fast == slow { + panic("fast and slow are the same") + } + + return &CacheFS{ + fast: fast, + slow: slow, + + cacheHandler: cacheHandler, + } +} + +// cacheState returns the state of the file at key. +func (c *CacheFS) cacheState(key string) cachestate.CacheState { + if c.fast != nil { + if _, err := c.fast.Stat(key); err == nil { + return cachestate.CacheStateHit + } + } + + if _, err := c.slow.Stat(key); err == nil { + return cachestate.CacheStateMiss + } + + return cachestate.CacheStateNotFound +} + +func (c *CacheFS) Name() string { + return fmt.Sprintf("CacheFS(%s, %s)", c.fast.Name(), c.slow.Name()) +} + +// Size returns the total size of the cache. +func (c *CacheFS) Size() int64 { + return c.slow.Size() +} + +// Set sets the file at key to src. If the file is already in the cache, it is replaced. +func (c *CacheFS) Set(key string, src []byte) error { + state := c.cacheState(key) + + switch state { + case cachestate.CacheStateHit: + if c.fast != nil { + c.fast.Delete(key) + } + return c.slow.Set(key, src) + case cachestate.CacheStateMiss, cachestate.CacheStateNotFound: + return c.slow.Set(key, src) + } + + panic(vfserror.ErrUnreachable) +} + +// Delete deletes the file at key from the cache. +func (c *CacheFS) Delete(key string) error { + if c.fast != nil { + c.fast.Delete(key) + } + return c.slow.Delete(key) +} + +// Get returns the file at key. If the file is not in the cache, it is fetched from the storage. +func (c *CacheFS) Get(key string) ([]byte, error) { + src, _, err := c.GetS(key) + return src, err +} + +// GetS returns the file at key. If the file is not in the cache, it is fetched from the storage. It also returns the cache state. +func (c *CacheFS) GetS(key string) ([]byte, cachestate.CacheState, error) { + state := c.cacheState(key) + + switch state { + case cachestate.CacheStateHit: + // if c.fast == nil then cacheState cannot be CacheStateHit so we can safely ignore the check + src, err := c.fast.Get(key) + return src, state, err + case cachestate.CacheStateMiss: + src, err := c.slow.Get(key) + if err != nil { + return nil, state, err + } + + sstat, _ := c.slow.Stat(key) + if sstat != nil && c.fast != nil { // file found in slow storage and fast storage is available + // We are accessing the file from the slow storage, and the file has been accessed less then a minute ago so it popular, so we should update the fast storage with the latest file. + if c.cacheHandler != nil && c.cacheHandler(sstat, state) { + if err := c.fast.Set(key, src); err != nil { + return nil, state, err + } + } + } + + return src, state, nil + case cachestate.CacheStateNotFound: + return nil, state, vfserror.ErrNotFound + } + + panic(vfserror.ErrUnreachable) +} + +// Stat returns information about the file at key. +// Warning: This will return information about the file in the fastest storage its in. +func (c *CacheFS) Stat(key string) (*vfs.FileInfo, error) { + state := c.cacheState(key) + + switch state { + case cachestate.CacheStateHit: + // if c.fast == nil then cacheState cannot be CacheStateHit so we can safely ignore the check + return c.fast.Stat(key) + case cachestate.CacheStateMiss: + return c.slow.Stat(key) + case cachestate.CacheStateNotFound: + return nil, vfserror.ErrNotFound + } + + panic(vfserror.ErrUnreachable) +} + +// StatAll returns information about all files in the cache. +// Warning: This only returns information about the files in the slow storage. +func (c *CacheFS) StatAll() []*vfs.FileInfo { + return c.slow.StatAll() +} diff --git a/vfs/cache/cache_test.go b/vfs/cache/cache_test.go new file mode 100644 index 0000000..c03368d --- /dev/null +++ b/vfs/cache/cache_test.go @@ -0,0 +1,205 @@ +package cache + +import ( + "errors" + "testing" + + "s1d3sw1ped/SteamCache2/vfs" + "s1d3sw1ped/SteamCache2/vfs/cachestate" + "s1d3sw1ped/SteamCache2/vfs/memory" + "s1d3sw1ped/SteamCache2/vfs/vfserror" +) + +func testMemory() vfs.VFS { + return memory.New(1024) +} + +func TestNew(t *testing.T) { + t.Parallel() + + fast := testMemory() + slow := testMemory() + + cache := New(fast, slow, nil) + if cache == nil { + t.Fatal("expected cache to be non-nil") + } +} + +func TestNewPanics(t *testing.T) { + t.Parallel() + + defer func() { + if r := recover(); r == nil { + t.Fatal("expected panic but did not get one") + } + }() + + New(nil, nil, nil) +} + +func TestSetAndGet(t *testing.T) { + t.Parallel() + + fast := testMemory() + slow := testMemory() + cache := New(fast, slow, nil) + + key := "test" + value := []byte("value") + + if err := cache.Set(key, value); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + got, err := cache.Get(key) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if string(got) != string(value) { + t.Fatalf("expected %s, got %s", value, got) + } +} + +func TestSetAndGetNoFast(t *testing.T) { + t.Parallel() + + slow := testMemory() + cache := New(nil, slow, nil) + + key := "test" + value := []byte("value") + + if err := cache.Set(key, value); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + got, err := cache.Get(key) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if string(got) != string(value) { + t.Fatalf("expected %s, got %s", value, got) + } +} +func TestCaching(t *testing.T) { + t.Parallel() + + fast := testMemory() + slow := testMemory() + cache := New(fast, slow, func(fi *vfs.FileInfo, cs cachestate.CacheState) bool { + return true + }) + + key := "test" + value := []byte("value") + + if err := fast.Set(key, value); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if err := slow.Set(key, value); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + _, state, err := cache.GetS(key) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if state != cachestate.CacheStateHit { + t.Fatalf("expected %v, got %v", cachestate.CacheStateHit, state) + } + + err = fast.Delete(key) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + got, state, err := cache.GetS(key) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if state != cachestate.CacheStateMiss { + t.Fatalf("expected %v, got %v", cachestate.CacheStateMiss, state) + } + + if string(got) != string(value) { + t.Fatalf("expected %s, got %s", value, got) + } + + err = cache.Delete(key) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + _, state, err = cache.GetS(key) + if !errors.Is(err, vfserror.ErrNotFound) { + t.Fatalf("expected %v, got %v", vfserror.ErrNotFound, err) + } + if state != cachestate.CacheStateNotFound { + t.Fatalf("expected %v, got %v", cachestate.CacheStateNotFound, state) + } +} + +func TestGetNotFound(t *testing.T) { + t.Parallel() + + fast := testMemory() + slow := testMemory() + cache := New(fast, slow, nil) + + _, err := cache.Get("nonexistent") + if !errors.Is(err, vfserror.ErrNotFound) { + t.Fatalf("expected %v, got %v", vfserror.ErrNotFound, err) + } +} + +func TestDelete(t *testing.T) { + t.Parallel() + + fast := testMemory() + slow := testMemory() + cache := New(fast, slow, nil) + + key := "test" + value := []byte("value") + + if err := cache.Set(key, value); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if err := cache.Delete(key); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + _, err := cache.Get(key) + if !errors.Is(err, vfserror.ErrNotFound) { + t.Fatalf("expected %v, got %v", vfserror.ErrNotFound, err) + } +} + +func TestStat(t *testing.T) { + t.Parallel() + + fast := testMemory() + slow := testMemory() + cache := New(fast, slow, nil) + + key := "test" + value := []byte("value") + + if err := cache.Set(key, value); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + info, err := cache.Stat(key) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if info == nil { + t.Fatal("expected file info to be non-nil") + } +} diff --git a/vfs/cachestate/cachestate.go b/vfs/cachestate/cachestate.go new file mode 100644 index 0000000..18bb65c --- /dev/null +++ b/vfs/cachestate/cachestate.go @@ -0,0 +1,24 @@ +package cachestate + +import "s1d3sw1ped/SteamCache2/vfs/vfserror" + +type CacheState int + +const ( + CacheStateHit CacheState = iota + CacheStateMiss + CacheStateNotFound +) + +func (c CacheState) String() string { + switch c { + case CacheStateHit: + return "hit" + case CacheStateMiss: + return "miss" + case CacheStateNotFound: + return "not found" + } + + panic(vfserror.ErrUnreachable) +} diff --git a/vfs/disk/disk.go b/vfs/disk/disk.go new file mode 100644 index 0000000..50e4726 --- /dev/null +++ b/vfs/disk/disk.go @@ -0,0 +1,211 @@ +package disk + +import ( + "log" + "os" + "path/filepath" + "s1d3sw1ped/SteamCache2/vfs" + "s1d3sw1ped/SteamCache2/vfs/vfserror" + "sync" + "time" + + "github.com/docker/go-units" +) + +// Ensure DiskFS implements VFS. +var _ vfs.VFS = (*DiskFS)(nil) + +// DiskFS is a virtual file system that stores files on disk. +type DiskFS struct { + root string + + info map[string]*vfs.FileInfo + capacity int64 + mu sync.Mutex + sg sync.WaitGroup +} + +// New creates a new DiskFS. +func new(root string, capacity int64, skipinit bool) *DiskFS { + dfs := &DiskFS{ + root: root, + info: make(map[string]*vfs.FileInfo), + capacity: capacity, + mu: sync.Mutex{}, + sg: sync.WaitGroup{}, + } + + os.MkdirAll(dfs.root, 0755) + + if !skipinit { + dfs.init() + } + + return dfs +} + +func New(root string, capacity int64) *DiskFS { + return new(root, capacity, false) +} + +func NewSkipInit(root string, capacity int64) *DiskFS { + return new(root, capacity, true) +} + +func (d *DiskFS) init() { + // log.Printf("DiskFS(%s, %s) init", d.root, units.HumanSize(float64(d.capacity))) + + tstart := time.Now() + + d.walk(d.root) + d.sg.Wait() + + log.Printf("DiskFS(%s, %s) init took %v", d.root, units.HumanSize(float64(d.capacity)), time.Since(tstart)) +} + +func (d *DiskFS) walk(path string) { + d.sg.Add(1) + go func() { + defer d.sg.Done() + filepath.Walk(path, func(npath string, info os.FileInfo, err error) error { + if path == npath { + return nil + } + + if err != nil { + return err + } + + if info.IsDir() { + d.walk(npath) + return filepath.SkipDir + } + + d.mu.Lock() + k := npath[len(d.root)+1:] + d.info[k] = vfs.NewFileInfoFromOS(info, k) + d.mu.Unlock() + + // log.Printf("DiskFS(%s, %s) init: %s", d.root, units.HumanSize(float64(d.capacity)), npath) + return nil + }) + }() +} + +func (d *DiskFS) Capacity() int64 { + return d.capacity +} + +func (d *DiskFS) Name() string { + return "DiskFS" +} + +func (d *DiskFS) Size() int64 { + var size int64 + d.mu.Lock() + defer d.mu.Unlock() + for _, v := range d.info { + size += v.Size() + } + return size +} + +func (d *DiskFS) Set(key string, src []byte) error { + if d.capacity > 0 { + if size := d.Size() + int64(len(src)); size > d.capacity { + return vfserror.ErrDiskFull + } + } + + if _, err := d.Stat(key); err == nil { + d.Delete(key) + } + + d.mu.Lock() + defer d.mu.Unlock() + os.MkdirAll(filepath.Join(d.root, filepath.Dir(key)), 0755) + if err := os.WriteFile(filepath.Join(d.root, key), src, 0644); err != nil { + return err + } + + fi, err := os.Stat(filepath.Join(d.root, key)) + if err != nil { + panic(err) + } + + d.info[key] = vfs.NewFileInfoFromOS(fi, key) + + return nil +} + +// Delete deletes the value of key. +func (d *DiskFS) Delete(key string) error { + _, err := d.Stat(key) + if err != nil { + return err + } + + d.mu.Lock() + defer d.mu.Unlock() + delete(d.info, key) + if err := os.Remove(filepath.Join(d.root, key)); err != nil { + return err + } + + return nil +} + +// Get gets the value of key and returns it. +func (d *DiskFS) Get(key string) ([]byte, error) { + _, err := d.Stat(key) + if err != nil { + return nil, err + } + + d.mu.Lock() + defer d.mu.Unlock() + + data, err := os.ReadFile(filepath.Join(d.root, key)) + if err != nil { + return nil, err + } + + return data, nil +} + +// Stat returns the FileInfo of key. If key is not found in the cache, it will stat the file on disk. If the file is not found on disk, it will return vfs.ErrNotFound. +func (d *DiskFS) Stat(key string) (*vfs.FileInfo, error) { + d.mu.Lock() + fi, ok := d.info[key] + d.mu.Unlock() // unlock before statting the file + + if !ok { + fii, err := os.Stat(filepath.Join(d.root, key)) + if err != nil { + return nil, vfserror.ErrNotFound + } + + d.mu.Lock() // relock to update the info map + defer d.mu.Unlock() // nothing else needs to unlock before returning + + d.info[key] = vfs.NewFileInfoFromOS(fii, key) + fi = d.info[key] + // fallthrough to return fi with shiny new info + } + + return fi, nil +} + +func (m *DiskFS) StatAll() []*vfs.FileInfo { + m.mu.Lock() + defer m.mu.Unlock() + + // hard copy the file info to prevent modification of the original file info or the other way around + files := make([]*vfs.FileInfo, 0, len(m.info)) + for _, v := range m.info { + fi := *v + files = append(files, &fi) + } + + return files +} diff --git a/vfs/disk/disk_test.go b/vfs/disk/disk_test.go new file mode 100644 index 0000000..0644af2 --- /dev/null +++ b/vfs/disk/disk_test.go @@ -0,0 +1,87 @@ +package disk + +import ( + "fmt" + "os" + "path/filepath" + "s1d3sw1ped/SteamCache2/vfs/vfserror" + "testing" +) + +func TestAllDisk(t *testing.T) { + t.Parallel() + + m := NewSkipInit(t.TempDir(), 1024) + if err := m.Set("key", []byte("value")); err != nil { + t.Errorf("Set failed: %v", err) + } + + if err := m.Set("key", []byte("value1")); err != nil { + t.Errorf("Set failed: %v", err) + } + + if d, err := m.Get("key"); err != nil { + t.Errorf("Get failed: %v", err) + } else if string(d) != "value1" { + t.Errorf("Get failed: got %s, want %s", d, "value1") + } + + if err := m.Delete("key"); err != nil { + t.Errorf("Delete failed: %v", err) + } + + if _, err := m.Get("key"); err == nil { + t.Errorf("Get failed: got nil, want %v", vfserror.ErrNotFound) + } + + if err := m.Delete("key"); err == nil { + t.Errorf("Delete failed: got nil, want %v", vfserror.ErrNotFound) + } + + if _, err := m.Stat("key"); err == nil { + t.Errorf("Stat failed: got nil, want %v", vfserror.ErrNotFound) + } + + if err := m.Set("key", []byte("value")); err != nil { + t.Errorf("Set failed: %v", err) + } + + if _, err := m.Stat("key"); err != nil { + t.Errorf("Stat failed: %v", err) + } +} + +func TestLimited(t *testing.T) { + t.Parallel() + + m := NewSkipInit(t.TempDir(), 10) + for i := 0; i < 11; i++ { + if err := m.Set(fmt.Sprintf("key%d", i), []byte("1")); err != nil && i < 10 { + t.Errorf("Set failed: %v", err) + } else if i == 10 && err == nil { + t.Errorf("Set succeeded: got nil, want %v", vfserror.ErrDiskFull) + } + } +} + +func TestInit(t *testing.T) { + t.Parallel() + + td := t.TempDir() + + path := filepath.Join(td, "test", "key") + + os.MkdirAll(filepath.Dir(path), 0755) + + os.WriteFile(path, []byte("value"), 0644) + + m := New(td, 10) + if _, err := m.Get("test/key"); err != nil { + t.Errorf("Get failed: %v", err) + } + + s, _ := m.Stat("test/key") + if s.Name() != "test/key" { + t.Errorf("Stat failed: got %s, want %s", s.Name(), "key") + } +} diff --git a/vfs/fileinfo.go b/vfs/fileinfo.go new file mode 100644 index 0000000..f3433f8 --- /dev/null +++ b/vfs/fileinfo.go @@ -0,0 +1,47 @@ +package vfs + +import ( + "os" + "time" +) + +type FileInfo struct { + name string + size int64 + MTime time.Time + ATime time.Time +} + +func NewFileInfo(name string, size int64, modTime time.Time) *FileInfo { + return &FileInfo{ + name: name, + size: size, + MTime: modTime, + ATime: time.Now(), + } +} + +func NewFileInfoFromOS(f os.FileInfo, key string) *FileInfo { + return &FileInfo{ + name: key, + size: f.Size(), + MTime: f.ModTime(), + ATime: time.Now(), + } +} + +func (f FileInfo) Name() string { + return f.name +} + +func (f FileInfo) Size() int64 { + return f.size +} + +func (f FileInfo) ModTime() time.Time { + return f.MTime +} + +func (f FileInfo) AccessTime() time.Time { + return f.ATime +} diff --git a/vfs/gc/gc.go b/vfs/gc/gc.go new file mode 100644 index 0000000..0122267 --- /dev/null +++ b/vfs/gc/gc.go @@ -0,0 +1,44 @@ +package gc + +import ( + "fmt" + "s1d3sw1ped/SteamCache2/vfs" + "s1d3sw1ped/SteamCache2/vfs/vfserror" +) + +// Ensure GCFS implements VFS. +var _ vfs.VFS = (*GCFS)(nil) + +// GCFS is a virtual file system that calls a GC handler when the disk is full. The GC handler is responsible for freeing up space on the disk. The GCFS is a wrapper around another VFS. +type GCFS struct { + vfs.VFS + multiplier int + gcHanderFunc GCHandlerFunc +} + +// GCHandlerFunc is a function that is called when the disk is full and the GCFS needs to free up space. It is passed the VFS and the size of the file that needs to be written. Its up to the implementation to free up space. How much space is freed is also up to the implementation. +type GCHandlerFunc func(vfs vfs.VFS, size int) + +func New(vfs vfs.VFS, multiplier int, gcHandlerFunc GCHandlerFunc) *GCFS { + return &GCFS{ + VFS: vfs, + multiplier: multiplier, + gcHanderFunc: gcHandlerFunc, + } +} + +// Set overrides the Set method of the VFS interface. It tries to set the key and src, if it fails due to disk full error, it calls the GC handler and tries again. If it still fails it returns the error. +func (g *GCFS) Set(key string, src []byte) error { + err := g.VFS.Set(key, src) // try to set the key and src + + if err == vfserror.ErrDiskFull && g.gcHanderFunc != nil { // if the error is disk full and there is a GC handler + g.gcHanderFunc(g.VFS, len(src)*g.multiplier) // call the GC handler + err = g.VFS.Set(key, src) // try again after GC if it still fails return the error + } + + return err +} + +func (g *GCFS) Name() string { + return fmt.Sprintf("GCFS(%s)", g.VFS.Name()) // wrap the name of the VFS with GCFS so we can see that its a GCFS +} diff --git a/vfs/gc/gc_test.go b/vfs/gc/gc_test.go new file mode 100644 index 0000000..60a9dde --- /dev/null +++ b/vfs/gc/gc_test.go @@ -0,0 +1,111 @@ +package gc + +import ( + "fmt" + "s1d3sw1ped/SteamCache2/vfs" + "s1d3sw1ped/SteamCache2/vfs/memory" + "sort" + "testing" + "time" + + "golang.org/x/exp/rand" +) + +func TestGCSmallRandom(t *testing.T) { + t.Parallel() + + m := memory.New(1024 * 1024 * 16) + gc := New(m, 10, func(vfs vfs.VFS, size int) { + tstart := time.Now() + deletions := 0 + targetreclaim := int64(size) + var reclaimed int64 + + t.Logf("GC starting to reclaim %d bytes", targetreclaim) + + stats := vfs.StatAll() + sort.Slice(stats, func(i, j int) bool { + // Sort by access time so we can remove the oldest files first. + return stats[i].AccessTime().Before(stats[j].AccessTime()) + }) + + // Delete the oldest files until we've reclaimed enough space. + for _, s := range stats { + sz := s.Size() // Get the size of the file + err := vfs.Delete(s.Name()) + if err != nil { + panic(err) + } + reclaimed += sz // Track how much space we've reclaimed + deletions++ // Track how many files we've deleted + + // t.Logf("GC deleting %s, %v", s.Name(), s.AccessTime().Format(time.RFC3339Nano)) + + if reclaimed >= targetreclaim { // We've reclaimed enough space + break + } + } + + t.Logf("GC took %v to reclaim %d bytes by deleting %d files", time.Since(tstart), reclaimed, deletions) + }) + + for i := 0; i < 10000; i++ { + if err := gc.Set(fmt.Sprintf("key:%d", i), genRandomData(1024*1, 1024*4)); err != nil { + t.Errorf("Set failed: %v", err) + } + } + + if gc.Size() > 1024*1024*16 { + t.Errorf("MemoryFS size is %d, want <= 1024", m.Size()) + } +} + +func genRandomData(min int, max int) []byte { + data := make([]byte, rand.Intn(max-min)+min) + rand.Read(data) + return data +} + +func TestGCLargeRandom(t *testing.T) { + t.Parallel() + + m := memory.New(1024 * 1024 * 16) // 16MB + gc := New(m, 10, func(vfs vfs.VFS, size int) { + tstart := time.Now() + deletions := 0 + targetreclaim := int64(size) + var reclaimed int64 + + t.Logf("GC starting to reclaim %d bytes", targetreclaim) + + stats := vfs.StatAll() + sort.Slice(stats, func(i, j int) bool { + // Sort by access time so we can remove the oldest files first. + return stats[i].AccessTime().Before(stats[j].AccessTime()) + }) + + // Delete the oldest files until we've reclaimed enough space. + for _, s := range stats { + sz := s.Size() // Get the size of the file + vfs.Delete(s.Name()) + reclaimed += sz // Track how much space we've reclaimed + deletions++ // Track how many files we've deleted + + if reclaimed >= targetreclaim { // We've reclaimed enough space + break + } + } + + t.Logf("GC took %v to reclaim %d bytes by deleting %d files", time.Since(tstart), reclaimed, deletions) + }) + + for i := 0; i < 10000; i++ { + if err := gc.Set(fmt.Sprintf("key:%d", i), genRandomData(1024, 1024*1024)); err != nil { + t.Errorf("Set failed: %v", err) + } + } + + if gc.Size() > 1024*1024*16 { + t.Errorf("MemoryFS size is %d, want <= 1024", m.Size()) + } +} diff --git a/vfs/memory/memory.go b/vfs/memory/memory.go new file mode 100644 index 0000000..95e76f0 --- /dev/null +++ b/vfs/memory/memory.go @@ -0,0 +1,133 @@ +package memory + +import ( + "s1d3sw1ped/SteamCache2/vfs" + "s1d3sw1ped/SteamCache2/vfs/vfserror" + "sync" + "time" +) + +// Ensure MemoryFS implements VFS. +var _ vfs.VFS = (*MemoryFS)(nil) + +// file represents a file in memory. +type file struct { + fileinfo *vfs.FileInfo + data []byte +} + +// MemoryFS is a virtual file system that stores files in memory. +type MemoryFS struct { + files map[string]*file + capacity int64 + mu sync.Mutex +} + +// New creates a new MemoryFS. +func New(capacity int64) *MemoryFS { + return &MemoryFS{ + files: make(map[string]*file), + capacity: capacity, + mu: sync.Mutex{}, + } +} + +func (m *MemoryFS) Capacity() int64 { + return m.capacity +} + +func (m *MemoryFS) Name() string { + return "MemoryFS" +} + +func (m *MemoryFS) Size() int64 { + var size int64 + + m.mu.Lock() + defer m.mu.Unlock() + + for _, v := range m.files { + size += int64(len(v.data)) + } + + return size +} + +func (m *MemoryFS) Set(key string, src []byte) error { + if m.capacity > 0 { + if size := m.Size() + int64(len(src)); size > m.capacity { + return vfserror.ErrDiskFull + } + } + + m.mu.Lock() + defer m.mu.Unlock() + + m.files[key] = &file{ + fileinfo: vfs.NewFileInfo( + key, + int64(len(src)), + time.Now(), + ), + data: make([]byte, len(src)), + } + copy(m.files[key].data, src) + + return nil +} + +func (m *MemoryFS) Delete(key string) error { + _, err := m.Stat(key) + if err != nil { + return err + } + + m.mu.Lock() + defer m.mu.Unlock() + + delete(m.files, key) + + return nil +} + +func (m *MemoryFS) Get(key string) ([]byte, error) { + _, err := m.Stat(key) + if err != nil { + return nil, err + } + + m.mu.Lock() + defer m.mu.Unlock() + + m.files[key].fileinfo.ATime = time.Now() + dst := make([]byte, len(m.files[key].data)) + copy(dst, m.files[key].data) + + return dst, nil +} + +func (m *MemoryFS) Stat(key string) (*vfs.FileInfo, error) { + m.mu.Lock() + defer m.mu.Unlock() + + f, ok := m.files[key] + if !ok { + return nil, vfserror.ErrNotFound + } + + return f.fileinfo, nil +} + +func (m *MemoryFS) StatAll() []*vfs.FileInfo { + m.mu.Lock() + defer m.mu.Unlock() + + // hard copy the file info to prevent modification of the original file info or the other way around + files := make([]*vfs.FileInfo, 0, len(m.files)) + for _, v := range m.files { + fi := *v.fileinfo + files = append(files, &fi) + } + + return files +} diff --git a/vfs/memory/memory_test.go b/vfs/memory/memory_test.go new file mode 100644 index 0000000..48d48f3 --- /dev/null +++ b/vfs/memory/memory_test.go @@ -0,0 +1,63 @@ +package memory + +import ( + "fmt" + "s1d3sw1ped/SteamCache2/vfs/vfserror" + "testing" +) + +func TestAllMemory(t *testing.T) { + t.Parallel() + + m := New(1024) + if err := m.Set("key", []byte("value")); err != nil { + t.Errorf("Set failed: %v", err) + } + + if err := m.Set("key", []byte("value1")); err != nil { + t.Errorf("Set failed: %v", err) + } + + if d, err := m.Get("key"); err != nil { + t.Errorf("Get failed: %v", err) + } else if string(d) != "value1" { + t.Errorf("Get failed: got %s, want %s", d, "value1") + } + + if err := m.Delete("key"); err != nil { + t.Errorf("Delete failed: %v", err) + } + + if _, err := m.Get("key"); err == nil { + t.Errorf("Get failed: got nil, want %v", vfserror.ErrNotFound) + } + + if err := m.Delete("key"); err == nil { + t.Errorf("Delete failed: got nil, want %v", vfserror.ErrNotFound) + } + + if _, err := m.Stat("key"); err == nil { + t.Errorf("Stat failed: got nil, want %v", vfserror.ErrNotFound) + } + + if err := m.Set("key", []byte("value")); err != nil { + t.Errorf("Set failed: %v", err) + } + + if _, err := m.Stat("key"); err != nil { + t.Errorf("Stat failed: %v", err) + } +} + +func TestLimited(t *testing.T) { + t.Parallel() + + m := New(10) + for i := 0; i < 11; i++ { + if err := m.Set(fmt.Sprintf("key%d", i), []byte("1")); err != nil && i < 10 { + t.Errorf("Set failed: %v", err) + } else if i == 10 && err == nil { + t.Errorf("Set succeeded: got nil, want %v", vfserror.ErrDiskFull) + } + } +} diff --git a/vfs/sync/sync.go b/vfs/sync/sync.go new file mode 100644 index 0000000..e342635 --- /dev/null +++ b/vfs/sync/sync.go @@ -0,0 +1,76 @@ +package sync + +import ( + "fmt" + "s1d3sw1ped/SteamCache2/vfs" + "sync" +) + +// Ensure SyncFS implements VFS. +var _ vfs.VFS = (*SyncFS)(nil) + +type SyncFS struct { + vfs vfs.VFS + mu sync.RWMutex +} + +func New(vfs vfs.VFS) *SyncFS { + return &SyncFS{ + vfs: vfs, + mu: sync.RWMutex{}, + } +} + +// Name returns the name of the file system. +func (sfs *SyncFS) Name() string { + return fmt.Sprintf("SyncFS(%s)", sfs.vfs.Name()) +} + +// Size returns the total size of all files in the file system. +func (sfs *SyncFS) Size() int64 { + sfs.mu.RLock() + defer sfs.mu.RUnlock() + + return sfs.vfs.Size() +} + +// Set sets the value of key as src. +// Setting the same key multiple times, the last set call takes effect. +func (sfs *SyncFS) Set(key string, src []byte) error { + sfs.mu.Lock() + defer sfs.mu.Unlock() + + return sfs.vfs.Set(key, src) +} + +// Delete deletes the value of key. +func (sfs *SyncFS) Delete(key string) error { + sfs.mu.Lock() + defer sfs.mu.Unlock() + + return sfs.vfs.Delete(key) +} + +// Get gets the value of key to dst, and returns dst no matter whether or not there is an error. +func (sfs *SyncFS) Get(key string) ([]byte, error) { + sfs.mu.RLock() + defer sfs.mu.RUnlock() + + return sfs.vfs.Get(key) +} + +// Stat returns the FileInfo of key. +func (sfs *SyncFS) Stat(key string) (*vfs.FileInfo, error) { + sfs.mu.RLock() + defer sfs.mu.RUnlock() + + return sfs.vfs.Stat(key) +} + +// StatAll returns the FileInfo of all keys. +func (sfs *SyncFS) StatAll() []*vfs.FileInfo { + sfs.mu.RLock() + defer sfs.mu.RUnlock() + + return sfs.vfs.StatAll() +} diff --git a/vfs/vfs.go b/vfs/vfs.go new file mode 100644 index 0000000..0812453 --- /dev/null +++ b/vfs/vfs.go @@ -0,0 +1,26 @@ +package vfs + +// VFS is the interface that wraps the basic methods of a virtual file system. +type VFS interface { + // Name returns the name of the file system. + Name() string + + // Size returns the total size of all files in the file system. + Size() int64 + + // Set sets the value of key as src. + // Setting the same key multiple times, the last set call takes effect. + Set(key string, src []byte) error + + // Delete deletes the value of key. + Delete(key string) error + + // Get gets the value of key to dst, and returns dst no matter whether or not there is an error. + Get(key string) ([]byte, error) + + // Stat returns the FileInfo of key. + Stat(key string) (*FileInfo, error) + + // StatAll returns the FileInfo of all keys. + StatAll() []*FileInfo +} diff --git a/vfs/vfserror/vfserror.go b/vfs/vfserror/vfserror.go new file mode 100644 index 0000000..f30f5bc --- /dev/null +++ b/vfs/vfserror/vfserror.go @@ -0,0 +1,14 @@ +package vfserror + +import "errors" + +var ( + // ErrUnreachable is returned when a code path is unreachable. + ErrUnreachable = errors.New("unreachable") + + // ErrNotFound is returned when a key is not found. + ErrNotFound = errors.New("vfs: key not found") + + // ErrDiskFull is returned when the disk is full. + ErrDiskFull = errors.New("vfs: disk full") +)