From c9fd86b91f4885aea0fc9ca083fb4dfd1e012990 Mon Sep 17 00:00:00 2001 From: Alec Thomas Date: Sat, 27 Dec 2025 14:48:47 +1100 Subject: [PATCH 01/10] feat: initial skeleton - Client and server packages, with helper scripts for local development. - API's for cache and strategies - Basic disk-backed LRU cache implementation - Linter config - CI config, includig release flow --- .github/workflows/ci.yml | 54 ++++ .gitignore | 2 + .golangci.yml | 466 +++++++++++++++++++++++++++++++++++ .goreleaser.yaml | 46 ++++ Justfile | 16 ++ README.md | 49 ++-- bin/.go-1.25.5.pkg | 1 + bin/.golangci-lint-2.7.2.pkg | 1 + bin/.goreleaser-2.13.2.pkg | 1 + bin/.just-1.45.0.pkg | 1 + bin/.lefthook-2.0.13.pkg | 1 + bin/.svu-3.3.0.pkg | 1 + bin/README.hermit.md | 7 + bin/activate-hermit | 21 ++ bin/activate-hermit.fish | 24 ++ bin/go | 1 + bin/gofmt | 1 + bin/golangci-lint | 1 + bin/goreleaser | 1 + bin/hermit | 43 ++++ bin/hermit.hcl | 6 + bin/just | 1 + bin/lefthook | 1 + bin/svu | 1 + cmd/sfptc/main.go | 15 ++ cmd/sfptcd/main.go | 22 ++ go.mod | 22 ++ go.sum | 20 ++ internal/cache/api.go | 41 +++ internal/cache/disk.go | 391 +++++++++++++++++++++++++++++ internal/cache/disk_test.go | 112 +++++++++ internal/logging/logging.go | 44 ++++ internal/strategy/api.go | 29 +++ internal/strategy/default.go | 3 + lefthook.yml | 16 ++ scripts/sfptc | 1 + scripts/sfptcd | 10 + 37 files changed, 1446 insertions(+), 27 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 .gitignore create mode 100644 .golangci.yml create mode 100644 .goreleaser.yaml create mode 100644 Justfile create mode 120000 bin/.go-1.25.5.pkg create mode 120000 bin/.golangci-lint-2.7.2.pkg create mode 120000 bin/.goreleaser-2.13.2.pkg create mode 120000 bin/.just-1.45.0.pkg create mode 120000 bin/.lefthook-2.0.13.pkg create mode 120000 bin/.svu-3.3.0.pkg create mode 100644 bin/README.hermit.md create mode 100755 bin/activate-hermit create mode 100755 bin/activate-hermit.fish create mode 120000 bin/go create mode 120000 bin/gofmt create mode 120000 bin/golangci-lint create mode 120000 bin/goreleaser create mode 100755 bin/hermit create mode 100644 bin/hermit.hcl create mode 120000 bin/just create mode 120000 bin/lefthook create mode 120000 bin/svu create mode 100644 cmd/sfptc/main.go create mode 100644 cmd/sfptcd/main.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/cache/api.go create mode 100644 internal/cache/disk.go create mode 100644 internal/cache/disk_test.go create mode 100644 internal/logging/logging.go create mode 100644 internal/strategy/api.go create mode 100644 internal/strategy/default.go create mode 100644 lefthook.yml create mode 120000 scripts/sfptc create mode 100755 scripts/sfptcd diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..5828f57 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,54 @@ +on: + push: + branches: + - main + tags: + - "v*.*.*" + pull_request: +name: CI +jobs: + formatted: + name: Formatted + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: cashapp/activate-hermit@v1 + - run: | + just fmt + git diff + test: + name: Test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: cashapp/activate-hermit@v1 + - name: Setup xattr-supported filesystem + run: | + mkdir -p /tmp/xattr-fs + sudo mount -t tmpfs -o size=1G tmpfs /tmp/xattr-fs + - name: Run tests + run: just test + env: + TMPDIR: /tmp/xattr-fs + lint: + name: Lint + runs-on: ubuntu-latest + env: + GOPROXY: direct + steps: + - uses: actions/checkout@v4 + - uses: cashapp/activate-hermit@v1 + - run: just lint + release: + if: startsWith(github.ref, 'refs/tags/v') + needs: ["test", "lint"] + name: Release + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - uses: cashapp/activate-hermit@v1 + - run: goreleaser release --fail-fast + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..69dcb52 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +# Added by goreleaser init: +dist/ diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..167b43c --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,466 @@ +# This file is licensed under the terms of the MIT license https://opensource.org/license/mit +# Copyright (c) 2021-2025 Marat Reimers + +## Golden config for golangci-lint v2.7.2 +# +# This is the best config for golangci-lint based on my experience and opinion. +# It is very strict, but not extremely strict. +# Feel free to adapt it to suit your needs. +# If this config helps you, please consider keeping a link to this repo (see the next comment). + +# Based on https://github.com/maratori/golangci-lint-config + +version: "2" + +issues: + # Maximum count of issues with the same text. + # Set to 0 to disable. + # Default: 3 + max-same-issues: 0 + +output: + formats: + text: + print-linter-name: true + print-issued-lines: false + colors: true + show-stats: false + +formatters: + enable: + - goimports # checks if the code and import statements are formatted according to the 'goimports' command + - gofmt + + ## you may want to enable + #- gci # checks if code and import statements are formatted, with additional rules + #- gofmt # checks if the code is formatted according to 'gofmt' command + #- gofumpt # enforces a stricter format than 'gofmt', while being backwards compatible + #- swaggo # formats swaggo comments + + # All settings can be found here https://github.com/golangci/golangci-lint/blob/HEAD/.golangci.reference.yml + settings: + goimports: + # A list of prefixes, which, if set, checks import paths + # with the given prefixes are grouped after 3rd-party packages. + # Default: [] + local-prefixes: + - github.com/block + +linters: + enable: + - asasalint # checks for pass []any as any in variadic func(...any) + - asciicheck # checks that your code does not contain non-ASCII identifiers + - bidichk # checks for dangerous unicode character sequences + - bodyclose # checks whether HTTP response body is closed successfully + - canonicalheader # checks whether net/http.Header uses canonical header + - copyloopvar # detects places where loop variables are copied (Go 1.22+) + - cyclop # checks function and package cyclomatic complexity + - depguard # checks if package imports are in a list of acceptable packages + - dupl # tool for code clone detection + - durationcheck # checks for two durations multiplied together + - embeddedstructfieldcheck # checks embedded types in structs + - errcheck # checking for unchecked errors, these unchecked errors can be critical bugs in some cases + - errname # checks that sentinel errors are prefixed with the Err and error types are suffixed with the Error + - errorlint # finds code that will cause problems with the error wrapping scheme introduced in Go 1.13 + - exhaustive # checks exhaustiveness of enum switch statements + - exptostd # detects functions from golang.org/x/exp/ that can be replaced by std functions + - fatcontext # detects nested contexts in loops + - forbidigo # forbids identifiers + - funcorder # checks the order of functions, methods, and constructors + - funlen # tool for detection of long functions + - gocheckcompilerdirectives # validates go compiler directive comments (//go:) + - gochecksumtype # checks exhaustiveness on Go "sum types" + - goconst # finds repeated strings that could be replaced by a constant + - gocritic # provides diagnostics that check for bugs, performance and style issues + - godoclint # checks Golang's documentation practice + - godot # checks if comments end in a period + - gomoddirectives # manages the use of 'replace', 'retract', and 'excludes' directives in go.mod + - goprintffuncname # checks that printf-like functions are named with f at the end + - gosec # inspects source code for security problems + - govet # reports suspicious constructs, such as Printf calls whose arguments do not align with the format string + - iface # checks the incorrect use of interfaces, helping developers avoid interface pollution + - ineffassign # detects when assignments to existing variables are not used + - intrange # finds places where for loops could make use of an integer range + - iotamixing # checks if iotas are being used in const blocks with other non-iota declarations + - loggercheck # checks key value pairs for common logger libraries (kitlog,klog,logr,zap) + - makezero # finds slice declarations with non-zero initial length + - mirror # reports wrong mirror patterns of bytes/strings usage + - modernize # suggests simplifications to Go code, using modern language and library features + - musttag # enforces field tags in (un)marshaled structs + - nakedret # finds naked returns in functions greater than a specified function length + - nilerr # finds the code that returns nil even if it checks that the error is not nil + - nilnesserr # reports that it checks for err != nil, but it returns a different nil value error (powered by nilness and nilerr) + - nilnil # checks that there is no simultaneous return of nil error and an invalid value + - noctx # finds sending http request without context.Context + - nonamedreturns # reports all named returns + - nosprintfhostport # checks for misuse of Sprintf to construct a host with port in a URL + - perfsprint # checks that fmt.Sprintf can be replaced with a faster alternative + - predeclared # finds code that shadows one of Go's predeclared identifiers + - promlinter # checks Prometheus metrics naming via promlint + - protogetter # reports direct reads from proto message fields when getters should be used + - reassign # checks that package variables are not reassigned + - recvcheck # checks for receiver type consistency + - revive # fast, configurable, extensible, flexible, and beautiful linter for Go, drop-in replacement of golint + - rowserrcheck # checks whether Err of rows is checked successfully + - sloglint # ensure consistent code style when using log/slog + - spancheck # checks for mistakes with OpenTelemetry/Census spans + - sqlclosecheck # checks that sql.Rows and sql.Stmt are closed + - staticcheck # is a go vet on steroids, applying a ton of static analysis checks + - testableexamples # checks if examples are testable (have an expected output) + - testifylint # checks usage of github.com/stretchr/testify + - testpackage # makes you use a separate _test package + - tparallel # detects inappropriate usage of t.Parallel() method in your Go test codes + - unconvert # removes unnecessary type conversions + - unparam # reports unused function parameters + - unqueryvet # detects SELECT * in SQL queries and SQL builders, encouraging explicit column selection + - unused # checks for unused constants, variables, functions and types + - usestdlibvars # detects the possibility to use variables/constants from the Go standard library + - usetesting # reports uses of functions with replacement inside the testing package + - wastedassign # finds wasted assignment statements + - whitespace # detects leading and trailing whitespace + + ## you may want to enable + #- arangolint # opinionated best practices for arangodb client + #- decorder # checks declaration order and count of types, constants, variables and functions + #- exhaustruct # [highly recommend to enable] checks if all structure fields are initialized + #- ginkgolinter # [if you use ginkgo/gomega] enforces standards of using ginkgo and gomega + #- godox # detects usage of FIXME, TODO and other keywords inside comments + #- goheader # checks is file header matches to pattern + #- inamedparam # [great idea, but too strict, need to ignore a lot of cases by default] reports interfaces with unnamed method parameters + #- interfacebloat # checks the number of methods inside an interface + #- ireturn # accept interfaces, return concrete types + #- noinlineerr # disallows inline error handling `if err := ...; err != nil {` + #- prealloc # [premature optimization, but can be used in some cases] finds slice declarations that could potentially be preallocated + #- tagalign # checks that struct tags are well aligned + #- varnamelen # [great idea, but too many false positives] checks that the length of a variable's name matches its scope + #- wrapcheck # checks that errors returned from external packages are wrapped + #- zerologlint # detects the wrong usage of zerolog that a user forgets to dispatch zerolog.Event + + ## disabled + #- containedctx # detects struct contained context.Context field + #- contextcheck # [too many false positives] checks the function whether use a non-inherited context + #- dogsled # checks assignments with too many blank identifiers (e.g. x, _, _, _, := f()) + #- dupword # [useless without config] checks for duplicate words in the source code + #- err113 # [too strict] checks the errors handling expressions + #- errchkjson # [don't see profit + I'm against of omitting errors like in the first example https://github.com/breml/errchkjson] checks types passed to the json encoding functions. Reports unsupported types and optionally reports occasions, where the check for the returned error can be omitted + #- forcetypeassert # [replaced by errcheck] finds forced type assertions + #- gomodguard # [use more powerful depguard] allow and block lists linter for direct Go module dependencies + #- gosmopolitan # reports certain i18n/l10n anti-patterns in your Go codebase + #- grouper # analyzes expression groups + #- importas # enforces consistent import aliases + #- lll # [replaced by golines] reports long lines + #- maintidx # measures the maintainability index of each function + #- misspell # [useless] finds commonly misspelled English words in comments + #- nlreturn # [too strict and mostly code is not more readable] checks for a new line before return and branch statements to increase code clarity + #- paralleltest # [too many false positives] detects missing usage of t.Parallel() method in your Go test + #- tagliatelle # checks the struct tags + #- thelper # detects golang test helpers without t.Helper() call and checks the consistency of test helpers + #- wsl # [too strict and mostly code is not more readable] whitespace linter forces you to use empty lines + #- wsl_v5 # [too strict and mostly code is not more readable] add or remove empty lines + + # All settings can be found here https://github.com/golangci/golangci-lint/blob/HEAD/.golangci.reference.yml + settings: + cyclop: + # The maximal code complexity to report. + # Default: 10 + max-complexity: 30 + # The maximal average package complexity. + # If it's higher than 0.0 (float) the check is enabled. + # Default: 0.0 + package-average: 10.0 + + depguard: + # Rules to apply. + # + # Variables: + # - File Variables + # Use an exclamation mark `!` to negate a variable. + # Example: `!$test` matches any file that is not a go test file. + # + # `$all` - matches all go files + # `$test` - matches all go test files + # + # - Package Variables + # + # `$gostd` - matches all of go's standard library (Pulled from `GOROOT`) + # + # Default (applies if no custom rules are defined): Only allow $gostd in all files. + rules: + "deprecated": + # List of file globs that will match this list of settings to compare against. + # By default, if a path is relative, it is relative to the directory where the golangci-lint command is executed. + # The placeholder '${base-path}' is substituted with a path relative to the mode defined with `run.relative-path-mode`. + # The placeholder '${config-path}' is substituted with a path relative to the configuration file. + # Default: $all + files: + - "$all" + # List of packages that are not allowed. + # Entries can be a variable (starting with $), a string prefix, or an exact match (if ending with $). + # Default: [] + deny: + - pkg: github.com/golang/protobuf + desc: Use google.golang.org/protobuf instead, see https://developers.google.com/protocol-buffers/docs/reference/go/faq#modules + - pkg: github.com/satori/go.uuid + desc: Use github.com/google/uuid instead, satori's package is not maintained + - pkg: github.com/gofrs/uuid$ + desc: Use github.com/gofrs/uuid/v5 or later, it was not a go module before v5 + "non-test files": + files: + - "!$test" + deny: + - pkg: math/rand$ + desc: Use math/rand/v2 instead, see https://go.dev/blog/randv2 + "non-main files": + files: + - "!**/main.go" + deny: + - pkg: log$ + desc: Use log/slog instead, see https://go.dev/blog/slog + + embeddedstructfieldcheck: + # Checks that sync.Mutex and sync.RWMutex are not used as embedded fields. + # Default: false + forbid-mutex: true + + errcheck: + # Report about not checking of errors in type assertions: `a := b.(MyStruct)`. + # Such cases aren't reported by default. + # Default: false + check-type-assertions: true + + exhaustive: + # Program elements to check for exhaustiveness. + # Default: [ switch ] + check: + - switch + - map + + exhaustruct: + # List of regular expressions to match type names that should be excluded from processing. + # Anonymous structs can be matched by '' alias. + # Has precedence over `include`. + # Each regular expression must match the full type name, including package path. + # For example, to match type `net/http.Cookie` regular expression should be `.*/http\.Cookie`, + # but not `http\.Cookie`. + # Default: [] + exclude: + # std libs + - ^net/http.Client$ + - ^net/http.Cookie$ + - ^net/http.Request$ + - ^net/http.Response$ + - ^net/http.Server$ + - ^net/http.Transport$ + - ^net/url.URL$ + - ^os/exec.Cmd$ + - ^reflect.StructField$ + # public libs + - ^github.com/Shopify/sarama.Config$ + - ^github.com/Shopify/sarama.ProducerMessage$ + - ^github.com/mitchellh/mapstructure.DecoderConfig$ + - ^github.com/prometheus/client_golang/.+Opts$ + - ^github.com/spf13/cobra.Command$ + - ^github.com/spf13/cobra.CompletionOptions$ + - ^github.com/stretchr/testify/mock.Mock$ + - ^github.com/testcontainers/testcontainers-go.+Request$ + - ^github.com/testcontainers/testcontainers-go.FromDockerfile$ + - ^golang.org/x/tools/go/analysis.Analyzer$ + - ^google.golang.org/protobuf/.+Options$ + - ^gopkg.in/yaml.v3.Node$ + # Allows empty structures in return statements. + # Default: false + allow-empty-returns: true + + funcorder: + # Checks if the exported methods of a structure are placed before the non-exported ones. + # Default: true + struct-method: false + + funlen: + # Checks the number of lines in a function. + # If lower than 0, disable the check. + # Default: 60 + lines: 100 + # Checks the number of statements in a function. + # If lower than 0, disable the check. + # Default: 40 + statements: 50 + + gochecksumtype: + # Presence of `default` case in switch statements satisfies exhaustiveness, if all members are not listed. + # Default: true + default-signifies-exhaustive: false + + gocognit: + # Minimal code complexity to report. + # Default: 30 (but we recommend 10-20) + min-complexity: 20 + + gocritic: + # Settings passed to gocritic. + # The settings key is the name of a supported gocritic checker. + # The list of supported checkers can be found at https://go-critic.com/overview. + settings: + captLocal: + # Whether to restrict checker to params only. + # Default: true + paramsOnly: false + underef: + # Whether to skip (*x).method() calls where x is a pointer receiver. + # Default: true + skipRecvDeref: false + + godoclint: + # List of rules to enable in addition to the default set. + # Default: empty + enable: + # Assert no unused link in godocs. + # https://github.com/godoc-lint/godoc-lint?tab=readme-ov-file#no-unused-link + - no-unused-link + + govet: + # Enable all analyzers. + # Default: false + enable-all: true + # Disable analyzers by name. + # Run `GL_DEBUG=govet golangci-lint run --enable=govet` to see default, all available analyzers, and enabled analyzers. + # Default: [] + disable: + - fieldalignment # too strict + # Settings per analyzer. + settings: + shadow: + # Whether to be strict about shadowing; can be noisy. + # Default: false + strict: true + + inamedparam: + # Skips check for interface methods with only a single parameter. + # Default: false + skip-single-param: true + + mnd: + # List of function patterns to exclude from analysis. + # Values always ignored: `time.Date`, + # `strconv.FormatInt`, `strconv.FormatUint`, `strconv.FormatFloat`, + # `strconv.ParseInt`, `strconv.ParseUint`, `strconv.ParseFloat`. + # Default: [] + ignored-functions: + - args.Error + - flag.Arg + - flag.Duration.* + - flag.Float.* + - flag.Int.* + - flag.Uint.* + - os.Chmod + - os.Mkdir.* + - os.OpenFile + - os.WriteFile + - prometheus.ExponentialBuckets.* + - prometheus.LinearBuckets + + nakedret: + # Make an issue if func has more lines of code than this setting, and it has naked returns. + # Default: 30 + max-func-lines: 0 + + nolintlint: + # Exclude following linters from requiring an explanation. + # Default: [] + allow-no-explanation: [funlen, gocognit, golines] + # Enable to require an explanation of nonzero length after each nolint directive. + # Default: false + require-explanation: true + # Enable to require nolint directives to mention the specific linter being suppressed. + # Default: false + require-specific: true + + perfsprint: + # Optimizes into strings concatenation. + # Default: true + strconcat: false + + reassign: + # Patterns for global variable names that are checked for reassignment. + # See https://github.com/curioswitch/go-reassign#usage + # Default: ["EOF", "Err.*"] + patterns: + - ".*" + + rowserrcheck: + # database/sql is always checked. + # Default: [] + packages: + - github.com/jmoiron/sqlx + + sloglint: + # Enforce not using global loggers. + # Values: + # - "": disabled + # - "all": report all global loggers + # - "default": report only the default slog logger + # https://github.com/go-simpler/sloglint?tab=readme-ov-file#no-global + # Default: "" + no-global: all + # Enforce using methods that accept a context. + # Values: + # - "": disabled + # - "all": report all contextless calls + # - "scope": report only if a context exists in the scope of the outermost function + # https://github.com/go-simpler/sloglint?tab=readme-ov-file#context-only + # Default: "" + context: scope + + staticcheck: + # SAxxxx checks in https://staticcheck.dev/docs/configuration/options/#checks + # Example (to disable some checks): [ "all", "-SA1000", "-SA1001"] + # Default: ["all", "-ST1000", "-ST1003", "-ST1016", "-ST1020", "-ST1021", "-ST1022"] + checks: + - all + # Incorrect or missing package comment. + # https://staticcheck.dev/docs/checks/#ST1000 + - -ST1000 + # Use consistent method receiver names. + # https://staticcheck.dev/docs/checks/#ST1016 + - -ST1016 + # Omit embedded fields from selector expression. + # https://staticcheck.dev/docs/checks/#QF1008 + - -QF1008 + + usetesting: + # Enable/disable `os.TempDir()` detections. + # Default: false + os-temp-dir: true + + exclusions: + # Predefined exclusion rules. + # Default: [] + presets: + - std-error-handling + - common-false-positives + # Excluding configuration per-path, per-linter, per-text and per-source. + rules: + - source: "TODO" + linters: [godot] + - text: "should have a package comment" + linters: [revive] + - text: 'exported \S+ \S+ should have comment( \(or a comment on this block\))? or be unexported' + linters: [revive] + - text: 'package comment should be of the form ".+"' + source: "// ?(nolint|TODO)" + linters: [revive] + - text: 'comment on exported \S+ \S+ should be of the form ".+"' + source: "// ?(nolint|TODO)" + linters: [revive, staticcheck] + - text: 'shadow: declaration of "err" shadows declaration' + linters: ["govet"] + - path: '_test\.go' + linters: + - bodyclose + - dupl + - errcheck + - funlen + - goconst + - gosec + - noctx + - wrapcheck diff --git a/.goreleaser.yaml b/.goreleaser.yaml new file mode 100644 index 0000000..d6bd36e --- /dev/null +++ b/.goreleaser.yaml @@ -0,0 +1,46 @@ +builds: + - main: ./cmd/sfptc + binary: sfptc + id: sfptc + env: + - CGO_ENABLED=0 + goos: + - linux + - darwin + goarch: + - amd64 + - arm64 + - main: ./cmd/sfptcd + id: sfptcd + binary: sfptcd + env: + - CGO_ENABLED=0 + goos: + - linux + - darwin + goarch: + - amd64 + - arm64 + +archives: + - format: tar.gz + # this name template makes the OS and Arch compatible with the results of uname. + name_template: >- + {{ .ProjectName }}- + {{- .Os }}- + {{- .Arch }} + format_overrides: + - goos: windows + format: zip +checksum: + name_template: "checksums.txt" +snapshot: + name_template: "{{ incpatch .Version }}-next" +changelog: + sort: asc + filters: + exclude: + - "^docs:" + - "^test:" +# yaml-language-server: $schema=https://goreleaser.com/static/schema.json +# vim: set ts=2 sw=2 tw=0 fo=cnqoj diff --git a/Justfile b/Justfile new file mode 100644 index 0000000..7db218b --- /dev/null +++ b/Justfile @@ -0,0 +1,16 @@ +_help: + @just -l + +# Run tests +test: + go test ./... + +# Lint code +lint: + golangci-lint run + +# Format code +fmt: + just --unstable --fmt + golangci-lint fmt + go mod tidy diff --git a/README.md b/README.md index b780028..e5293d3 100644 --- a/README.md +++ b/README.md @@ -1,37 +1,32 @@ -# sfptc README +# Super-fast Pass-through Cache (SFPTC) -Congrats, project leads! You got a new project to grow! +SFPTC is a server and tooling for incredibly efficient, protocol-aware caching. It is +designed to be used at scale, with minimal impact on upstream systems. By "protocol-aware", we mean that the proxy isn't +just a naive HTTP proxy, it is aware of the higher level protocol being proxied (Git, Docker, etc.) and can make more efficient decisions. -This stub is meant to help you form a strong community around your work. It's yours to adapt, and may -diverge from this initial structure. Just keep the files seeded in this repo, and the rest is yours to evolve! +## Git -## Introduction +Git causes a number of problems for us, but the most obvious are: -Orient users to the project here. This is a good place to start with an assumption -that the user knows very little - so start with the Big Picture and show how this -project fits into it. +1. Rate limiting by service providers. +2. `git clone` is very slow, even discounting network overhead -Then maybe a dive into what this project does. +To solve this we apply two different strategies on the server: -Diagrams and other visuals are helpful here. Perhaps code snippets showing usage. +1. Periodic full `.tar.zst` snapshots of the repository. These snapshots restore 4-5x faster than `git clone`. +2. Passthrough caching of the packs returned by `POST /repo.git/git-upload-pack` to support incremental pulls. -Project leads should complete, alongside this `README`: -* [CODEOWNERS](./CODEOWNERS) - set project lead(s) -* [CONTRIBUTING.md](./CONTRIBUTING.md) - Fill out how to: install prereqs, build, test, run, access CI, chat, discuss, file issues -* [Bug-report.md](.github/ISSUE_TEMPLATE/bug-report.md) - Fill out `Assignees` add codeowners @names -* [config.yml](.github/ISSUE_TEMPLATE/config.yml) - remove "(/add your discord channel..)" and replace the url with your Discord channel if applicable +On the client we redirect git to the proxy: -The other files in this template repo may be used as-is: -* [CODE_OF_CONDUCT.md](./CODE_OF_CONDUCT.md) -* [GOVERNANCE.md](./GOVERNANCE.md) -* [LICENSE](./LICENSE) +```ini +[url "https://sfptc.local/github/"] + insteadOf = https://github.com/ +``` -## Project Resources +As Git itself isn't aware of the snapshots, Git-specific code in the SFPTC CLI can be used to reconstruct a repository. -| Resource | Description | -| ------------------------------------------ | ------------------------------------------------------------------------------ | -| [CODEOWNERS](./CODEOWNERS) | Outlines the project lead(s) | -| [CODE_OF_CONDUCT.md](./CODE_OF_CONDUCT.md) | Expected behavior for project contributors, promoting a welcoming environment | -| [CONTRIBUTING.md](./CONTRIBUTING.md) | Developer guide to build, test, run, access CI, chat, discuss, file issues | -| [GOVERNANCE.md](./GOVERNANCE.md) | Project governance | -| [LICENSE](./LICENSE) | Apache License, Version 2.0 | +## Docker + +## Hermit + +Hermit diff --git a/bin/.go-1.25.5.pkg b/bin/.go-1.25.5.pkg new file mode 120000 index 0000000..383f451 --- /dev/null +++ b/bin/.go-1.25.5.pkg @@ -0,0 +1 @@ +hermit \ No newline at end of file diff --git a/bin/.golangci-lint-2.7.2.pkg b/bin/.golangci-lint-2.7.2.pkg new file mode 120000 index 0000000..383f451 --- /dev/null +++ b/bin/.golangci-lint-2.7.2.pkg @@ -0,0 +1 @@ +hermit \ No newline at end of file diff --git a/bin/.goreleaser-2.13.2.pkg b/bin/.goreleaser-2.13.2.pkg new file mode 120000 index 0000000..383f451 --- /dev/null +++ b/bin/.goreleaser-2.13.2.pkg @@ -0,0 +1 @@ +hermit \ No newline at end of file diff --git a/bin/.just-1.45.0.pkg b/bin/.just-1.45.0.pkg new file mode 120000 index 0000000..383f451 --- /dev/null +++ b/bin/.just-1.45.0.pkg @@ -0,0 +1 @@ +hermit \ No newline at end of file diff --git a/bin/.lefthook-2.0.13.pkg b/bin/.lefthook-2.0.13.pkg new file mode 120000 index 0000000..383f451 --- /dev/null +++ b/bin/.lefthook-2.0.13.pkg @@ -0,0 +1 @@ +hermit \ No newline at end of file diff --git a/bin/.svu-3.3.0.pkg b/bin/.svu-3.3.0.pkg new file mode 120000 index 0000000..383f451 --- /dev/null +++ b/bin/.svu-3.3.0.pkg @@ -0,0 +1 @@ +hermit \ No newline at end of file diff --git a/bin/README.hermit.md b/bin/README.hermit.md new file mode 100644 index 0000000..e889550 --- /dev/null +++ b/bin/README.hermit.md @@ -0,0 +1,7 @@ +# Hermit environment + +This is a [Hermit](https://github.com/cashapp/hermit) bin directory. + +The symlinks in this directory are managed by Hermit and will automatically +download and install Hermit itself as well as packages. These packages are +local to this environment. diff --git a/bin/activate-hermit b/bin/activate-hermit new file mode 100755 index 0000000..fe28214 --- /dev/null +++ b/bin/activate-hermit @@ -0,0 +1,21 @@ +#!/bin/bash +# This file must be used with "source bin/activate-hermit" from bash or zsh. +# You cannot run it directly +# +# THIS FILE IS GENERATED; DO NOT MODIFY + +if [ "${BASH_SOURCE-}" = "$0" ]; then + echo "You must source this script: \$ source $0" >&2 + exit 33 +fi + +BIN_DIR="$(dirname "${BASH_SOURCE[0]:-${(%):-%x}}")" +if "${BIN_DIR}/hermit" noop > /dev/null; then + eval "$("${BIN_DIR}/hermit" activate "${BIN_DIR}/..")" + + if [ -n "${BASH-}" ] || [ -n "${ZSH_VERSION-}" ]; then + hash -r 2>/dev/null + fi + + echo "Hermit environment $("${HERMIT_ENV}"/bin/hermit env HERMIT_ENV) activated" +fi diff --git a/bin/activate-hermit.fish b/bin/activate-hermit.fish new file mode 100755 index 0000000..0367d23 --- /dev/null +++ b/bin/activate-hermit.fish @@ -0,0 +1,24 @@ +#!/usr/bin/env fish + +# This file must be sourced with "source bin/activate-hermit.fish" from Fish shell. +# You cannot run it directly. +# +# THIS FILE IS GENERATED; DO NOT MODIFY + +if status is-interactive + set BIN_DIR (dirname (status --current-filename)) + + if "$BIN_DIR/hermit" noop > /dev/null + # Source the activation script generated by Hermit + "$BIN_DIR/hermit" activate "$BIN_DIR/.." | source + + # Clear the command cache if applicable + functions -c > /dev/null 2>&1 + + # Display activation message + echo "Hermit environment $($HERMIT_ENV/bin/hermit env HERMIT_ENV) activated" + end +else + echo "You must source this script: source $argv[0]" >&2 + exit 33 +end diff --git a/bin/go b/bin/go new file mode 120000 index 0000000..4778489 --- /dev/null +++ b/bin/go @@ -0,0 +1 @@ +.go-1.25.5.pkg \ No newline at end of file diff --git a/bin/gofmt b/bin/gofmt new file mode 120000 index 0000000..4778489 --- /dev/null +++ b/bin/gofmt @@ -0,0 +1 @@ +.go-1.25.5.pkg \ No newline at end of file diff --git a/bin/golangci-lint b/bin/golangci-lint new file mode 120000 index 0000000..fac76e4 --- /dev/null +++ b/bin/golangci-lint @@ -0,0 +1 @@ +.golangci-lint-2.7.2.pkg \ No newline at end of file diff --git a/bin/goreleaser b/bin/goreleaser new file mode 120000 index 0000000..0a542e0 --- /dev/null +++ b/bin/goreleaser @@ -0,0 +1 @@ +.goreleaser-2.13.2.pkg \ No newline at end of file diff --git a/bin/hermit b/bin/hermit new file mode 100755 index 0000000..31559b7 --- /dev/null +++ b/bin/hermit @@ -0,0 +1,43 @@ +#!/bin/bash +# +# THIS FILE IS GENERATED; DO NOT MODIFY + +set -eo pipefail + +export HERMIT_USER_HOME=~ + +if [ -z "${HERMIT_STATE_DIR}" ]; then + case "$(uname -s)" in + Darwin) + export HERMIT_STATE_DIR="${HERMIT_USER_HOME}/Library/Caches/hermit" + ;; + Linux) + export HERMIT_STATE_DIR="${XDG_CACHE_HOME:-${HERMIT_USER_HOME}/.cache}/hermit" + ;; + esac +fi + +export HERMIT_DIST_URL="${HERMIT_DIST_URL:-https://github.com/cashapp/hermit/releases/download/stable}" +HERMIT_CHANNEL="$(basename "${HERMIT_DIST_URL}")" +export HERMIT_CHANNEL +export HERMIT_EXE=${HERMIT_EXE:-${HERMIT_STATE_DIR}/pkg/hermit@${HERMIT_CHANNEL}/hermit} + +if [ ! -x "${HERMIT_EXE}" ]; then + echo "Bootstrapping ${HERMIT_EXE} from ${HERMIT_DIST_URL}" 1>&2 + INSTALL_SCRIPT="$(mktemp)" + # This value must match that of the install script + INSTALL_SCRIPT_SHA256="09ed936378857886fd4a7a4878c0f0c7e3d839883f39ca8b4f2f242e3126e1c6" + if [ "${INSTALL_SCRIPT_SHA256}" = "BYPASS" ]; then + curl -fsSL "${HERMIT_DIST_URL}/install.sh" -o "${INSTALL_SCRIPT}" + else + # Install script is versioned by its sha256sum value + curl -fsSL "${HERMIT_DIST_URL}/install-${INSTALL_SCRIPT_SHA256}.sh" -o "${INSTALL_SCRIPT}" + # Verify install script's sha256sum + openssl dgst -sha256 "${INSTALL_SCRIPT}" | \ + awk -v EXPECTED="$INSTALL_SCRIPT_SHA256" \ + '$2!=EXPECTED {print "Install script sha256 " $2 " does not match " EXPECTED; exit 1}' + fi + /bin/bash "${INSTALL_SCRIPT}" 1>&2 +fi + +exec "${HERMIT_EXE}" --level=fatal exec "$0" -- "$@" diff --git a/bin/hermit.hcl b/bin/hermit.hcl new file mode 100644 index 0000000..fe08678 --- /dev/null +++ b/bin/hermit.hcl @@ -0,0 +1,6 @@ +env = { + "PATH": "${HERMIT_ENV}/scripts:${PATH}", +} + +github-token-auth { +} diff --git a/bin/just b/bin/just new file mode 120000 index 0000000..06b74b0 --- /dev/null +++ b/bin/just @@ -0,0 +1 @@ +.just-1.45.0.pkg \ No newline at end of file diff --git a/bin/lefthook b/bin/lefthook new file mode 120000 index 0000000..40c4321 --- /dev/null +++ b/bin/lefthook @@ -0,0 +1 @@ +.lefthook-2.0.13.pkg \ No newline at end of file diff --git a/bin/svu b/bin/svu new file mode 120000 index 0000000..0c68381 --- /dev/null +++ b/bin/svu @@ -0,0 +1 @@ +.svu-3.3.0.pkg \ No newline at end of file diff --git a/cmd/sfptc/main.go b/cmd/sfptc/main.go new file mode 100644 index 0000000..073b3ba --- /dev/null +++ b/cmd/sfptc/main.go @@ -0,0 +1,15 @@ +package main + +import ( + "github.com/alecthomas/kong" + + "github.com/block/sfptc/internal/logging" +) + +var cli struct { + logging.Config +} + +func main() { + kong.Parse(&cli) +} diff --git a/cmd/sfptcd/main.go b/cmd/sfptcd/main.go new file mode 100644 index 0000000..9c44ee7 --- /dev/null +++ b/cmd/sfptcd/main.go @@ -0,0 +1,22 @@ +package main + +import ( + "context" + + "github.com/alecthomas/kong" + + "github.com/block/sfptc/internal/logging" +) + +var cli struct { + logging.Config `prefix:"log-"` +} + +func main() { + kong.Parse(&cli) + + ctx := context.Background() + logger, ctx := logging.Configure(ctx, cli.Config) + + logger.InfoContext(ctx, "Starting sfptcd") +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..90cac21 --- /dev/null +++ b/go.mod @@ -0,0 +1,22 @@ +module github.com/block/sfptc + +go 1.25.5 + +require ( + github.com/alecthomas/hcl v0.5.5 + github.com/alecthomas/kong v1.13.0 + github.com/lmittmann/tint v1.1.2 +) + +require ( + github.com/hexops/gotextdiff v1.0.3 // indirect + golang.org/x/sys v0.0.0-20220408201424-a24fb2fb8a0f // indirect +) + +require ( + github.com/alecthomas/assert/v2 v2.11.0 + github.com/alecthomas/errors v0.8.3 + github.com/alecthomas/participle/v2 v2.0.0-beta.5 // indirect + github.com/alecthomas/repr v0.5.2 // indirect + github.com/pkg/xattr v0.4.12 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..10be116 --- /dev/null +++ b/go.sum @@ -0,0 +1,20 @@ +github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= +github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= +github.com/alecthomas/errors v0.8.3 h1:IPyQj2fU3GGsl6C/r4OPmYgqgNSDLWJLE/ln2fLjwas= +github.com/alecthomas/errors v0.8.3/go.mod h1:l8mjMEHMGUdIWPMNtvDyRYPVS1fQFXHFXc/iVCCLGkI= +github.com/alecthomas/hcl v0.5.5 h1:NvSiDRO6IvzZp+cfGVost6l6BAjYtWH++G2zZd5sqk4= +github.com/alecthomas/hcl v0.5.5/go.mod h1:XAFzM7e+MoxvgApYUKiw0+C5hm22BYep3rWpAXJUT+M= +github.com/alecthomas/kong v1.13.0 h1:5e/7XC3ugvhP1DQBmTS+WuHtCbcv44hsohMgcvVxSrA= +github.com/alecthomas/kong v1.13.0/go.mod h1:wrlbXem1CWqUV5Vbmss5ISYhsVPkBb1Yo7YKJghju2I= +github.com/alecthomas/participle/v2 v2.0.0-beta.5 h1:y6dsSYVb1G5eK6mgmy+BgI3Mw35a3WghArZ/Hbebrjo= +github.com/alecthomas/participle/v2 v2.0.0-beta.5/go.mod h1:RC764t6n4L8D8ITAJv0qdokritYSNR3wV5cVwmIEaMM= +github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs= +github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= +github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= +github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= +github.com/lmittmann/tint v1.1.2 h1:2CQzrL6rslrsyjqLDwD11bZ5OpLBPU+g3G/r5LSfS8w= +github.com/lmittmann/tint v1.1.2/go.mod h1:HIS3gSy7qNwGCj+5oRjAutErFBl4BzdQP6cJZ0NfMwE= +github.com/pkg/xattr v0.4.12 h1:rRTkSyFNTRElv6pkA3zpjHpQ90p/OdHQC1GmGh1aTjM= +github.com/pkg/xattr v0.4.12/go.mod h1:di8WF84zAKk8jzR1UBTEWh9AUlIZZ7M/JNt8e9B6ktU= +golang.org/x/sys v0.0.0-20220408201424-a24fb2fb8a0f h1:8w7RhxzTVgUzw/AH/9mUV5q0vMgy40SQRursCcfmkCw= +golang.org/x/sys v0.0.0-20220408201424-a24fb2fb8a0f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= diff --git a/internal/cache/api.go b/internal/cache/api.go new file mode 100644 index 0000000..fd99403 --- /dev/null +++ b/internal/cache/api.go @@ -0,0 +1,41 @@ +// Package cache provides a framework for implementing and registering different cache backends. +package cache + +import ( + "context" + "io" + "time" + + "github.com/alecthomas/errors" + "github.com/alecthomas/hcl" +) + +var registry = map[string]func(config *hcl.Block) (Cache, error){} + +// Factory is a function that creates a new cache instance from the given hcl-tagged configuration struct. +type Factory[Config any, C Cache] func(ctx context.Context, config Config) (C, error) + +// Register a cache factory function. +func Register[Config any, C Cache](id string, factory Factory[Config, C]) { + registry[id] = func(config *hcl.Block) (Cache, error) { + var cfg Config + if err := hcl.UnmarshalBlock(config, &cfg); err != nil { + return nil, errors.WithStack(err) + } + return factory(context.Background(), cfg) + } +} + +// A Cache knows how to retrieve, create and delete files from a cache. +type Cache interface { + // Open an existing file in the cache. + Open(ctx context.Context, path string) (io.ReadCloser, error) + // Create a new file in the cache. + // + // The file must be atomically created once closed. + Create(ctx context.Context, path string, ttl time.Duration) (io.WriteCloser, error) + // Delete a file from the cache. + Delete(ctx context.Context, path string) error + // Close the Cache. + Close() error +} diff --git a/internal/cache/disk.go b/internal/cache/disk.go new file mode 100644 index 0000000..86fcb30 --- /dev/null +++ b/internal/cache/disk.go @@ -0,0 +1,391 @@ +package cache + +import ( + "context" + "fmt" + "io" + "io/fs" + "log/slog" + "os" + "path/filepath" + "sort" + "sync/atomic" + "time" + + "github.com/alecthomas/errors" + "github.com/alecthomas/kong" + "github.com/pkg/xattr" + + "github.com/block/sfptc/internal/logging" +) + +func init() { + Register("disk", NewDisk) +} + +type DiskConfig struct { + Root string `hcl:"root" help:"Root directory for the disk storage."` + LimitMB int `hcl:"limit-mb,optional" help:"Maximum size of the disk cache in megabytes (defaults to 1GB)." default:"1024"` + MaxTTL time.Duration `hcl:"max-ttl,optional" help:"Maximum time-to-live for entries in the disk cache (defaults to 1 hour)." default:"1h"` + EvictInterval time.Duration `hcl:"evict-interval,optional" help:"Interval at which to check files for eviction (defaults to 1 minute)." default:"1m"` +} + +type Disk struct { + logger *slog.Logger + config DiskConfig + root *os.Root + size atomic.Int64 + runEviction chan struct{} + stop context.CancelFunc +} + +var _ Cache = (*Disk)(nil) + +// NewDisk creates a new disk-based cache instance. +// +// config.Root MUST be set. +// +// This [Cache] implementation stores cache entries under a directory. If total usage exceeds the limit, entries are +// evicted based on their last access time. TTLs are stored in extended file attributes (xattr). If an entry exceeds its +// TTL or the default, it is evicted. The implementation is safe for concurrent use within a single Go process. +func NewDisk(ctx context.Context, config DiskConfig) (*Disk, error) { + // Validate config + if config.Root == "" { + return nil, errors.New("root directory is required") + } + err := kong.ApplyDefaults(&config) + if err != nil { + return nil, errors.Errorf("failed to apply defaults: %w", err) + } + config.Root, err = filepath.Abs(config.Root) + if err != nil { + return nil, errors.Errorf("failed to get absolute path for cache root: %w", err) + } + + if err := os.MkdirAll(config.Root, 0750); err != nil { + return nil, errors.Errorf("failed to create cache root: %w", err) + } + + // Check if the filesystem supports xattr's, and simultaneously configure the limit. + if err := xattr.Set(config.Root, "limit-mb", fmt.Appendf(nil, "%x", config.LimitMB)); err != nil { + return nil, errors.Errorf("fatal: xattrs are not supported on %s: %w", config.Root, err) + } + + // Open an os.Root to "chroot" access. + root, err := os.OpenRoot(config.Root) + if err != nil { + return nil, errors.Errorf("failed to open cache root: %w", err) + } + + // Determine the initial size. + var size int64 + err = filepath.Walk(config.Root, func(_ string, info fs.FileInfo, err error) error { + if err != nil { + return err + } + if info.IsDir() { + return nil + } + size += info.Size() + return nil + }) + if err != nil { + return nil, errors.Errorf("failed to walk cache root: %w", err) + } + + logger := logging.FromContext(ctx) + + ctx, stop := context.WithCancel(ctx) + + disk := &Disk{ + logger: logger, + config: config, + root: root, + runEviction: make(chan struct{}), + stop: stop, + } + disk.size.Store(size) + + go disk.evictionLoop(ctx) + + return disk, nil +} + +func (d *Disk) Close() error { + d.stop() + return d.root.Close() +} + +func (d *Disk) Size() int64 { + return d.size.Load() +} + +func (d *Disk) Create(_ context.Context, path string, ttl time.Duration) (io.WriteCloser, error) { + if ttl > d.config.MaxTTL || ttl == 0 { + ttl = d.config.MaxTTL + } + + path = d.normalizePath(path) + + dir := filepath.Dir(path) + if dir != "" && dir != "." { + if err := d.root.MkdirAll(dir, 0755); err != nil { + return nil, errors.Errorf("failed to create directory %s: %w", dir, err) + } + } + + tempPath := path + ".tmp" + f, err := d.root.OpenFile(tempPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644) + if err != nil { + return nil, errors.Errorf("failed to create temp file: %w", err) + } + + expiresAt := time.Now().Add(ttl) + + return &diskWriter{ + disk: d, + file: f, + path: path, + tempPath: tempPath, + expiresAt: expiresAt, + }, nil +} + +func (d *Disk) Delete(_ context.Context, path string) error { + path = d.normalizePath(path) + + // Check if file is expired + expired := false + fullPath := filepath.Join(d.config.Root, path) + expiresAtBytes, err := xattr.Get(fullPath, "expires-at") + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + return fs.ErrNotExist + } + // Continue with deletion even if we can't read xattr + } else { + var expiresAt time.Time + if err := expiresAt.UnmarshalBinary(expiresAtBytes); err == nil { + if time.Now().After(expiresAt) { + expired = true + } + } + } + + info, err := d.root.Stat(path) + if err != nil { + return errors.Errorf("failed to stat file: %w", err) + } + + if err := d.root.Remove(path); err != nil { + return errors.Errorf("failed to remove file: %w", err) + } + + d.size.Add(-info.Size()) + + if expired { + return errors.Errorf("%s: %w", path, fs.ErrNotExist) + } + return nil +} + +func (d *Disk) Open(ctx context.Context, path string) (io.ReadCloser, error) { + path = d.normalizePath(path) + + f, err := d.root.Open(path) + if err != nil { + return nil, errors.Errorf("failed to open file: %w", err) + } + + expiresAtBytes, err := xattr.FGet(f, "expires-at") + if err != nil { + return nil, errors.Join(errors.Errorf("failed to get expiration time: %w", err), f.Close()) + } + + var expiresAt time.Time + if err := expiresAt.UnmarshalBinary(expiresAtBytes); err != nil { + return nil, errors.Join(errors.Errorf("failed to unmarshal expiration time: %w", err), f.Close()) + } + + now := time.Now() + if now.After(expiresAt) { + return nil, errors.Join(fs.ErrNotExist, f.Close(), d.Delete(ctx, path)) + } + + // Reset expiration time to implement LRU + ttl := min(expiresAt.Sub(now), d.config.MaxTTL) + newExpiresAt := now.Add(ttl) + newExpiresAtBytes, err := newExpiresAt.MarshalBinary() + if err != nil { + return nil, errors.Join(errors.Errorf("failed to marshal new expiration time: %w", err), f.Close()) + } + + if err := xattr.FSet(f, "expires-at", newExpiresAtBytes); err != nil { + return nil, errors.Join(errors.Errorf("failed to update expiration time: %w", err), f.Close()) + } + + return f, nil +} + +func (d *Disk) normalizePath(path string) string { + path = filepath.Clean(path) + if filepath.IsAbs(path) { + path = path[1:] + } + return path +} + +func (d *Disk) evictionLoop(ctx context.Context) { + ticker := time.NewTicker(d.config.EvictInterval) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + if err := d.evict(); err != nil { + d.logger.ErrorContext(ctx, "eviction failed", "error", err) + } + case <-d.runEviction: + if err := d.evict(); err != nil { + d.logger.ErrorContext(ctx, "eviction failed", "error", err) + } + } + } +} + +func (d *Disk) evict() error { + type fileInfo struct { + path string + size int64 + expiresAt time.Time + accessedAt time.Time + } + + var files []fileInfo + now := time.Now() + + err := filepath.Walk(d.config.Root, func(path string, info fs.FileInfo, err error) error { + if err != nil { + return err + } + if info.IsDir() { + return nil + } + + relPath, err := filepath.Rel(d.config.Root, path) + if err != nil { + return err + } + + expiresAtBytes, err := xattr.Get(path, "expires-at") + if err != nil { + return nil //nolint:nilerr + } + + var expiresAt time.Time + if err := expiresAt.UnmarshalBinary(expiresAtBytes); err != nil { + return nil //nolint:nilerr + } + + files = append(files, fileInfo{ + path: relPath, + size: info.Size(), + expiresAt: expiresAt, + accessedAt: info.ModTime(), + }) + + return nil + }) + + if err != nil { + return errors.Errorf("failed to walk cache directory: %w", err) + } + + var remainingFiles []fileInfo + + for _, f := range files { + if now.After(f.expiresAt) { + if err := d.Delete(context.Background(), f.path); err != nil && !errors.Is(err, fs.ErrNotExist) { + return errors.Errorf("failed to delete expired file %s: %w", f.path, err) + } + } else { + remainingFiles = append(remainingFiles, f) + } + } + + limitBytes := int64(d.config.LimitMB) * 1024 * 1024 + if d.size.Load() <= limitBytes { + return nil + } + + // Sort by access time (oldest first) + sort.Slice(remainingFiles, func(i, j int) bool { + return remainingFiles[i].accessedAt.Before(remainingFiles[j].accessedAt) + }) + + for _, f := range remainingFiles { + if d.size.Load() <= limitBytes { + break + } + + if err := d.Delete(context.Background(), f.path); err != nil && !errors.Is(err, fs.ErrNotExist) { + return errors.Errorf("failed to delete file during size eviction %s: %w", f.path, err) + } + } + + return nil +} + +type diskWriter struct { + disk *Disk + file *os.File + path string + tempPath string + expiresAt time.Time + size int64 +} + +func (w *diskWriter) Write(p []byte) (int, error) { + n, err := w.file.Write(p) + w.size += int64(n) + return n, err +} + +func (w *diskWriter) Close() error { + if err := w.file.Close(); err != nil { + return errors.Errorf("failed to close temp file: %w", err) + } + + f, err := w.disk.root.Open(w.tempPath) + if err != nil { + return errors.Errorf("failed to open temp file for setting xattr: %w", err) + } + + expiresAtBytes, err := w.expiresAt.MarshalBinary() + if err != nil { + return errors.Join(errors.Errorf("failed to marshal expiration time: %w", err), f.Close()) + } + + if err := xattr.FSet(f, "expires-at", expiresAtBytes); err != nil { + return errors.Join(errors.Errorf("failed to set expiration time: %w", err), f.Close()) + } + + if err := f.Close(); err != nil { + return errors.Errorf("failed to close temp file after setting xattr: %w", err) + } + + if err := w.disk.root.Rename(w.tempPath, w.path); err != nil { + return errors.Errorf("failed to rename temp file: %w", err) + } + + w.disk.size.Add(w.size) + + select { + case w.disk.runEviction <- struct{}{}: + default: + } + + return nil +} diff --git a/internal/cache/disk_test.go b/internal/cache/disk_test.go new file mode 100644 index 0000000..d4c0b65 --- /dev/null +++ b/internal/cache/disk_test.go @@ -0,0 +1,112 @@ +package cache_test + +import ( + "log/slog" + "os" + "path/filepath" + "testing" + "time" + + "github.com/alecthomas/assert/v2" + + "github.com/block/sfptc/internal/cache" + "github.com/block/sfptc/internal/logging" +) + +func TestDiskStorageExpiry(t *testing.T) { + dir := t.TempDir() + _, ctx := logging.Configure(t.Context(), logging.Config{Level: slog.LevelDebug}) + + disk, err := cache.NewDisk(ctx, cache.DiskConfig{Root: dir}) + assert.NoError(t, err) + + assert.Equal(t, int64(0), disk.Size(), "initial size should be 0") + + w1, err := disk.Create(ctx, "/first", time.Second*3) + assert.NoError(t, err) + _, err = w1.Write([]byte("first file data")) + assert.NoError(t, err) + assert.NoError(t, w1.Close()) + + w2, err := disk.Create(ctx, "/second", time.Second) + assert.NoError(t, err) + _, err = w2.Write([]byte("second file data")) + assert.NoError(t, err) + assert.NoError(t, w2.Close()) + + expectedSize := int64(len("first file data") + len("second file data")) + assert.Equal(t, expectedSize, disk.Size(), "size should match written data") + + time.Sleep(time.Second * 2) + + r, err := disk.Open(ctx, "/first") + assert.NoError(t, err) + assert.NoError(t, r.Close()) + + _, err = disk.Open(ctx, "/second") + assert.IsError(t, err, os.ErrNotExist) +} + +func TestDiskAsyncEviction(t *testing.T) { + dir := t.TempDir() + _, ctx := logging.Configure(t.Context(), logging.Config{Level: slog.LevelDebug}) + + disk, err := cache.NewDisk(ctx, cache.DiskConfig{ + Root: dir, + EvictInterval: 500 * time.Millisecond, + }) + assert.NoError(t, err) + t.Cleanup(func() { assert.NoError(t, disk.Close()) }) + + assert.Equal(t, int64(0), disk.Size(), "initial size should be 0") + + // Create files that will expire + w1, err := disk.Create(ctx, "/expire1", 800*time.Millisecond) + assert.NoError(t, err) + _, err = w1.Write([]byte("expires soon")) + assert.NoError(t, err) + assert.NoError(t, w1.Close()) + + w2, err := disk.Create(ctx, "/expire2", 800*time.Millisecond) + assert.NoError(t, err) + _, err = w2.Write([]byte("expires soon")) + assert.NoError(t, err) + assert.NoError(t, w2.Close()) + + // Create files that won't expire + w3, err := disk.Create(ctx, "/keep1", 10*time.Second) + assert.NoError(t, err) + _, err = w3.Write([]byte("keep this")) + assert.NoError(t, err) + assert.NoError(t, w3.Close()) + + w4, err := disk.Create(ctx, "/keep2", 10*time.Second) + assert.NoError(t, err) + _, err = w4.Write([]byte("keep this too")) + assert.NoError(t, err) + assert.NoError(t, w4.Close()) + + expectedSize := int64(len("expires soon")*2 + len("keep this") + len("keep this too")) + assert.Equal(t, expectedSize, disk.Size(), "size should match total written data") + + // Wait for expired files to be evicted + time.Sleep(1500 * time.Millisecond) + + // Check filesystem directly to verify async eviction actually deleted files + _, err = os.Stat(filepath.Join(dir, "expire1")) + assert.IsError(t, err, os.ErrNotExist, "expire1 should be deleted from disk") + + _, err = os.Stat(filepath.Join(dir, "expire2")) + assert.IsError(t, err, os.ErrNotExist, "expire2 should be deleted from disk") + + // Non-expired files should still exist on disk + _, err = os.Stat(filepath.Join(dir, "keep1")) + assert.NoError(t, err, "keep1 should still exist on disk") + + _, err = os.Stat(filepath.Join(dir, "keep2")) + assert.NoError(t, err, "keep2 should still exist on disk") + + // Verify size only includes non-expired files + expectedSizeAfterEviction := int64(len("keep this") + len("keep this too")) + assert.Equal(t, expectedSizeAfterEviction, disk.Size(), "size should only include non-expired files") +} diff --git a/internal/logging/logging.go b/internal/logging/logging.go new file mode 100644 index 0000000..385c00e --- /dev/null +++ b/internal/logging/logging.go @@ -0,0 +1,44 @@ +// Package logging provides logging configuration and utility functions. +package logging + +import ( + "context" + "log/slog" + "os" + + "github.com/lmittmann/tint" +) + +type Config struct { + JSON bool `help:"Enable JSON logging."` + Level slog.Level `help:"Set the logging level." default:"info"` +} + +type logKey struct{} + +func Configure(ctx context.Context, config Config) (*slog.Logger, context.Context) { + var handler slog.Handler + if config.JSON { + handler = slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: config.Level}) + } else { + handler = tint.NewHandler(os.Stderr, &tint.Options{ + Level: config.Level, + ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr { + if a.Key == slog.TimeKey && len(groups) == 0 { + return slog.Attr{} + } + return a + }, + }) + } + logger := slog.New(handler) + return logger, context.WithValue(ctx, logKey{}, logger) +} + +func FromContext(ctx context.Context) *slog.Logger { + logger, ok := ctx.Value(logKey{}).(*slog.Logger) + if !ok { + panic("no logger in context") + } + return logger +} diff --git a/internal/strategy/api.go b/internal/strategy/api.go new file mode 100644 index 0000000..7f390a6 --- /dev/null +++ b/internal/strategy/api.go @@ -0,0 +1,29 @@ +// Package strategy provides a framework for implementing and registering different caching strategies. +package strategy + +import ( + "context" + "net/http" + + "github.com/alecthomas/errors" + "github.com/alecthomas/hcl" +) + +var registry = map[string]func(config *hcl.Block) (Strategy, error){} + +type Factory[Config any] func(ctx context.Context, config Config) (Strategy, error) + +// Register a new caching strategy. +func Register[Config any](id string, factory Factory[Config]) { + registry[id] = func(config *hcl.Block) (Strategy, error) { + var cfg Config + if err := hcl.UnmarshalBlock(config, &cfg); err != nil { + return nil, errors.WithStack(err) + } + return factory(context.Background(), cfg) + } +} + +type Strategy interface { + Register(mux *http.ServeMux) +} diff --git a/internal/strategy/default.go b/internal/strategy/default.go new file mode 100644 index 0000000..a6f3767 --- /dev/null +++ b/internal/strategy/default.go @@ -0,0 +1,3 @@ +package strategy + +type Default struct{} diff --git a/lefthook.yml b/lefthook.yml new file mode 100644 index 0000000..328d6d0 --- /dev/null +++ b/lefthook.yml @@ -0,0 +1,16 @@ +skip_lfs: true +output: + - success + - failure +pre-commit: + commands: + format: + run: just fmt + stage_fixed: true +pre-push: + parallel: true + commands: + test: + run: just test + lint: + run: just lint diff --git a/scripts/sfptc b/scripts/sfptc new file mode 120000 index 0000000..83eaee9 --- /dev/null +++ b/scripts/sfptc @@ -0,0 +1 @@ +sfptcd \ No newline at end of file diff --git a/scripts/sfptcd b/scripts/sfptcd new file mode 100755 index 0000000..59e5c7a --- /dev/null +++ b/scripts/sfptcd @@ -0,0 +1,10 @@ +#!/bin/bash +set -euo pipefail + +root="$(dirname "$(readlink -f "$0")")/.." +dest="${root}/dist/devel" +name="$(basename "$0")" +src="./cmd/${name}" +mkdir -p "$dest" + +"${root}/bin/go" build -C "${root}/${src}" -ldflags="-s -w -buildid=" -o "$dest/${name}" . && exec "$dest/${name}" "$@" From a765c22e171c9b1e5b50785846d579b3710cc2e2 Mon Sep 17 00:00:00 2001 From: Alec Thomas Date: Sat, 27 Dec 2025 15:53:14 +1100 Subject: [PATCH 02/10] ci: setup ext4 filesystem with xattr support for tests --- .github/workflows/ci.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5828f57..de82b81 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,8 +24,11 @@ jobs: - uses: cashapp/activate-hermit@v1 - name: Setup xattr-supported filesystem run: | + dd if=/dev/zero of=/tmp/xattr.img bs=1M count=1024 + mkfs.ext4 /tmp/xattr.img mkdir -p /tmp/xattr-fs - sudo mount -t tmpfs -o size=1G tmpfs /tmp/xattr-fs + sudo mount -o loop,user_xattr /tmp/xattr.img /tmp/xattr-fs + sudo chmod 1777 /tmp/xattr-fs - name: Run tests run: just test env: From d916365f7dc110b4f8c5ae5a877254d51e058171 Mon Sep 17 00:00:00 2001 From: Alec Thomas Date: Sat, 27 Dec 2025 15:54:29 +1100 Subject: [PATCH 03/10] ci: explicitly enable user_xattr on ext4 filesystem --- .github/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index de82b81..8e5bda2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,9 +25,9 @@ jobs: - name: Setup xattr-supported filesystem run: | dd if=/dev/zero of=/tmp/xattr.img bs=1M count=1024 - mkfs.ext4 /tmp/xattr.img + mkfs.ext4 -O user_xattr /tmp/xattr.img mkdir -p /tmp/xattr-fs - sudo mount -o loop,user_xattr /tmp/xattr.img /tmp/xattr-fs + sudo mount -o loop /tmp/xattr.img /tmp/xattr-fs sudo chmod 1777 /tmp/xattr-fs - name: Run tests run: just test From 96644c9264751239a52486011ccd10eb51665065 Mon Sep 17 00:00:00 2001 From: Alec Thomas Date: Sat, 27 Dec 2025 15:55:40 +1100 Subject: [PATCH 04/10] ci: use default ext4 options (user_xattr is enabled by default) --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8e5bda2..eda9b74 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,7 +25,7 @@ jobs: - name: Setup xattr-supported filesystem run: | dd if=/dev/zero of=/tmp/xattr.img bs=1M count=1024 - mkfs.ext4 -O user_xattr /tmp/xattr.img + mkfs.ext4 -q /tmp/xattr.img mkdir -p /tmp/xattr-fs sudo mount -o loop /tmp/xattr.img /tmp/xattr-fs sudo chmod 1777 /tmp/xattr-fs From b8dffce856197a90453e9fc3f36fa0de1c1dbd74 Mon Sep 17 00:00:00 2001 From: Alec Thomas Date: Sat, 27 Dec 2025 15:57:00 +1100 Subject: [PATCH 05/10] ci: add xattr verification step --- .github/workflows/ci.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index eda9b74..a9e684f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,6 +29,11 @@ jobs: mkdir -p /tmp/xattr-fs sudo mount -o loop /tmp/xattr.img /tmp/xattr-fs sudo chmod 1777 /tmp/xattr-fs + # Verify xattr support + echo "test" > /tmp/xattr-fs/test-file + setfattr -n user.test -v "test-value" /tmp/xattr-fs/test-file + getfattr -n user.test /tmp/xattr-fs/test-file + rm /tmp/xattr-fs/test-file - name: Run tests run: just test env: From 22acedda68034dc5dbbc4f213f4985727ae59dad Mon Sep 17 00:00:00 2001 From: Alec Thomas Date: Sat, 27 Dec 2025 15:57:59 +1100 Subject: [PATCH 06/10] fix: test xattr support on temp file, not directory --- internal/cache/disk.go | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/internal/cache/disk.go b/internal/cache/disk.go index 86fcb30..e338096 100644 --- a/internal/cache/disk.go +++ b/internal/cache/disk.go @@ -66,10 +66,18 @@ func NewDisk(ctx context.Context, config DiskConfig) (*Disk, error) { return nil, errors.Errorf("failed to create cache root: %w", err) } - // Check if the filesystem supports xattr's, and simultaneously configure the limit. - if err := xattr.Set(config.Root, "limit-mb", fmt.Appendf(nil, "%x", config.LimitMB)); err != nil { + // Check if the filesystem supports xattr's by creating a temporary test file. + f, err := os.CreateTemp(config.Root, ".xattr-test-*") + if err != nil { + return nil, errors.Errorf("failed to create xattr test file: %w", err) + } + testFile := f.Name() + f.Close() + if err := xattr.Set(testFile, "limit-mb", fmt.Appendf(nil, "%x", config.LimitMB)); err != nil { + os.Remove(testFile) return nil, errors.Errorf("fatal: xattrs are not supported on %s: %w", config.Root, err) } + os.Remove(testFile) // Open an os.Root to "chroot" access. root, err := os.OpenRoot(config.Root) From 38748a2525de96c0f3e9cf31db22c501eb56a983 Mon Sep 17 00:00:00 2001 From: Alec Thomas Date: Sat, 27 Dec 2025 15:58:20 +1100 Subject: [PATCH 07/10] refactor: use xattr.FSet instead of Set --- internal/cache/disk.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/internal/cache/disk.go b/internal/cache/disk.go index e338096..3a40256 100644 --- a/internal/cache/disk.go +++ b/internal/cache/disk.go @@ -72,11 +72,12 @@ func NewDisk(ctx context.Context, config DiskConfig) (*Disk, error) { return nil, errors.Errorf("failed to create xattr test file: %w", err) } testFile := f.Name() - f.Close() - if err := xattr.Set(testFile, "limit-mb", fmt.Appendf(nil, "%x", config.LimitMB)); err != nil { + if err := xattr.FSet(f, "limit-mb", fmt.Appendf(nil, "%x", config.LimitMB)); err != nil { + f.Close() os.Remove(testFile) return nil, errors.Errorf("fatal: xattrs are not supported on %s: %w", config.Root, err) } + f.Close() os.Remove(testFile) // Open an os.Root to "chroot" access. From 24551ce5db8819f86007ab2e6d30c0480a2eed54 Mon Sep 17 00:00:00 2001 From: Alec Thomas Date: Sat, 27 Dec 2025 15:58:42 +1100 Subject: [PATCH 08/10] ci: use tmpfs with user_xattr option --- .github/workflows/ci.yml | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a9e684f..066f2a5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,16 +24,9 @@ jobs: - uses: cashapp/activate-hermit@v1 - name: Setup xattr-supported filesystem run: | - dd if=/dev/zero of=/tmp/xattr.img bs=1M count=1024 - mkfs.ext4 -q /tmp/xattr.img mkdir -p /tmp/xattr-fs - sudo mount -o loop /tmp/xattr.img /tmp/xattr-fs + sudo mount -t tmpfs -o size=1G,user_xattr tmpfs /tmp/xattr-fs sudo chmod 1777 /tmp/xattr-fs - # Verify xattr support - echo "test" > /tmp/xattr-fs/test-file - setfattr -n user.test -v "test-value" /tmp/xattr-fs/test-file - getfattr -n user.test /tmp/xattr-fs/test-file - rm /tmp/xattr-fs/test-file - name: Run tests run: just test env: From 96699955092eb4a149da35b80cd4a2d6b3f06613 Mon Sep 17 00:00:00 2001 From: Alec Thomas Date: Sat, 27 Dec 2025 15:59:08 +1100 Subject: [PATCH 09/10] fix: handle errors in xattr test cleanup --- .github/workflows/ci.yml | 3 +-- Justfile | 1 + bin/.actionlint-1.6.26.pkg | 1 + bin/actionlint | 1 + internal/cache/disk.go | 8 ++++---- 5 files changed, 8 insertions(+), 6 deletions(-) create mode 120000 bin/.actionlint-1.6.26.pkg create mode 120000 bin/actionlint diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 066f2a5..5828f57 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,8 +25,7 @@ jobs: - name: Setup xattr-supported filesystem run: | mkdir -p /tmp/xattr-fs - sudo mount -t tmpfs -o size=1G,user_xattr tmpfs /tmp/xattr-fs - sudo chmod 1777 /tmp/xattr-fs + sudo mount -t tmpfs -o size=1G tmpfs /tmp/xattr-fs - name: Run tests run: just test env: diff --git a/Justfile b/Justfile index 7db218b..3b85b00 100644 --- a/Justfile +++ b/Justfile @@ -8,6 +8,7 @@ test: # Lint code lint: golangci-lint run + actionlint # Format code fmt: diff --git a/bin/.actionlint-1.6.26.pkg b/bin/.actionlint-1.6.26.pkg new file mode 120000 index 0000000..383f451 --- /dev/null +++ b/bin/.actionlint-1.6.26.pkg @@ -0,0 +1 @@ +hermit \ No newline at end of file diff --git a/bin/actionlint b/bin/actionlint new file mode 120000 index 0000000..40c81b8 --- /dev/null +++ b/bin/actionlint @@ -0,0 +1 @@ +.actionlint-1.6.26.pkg \ No newline at end of file diff --git a/internal/cache/disk.go b/internal/cache/disk.go index 3a40256..27dc19d 100644 --- a/internal/cache/disk.go +++ b/internal/cache/disk.go @@ -73,12 +73,12 @@ func NewDisk(ctx context.Context, config DiskConfig) (*Disk, error) { } testFile := f.Name() if err := xattr.FSet(f, "limit-mb", fmt.Appendf(nil, "%x", config.LimitMB)); err != nil { - f.Close() - os.Remove(testFile) + _ = f.Close() + _ = os.Remove(testFile) return nil, errors.Errorf("fatal: xattrs are not supported on %s: %w", config.Root, err) } - f.Close() - os.Remove(testFile) + _ = f.Close() + _ = os.Remove(testFile) // Open an os.Root to "chroot" access. root, err := os.OpenRoot(config.Root) From 3c8d97f7cbc8bdd24200f4717215a71f35bb5ebb Mon Sep 17 00:00:00 2001 From: Alec Thomas Date: Sat, 27 Dec 2025 16:03:59 +1100 Subject: [PATCH 10/10] fix: add user. prefix to xattr names for Linux compatibility --- .github/workflows/ci.yml | 6 ------ internal/cache/disk.go | 18 +++++++++--------- 2 files changed, 9 insertions(+), 15 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5828f57..8b04aff 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,14 +22,8 @@ jobs: steps: - uses: actions/checkout@v4 - uses: cashapp/activate-hermit@v1 - - name: Setup xattr-supported filesystem - run: | - mkdir -p /tmp/xattr-fs - sudo mount -t tmpfs -o size=1G tmpfs /tmp/xattr-fs - name: Run tests run: just test - env: - TMPDIR: /tmp/xattr-fs lint: name: Lint runs-on: ubuntu-latest diff --git a/internal/cache/disk.go b/internal/cache/disk.go index 27dc19d..0f7b379 100644 --- a/internal/cache/disk.go +++ b/internal/cache/disk.go @@ -19,6 +19,8 @@ import ( "github.com/block/sfptc/internal/logging" ) +const expiresAtXAttr = "user.expires-at" + func init() { Register("disk", NewDisk) } @@ -72,10 +74,8 @@ func NewDisk(ctx context.Context, config DiskConfig) (*Disk, error) { return nil, errors.Errorf("failed to create xattr test file: %w", err) } testFile := f.Name() - if err := xattr.FSet(f, "limit-mb", fmt.Appendf(nil, "%x", config.LimitMB)); err != nil { - _ = f.Close() - _ = os.Remove(testFile) - return nil, errors.Errorf("fatal: xattrs are not supported on %s: %w", config.Root, err) + if err := xattr.FSet(f, "user.limit-mb", fmt.Appendf(nil, "%x", config.LimitMB)); err != nil { + return nil, errors.Join(errors.Errorf("fatal: xattrs are not supported on %s: %w", config.Root, err), f.Close(), os.Remove(testFile)) } _ = f.Close() _ = os.Remove(testFile) @@ -166,7 +166,7 @@ func (d *Disk) Delete(_ context.Context, path string) error { // Check if file is expired expired := false fullPath := filepath.Join(d.config.Root, path) - expiresAtBytes, err := xattr.Get(fullPath, "expires-at") + expiresAtBytes, err := xattr.Get(fullPath, expiresAtXAttr) if err != nil { if errors.Is(err, fs.ErrNotExist) { return fs.ErrNotExist @@ -206,7 +206,7 @@ func (d *Disk) Open(ctx context.Context, path string) (io.ReadCloser, error) { return nil, errors.Errorf("failed to open file: %w", err) } - expiresAtBytes, err := xattr.FGet(f, "expires-at") + expiresAtBytes, err := xattr.FGet(f, expiresAtXAttr) if err != nil { return nil, errors.Join(errors.Errorf("failed to get expiration time: %w", err), f.Close()) } @@ -229,7 +229,7 @@ func (d *Disk) Open(ctx context.Context, path string) (io.ReadCloser, error) { return nil, errors.Join(errors.Errorf("failed to marshal new expiration time: %w", err), f.Close()) } - if err := xattr.FSet(f, "expires-at", newExpiresAtBytes); err != nil { + if err := xattr.FSet(f, expiresAtXAttr, newExpiresAtBytes); err != nil { return nil, errors.Join(errors.Errorf("failed to update expiration time: %w", err), f.Close()) } @@ -288,7 +288,7 @@ func (d *Disk) evict() error { return err } - expiresAtBytes, err := xattr.Get(path, "expires-at") + expiresAtBytes, err := xattr.Get(path, expiresAtXAttr) if err != nil { return nil //nolint:nilerr } @@ -377,7 +377,7 @@ func (w *diskWriter) Close() error { return errors.Join(errors.Errorf("failed to marshal expiration time: %w", err), f.Close()) } - if err := xattr.FSet(f, "expires-at", expiresAtBytes); err != nil { + if err := xattr.FSet(f, expiresAtXAttr, expiresAtBytes); err != nil { return errors.Join(errors.Errorf("failed to set expiration time: %w", err), f.Close()) }