diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..475dfed --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,38 @@ +version: 2 +updates: + - package-ecosystem: gomod + directory: / + schedule: + interval: weekly + open-pull-requests-limit: 10 + groups: + # The archive decoders parse untrusted user-uploaded bytes; keep them + # together so a security bump lands as one reviewable change. Groups + # cover version updates only unless applies-to says otherwise, so the + # security updates these exist for need their own entry. + archive-decoders: + patterns: + - github.com/bodgit/* + - github.com/nwaples/rardecode/* + - github.com/dsnet/compress + - github.com/ulikunitz/xz + - github.com/klauspost/compress + archive-decoders-security: + applies-to: security-updates + patterns: + - github.com/bodgit/* + - github.com/nwaples/rardecode/* + - github.com/dsnet/compress + - github.com/ulikunitz/xz + - github.com/klauspost/compress + aws: + patterns: + - github.com/aws/* + otel: + patterns: + - go.opentelemetry.io/* + + - package-ecosystem: github-actions + directory: / + schedule: + interval: monthly diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 36996be..49cef7b 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -14,18 +14,29 @@ jobs: os: [ubuntu-latest] runs-on: ${{ matrix.os }} steps: + - name: Checkout code + uses: actions/checkout@v5 + with: + # Nothing here pushes back, so the job has no use for the token + # checkout would otherwise leave in .git/config for every later step. + persist-credentials: false + - name: Install Go - uses: actions/setup-go@v2 + uses: actions/setup-go@v6 with: - stable: false go-version: ${{ matrix.go-version }} - - name: Checkout code - uses: actions/checkout@v2 - - name: Set up dependencies run: go mod download + - name: Run govulncheck + uses: golang/govulncheck-action@v1 + with: + go-version-input: ${{ matrix.go-version }} + # The code is already checked out above; the action's own checkout + # would only redo it and re-persist the credentials. + repo-checkout: false + - name: Run golangci-lint uses: golangci/golangci-lint-action@v8 with: @@ -53,13 +64,16 @@ jobs: test-cache: runs-on: ubuntu-latest steps: + - name: Checkout code + uses: actions/checkout@v5 + with: + persist-credentials: false + - name: Install Go - uses: actions/setup-go@v2 + uses: actions/setup-go@v6 with: go-version: 1.26.x - - name: Checkout code - uses: actions/checkout@v2 - uses: actions/cache@v4 with: # In order: diff --git a/go.mod b/go.mod index f79983a..db87c6c 100644 --- a/go.mod +++ b/go.mod @@ -1,29 +1,38 @@ module github.com/gameap/daemon -go 1.26 +// 1.26.5 is a security floor, not a language requirement: it carries the fixes +// for GO-2026-4970 and GO-2026-4864 (os.Root escapes) and GO-2026-4869 +// (unbounded allocation in archive/tar). The daemon resolves every +// caller-supplied path through os.Root, so those are load-bearing. +go 1.26.5 require ( + github.com/bodgit/sevenzip v1.6.5 github.com/containerd/errdefs v1.0.0 github.com/dgraph-io/ristretto/v2 v2.4.0 + github.com/dsnet/compress v0.0.1 github.com/emirpasic/gods v1.18.1 - github.com/gameap/gameap v0.0.0-20260514194917-d291e6dbd609 + github.com/gameap/gameap v0.0.0-20260729224522-5d77d14698ba github.com/gameap/gameapctl v0.25.0 github.com/goccy/go-yaml v1.19.2 github.com/google/uuid v1.6.0 github.com/gopherclass/go-shellquote v0.0.0-20200814145606-fab22d094485 github.com/hashicorp/go-getter v1.8.6 + github.com/klauspost/compress v1.19.1 github.com/moby/moby/api v1.54.1 github.com/moby/moby/client v0.4.0 + github.com/nwaples/rardecode/v2 v2.3.0 github.com/pkg/errors v0.9.1 github.com/rs/xid v1.6.0 github.com/shirou/gopsutil/v3 v3.24.5 github.com/sirupsen/logrus v1.9.4 github.com/stretchr/testify v1.11.1 + github.com/ulikunitz/xz v0.5.16 github.com/urfave/cli/v2 v2.27.7 go.uber.org/mock v0.6.0 - golang.org/x/sync v0.20.0 - golang.org/x/sys v0.43.0 - google.golang.org/grpc v1.80.0 + golang.org/x/sync v0.22.0 + golang.org/x/sys v0.46.0 + google.golang.org/grpc v1.82.1 google.golang.org/protobuf v1.36.11 ) @@ -36,32 +45,35 @@ require ( cloud.google.com/go/iam v1.5.3 // indirect cloud.google.com/go/monitoring v1.24.3 // indirect cloud.google.com/go/storage v1.61.3 // indirect - github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.31.0 // indirect + github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.32.0 // indirect github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.55.0 // indirect github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.55.0 // indirect github.com/Microsoft/go-winio v0.6.2 // indirect - github.com/aws/aws-sdk-go-v2 v1.41.4 // indirect - github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.7 // indirect + github.com/andybalholm/brotli v1.2.2 // indirect + github.com/aws/aws-sdk-go-v2 v1.41.5 // indirect + github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 // indirect github.com/aws/aws-sdk-go-v2/config v1.32.12 // indirect github.com/aws/aws-sdk-go-v2/credentials v1.19.12 // indirect github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.20 // indirect - github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.20 // indirect - github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.20 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 // indirect github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6 // indirect - github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.21 // indirect + github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.22 // indirect github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.12 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.20 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.20 // indirect - github.com/aws/aws-sdk-go-v2/service/s3 v1.97.1 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.13 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.21 // indirect + github.com/aws/aws-sdk-go-v2/service/s3 v1.97.3 // indirect github.com/aws/aws-sdk-go-v2/service/signin v1.0.8 // indirect github.com/aws/aws-sdk-go-v2/service/sso v1.30.13 // indirect github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.17 // indirect github.com/aws/aws-sdk-go-v2/service/sts v1.41.9 // indirect github.com/aws/smithy-go v1.24.2 // indirect github.com/bgentry/go-netrc v0.0.0-20140422174119-9fd32a8b3d3d // indirect + github.com/bodgit/plumbing v1.3.0 // indirect + github.com/bodgit/windows v1.0.1 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect - github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5 // indirect + github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 // indirect github.com/containerd/errdefs/pkg v0.3.0 // indirect github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect @@ -69,8 +81,8 @@ require ( github.com/docker/go-connections v0.6.0 // indirect github.com/docker/go-units v0.5.0 // indirect github.com/dustin/go-humanize v1.0.1 // indirect - github.com/envoyproxy/go-control-plane/envoy v1.36.0 // indirect - github.com/envoyproxy/protoc-gen-validate v1.3.0 // indirect + github.com/envoyproxy/go-control-plane/envoy v1.37.0 // indirect + github.com/envoyproxy/protoc-gen-validate v1.3.3 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/go-jose/go-jose/v4 v4.1.4 // indirect github.com/go-logr/logr v1.4.3 // indirect @@ -82,40 +94,43 @@ require ( github.com/hashicorp/aws-sdk-go-base/v2 v2.0.0-beta.72 // indirect github.com/hashicorp/go-cleanhttp v0.5.2 // indirect github.com/hashicorp/go-version v1.8.0 // indirect - github.com/klauspost/compress v1.18.5 // indirect + github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect github.com/mitchellh/go-homedir v1.1.0 // indirect github.com/moby/docker-image-spec v1.3.1 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/image-spec v1.1.1 // indirect + github.com/pierrec/lz4/v4 v4.1.27 // indirect github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/shoenig/go-m1cpu v0.1.6 // indirect + github.com/spf13/afero v1.15.0 // indirect github.com/spiffe/go-spiffe/v2 v2.6.0 // indirect + github.com/stangelandcl/ppmd v0.1.1 // indirect github.com/tklauser/go-sysconf v0.3.12 // indirect github.com/tklauser/numcpus v0.6.1 // indirect - github.com/ulikunitz/xz v0.5.15 // indirect github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect github.com/yusufpapurcu/wmi v1.2.4 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect - go.opentelemetry.io/contrib/detectors/gcp v1.39.0 // indirect + go.opentelemetry.io/contrib/detectors/gcp v1.43.0 // indirect go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.63.0 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect - go.opentelemetry.io/otel v1.42.0 // indirect - go.opentelemetry.io/otel/metric v1.42.0 // indirect - go.opentelemetry.io/otel/sdk v1.42.0 // indirect - go.opentelemetry.io/otel/sdk/metric v1.42.0 // indirect - go.opentelemetry.io/otel/trace v1.42.0 // indirect - golang.org/x/crypto v0.50.0 // indirect - golang.org/x/net v0.53.0 // indirect + go.opentelemetry.io/otel v1.43.0 // indirect + go.opentelemetry.io/otel/metric v1.43.0 // indirect + go.opentelemetry.io/otel/sdk v1.43.0 // indirect + go.opentelemetry.io/otel/sdk/metric v1.43.0 // indirect + go.opentelemetry.io/otel/trace v1.43.0 // indirect + go4.org v0.0.0-20260112195520-a5071408f32f // indirect + golang.org/x/crypto v0.53.0 // indirect + golang.org/x/net v0.56.0 // indirect golang.org/x/oauth2 v0.36.0 // indirect - golang.org/x/text v0.36.0 // indirect + golang.org/x/text v0.40.0 // indirect golang.org/x/time v0.15.0 // indirect google.golang.org/api v0.271.0 // indirect google.golang.org/genproto v0.0.0-20260128011058-8636f8732409 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20260203192932-546029d2fa20 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260414002931-afd174a4e478 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260420184626-e10c466a9529 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 472e312..8100b3f 100644 --- a/go.sum +++ b/go.sum @@ -20,8 +20,8 @@ cloud.google.com/go/storage v1.61.3 h1:VS//ZfBuPGDvakfD9xyPW1RGF1Vy3BWUoVZXgW1KM cloud.google.com/go/storage v1.61.3/go.mod h1:JtqK8BBB7TWv0HVGHubtUdzYYrakOQIsMLffZ2Z/HWk= cloud.google.com/go/trace v1.11.7 h1:kDNDX8JkaAG3R2nq1lIdkb7FCSi1rCmsEtKVsty7p+U= cloud.google.com/go/trace v1.11.7/go.mod h1:TNn9d5V3fQVf6s4SCveVMIBS2LJUqo73GACmq/Tky0s= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.31.0 h1:DHa2U07rk8syqvCge0QIGMCE1WxGj9njT44GH7zNJLQ= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.31.0/go.mod h1:P4WPRUkOhJC13W//jWpyfJNDAIpvRbAUIYLX/4jtlE0= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.32.0 h1:rIkQfkCOVKc1OiRCNcSDD8ml5RJlZbH/Xsq7lbpynwc= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.32.0/go.mod h1:RD2SsorTmYhF6HkTmDw7KmPYQk8OBYwTkuasChwv7R4= github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.55.0 h1:UnDZ/zFfG1JhH/DqxIZYU/1CUAlTUScoXD/LcM2Ykk8= github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.55.0/go.mod h1:IA1C1U7jO/ENqm/vhi7V9YYpBsp+IMyqNrEN94N7tVc= github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.55.0 h1:7t/qx5Ost0s0wbA/VDrByOooURhp+ikYwv20i9Y07TQ= @@ -30,34 +30,36 @@ github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapp github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.55.0/go.mod h1:Mf6O40IAyB9zR/1J8nGDDPirZQQPbYJni8Yisy7NTMc= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= -github.com/aws/aws-sdk-go-v2 v1.41.4 h1:10f50G7WyU02T56ox1wWXq+zTX9I1zxG46HYuG1hH/k= -github.com/aws/aws-sdk-go-v2 v1.41.4/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o= -github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.7 h1:3kGOqnh1pPeddVa/E37XNTaWJ8W6vrbYV9lJEkCnhuY= -github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.7/go.mod h1:lyw7GFp3qENLh7kwzf7iMzAxDn+NzjXEAGjKS2UOKqI= +github.com/andybalholm/brotli v1.2.2 h1:HzTuoo2ErYQqf5qvcJInB8uvqSVxRttzkFexPWtnceM= +github.com/andybalholm/brotli v1.2.2/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= +github.com/aws/aws-sdk-go-v2 v1.41.5 h1:dj5kopbwUsVUVFgO4Fi5BIT3t4WyqIDjGKCangnV/yY= +github.com/aws/aws-sdk-go-v2 v1.41.5/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 h1:eBMB84YGghSocM7PsjmmPffTa+1FBUeNvGvFou6V/4o= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8/go.mod h1:lyw7GFp3qENLh7kwzf7iMzAxDn+NzjXEAGjKS2UOKqI= github.com/aws/aws-sdk-go-v2/config v1.32.12 h1:O3csC7HUGn2895eNrLytOJQdoL2xyJy0iYXhoZ1OmP0= github.com/aws/aws-sdk-go-v2/config v1.32.12/go.mod h1:96zTvoOFR4FURjI+/5wY1vc1ABceROO4lWgWJuxgy0g= github.com/aws/aws-sdk-go-v2/credentials v1.19.12 h1:oqtA6v+y5fZg//tcTWahyN9PEn5eDU/Wpvc2+kJ4aY8= github.com/aws/aws-sdk-go-v2/credentials v1.19.12/go.mod h1:U3R1RtSHx6NB0DvEQFGyf/0sbrpJrluENHdPy1j/3TE= github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.20 h1:zOgq3uezl5nznfoK3ODuqbhVg1JzAGDUhXOsU0IDCAo= github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.20/go.mod h1:z/MVwUARehy6GAg/yQ1GO2IMl0k++cu1ohP9zo887wE= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.20 h1:CNXO7mvgThFGqOFgbNAP2nol2qAWBOGfqR/7tQlvLmc= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.20/go.mod h1:oydPDJKcfMhgfcgBUZaG+toBbwy8yPWubJXBVERtI4o= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.20 h1:tN6W/hg+pkM+tf9XDkWUbDEjGLb+raoBMFsTodcoYKw= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.20/go.mod h1:YJ898MhD067hSHA6xYCx5ts/jEd8BSOLtQDL3iZsvbc= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 h1:Rgg6wvjjtX8bNHcvi9OnXWwcE0a2vGpbwmtICOsvcf4= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21/go.mod h1:A/kJFst/nm//cyqonihbdpQZwiUhhzpqTsdbhDdRF9c= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 h1:PEgGVtPoB6NTpPrBgqSE5hE/o47Ij9qk/SEZFbUOe9A= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21/go.mod h1:p+hz+PRAYlY3zcpJhPwXlLC4C+kqn70WIHwnzAfs6ps= github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6 h1:qYQ4pzQ2Oz6WpQ8T3HvGHnZydA72MnLuFK9tJwmrbHw= github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6/go.mod h1:O3h0IK87yXci+kg6flUKzJnWeziQUKciKrLjcatSNcY= -github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.21 h1:SwGMTMLIlvDNyhMteQ6r8IJSBPlRdXX5d4idhIGbkXA= -github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.21/go.mod h1:UUxgWxofmOdAMuqEsSppbDtGKLfR04HGsD0HXzvhI1k= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.22 h1:rWyie/PxDRIdhNf4DzRk0lvjVOqFJuNnO8WwaIRVxzQ= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.22/go.mod h1:zd/JsJ4P7oGfUhXn1VyLqaRZwPmZwg44Jf2dS84Dm3Y= github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 h1:5EniKhLZe4xzL7a+fU3C2tfUN4nWIqlLesfrjkuPFTY= github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7/go.mod h1:x0nZssQ3qZSnIcePWLvcoFisRXJzcTVvYpAAdYX8+GI= -github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.12 h1:qtJZ70afD3ISKWnoX3xB0J2otEqu3LqicRcDBqsj0hQ= -github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.12/go.mod h1:v2pNpJbRNl4vEUWEh5ytQok0zACAKfdmKS51Hotc3pQ= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.20 h1:2HvVAIq+YqgGotK6EkMf+KIEqTISmTYh5zLpYyeTo1Y= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.20/go.mod h1:V4X406Y666khGa8ghKmphma/7C0DAtEQYhkq9z4vpbk= -github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.20 h1:siU1A6xjUZ2N8zjTHSXFhB9L/2OY8Dqs0xXiLjF30jA= -github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.20/go.mod h1:4TLZCmVJDM3FOu5P5TJP0zOlu9zWgDWU7aUxWbr+rcw= -github.com/aws/aws-sdk-go-v2/service/s3 v1.97.1 h1:csi9NLpFZXb9fxY7rS1xVzgPRGMt7MSNWeQ6eo247kE= -github.com/aws/aws-sdk-go-v2/service/s3 v1.97.1/go.mod h1:qXVal5H0ChqXP63t6jze5LmFalc7+ZE7wOdLtZ0LCP0= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.13 h1:JRaIgADQS/U6uXDqlPiefP32yXTda7Kqfx+LgspooZM= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.13/go.mod h1:CEuVn5WqOMilYl+tbccq8+N2ieCy0gVn3OtRb0vBNNM= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21 h1:c31//R3xgIJMSC8S6hEVq+38DcvUlgFY0FM6mSI5oto= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21/go.mod h1:r6+pf23ouCB718FUxaqzZdbpYFyDtehyZcmP5KL9FkA= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.21 h1:ZlvrNcHSFFWURB8avufQq9gFsheUgjVD9536obIknfM= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.21/go.mod h1:cv3TNhVrssKR0O/xxLJVRfd2oazSnZnkUeTf6ctUwfQ= +github.com/aws/aws-sdk-go-v2/service/s3 v1.97.3 h1:HwxWTbTrIHm5qY+CAEur0s/figc3qwvLWsNkF4RPToo= +github.com/aws/aws-sdk-go-v2/service/s3 v1.97.3/go.mod h1:uoA43SdFwacedBfSgfFSjjCvYe8aYBS7EnU5GZ/YKMM= github.com/aws/aws-sdk-go-v2/service/signin v1.0.8 h1:0GFOLzEbOyZABS3PhYfBIx2rNBACYcKty+XGkTgw1ow= github.com/aws/aws-sdk-go-v2/service/signin v1.0.8/go.mod h1:LXypKvk85AROkKhOG6/YEcHFPoX+prKTowKnVdcaIxE= github.com/aws/aws-sdk-go-v2/service/sso v1.30.13 h1:kiIDLZ005EcKomYYITtfsjn7dtOwHDOFy7IbPXKek2o= @@ -70,16 +72,24 @@ github.com/aws/smithy-go v1.24.2 h1:FzA3bu/nt/vDvmnkg+R8Xl46gmzEDam6mZ1hzmwXFng= github.com/aws/smithy-go v1.24.2/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= github.com/bgentry/go-netrc v0.0.0-20140422174119-9fd32a8b3d3d h1:xDfNPAt8lFiC1UJrqV3uuy861HCTo708pDMbjHHdCas= github.com/bgentry/go-netrc v0.0.0-20140422174119-9fd32a8b3d3d/go.mod h1:6QX/PXZ00z/TKoufEY6K/a0k6AhaJrQKdFe6OfVXsa4= +github.com/bodgit/plumbing v1.3.0 h1:pf9Itz1JOQgn7vEOE7v7nlEfBykYqvUYioC61TwWCFU= +github.com/bodgit/plumbing v1.3.0/go.mod h1:JOTb4XiRu5xfnmdnDJo6GmSbSbtSyufrsyZFByMtKEs= +github.com/bodgit/sevenzip v1.6.5 h1:7H7BxgmeX0j6UX42lH+KXQ92WgMQJ49DoocFdfHbCng= +github.com/bodgit/sevenzip v1.6.5/go.mod h1:GhuB6Lq1xCpP1sps+horjZ8lgiKPJcy2zUX3prla9wc= +github.com/bodgit/windows v1.0.1 h1:tF7K6KOluPYygXa3Z2594zxlkbKPAOvqr97etrGNIz4= +github.com/bodgit/windows v1.0.1/go.mod h1:a6JLwrB4KrTR5hBpp8FI9/9W9jJfeQ2h4XDXU74ZCdM= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5 h1:6xNmx7iTtyBRev0+D/Tv1FZd4SCg8axKApyNyRsAt/w= -github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5/go.mod h1:KdCmV+x/BuvyMxRnYBlmVaq4OLiKW6iRQfvC62cvdkI= +github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 h1:aBangftG7EVZoUb69Os8IaYg++6uMOdKK83QtkkvJik= +github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2/go.mod h1:qwXFYgsP6T7XnJtbKlf1HP8AjxZZyzxMmc+Lq5GjlU4= github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= github.com/cpuguy83/go-md2man/v2 v2.0.7 h1:zbFlGlXEAKlwXpmvle3d8Oe3YnkKIK4xSRTd3sHPnBo= github.com/cpuguy83/go-md2man/v2 v2.0.7/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dgraph-io/ristretto/v2 v2.4.0 h1:I/w09yLjhdcVD2QV192UJcq8dPBaAJb9pOuMyNy0XlU= @@ -92,22 +102,25 @@ github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pM github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE= 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/dsnet/compress v0.0.1 h1:PlZu0n3Tuv04TzpfPbrnI0HW/YwodEXDS+oPKahKF0Q= +github.com/dsnet/compress v0.0.1/go.mod h1:Aw8dCMJ7RioblQeTqt88akK31OvO8Dhf5JflhBbQEHo= +github.com/dsnet/golib v0.0.0-20171103203638-1ea166775780/go.mod h1:Lj+Z9rebOhdfkVLjJ8T6VcRQv3SXugXy999NBtR9aFY= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= github.com/envoyproxy/go-control-plane v0.14.0 h1:hbG2kr4RuFj222B6+7T83thSPqLjwBIfQawTkC++2HA= github.com/envoyproxy/go-control-plane v0.14.0/go.mod h1:NcS5X47pLl/hfqxU70yPwL9ZMkUlwlKxtAohpi2wBEU= -github.com/envoyproxy/go-control-plane/envoy v1.36.0 h1:yg/JjO5E7ubRyKX3m07GF3reDNEnfOboJ0QySbH736g= -github.com/envoyproxy/go-control-plane/envoy v1.36.0/go.mod h1:ty89S1YCCVruQAm9OtKeEkQLTb+Lkz0k8v9W0Oxsv98= +github.com/envoyproxy/go-control-plane/envoy v1.37.0 h1:u3riX6BoYRfF4Dr7dwSOroNfdSbEPe9Yyl09/B6wBrQ= +github.com/envoyproxy/go-control-plane/envoy v1.37.0/go.mod h1:DReE9MMrmecPy+YvQOAOHNYMALuowAnbjjEMkkWOi6A= github.com/envoyproxy/go-control-plane/ratelimit v0.1.0 h1:/G9QYbddjL25KvtKTv3an9lx6VBE2cnb8wp1vEGNYGI= github.com/envoyproxy/go-control-plane/ratelimit v0.1.0/go.mod h1:Wk+tMFAFbCXaJPzVVHnPgRKdUdwW/KdbRt94AzgRee4= -github.com/envoyproxy/protoc-gen-validate v1.3.0 h1:TvGH1wof4H33rezVKWSpqKz5NXWg5VPuZ0uONDT6eb4= -github.com/envoyproxy/protoc-gen-validate v1.3.0/go.mod h1:HvYl7zwPa5mffgyeTUHA9zHIH36nmrm7oCbo4YKoSWA= +github.com/envoyproxy/protoc-gen-validate v1.3.3 h1:MVQghNeW+LZcmXe7SY1V36Z+WFMDjpqGAGacLe2T0ds= +github.com/envoyproxy/protoc-gen-validate v1.3.3/go.mod h1:TsndJ/ngyIdQRhMcVVGDDHINPLWB7C82oDArY51KfB0= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= -github.com/gameap/gameap v0.0.0-20260514194917-d291e6dbd609 h1:koemMHCkyPGbl9ExIwYdbOsSHOQvU0NmuIsefSAjHcE= -github.com/gameap/gameap v0.0.0-20260514194917-d291e6dbd609/go.mod h1:9WvVGHRva598VzZuI51HVV4U454dii7O6M9/FoiaJRU= +github.com/gameap/gameap v0.0.0-20260729224522-5d77d14698ba h1:k0YCLSokN2HoPl0oykGuCpr0NHfgZ1Z9fCha3cLbafA= +github.com/gameap/gameap v0.0.0-20260729224522-5d77d14698ba/go.mod h1:rjxnoVnZz9li4bqIuZMYVgxOEAa7vBPOatslu1FMoW4= github.com/gameap/gameapctl v0.25.0 h1:ECxwkcEDY7XFo1K06O5YVgTAbZhpMEWOtoGf5T9dbKM= github.com/gameap/gameapctl v0.25.0/go.mod h1:vljtrlaxhRzKhvIxg/W9/mfOzGXaXXeoDPaXrH7E8ts= github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA= @@ -146,8 +159,12 @@ github.com/hashicorp/go-getter v1.8.6 h1:9sQboWULaydVphxc4S64oAI4YqpuCk7nPmvbk13 github.com/hashicorp/go-getter v1.8.6/go.mod h1:nVH12eOV2P58dIiL3rsU6Fh3wLeJEKBOJzhMmzlSWoo= github.com/hashicorp/go-version v1.8.0 h1:KAkNb1HAiZd1ukkxDFGmokVZe1Xy9HG6NUp+bPle2i4= github.com/hashicorp/go-version v1.8.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= -github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE= -github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/klauspost/compress v1.4.1/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0guNDohfE1A= +github.com/klauspost/compress v1.19.1 h1:VsB4HPswih7mmZ8WleSFQ75c/Ui1M4trX5oAsJnhSlk= +github.com/klauspost/compress v1.19.1/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= +github.com/klauspost/cpuid v1.2.0/go.mod h1:Pj4uuM528wm8OyEC2QMXAi2YiTZ96dNQPGgoMS4s3ek= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= @@ -162,14 +179,19 @@ github.com/moby/moby/api v1.54.1 h1:TqVzuJkOLsgLDDwNLmYqACUuTehOHRGKiPhvH8V3Nn4= github.com/moby/moby/api v1.54.1/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs= github.com/moby/moby/client v0.4.0 h1:S+2XegzHQrrvTCvF6s5HFzcrywWQmuVnhOXe2kiWjIw= github.com/moby/moby/client v0.4.0/go.mod h1:QWPbvWchQbxBNdaLSpoKpCdf5E+WxFAgNHogCWDoa7g= +github.com/nwaples/rardecode/v2 v2.3.0 h1:CtgyxWm8ClLcSh1u4M58fOz6lmeb/j4V7KpaEi/6UtM= +github.com/nwaples/rardecode/v2 v2.3.0/go.mod h1:7uz379lSxPe6j9nvzxUZ+n7mnJNgjsRNb6IbvGVHRmw= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= +github.com/pierrec/lz4/v4 v4.1.27 h1:+PhzhWDrjRj89TH2sw43nE3+4+W8lSxIuQadEHZyjUk= +github.com/pierrec/lz4/v4 v4.1.27/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo= github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw= @@ -188,60 +210,77 @@ github.com/shoenig/test v0.6.4 h1:kVTaSd7WLz5WZ2IaoM0RSzRsUD+m8wRR+5qvntpn4LU= github.com/shoenig/test v0.6.4/go.mod h1:byHiCGXqrVaflBLAMq/srcZIHynQPQgeyvkvXnjqq0k= github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= +github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= +github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= github.com/spiffe/go-spiffe/v2 v2.6.0 h1:l+DolpxNWYgruGQVV0xsfeya3CsC7m8iBzDnMpsbLuo= github.com/spiffe/go-spiffe/v2 v2.6.0/go.mod h1:gm2SeUoMZEtpnzPNs2Csc0D/gX33k1xIx7lEzqblHEs= +github.com/stangelandcl/ppmd v0.1.1 h1:c25QazhlWUn5nmR1QOzafKhQxBicAr7GGCKER2aJ8H8= +github.com/stangelandcl/ppmd v0.1.1/go.mod h1:Rrv7M+/2P5jYr/GMLhBl7Ug3uJ1bUiVzr5LbbaV6xgY= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU= github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI= github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk= github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY= -github.com/ulikunitz/xz v0.5.15 h1:9DNdB5s+SgV3bQ2ApL10xRc35ck0DuIX/isZvIk+ubY= -github.com/ulikunitz/xz v0.5.15/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= +github.com/ulikunitz/xz v0.5.6/go.mod h1:2bypXElzHzzJZwzH67Y6wb67pO62Rzfn7BSiF4ABRW8= +github.com/ulikunitz/xz v0.5.16 h1:ld6NyySjx5lowVKwJvMRLnW5nxKX/xnpSiFYZ/Lxur0= +github.com/ulikunitz/xz v0.5.16/go.mod h1:H9Rt/W6/Qj27PGauhQc6nfCDy7vHpzsOThBSaYDoEhw= github.com/urfave/cli/v2 v2.27.7 h1:bH59vdhbjLv3LAvIu6gd0usJHgoTTPhCFib8qqOwXYU= github.com/urfave/cli/v2 v2.27.7/go.mod h1:CyNAG/xg+iAOg0N4MPGZqVmv2rCoP267496AOXUZjA4= github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 h1:gEOO8jv9F4OT7lGCjxCBTO/36wtF6j2nSip77qHd4x4= github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM= +github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= +github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= -go.opentelemetry.io/contrib/detectors/gcp v1.39.0 h1:kWRNZMsfBHZ+uHjiH4y7Etn2FK26LAGkNFw7RHv1DhE= -go.opentelemetry.io/contrib/detectors/gcp v1.39.0/go.mod h1:t/OGqzHBa5v6RHZwrDBJ2OirWc+4q/w2fTbLZwAKjTk= +go.opentelemetry.io/contrib/detectors/gcp v1.43.0 h1:62yY3dT7/ShwOxzA0RsKRgshBmfElKI4d/Myu2OxDFU= +go.opentelemetry.io/contrib/detectors/gcp v1.43.0/go.mod h1:RyaZMFY7yi1kAs45S6mbFGz8O8rqB0dTY14uzvG4LCs= go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.63.0 h1:YH4g8lQroajqUwWbq/tr2QX1JFmEXaDLgG+ew9bLMWo= go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.63.0/go.mod h1:fvPi2qXDqFs8M4B4fmJhE92TyQs9Ydjlg3RvfUp+NbQ= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q= -go.opentelemetry.io/otel v1.42.0 h1:lSQGzTgVR3+sgJDAU/7/ZMjN9Z+vUip7leaqBKy4sho= -go.opentelemetry.io/otel v1.42.0/go.mod h1:lJNsdRMxCUIWuMlVJWzecSMuNjE7dOYyWlqOXWkdqCc= +go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= +go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0= go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.40.0 h1:ZrPRak/kS4xI3AVXy8F7pipuDXmDsrO8Lg+yQjBLjw0= go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.40.0/go.mod h1:3y6kQCWztq6hyW8Z9YxQDDm0Je9AJoFar2G0yDcmhRk= -go.opentelemetry.io/otel/metric v1.42.0 h1:2jXG+3oZLNXEPfNmnpxKDeZsFI5o4J+nz6xUlaFdF/4= -go.opentelemetry.io/otel/metric v1.42.0/go.mod h1:RlUN/7vTU7Ao/diDkEpQpnz3/92J9ko05BIwxYa2SSI= -go.opentelemetry.io/otel/sdk v1.42.0 h1:LyC8+jqk6UJwdrI/8VydAq/hvkFKNHZVIWuslJXYsDo= -go.opentelemetry.io/otel/sdk v1.42.0/go.mod h1:rGHCAxd9DAph0joO4W6OPwxjNTYWghRWmkHuGbayMts= -go.opentelemetry.io/otel/sdk/metric v1.42.0 h1:D/1QR46Clz6ajyZ3G8SgNlTJKBdGp84q9RKCAZ3YGuA= -go.opentelemetry.io/otel/sdk/metric v1.42.0/go.mod h1:Ua6AAlDKdZ7tdvaQKfSmnFTdHx37+J4ba8MwVCYM5hc= -go.opentelemetry.io/otel/trace v1.42.0 h1:OUCgIPt+mzOnaUTpOQcBiM/PLQ/Op7oq6g4LenLmOYY= -go.opentelemetry.io/otel/trace v1.42.0/go.mod h1:f3K9S+IFqnumBkKhRJMeaZeNk9epyhnCmQh/EysQCdc= +go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM= +go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY= +go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg= +go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg= +go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw= +go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A= +go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A= +go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= -golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= -golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= -golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= -golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= +go4.org v0.0.0-20260112195520-a5071408f32f h1:ziUVAjmTPwQMBmYR1tbdRFJPtTcQUI12fH9QQjfb0Sw= +go4.org v0.0.0-20260112195520-a5071408f32f/go.mod h1:ZRJnO5ZI4zAwMFp+dS1+V6J6MSyAowhRqAE+DPa1Xp0= +golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= +golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= +golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= +golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= -golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= -golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= -golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= -golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= +golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= +golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= +golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= @@ -251,17 +290,18 @@ google.golang.org/api v0.271.0 h1:cIPN4qcUc61jlh7oXu6pwOQqbJW2GqYh5PS6rB2C/JY= google.golang.org/api v0.271.0/go.mod h1:CGT29bhwkbF+i11qkRUJb2KMKqcJ1hdFceEIRd9u64Q= google.golang.org/genproto v0.0.0-20260128011058-8636f8732409 h1:VQZ/yAbAtjkHgH80teYd2em3xtIkkHd7ZhqfH2N9CsM= google.golang.org/genproto v0.0.0-20260128011058-8636f8732409/go.mod h1:rxKD3IEILWEu3P44seeNOAwZN4SaoKaQ/2eTg4mM6EM= -google.golang.org/genproto/googleapis/api v0.0.0-20260203192932-546029d2fa20 h1:7ei4lp52gK1uSejlA8AZl5AJjeLUOHBQscRQZUgAcu0= -google.golang.org/genproto/googleapis/api v0.0.0-20260203192932-546029d2fa20/go.mod h1:ZdbssH/1SOVnjnDlXzxDHK2MCidiqXtbYccJNzNYPEE= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 h1:m8qni9SQFH0tJc1X0vmnpw/0t+AImlSvp30sEupozUg= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= -google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM= -google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4= +google.golang.org/genproto/googleapis/api v0.0.0-20260414002931-afd174a4e478 h1:yQugLulqltosq0B/f8l4w9VryjV+N/5gcW0jQ3N8Qec= +google.golang.org/genproto/googleapis/api v0.0.0-20260414002931-afd174a4e478/go.mod h1:C6ADNqOxbgdUUeRTU+LCHDPB9ttAMCTff6auwCVa4uc= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260420184626-e10c466a9529 h1:XF8+t6QQiS0o9ArVan/HW8Q7cycNPGsJf6GA2nXxYAg= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260420184626-e10c466a9529/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.82.1 h1:NnAxzGRA0677vCa4BUkOAnO5+FfQqVl9iUXeD0IqcGE= +google.golang.org/grpc v1.82.1/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= diff --git a/internal/app/archive/archiver.go b/internal/app/archive/archiver.go new file mode 100644 index 0000000..326fa1d --- /dev/null +++ b/internal/app/archive/archiver.go @@ -0,0 +1,130 @@ +// Package archive creates and extracts archives (zip, tar family, single-file +// compression, 7z, rar) on behalf of API requests. Every caller-supplied path +// is resolved with fsutil.RootRel and accessed through an *os.Root opened at +// the work directory, so no operation can escape the work directory — os.Root +// refuses symlink and ".." resolution outside of it. Extracted archive entries +// are additionally rejected lexically when their path is absolute or starts +// with ".." (zip-slip), because the API contract requires the friendly error. +// +// Extraction feeds attacker-controlled bytes to third-party format decoders, +// so the caller is expected to run it under a recover (see the gRPC archive +// handler). One residual risk cannot be handled here: bodgit/sevenzip sizes +// several slices directly from 7z header counts and validates them only +// against MaxUint32, so a crafted header can request an allocation large +// enough for the Go runtime to abort the process — a throw that no recover +// can intercept. It exposes no option to bound this; the entry-count check +// below runs only after its reader is already constructed. +package archive + +import ( + "math" + + "github.com/pkg/errors" + + "github.com/gameap/daemon/internal/app/osowner" + pb "github.com/gameap/gameap/pkg/proto" +) + +const ( + // defaultMaxTotalBytes caps the uncompressed payload of one operation when + // the request does not set a limit (decompression-bomb protection). + defaultMaxTotalBytes uint64 = 10 << 30 // 10 GiB + // defaultMaxFiles caps the number of entries of one operation when the + // request does not set a limit. + defaultMaxFiles uint32 = 100_000 + + // maxFollowDepth bounds symlink-following recursion during create. It is a + // backstop only: os.Root refuses to resolve a path crossing more than + // rootMaxSymlinks (8) links, so in practice that limit fires first. + maxFollowDepth = 40 +) + +// ErrArchiveEncrypted reports an archive the daemon cannot open because it is +// password protected. The API distinguishes this from a corrupt archive to +// prompt the user for a password. +var ErrArchiveEncrypted = errors.New("archive is encrypted, password required") + +// Result summarizes one Create or Extract call. +type Result struct { + FilesProcessed int64 + BytesProcessed int64 // uncompressed bytes + ArchiveSize int64 // produced archive when creating, source archive when extracting + Skipped []string + // Format the operation actually used, which differs from the requested one + // when the request left it unspecified and the daemon resolved it. + Format pb.ArchiveFormat +} + +// ProgressFunc is invoked after each processed entry (it may be nil). +// Totals are intentionally not reported: the proto allows 0 = unknown. +type ProgressFunc func(filesProcessed, bytesProcessed int64, currentEntry string) + +// accumulator tracks running counters, enforces the limits and reports +// progress for both create and extract. +type accumulator struct { + files int64 + bytes int64 + maxBytes uint64 + maxFiles uint64 + progress ProgressFunc +} + +func newAccumulator(maxBytes uint64, maxFiles uint32, progress ProgressFunc) *accumulator { + if maxBytes == 0 { + maxBytes = defaultMaxTotalBytes + } + // maxBytes arrives as uint64 while the counters run on int64; clamp so + // the conversions in bytesLeft and addEntry cannot wrap negative. + if maxBytes > math.MaxInt64 { + maxBytes = math.MaxInt64 + } + if maxFiles == 0 { + maxFiles = defaultMaxFiles + } + + return &accumulator{maxBytes: maxBytes, maxFiles: uint64(maxFiles), progress: progress} +} + +// bytesLeft is used to cap streaming copies one byte past the limit so an +// oversized payload is detected instead of silently truncated. +func (a *accumulator) bytesLeft() int64 { + left := int64(a.maxBytes) - a.bytes + if left < 0 { + return 0 + } + + return left +} + +// checkEntryCount rejects an archive whose entry count is known up front and +// already exceeds the limit, so nothing is written before the operation fails. +func (a *accumulator) checkEntryCount(n int) error { + if uint64(n) > a.maxFiles { + return errors.Errorf("max files limit exceeded (%d)", a.maxFiles) + } + + return nil +} + +// addEntry records one processed entry with n uncompressed content bytes. +func (a *accumulator) addEntry(name string, n int64) error { + a.files++ + if uint64(a.files) > a.maxFiles { + return errors.Errorf("max files limit exceeded (%d)", a.maxFiles) + } + + a.bytes += n + if a.bytes > int64(a.maxBytes) { + return errors.Errorf("max total bytes limit exceeded (%d)", a.maxBytes) + } + + if a.progress != nil { + a.progress(a.files, a.bytes, name) + } + + return nil +} + +func ownerOptions(user string, uid, gid int32) osowner.Options { + return osowner.Options{User: user, UID: uid, GID: gid} +} diff --git a/internal/app/archive/create.go b/internal/app/archive/create.go new file mode 100644 index 0000000..d9437f8 --- /dev/null +++ b/internal/app/archive/create.go @@ -0,0 +1,329 @@ +package archive + +import ( + "context" + "io" + "io/fs" + "os" + "path" + + "github.com/pkg/errors" + + "github.com/gameap/daemon/internal/app/fsutil" + "github.com/gameap/daemon/internal/app/osowner" + pb "github.com/gameap/gameap/pkg/proto" +) + +// sourceEntry is one item scheduled for archiving: a regular file, a +// directory or a symlink stored as-is. +type sourceEntry struct { + rel string // path inside the root + name string // entry name inside the archive, relative to base_path + info os.FileInfo + link string // symlink target, set when the symlink is stored as a symlink +} + +func (e sourceEntry) isSymlink() bool { + return e.info.Mode()&os.ModeSymlink != 0 +} + +// Create packs the requested sources into archive_path. See the package doc +// for the confinement model. +func Create(ctx context.Context, workDir string, p *pb.CreateArchiveParams, progress ProgressFunc) (*Result, error) { + if err := ctx.Err(); err != nil { + return nil, errors.Wrap(err, "create archive canceled") + } + + if len(p.GetSources()) == 0 { + return nil, errors.New("no sources given") + } + + root, err := os.OpenRoot(workDir) + if err != nil { + return nil, errors.Wrap(err, "work directory unavailable") + } + defer root.Close() + + archiveRel, err := fsutil.RootRel(p.GetArchivePath()) + if err != nil { + return nil, err + } + + // The proto resolves an unset create format from the target file extension. + format := p.GetFormat() + if format == pb.ArchiveFormat_ARCHIVE_FORMAT_UNSPECIFIED { + if format = formatFromExtension(archiveRel); format == pb.ArchiveFormat_ARCHIVE_FORMAT_UNSPECIFIED { + return nil, errors.Errorf( + "archive format is unspecified and %q has no known extension", p.GetArchivePath(), + ) + } + } + + class, err := classifyForCreate(format) + if err != nil { + return nil, err + } + + baseRel, err := fsutil.RootRel(p.GetBasePath()) + if err != nil { + return nil, err + } + + if parent := path.Dir(archiveRel); parent != "." && parent != "/" { + if err := root.MkdirAll(parent, 0o755); err != nil { + return nil, errors.Wrapf(err, "failed to create directory %q", parent) + } + } + + flags := os.O_WRONLY | os.O_CREATE | os.O_EXCL + if p.GetOverwrite() { + flags = os.O_WRONLY | os.O_CREATE | os.O_TRUNC + } + + archiveFile, err := root.OpenFile(archiveRel, flags, 0o644) + if err != nil { + if errors.Is(err, os.ErrExist) { + return nil, errors.Errorf("archive %q already exists and overwrite is disabled", p.GetArchivePath()) + } + + return nil, errors.Wrap(err, "failed to create archive file") + } + + acc := newAccumulator(p.GetMaxTotalBytes(), p.GetMaxFiles(), progress) + + createErr := createInto(ctx, root, archiveFile, baseRel, p, format, class, acc) + + closeErr := archiveFile.Close() + if createErr != nil { + // A failed operation must not leave a partial archive behind. + _ = root.Remove(archiveRel) + + return nil, createErr + } + if closeErr != nil { + _ = root.Remove(archiveRel) + + return nil, errors.Wrap(closeErr, "failed to close archive file") + } + + // Everything past this point still counts as a failed operation, so the + // archive is removed rather than left behind with the wrong mode or owner. + if p.GetMode() != 0 { + if err := root.Chmod(archiveRel, os.FileMode(p.GetMode()).Perm()); err != nil { + _ = root.Remove(archiveRel) + + return nil, errors.Wrap(err, "failed to chmod archive file") + } + } + + owner := ownerOptions(p.GetOwnerUser(), p.GetOwnerUid(), p.GetOwnerGid()) + if err := osowner.ApplyToPathInRoot(root, archiveRel, owner); err != nil { + _ = root.Remove(archiveRel) + + return nil, errors.Wrap(err, "failed to apply archive owner") + } + + info, err := root.Stat(archiveRel) + if err != nil { + _ = root.Remove(archiveRel) + + return nil, errors.Wrap(err, "failed to stat archive file") + } + + return &Result{ + FilesProcessed: acc.files, + BytesProcessed: acc.bytes, + ArchiveSize: info.Size(), + Format: format, + }, nil +} + +func createInto( + ctx context.Context, + root *os.Root, + archiveFile *os.File, + baseRel string, + p *pb.CreateArchiveParams, + format pb.ArchiveFormat, + class formatClass, + acc *accumulator, +) error { + archiveInfo, err := archiveFile.Stat() + if err != nil { + return errors.Wrap(err, "failed to stat archive file") + } + + entries, err := collectSources(ctx, root, baseRel, p.GetSources(), &walkLimits{ + follow: p.GetFollowSymlinks(), + maxEntries: acc.maxFiles, + archive: archiveInfo, + }) + if err != nil { + return err + } + + if class == classSingle { + return createSingle(root, archiveFile, entries, p, format, acc) + } + + if len(entries) == 0 { + return errors.New("nothing to archive: sources contain no files") + } + + if class == classZip { + return createZip(ctx, root, archiveFile, entries, p, acc) + } + + return createTar(ctx, root, archiveFile, entries, p, tarCompression(format), acc) +} + +// walkLimits bounds one source expansion. +type walkLimits struct { + follow bool + // maxEntries stops the expansion itself, not just the write phase. The + // whole entry list is materialized before a single byte is archived, and + // with follow_symlinks a handful of links fans out into an enormous number + // of distinct paths, so the limit has to apply here too. + maxEntries uint64 + // archive identifies the file being written, so it is never archived into + // itself. Comparing identity rather than the path name also covers reaching + // it through a symlink, where the path differs. + archive os.FileInfo +} + +// sourceWalker expands the request sources (relative to base_path) into a flat +// entry list. Directories are walked recursively; symlinks are stored as +// symlinks unless follow is set, in which case the symlink target contents are +// archived (os.Root still refuses targets outside the work directory). +type sourceWalker struct { + ctx context.Context + root *os.Root + limits *walkLimits + entries []sourceEntry +} + +// collectSources expands every source into one flat, bounded entry list. +func collectSources( + ctx context.Context, root *os.Root, baseRel string, sources []string, limits *walkLimits, +) ([]sourceEntry, error) { + w := &sourceWalker{ctx: ctx, root: root, limits: limits} + + for _, src := range sources { + srcRel, err := fsutil.RootRel(src) + if err != nil { + return nil, errors.Wrapf(err, "invalid source %q", src) + } + + rel := srcRel + if baseRel != "." { + rel = path.Join(baseRel, srcRel) + } + + if err := w.walk(rel, srcRel, 0); err != nil { + return nil, err + } + } + + return w.entries, nil +} + +func (w *sourceWalker) add(e sourceEntry) error { + if uint64(len(w.entries)) >= w.limits.maxEntries { + return errors.Errorf("max files limit exceeded (%d)", w.limits.maxEntries) + } + + w.entries = append(w.entries, e) + + return nil +} + +// copySource streams one source file into an archive entry writer, capping +// the read one byte past the remaining byte budget so an oversized payload is +// reported by the accumulator instead of silently truncated. +func copySource(root *os.Root, rel string, w io.Writer, bytesLeft int64) (int64, error) { + src, err := root.Open(rel) + if err != nil { + return 0, errors.Wrapf(err, "failed to open source %q", rel) + } + defer src.Close() + + n, err := io.Copy(w, io.LimitReader(src, bytesLeft+1)) + if err != nil { + return 0, errors.Wrapf(err, "failed to write %q", rel) + } + + return n, nil +} + +func (w *sourceWalker) walk(rel, name string, symlinkDepth int) error { + if err := w.ctx.Err(); err != nil { + return errors.Wrap(err, "create archive canceled") + } + + info, err := w.root.Lstat(rel) + if err != nil { + return errors.Wrapf(err, "failed to stat source %q", rel) + } + + if os.SameFile(info, w.limits.archive) { + return nil + } + + if info.Mode()&os.ModeSymlink != 0 && w.limits.follow { + symlinkDepth++ + if symlinkDepth > maxFollowDepth { + return errors.Errorf("symlink nesting too deep at %q", rel) + } + + info, err = w.root.Stat(rel) + if err != nil { + return errors.Wrapf(err, "failed to resolve symlink %q", rel) + } + } + + switch { + case info.IsDir(): + return w.walkDir(rel, name, info, symlinkDepth) + case info.Mode()&os.ModeSymlink != 0: + link, err := w.root.Readlink(rel) + if err != nil { + return errors.Wrapf(err, "failed to read symlink %q", rel) + } + + return w.add(sourceEntry{rel: rel, name: name, info: info, link: link}) + case info.Mode().IsRegular(): + return w.add(sourceEntry{rel: rel, name: name, info: info}) + default: + // Sockets, fifos and device nodes cannot be archived; game-server work + // directories legitimately contain unix sockets. Matches fsutil.Copy. + return nil + } +} + +func (w *sourceWalker) walkDir(rel, name string, info os.FileInfo, symlinkDepth int) error { + // The "." source contributes its children only; storing "." itself would + // produce a useless root entry. + if name != "." { + if err := w.add(sourceEntry{rel: rel, name: name, info: info}); err != nil { + return err + } + } + + dirEntries, err := fs.ReadDir(w.root.FS(), rel) + if err != nil { + return errors.Wrapf(err, "failed to read directory %q", rel) + } + + for _, child := range dirEntries { + childName := child.Name() + if name != "." { + childName = path.Join(name, child.Name()) + } + + if err := w.walk(path.Join(rel, child.Name()), childName, symlinkDepth); err != nil { + return err + } + } + + return nil +} diff --git a/internal/app/archive/create_single.go b/internal/app/archive/create_single.go new file mode 100644 index 0000000..61ac557 --- /dev/null +++ b/internal/app/archive/create_single.go @@ -0,0 +1,55 @@ +package archive + +import ( + "io" + "os" + + "github.com/pkg/errors" + + pb "github.com/gameap/gameap/pkg/proto" +) + +// createSingle writes the gz/bz2/xz/zstd single-file formats: the archive is +// the compressed stream of exactly one regular file. +func createSingle( + root *os.Root, + archiveFile io.Writer, + entries []sourceEntry, + p *pb.CreateArchiveParams, + format pb.ArchiveFormat, + acc *accumulator, +) error { + if len(entries) != 1 || !entries[0].info.Mode().IsRegular() { + return errors.Errorf( + "archive format %s requires exactly one regular file source", format, + ) + } + + e := entries[0] + + stream, closer, err := compressWriter(archiveFile, singleCompression(format), p.CompressionLevel) + if err != nil { + return err + } + + closed := false + defer func() { + if closer != nil && !closed { + _ = closer.Close() + } + }() + + n, err := copySource(root, e.rel, stream, acc.bytesLeft()) + if err != nil { + return err + } + + if closer != nil { + closed = true + if err := closer.Close(); err != nil { + return errors.Wrap(err, "failed to finish compressor stream") + } + } + + return acc.addEntry(e.name, n) +} diff --git a/internal/app/archive/create_tar.go b/internal/app/archive/create_tar.go new file mode 100644 index 0000000..889b6bd --- /dev/null +++ b/internal/app/archive/create_tar.go @@ -0,0 +1,166 @@ +package archive + +import ( + "archive/tar" + "compress/bzip2" + "compress/gzip" + "context" + "io" + "os" + "strings" + + "github.com/pkg/errors" + + dsbzip2 "github.com/dsnet/compress/bzip2" + pb "github.com/gameap/gameap/pkg/proto" + "github.com/klauspost/compress/zstd" + "github.com/ulikunitz/xz" +) + +// compressWriter wraps w with the requested stream compressor. The returned +// closer must be closed before w itself is closed; it closes only the +// compressor stream. +func compressWriter(w io.Writer, comp compression, level *int32) (io.Writer, io.Closer, error) { + switch comp { + case compGzip: + gw, err := gzip.NewWriterLevel(w, gzipLevel(level)) + if err != nil { + return nil, nil, errors.Wrap(err, "failed to init gzip writer") + } + + return gw, gw, nil + case compBzip2: + bw, err := dsbzip2.NewWriter(w, &dsbzip2.WriterConfig{Level: bzip2Level(level)}) + if err != nil { + return nil, nil, errors.Wrap(err, "failed to init bzip2 writer") + } + + return bw, bw, nil + case compXz: + // The xz format has no compression levels (only filter presets), so a + // requested level is silently ignored here. + xw, err := xz.NewWriter(w) + if err != nil { + return nil, nil, errors.Wrap(err, "failed to init xz writer") + } + + return xw, xw, nil + case compZstd: + zw, err := zstd.NewWriter(w, zstd.WithEncoderLevel(zstdLevel(level))) + if err != nil { + return nil, nil, errors.Wrap(err, "failed to init zstd writer") + } + + return zw, zw, nil + default: + return w, nil, nil + } +} + +// decompressReader wraps r with the matching stream decompressor. Closing +// the returned reader releases the decompressor; it never closes r itself. +func decompressReader(r io.Reader, comp compression) (io.ReadCloser, error) { + switch comp { + case compGzip: + gr, err := gzip.NewReader(r) + if err != nil { + return nil, errors.Wrap(err, "failed to init gzip reader") + } + + return gr, nil + case compBzip2: + // stdlib on the read path: it is continuously fuzzed and reports + // corruption as an error. dsnet/compress is kept for writing only — + // it is the one that exposes compression levels, but it decodes with + // panic-as-control-flow behind a recover that re-raises anything it + // does not recognize, which is not what should face untrusted input. + return io.NopCloser(bzip2.NewReader(r)), nil + case compXz: + xr, err := xz.NewReader(r) + if err != nil { + return nil, errors.Wrap(err, "failed to init xz reader") + } + + return io.NopCloser(xr), nil + case compZstd: + zr, err := zstd.NewReader(r) + if err != nil { + return nil, errors.Wrap(err, "failed to init zstd reader") + } + + return zr.IOReadCloser(), nil + default: + return io.NopCloser(r), nil + } +} + +// createTar writes entries into a tar stream, optionally through a stream +// compressor (tar.gz, tar.bz2, tar.xz, tar.zst). +func createTar( + ctx context.Context, + root *os.Root, + w io.Writer, + entries []sourceEntry, + p *pb.CreateArchiveParams, + comp compression, + acc *accumulator, +) error { + stream, closer, err := compressWriter(w, comp, p.CompressionLevel) + if err != nil { + return err + } + + closed := false + defer func() { + if closer != nil && !closed { + _ = closer.Close() + } + }() + + tw := tar.NewWriter(stream) + + for _, e := range entries { + if err := ctx.Err(); err != nil { + return errors.Wrap(err, "create archive canceled") + } + + hdr, err := tar.FileInfoHeader(e.info, e.link) + if err != nil { + return errors.Wrapf(err, "failed to build header for %q", e.name) + } + + hdr.Name = e.name + if e.info.IsDir() && !strings.HasSuffix(hdr.Name, "/") { + hdr.Name += "/" + } + + if err := tw.WriteHeader(hdr); err != nil { + return errors.Wrapf(err, "failed to write header for %q", e.name) + } + + var n int64 + if hdr.Typeflag == tar.TypeReg { + n, err = copySource(root, e.rel, tw, acc.bytesLeft()) + if err != nil { + return err + } + } + + if err := acc.addEntry(e.name, n); err != nil { + return err + } + } + + if err := tw.Close(); err != nil { + return errors.Wrap(err, "failed to finish tar archive") + } + + if closer != nil { + closed = true + if err := closer.Close(); err != nil { + return errors.Wrap(err, "failed to finish compressor stream") + } + } + + return nil +} diff --git a/internal/app/archive/create_test.go b/internal/app/archive/create_test.go new file mode 100644 index 0000000..863afb8 --- /dev/null +++ b/internal/app/archive/create_test.go @@ -0,0 +1,658 @@ +package archive + +import ( + "context" + "fmt" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + pb "github.com/gameap/gameap/pkg/proto" +) + +var roundtripFormats = []struct { + name string + format pb.ArchiveFormat + ext string +}{ + {"zip", pb.ArchiveFormat_ARCHIVE_FORMAT_ZIP, "zip"}, + {"tar", pb.ArchiveFormat_ARCHIVE_FORMAT_TAR, "tar"}, + {"tar.gz", pb.ArchiveFormat_ARCHIVE_FORMAT_TAR_GZ, "tar.gz"}, + {"tar.bz2", pb.ArchiveFormat_ARCHIVE_FORMAT_TAR_BZ2, "tar.bz2"}, + {"tar.xz", pb.ArchiveFormat_ARCHIVE_FORMAT_TAR_XZ, "tar.xz"}, + {"tar.zst", pb.ArchiveFormat_ARCHIVE_FORMAT_TAR_ZSTD, "tar.zst"}, +} + +func TestCreateExtractRoundtrip(t *testing.T) { + srcFiles := map[string]string{ + "src/a.txt": "alpha", + "src/sub/b.txt": "bravo\nsecond line", + "src/sub/c empty": "", + "src/deep/d/e.txt": "echo", + } + + for _, tc := range roundtripFormats { + t.Run(tc.name, func(t *testing.T) { + workDir := t.TempDir() + writeTree(t, workDir, srcFiles) + + progress := &progressRecord{} + createRes, err := Create(context.Background(), workDir, &pb.CreateArchiveParams{ + ArchivePath: "out." + tc.ext, + Format: tc.format, + BasePath: ".", + Sources: []string{"src"}, + }, progress.fn()) + require.NoError(t, err) + assert.Positive(t, createRes.FilesProcessed) + assert.Positive(t, createRes.BytesProcessed) + assert.Positive(t, createRes.ArchiveSize) + assert.Equal(t, createRes.FilesProcessed, progress.files, "progress must track processed entries") + assert.NotEmpty(t, progress.entries) + + extractRes, err := Extract(context.Background(), workDir, &pb.ExtractArchiveParams{ + ArchivePath: "out." + tc.ext, + Destination: "dst", + Format: tc.format, + CreateDestination: true, + }, nil) + require.NoError(t, err) + assert.Positive(t, extractRes.FilesProcessed) + assert.Empty(t, extractRes.Skipped) + + want := map[string]string{ + "src/a.txt": "alpha", + "src/sub/b.txt": "bravo\nsecond line", + "src/sub/c empty": "", + "src/deep/d/e.txt": "echo", + } + assert.Equal(t, want, readTree(t, filepath.Join(workDir, "dst"))) + }) + } +} + +func TestCreateExtractRoundtripBasePath(t *testing.T) { + workDir := t.TempDir() + writeTree(t, workDir, map[string]string{ + "base/one.txt": "1", + "base/dir/two.txt": "2", + }) + + _, err := Create(context.Background(), workDir, &pb.CreateArchiveParams{ + ArchivePath: "out.tar", + Format: pb.ArchiveFormat_ARCHIVE_FORMAT_TAR, + BasePath: "base", + Sources: []string{"."}, + }, nil) + require.NoError(t, err) + + _, err = Extract(context.Background(), workDir, &pb.ExtractArchiveParams{ + ArchivePath: "out.tar", + Destination: "dst", + Format: pb.ArchiveFormat_ARCHIVE_FORMAT_TAR, + CreateDestination: true, + }, nil) + require.NoError(t, err) + + assert.Equal(t, map[string]string{ + "one.txt": "1", + "dir/two.txt": "2", + }, readTree(t, filepath.Join(workDir, "dst"))) +} + +var singleFormats = []struct { + name string + format pb.ArchiveFormat + ext string +}{ + {"gz", pb.ArchiveFormat_ARCHIVE_FORMAT_GZ, "gz"}, + {"bz2", pb.ArchiveFormat_ARCHIVE_FORMAT_BZ2, "bz2"}, + {"xz", pb.ArchiveFormat_ARCHIVE_FORMAT_XZ, "xz"}, + {"zst", pb.ArchiveFormat_ARCHIVE_FORMAT_ZSTD, "zst"}, +} + +func TestSingleFileRoundtrip(t *testing.T) { + for _, tc := range singleFormats { + t.Run(tc.name, func(t *testing.T) { + workDir := t.TempDir() + writeTree(t, workDir, map[string]string{"data.bin": "single file payload"}) + + _, err := Create(context.Background(), workDir, &pb.CreateArchiveParams{ + ArchivePath: "data.bin." + tc.ext, + Format: tc.format, + BasePath: ".", + Sources: []string{"data.bin"}, + }, nil) + require.NoError(t, err) + + _, err = Extract(context.Background(), workDir, &pb.ExtractArchiveParams{ + ArchivePath: "data.bin." + tc.ext, + Destination: "dst", + Format: tc.format, + CreateDestination: true, + }, nil) + require.NoError(t, err) + + assert.Equal(t, map[string]string{"data.bin": "single file payload"}, + readTree(t, filepath.Join(workDir, "dst"))) + }) + } +} + +func TestSingleFileExtractNoSuffix(t *testing.T) { + workDir := t.TempDir() + writeTree(t, workDir, map[string]string{"payload": "no suffix payload"}) + + _, err := Create(context.Background(), workDir, &pb.CreateArchiveParams{ + ArchivePath: "compressed", + Format: pb.ArchiveFormat_ARCHIVE_FORMAT_GZ, + BasePath: ".", + Sources: []string{"payload"}, + }, nil) + require.NoError(t, err) + + _, err = Extract(context.Background(), workDir, &pb.ExtractArchiveParams{ + ArchivePath: "compressed", + Destination: "dst", + Format: pb.ArchiveFormat_ARCHIVE_FORMAT_GZ, + CreateDestination: true, + }, nil) + require.NoError(t, err) + + assert.Equal(t, map[string]string{"compressed.out": "no suffix payload"}, + readTree(t, filepath.Join(workDir, "dst"))) +} + +func TestCreateErrors(t *testing.T) { + t.Run("unspecified format without a known extension", func(t *testing.T) { + workDir := t.TempDir() + writeTree(t, workDir, map[string]string{"a.txt": "a"}) + + _, err := Create(context.Background(), workDir, &pb.CreateArchiveParams{ + ArchivePath: "out.bundle", + Format: pb.ArchiveFormat_ARCHIVE_FORMAT_UNSPECIFIED, + Sources: []string{"a.txt"}, + }, nil) + require.Error(t, err) + assert.Contains(t, err.Error(), "no known extension") + }) + + t.Run("extract-only formats", func(t *testing.T) { + for _, format := range []pb.ArchiveFormat{ + pb.ArchiveFormat_ARCHIVE_FORMAT_7Z, + pb.ArchiveFormat_ARCHIVE_FORMAT_RAR, + } { + workDir := t.TempDir() + writeTree(t, workDir, map[string]string{"a.txt": "a"}) + + _, err := Create(context.Background(), workDir, &pb.CreateArchiveParams{ + ArchivePath: "out.arc", + Format: format, + Sources: []string{"a.txt"}, + }, nil) + require.Error(t, err) + assert.Contains(t, err.Error(), "extract-only") + } + }) + + t.Run("empty sources", func(t *testing.T) { + workDir := t.TempDir() + + _, err := Create(context.Background(), workDir, &pb.CreateArchiveParams{ + ArchivePath: "out.zip", + Format: pb.ArchiveFormat_ARCHIVE_FORMAT_ZIP, + Sources: []string{}, + }, nil) + require.Error(t, err) + }) + + t.Run("existing archive without overwrite", func(t *testing.T) { + workDir := t.TempDir() + writeTree(t, workDir, map[string]string{"a.txt": "a", "out.zip": "old"}) + + _, err := Create(context.Background(), workDir, &pb.CreateArchiveParams{ + ArchivePath: "out.zip", + Format: pb.ArchiveFormat_ARCHIVE_FORMAT_ZIP, + Sources: []string{"a.txt"}, + }, nil) + require.Error(t, err) + assert.Contains(t, err.Error(), "already exists") + + content, readErr := os.ReadFile(filepath.Join(workDir, "out.zip")) + require.NoError(t, readErr) + assert.Equal(t, "old", string(content), "existing archive must stay untouched") + }) + + t.Run("existing archive with overwrite", func(t *testing.T) { + workDir := t.TempDir() + writeTree(t, workDir, map[string]string{"a.txt": "a", "out.zip": "old"}) + + _, err := Create(context.Background(), workDir, &pb.CreateArchiveParams{ + ArchivePath: "out.zip", + Format: pb.ArchiveFormat_ARCHIVE_FORMAT_ZIP, + Sources: []string{"a.txt"}, + Overwrite: true, + }, nil) + require.NoError(t, err) + }) + + t.Run("single format with two sources", func(t *testing.T) { + workDir := t.TempDir() + writeTree(t, workDir, map[string]string{"a.txt": "a", "b.txt": "b"}) + + _, err := Create(context.Background(), workDir, &pb.CreateArchiveParams{ + ArchivePath: "out.gz", + Format: pb.ArchiveFormat_ARCHIVE_FORMAT_GZ, + Sources: []string{"a.txt", "b.txt"}, + }, nil) + require.Error(t, err) + assert.Contains(t, err.Error(), "exactly one") + }) + + t.Run("single format with directory source", func(t *testing.T) { + workDir := t.TempDir() + writeTree(t, workDir, map[string]string{"dir/a.txt": "a"}) + + _, err := Create(context.Background(), workDir, &pb.CreateArchiveParams{ + ArchivePath: "out.gz", + Format: pb.ArchiveFormat_ARCHIVE_FORMAT_GZ, + Sources: []string{"dir"}, + }, nil) + require.Error(t, err) + }) + + t.Run("source escapes base path", func(t *testing.T) { + workDir := t.TempDir() + writeTree(t, workDir, map[string]string{"a.txt": "a"}) + + _, err := Create(context.Background(), workDir, &pb.CreateArchiveParams{ + ArchivePath: "out.zip", + Format: pb.ArchiveFormat_ARCHIVE_FORMAT_ZIP, + BasePath: "sub", + Sources: []string{"../a.txt"}, + }, nil) + require.Error(t, err) + }) +} + +func TestCreateMaxFilesLimit(t *testing.T) { + workDir := t.TempDir() + writeTree(t, workDir, map[string]string{"a.txt": "a", "b.txt": "b"}) + + _, err := Create(context.Background(), workDir, &pb.CreateArchiveParams{ + ArchivePath: "out.zip", + Format: pb.ArchiveFormat_ARCHIVE_FORMAT_ZIP, + Sources: []string{"a.txt", "b.txt"}, + MaxFiles: 1, + }, nil) + require.Error(t, err) + assert.Contains(t, err.Error(), "max files limit exceeded") + + _, statErr := os.Stat(filepath.Join(workDir, "out.zip")) + assert.True(t, os.IsNotExist(statErr), "failed create must not leave a partial archive") +} + +func TestCreateMaxTotalBytesLimit(t *testing.T) { + workDir := t.TempDir() + writeTree(t, workDir, map[string]string{"a.txt": "more than one byte"}) + + _, err := Create(context.Background(), workDir, &pb.CreateArchiveParams{ + ArchivePath: "out.zip", + Format: pb.ArchiveFormat_ARCHIVE_FORMAT_ZIP, + Sources: []string{"a.txt"}, + MaxTotalBytes: 1, + }, nil) + require.Error(t, err) + assert.Contains(t, err.Error(), "max total bytes limit exceeded") +} + +func TestCreateModeOnArchive(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("unix permission bits are not supported on windows") + } + + workDir := t.TempDir() + writeTree(t, workDir, map[string]string{"a.txt": "a"}) + + _, err := Create(context.Background(), workDir, &pb.CreateArchiveParams{ + ArchivePath: "out.zip", + Format: pb.ArchiveFormat_ARCHIVE_FORMAT_ZIP, + Sources: []string{"a.txt"}, + Mode: 0o600, + }, nil) + require.NoError(t, err) + + info, err := os.Stat(filepath.Join(workDir, "out.zip")) + require.NoError(t, err) + assert.Equal(t, os.FileMode(0o600), info.Mode().Perm()) +} + +func TestCreateCanceledContext(t *testing.T) { + workDir := t.TempDir() + writeTree(t, workDir, map[string]string{"a.txt": "a"}) + + _, err := Create(canceledContext(t), workDir, &pb.CreateArchiveParams{ + ArchivePath: "out.zip", + Format: pb.ArchiveFormat_ARCHIVE_FORMAT_ZIP, + Sources: []string{"a.txt"}, + }, nil) + require.Error(t, err) + assert.ErrorIs(t, err, context.Canceled) +} + +func TestCreateCompressionLevels(t *testing.T) { + levels := map[string]*int32{ + "store": new(int32), + "fastest": new(int32(1)), + "best": new(int32(9)), + } + + formats := []struct { + name string + format pb.ArchiveFormat + }{ + {"zip", pb.ArchiveFormat_ARCHIVE_FORMAT_ZIP}, + {"tar.gz", pb.ArchiveFormat_ARCHIVE_FORMAT_TAR_GZ}, + {"tar.bz2", pb.ArchiveFormat_ARCHIVE_FORMAT_TAR_BZ2}, + {"tar.zst", pb.ArchiveFormat_ARCHIVE_FORMAT_TAR_ZSTD}, + } + + for _, f := range formats { + for levelName, level := range levels { + t.Run(f.name+"/"+levelName, func(t *testing.T) { + workDir := t.TempDir() + writeTree(t, workDir, map[string]string{"a.txt": "compressible payload payload payload"}) + + _, err := Create(context.Background(), workDir, &pb.CreateArchiveParams{ + ArchivePath: "out.arc", + Format: f.format, + Sources: []string{"a.txt"}, + CompressionLevel: level, + }, nil) + require.NoError(t, err) + + _, err = Extract(context.Background(), workDir, &pb.ExtractArchiveParams{ + ArchivePath: "out.arc", + Destination: "dst", + Format: f.format, + CreateDestination: true, + }, nil) + require.NoError(t, err) + + assert.Equal(t, map[string]string{"a.txt": "compressible payload payload payload"}, + readTree(t, filepath.Join(workDir, "dst"))) + }) + } + } +} + +func TestSymlinkRoundtrip(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("symlink creation needs privileges on windows") + } + + for _, tc := range []struct { + name string + format pb.ArchiveFormat + ext string + }{ + {"zip", pb.ArchiveFormat_ARCHIVE_FORMAT_ZIP, "zip"}, + {"tar", pb.ArchiveFormat_ARCHIVE_FORMAT_TAR, "tar"}, + } { + t.Run(tc.name, func(t *testing.T) { + workDir := t.TempDir() + writeTree(t, workDir, map[string]string{"src/target.txt": "link me"}) + require.NoError(t, os.Symlink("target.txt", filepath.Join(workDir, "src", "link.txt"))) + + _, err := Create(context.Background(), workDir, &pb.CreateArchiveParams{ + ArchivePath: "out." + tc.ext, + Format: tc.format, + Sources: []string{"src"}, + }, nil) + require.NoError(t, err) + + _, err = Extract(context.Background(), workDir, &pb.ExtractArchiveParams{ + ArchivePath: "out." + tc.ext, + Destination: "dst", + Format: tc.format, + CreateDestination: true, + }, nil) + require.NoError(t, err) + + link, err := os.Readlink(filepath.Join(workDir, "dst", "src", "link.txt")) + require.NoError(t, err) + assert.Equal(t, "target.txt", link) + }) + } +} + +func TestCreateFollowSymlinks(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("symlink creation needs privileges on windows") + } + + workDir := t.TempDir() + writeTree(t, workDir, map[string]string{"src/target.txt": "followed"}) + require.NoError(t, os.Symlink("target.txt", filepath.Join(workDir, "src", "link.txt"))) + + _, err := Create(context.Background(), workDir, &pb.CreateArchiveParams{ + ArchivePath: "out.tar", + Format: pb.ArchiveFormat_ARCHIVE_FORMAT_TAR, + Sources: []string{"src"}, + FollowSymlinks: true, + }, nil) + require.NoError(t, err) + + _, err = Extract(context.Background(), workDir, &pb.ExtractArchiveParams{ + ArchivePath: "out.tar", + Destination: "dst", + Format: pb.ArchiveFormat_ARCHIVE_FORMAT_TAR, + CreateDestination: true, + }, nil) + require.NoError(t, err) + + info, err := os.Lstat(filepath.Join(workDir, "dst", "src", "link.txt")) + require.NoError(t, err) + assert.True(t, info.Mode().IsRegular(), "followed symlink must be archived as a regular file") + + content, err := os.ReadFile(filepath.Join(workDir, "dst", "src", "link.txt")) + require.NoError(t, err) + assert.Equal(t, "followed", string(content)) +} + +func TestCreateDeepDirectoryNesting(t *testing.T) { + workDir := t.TempDir() + + deep := "src" + for range maxFollowDepth + 10 { + deep += "/d" + } + writeTree(t, workDir, map[string]string{deep + "/file.txt": "deep"}) + + _, err := Create(context.Background(), workDir, &pb.CreateArchiveParams{ + ArchivePath: "out.tar", + Format: pb.ArchiveFormat_ARCHIVE_FORMAT_TAR, + Sources: []string{"src"}, + }, nil) + require.NoError(t, err, "deep ordinary directory nesting must not trigger the symlink depth limit") +} + +func TestCreateFollowSymlinksDepthLimit(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("symlink creation needs privileges on windows") + } + + workDir := t.TempDir() + writeTree(t, workDir, map[string]string{"src/file.txt": "data"}) + require.NoError(t, os.Symlink(".", filepath.Join(workDir, "src", "loop"))) + + _, err := Create(context.Background(), workDir, &pb.CreateArchiveParams{ + ArchivePath: "out.tar", + Format: pb.ArchiveFormat_ARCHIVE_FORMAT_TAR, + Sources: []string{"src"}, + FollowSymlinks: true, + }, nil) + require.Error(t, err) + // The daemon's own hop limit fires at maxFollowDepth+1; the OS may reject + // the accumulated path earlier with its own symlink expansion limit. + assert.True(t, + strings.Contains(err.Error(), "symlink nesting too deep") || + strings.Contains(err.Error(), "too many levels of symbolic links"), + "unexpected error: %v", err) +} + +// TestWalkSourceSymlinkDepthLimit checks the daemon's own hop counter +// deterministically: following one more symlink past maxFollowDepth fails +// before any OS-level resolution is attempted. +func TestWalkSourceSymlinkDepthLimit(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("symlink creation needs privileges on windows") + } + + workDir := t.TempDir() + writeTree(t, workDir, map[string]string{"src/file.txt": "data"}) + require.NoError(t, os.Symlink(".", filepath.Join(workDir, "src", "loop"))) + + root, err := os.OpenRoot(workDir) + require.NoError(t, err) + defer root.Close() + + w := &sourceWalker{ + ctx: context.Background(), + root: root, + limits: &walkLimits{follow: true, maxEntries: defaultMaxFilesLimit()}, + } + + err = w.walk("src/loop", "src/loop", maxFollowDepth) + require.Error(t, err) + assert.Contains(t, err.Error(), "symlink nesting too deep") +} + +func defaultMaxFilesLimit() uint64 { + return uint64(defaultMaxFiles) +} + +// TestCreateFollowSymlinksFanOutIsBounded builds a symlink DAG that stays under +// os.Root's own 8-hop limit, so no single path is ever rejected while the +// number of distinct paths grows exponentially. The entry limit has to apply +// during the walk, not only when entries are written. +func TestCreateFollowSymlinksFanOutIsBounded(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("symlink creation needs privileges on windows") + } + + const levels = 7 + + workDir := t.TempDir() + writeTree(t, workDir, map[string]string{ + filepath.ToSlash(filepath.Join("src", fmt.Sprintf("d%d", levels), "leaf.txt")): "leaf", + }) + + for lvl := range levels { + dir := filepath.Join(workDir, "src", fmt.Sprintf("d%d", lvl)) + require.NoError(t, os.MkdirAll(dir, 0o755)) + + for k := range 3 { + require.NoError(t, os.Symlink( + fmt.Sprintf("../d%d", lvl+1), + filepath.Join(dir, fmt.Sprintf("l%d", k)), + )) + } + } + + _, err := Create(context.Background(), workDir, &pb.CreateArchiveParams{ + ArchivePath: "out.tar", + Format: pb.ArchiveFormat_ARCHIVE_FORMAT_TAR, + Sources: []string{"src"}, + FollowSymlinks: true, + MaxFiles: 50, + }, nil) + require.Error(t, err) + assert.Contains(t, err.Error(), "max files limit exceeded") +} + +func TestSourceWalkerRespectsContext(t *testing.T) { + workDir := t.TempDir() + writeTree(t, workDir, map[string]string{"src/a.txt": "a"}) + + root, err := os.OpenRoot(workDir) + require.NoError(t, err) + defer root.Close() + + w := &sourceWalker{ + ctx: canceledContext(t), + root: root, + limits: &walkLimits{maxEntries: defaultMaxFilesLimit()}, + } + + err = w.walk("src", "src", 0) + require.ErrorIs(t, err, context.Canceled) +} + +// TestCreateExcludesItself covers the archive sitting inside the tree being +// walked: it must not be archived into itself while it is being written. +func TestCreateExcludesItself(t *testing.T) { + workDir := t.TempDir() + writeTree(t, workDir, map[string]string{"src/a.txt": "alpha"}) + + _, err := Create(context.Background(), workDir, &pb.CreateArchiveParams{ + ArchivePath: "src/out.tar", + Format: pb.ArchiveFormat_ARCHIVE_FORMAT_TAR, + BasePath: "src", + Sources: []string{"."}, + }, nil) + require.NoError(t, err) + + _, err = Extract(context.Background(), workDir, &pb.ExtractArchiveParams{ + ArchivePath: "src/out.tar", + Destination: "dst", + Format: pb.ArchiveFormat_ARCHIVE_FORMAT_TAR, + CreateDestination: true, + }, nil) + require.NoError(t, err) + + assert.Equal(t, map[string]string{"a.txt": "alpha"}, readTree(t, filepath.Join(workDir, "dst"))) +} + +// TestFormatDetection checks the proto contract that an unset format is +// resolved from the archive itself, including telling tar.gz from a bare gz — +// the two share a magic number and differ only in what the stream contains. +func TestFormatDetection(t *testing.T) { + detect := func(t *testing.T, name, ext, source string, format pb.ArchiveFormat) { + t.Helper() + + t.Run(name, func(t *testing.T) { + workDir := t.TempDir() + writeTree(t, workDir, map[string]string{"src/a.txt": "alpha"}) + + _, err := Create(context.Background(), workDir, &pb.CreateArchiveParams{ + ArchivePath: "out." + ext, + Format: format, + Sources: []string{source}, + }, nil) + require.NoError(t, err) + + res, err := Extract(context.Background(), workDir, &pb.ExtractArchiveParams{ + ArchivePath: "out." + ext, + Destination: "dst", + Format: pb.ArchiveFormat_ARCHIVE_FORMAT_UNSPECIFIED, + CreateDestination: true, + }, nil) + require.NoError(t, err) + assert.Equal(t, format, res.Format) + }) + } + + for _, tc := range roundtripFormats { + detect(t, tc.name, tc.ext, "src", tc.format) + } + + for _, tc := range singleFormats { + detect(t, tc.name, tc.ext, "src/a.txt", tc.format) + } +} diff --git a/internal/app/archive/create_zip.go b/internal/app/archive/create_zip.go new file mode 100644 index 0000000..bd37e87 --- /dev/null +++ b/internal/app/archive/create_zip.go @@ -0,0 +1,90 @@ +package archive + +import ( + "archive/zip" + "compress/flate" + "context" + "io" + "os" + + "github.com/pkg/errors" + + pb "github.com/gameap/gameap/pkg/proto" +) + +// createZip writes entries into a zip stream. Compression level 0 maps to +// per-entry Store; other levels tune the deflate compressor. +func createZip( + ctx context.Context, + root *os.Root, + w io.Writer, + entries []sourceEntry, + p *pb.CreateArchiveParams, + acc *accumulator, +) error { + zw := zip.NewWriter(w) + + store := false + if p.CompressionLevel != nil { + if *p.CompressionLevel == 0 { + store = true + } else { + level := flateLevel(p.CompressionLevel) + zw.RegisterCompressor(zip.Deflate, func(out io.Writer) (io.WriteCloser, error) { + return flate.NewWriter(out, level) + }) + } + } + + for _, e := range entries { + if err := ctx.Err(); err != nil { + return errors.Wrap(err, "create archive canceled") + } + + hdr, err := zip.FileInfoHeader(e.info) + if err != nil { + return errors.Wrapf(err, "failed to build header for %q", e.name) + } + + hdr.Name = e.name + if e.info.IsDir() { + hdr.Name += "/" + } + + if store || e.isSymlink() { + hdr.Method = zip.Store + } + + entryWriter, err := zw.CreateHeader(hdr) + if err != nil { + return errors.Wrapf(err, "failed to write header for %q", e.name) + } + + var n int64 + + switch { + case e.info.IsDir(): + case e.isSymlink(): + nw, writeErr := entryWriter.Write([]byte(e.link)) + n = int64(nw) + if writeErr != nil { + return errors.Wrapf(writeErr, "failed to write symlink %q", e.name) + } + default: + n, err = copySource(root, e.rel, entryWriter, acc.bytesLeft()) + if err != nil { + return err + } + } + + if err := acc.addEntry(e.name, n); err != nil { + return err + } + } + + if err := zw.Close(); err != nil { + return errors.Wrap(err, "failed to finish zip archive") + } + + return nil +} diff --git a/internal/app/archive/detect.go b/internal/app/archive/detect.go new file mode 100644 index 0000000..713d54e --- /dev/null +++ b/internal/app/archive/detect.go @@ -0,0 +1,195 @@ +package archive + +import ( + "bytes" + "io" + "os" + "path" + "strings" + + "github.com/pkg/errors" + + pb "github.com/gameap/gameap/pkg/proto" +) + +// headerPeekBytes is one tar block: enough for every container signature below +// and for the "ustar" marker a tar header carries at offset 257. +const headerPeekBytes = 512 + +// tarMagicOffset is where a POSIX tar header stores "ustar". +const tarMagicOffset = 257 + +var tarMagic = []byte("ustar") + +// extensionFormats maps a file suffix onto the format it conventionally names. +// Longest suffix wins, so ".tar.gz" is matched before ".gz". +var extensionFormats = []struct { + suffix string + format pb.ArchiveFormat +}{ + {".tar.gz", pb.ArchiveFormat_ARCHIVE_FORMAT_TAR_GZ}, + {".tar.bz2", pb.ArchiveFormat_ARCHIVE_FORMAT_TAR_BZ2}, + {".tar.xz", pb.ArchiveFormat_ARCHIVE_FORMAT_TAR_XZ}, + {".tar.zst", pb.ArchiveFormat_ARCHIVE_FORMAT_TAR_ZSTD}, + {".tgz", pb.ArchiveFormat_ARCHIVE_FORMAT_TAR_GZ}, + {".tbz2", pb.ArchiveFormat_ARCHIVE_FORMAT_TAR_BZ2}, + {".tbz", pb.ArchiveFormat_ARCHIVE_FORMAT_TAR_BZ2}, + {".txz", pb.ArchiveFormat_ARCHIVE_FORMAT_TAR_XZ}, + {".tzst", pb.ArchiveFormat_ARCHIVE_FORMAT_TAR_ZSTD}, + {".tar", pb.ArchiveFormat_ARCHIVE_FORMAT_TAR}, + {".zip", pb.ArchiveFormat_ARCHIVE_FORMAT_ZIP}, + {".7z", pb.ArchiveFormat_ARCHIVE_FORMAT_7Z}, + {".rar", pb.ArchiveFormat_ARCHIVE_FORMAT_RAR}, + {".gz", pb.ArchiveFormat_ARCHIVE_FORMAT_GZ}, + {".bz2", pb.ArchiveFormat_ARCHIVE_FORMAT_BZ2}, + {".xz", pb.ArchiveFormat_ARCHIVE_FORMAT_XZ}, + {".zst", pb.ArchiveFormat_ARCHIVE_FORMAT_ZSTD}, +} + +// formatFromExtension resolves a format from a file name, returning +// ARCHIVE_FORMAT_UNSPECIFIED when no known suffix matches. +func formatFromExtension(name string) pb.ArchiveFormat { + lower := strings.ToLower(path.Base(name)) + + for _, e := range extensionFormats { + if strings.HasSuffix(lower, e.suffix) { + return e.format + } + } + + return pb.ArchiveFormat_ARCHIVE_FORMAT_UNSPECIFIED +} + +// containerFromMagic recognizes the self-describing container formats. +func containerFromMagic(head []byte) (pb.ArchiveFormat, bool) { + switch { + case bytes.HasPrefix(head, []byte("PK\x03\x04")), + bytes.HasPrefix(head, []byte("PK\x05\x06")), + bytes.HasPrefix(head, []byte("PK\x07\x08")): + return pb.ArchiveFormat_ARCHIVE_FORMAT_ZIP, true + case bytes.HasPrefix(head, []byte("7z\xbc\xaf\x27\x1c")): + return pb.ArchiveFormat_ARCHIVE_FORMAT_7Z, true + case bytes.HasPrefix(head, []byte("Rar!\x1a\x07")): + return pb.ArchiveFormat_ARCHIVE_FORMAT_RAR, true + case looksLikeTar(head): + return pb.ArchiveFormat_ARCHIVE_FORMAT_TAR, true + default: + return pb.ArchiveFormat_ARCHIVE_FORMAT_UNSPECIFIED, false + } +} + +// compressionFromMagic recognizes the stream compressors. They say nothing +// about whether a tar sits inside, which is resolved separately. +func compressionFromMagic(head []byte) (compression, bool) { + switch { + case bytes.HasPrefix(head, []byte("\x1f\x8b")): + return compGzip, true + case bytes.HasPrefix(head, []byte("BZh")): + return compBzip2, true + case bytes.HasPrefix(head, []byte("\xfd7zXZ\x00")): + return compXz, true + case bytes.HasPrefix(head, []byte("\x28\xb5\x2f\xfd")): + return compZstd, true + default: + return compNone, false + } +} + +func looksLikeTar(head []byte) bool { + if len(head) < tarMagicOffset+len(tarMagic) { + return false + } + + return bytes.Equal(head[tarMagicOffset:tarMagicOffset+len(tarMagic)], tarMagic) +} + +// detectFormat resolves ARCHIVE_FORMAT_UNSPECIFIED for extraction the way the +// proto describes: by magic bytes, falling back to the file extension. The +// file offset is restored before returning, so the caller can read from the +// start regardless of how much was consumed while sniffing. +func detectFormat(f *os.File, name string) (pb.ArchiveFormat, error) { + defer func() { + _, _ = f.Seek(0, io.SeekStart) + }() + + head := make([]byte, headerPeekBytes) + n, err := f.ReadAt(head, 0) + if err != nil && !errors.Is(err, io.EOF) { + return pb.ArchiveFormat_ARCHIVE_FORMAT_UNSPECIFIED, errors.Wrapf( + err, "failed to read header of %q", name, + ) + } + head = head[:n] + + if format, ok := containerFromMagic(head); ok { + return format, nil + } + + if comp, ok := compressionFromMagic(head); ok { + return compressedFormat(f, comp), nil + } + + if format := formatFromExtension(name); format != pb.ArchiveFormat_ARCHIVE_FORMAT_UNSPECIFIED { + return format, nil + } + + return pb.ArchiveFormat_ARCHIVE_FORMAT_UNSPECIFIED, errors.Errorf( + "cannot detect the format of archive %q", name, + ) +} + +// compressedFormat decides whether a compressed stream carries a tar or a bare +// file by decompressing just the first tar block and looking for its marker. +// A stream that cannot be decompressed is reported as single-file; opening it +// for real will surface the actual error. +func compressedFormat(f *os.File, comp compression) pb.ArchiveFormat { + if _, err := f.Seek(0, io.SeekStart); err != nil { + return singleFormatFor(comp) + } + + stream, err := decompressReader(f, comp) + if err != nil { + return singleFormatFor(comp) + } + defer stream.Close() + + head := make([]byte, headerPeekBytes) + n, err := io.ReadFull(stream, head) + if err != nil && !errors.Is(err, io.EOF) && !errors.Is(err, io.ErrUnexpectedEOF) { + return singleFormatFor(comp) + } + + if looksLikeTar(head[:n]) { + return tarFormatFor(comp) + } + + return singleFormatFor(comp) +} + +func tarFormatFor(comp compression) pb.ArchiveFormat { + switch comp { + case compGzip: + return pb.ArchiveFormat_ARCHIVE_FORMAT_TAR_GZ + case compBzip2: + return pb.ArchiveFormat_ARCHIVE_FORMAT_TAR_BZ2 + case compXz: + return pb.ArchiveFormat_ARCHIVE_FORMAT_TAR_XZ + case compZstd: + return pb.ArchiveFormat_ARCHIVE_FORMAT_TAR_ZSTD + default: + return pb.ArchiveFormat_ARCHIVE_FORMAT_TAR + } +} + +func singleFormatFor(comp compression) pb.ArchiveFormat { + switch comp { + case compGzip: + return pb.ArchiveFormat_ARCHIVE_FORMAT_GZ + case compBzip2: + return pb.ArchiveFormat_ARCHIVE_FORMAT_BZ2 + case compXz: + return pb.ArchiveFormat_ARCHIVE_FORMAT_XZ + default: + return pb.ArchiveFormat_ARCHIVE_FORMAT_ZSTD + } +} diff --git a/internal/app/archive/extract.go b/internal/app/archive/extract.go new file mode 100644 index 0000000..58730d8 --- /dev/null +++ b/internal/app/archive/extract.go @@ -0,0 +1,557 @@ +package archive + +import ( + "context" + "io" + "os" + "path" + "strings" + + "github.com/pkg/errors" + + "github.com/gameap/daemon/internal/app/fsutil" + "github.com/gameap/daemon/internal/app/osowner" + pb "github.com/gameap/gameap/pkg/proto" +) + +const ( + defaultFilePerm os.FileMode = 0o644 + defaultDirPerm os.FileMode = 0o755 +) + +// sink places extracted entries under the destination inside the root, +// applying the conflict policy, permission rules, ownership and limits. +type sink struct { + root *os.Root + dest string + policy pb.ArchiveConflictPolicy + preserve bool + mode os.FileMode // when != 0 overrides file permissions from the archive + owner osowner.Options + acc *accumulator + skipped []string + links []createdLink +} + +// createdLink records a symlink this run put on disk so its target can be +// checked again once the archive can no longer move anything. +type createdLink struct { + path string // where the link was stored, relative to the root + target string // literal target as it came out of the archive +} + +// safeEntryName validates an archive entry name and returns its path relative +// to the root (dest-prefixed). ok=false means the entry is a "." artifact and +// should be skipped silently. Absolute names and names escaping the +// destination through ".." are rejected (zip-slip); os.Root remains the hard +// boundary against symlink escapes below. +func (s *sink) safeEntryName(name string) (target string, ok bool, err error) { + // Windows-produced archives may use backslashes as separators; normalize + // so ".." segments hidden behind them are caught too. + clean := path.Clean(strings.ReplaceAll(name, `\`, "/")) + if clean == "." { + return "", false, nil + } + + if path.IsAbs(clean) || clean == ".." || strings.HasPrefix(clean, "../") { + return "", false, errors.Errorf("archive entry %q escapes the destination", name) + } + + return path.Join(s.dest, clean), true, nil +} + +func (s *sink) resolveConflict(target string, isDir bool) (skip, existed bool, err error) { + existing, lstatErr := s.root.Lstat(target) + if lstatErr != nil { + if errors.Is(lstatErr, os.ErrNotExist) { + return false, false, nil + } + + return false, false, errors.Wrapf(lstatErr, "failed to stat %q", target) + } + + // An existing directory merging with a directory entry is not a conflict. + if isDir && existing.IsDir() { + return false, true, nil + } + + switch s.policy { + case pb.ArchiveConflictPolicy_ARCHIVE_CONFLICT_POLICY_SKIP: + return true, true, nil + case pb.ArchiveConflictPolicy_ARCHIVE_CONFLICT_POLICY_OVERWRITE: + if existing.IsDir() { + if err := s.root.RemoveAll(target); err != nil { + return false, true, errors.Wrapf(err, "failed to remove %q", target) + } + } else if err := s.root.Remove(target); err != nil { + return false, true, errors.Wrapf(err, "failed to remove %q", target) + } + + return false, false, nil + default: // UNSPECIFIED and ERROR both fail fast, per the proto contract + return false, true, errors.Errorf("destination entry %q already exists", target) + } +} + +func (s *sink) filePerm(archiveMode os.FileMode) os.FileMode { + if s.mode != 0 { + return s.mode + } + if s.preserve && archiveMode.Perm() != 0 { + return archiveMode.Perm() + } + + return defaultFilePerm +} + +func (s *sink) dirPerm(archiveMode os.FileMode) os.FileMode { + if s.preserve && archiveMode.Perm() != 0 { + return archiveMode.Perm() + } + + return defaultDirPerm +} + +func (s *sink) putDir(name string, archiveMode os.FileMode) error { + target, ok, err := s.safeEntryName(name) + if err != nil { + return err + } + if !ok { + return nil + } + + skip, existed, err := s.resolveConflict(target, true) + if err != nil { + return err + } + if skip { + s.skipped = append(s.skipped, name) + + return nil + } + + if err := mkdirAllOwned(s.root, target, s.dirPerm(archiveMode), s.owner); err != nil { + return err + } + + // MkdirAll applies umask; chmod for the exact requested permissions. A + // pre-existing directory merged into keeps its mode unless permissions + // are explicitly preserved from the archive. + if !existed || s.preserve { + if err := s.root.Chmod(target, s.dirPerm(archiveMode)); err != nil { + return errors.Wrapf(err, "failed to chmod directory %q", target) + } + } + + if err := osowner.ApplyToPathInRoot(s.root, target, s.owner); err != nil { + return errors.Wrapf(err, "failed to apply owner to %q", target) + } + + return s.acc.addEntry(name, 0) +} + +func (s *sink) putFile(name string, archiveMode os.FileMode, r io.Reader) error { + target, ok, err := s.safeEntryName(name) + if err != nil { + return err + } + if !ok { + // A "." entry still has to be consumed by sequential readers; the + // caller passes the reader and drains it here. + _, err := io.Copy(io.Discard, r) + + return errors.Wrap(err, "failed to skip archive entry") + } + + skip, _, err := s.resolveConflict(target, false) + if err != nil { + return err + } + if skip { + s.skipped = append(s.skipped, name) + _, err := io.Copy(io.Discard, r) + + return errors.Wrap(err, "failed to skip archive entry") + } + + perm := s.filePerm(archiveMode) + + if err := s.ensureParent(target); err != nil { + return err + } + + out, err := s.root.OpenFile(target, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, perm) + if err != nil { + return errors.Wrapf(err, "failed to create file %q", target) + } + + n, copyErr := io.Copy(out, io.LimitReader(r, s.acc.bytesLeft()+1)) + closeErr := out.Close() + + if copyErr != nil { + _ = s.root.Remove(target) + + return errors.Wrapf(copyErr, "failed to write file %q", target) + } + if closeErr != nil { + _ = s.root.Remove(target) + + return errors.Wrapf(closeErr, "failed to close file %q", target) + } + + if err := s.acc.addEntry(name, n); err != nil { + _ = s.root.Remove(target) + + return err + } + + // OpenFile applies umask; chmod for the exact requested permissions. + if err := s.root.Chmod(target, perm); err != nil { + return errors.Wrapf(err, "failed to chmod file %q", target) + } + + if err := osowner.ApplyToPathInRoot(s.root, target, s.owner); err != nil { + return errors.Wrapf(err, "failed to apply owner to %q", target) + } + + return nil +} + +// maxSymlinkResolveHops bounds symlink expansion while validating a link +// target. It matches os.Root's own rootMaxSymlinks, so a target accepted here +// is one os.Root will also agree to resolve later. +const maxSymlinkResolveHops = 8 + +func (s *sink) withinDest(resolved string) bool { + if s.dest == "." { + return resolved != ".." && !strings.HasPrefix(resolved, "../") + } + + return resolved == s.dest || strings.HasPrefix(resolved, s.dest+"/") +} + +// resolveLinkTarget resolves linkTarget the way the kernel would: relative to +// the directory holding the link, expanding any path component that is itself +// a symlink already present under the root. +// +// Resolving lexically is not enough. An archive can first store dst/a/b/l with +// target "../..", which cleans to dst and is accepted, and then store dst/esc +// with target "a/b/l/../../../x". That cleans to dst/x — apparently inside the +// destination — while the kernel walks it to three levels above the work +// directory, leaving an escaping symlink on disk for whatever reads that tree +// without os.Root. +func (s *sink) resolveLinkTarget(linkDir, linkTarget string) (string, error) { + cur := linkDir + pending := strings.Split(linkTarget, "/") + hops := 0 + + for len(pending) > 0 { + part := pending[0] + pending = pending[1:] + + switch part { + case "", ".": + continue + case "..": + if cur == "." { + return "", errors.New("symlink target escapes the work directory") + } + cur = path.Dir(cur) + + continue + } + + next := part + if cur != "." { + next = path.Join(cur, part) + } + + info, statErr := s.root.Lstat(next) + if statErr != nil || info.Mode()&os.ModeSymlink == 0 { + // Missing entries resolve literally: the archive may create them + // later, and a dangling link is not by itself an escape. + cur = next + + continue + } + + hops++ + if hops > maxSymlinkResolveHops { + return "", errors.New("symlink target crosses too many links") + } + + nested, linkErr := s.root.Readlink(next) + if linkErr != nil { + return "", errors.Wrapf(linkErr, "failed to read symlink %q", next) + } + if path.IsAbs(nested) { + return "", errors.New("symlink target crosses an absolute symlink") + } + + // The kernel replaces the link with its target resolved from the + // directory holding it, so cur stays put and the target is spliced in + // front of what is left to walk. + pending = append(strings.Split(nested, "/"), pending...) + } + + return cur, nil +} + +// checkLinkTarget reports whether the symlink stored at linkPath with the given +// literal target stays inside the destination. +// +// The directory holding the link is resolved first: os.Root creates the link in +// the directory linkPath resolves to, so a parent component that is itself a +// symlink moves the link — and with it what every ".." in its target pops off. +// An archive storing "s -> ." and then "s/l -> ../x" has os.Root put l next to +// s instead of below it, which turns a target the lexical path says is inside +// the destination into one that leaves it. +func (s *sink) checkLinkTarget(linkPath, linkTarget string) error { + if path.IsAbs(linkTarget) { + return errors.New("absolute symlink target") + } + + linkDir, err := s.resolveLinkTarget(".", path.Dir(linkPath)) + if err != nil { + return err + } + + resolved, err := s.resolveLinkTarget(linkDir, linkTarget) + if err != nil { + return err + } + if !s.withinDest(resolved) { + return errors.New("symlink target escapes the destination") + } + + return nil +} + +// revalidateLinks checks every symlink this run created once more, against the +// finished tree. +// +// checkLinkTarget can only see the tree as it stands when the entry arrives. A +// later entry can turn a component an earlier target resolved through into a +// symlink of its own — either by being stored after it or by replacing a +// directory under the overwrite policy — so two links that are each confined on +// their own combine into one that is not. +// +// A link that fails invalidates the whole run, so every link it created is +// removed: the checks are order dependent, and dropping only the offenders +// would leave the links validated before them resting on a tree that changed +// underneath. +func (s *sink) revalidateLinks() error { + for _, l := range s.links { + // A later entry may have taken the link away again (an overwritten + // directory takes its whole subtree with it); whatever sits there now + // belongs to that entry, not to this one. + info, err := s.root.Lstat(l.path) + if err != nil || info.Mode()&os.ModeSymlink == 0 { + continue + } + + if err := s.checkLinkTarget(l.path, l.target); err != nil { + s.removeCreatedLinks() + + return errors.Wrapf(err, "symlink %q", l.path) + } + } + + return nil +} + +func (s *sink) removeCreatedLinks() { + for _, l := range s.links { + if info, err := s.root.Lstat(l.path); err != nil || info.Mode()&os.ModeSymlink == 0 { + continue + } + + _ = s.root.Remove(l.path) + } +} + +func (s *sink) putSymlink(name, linkTarget string) error { + target, ok, err := s.safeEntryName(name) + if err != nil { + return err + } + if !ok { + return nil + } + + if err := s.checkLinkTarget(target, linkTarget); err != nil { + return errors.Wrapf(err, "archive entry %q", name) + } + + skip, _, err := s.resolveConflict(target, false) + if err != nil { + return err + } + if skip { + s.skipped = append(s.skipped, name) + + return nil + } + + if err := s.ensureParent(target); err != nil { + return err + } + + if err := s.root.Symlink(linkTarget, target); err != nil { + return errors.Wrapf(err, "failed to create symlink %q", target) + } + + s.links = append(s.links, createdLink{path: target, target: linkTarget}) + + if err := osowner.ApplyToPathInRoot(s.root, target, s.owner); err != nil { + return errors.Wrapf(err, "failed to apply owner to %q", target) + } + + return s.acc.addEntry(name, 0) +} + +func (s *sink) ensureParent(target string) error { + parent := path.Dir(target) + if parent == "." || parent == "/" { + return nil + } + + return mkdirAllOwned(s.root, parent, defaultDirPerm, s.owner) +} + +// mkdirAllOwned creates rel and applies the owner to exactly the directories it +// had to create, leaving pre-existing (often shared) parents alone. Without +// this, a game server running as an unprivileged su_user cannot traverse into +// the tree the daemon just unpacked for it. +func mkdirAllOwned(root *os.Root, rel string, perm os.FileMode, owner osowner.Options) error { + created, err := osowner.MissingSegmentsInRoot(root, rel) + if err != nil { + return errors.Wrapf(err, "failed to inspect directory %q", rel) + } + + if err := root.MkdirAll(rel, perm); err != nil { + return errors.Wrapf(err, "failed to create directory %q", rel) + } + + for _, segment := range created { + if err := osowner.ApplyToPathInRoot(root, segment, owner); err != nil { + return errors.Wrapf(err, "failed to apply owner to %q", segment) + } + } + + return nil +} + +// Extract unpacks archive_path into destination. See the package doc for the +// confinement model and safeEntryName for the zip-slip rules. +func Extract(ctx context.Context, workDir string, p *pb.ExtractArchiveParams, progress ProgressFunc) (*Result, error) { + if err := ctx.Err(); err != nil { + return nil, errors.Wrap(err, "extract archive canceled") + } + + root, err := os.OpenRoot(workDir) + if err != nil { + return nil, errors.Wrap(err, "work directory unavailable") + } + defer root.Close() + + archiveRel, err := fsutil.RootRel(p.GetArchivePath()) + if err != nil { + return nil, err + } + + destRel, err := fsutil.RootRel(p.GetDestination()) + if err != nil { + return nil, err + } + + if err := prepareDestination(root, destRel, p); err != nil { + return nil, err + } + + archiveFile, err := root.Open(archiveRel) + if err != nil { + return nil, errors.Wrapf(err, "failed to open archive %q", p.GetArchivePath()) + } + defer archiveFile.Close() + + archiveInfo, err := archiveFile.Stat() + if err != nil { + return nil, errors.Wrapf(err, "failed to stat archive %q", p.GetArchivePath()) + } + + // The proto lets the request leave the format unset and expects the daemon + // to work it out from the content or the file name. + format := p.GetFormat() + if format == pb.ArchiveFormat_ARCHIVE_FORMAT_UNSPECIFIED { + if format, err = detectFormat(archiveFile, archiveRel); err != nil { + return nil, err + } + } + + class, err := classify(format) + if err != nil { + return nil, err + } + + s := &sink{ + root: root, + dest: destRel, + policy: p.GetConflictPolicy(), + preserve: p.GetPreservePermissions(), + mode: os.FileMode(p.GetMode()).Perm(), + owner: ownerOptions(p.GetOwnerUser(), p.GetOwnerUid(), p.GetOwnerGid()), + acc: newAccumulator(p.GetMaxTotalBytes(), p.GetMaxFiles(), progress), + } + + extractErr := extractEntries(ctx, archiveFile, archiveRel, class, format, s) + + // Symlink confinement is only settled once the archive can no longer move + // anything, so it is decided here — including for a run that failed, which + // leaves its partial tree behind and must not leave an escaping link in it. + linkErr := s.revalidateLinks() + + if extractErr != nil { + return nil, extractErr + } + if linkErr != nil { + return nil, linkErr + } + + return &Result{ + FilesProcessed: s.acc.files, + BytesProcessed: s.acc.bytes, + // The proto defines archive_size as the source archive when extracting. + ArchiveSize: archiveInfo.Size(), + Skipped: s.skipped, + Format: format, + }, nil +} + +func prepareDestination(root *os.Root, destRel string, p *pb.ExtractArchiveParams) error { + info, err := root.Stat(destRel) + switch { + case err == nil: + if !info.IsDir() { + return errors.Errorf("destination %q is not a directory", p.GetDestination()) + } + + return nil + case errors.Is(err, os.ErrNotExist): + if !p.GetCreateDestination() { + return errors.Errorf( + "destination %q does not exist and create_destination is disabled", p.GetDestination(), + ) + } + + owner := ownerOptions(p.GetOwnerUser(), p.GetOwnerUid(), p.GetOwnerGid()) + if err := mkdirAllOwned(root, destRel, defaultDirPerm, owner); err != nil { + return errors.Wrapf(err, "failed to create destination %q", p.GetDestination()) + } + + return nil + default: + return errors.Wrapf(err, "failed to stat destination %q", p.GetDestination()) + } +} diff --git a/internal/app/archive/extract_formats.go b/internal/app/archive/extract_formats.go new file mode 100644 index 0000000..5e982f6 --- /dev/null +++ b/internal/app/archive/extract_formats.go @@ -0,0 +1,330 @@ +package archive + +import ( + "archive/tar" + "archive/zip" + "context" + "io" + "os" + "path" + + "github.com/bodgit/sevenzip" + rardecode "github.com/nwaples/rardecode/v2" + "github.com/pkg/errors" + + pb "github.com/gameap/gameap/pkg/proto" +) + +// maxLinkTargetBytes caps how much is read for a symlink entry body; real +// link targets are a few hundred bytes at most. +const maxLinkTargetBytes = 1 << 20 + +// maxRarDictBytes caps the LZ window rardecode allocates up front from the +// archive header. Its own default is 4 GiB, which a crafted RAR can demand in +// a single allocation; WinRAR tops out at 32 MiB for the presets that produce +// real-world archives, so this leaves generous headroom. +const maxRarDictBytes = 256 << 20 + +func extractEntries( + ctx context.Context, + archiveFile *os.File, + archiveRel string, + class formatClass, + format pb.ArchiveFormat, + s *sink, +) error { + switch class { + case classZip: + return extractZip(ctx, archiveFile, s) + case classTar: + return extractTar(ctx, archiveFile, tarCompression(format), s) + case classSingle: + return extractSingle(archiveFile, archiveRel, format, s) + case class7z: + return extract7z(ctx, archiveFile, s) + default: + return extractRar(ctx, archiveFile, s) + } +} + +func extractZip(ctx context.Context, archiveFile *os.File, s *sink) error { + info, err := archiveFile.Stat() + if err != nil { + return errors.Wrap(err, "failed to stat archive") + } + + zr, err := zip.NewReader(archiveFile, info.Size()) + if err != nil { + return errors.Wrap(err, "failed to read zip archive") + } + + if err := s.acc.checkEntryCount(len(zr.File)); err != nil { + return err + } + + for _, f := range zr.File { + if err := ctx.Err(); err != nil { + return errors.Wrap(err, "extract archive canceled") + } + + switch mode := f.Mode(); { + case f.FileInfo().IsDir(): + if err := s.putDir(f.Name, mode); err != nil { + return err + } + case mode&os.ModeSymlink != 0: + if err := extractZipSymlink(f, s); err != nil { + return err + } + default: + rc, err := f.Open() + if err != nil { + return errors.Wrapf(err, "failed to open zip entry %q", f.Name) + } + + err = s.putFile(f.Name, mode, rc) + _ = rc.Close() + + if err != nil { + return err + } + } + } + + return nil +} + +// putSymlinkFrom materializes a symlink whose target is stored as the entry +// body — the convention zip, 7z and RAR4 share. +func putSymlinkFrom(r io.Reader, name string, s *sink) error { + // One byte past the cap, so an oversized target is reported instead of + // silently becoming a truncated link. + target, err := io.ReadAll(io.LimitReader(r, maxLinkTargetBytes+1)) + if err != nil { + return errors.Wrapf(err, "failed to read symlink entry %q", name) + } + if len(target) > maxLinkTargetBytes { + return errors.Errorf("symlink entry %q exceeds the %d byte target limit", name, maxLinkTargetBytes) + } + + return s.putSymlink(name, string(target)) +} + +func extractZipSymlink(f *zip.File, s *sink) error { + rc, err := f.Open() + if err != nil { + return errors.Wrapf(err, "failed to open zip entry %q", f.Name) + } + defer rc.Close() + + return putSymlinkFrom(rc, f.Name, s) +} + +func extractTar(ctx context.Context, archiveFile *os.File, comp compression, s *sink) error { + stream, err := decompressReader(archiveFile, comp) + if err != nil { + return err + } + defer stream.Close() + + tr := tar.NewReader(stream) + + for { + hdr, err := tr.Next() + if errors.Is(err, io.EOF) { + return nil + } + if err != nil { + return errors.Wrap(err, "failed to read tar archive") + } + + if err := ctx.Err(); err != nil { + return errors.Wrap(err, "extract archive canceled") + } + + switch hdr.Typeflag { + case tar.TypeDir: + if err := s.putDir(hdr.Name, hdr.FileInfo().Mode()); err != nil { + return err + } + case tar.TypeSymlink: + if err := s.putSymlink(hdr.Name, hdr.Linkname); err != nil { + return err + } + case tar.TypeReg, tar.TypeRegA: + if err := s.putFile(hdr.Name, hdr.FileInfo().Mode(), tr); err != nil { + return err + } + case tar.TypeLink: + if err := extractTarHardlink(hdr, s); err != nil { + return err + } + default: + // Fifos, device nodes and pax/gnu metadata records are skipped. + } + } +} + +// extractTarHardlink materializes a hardlink entry as a copy of the already +// extracted link target. +func extractTarHardlink(hdr *tar.Header, s *sink) error { + target, ok, err := s.safeEntryName(hdr.Linkname) + if err != nil { + return err + } + if !ok { + return errors.Errorf("hardlink entry %q has an empty target", hdr.Name) + } + + src, err := s.root.Open(target) + if err != nil { + return errors.Wrapf(err, "failed to open hardlink target %q", hdr.Linkname) + } + defer src.Close() + + return s.putFile(hdr.Name, hdr.FileInfo().Mode(), src) +} + +// extractSingle handles the gz/bz2/xz/zstd formats: the whole stream is one +// file, named after the archive minus its compression suffix. +func extractSingle(archiveFile *os.File, archiveRel string, format pb.ArchiveFormat, s *sink) error { + stream, err := decompressReader(archiveFile, singleCompression(format)) + if err != nil { + return err + } + defer stream.Close() + + name := singleOutputName(path.Base(archiveRel), format) + + return s.putFile(name, 0, stream) +} + +func extract7z(ctx context.Context, archiveFile *os.File, s *sink) error { + info, err := archiveFile.Stat() + if err != nil { + return errors.Wrap(err, "failed to stat archive") + } + + zr, err := sevenzip.NewReader(archiveFile, info.Size()) + if err != nil { + return wrapArchiveReadErr(err, "7z") + } + + if err := s.acc.checkEntryCount(len(zr.File)); err != nil { + return err + } + + for _, f := range zr.File { + if err := ctx.Err(); err != nil { + return errors.Wrap(err, "extract archive canceled") + } + + if f.FileInfo().IsDir() { + if err := s.putDir(f.Name, f.Mode()); err != nil { + return err + } + + continue + } + + if err := extract7zEntry(f, s); err != nil { + // A 7z archive can keep its header readable while only the entry + // data is encrypted, so "password required" first surfaces here and + // still has to reach the API as ErrArchiveEncrypted. + if encrypted7z(err) { + return errors.Wrap(ErrArchiveEncrypted, "7z archive") + } + + return err + } + } + + return nil +} + +func extract7zEntry(f *sevenzip.File, s *sink) error { + rc, err := f.Open() + if err != nil { + return errors.Wrapf(err, "failed to open 7z entry %q", f.Name) + } + defer rc.Close() + + // Like zip, a unix symlink is stored as an entry whose body is the link + // target. + if f.Mode()&os.ModeSymlink != 0 { + return putSymlinkFrom(rc, f.Name, s) + } + + return s.putFile(f.Name, f.Mode(), rc) +} + +func extractRar(ctx context.Context, archiveFile *os.File, s *sink) error { + rr, err := rardecode.NewReader(archiveFile, rardecode.MaxDictionarySize(maxRarDictBytes)) + if err != nil { + return wrapArchiveReadErr(err, "rar") + } + + for { + hdr, err := rr.Next() + if errors.Is(err, io.EOF) { + return nil + } + if err != nil { + return wrapArchiveReadErr(err, "rar") + } + + if err := ctx.Err(); err != nil { + return errors.Wrap(err, "extract archive canceled") + } + + if hdr.IsDir { + if err := s.putDir(hdr.Name, hdr.Mode()); err != nil { + return err + } + + continue + } + + if hdr.Mode()&os.ModeSymlink != 0 { + if err := extractRarSymlink(hdr, rr, s); err != nil { + return err + } + + continue + } + + // The rar reader is sequential: putFile consumes the current entry + // body (or drains it when the entry is skipped). + if err := s.putFile(hdr.Name, hdr.Mode(), rr); err != nil { + return err + } + } +} + +// extractRarSymlink materializes a RAR4 unix symlink entry. RAR5 redirection +// records are not exposed by rardecode, so those symlinks are still extracted +// as files. +func extractRarSymlink(hdr *rardecode.FileHeader, rr io.Reader, s *sink) error { + return putSymlinkFrom(rr, hdr.Name, s) +} + +// wrapArchiveReadErr turns a decoder failure into the operation error. Both +// decoders can tell "this archive is encrypted" apart from "this archive is +// broken", and the API needs that distinction to prompt for a password +// instead of showing a generic read failure. +func wrapArchiveReadErr(err error, format string) error { + if errors.Is(err, rardecode.ErrArchiveEncrypted) || encrypted7z(err) { + return errors.Wrapf(ErrArchiveEncrypted, "%s archive", format) + } + + return errors.Wrapf(err, "failed to read %s archive", format) +} + +// encrypted7z reports a sevenzip failure caused by missing decryption. The +// decoder hands its read errors out as *ReadError, so the target has to be the +// pointer type — matching the value never fires. +func encrypted7z(err error) bool { + var readErr *sevenzip.ReadError + + return errors.As(err, &readErr) && readErr.Encrypted +} diff --git a/internal/app/archive/extract_test.go b/internal/app/archive/extract_test.go new file mode 100644 index 0000000..eb0ed30 --- /dev/null +++ b/internal/app/archive/extract_test.go @@ -0,0 +1,701 @@ +package archive + +import ( + "archive/zip" + "bytes" + "context" + "encoding/binary" + "hash/crc32" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + pb "github.com/gameap/gameap/pkg/proto" +) + +func TestExtract7zFixture(t *testing.T) { + workDir := t.TempDir() + copyFixture(t, workDir, "test.7z") + + res, err := Extract(context.Background(), workDir, &pb.ExtractArchiveParams{ + ArchivePath: "test.7z", + Destination: "dst", + Format: pb.ArchiveFormat_ARCHIVE_FORMAT_7Z, + CreateDestination: true, + }, nil) + require.NoError(t, err) + assert.Equal(t, int64(2), res.FilesProcessed) + + assert.Equal(t, map[string]string{ + "bar": "bar\n", + "foo": "foo\n", + }, readTree(t, filepath.Join(workDir, "dst"))) +} + +func TestExtractRarFixture(t *testing.T) { + workDir := t.TempDir() + copyFixture(t, workDir, "test.rar") + + res, err := Extract(context.Background(), workDir, &pb.ExtractArchiveParams{ + ArchivePath: "test.rar", + Destination: "dst", + Format: pb.ArchiveFormat_ARCHIVE_FORMAT_RAR, + CreateDestination: true, + }, nil) + require.NoError(t, err) + assert.Equal(t, int64(2), res.FilesProcessed) + + assert.Equal(t, map[string]string{ + "hello.txt": "Hello, RAR!\n", + "subdir/nested.txt": "nested rar content\n", + }, readTree(t, filepath.Join(workDir, "dst"))) +} + +// buildZip writes a zip archive with the given raw entry names (no +// sanitization, so malicious names can be constructed). +func buildZip(t *testing.T, path string, entries map[string]string) { + t.Helper() + + f, err := os.Create(path) + require.NoError(t, err) + + zw := zip.NewWriter(f) + for name, content := range entries { + w, err := zw.Create(name) + require.NoError(t, err) + _, err = w.Write([]byte(content)) + require.NoError(t, err) + } + + require.NoError(t, zw.Close()) + require.NoError(t, f.Close()) +} + +// zipEntry is one entry for buildZipModes, carrying a full os.FileMode so +// symlink and directory entries can be constructed. +type zipEntry struct { + name string + mode os.FileMode + body string +} + +// buildZipModes writes a zip preserving entry order, so entries that depend on +// earlier ones (a symlink into a directory unpacked before it) behave the way +// they would in a real archive. +func buildZipModes(t *testing.T, path string, entries []zipEntry) { + t.Helper() + + f, err := os.Create(path) + require.NoError(t, err) + + zw := zip.NewWriter(f) + for _, e := range entries { + hdr := &zip.FileHeader{Name: e.name, Method: zip.Store} + hdr.SetMode(e.mode) + + w, createErr := zw.CreateHeader(hdr) + require.NoError(t, createErr) + _, writeErr := w.Write([]byte(e.body)) + require.NoError(t, writeErr) + } + + require.NoError(t, zw.Close()) + require.NoError(t, f.Close()) +} + +// TestExtractSymlinkChainEscape covers a target that a purely lexical check +// accepts: "a/b/l/../../../x" cleans to "dst/x", but "a/b/l" is itself a link +// to "../..", so the kernel walks the path out of the work directory. +func TestExtractSymlinkChainEscape(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("symlink creation needs privileges on windows") + } + + workDir := t.TempDir() + buildZipModes(t, filepath.Join(workDir, "chain.zip"), []zipEntry{ + {name: "a/b/", mode: os.ModeDir | 0o755}, + {name: "a/b/l", mode: os.ModeSymlink | 0o777, body: "../.."}, + {name: "esc", mode: os.ModeSymlink | 0o777, body: "a/b/l/../../../x"}, + }) + + _, err := Extract(context.Background(), workDir, &pb.ExtractArchiveParams{ + ArchivePath: "chain.zip", + Destination: "dst", + Format: pb.ArchiveFormat_ARCHIVE_FORMAT_ZIP, + CreateDestination: true, + }, nil) + require.Error(t, err) + assert.Contains(t, err.Error(), `"esc"`) + + link, readErr := os.Readlink(filepath.Join(workDir, "dst", "esc")) + assert.Error(t, readErr, "escaping symlink must not be created, points at %q", link) +} + +// TestExtractSymlinkChainWithinDestination is the counterpart: the same kind of +// chain must keep working as long as it stays inside the destination. +func TestExtractSymlinkChainWithinDestination(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("symlink creation needs privileges on windows") + } + + workDir := t.TempDir() + buildZipModes(t, filepath.Join(workDir, "chain.zip"), []zipEntry{ + {name: "a/b/", mode: os.ModeDir | 0o755}, + {name: "a/b/l", mode: os.ModeSymlink | 0o777, body: "../.."}, + {name: "ok", mode: os.ModeSymlink | 0o777, body: "a/b/l/payload.txt"}, + {name: "payload.txt", mode: 0o644, body: "payload"}, + }) + + _, err := Extract(context.Background(), workDir, &pb.ExtractArchiveParams{ + ArchivePath: "chain.zip", + Destination: "dst", + Format: pb.ArchiveFormat_ARCHIVE_FORMAT_ZIP, + CreateDestination: true, + }, nil) + require.NoError(t, err) + + content, err := os.ReadFile(filepath.Join(workDir, "dst", "ok")) + require.NoError(t, err) + assert.Equal(t, "payload", string(content), "a chain resolving inside the destination must still work") +} + +// TestExtractSymlinkEscapeThroughLaterEntry covers the same escape built the +// other way round: "esc" is stored while "a/b" is still missing, so its target +// resolves literally to "dst/c", and only the entry after it turns "a/b" into +// the link that walks the finished tree out of the work directory. +func TestExtractSymlinkEscapeThroughLaterEntry(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("symlink creation needs privileges on windows") + } + + workDir := t.TempDir() + buildZipModes(t, filepath.Join(workDir, "chain.zip"), []zipEntry{ + {name: "esc", mode: os.ModeSymlink | 0o777, body: "a/b/../../c"}, + {name: "a/b", mode: os.ModeSymlink | 0o777, body: ".."}, + }) + + _, err := Extract(context.Background(), workDir, &pb.ExtractArchiveParams{ + ArchivePath: "chain.zip", + Destination: "dst", + Format: pb.ArchiveFormat_ARCHIVE_FORMAT_ZIP, + CreateDestination: true, + }, nil) + require.Error(t, err) + + link, readErr := os.Readlink(filepath.Join(workDir, "dst", "esc")) + assert.Error(t, readErr, "escaping symlink must not survive the run, points at %q", link) +} + +// TestExtractSymlinkEscapeThroughOverwrittenDirectory is the overwrite variant: +// "a/b" is a directory when "esc" is checked against it and a symlink by the +// time the run ends. +func TestExtractSymlinkEscapeThroughOverwrittenDirectory(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("symlink creation needs privileges on windows") + } + + workDir := t.TempDir() + buildZipModes(t, filepath.Join(workDir, "chain.zip"), []zipEntry{ + {name: "a/b/", mode: os.ModeDir | 0o755}, + {name: "esc", mode: os.ModeSymlink | 0o777, body: "a/b/../../c"}, + {name: "a/b", mode: os.ModeSymlink | 0o777, body: ".."}, + }) + + _, err := Extract(context.Background(), workDir, &pb.ExtractArchiveParams{ + ArchivePath: "chain.zip", + Destination: "dst", + Format: pb.ArchiveFormat_ARCHIVE_FORMAT_ZIP, + CreateDestination: true, + ConflictPolicy: pb.ArchiveConflictPolicy_ARCHIVE_CONFLICT_POLICY_OVERWRITE, + }, nil) + require.Error(t, err) + + link, readErr := os.Readlink(filepath.Join(workDir, "dst", "esc")) + assert.Error(t, readErr, "escaping symlink must not survive the run, points at %q", link) +} + +// TestExtractSymlinkEscapeThroughSymlinkedParent covers the link that is not +// stored where its name says: "s1" and "s2" both resolve back to the +// destination, so os.Root puts "l" directly under it and its "../.." reaches +// above the work directory, however deep the entry name looks. +func TestExtractSymlinkEscapeThroughSymlinkedParent(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("symlink creation needs privileges on windows") + } + + workDir := t.TempDir() + buildZipModes(t, filepath.Join(workDir, "parent.zip"), []zipEntry{ + {name: "s1", mode: os.ModeSymlink | 0o777, body: "."}, + {name: "s1/s2", mode: os.ModeSymlink | 0o777, body: "."}, + {name: "s1/s2/l", mode: os.ModeSymlink | 0o777, body: "../../y"}, + }) + + _, err := Extract(context.Background(), workDir, &pb.ExtractArchiveParams{ + ArchivePath: "parent.zip", + Destination: "dst", + Format: pb.ArchiveFormat_ARCHIVE_FORMAT_ZIP, + CreateDestination: true, + }, nil) + require.Error(t, err) + assert.Contains(t, err.Error(), `"s1/s2/l"`) + + link, readErr := os.Readlink(filepath.Join(workDir, "dst", "l")) + assert.Error(t, readErr, "escaping symlink must not be created, points at %q", link) +} + +// TestExtractOversizedSymlinkTarget pins that a target past the cap is reported +// instead of silently truncated into a link pointing somewhere else entirely. +func TestExtractOversizedSymlinkTarget(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("symlink creation needs privileges on windows") + } + + workDir := t.TempDir() + buildZipModes(t, filepath.Join(workDir, "big.zip"), []zipEntry{ + {name: "link", mode: os.ModeSymlink | 0o777, body: strings.Repeat("a", maxLinkTargetBytes+1)}, + }) + + _, err := Extract(context.Background(), workDir, &pb.ExtractArchiveParams{ + ArchivePath: "big.zip", + Destination: "dst", + Format: pb.ArchiveFormat_ARCHIVE_FORMAT_ZIP, + CreateDestination: true, + }, nil) + require.Error(t, err) + assert.Contains(t, err.Error(), "target limit") +} + +func TestExtractCorruptedArchives(t *testing.T) { + for _, tc := range []struct { + name string + fixture string + format pb.ArchiveFormat + }{ + {"7z", "test.7z", pb.ArchiveFormat_ARCHIVE_FORMAT_7Z}, + {"rar", "test.rar", pb.ArchiveFormat_ARCHIVE_FORMAT_RAR}, + } { + t.Run(tc.name, func(t *testing.T) { + workDir := t.TempDir() + copyFixture(t, workDir, tc.fixture) + + p := filepath.Join(workDir, tc.fixture) + data, err := os.ReadFile(p) + require.NoError(t, err) + + // Keep the signature so the decoder commits to parsing, then feed + // it garbage: this must surface as an error, never a panic. + for i := 8; i < len(data); i++ { + data[i] ^= 0xFF + } + require.NoError(t, os.WriteFile(p, data, 0o644)) + + _, err = Extract(context.Background(), workDir, &pb.ExtractArchiveParams{ + ArchivePath: tc.fixture, + Destination: "dst", + Format: tc.format, + CreateDestination: true, + }, nil) + require.Error(t, err) + }) + } +} + +// rar4Entry is one stored (uncompressed) RAR4 file entry; attr is the unix +// st_mode value reported in the header (host OS is always unix here). +type rar4Entry struct { + name string + attr uint32 + data []byte +} + +// buildRar4 writes a minimal RAR4 archive with stored entries: marker block, +// main header, one file header per entry and an end-of-archive block. There +// is no RAR encoder in the module dependencies, so the bytes are assembled +// by hand following the layout rardecode parses. +func buildRar4(t *testing.T, path string, entries []rar4Entry) { + t.Helper() + + var buf bytes.Buffer + buf.Write([]byte{0x52, 0x61, 0x72, 0x21, 0x1A, 0x07, 0x00}) // RAR4 marker block + writeRar4Block(&buf, 0x73, 0, make([]byte, 6)) // main archive header + + for _, e := range entries { + hdr := make([]byte, 0, 25+len(e.name)) + hdr = binary.LittleEndian.AppendUint32(hdr, uint32(len(e.data))) // packed size + hdr = binary.LittleEndian.AppendUint32(hdr, uint32(len(e.data))) // unpacked size + hdr = append(hdr, 3) // host OS: unix + hdr = binary.LittleEndian.AppendUint32(hdr, crc32.ChecksumIEEE(e.data)) + hdr = binary.LittleEndian.AppendUint32(hdr, 0) // modification time (dos format) + hdr = append(hdr, 20) // minimum rar version to extract + hdr = append(hdr, 0x30) // method: store + hdr = binary.LittleEndian.AppendUint16(hdr, uint16(len(e.name))) + hdr = binary.LittleEndian.AppendUint32(hdr, e.attr) + hdr = append(hdr, e.name...) + + writeRar4Block(&buf, 0x74, 0x8000, hdr) // 0x8000: entry data follows the header + buf.Write(e.data) + } + + writeRar4Block(&buf, 0x7B, 0, nil) // end of archive + + require.NoError(t, os.WriteFile(path, buf.Bytes(), 0o644)) +} + +// writeRar4Block appends one RAR4 block: crc16 (low bits of the CRC32 over +// type..end), type, flags, header size and the header body. +func writeRar4Block(buf *bytes.Buffer, btype byte, flags uint16, data []byte) { + body := make([]byte, 0, 5+len(data)) + body = append(body, btype) + body = binary.LittleEndian.AppendUint16(body, flags) + body = binary.LittleEndian.AppendUint16(body, uint16(7+len(data))) + body = append(body, data...) + + buf.Write(binary.LittleEndian.AppendUint16(nil, uint16(crc32.ChecksumIEEE(body)))) + buf.Write(body) +} + +func TestExtractRarSymlink(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("symlink creation needs privileges on windows") + } + + workDir := t.TempDir() + buildRar4(t, filepath.Join(workDir, "links.rar"), []rar4Entry{ + {name: "target.txt", attr: 0x81A4, data: []byte("linked content\n")}, // regular 0644 + {name: "link.txt", attr: 0xA1FF, data: []byte("target.txt")}, // symlink 0777 + }) + + res, err := Extract(context.Background(), workDir, &pb.ExtractArchiveParams{ + ArchivePath: "links.rar", + Destination: "dst", + Format: pb.ArchiveFormat_ARCHIVE_FORMAT_RAR, + CreateDestination: true, + }, nil) + require.NoError(t, err) + assert.Equal(t, int64(2), res.FilesProcessed) + + link, err := os.Readlink(filepath.Join(workDir, "dst", "link.txt")) + require.NoError(t, err) + assert.Equal(t, "target.txt", link, "rar symlink entry must be extracted as a symlink, not a regular file") + + content, err := os.ReadFile(filepath.Join(workDir, "dst", "target.txt")) + require.NoError(t, err) + assert.Equal(t, "linked content\n", string(content)) +} + +func TestExtractZipSlip(t *testing.T) { + t.Run("dotdot entry", func(t *testing.T) { + workDir := t.TempDir() + require.NoError(t, os.Mkdir(filepath.Join(workDir, "dst"), 0o755)) + buildZip(t, filepath.Join(workDir, "evil.zip"), map[string]string{"../evil.txt": "pwned"}) + + _, err := Extract(context.Background(), workDir, &pb.ExtractArchiveParams{ + ArchivePath: "evil.zip", + Destination: "dst", + Format: pb.ArchiveFormat_ARCHIVE_FORMAT_ZIP, + }, nil) + require.Error(t, err) + assert.Contains(t, err.Error(), "escapes the destination") + + _, statErr := os.Stat(filepath.Join(workDir, "evil.txt")) + assert.True(t, os.IsNotExist(statErr), "zip-slip file must not be created outside the destination") + }) + + t.Run("absolute entry", func(t *testing.T) { + workDir := t.TempDir() + require.NoError(t, os.Mkdir(filepath.Join(workDir, "dst"), 0o755)) + buildZip(t, filepath.Join(workDir, "evil.zip"), map[string]string{"/abs.txt": "pwned"}) + + _, err := Extract(context.Background(), workDir, &pb.ExtractArchiveParams{ + ArchivePath: "evil.zip", + Destination: "dst", + Format: pb.ArchiveFormat_ARCHIVE_FORMAT_ZIP, + }, nil) + require.Error(t, err) + assert.Contains(t, err.Error(), "escapes the destination") + }) + + t.Run("backslash dotdot entry", func(t *testing.T) { + workDir := t.TempDir() + require.NoError(t, os.Mkdir(filepath.Join(workDir, "dst"), 0o755)) + buildZip(t, filepath.Join(workDir, "evil.zip"), map[string]string{`..\evil.txt`: "pwned"}) + + _, err := Extract(context.Background(), workDir, &pb.ExtractArchiveParams{ + ArchivePath: "evil.zip", + Destination: "dst", + Format: pb.ArchiveFormat_ARCHIVE_FORMAT_ZIP, + }, nil) + require.Error(t, err) + assert.Contains(t, err.Error(), "escapes the destination") + }) +} + +func TestExtractConflictPolicy(t *testing.T) { + buildArchive := func(t *testing.T, workDir string) { + t.Helper() + writeTree(t, workDir, map[string]string{"src/exists.txt": "new", "src/fresh.txt": "fresh"}) + _, err := Create(context.Background(), workDir, &pb.CreateArchiveParams{ + ArchivePath: "out.zip", + Format: pb.ArchiveFormat_ARCHIVE_FORMAT_ZIP, + Sources: []string{"src"}, + }, nil) + require.NoError(t, err) + } + + prepareDst := func(t *testing.T, workDir string) { + t.Helper() + writeTree(t, workDir, map[string]string{"dst/src/exists.txt": "old"}) + } + + extract := func(t *testing.T, workDir string, policy pb.ArchiveConflictPolicy) (*Result, error) { + t.Helper() + + return Extract(context.Background(), workDir, &pb.ExtractArchiveParams{ + ArchivePath: "out.zip", + Destination: "dst", + Format: pb.ArchiveFormat_ARCHIVE_FORMAT_ZIP, + ConflictPolicy: policy, + }, nil) + } + + conflictingContent := func(t *testing.T, workDir string) string { + t.Helper() + content, err := os.ReadFile(filepath.Join(workDir, "dst", "src", "exists.txt")) + require.NoError(t, err) + + return string(content) + } + + t.Run("error is the default", func(t *testing.T) { + workDir := t.TempDir() + buildArchive(t, workDir) + prepareDst(t, workDir) + + _, err := extract(t, workDir, pb.ArchiveConflictPolicy_ARCHIVE_CONFLICT_POLICY_UNSPECIFIED) + require.Error(t, err) + assert.Contains(t, err.Error(), "already exists") + assert.Equal(t, "old", conflictingContent(t, workDir)) + }) + + t.Run("error policy", func(t *testing.T) { + workDir := t.TempDir() + buildArchive(t, workDir) + prepareDst(t, workDir) + + _, err := extract(t, workDir, pb.ArchiveConflictPolicy_ARCHIVE_CONFLICT_POLICY_ERROR) + require.Error(t, err) + assert.Equal(t, "old", conflictingContent(t, workDir)) + }) + + t.Run("skip policy", func(t *testing.T) { + workDir := t.TempDir() + buildArchive(t, workDir) + prepareDst(t, workDir) + + res, err := extract(t, workDir, pb.ArchiveConflictPolicy_ARCHIVE_CONFLICT_POLICY_SKIP) + require.NoError(t, err) + assert.Equal(t, []string{"src/exists.txt"}, res.Skipped) + assert.Equal(t, "old", conflictingContent(t, workDir)) + assert.Equal(t, "fresh", readTree(t, filepath.Join(workDir, "dst"))["src/fresh.txt"], + "non-conflicting entries must still be extracted") + }) + + t.Run("overwrite policy", func(t *testing.T) { + workDir := t.TempDir() + buildArchive(t, workDir) + prepareDst(t, workDir) + + res, err := extract(t, workDir, pb.ArchiveConflictPolicy_ARCHIVE_CONFLICT_POLICY_OVERWRITE) + require.NoError(t, err) + assert.Empty(t, res.Skipped) + assert.Equal(t, "new", conflictingContent(t, workDir)) + }) +} + +func TestExtractLimits(t *testing.T) { + setup := func(t *testing.T, workDir string) { + t.Helper() + writeTree(t, workDir, map[string]string{"src/a.txt": "aaaa", "src/b.txt": "bbbb"}) + _, err := Create(context.Background(), workDir, &pb.CreateArchiveParams{ + ArchivePath: "out.zip", + Format: pb.ArchiveFormat_ARCHIVE_FORMAT_ZIP, + Sources: []string{"src"}, + }, nil) + require.NoError(t, err) + } + + t.Run("max total bytes", func(t *testing.T) { + workDir := t.TempDir() + setup(t, workDir) + + _, err := Extract(context.Background(), workDir, &pb.ExtractArchiveParams{ + ArchivePath: "out.zip", + Destination: "dst", + Format: pb.ArchiveFormat_ARCHIVE_FORMAT_ZIP, + CreateDestination: true, + MaxTotalBytes: 1, + }, nil) + require.Error(t, err) + assert.Contains(t, err.Error(), "max total bytes limit exceeded") + }) + + t.Run("max files", func(t *testing.T) { + workDir := t.TempDir() + setup(t, workDir) + + _, err := Extract(context.Background(), workDir, &pb.ExtractArchiveParams{ + ArchivePath: "out.zip", + Destination: "dst", + Format: pb.ArchiveFormat_ARCHIVE_FORMAT_ZIP, + CreateDestination: true, + MaxFiles: 1, + }, nil) + require.Error(t, err) + assert.Contains(t, err.Error(), "max files limit exceeded") + }) +} + +func TestExtractDestination(t *testing.T) { + setup := func(t *testing.T, workDir string) { + t.Helper() + writeTree(t, workDir, map[string]string{"a.txt": "a"}) + _, err := Create(context.Background(), workDir, &pb.CreateArchiveParams{ + ArchivePath: "out.zip", + Format: pb.ArchiveFormat_ARCHIVE_FORMAT_ZIP, + Sources: []string{"a.txt"}, + }, nil) + require.NoError(t, err) + } + + t.Run("missing destination without create flag", func(t *testing.T) { + workDir := t.TempDir() + setup(t, workDir) + + _, err := Extract(context.Background(), workDir, &pb.ExtractArchiveParams{ + ArchivePath: "out.zip", + Destination: "missing", + Format: pb.ArchiveFormat_ARCHIVE_FORMAT_ZIP, + }, nil) + require.Error(t, err) + assert.Contains(t, err.Error(), "does not exist") + }) + + t.Run("missing destination with create flag", func(t *testing.T) { + workDir := t.TempDir() + setup(t, workDir) + + _, err := Extract(context.Background(), workDir, &pb.ExtractArchiveParams{ + ArchivePath: "out.zip", + Destination: "missing/nested", + Format: pb.ArchiveFormat_ARCHIVE_FORMAT_ZIP, + CreateDestination: true, + }, nil) + require.NoError(t, err) + assert.Equal(t, map[string]string{"a.txt": "a"}, readTree(t, filepath.Join(workDir, "missing", "nested"))) + }) + + t.Run("destination is a file", func(t *testing.T) { + workDir := t.TempDir() + setup(t, workDir) + + _, err := Extract(context.Background(), workDir, &pb.ExtractArchiveParams{ + ArchivePath: "out.zip", + Destination: "a.txt", + Format: pb.ArchiveFormat_ARCHIVE_FORMAT_ZIP, + }, nil) + require.Error(t, err) + assert.Contains(t, err.Error(), "not a directory") + }) + + t.Run("unspecified format is detected", func(t *testing.T) { + workDir := t.TempDir() + setup(t, workDir) + + res, err := Extract(context.Background(), workDir, &pb.ExtractArchiveParams{ + ArchivePath: "out.zip", + Destination: "dst", + Format: pb.ArchiveFormat_ARCHIVE_FORMAT_UNSPECIFIED, + CreateDestination: true, + }, nil) + require.NoError(t, err) + assert.Equal(t, pb.ArchiveFormat_ARCHIVE_FORMAT_ZIP, res.Format) + assert.Equal(t, map[string]string{"a.txt": "a"}, readTree(t, filepath.Join(workDir, "dst"))) + }) +} + +func TestExtractModeOverride(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("unix permission bits are not supported on windows") + } + + workDir := t.TempDir() + writeTree(t, workDir, map[string]string{"a.txt": "a"}) + _, err := Create(context.Background(), workDir, &pb.CreateArchiveParams{ + ArchivePath: "out.tar", + Format: pb.ArchiveFormat_ARCHIVE_FORMAT_TAR, + Sources: []string{"a.txt"}, + }, nil) + require.NoError(t, err) + + _, err = Extract(context.Background(), workDir, &pb.ExtractArchiveParams{ + ArchivePath: "out.tar", + Destination: "dst", + Format: pb.ArchiveFormat_ARCHIVE_FORMAT_TAR, + CreateDestination: true, + Mode: 0o600, + }, nil) + require.NoError(t, err) + + info, err := os.Stat(filepath.Join(workDir, "dst", "a.txt")) + require.NoError(t, err) + assert.Equal(t, os.FileMode(0o600), info.Mode().Perm(), "mode must override archive permissions") +} + +func TestExtractPreservePermissions(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("unix permission bits are not supported on windows") + } + + workDir := t.TempDir() + writeTree(t, workDir, map[string]string{"script.sh": "#!/bin/sh\n"}) + require.NoError(t, os.Chmod(filepath.Join(workDir, "script.sh"), 0o750)) + + _, err := Create(context.Background(), workDir, &pb.CreateArchiveParams{ + ArchivePath: "out.tar", + Format: pb.ArchiveFormat_ARCHIVE_FORMAT_TAR, + Sources: []string{"script.sh"}, + }, nil) + require.NoError(t, err) + + _, err = Extract(context.Background(), workDir, &pb.ExtractArchiveParams{ + ArchivePath: "out.tar", + Destination: "dst", + Format: pb.ArchiveFormat_ARCHIVE_FORMAT_TAR, + CreateDestination: true, + PreservePermissions: true, + }, nil) + require.NoError(t, err) + + info, err := os.Stat(filepath.Join(workDir, "dst", "script.sh")) + require.NoError(t, err) + assert.Equal(t, os.FileMode(0o750), info.Mode().Perm(), "archive permissions must be preserved") +} + +func TestExtractCanceledContext(t *testing.T) { + workDir := t.TempDir() + copyFixture(t, workDir, "test.7z") + + _, err := Extract(canceledContext(t), workDir, &pb.ExtractArchiveParams{ + ArchivePath: "test.7z", + Destination: "dst", + Format: pb.ArchiveFormat_ARCHIVE_FORMAT_7Z, + CreateDestination: true, + }, nil) + require.Error(t, err) + assert.ErrorIs(t, err, context.Canceled) +} diff --git a/internal/app/archive/format.go b/internal/app/archive/format.go new file mode 100644 index 0000000..10e6422 --- /dev/null +++ b/internal/app/archive/format.go @@ -0,0 +1,195 @@ +package archive + +import ( + "compress/gzip" + "strings" + + "github.com/pkg/errors" + + dsbzip2 "github.com/dsnet/compress/bzip2" + pb "github.com/gameap/gameap/pkg/proto" + "github.com/klauspost/compress/zstd" +) + +// formatClass groups archive formats by the code path that handles them. +type formatClass int + +const ( + classZip formatClass = iota + 1 + classTar // plain tar and tar wrapped into a compressor stream + classSingle // gz/bz2/xz/zstd compressing one bare file + class7z + classRar +) + +// compression identifies the stream compressor wrapped around a tar stream or +// used for a single-file format. +type compression int + +const ( + compNone compression = iota + compGzip + compBzip2 + compXz + compZstd +) + +func classify(format pb.ArchiveFormat) (formatClass, error) { + switch format { + case pb.ArchiveFormat_ARCHIVE_FORMAT_ZIP: + return classZip, nil + case pb.ArchiveFormat_ARCHIVE_FORMAT_TAR, + pb.ArchiveFormat_ARCHIVE_FORMAT_TAR_GZ, + pb.ArchiveFormat_ARCHIVE_FORMAT_TAR_BZ2, + pb.ArchiveFormat_ARCHIVE_FORMAT_TAR_XZ, + pb.ArchiveFormat_ARCHIVE_FORMAT_TAR_ZSTD: + return classTar, nil + case pb.ArchiveFormat_ARCHIVE_FORMAT_GZ, + pb.ArchiveFormat_ARCHIVE_FORMAT_BZ2, + pb.ArchiveFormat_ARCHIVE_FORMAT_XZ, + pb.ArchiveFormat_ARCHIVE_FORMAT_ZSTD: + return classSingle, nil + case pb.ArchiveFormat_ARCHIVE_FORMAT_7Z: + return class7z, nil + case pb.ArchiveFormat_ARCHIVE_FORMAT_RAR: + return classRar, nil + default: + return 0, errors.Errorf("unsupported archive format: %s", format) + } +} + +// classifyForCreate rejects formats the daemon cannot write. +func classifyForCreate(format pb.ArchiveFormat) (formatClass, error) { + if format == pb.ArchiveFormat_ARCHIVE_FORMAT_UNSPECIFIED { + return 0, errors.New("archive format is unspecified") + } + + class, err := classify(format) + if err != nil { + return 0, err + } + + if class == class7z || class == classRar { + return 0, errors.Errorf("archive format %s is extract-only", format) + } + + return class, nil +} + +func tarCompression(format pb.ArchiveFormat) compression { + switch format { + case pb.ArchiveFormat_ARCHIVE_FORMAT_TAR_GZ: + return compGzip + case pb.ArchiveFormat_ARCHIVE_FORMAT_TAR_BZ2: + return compBzip2 + case pb.ArchiveFormat_ARCHIVE_FORMAT_TAR_XZ: + return compXz + case pb.ArchiveFormat_ARCHIVE_FORMAT_TAR_ZSTD: + return compZstd + default: + return compNone + } +} + +func singleCompression(format pb.ArchiveFormat) compression { + switch format { + case pb.ArchiveFormat_ARCHIVE_FORMAT_GZ: + return compGzip + case pb.ArchiveFormat_ARCHIVE_FORMAT_BZ2: + return compBzip2 + case pb.ArchiveFormat_ARCHIVE_FORMAT_XZ: + return compXz + default: + return compZstd + } +} + +// singleSuffix maps a single-file format to its conventional file suffix. +func singleSuffix(format pb.ArchiveFormat) string { + switch format { + case pb.ArchiveFormat_ARCHIVE_FORMAT_GZ: + return ".gz" + case pb.ArchiveFormat_ARCHIVE_FORMAT_BZ2: + return ".bz2" + case pb.ArchiveFormat_ARCHIVE_FORMAT_XZ: + return ".xz" + default: + return ".zst" + } +} + +// singleOutputName derives the extracted file name for a single-file format: +// the archive base name minus its compression suffix, or ".out" when +// the archive name carries no known suffix. +func singleOutputName(archiveName string, format pb.ArchiveFormat) string { + if base, ok := strings.CutSuffix(archiveName, singleSuffix(format)); ok && base != "" { + return base + } + + return archiveName + ".out" +} + +// gzipLevel maps the proto compression level onto compress/gzip levels: +// unset = format default, 0 = store (NoCompression), 1..9 passed through. +func gzipLevel(level *int32) int { + if level == nil { + return gzip.DefaultCompression + } + + return clampLevel(*level) +} + +// bzip2Level maps the proto compression level onto dsnet bzip2 levels. The +// format cannot store, so a store request degrades to the fastest level. +func bzip2Level(level *int32) int { + if level == nil { + return dsbzip2.DefaultCompression + } + if *level == 0 { + return dsbzip2.BestSpeed + } + + return clampLevel(*level) +} + +// zstdLevel maps the proto compression level onto klauspost zstd encoder +// levels. The format cannot store, so a store request degrades to the +// fastest level. +func zstdLevel(level *int32) zstd.EncoderLevel { + if level == nil { + return zstd.SpeedDefault + } + + switch { + case *level <= 3: + return zstd.SpeedFastest + case *level <= 6: + return zstd.SpeedDefault + case *level <= 8: + return zstd.SpeedBetterCompression + default: + return zstd.SpeedBestCompression + } +} + +// flateLevel maps the proto compression level onto compress/flate levels for +// zip deflate entries. xz is not mapped at all: the xz format has no +// compression levels (only dictionary presets), so the level is ignored there. +func flateLevel(level *int32) int { + if level == nil { + return gzip.DefaultCompression + } + + return clampLevel(*level) +} + +func clampLevel(level int32) int { + if level < 0 { + return 0 + } + if level > 9 { + return 9 + } + + return int(level) +} diff --git a/internal/app/archive/helpers_test.go b/internal/app/archive/helpers_test.go new file mode 100644 index 0000000..5974080 --- /dev/null +++ b/internal/app/archive/helpers_test.go @@ -0,0 +1,88 @@ +package archive + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" +) + +// writeTree creates files (and their parent directories) under dir. Map keys +// are slash-separated relative paths. +func writeTree(t *testing.T, dir string, files map[string]string) { + t.Helper() + + for name, content := range files { + p := filepath.Join(dir, filepath.FromSlash(name)) + require.NoError(t, os.MkdirAll(filepath.Dir(p), 0o755)) + require.NoError(t, os.WriteFile(p, []byte(content), 0o644)) + } +} + +// readTree reads every regular file under dir into a slash-separated +// relative-path map. +func readTree(t *testing.T, dir string) map[string]string { + t.Helper() + + files := map[string]string{} + + err := filepath.Walk(dir, func(p string, info os.FileInfo, err error) error { + if err != nil { + return err + } + if !info.Mode().IsRegular() { + return nil + } + + rel, err := filepath.Rel(dir, p) + if err != nil { + return err + } + + content, err := os.ReadFile(p) + if err != nil { + return err + } + + files[filepath.ToSlash(rel)] = string(content) + + return nil + }) + require.NoError(t, err) + + return files +} + +// copyFixture copies a repo fixture from test/files into the work directory. +func copyFixture(t *testing.T, workDir, name string) { + t.Helper() + + data, err := os.ReadFile(filepath.Join("..", "..", "..", "test", "files", name)) + require.NoError(t, err) + require.NoError(t, os.WriteFile(filepath.Join(workDir, name), data, 0o644)) +} + +type progressRecord struct { + files int64 + bytes int64 + entries []string +} + +func (p *progressRecord) fn() ProgressFunc { + return func(filesProcessed, bytesProcessed int64, currentEntry string) { + p.files = filesProcessed + p.bytes = bytesProcessed + p.entries = append(p.entries, currentEntry) + } +} + +func canceledContext(t *testing.T) context.Context { + t.Helper() + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + return ctx +} diff --git a/internal/app/di/internal/definitions/grpc.go b/internal/app/di/internal/definitions/grpc.go index 69cb286..a65e238 100644 --- a/internal/app/di/internal/definitions/grpc.go +++ b/internal/app/di/internal/definitions/grpc.go @@ -71,6 +71,10 @@ func CreateConnectionManager( ) client.SetTransferHandler(transferHandler) + // 0 selects the handler's own default concurrency. + archiveHandler := grpcclient.NewGRPCArchiveHandler(cfg.WorkPath, client, 0) + client.SetArchiveHandler(archiveHandler) + serverRepo := c.Repositories().ServerRepository(ctx).(*repositories.ServerRepository) attachHandler := grpcclient.NewGRPCAttachHandler( serverRepo, diff --git a/internal/app/grpc/archive_handler.go b/internal/app/grpc/archive_handler.go new file mode 100644 index 0000000..d17f233 --- /dev/null +++ b/internal/app/grpc/archive_handler.go @@ -0,0 +1,329 @@ +package grpc + +import ( + "context" + "sync" + "sync/atomic" + "time" + + daemonarchive "github.com/gameap/daemon/internal/app/archive" + pb "github.com/gameap/gameap/pkg/proto" + "github.com/pkg/errors" + log "github.com/sirupsen/logrus" + "golang.org/x/sync/semaphore" +) + +const ( + defaultMaxConcurrentArchives = 4 + defaultArchiveTimeout = time.Hour + defaultProgressInterval = time.Second + + // minProgressInterval floors the requested reporting rate. Every tick puts + // a message on the shared outbound channel, which drops messages once it is + // full — an unclamped interval would let progress reports starve final + // responses and task statuses. + minProgressInterval = 100 * time.Millisecond + // maxArchiveTimeout caps how long one operation may hold its slot in the + // concurrency semaphore. + maxArchiveTimeout = 24 * time.Hour + + maxSkippedEntries = 1000 +) + +// activeArchive tracks one in-flight archive operation so an ArchiveCancel can +// find it and abort its context. +type activeArchive struct { + cancel context.CancelFunc + reason atomic.Pointer[string] +} + +type archiveProgressState struct { + filesProcessed int64 + bytesProcessed int64 + currentEntry string +} + +type GRPCArchiveHandler struct { + workDir string + responseSender ResponseSender + sem *semaphore.Weighted + activeArchives sync.Map // map[string]*activeArchive +} + +func NewGRPCArchiveHandler(workDir string, responseSender ResponseSender, maxConcurrent int64) *GRPCArchiveHandler { + if maxConcurrent <= 0 { + maxConcurrent = defaultMaxConcurrentArchives + } + + return &GRPCArchiveHandler{ + workDir: workDir, + responseSender: responseSender, + sem: semaphore.NewWeighted(maxConcurrent), + } +} + +// HandleArchiveRequest handles an archive create/extract request from the API. +// The operation runs in the background; progress and the single final +// ArchiveResponse are delivered through the response sender. +func (h *GRPCArchiveHandler) HandleArchiveRequest(ctx context.Context, req *pb.ArchiveRequest) { + requestID := req.GetRequestId() + l := log.WithField("request_id", requestID) + + if requestID == "" { + l.Error("Archive request with empty request_id, dropping") + return + } + + format := archiveRequestFormat(req) + + if req.GetExtract() == nil && req.GetCreate() == nil { + l.Warn("Archive request without extract or create operation") + h.sendResponse(&pb.ArchiveResponse{ + RequestId: requestID, + Error: "extract or create operation required", + Format: format, + }) + return + } + + timeout := req.GetTimeout().AsDuration() + if timeout <= 0 { + timeout = defaultArchiveTimeout + } + if timeout > maxArchiveTimeout { + timeout = maxArchiveTimeout + } + + opCtx, cancel := context.WithTimeout(ctx, timeout) + entry := &activeArchive{cancel: cancel} + + // Registered synchronously so an ArchiveCancel arriving right after this + // request still finds the operation; the goroutine removes the entry. + if _, loaded := h.activeArchives.LoadOrStore(requestID, entry); loaded { + cancel() + l.Warn("Archive request already active, rejecting duplicate") + h.sendResponse(&pb.ArchiveResponse{ + RequestId: requestID, + Error: "archive request already active: " + requestID, + Format: format, + }) + return + } + + l.Info("Handling archive request") + + go h.run(opCtx, entry, requestID, req, format, l) +} + +// HandleArchiveCancel cancels an active archive operation. No response is sent +// here: the operation itself answers with the final ArchiveResponse. +func (h *GRPCArchiveHandler) HandleArchiveCancel(_ context.Context, cancel *pb.ArchiveCancel) { + requestID := cancel.GetRequestId() + + v, ok := h.activeArchives.Load(requestID) + if !ok { + log.WithField("request_id", requestID).Warn("Archive cancel for unknown request") + return + } + + entry := v.(*activeArchive) + if reason := cancel.GetReason(); reason != "" { + entry.reason.Store(&reason) + } + entry.cancel() +} + +func (h *GRPCArchiveHandler) run( + ctx context.Context, + entry *activeArchive, + requestID string, + req *pb.ArchiveRequest, + format pb.ArchiveFormat, + l *log.Entry, +) { + defer h.activeArchives.Delete(requestID) + defer entry.cancel() + + if err := h.sem.Acquire(ctx, 1); err != nil { + l.WithError(err).Warn("Failed to acquire archive semaphore") + h.sendErrorResponse(ctx, entry, requestID, format, err) + return + } + defer h.sem.Release(1) + + var progress atomic.Pointer[archiveProgressState] + progressFn := func(filesProcessed, bytesProcessed int64, currentEntry string) { + progress.Store(&archiveProgressState{ + filesProcessed: filesProcessed, + bytesProcessed: bytesProcessed, + currentEntry: currentEntry, + }) + } + + progressInterval := req.GetProgressInterval().AsDuration() + if progressInterval <= 0 { + progressInterval = defaultProgressInterval + } + if progressInterval < minProgressInterval { + progressInterval = minProgressInterval + } + + progressDone := make(chan struct{}) + progressStopped := make(chan struct{}) + go h.progressLoop(ctx, progressDone, progressStopped, progressInterval, requestID, &progress) + + // Waits for the reporter to actually stop, not just to be told to: the proto + // promises a single final response that ends the operation, and a progress + // message queued after it would reopen an operation the API considers done. + stopProgress := sync.OnceFunc(func() { + close(progressDone) + <-progressStopped + }) + + // Registered last so it runs first (LIFO): the failure response goes out + // before entry.cancel() marks the context canceled, and the remaining + // defers (sem release, cancel, registry delete) still run after recover. + // Registering it only here — after the reporter exists — is what lets the + // panic path join the reporter before it answers, same as the normal one. + defer func() { + if r := recover(); r != nil { + stopProgress() + + err := errors.Errorf("archive operation panicked: %v", r) + l.WithError(err).Error("Archive operation panicked") + h.sendErrorResponse(ctx, entry, requestID, format, err) + } + }() + + var result *daemonarchive.Result + var err error + if create := req.GetCreate(); create != nil { + l.WithField("archive_path", create.GetArchivePath()).Info("Creating archive") + result, err = daemonarchive.Create(ctx, h.workDir, create, progressFn) + } else { + extract := req.GetExtract() + l.WithField("archive_path", extract.GetArchivePath()).Info("Extracting archive") + result, err = daemonarchive.Extract(ctx, h.workDir, extract, progressFn) + } + + stopProgress() + + if err != nil { + l.WithError(err).Warn("Archive operation failed") + h.sendErrorResponse(ctx, entry, requestID, format, err) + return + } + + skipped := result.Skipped + if len(skipped) > maxSkippedEntries { + skipped = skipped[:maxSkippedEntries] + } + + // The proto asks for the format the daemon actually used, which is the + // resolved one when the request left it unspecified. + if result.Format != pb.ArchiveFormat_ARCHIVE_FORMAT_UNSPECIFIED { + format = result.Format + } + + h.sendResponse(&pb.ArchiveResponse{ + RequestId: requestID, + Success: true, + FilesProcessed: uint32(result.FilesProcessed), + BytesProcessed: uint64(result.BytesProcessed), + ArchiveSize: uint64(result.ArchiveSize), + Skipped: skipped, + SkippedCount: uint32(len(result.Skipped)), + Format: format, + }) + + l.WithFields(log.Fields{ + "files_processed": result.FilesProcessed, + "bytes_processed": result.BytesProcessed, + "archive_size": result.ArchiveSize, + "format": format, + }).Info("Archive operation completed") +} + +// progressLoop reports the last progress snapshot on every tick until done is +// closed, then closes stopped. Totals stay at zero (unknown) as the proto +// allows. +func (h *GRPCArchiveHandler) progressLoop( + ctx context.Context, + done, stopped chan struct{}, + interval time.Duration, + requestID string, + progress *atomic.Pointer[archiveProgressState], +) { + defer close(stopped) + + ticker := time.NewTicker(interval) + defer ticker.Stop() + + for { + select { + case <-done: + return + case <-ctx.Done(): + return + case <-ticker.C: + msg := &pb.ArchiveProgress{RequestId: requestID} + if state := progress.Load(); state != nil { + msg.FilesProcessed = uint32(state.filesProcessed) + msg.BytesProcessed = uint64(state.bytesProcessed) + msg.CurrentEntry = state.currentEntry + } + h.responseSender.Send(&pb.DaemonMessage{ + RequestId: requestID, + Payload: &pb.DaemonMessage_ArchiveProgress{ + ArchiveProgress: msg, + }, + }) + } + } +} + +func (h *GRPCArchiveHandler) sendErrorResponse( + ctx context.Context, + entry *activeArchive, + requestID string, + format pb.ArchiveFormat, + err error, +) { + errMsg := err.Error() + + switch { + case errors.Is(ctx.Err(), context.Canceled): + errMsg = "canceled" + if reason := entry.reason.Load(); reason != nil && *reason != "" { + errMsg = "canceled: " + *reason + } + case errors.Is(ctx.Err(), context.DeadlineExceeded): + errMsg = "timeout exceeded" + } + + h.sendResponse(&pb.ArchiveResponse{ + RequestId: requestID, + Error: errMsg, + Format: format, + }) +} + +func (h *GRPCArchiveHandler) sendResponse(resp *pb.ArchiveResponse) { + h.responseSender.Send(&pb.DaemonMessage{ + RequestId: resp.RequestId, + Payload: &pb.DaemonMessage_ArchiveResponse{ + ArchiveResponse: resp, + }, + }) +} + +func archiveRequestFormat(req *pb.ArchiveRequest) pb.ArchiveFormat { + if create := req.GetCreate(); create != nil { + return create.GetFormat() + } + if extract := req.GetExtract(); extract != nil { + return extract.GetFormat() + } + + return pb.ArchiveFormat_ARCHIVE_FORMAT_UNSPECIFIED +} diff --git a/internal/app/grpc/archive_handler_test.go b/internal/app/grpc/archive_handler_test.go new file mode 100644 index 0000000..1c65e01 --- /dev/null +++ b/internal/app/grpc/archive_handler_test.go @@ -0,0 +1,340 @@ +package grpc + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strings" + "sync" + "testing" + "time" + + pb "github.com/gameap/gameap/pkg/proto" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/types/known/durationpb" +) + +type fakeSender struct { + mu sync.Mutex + msgs []*pb.DaemonMessage + + // panicOnResponse makes the first ArchiveResponse blow up inside Send. It + // is the one fault a test can inject on the operation's own goroutine, + // which is what the handler recovers from. + panicOnResponse bool + panicked bool +} + +func (f *fakeSender) Send(msg *pb.DaemonMessage) { + f.mu.Lock() + defer f.mu.Unlock() + + if f.panicOnResponse && !f.panicked && msg.GetArchiveResponse() != nil { + f.panicked = true + + panic("injected sender failure") + } + + f.msgs = append(f.msgs, msg) +} + +func (f *fakeSender) archiveResponses(requestID string) []*pb.ArchiveResponse { + f.mu.Lock() + defer f.mu.Unlock() + + var out []*pb.ArchiveResponse + for _, m := range f.msgs { + if r := m.GetArchiveResponse(); r != nil && r.GetRequestId() == requestID { + out = append(out, r) + } + } + + return out +} + +func (f *fakeSender) allProgress() []*pb.ArchiveProgress { + f.mu.Lock() + defer f.mu.Unlock() + + var out []*pb.ArchiveProgress + for _, m := range f.msgs { + if p := m.GetArchiveProgress(); p != nil { + out = append(out, p) + } + } + + return out +} + +func (f *fakeSender) messageCount() int { + f.mu.Lock() + defer f.mu.Unlock() + return len(f.msgs) +} + +func (f *fakeSender) waitFinalResponse(t *testing.T, requestID string) *pb.ArchiveResponse { + t.Helper() + return f.waitFinalResponses(t, requestID, 1)[0] +} + +func (f *fakeSender) waitFinalResponses(t *testing.T, requestID string, n int) []*pb.ArchiveResponse { + t.Helper() + + deadline := time.Now().Add(5 * time.Second) + for { + resps := f.archiveResponses(requestID) + if len(resps) >= n { + return resps + } + if time.Now().After(deadline) { + t.Fatalf("timed out waiting for %d final ArchiveResponse(s) for %q, got %d", n, requestID, len(resps)) + } + time.Sleep(10 * time.Millisecond) + } +} + +func setupArchiveWorkDir(t *testing.T, files int) string { + t.Helper() + + workDir := t.TempDir() + srcDir := filepath.Join(workDir, "src") + require.NoError(t, os.MkdirAll(srcDir, 0o755)) + + for i := 0; i < files; i++ { + name := filepath.Join(srcDir, fmt.Sprintf("file_%04d.txt", i)) + content := fmt.Sprintf("content of file %d\n", i) + require.NoError(t, os.WriteFile(name, []byte(content), 0o644)) + } + + return workDir +} + +func createArchiveRequest(requestID, archivePath string) *pb.ArchiveRequest { + return &pb.ArchiveRequest{ + RequestId: requestID, + Operation: &pb.ArchiveRequest_Create{ + Create: &pb.CreateArchiveParams{ + ArchivePath: archivePath, + Format: pb.ArchiveFormat_ARCHIVE_FORMAT_ZIP, + BasePath: "src", + Sources: []string{"."}, + }, + }, + } +} + +func TestGRPCArchiveHandler_CreateZip(t *testing.T) { + workDir := setupArchiveWorkDir(t, 2) + + sender := &fakeSender{} + h := NewGRPCArchiveHandler(workDir, sender, 4) + + req := createArchiveRequest("create-1", "out.zip") + req.ProgressInterval = durationpb.New(time.Millisecond) + h.HandleArchiveRequest(context.Background(), req) + + resp := sender.waitFinalResponse(t, "create-1") + require.True(t, resp.Success, resp.Error) + assert.GreaterOrEqual(t, resp.FilesProcessed, uint32(2)) + assert.Greater(t, resp.ArchiveSize, uint64(0)) + assert.Equal(t, pb.ArchiveFormat_ARCHIVE_FORMAT_ZIP, resp.Format) + + for _, p := range sender.allProgress() { + assert.Equal(t, "create-1", p.GetRequestId()) + } +} + +// TestGRPCArchiveHandler_ProgressStopsBeforeResponse pins the proto rule that a +// single final response ends the operation: no progress may be queued after it, +// which means the reporter has to be joined, not merely signalled. +func TestGRPCArchiveHandler_ProgressStopsBeforeResponse(t *testing.T) { + workDir := setupArchiveWorkDir(t, 300) + + sender := &fakeSender{} + h := NewGRPCArchiveHandler(workDir, sender, 4) + + req := createArchiveRequest("progress-1", "out.zip") + req.ProgressInterval = durationpb.New(time.Nanosecond) + h.HandleArchiveRequest(context.Background(), req) + + resp := sender.waitFinalResponse(t, "progress-1") + require.True(t, resp.Success, resp.Error) + + // Give a straggling reporter a chance to show up before asserting. + time.Sleep(200 * time.Millisecond) + + sender.mu.Lock() + defer sender.mu.Unlock() + + seenResponse := false + for _, m := range sender.msgs { + if m.GetRequestId() != "progress-1" { + continue + } + if m.GetArchiveResponse() != nil { + seenResponse = true + + continue + } + if m.GetArchiveProgress() != nil { + assert.False(t, seenResponse, "progress must not be sent after the final response") + } + } + assert.True(t, seenResponse) +} + +// TestGRPCArchiveHandler_PanicAnsweredGracefully drives the recover branch in +// run: a panicking operation must still leave the API with one failed response +// and nothing queued behind it, instead of a request that never ends and a +// daemon that dies with it. +func TestGRPCArchiveHandler_PanicAnsweredGracefully(t *testing.T) { + workDir := setupArchiveWorkDir(t, 100) + + sender := &fakeSender{panicOnResponse: true} + h := NewGRPCArchiveHandler(workDir, sender, 4) + + req := createArchiveRequest("panic-1", "panic.zip") + req.ProgressInterval = durationpb.New(time.Millisecond) + h.HandleArchiveRequest(context.Background(), req) + + resp := sender.waitFinalResponse(t, "panic-1") + assert.False(t, resp.Success) + assert.Contains(t, resp.Error, "panicked") + + // Give a reporter that outlived the recovery a chance to show up. + time.Sleep(200 * time.Millisecond) + + sender.mu.Lock() + defer sender.mu.Unlock() + + seenResponse := false + for _, m := range sender.msgs { + if m.GetRequestId() != "panic-1" { + continue + } + if m.GetArchiveResponse() != nil { + seenResponse = true + + continue + } + if m.GetArchiveProgress() != nil { + assert.False(t, seenResponse, "progress must not be sent after the final response") + } + } + assert.True(t, seenResponse) +} + +func TestGRPCArchiveHandler_ExtractMissingArchive(t *testing.T) { + workDir := t.TempDir() + + sender := &fakeSender{} + h := NewGRPCArchiveHandler(workDir, sender, 4) + + h.HandleArchiveRequest(context.Background(), &pb.ArchiveRequest{ + RequestId: "extract-1", + Operation: &pb.ArchiveRequest_Extract{ + Extract: &pb.ExtractArchiveParams{ + ArchivePath: "missing.zip", + Destination: "dst", + Format: pb.ArchiveFormat_ARCHIVE_FORMAT_ZIP, + }, + }, + }) + + resp := sender.waitFinalResponse(t, "extract-1") + assert.False(t, resp.Success) + assert.NotEmpty(t, resp.Error) +} + +func TestGRPCArchiveHandler_Cancel(t *testing.T) { + workDir := setupArchiveWorkDir(t, 500) + + sender := &fakeSender{} + h := NewGRPCArchiveHandler(workDir, sender, 4) + ctx := context.Background() + + h.HandleArchiveRequest(ctx, createArchiveRequest("cancel-1", "cancel.zip")) + h.HandleArchiveCancel(ctx, &pb.ArchiveCancel{RequestId: "cancel-1", Reason: "test"}) + + resp := sender.waitFinalResponse(t, "cancel-1") + assert.False(t, resp.Success) + assert.Contains(t, resp.Error, "canceled") +} + +func TestGRPCArchiveHandler_DuplicateRejected(t *testing.T) { + workDir := setupArchiveWorkDir(t, 500) + + sender := &fakeSender{} + h := NewGRPCArchiveHandler(workDir, sender, 4) + ctx := context.Background() + + h.HandleArchiveRequest(ctx, createArchiveRequest("dup-1", "dup.zip")) + h.HandleArchiveRequest(ctx, createArchiveRequest("dup-1", "dup2.zip")) + + resps := sender.waitFinalResponses(t, "dup-1", 2) + + var duplicate *pb.ArchiveResponse + for _, r := range resps { + if strings.Contains(r.Error, "already active") { + duplicate = r + } + } + require.NotNil(t, duplicate, "expected an immediate 'already active' rejection") + assert.False(t, duplicate.Success) + assert.Contains(t, duplicate.Error, "dup-1") +} + +func TestGRPCArchiveHandler_CancelUnknown(t *testing.T) { + sender := &fakeSender{} + h := NewGRPCArchiveHandler(t.TempDir(), sender, 4) + + h.HandleArchiveCancel(context.Background(), &pb.ArchiveCancel{RequestId: "nope", Reason: "x"}) + + time.Sleep(100 * time.Millisecond) + assert.Equal(t, 0, sender.messageCount()) +} + +func TestGRPCArchiveHandler_NoOperation(t *testing.T) { + sender := &fakeSender{} + h := NewGRPCArchiveHandler(t.TempDir(), sender, 4) + + h.HandleArchiveRequest(context.Background(), &pb.ArchiveRequest{RequestId: "noop-1"}) + + resp := sender.waitFinalResponse(t, "noop-1") + assert.False(t, resp.Success) + assert.Equal(t, "extract or create operation required", resp.Error) +} + +func TestGRPCArchiveHandler_EmptyRequestIDDropped(t *testing.T) { + sender := &fakeSender{} + h := NewGRPCArchiveHandler(t.TempDir(), sender, 4) + + h.HandleArchiveRequest(context.Background(), &pb.ArchiveRequest{ + Operation: &pb.ArchiveRequest_Create{ + Create: &pb.CreateArchiveParams{ + ArchivePath: "out.zip", + Format: pb.ArchiveFormat_ARCHIVE_FORMAT_ZIP, + }, + }, + }) + + time.Sleep(300 * time.Millisecond) + assert.Equal(t, 0, sender.messageCount()) +} + +func TestGRPCArchiveHandler_Timeout(t *testing.T) { + workDir := setupArchiveWorkDir(t, 100) + + sender := &fakeSender{} + h := NewGRPCArchiveHandler(workDir, sender, 4) + + req := createArchiveRequest("timeout-1", "timeout.zip") + req.Timeout = durationpb.New(time.Nanosecond) + h.HandleArchiveRequest(context.Background(), req) + + resp := sender.waitFinalResponse(t, "timeout-1") + assert.False(t, resp.Success) + assert.Contains(t, resp.Error, "timeout exceeded") +} diff --git a/internal/app/grpc/client.go b/internal/app/grpc/client.go index d9b080f..5b7164d 100644 --- a/internal/app/grpc/client.go +++ b/internal/app/grpc/client.go @@ -59,6 +59,11 @@ type TransferHandler interface { HandleFileDownloadTask(ctx context.Context, requestID string, task *pb.FileDownloadTask) } +type ArchiveHandler interface { + HandleArchiveRequest(ctx context.Context, req *pb.ArchiveRequest) + HandleArchiveCancel(ctx context.Context, cancel *pb.ArchiveCancel) +} + type AttachHandler interface { HandleAttachRequest(ctx context.Context, req *pb.AttachRequest) HandleAttachInput(ctx context.Context, input *pb.AttachInput) @@ -99,6 +104,7 @@ type GatewayClient struct { fileHandler FileHandler serverHandler ServerHandler transferHandler TransferHandler + archiveHandler ArchiveHandler attachHandler AttachHandler consoleLogHandler ConsoleLogHandler httpProxyHandler HTTPProxyHandler @@ -196,10 +202,12 @@ func (c *GatewayClient) register(ctx context.Context) error { registerReq := &pb.DaemonMessage{ Payload: &pb.DaemonMessage_Register{ Register: &pb.RegisterRequest{ - NodeId: uint64(c.cfg.NodeID), - ApiKey: c.cfg.APIKey, - Version: build.Version, - Capabilities: []string{"grpc", "file_transfer", "server_status", "attach", "http_proxy", "metrics"}, + NodeId: uint64(c.cfg.NodeID), + ApiKey: c.cfg.APIKey, + Version: build.Version, + Capabilities: []string{ + "grpc", "file_transfer", "server_status", "attach", "http_proxy", "metrics", "archive", + }, InFlightTasks: inFlightTasks, ServerTaskSnapshotVersion: 0, InFlightServerTaskExecutions: inFlightServerTaskExecutions, @@ -476,16 +484,21 @@ func (c *GatewayClient) handleMessage(ctx context.Context, msg *pb.GatewayMessag c.handleShutdownMessage(payload.Shutdown) case *pb.GatewayMessage_FileOperation: - resp, err := c.fileHandler.HandleFileOperation(ctx, payload.FileOperation) - if err != nil { - log.WithError(err).Error("Failed to handle file operation") - return - } - c.Send(&pb.DaemonMessage{ - Payload: &pb.DaemonMessage_FileOperationResponse{ - FileOperationResponse: resp, - }, - }) + // Off the receive loop: a file operation can be arbitrarily long + // (hashing walks whole files), and blocking here would stall every + // other gateway message — task dispatch, cancels, shutdown. + go func() { + resp, err := c.fileHandler.HandleFileOperation(ctx, payload.FileOperation) + if err != nil { + log.WithError(err).Error("Failed to handle file operation") + return + } + c.Send(&pb.DaemonMessage{ + Payload: &pb.DaemonMessage_FileOperationResponse{ + FileOperationResponse: resp, + }, + }) + }() case *pb.GatewayMessage_FileUploadTask: c.runFileTransfer("FileUploadTask", func() { @@ -497,6 +510,16 @@ func (c *GatewayClient) handleMessage(ctx context.Context, msg *pb.GatewayMessag c.transferHandler.HandleFileDownloadTask(ctx, msg.RequestId, payload.FileDownloadTask) }) + case *pb.GatewayMessage_Archive: + c.runArchiveOp("ArchiveRequest", func() { + c.archiveHandler.HandleArchiveRequest(ctx, payload.Archive) + }) + + case *pb.GatewayMessage_ArchiveCancel: + if c.archiveHandler != nil { + c.archiveHandler.HandleArchiveCancel(ctx, payload.ArchiveCancel) + } + case *pb.GatewayMessage_AttachRequest: if c.attachHandler != nil { c.attachHandler.HandleAttachRequest(ctx, payload.AttachRequest) @@ -583,6 +606,14 @@ func (c *GatewayClient) runFileTransfer(name string, fn func()) { go fn() } +func (c *GatewayClient) runArchiveOp(name string, fn func()) { + if c.archiveHandler == nil { + log.Warnf("%s received but no archive handler configured", name) + return + } + go fn() +} + func (c *GatewayClient) handleShutdownMessage(shutdown *pb.ShutdownNotification) { log.WithField("reason", shutdown.Reason). WithField("reconnect_delay", shutdown.ReconnectDelay). @@ -757,6 +788,10 @@ func (c *GatewayClient) SetTransferHandler(h TransferHandler) { c.transferHandler = h } +func (c *GatewayClient) SetArchiveHandler(h ArchiveHandler) { + c.archiveHandler = h +} + func (c *GatewayClient) SetAttachHandler(h AttachHandler) { c.attachHandler = h } diff --git a/internal/app/grpc/file_handler.go b/internal/app/grpc/file_handler.go index d110a29..ed42f8f 100644 --- a/internal/app/grpc/file_handler.go +++ b/internal/app/grpc/file_handler.go @@ -2,6 +2,7 @@ package grpc import ( "context" + "encoding/hex" "io" "io/fs" "os" @@ -19,6 +20,11 @@ import ( const ( defaultFileChunkSize = 64 * 1024 maxFileSize = 100 * 1024 * 1024 + + // maxHashPaths caps one hash request. Each path costs a full file read, and + // every result is carried in a single response message, so an unbounded + // list is both a work amplifier and a way to outgrow the gRPC frame limit. + maxHashPaths = 1000 ) type GRPCFileHandler struct { @@ -167,7 +173,7 @@ func (h *GRPCFileHandler) HandleFileWrite( } } - mode := os.FileMode(req.Mode) + mode := permMode(req.Mode) if mode == 0 { mode = 0644 } @@ -311,6 +317,14 @@ func relUnder(base, name string) (string, bool) { return name[len(prefix):], true } +// permMode keeps only the permission bits of a caller-supplied mode. Go maps +// os.ModeSetuid/Setgid/Sticky onto the real S_ISUID/S_ISGID/S_ISVTX bits, and +// umask does not strip them, so an unmasked mode would let an API caller ask a +// root daemon to create a setuid file inside a game-server directory. +func permMode(mode int32) os.FileMode { + return os.FileMode(mode).Perm() //nolint:gosec // masked to 0777 by Perm +} + func fileOpErrResp(requestID string, err error) (*pb.FileOperationResponse, error) { return &pb.FileOperationResponse{ RequestId: requestID, @@ -327,7 +341,7 @@ func fileOpOkResp(requestID string) (*pb.FileOperationResponse, error) { } func (h *GRPCFileHandler) HandleFileOperation( - _ context.Context, req *pb.FileOperationRequest, + ctx context.Context, req *pb.FileOperationRequest, ) (*pb.FileOperationResponse, error) { rid := req.GetRequestId() @@ -399,7 +413,7 @@ func (h *GRPCFileHandler) HandleFileOperation( if relErr != nil { return fileOpErrResp(rid, relErr) } - if err := root.Chmod(rel, os.FileMode(p.GetMode())); err != nil { + if err := root.Chmod(rel, permMode(p.GetMode())); err != nil { return fileOpErrResp(rid, err) } return fileOpOkResp(rid) @@ -424,6 +438,9 @@ func (h *GRPCFileHandler) HandleFileOperation( case pb.FileOperationType_FILE_OPERATION_TYPE_TOUCH: return h.handleTouchOp(root, rid, req.GetTouchParams()) + case pb.FileOperationType_FILE_OPERATION_TYPE_HASH: + return h.handleHashOp(ctx, root, rid, req.GetHashParams()) + default: return fileOpErrResp(rid, errors.Errorf("unsupported file operation: %s", req.GetOperation())) } @@ -507,7 +524,7 @@ func (h *GRPCFileHandler) handleMkdirOp( GID: p.GetOwnerGid(), } - mode := os.FileMode(p.GetMode()) + mode := permMode(p.GetMode()) if mode == 0 { mode = 0755 } @@ -569,6 +586,111 @@ func (h *GRPCFileHandler) handleTouchOp( return fileOpOkResp(rid) } +func (h *GRPCFileHandler) handleHashOp( + ctx context.Context, root *os.Root, rid string, p *pb.HashParams, +) (*pb.FileOperationResponse, error) { + if p == nil { + return fileOpErrResp(rid, errors.New("hash_params required")) + } + if _, err := hasherForAlgorithm(p.GetAlgorithm()); err != nil { + return fileOpErrResp(rid, err) + } + if len(p.GetPaths()) > maxHashPaths { + return fileOpErrResp(rid, errors.Errorf( + "too many paths to hash: %d, limit is %d", len(p.GetPaths()), maxHashPaths, + )) + } + + hashes := make([]*pb.FileHash, 0, len(p.GetPaths())) + for _, pth := range p.GetPaths() { + if err := ctx.Err(); err != nil { + return fileOpErrResp(rid, errors.Wrap(err, "hash operation canceled")) + } + + hashes = append(hashes, hashFileInRoot(ctx, root, pth, p.GetAlgorithm())) + } + + return &pb.FileOperationResponse{ + RequestId: rid, + Success: true, + Result: &pb.FileOperationResponse_HashResult{ + HashResult: &pb.HashResult{ + Algorithm: p.GetAlgorithm(), + Hashes: hashes, + }, + }, + }, nil +} + +// hashFileInRoot hashes a single file inside root. Any failure is reported in +// the returned FileHash.Error; per-file failures must not fail the operation. +func hashFileInRoot( + ctx context.Context, root *os.Root, path string, algorithm pb.HashAlgorithm, +) *pb.FileHash { + fh := &pb.FileHash{Path: path} + + rel, err := fsutil.RootRel(path) + if err != nil { + fh.Error = err.Error() + return fh + } + + info, err := root.Lstat(rel) + if err != nil { + fh.Error = err.Error() + return fh + } + + if info.IsDir() { + fh.Error = "is a directory" + return fh + } + + if !info.Mode().IsRegular() { + fh.Error = "not a regular file" + return fh + } + + hasher, err := hasherForAlgorithm(algorithm) + if err != nil { + fh.Error = err.Error() + return fh + } + + f, err := root.Open(rel) + if err != nil { + fh.Error = err.Error() + return fh + } + defer f.Close() + + n, err := io.Copy(hasher, &ctxReader{ctx: ctx, r: f}) + if err != nil { + fh.Error = err.Error() + return fh + } + + fh.Hash = hex.EncodeToString(hasher.Sum(nil)) + fh.Size = uint64(n) + + return fh +} + +// ctxReader aborts a streaming read when ctx is done. Hashing a multi-gigabyte +// file otherwise runs to completion no matter what happens to the connection. +type ctxReader struct { + ctx context.Context + r io.Reader +} + +func (r *ctxReader) Read(p []byte) (int, error) { + if err := r.ctx.Err(); err != nil { + return 0, errors.Wrap(err, "read canceled") + } + + return r.r.Read(p) +} + func fileInfoToStat(path string, info os.FileInfo) *pb.FileStat { ft := pb.FileType_FILE_TYPE_REGULAR switch { diff --git a/internal/app/grpc/file_handler_hash_test.go b/internal/app/grpc/file_handler_hash_test.go new file mode 100644 index 0000000..8e2f78b --- /dev/null +++ b/internal/app/grpc/file_handler_hash_test.go @@ -0,0 +1,292 @@ +package grpc + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "os" + "path/filepath" + "strings" + "testing" + + pb "github.com/gameap/gameap/pkg/proto" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func hashOpRequest(p *pb.HashParams) *pb.FileOperationRequest { + return &pb.FileOperationRequest{ + RequestId: "req-1", + Operation: pb.FileOperationType_FILE_OPERATION_TYPE_HASH, + Parameters: &pb.FileOperationRequest_HashParams{ + HashParams: p, + }, + } +} + +func TestHandleFileOperation_HashAlgorithms(t *testing.T) { + tests := []struct { + name string + algorithm pb.HashAlgorithm + content string + expected string + }{ + { + name: "md5", + algorithm: pb.HashAlgorithm_HASH_ALGORITHM_MD5, + content: "", + expected: "d41d8cd98f00b204e9800998ecf8427e", + }, + { + name: "sha1", + algorithm: pb.HashAlgorithm_HASH_ALGORITHM_SHA1, + content: "", + expected: "da39a3ee5e6b4b0d3255bfef95601890afd80709", + }, + { + name: "sha256", + algorithm: pb.HashAlgorithm_HASH_ALGORITHM_SHA256, + content: "", + expected: "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + }, + { + name: "sha512", + algorithm: pb.HashAlgorithm_HASH_ALGORITHM_SHA512, + content: "", + expected: "cf83e1357eefb8bdf1542850d66d8007d620e4050b5715dc83f4a921d36ce9ce" + + "47d0d13c5d85f2b0ff8318d2877eec2f63b931bd47417a81a538327af927da3e", + }, + { + name: "crc32", + algorithm: pb.HashAlgorithm_HASH_ALGORITHM_CRC32, + content: "123456789", + expected: "cbf43926", + }, + { + name: "crc64", + algorithm: pb.HashAlgorithm_HASH_ALGORITHM_CRC64, + content: "123456789", + expected: "995dc9bbdf1939fa", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + workDir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(workDir, "file.bin"), []byte(tc.content), 0o644)) + h := NewGRPCFileHandler(workDir) + + resp, err := h.HandleFileOperation(context.Background(), hashOpRequest(&pb.HashParams{ + Paths: []string{"file.bin"}, + Algorithm: tc.algorithm, + })) + + require.NoError(t, err) + require.True(t, resp.Success, resp.Error) + + result := resp.GetHashResult() + require.NotNil(t, result) + assert.Equal(t, tc.algorithm, result.GetAlgorithm()) + require.Len(t, result.GetHashes(), 1) + + fh := result.GetHashes()[0] + assert.Equal(t, "file.bin", fh.GetPath()) + assert.Empty(t, fh.GetError()) + assert.Equal(t, tc.expected, fh.GetHash()) + assert.Equal(t, fh.GetHash(), strings.ToLower(fh.GetHash()), "hash must be lowercase hex") + assert.Equal(t, uint64(len(tc.content)), fh.GetSize()) + }) + } +} + +func TestHandleFileOperation_HashDirectory(t *testing.T) { + workDir := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(workDir, "subdir"), 0o755)) + h := NewGRPCFileHandler(workDir) + + resp, err := h.HandleFileOperation(context.Background(), hashOpRequest(&pb.HashParams{ + Paths: []string{"subdir"}, + Algorithm: pb.HashAlgorithm_HASH_ALGORITHM_SHA256, + })) + + require.NoError(t, err) + require.True(t, resp.Success, resp.Error) + + result := resp.GetHashResult() + require.NotNil(t, result) + require.Len(t, result.GetHashes(), 1) + + fh := result.GetHashes()[0] + assert.Equal(t, "subdir", fh.GetPath()) + assert.Equal(t, "is a directory", fh.GetError()) + assert.Empty(t, fh.GetHash()) + assert.Zero(t, fh.GetSize()) +} + +func TestHandleFileOperation_HashMissingFile(t *testing.T) { + h := NewGRPCFileHandler(t.TempDir()) + + resp, err := h.HandleFileOperation(context.Background(), hashOpRequest(&pb.HashParams{ + Paths: []string{"no/such/file.txt"}, + Algorithm: pb.HashAlgorithm_HASH_ALGORITHM_SHA256, + })) + + require.NoError(t, err) + require.True(t, resp.Success, resp.Error) + + result := resp.GetHashResult() + require.NotNil(t, result) + require.Len(t, result.GetHashes(), 1) + + fh := result.GetHashes()[0] + assert.Equal(t, "no/such/file.txt", fh.GetPath()) + assert.NotEmpty(t, fh.GetError()) + assert.Empty(t, fh.GetHash()) +} + +func TestHandleFileOperation_HashMultiplePaths(t *testing.T) { + workDir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(workDir, "ok.txt"), []byte("data"), 0o644)) + require.NoError(t, os.MkdirAll(filepath.Join(workDir, "dir"), 0o755)) + h := NewGRPCFileHandler(workDir) + + resp, err := h.HandleFileOperation(context.Background(), hashOpRequest(&pb.HashParams{ + Paths: []string{"ok.txt", "dir", "missing.txt"}, + Algorithm: pb.HashAlgorithm_HASH_ALGORITHM_SHA256, + })) + + require.NoError(t, err) + require.True(t, resp.Success, resp.Error) + + result := resp.GetHashResult() + require.NotNil(t, result) + require.Len(t, result.GetHashes(), 3) + + sum := sha256.Sum256([]byte("data")) + + ok := result.GetHashes()[0] + assert.Equal(t, "ok.txt", ok.GetPath()) + assert.Empty(t, ok.GetError()) + assert.Equal(t, hex.EncodeToString(sum[:]), ok.GetHash()) + assert.Equal(t, uint64(4), ok.GetSize()) + + dir := result.GetHashes()[1] + assert.Equal(t, "dir", dir.GetPath()) + assert.Equal(t, "is a directory", dir.GetError()) + assert.Empty(t, dir.GetHash()) + + missing := result.GetHashes()[2] + assert.Equal(t, "missing.txt", missing.GetPath()) + assert.NotEmpty(t, missing.GetError()) + assert.Empty(t, missing.GetHash()) +} + +func TestHandleFileOperation_HashTooManyPaths(t *testing.T) { + h := NewGRPCFileHandler(t.TempDir()) + + paths := make([]string, maxHashPaths+1) + for i := range paths { + paths[i] = "f.txt" + } + + resp, err := h.HandleFileOperation(context.Background(), hashOpRequest(&pb.HashParams{ + Paths: paths, + Algorithm: pb.HashAlgorithm_HASH_ALGORITHM_SHA256, + })) + + require.NoError(t, err) + assert.False(t, resp.Success) + assert.Contains(t, resp.Error, "too many paths") +} + +func TestHandleFileOperation_HashCanceled(t *testing.T) { + workDir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(workDir, "f.txt"), []byte("data"), 0o644)) + h := NewGRPCFileHandler(workDir) + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + resp, err := h.HandleFileOperation(ctx, hashOpRequest(&pb.HashParams{ + Paths: []string{"f.txt"}, + Algorithm: pb.HashAlgorithm_HASH_ALGORITHM_SHA256, + })) + + require.NoError(t, err) + assert.False(t, resp.Success) + assert.Contains(t, resp.Error, "canceled") +} + +func TestHandleFileOperation_HashNilParams(t *testing.T) { + h := NewGRPCFileHandler(t.TempDir()) + + resp, err := h.HandleFileOperation(context.Background(), &pb.FileOperationRequest{ + RequestId: "req-1", + Operation: pb.FileOperationType_FILE_OPERATION_TYPE_HASH, + }) + + require.NoError(t, err) + assert.False(t, resp.Success) + assert.Contains(t, resp.Error, "hash_params") +} + +func TestHandleFileOperation_HashUnspecifiedAlgorithm(t *testing.T) { + workDir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(workDir, "f.txt"), []byte("data"), 0o644)) + h := NewGRPCFileHandler(workDir) + + resp, err := h.HandleFileOperation(context.Background(), hashOpRequest(&pb.HashParams{ + Paths: []string{"f.txt"}, + Algorithm: pb.HashAlgorithm_HASH_ALGORITHM_UNSPECIFIED, + })) + + require.NoError(t, err) + assert.False(t, resp.Success) + assert.NotEmpty(t, resp.Error) +} + +func TestHandleFileOperation_HashPathTraversal(t *testing.T) { + h := NewGRPCFileHandler(t.TempDir()) + + resp, err := h.HandleFileOperation(context.Background(), hashOpRequest(&pb.HashParams{ + Paths: []string{"../outside"}, + Algorithm: pb.HashAlgorithm_HASH_ALGORITHM_SHA256, + })) + + require.NoError(t, err) + require.True(t, resp.Success, resp.Error) + + result := resp.GetHashResult() + require.NotNil(t, result) + require.Len(t, result.GetHashes(), 1) + + fh := result.GetHashes()[0] + assert.Equal(t, "../outside", fh.GetPath()) + assert.Contains(t, fh.GetError(), "outside work directory") + assert.Empty(t, fh.GetHash()) +} + +func TestHandleFileOperation_HashSymlink(t *testing.T) { + workDir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(workDir, "target.txt"), []byte("data"), 0o644)) + require.NoError(t, os.Symlink("target.txt", filepath.Join(workDir, "link.txt"))) + h := NewGRPCFileHandler(workDir) + + resp, err := h.HandleFileOperation(context.Background(), hashOpRequest(&pb.HashParams{ + Paths: []string{"link.txt"}, + Algorithm: pb.HashAlgorithm_HASH_ALGORITHM_SHA256, + })) + + require.NoError(t, err) + require.True(t, resp.Success, resp.Error) + + result := resp.GetHashResult() + require.NotNil(t, result) + require.Len(t, result.GetHashes(), 1) + + fh := result.GetHashes()[0] + assert.Equal(t, "link.txt", fh.GetPath()) + assert.Equal(t, "not a regular file", fh.GetError()) + assert.Empty(t, fh.GetHash()) + assert.Zero(t, fh.GetSize()) +} diff --git a/internal/app/grpc/hash.go b/internal/app/grpc/hash.go new file mode 100644 index 0000000..1bad27d --- /dev/null +++ b/internal/app/grpc/hash.go @@ -0,0 +1,33 @@ +package grpc + +import ( + "crypto/md5" //nolint:gosec // md5 hashing is part of the protocol contract + "crypto/sha1" //nolint:gosec // sha1 hashing is part of the protocol contract + "crypto/sha256" + "crypto/sha512" + "hash" + "hash/crc32" + "hash/crc64" + + pb "github.com/gameap/gameap/pkg/proto" + "github.com/pkg/errors" +) + +func hasherForAlgorithm(a pb.HashAlgorithm) (hash.Hash, error) { + switch a { + case pb.HashAlgorithm_HASH_ALGORITHM_MD5: + return md5.New(), nil //nolint:gosec // md5 hashing is part of the protocol contract + case pb.HashAlgorithm_HASH_ALGORITHM_SHA1: + return sha1.New(), nil //nolint:gosec // sha1 hashing is part of the protocol contract + case pb.HashAlgorithm_HASH_ALGORITHM_SHA256: + return sha256.New(), nil + case pb.HashAlgorithm_HASH_ALGORITHM_SHA512: + return sha512.New(), nil + case pb.HashAlgorithm_HASH_ALGORITHM_CRC32: + return crc32.NewIEEE(), nil + case pb.HashAlgorithm_HASH_ALGORITHM_CRC64: + return crc64.New(crc64.MakeTable(crc64.ECMA)), nil + default: + return nil, errors.Errorf("unsupported hash algorithm: %s", a) + } +} diff --git a/internal/app/grpc/server_handler.go b/internal/app/grpc/server_handler.go index e557546..293a6b5 100644 --- a/internal/app/grpc/server_handler.go +++ b/internal/app/grpc/server_handler.go @@ -99,7 +99,7 @@ func (h *GRPCServerHandler) handleServerProto(srv *pb.Server, settings domain.Se settings, updatedAt, int(srv.GetCpuLimit()), - int64(srv.GetRamLimit()), + srv.GetRamLimit(), ) h.serverRepo.SaveToCache(existing) @@ -131,7 +131,7 @@ func (h *GRPCServerHandler) handleServerProto(srv *pb.Server, settings domain.Se settings, updatedAt, int(srv.GetCpuLimit()), - int64(srv.GetRamLimit()), + srv.GetRamLimit(), ) h.serverRepo.SaveToCache(server) diff --git a/test/files/test.7z b/test/files/test.7z new file mode 100644 index 0000000..209a6cc Binary files /dev/null and b/test/files/test.7z differ diff --git a/test/files/test.rar b/test/files/test.rar new file mode 100644 index 0000000..c12a6a8 Binary files /dev/null and b/test/files/test.rar differ