mirror of
https://github.com/pterodactyl/wings.git
synced 2026-04-11 12:34:03 -05:00
Compare commits
1 Commits
dane/fs
...
release/v1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5977b96c13 |
2
.github/workflows/codeql.yaml
vendored
2
.github/workflows/codeql.yaml
vendored
@@ -26,7 +26,7 @@ jobs:
|
||||
security-events: write
|
||||
steps:
|
||||
- name: Code checkout
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@0499de31b99561a6d14a36a5f662c2a54f91beee # v3.29.5
|
||||
|
||||
12
.github/workflows/docker.yaml
vendored
12
.github/workflows/docker.yaml
vendored
@@ -19,11 +19,11 @@ jobs:
|
||||
packages: write
|
||||
steps:
|
||||
- name: Code checkout
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
|
||||
- name: Docker metadata
|
||||
id: docker_meta
|
||||
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
|
||||
uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f # v5.8.0
|
||||
with:
|
||||
images: ghcr.io/${{ github.repository }}
|
||||
flavor: |
|
||||
@@ -34,10 +34,10 @@ jobs:
|
||||
type=ref,event=branch
|
||||
|
||||
- name: Setup QEMU
|
||||
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
|
||||
uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392 # v3.6.0
|
||||
|
||||
- name: Setup Docker buildx
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
|
||||
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
|
||||
@@ -49,8 +49,8 @@ jobs:
|
||||
- name: Get Build Information
|
||||
id: build_info
|
||||
run: |
|
||||
echo "version_tag=${GITHUB_REF/refs\/tags\/v/}" >> "$GITHUB_OUTPUT"
|
||||
echo "short_sha=$(git rev-parse --short HEAD)" >> "$GITHUB_OUTPUT"
|
||||
echo "version_tag=${GITHUB_REF/refs\/tags\/v/}" >> $GITHUB_OUTPUT
|
||||
echo "short_sha=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Build and Push (tag)
|
||||
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
|
||||
|
||||
14
.github/workflows/push.yaml
vendored
14
.github/workflows/push.yaml
vendored
@@ -16,17 +16,17 @@ jobs:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-24.04]
|
||||
go: ["1.25.6"]
|
||||
go: ["1.24.1"]
|
||||
goos: [linux]
|
||||
goarch: [amd64, arm64]
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Code checkout
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6.2.0
|
||||
uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 # v6.0.0
|
||||
with:
|
||||
go-version: ${{ matrix.go }}
|
||||
|
||||
@@ -62,15 +62,15 @@ jobs:
|
||||
go test -race $(go list ./...)
|
||||
|
||||
- name: Upload Release Artifact
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
if: ${{ (github.ref == 'refs/heads/develop' || github.event_name == 'pull_request') }}
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
if: ${{ (github.ref == 'refs/heads/develop' || github.event_name == 'pull_request') && matrix.go == '1.23.7' }}
|
||||
with:
|
||||
name: wings_linux_${{ matrix.goarch }}
|
||||
path: dist/wings
|
||||
|
||||
- name: Upload Debug Artifact
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
if: ${{ (github.ref == 'refs/heads/develop' || github.event_name == 'pull_request') }}
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
if: ${{ (github.ref == 'refs/heads/develop' || github.event_name == 'pull_request') && matrix.go == '1.23.7' }}
|
||||
with:
|
||||
name: wings_linux_${{ matrix.goarch }}_debug
|
||||
path: dist/wings_debug
|
||||
|
||||
21
.github/workflows/release.yaml
vendored
21
.github/workflows/release.yaml
vendored
@@ -6,18 +6,14 @@ on:
|
||||
jobs:
|
||||
release:
|
||||
name: Release
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Code checkout
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6.2.0
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
- uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 # v6.0.0
|
||||
with:
|
||||
go-version: 1.25.6
|
||||
|
||||
go-version: "1.24.1"
|
||||
- name: Build release binaries
|
||||
env:
|
||||
CGO_ENABLED: 0
|
||||
@@ -26,25 +22,22 @@ jobs:
|
||||
chmod 755 dist/wings_linux_amd64
|
||||
GOARCH=arm64 go build -o dist/wings_linux_arm64 -v -trimpath -ldflags="-s -w -X github.com/pterodactyl/wings/system.Version=${{ github.ref_name }}" github.com/pterodactyl/wings
|
||||
chmod 755 dist/wings_linux_arm64
|
||||
|
||||
- name: Create release branch
|
||||
env:
|
||||
VERSION: ${{ github.ref_name }}
|
||||
REF: ${{ github.ref }}
|
||||
run: |
|
||||
BRANCH=release/${{ env.VERSION }}
|
||||
BRANCH=release/${{ github.ref_name }}
|
||||
git config --local user.email "ci@pterodactyl.io"
|
||||
git config --local user.name "Pterodactyl CI"
|
||||
git checkout -b $BRANCH
|
||||
git push -u origin $BRANCH
|
||||
sed -i "s/var Version = \".*\"/var Version = \"${VERSION:1}\"/" system/const.go
|
||||
sed -i "s/var Version = \".*\"/var Version = \"${{ github.ref_name }}\"/" system/const.go
|
||||
git add system/const.go
|
||||
git commit -m "ci(release): bump version"
|
||||
git push
|
||||
|
||||
- name: write changelog
|
||||
run: |
|
||||
sed -n "/^## ${{ github.ref_name }}/,/^## /{/^## /b;p}" CHANGELOG.md > ./RELEASE_CHANGELOG
|
||||
|
||||
- uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2.5.0
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
# Changelog
|
||||
|
||||
## v1.12.1
|
||||
### Added
|
||||
* Add mount for /etc/machine-id for servers for Hytale ([#292](https://github.com/pterodactyl/wings/pull/292))
|
||||
|
||||
## v1.12.0
|
||||
### Fixed
|
||||
* [CVE-2025-68954](https://github.com/pterodactyl/panel/security/advisories/GHSA-8c39-xppg-479c)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# Stage 1 (Build)
|
||||
FROM golang:1.25.6-alpine AS builder
|
||||
FROM golang:1.24.1-alpine AS builder
|
||||
|
||||
ARG VERSION
|
||||
RUN apk add --update --no-cache git make mailcap
|
||||
|
||||
@@ -197,9 +197,9 @@ func rootCmdRun(cmd *cobra.Command, _ []string) {
|
||||
for _, serv := range manager.All() {
|
||||
s := serv
|
||||
|
||||
// For each server ensure the minimal environment is configured for the server.
|
||||
if err := s.CreateEnvironment(); err != nil {
|
||||
s.Log().WithField("error", err).Error("could not create base environment for server...")
|
||||
// For each server we encounter make sure the root data directory exists.
|
||||
if err := s.EnsureDataDirectoryExists(); err != nil {
|
||||
s.Log().Error("could not create root data directory for server: not loading server...")
|
||||
continue
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"text/template"
|
||||
"time"
|
||||
|
||||
@@ -21,6 +22,7 @@ import (
|
||||
"github.com/apex/log"
|
||||
"github.com/creasty/defaults"
|
||||
"github.com/gbrlsnchs/jwt/v3"
|
||||
"golang.org/x/sys/unix"
|
||||
"gopkg.in/yaml.v2"
|
||||
|
||||
"github.com/pterodactyl/wings/system"
|
||||
@@ -175,11 +177,11 @@ type SystemConfiguration struct {
|
||||
Passwd struct {
|
||||
// Enable controls whether generated passwd files should be mounted into containers.
|
||||
//
|
||||
// By default this option is disabled and Wings will not mount any
|
||||
// additional passwd files into containers.
|
||||
// By default this option is disabled and Wings will not mount any additional passwd
|
||||
// files into containers.
|
||||
Enable bool `yaml:"enabled" default:"false"`
|
||||
|
||||
// Directory is the directory on disk where the generated passwd files will be stored.
|
||||
// Directory is the directory on disk where the generated files will be stored.
|
||||
// This directory may be temporary as it will be re-created whenever Wings is started.
|
||||
//
|
||||
// This path **WILL** be both written to by Wings and mounted into containers created by
|
||||
@@ -190,26 +192,6 @@ type SystemConfiguration struct {
|
||||
Directory string `yaml:"directory" default:"/run/wings/etc"`
|
||||
} `yaml:"passwd"`
|
||||
|
||||
// MachineID controls the mounting of a generated `/etc/machine-id` file into containers started by Wings.
|
||||
MachineID struct {
|
||||
// Enable controls whether a generated machine-id file should be mounted
|
||||
// into containers.
|
||||
//
|
||||
// By default this option is enabled and Wings will mount an additional
|
||||
// machine-id file into containers.
|
||||
Enable bool `yaml:"enabled" default:"true"`
|
||||
|
||||
// Directory is the directory on disk where the generated machine-id files will be stored.
|
||||
// This directory may be temporary as it will be re-created whenever Wings is started.
|
||||
//
|
||||
// This path **WILL** be both written to by Wings and mounted into containers created by
|
||||
// Wings. If you are running Wings itself in a container, this path will need to be mounted
|
||||
// into the Wings container as the exact path on the host, which should match the value
|
||||
// specified here. If you are using SELinux, you will need to make sure this file has the
|
||||
// correct SELinux context in order for containers to use it.
|
||||
Directory string `yaml:"directory" default:"/run/wings/machine-id"`
|
||||
} `yaml:"machine_id"`
|
||||
|
||||
// The amount of time in seconds that can elapse before a server's disk space calculation is
|
||||
// considered stale and a re-check should occur. DANGER: setting this value too low can seriously
|
||||
// impact system performance and cause massive I/O bottlenecks and high CPU usage for the Wings
|
||||
@@ -249,6 +231,8 @@ type SystemConfiguration struct {
|
||||
Backups Backups `yaml:"backups"`
|
||||
|
||||
Transfers Transfers `yaml:"transfers"`
|
||||
|
||||
OpenatMode string `default:"auto" yaml:"openat_mode"`
|
||||
}
|
||||
|
||||
type CrashDetection struct {
|
||||
@@ -650,11 +634,6 @@ func ConfigureDirectories() error {
|
||||
return err
|
||||
}
|
||||
|
||||
log.WithField("path", _config.System.TmpDirectory).Debug("ensuring temporary data directory exists")
|
||||
if err := os.MkdirAll(_config.System.TmpDirectory, 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
log.WithField("path", _config.System.ArchiveDirectory).Debug("ensuring archive data directory exists")
|
||||
if err := os.MkdirAll(_config.System.ArchiveDirectory, 0o700); err != nil {
|
||||
return err
|
||||
@@ -672,13 +651,6 @@ func ConfigureDirectories() error {
|
||||
}
|
||||
}
|
||||
|
||||
if _config.System.MachineID.Enable {
|
||||
log.WithField("path", _config.System.MachineID.Directory).Debug("ensuring machine-id directory exists")
|
||||
if err := os.MkdirAll(_config.System.MachineID.Directory, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -794,6 +766,39 @@ func getSystemName() (string, error) {
|
||||
return release["ID"], nil
|
||||
}
|
||||
|
||||
var (
|
||||
openat2 atomic.Bool
|
||||
openat2Set atomic.Bool
|
||||
)
|
||||
|
||||
func UseOpenat2() bool {
|
||||
if openat2Set.Load() {
|
||||
return openat2.Load()
|
||||
}
|
||||
defer openat2Set.Store(true)
|
||||
|
||||
c := Get()
|
||||
openatMode := c.System.OpenatMode
|
||||
switch openatMode {
|
||||
case "openat2":
|
||||
openat2.Store(true)
|
||||
return true
|
||||
case "openat":
|
||||
openat2.Store(false)
|
||||
return false
|
||||
default:
|
||||
fd, err := unix.Openat2(unix.AT_FDCWD, "/", &unix.OpenHow{})
|
||||
if err != nil {
|
||||
log.WithError(err).Warn("error occurred while checking for openat2 support, falling back to openat")
|
||||
openat2.Store(false)
|
||||
return false
|
||||
}
|
||||
_ = unix.Close(fd)
|
||||
openat2.Store(true)
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// Expand expands an input string by calling [os.ExpandEnv] to expand all
|
||||
// environment variables, then checks if the value is prefixed with `file://`
|
||||
// to support reading the value from a file.
|
||||
|
||||
@@ -23,7 +23,6 @@ services:
|
||||
- "/var/log/pterodactyl/:/var/log/pterodactyl/"
|
||||
- "/tmp/pterodactyl/:/tmp/pterodactyl/"
|
||||
- "/etc/ssl/certs:/etc/ssl/certs:ro"
|
||||
- "/run/wings:/run/wings"
|
||||
# you may need /srv/daemon-data if you are upgrading from an old daemon
|
||||
#- "/srv/daemon-data/:/srv/daemon-data/"
|
||||
# Required for ssl if you use let's encrypt. uncomment to use.
|
||||
|
||||
24
flake.lock
generated
24
flake.lock
generated
@@ -5,11 +5,11 @@
|
||||
"nixpkgs-lib": "nixpkgs-lib"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1768135262,
|
||||
"narHash": "sha256-PVvu7OqHBGWN16zSi6tEmPwwHQ4rLPU9Plvs8/1TUBY=",
|
||||
"lastModified": 1741352980,
|
||||
"narHash": "sha256-+u2UunDA4Cl5Fci3m7S643HzKmIDAe+fiXrLqYsR2fs=",
|
||||
"owner": "hercules-ci",
|
||||
"repo": "flake-parts",
|
||||
"rev": "80daad04eddbbf5a4d883996a73f3f542fa437ac",
|
||||
"rev": "f4330d22f1c5d2ba72d3d22df5597d123fdb60a9",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -20,11 +20,11 @@
|
||||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1768127708,
|
||||
"narHash": "sha256-1Sm77VfZh3mU0F5OqKABNLWxOuDeHIlcFjsXeeiPazs=",
|
||||
"lastModified": 1741379970,
|
||||
"narHash": "sha256-Wh7esNh7G24qYleLvgOSY/7HlDUzWaL/n4qzlBePpiw=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "ffbc9f8cbaacfb331b6017d5a5abb21a492c9a38",
|
||||
"rev": "36fd87baa9083f34f7f5027900b62ee6d09b1f2f",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -36,11 +36,11 @@
|
||||
},
|
||||
"nixpkgs-lib": {
|
||||
"locked": {
|
||||
"lastModified": 1765674936,
|
||||
"narHash": "sha256-k00uTP4JNfmejrCLJOwdObYC9jHRrr/5M/a/8L2EIdo=",
|
||||
"lastModified": 1740877520,
|
||||
"narHash": "sha256-oiwv/ZK/2FhGxrCkQkB83i7GnWXPPLzoqFHpDD3uYpk=",
|
||||
"owner": "nix-community",
|
||||
"repo": "nixpkgs.lib",
|
||||
"rev": "2075416fcb47225d9b68ac469a5c4801a9c4dd85",
|
||||
"rev": "147dee35aab2193b174e4c0868bd80ead5ce755c",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -63,11 +63,11 @@
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1768158989,
|
||||
"narHash": "sha256-67vyT1+xClLldnumAzCTBvU0jLZ1YBcf4vANRWP3+Ak=",
|
||||
"lastModified": 1739829690,
|
||||
"narHash": "sha256-mL1szCeIsjh6Khn3nH2cYtwO5YXG6gBiTw1A30iGeDU=",
|
||||
"owner": "numtide",
|
||||
"repo": "treefmt-nix",
|
||||
"rev": "e96d59dff5c0d7fddb9d113ba108f03c3ef99eca",
|
||||
"rev": "3d0579f5cc93436052d94b73925b48973a104204",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
|
||||
13
go.mod
13
go.mod
@@ -1,6 +1,8 @@
|
||||
module github.com/pterodactyl/wings
|
||||
|
||||
go 1.25.6
|
||||
go 1.24.0
|
||||
|
||||
toolchain go1.24.1
|
||||
|
||||
require (
|
||||
emperror.dev/errors v0.8.1
|
||||
@@ -24,13 +26,12 @@ require (
|
||||
github.com/gin-gonic/gin v1.10.1
|
||||
github.com/glebarez/sqlite v1.11.0
|
||||
github.com/go-co-op/gocron v1.37.0
|
||||
github.com/goccy/go-json v0.10.5
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
github.com/iancoleman/strcase v0.3.0
|
||||
github.com/icza/dyno v0.0.0-20230330125955-09f820a8d9c0
|
||||
github.com/juju/ratelimit v1.0.2
|
||||
github.com/karrick/godirwalk v1.17.0
|
||||
github.com/klauspost/compress v1.18.0
|
||||
github.com/klauspost/pgzip v1.2.6
|
||||
github.com/magiconair/properties v1.8.9
|
||||
github.com/mattn/go-colorable v0.1.14
|
||||
@@ -43,7 +44,7 @@ require (
|
||||
github.com/stretchr/testify v1.10.0
|
||||
golang.org/x/crypto v0.46.0
|
||||
golang.org/x/sync v0.19.0
|
||||
golang.org/x/time v0.0.0-20220922220347-f3bd1da661af
|
||||
golang.org/x/sys v0.39.0
|
||||
gopkg.in/ini.v1 v1.67.0
|
||||
gopkg.in/yaml.v2 v2.4.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
@@ -78,6 +79,7 @@ require (
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.25.0 // indirect
|
||||
github.com/goccy/go-json v0.10.5 // indirect
|
||||
github.com/gogo/protobuf v1.3.2 // indirect
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
@@ -85,7 +87,6 @@ require (
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
|
||||
github.com/klauspost/compress v1.18.0 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.10 // indirect
|
||||
github.com/kr/fs v0.1.0 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
@@ -129,9 +130,9 @@ require (
|
||||
golang.org/x/arch v0.15.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 // indirect
|
||||
golang.org/x/net v0.47.0 // indirect
|
||||
golang.org/x/sys v0.39.0 // indirect
|
||||
golang.org/x/term v0.38.0 // indirect
|
||||
golang.org/x/text v0.32.0 // indirect
|
||||
golang.org/x/time v0.0.0-20220922220347-f3bd1da661af // indirect
|
||||
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect
|
||||
google.golang.org/protobuf v1.36.5 // indirect
|
||||
gotest.tools/v3 v3.0.2 // indirect
|
||||
|
||||
2
go.sum
2
go.sum
@@ -216,8 +216,6 @@ github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1
|
||||
github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk=
|
||||
github.com/juju/ratelimit v1.0.2 h1:sRxmtRiajbvrcLQT7S+JbqU0ntsb9W2yhSdNN8tWfaI=
|
||||
github.com/juju/ratelimit v1.0.2/go.mod h1:qapgC/Gy+xNh9UxzV13HGGl/6UXNN+ct+vwSgWNm/qk=
|
||||
github.com/karrick/godirwalk v1.17.0 h1:b4kY7nqDdioR/6qnbHQyDvmA17u5G1cZ6J+CZXwSWoI=
|
||||
github.com/karrick/godirwalk v1.17.0/go.mod h1:j4mkqPuvaLI8mp1DroR3P6ad7cyYd4c1qeJ3RV7ULlk=
|
||||
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs=
|
||||
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8=
|
||||
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
|
||||
|
||||
21
internal/ufs/LICENSE
Normal file
21
internal/ufs/LICENSE
Normal file
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2024 Matthew Penner
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
21
internal/ufs/README.md
Normal file
21
internal/ufs/README.md
Normal file
@@ -0,0 +1,21 @@
|
||||
# Filesystem
|
||||
|
||||
Coming Soon™
|
||||
|
||||
> TODO
|
||||
|
||||
## Licensing
|
||||
|
||||
Most code in this package is licensed under `MIT` with some exceptions.
|
||||
|
||||
The following files are licensed under `BSD-3-Clause` due to them being copied
|
||||
verbatim or derived from [Go](https://go.dev)'s source code.
|
||||
|
||||
- [`file_posix.go`](./file_posix.go)
|
||||
- [`mkdir_unix.go`](./mkdir_unix.go)
|
||||
- [`path_unix.go`](./path_unix.go)
|
||||
- [`removeall_unix.go`](./removeall_unix.go)
|
||||
- [`stat_unix.go`](./stat_unix.go)
|
||||
- [`walk.go`](./walk.go)
|
||||
|
||||
These changes are not associated with nor endorsed by The Go Authors.
|
||||
12
internal/ufs/doc.go
Normal file
12
internal/ufs/doc.go
Normal file
@@ -0,0 +1,12 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// SPDX-FileCopyrightText: Copyright (c) 2024 Matthew Penner
|
||||
|
||||
// Package ufs provides an abstraction layer for performing I/O on filesystems.
|
||||
// This package is designed to be used in-place of standard `os` package I/O
|
||||
// calls, and is not designed to be used as a generic filesystem abstraction
|
||||
// like the `io/fs` package.
|
||||
//
|
||||
// The primary use-case of this package was to provide a "chroot-like" `os`
|
||||
// wrapper, so we can safely sandbox I/O operations within a directory and
|
||||
// use untrusted arbitrary paths.
|
||||
package ufs
|
||||
183
internal/ufs/error.go
Normal file
183
internal/ufs/error.go
Normal file
@@ -0,0 +1,183 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// SPDX-FileCopyrightText: Copyright (c) 2024 Matthew Penner
|
||||
|
||||
package ufs
|
||||
|
||||
import (
|
||||
"errors"
|
||||
iofs "io/fs"
|
||||
"os"
|
||||
"syscall"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrIsDirectory is an error for when an operation that operates only on
|
||||
// files is given a path to a directory.
|
||||
ErrIsDirectory = errors.New("is a directory")
|
||||
// ErrNotDirectory is an error for when an operation that operates only on
|
||||
// directories is given a path to a file.
|
||||
ErrNotDirectory = errors.New("not a directory")
|
||||
// ErrBadPathResolution is an error for when a sand-boxed filesystem
|
||||
// resolves a given path to a forbidden location.
|
||||
ErrBadPathResolution = errors.New("bad path resolution")
|
||||
// ErrNotRegular is an error for when an operation that operates only on
|
||||
// regular files is passed something other than a regular file.
|
||||
ErrNotRegular = errors.New("not a regular file")
|
||||
|
||||
// ErrClosed is an error for when an entry was accessed after being closed.
|
||||
ErrClosed = iofs.ErrClosed
|
||||
// ErrInvalid is an error for when an invalid argument was used.
|
||||
ErrInvalid = iofs.ErrInvalid
|
||||
// ErrExist is an error for when an entry already exists.
|
||||
ErrExist = iofs.ErrExist
|
||||
// ErrNotExist is an error for when an entry does not exist.
|
||||
ErrNotExist = iofs.ErrNotExist
|
||||
// ErrPermission is an error for when the required permissions to perform an
|
||||
// operation are missing.
|
||||
ErrPermission = iofs.ErrPermission
|
||||
)
|
||||
|
||||
// LinkError records an error during a link or symlink or rename
|
||||
// system call and the paths that caused it.
|
||||
type LinkError = os.LinkError
|
||||
|
||||
// PathError records an error and the operation and file path that caused it.
|
||||
type PathError = iofs.PathError
|
||||
|
||||
// SyscallError records an error from a specific system call.
|
||||
type SyscallError = os.SyscallError
|
||||
|
||||
// NewSyscallError returns, as an error, a new [*os.SyscallError] with the
|
||||
// given system call name and error details. As a convenience, if err is nil,
|
||||
// [NewSyscallError] returns nil.
|
||||
func NewSyscallError(syscall string, err error) error {
|
||||
return os.NewSyscallError(syscall, err)
|
||||
}
|
||||
|
||||
// convertErrorType converts errors into our custom errors to ensure consistent
|
||||
// error values.
|
||||
func convertErrorType(err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var pErr *PathError
|
||||
if errors.As(err, &pErr) {
|
||||
if errno, ok := pErr.Err.(syscall.Errno); ok {
|
||||
return errnoToPathError(errno, pErr.Op, pErr.Path)
|
||||
}
|
||||
return pErr
|
||||
}
|
||||
|
||||
// If the error wasn't already a path error and is a errno, wrap it with
|
||||
// details that we can use to know there is something wrong with our
|
||||
// error wrapping somewhere.
|
||||
var errno syscall.Errno
|
||||
if errors.As(err, &errno) {
|
||||
return &PathError{
|
||||
Op: "!(UNKNOWN)",
|
||||
Path: "!(UNKNOWN)",
|
||||
Err: err,
|
||||
}
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// ensurePathError ensures that err is a PathError. The op and path arguments
|
||||
// are only used of the error isn't already a PathError.
|
||||
func ensurePathError(err error, op, path string) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check if the error is already a PathError.
|
||||
var pErr *PathError
|
||||
if errors.As(err, &pErr) {
|
||||
// If underlying error is a errno, convert it.
|
||||
//
|
||||
// DO NOT USE `errors.As` or whatever here, the error will either be
|
||||
// an errno, or it will be wrapped already.
|
||||
if errno, ok := pErr.Err.(syscall.Errno); ok {
|
||||
return errnoToPathError(errno, pErr.Op, pErr.Path)
|
||||
}
|
||||
// Return the PathError as-is without modification.
|
||||
return pErr
|
||||
}
|
||||
|
||||
// If the error is directly an errno, convert it to a PathError.
|
||||
var errno syscall.Errno
|
||||
if errors.As(err, &errno) {
|
||||
return errnoToPathError(errno, op, path)
|
||||
}
|
||||
|
||||
// Otherwise just wrap it as a PathError without any additional changes.
|
||||
return &PathError{
|
||||
Op: op,
|
||||
Path: path,
|
||||
Err: err,
|
||||
}
|
||||
}
|
||||
|
||||
// errnoToPathError converts an errno into a proper path error.
|
||||
func errnoToPathError(err syscall.Errno, op, path string) error {
|
||||
switch err {
|
||||
// File exists
|
||||
case unix.EEXIST:
|
||||
return &PathError{
|
||||
Op: op,
|
||||
Path: path,
|
||||
Err: ErrExist,
|
||||
}
|
||||
// Is a directory
|
||||
case unix.EISDIR:
|
||||
return &PathError{
|
||||
Op: op,
|
||||
Path: path,
|
||||
Err: ErrIsDirectory,
|
||||
}
|
||||
// Not a directory
|
||||
case unix.ENOTDIR:
|
||||
return &PathError{
|
||||
Op: op,
|
||||
Path: path,
|
||||
Err: ErrNotDirectory,
|
||||
}
|
||||
// No such file or directory
|
||||
case unix.ENOENT:
|
||||
return &PathError{
|
||||
Op: op,
|
||||
Path: path,
|
||||
Err: ErrNotExist,
|
||||
}
|
||||
// Operation not permitted
|
||||
case unix.EPERM:
|
||||
return &PathError{
|
||||
Op: op,
|
||||
Path: path,
|
||||
Err: ErrPermission,
|
||||
}
|
||||
// Invalid cross-device link
|
||||
case unix.EXDEV:
|
||||
return &PathError{
|
||||
Op: op,
|
||||
Path: path,
|
||||
Err: ErrBadPathResolution,
|
||||
}
|
||||
// Too many levels of symbolic links
|
||||
case unix.ELOOP:
|
||||
return &PathError{
|
||||
Op: op,
|
||||
Path: path,
|
||||
Err: ErrBadPathResolution,
|
||||
}
|
||||
default:
|
||||
return &PathError{
|
||||
Op: op,
|
||||
Path: path,
|
||||
Err: err,
|
||||
}
|
||||
}
|
||||
}
|
||||
180
internal/ufs/file.go
Normal file
180
internal/ufs/file.go
Normal file
@@ -0,0 +1,180 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// SPDX-FileCopyrightText: Copyright (c) 2024 Matthew Penner
|
||||
|
||||
package ufs
|
||||
|
||||
import (
|
||||
"io"
|
||||
iofs "io/fs"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
// DirEntry is an entry read from a directory.
|
||||
type DirEntry = iofs.DirEntry
|
||||
|
||||
// File describes readable and/or writable file from a Filesystem.
|
||||
type File interface {
|
||||
// Name returns the base name of the file.
|
||||
Name() string
|
||||
|
||||
// Stat returns the FileInfo structure describing the file.
|
||||
// If there is an error, it will be of type *PathError.
|
||||
Stat() (FileInfo, error)
|
||||
|
||||
// ReadDir reads the contents of the directory associated with the file f
|
||||
// and returns a slice of DirEntry values in directory order.
|
||||
// Subsequent calls on the same file will yield later DirEntry records in the directory.
|
||||
//
|
||||
// If n > 0, ReadDir returns at most n DirEntry records.
|
||||
// In this case, if ReadDir returns an empty slice, it will return an error explaining why.
|
||||
// At the end of a directory, the error is io.EOF.
|
||||
//
|
||||
// If n <= 0, ReadDir returns all the DirEntry records remaining in the directory.
|
||||
// When it succeeds, it returns a nil error (not io.EOF).
|
||||
ReadDir(n int) ([]DirEntry, error)
|
||||
|
||||
// Readdirnames reads the contents of the directory associated with file
|
||||
// and returns a slice of up to n names of files in the directory,
|
||||
// in directory order. Subsequent calls on the same file will yield
|
||||
// further names.
|
||||
//
|
||||
// If n > 0, Readdirnames returns at most n names. In this case, if
|
||||
// Readdirnames returns an empty slice, it will return a non-nil error
|
||||
// explaining why. At the end of a directory, the error is io.EOF.
|
||||
//
|
||||
// If n <= 0, Readdirnames returns all the names from the directory in
|
||||
// a single slice. In this case, if Readdirnames succeeds (reads all
|
||||
// the way to the end of the directory), it returns the slice and a
|
||||
// nil error. If it encounters an error before the end of the
|
||||
// directory, Readdirnames returns the names read until that point and
|
||||
// a non-nil error.
|
||||
Readdirnames(n int) (names []string, err error)
|
||||
|
||||
// Fd returns the integer Unix file descriptor referencing the open file.
|
||||
// If f is closed, the file descriptor becomes invalid.
|
||||
// If f is garbage collected, a finalizer may close the file descriptor,
|
||||
// making it invalid; see runtime.SetFinalizer for more information on when
|
||||
// a finalizer might be run. On Unix systems this will cause the SetDeadline
|
||||
// methods to stop working.
|
||||
// Because file descriptors can be reused, the returned file descriptor may
|
||||
// only be closed through the Close method of f, or by its finalizer during
|
||||
// garbage collection. Otherwise, during garbage collection the finalizer
|
||||
// may close an unrelated file descriptor with the same (reused) number.
|
||||
//
|
||||
// As an alternative, see the f.SyscallConn method.
|
||||
Fd() uintptr
|
||||
|
||||
// Truncate changes the size of the file.
|
||||
// It does not change the I/O offset.
|
||||
// If there is an error, it will be of type *PathError.
|
||||
Truncate(size int64) error
|
||||
|
||||
io.Closer
|
||||
|
||||
io.Reader
|
||||
io.ReaderAt
|
||||
io.ReaderFrom
|
||||
|
||||
io.Writer
|
||||
io.WriterAt
|
||||
|
||||
io.Seeker
|
||||
}
|
||||
|
||||
// FileInfo describes a file and is returned by Stat and Lstat.
|
||||
type FileInfo = iofs.FileInfo
|
||||
|
||||
// FileMode represents a file's mode and permission bits.
|
||||
// The bits have the same definition on all systems, so that
|
||||
// information about files can be moved from one system
|
||||
// to another portably. Not all bits apply to all systems.
|
||||
// The only required bit is ModeDir for directories.
|
||||
type FileMode = iofs.FileMode
|
||||
|
||||
// The defined file mode bits are the most significant bits of the FileMode.
|
||||
// The nine least-significant bits are the standard Unix rwxrwxrwx permissions.
|
||||
// The values of these bits should be considered part of the public API and
|
||||
// may be used in wire protocols or disk representations: they must not be
|
||||
// changed, although new bits might be added.
|
||||
const (
|
||||
// ModeDir represents a directory.
|
||||
// d: is a directory
|
||||
ModeDir = iofs.ModeDir
|
||||
// ModeAppend represents an append-only file.
|
||||
// a: append-only
|
||||
ModeAppend = iofs.ModeAppend
|
||||
// ModeExclusive represents an exclusive file.
|
||||
// l: exclusive use
|
||||
ModeExclusive = iofs.ModeExclusive
|
||||
// ModeTemporary .
|
||||
// T: temporary file; Plan 9 only.
|
||||
ModeTemporary = iofs.ModeTemporary
|
||||
// ModeSymlink .
|
||||
// L: symbolic link.
|
||||
ModeSymlink = iofs.ModeSymlink
|
||||
// ModeDevice .
|
||||
// D: device file.
|
||||
ModeDevice = iofs.ModeDevice
|
||||
// ModeNamedPipe .
|
||||
// p: named pipe (FIFO)
|
||||
ModeNamedPipe = iofs.ModeNamedPipe
|
||||
// ModeSocket .
|
||||
// S: Unix domain socket.
|
||||
ModeSocket = iofs.ModeSocket
|
||||
// ModeSetuid .
|
||||
// u: setuid
|
||||
ModeSetuid = iofs.ModeSetuid
|
||||
// ModeSetgid .
|
||||
// g: setgid
|
||||
ModeSetgid = iofs.ModeSetgid
|
||||
// ModeCharDevice .
|
||||
// c: Unix character device, when ModeDevice is set
|
||||
ModeCharDevice = iofs.ModeCharDevice
|
||||
// ModeSticky .
|
||||
// t: sticky
|
||||
ModeSticky = iofs.ModeSticky
|
||||
// ModeIrregular .
|
||||
// ?: non-regular file; nothing else is known about this file.
|
||||
ModeIrregular = iofs.ModeIrregular
|
||||
|
||||
// ModeType .
|
||||
ModeType = iofs.ModeType
|
||||
|
||||
// ModePerm .
|
||||
// Unix permission bits, 0o777.
|
||||
ModePerm = iofs.ModePerm
|
||||
)
|
||||
|
||||
// Re-using the same names as Go's official `unix` and `os` package do.
|
||||
const (
|
||||
// O_RDONLY opens the file read-only.
|
||||
O_RDONLY = unix.O_RDONLY
|
||||
// O_WRONLY opens the file write-only.
|
||||
O_WRONLY = unix.O_WRONLY
|
||||
// O_RDWR opens the file read-write.
|
||||
O_RDWR = unix.O_RDWR
|
||||
// O_APPEND appends data to the file when writing.
|
||||
O_APPEND = unix.O_APPEND
|
||||
// O_CREATE creates a new file if it doesn't exist.
|
||||
O_CREATE = unix.O_CREAT
|
||||
// O_EXCL is used with O_CREATE, file must not exist.
|
||||
O_EXCL = unix.O_EXCL
|
||||
// O_SYNC open for synchronous I/O.
|
||||
O_SYNC = unix.O_SYNC
|
||||
// O_TRUNC truncates regular writable file when opened.
|
||||
O_TRUNC = unix.O_TRUNC
|
||||
// O_DIRECTORY opens a directory only. If the entry is not a directory an
|
||||
// error will be returned.
|
||||
O_DIRECTORY = unix.O_DIRECTORY
|
||||
// O_NOFOLLOW opens the exact path given without following symlinks.
|
||||
O_NOFOLLOW = unix.O_NOFOLLOW
|
||||
O_CLOEXEC = unix.O_CLOEXEC
|
||||
O_LARGEFILE = unix.O_LARGEFILE
|
||||
)
|
||||
|
||||
const (
|
||||
AT_SYMLINK_NOFOLLOW = unix.AT_SYMLINK_NOFOLLOW
|
||||
AT_REMOVEDIR = unix.AT_REMOVEDIR
|
||||
AT_EMPTY_PATH = unix.AT_EMPTY_PATH
|
||||
)
|
||||
49
internal/ufs/file_posix.go
Normal file
49
internal/ufs/file_posix.go
Normal file
@@ -0,0 +1,49 @@
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
// Code in this file was copied from `go/src/os/file_posix.go`.
|
||||
|
||||
// Copyright 2009 The Go Authors. All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style
|
||||
// license that can be found in the `go.LICENSE` file.
|
||||
|
||||
//go:build unix || (js && wasm) || wasip1 || windows
|
||||
|
||||
package ufs
|
||||
|
||||
import (
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
// ignoringEINTR makes a function call and repeats it if it returns an
|
||||
// EINTR error. This appears to be required even though we install all
|
||||
// signal handlers with SA_RESTART: see https://go.dev/issue/22838,
|
||||
// https://go.dev/issue/38033, https://go.dev/issue/38836,
|
||||
// https://go.dev/issue/40846. Also, https://go.dev/issue/20400 and
|
||||
// https://go.dev/issue/36644 are issues in which a signal handler is
|
||||
// installed without setting SA_RESTART. None of these are the common case,
|
||||
// but there are enough of them that it seems that we can't avoid
|
||||
// an EINTR loop.
|
||||
func ignoringEINTR(fn func() error) error {
|
||||
for {
|
||||
err := fn()
|
||||
if err != unix.EINTR {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// syscallMode returns the syscall-specific mode bits from Go's portable mode bits.
|
||||
func syscallMode(i FileMode) (o FileMode) {
|
||||
o |= i.Perm()
|
||||
if i&ModeSetuid != 0 {
|
||||
o |= unix.S_ISUID
|
||||
}
|
||||
if i&ModeSetgid != 0 {
|
||||
o |= unix.S_ISGID
|
||||
}
|
||||
if i&ModeSticky != 0 {
|
||||
o |= unix.S_ISVTX
|
||||
}
|
||||
// No mapping for Go's ModeTemporary (plan9 only).
|
||||
return
|
||||
}
|
||||
168
internal/ufs/filesystem.go
Normal file
168
internal/ufs/filesystem.go
Normal file
@@ -0,0 +1,168 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// SPDX-FileCopyrightText: Copyright (c) 2024 Matthew Penner
|
||||
|
||||
package ufs
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// Filesystem represents a filesystem capable of performing I/O operations.
|
||||
type Filesystem interface {
|
||||
// Chmod changes the mode of the named file to mode.
|
||||
//
|
||||
// If the file is a symbolic link, it changes the mode of the link's target.
|
||||
// If there is an error, it will be of type *PathError.
|
||||
//
|
||||
// A different subset of the mode bits are used, depending on the
|
||||
// operating system.
|
||||
//
|
||||
// On Unix, the mode's permission bits, ModeSetuid, ModeSetgid, and
|
||||
// ModeSticky are used.
|
||||
//
|
||||
// On Windows, only the 0200 bit (owner writable) of mode is used; it
|
||||
// controls whether the file's read-only attribute is set or cleared.
|
||||
// The other bits are currently unused. For compatibility with Go 1.12
|
||||
// and earlier, use a non-zero mode. Use mode 0400 for a read-only
|
||||
// file and 0600 for a readable+writable file.
|
||||
//
|
||||
// On Plan 9, the mode's permission bits, ModeAppend, ModeExclusive,
|
||||
// and ModeTemporary are used.
|
||||
Chmod(name string, mode FileMode) error
|
||||
|
||||
// Chown changes the numeric uid and gid of the named file.
|
||||
//
|
||||
// If the file is a symbolic link, it changes the uid and gid of the link's target.
|
||||
// A uid or gid of -1 means to not change that value.
|
||||
// If there is an error, it will be of type *PathError.
|
||||
//
|
||||
// On Windows or Plan 9, Chown always returns the syscall.EWINDOWS or
|
||||
// EPLAN9 error, wrapped in *PathError.
|
||||
Chown(name string, uid, gid int) error
|
||||
|
||||
// Lchown changes the numeric uid and gid of the named file.
|
||||
//
|
||||
// If the file is a symbolic link, it changes the uid and gid of the link itself.
|
||||
// If there is an error, it will be of type *PathError.
|
||||
//
|
||||
// On Windows, it always returns the syscall.EWINDOWS error, wrapped
|
||||
// in *PathError.
|
||||
Lchown(name string, uid, gid int) error
|
||||
|
||||
// Chtimes changes the access and modification times of the named
|
||||
// file, similar to the Unix utime() or utimes() functions.
|
||||
//
|
||||
// The underlying filesystem may truncate or round the values to a
|
||||
// less precise time unit.
|
||||
//
|
||||
// If there is an error, it will be of type *PathError.
|
||||
Chtimes(name string, atime, mtime time.Time) error
|
||||
|
||||
// Create creates or truncates the named file. If the file already exists,
|
||||
// it is truncated.
|
||||
//
|
||||
// If the file does not exist, it is created with mode 0666
|
||||
// (before umask). If successful, methods on the returned File can
|
||||
// be used for I/O; the associated file descriptor has mode O_RDWR.
|
||||
// If there is an error, it will be of type *PathError.
|
||||
Create(name string) (File, error)
|
||||
|
||||
// Mkdir creates a new directory with the specified name and permission
|
||||
// bits (before umask).
|
||||
//
|
||||
// If there is an error, it will be of type *PathError.
|
||||
Mkdir(name string, perm FileMode) error
|
||||
|
||||
// MkdirAll creates a directory named path, along with any necessary
|
||||
// parents, and returns nil, or else returns an error.
|
||||
//
|
||||
// The permission bits perm (before umask) are used for all
|
||||
// directories that MkdirAll creates.
|
||||
// If path is already a directory, MkdirAll does nothing
|
||||
// and returns nil.
|
||||
MkdirAll(path string, perm FileMode) error
|
||||
|
||||
// Open opens the named file for reading.
|
||||
//
|
||||
// If successful, methods on the returned file can be used for reading; the
|
||||
// associated file descriptor has mode O_RDONLY.
|
||||
//
|
||||
// If there is an error, it will be of type *PathError.
|
||||
Open(name string) (File, error)
|
||||
|
||||
// OpenFile is the generalized open call; most users will use Open
|
||||
// or Create instead. It opens the named file with specified flag
|
||||
// (O_RDONLY etc.).
|
||||
//
|
||||
// If the file does not exist, and the O_CREATE flag
|
||||
// is passed, it is created with mode perm (before umask). If successful,
|
||||
// methods on the returned File can be used for I/O.
|
||||
//
|
||||
// If there is an error, it will be of type *PathError.
|
||||
OpenFile(name string, flag int, perm FileMode) (File, error)
|
||||
|
||||
// ReadDir reads the named directory,
|
||||
//
|
||||
// returning all its directory entries sorted by filename.
|
||||
// If an error occurs reading the directory, ReadDir returns the entries it
|
||||
// was able to read before the error, along with the error.
|
||||
ReadDir(name string) ([]DirEntry, error)
|
||||
|
||||
// Remove removes the named file or (empty) directory.
|
||||
//
|
||||
// If there is an error, it will be of type *PathError.
|
||||
Remove(name string) error
|
||||
|
||||
// RemoveAll removes path and any children it contains.
|
||||
//
|
||||
// It removes everything it can but returns the first error
|
||||
// it encounters. If the path does not exist, RemoveAll
|
||||
// returns nil (no error).
|
||||
//
|
||||
// If there is an error, it will be of type *PathError.
|
||||
RemoveAll(path string) error
|
||||
|
||||
// Rename renames (moves) oldpath to newpath.
|
||||
//
|
||||
// If newpath already exists and is not a directory, Rename replaces it.
|
||||
// OS-specific restrictions may apply when oldpath and newpath are in different directories.
|
||||
// Even within the same directory, on non-Unix platforms Rename is not an atomic operation.
|
||||
//
|
||||
// If there is an error, it will be of type *LinkError.
|
||||
Rename(oldname, newname string) error
|
||||
|
||||
// Stat returns a FileInfo describing the named file.
|
||||
//
|
||||
// If there is an error, it will be of type *PathError.
|
||||
Stat(name string) (FileInfo, error)
|
||||
|
||||
// Lstat returns a FileInfo describing the named file.
|
||||
//
|
||||
// If the file is a symbolic link, the returned FileInfo
|
||||
// describes the symbolic link. Lstat makes no attempt to follow the link.
|
||||
//
|
||||
// If there is an error, it will be of type *PathError.
|
||||
Lstat(name string) (FileInfo, error)
|
||||
|
||||
// Symlink creates newname as a symbolic link to oldname.
|
||||
//
|
||||
// On Windows, a symlink to a non-existent oldname creates a file symlink;
|
||||
// if oldname is later created as a directory the symlink will not work.
|
||||
//
|
||||
// If there is an error, it will be of type *LinkError.
|
||||
Symlink(oldname, newname string) error
|
||||
|
||||
// WalkDir walks the file tree rooted at root, calling fn for each file or
|
||||
// directory in the tree, including root.
|
||||
//
|
||||
// All errors that arise visiting files and directories are filtered by fn:
|
||||
// see the [WalkDirFunc] documentation for details.
|
||||
//
|
||||
// The files are walked in lexical order, which makes the output deterministic
|
||||
// but requires WalkDir to read an entire directory into memory before proceeding
|
||||
// to walk that directory.
|
||||
//
|
||||
// WalkDir does not follow symbolic links found in directories,
|
||||
// but if root itself is a symbolic link, its target will be walked.
|
||||
WalkDir(root string, fn WalkDirFunc) error
|
||||
}
|
||||
169
internal/ufs/fs_quota.go
Normal file
169
internal/ufs/fs_quota.go
Normal file
@@ -0,0 +1,169 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// SPDX-FileCopyrightText: Copyright (c) 2024 Matthew Penner
|
||||
|
||||
package ufs
|
||||
|
||||
import (
|
||||
"sync/atomic"
|
||||
)
|
||||
|
||||
// Quota is a wrapper around [*UnixFS] that provides the ability to limit the
|
||||
// disk usage of the filesystem.
|
||||
//
|
||||
// NOTE: this is not a full complete quota filesystem, it provides utilities for
|
||||
// tracking and checking the usage of the filesystem. The only operation that is
|
||||
// automatically accounted against the quota are file deletions.
|
||||
type Quota struct {
|
||||
// fs is the underlying filesystem that runs the actual I/O operations.
|
||||
*UnixFS
|
||||
|
||||
// limit is the size limit of the filesystem.
|
||||
//
|
||||
// limit is atomic to allow the limit to be safely changed after the
|
||||
// filesystem was created.
|
||||
//
|
||||
// A limit of `-1` disables any write operation from being performed.
|
||||
// A limit of `0` disables any limit checking.
|
||||
limit atomic.Int64
|
||||
|
||||
// usage is the current usage of the filesystem.
|
||||
//
|
||||
// If usage is set to `-1`, it hasn't been calculated yet.
|
||||
usage atomic.Int64
|
||||
}
|
||||
|
||||
// NewQuota creates a new Quota filesystem using an existing UnixFS and a limit.
|
||||
func NewQuota(fs *UnixFS, limit int64) *Quota {
|
||||
qfs := Quota{UnixFS: fs}
|
||||
qfs.limit.Store(limit)
|
||||
return &qfs
|
||||
}
|
||||
|
||||
// Close closes the filesystem.
|
||||
func (fs *Quota) Close() (err error) {
|
||||
err = fs.UnixFS.Close()
|
||||
return
|
||||
}
|
||||
|
||||
// Limit returns the limit of the filesystem.
|
||||
func (fs *Quota) Limit() int64 {
|
||||
return fs.limit.Load()
|
||||
}
|
||||
|
||||
// SetLimit returns the limit of the filesystem.
|
||||
func (fs *Quota) SetLimit(newLimit int64) int64 {
|
||||
return fs.limit.Swap(newLimit)
|
||||
}
|
||||
|
||||
// Usage returns the current usage of the filesystem.
|
||||
func (fs *Quota) Usage() int64 {
|
||||
return fs.usage.Load()
|
||||
}
|
||||
|
||||
// SetUsage updates the total usage of the filesystem.
|
||||
func (fs *Quota) SetUsage(newUsage int64) int64 {
|
||||
return fs.usage.Swap(newUsage)
|
||||
}
|
||||
|
||||
// Add adds `i` to the tracked usage total.
|
||||
func (fs *Quota) Add(i int64) int64 {
|
||||
usage := fs.Usage()
|
||||
|
||||
// If adding `i` to the usage will put us below 0, cap it. (`i` can be negative)
|
||||
if usage+i < 0 {
|
||||
fs.usage.Store(0)
|
||||
return 0
|
||||
}
|
||||
return fs.usage.Add(i)
|
||||
}
|
||||
|
||||
// CanFit checks if the given size can fit in the filesystem without exceeding
|
||||
// the limit of the filesystem.
|
||||
func (fs *Quota) CanFit(size int64) bool {
|
||||
// Get the size limit of the filesystem.
|
||||
limit := fs.Limit()
|
||||
switch limit {
|
||||
case -1:
|
||||
// A limit of -1 means no write operations are allowed.
|
||||
return false
|
||||
case 0:
|
||||
// A limit of 0 means unlimited.
|
||||
return true
|
||||
}
|
||||
|
||||
// Any other limit is a value we need to check.
|
||||
usage := fs.Usage()
|
||||
if usage == -1 {
|
||||
// We don't know what the current usage is yet.
|
||||
return true
|
||||
}
|
||||
|
||||
// If the current usage + the requested size are under the limit of the
|
||||
// filesystem, allow it.
|
||||
if usage+size <= limit {
|
||||
return true
|
||||
}
|
||||
|
||||
// Welp, the size would exceed the limit of the filesystem, deny it.
|
||||
return false
|
||||
}
|
||||
|
||||
// Remove removes the named file or (empty) directory.
|
||||
//
|
||||
// If there is an error, it will be of type [*PathError].
|
||||
func (fs *Quota) Remove(name string) error {
|
||||
// For information on why this interface is used here, check its
|
||||
// documentation.
|
||||
s, err := fs.RemoveStat(name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Don't reduce the quota's usage as `name` is not a regular file.
|
||||
if !s.Mode().IsRegular() {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Remove the size of the deleted file from the quota usage.
|
||||
fs.Add(-s.Size())
|
||||
return nil
|
||||
}
|
||||
|
||||
// RemoveAll removes path and any children it contains.
|
||||
//
|
||||
// It removes everything it can but returns the first error
|
||||
// it encounters. If the path does not exist, RemoveAll
|
||||
// returns nil (no error).
|
||||
//
|
||||
// If there is an error, it will be of type [*PathError].
|
||||
func (fs *Quota) RemoveAll(name string) error {
|
||||
name, err := fs.unsafePath(name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// While removeAll internally checks this, I want to make sure we check it
|
||||
// and return the proper error so our tests can ensure that this will never
|
||||
// be a possibility.
|
||||
if name == "." {
|
||||
return &PathError{
|
||||
Op: "removeall",
|
||||
Path: name,
|
||||
Err: ErrBadPathResolution,
|
||||
}
|
||||
}
|
||||
return fs.removeAll(name)
|
||||
}
|
||||
|
||||
func (fs *Quota) removeAll(path string) error {
|
||||
return removeAll(fs, path)
|
||||
}
|
||||
|
||||
func (fs *Quota) unlinkat(dirfd int, name string, flags int) error {
|
||||
if flags == 0 {
|
||||
s, err := fs.Lstatat(dirfd, name)
|
||||
if err == nil && s.Mode().IsRegular() {
|
||||
fs.Add(-s.Size())
|
||||
}
|
||||
}
|
||||
return fs.UnixFS.unlinkat(dirfd, name, flags)
|
||||
}
|
||||
895
internal/ufs/fs_unix.go
Normal file
895
internal/ufs/fs_unix.go
Normal file
@@ -0,0 +1,895 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// SPDX-FileCopyrightText: Copyright (c) 2024 Matthew Penner
|
||||
|
||||
//go:build unix
|
||||
|
||||
package ufs
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
// UnixFS is a filesystem that uses the unix package to make io calls.
|
||||
//
|
||||
// This is used for proper sand-boxing and full control over the exact syscalls
|
||||
// being performed.
|
||||
type UnixFS struct {
|
||||
// basePath is the base path for file operations to take place in.
|
||||
basePath string
|
||||
|
||||
// useOpenat2 controls whether the `openat2` syscall is used instead of the
|
||||
// older `openat` syscall.
|
||||
useOpenat2 bool
|
||||
}
|
||||
|
||||
// NewUnixFS creates a new sandboxed unix filesystem. BasePath is used as the
|
||||
// sandbox path, operations on BasePath itself are not allowed, but any
|
||||
// operations on its descendants are. Symlinks pointing outside BasePath are
|
||||
// checked and prevented from enabling an escape in a non-raceable manor.
|
||||
func NewUnixFS(basePath string, useOpenat2 bool) (*UnixFS, error) {
|
||||
basePath = strings.TrimSuffix(basePath, "/")
|
||||
fs := &UnixFS{
|
||||
basePath: basePath,
|
||||
useOpenat2: useOpenat2,
|
||||
}
|
||||
return fs, nil
|
||||
}
|
||||
|
||||
// BasePath returns the base path of the UnixFS sandbox, file operations
|
||||
// pointing outside this path are prohibited and will be blocked by all
|
||||
// operations implemented by UnixFS.
|
||||
func (fs *UnixFS) BasePath() string {
|
||||
return fs.basePath
|
||||
}
|
||||
|
||||
// Close releases the file descriptor used to sandbox operations within the
|
||||
// base path of the filesystem.
|
||||
func (fs *UnixFS) Close() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Chmod changes the mode of the named file to mode.
|
||||
//
|
||||
// If the file is a symbolic link, it changes the mode of the link's target.
|
||||
// If there is an error, it will be of type *PathError.
|
||||
//
|
||||
// A different subset of the mode bits are used, depending on the
|
||||
// operating system.
|
||||
//
|
||||
// On Unix, the mode's permission bits, ModeSetuid, ModeSetgid, and
|
||||
// ModeSticky are used.
|
||||
//
|
||||
// On Windows, only the 0200 bit (owner writable) of mode is used; it
|
||||
// controls whether the file's read-only attribute is set or cleared.
|
||||
// The other bits are currently unused. For compatibility with Go 1.12
|
||||
// and earlier, use a non-zero mode. Use mode 0400 for a read-only
|
||||
// file and 0600 for a readable+writable file.
|
||||
//
|
||||
// On Plan 9, the mode's permission bits, ModeAppend, ModeExclusive,
|
||||
// and ModeTemporary are used.
|
||||
func (fs *UnixFS) Chmod(name string, mode FileMode) error {
|
||||
dirfd, name, closeFd, err := fs.safePath(name)
|
||||
defer closeFd()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return fs.fchmodat("chmod", dirfd, name, mode)
|
||||
}
|
||||
|
||||
// Chmodat is like Chmod but it takes a dirfd and name instead of a full path.
|
||||
func (fs *UnixFS) Chmodat(dirfd int, name string, mode FileMode) error {
|
||||
return fs.fchmodat("chmodat", dirfd, name, mode)
|
||||
}
|
||||
|
||||
func (fs *UnixFS) fchmodat(op string, dirfd int, name string, mode FileMode) error {
|
||||
return ensurePathError(unix.Fchmodat(dirfd, name, uint32(mode), 0), op, name)
|
||||
}
|
||||
|
||||
// Chown changes the numeric uid and gid of the named file.
|
||||
//
|
||||
// If the file is a symbolic link, it changes the uid and gid of the link's target.
|
||||
// A uid or gid of -1 means to not change that value.
|
||||
// If there is an error, it will be of type *PathError.
|
||||
//
|
||||
// On Windows or Plan 9, Chown always returns the syscall.EWINDOWS or
|
||||
// EPLAN9 error, wrapped in *PathError.
|
||||
func (fs *UnixFS) Chown(name string, uid, gid int) error {
|
||||
return ensurePathError(fs.fchown(name, uid, gid, 0), "chown", name)
|
||||
}
|
||||
|
||||
// Lchown changes the numeric uid and gid of the named file.
|
||||
//
|
||||
// If the file is a symbolic link, it changes the uid and gid of the link itself.
|
||||
// If there is an error, it will be of type *PathError.
|
||||
//
|
||||
// On Windows, it always returns the syscall.EWINDOWS error, wrapped
|
||||
// in *PathError.
|
||||
func (fs *UnixFS) Lchown(name string, uid, gid int) error {
|
||||
// With AT_SYMLINK_NOFOLLOW, Fchownat acts like Lchown but allows us to
|
||||
// pass a dirfd.
|
||||
return ensurePathError(fs.fchown(name, uid, gid, AT_SYMLINK_NOFOLLOW), "lchown", name)
|
||||
}
|
||||
|
||||
// fchown is a re-usable Fchownat syscall used by Chown and Lchown.
|
||||
func (fs *UnixFS) fchown(name string, uid, gid, flags int) error {
|
||||
dirfd, name, closeFd, err := fs.safePath(name)
|
||||
defer closeFd()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return unix.Fchownat(dirfd, name, uid, gid, flags)
|
||||
}
|
||||
|
||||
// Chownat is like Chown but allows passing an existing directory file
|
||||
// descriptor rather than needing to resolve one.
|
||||
func (fs *UnixFS) Chownat(dirfd int, name string, uid, gid int) error {
|
||||
return ensurePathError(unix.Fchownat(dirfd, name, uid, gid, 0), "chownat", name)
|
||||
}
|
||||
|
||||
// Lchownat is like Lchown but allows passing an existing directory file
|
||||
// descriptor rather than needing to resolve one.
|
||||
func (fs *UnixFS) Lchownat(dirfd int, name string, uid, gid int) error {
|
||||
return ensurePathError(unix.Fchownat(dirfd, name, uid, gid, AT_SYMLINK_NOFOLLOW), "lchownat", name)
|
||||
}
|
||||
|
||||
// Chtimes changes the access and modification times of the named
|
||||
// file, similar to the Unix utime() or utimes() functions.
|
||||
//
|
||||
// The underlying filesystem may truncate or round the values to a
|
||||
// less precise time unit.
|
||||
//
|
||||
// If there is an error, it will be of type *PathError.
|
||||
func (fs *UnixFS) Chtimes(name string, atime, mtime time.Time) error {
|
||||
dirfd, name, closeFd, err := fs.safePath(name)
|
||||
defer closeFd()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return fs.Chtimesat(dirfd, name, atime, mtime)
|
||||
}
|
||||
|
||||
// Chtimesat is like Chtimes but allows passing an existing directory file
|
||||
// descriptor rather than needing to resolve one.
|
||||
func (fs *UnixFS) Chtimesat(dirfd int, name string, atime, mtime time.Time) error {
|
||||
var utimes [2]unix.Timespec
|
||||
set := func(i int, t time.Time) {
|
||||
if t.IsZero() {
|
||||
utimes[i] = unix.Timespec{Sec: unix.UTIME_OMIT, Nsec: unix.UTIME_OMIT}
|
||||
} else {
|
||||
utimes[i] = unix.NsecToTimespec(t.UnixNano())
|
||||
}
|
||||
}
|
||||
set(0, atime)
|
||||
set(1, mtime)
|
||||
|
||||
// This does support `AT_SYMLINK_NOFOLLOW` as well if needed.
|
||||
return ensurePathError(unix.UtimesNanoAt(dirfd, name, utimes[0:], 0), "chtimes", name)
|
||||
}
|
||||
|
||||
// Create creates or truncates the named file. If the file already exists,
|
||||
// it is truncated.
|
||||
//
|
||||
// If the file does not exist, it is created with mode 0666
|
||||
// (before umask). If successful, methods on the returned File can
|
||||
// be used for I/O; the associated file descriptor has mode O_RDWR.
|
||||
// If there is an error, it will be of type *PathError.
|
||||
func (fs *UnixFS) Create(name string) (File, error) {
|
||||
return fs.OpenFile(name, O_CREATE|O_WRONLY|O_TRUNC, 0o644)
|
||||
}
|
||||
|
||||
// Mkdir creates a new directory with the specified name and permission
|
||||
// bits (before umask).
|
||||
//
|
||||
// If there is an error, it will be of type *PathError.
|
||||
func (fs *UnixFS) Mkdir(name string, mode FileMode) error {
|
||||
dirfd, name, closeFd, err := fs.safePath(name)
|
||||
defer closeFd()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return fs.mkdirat("mkdir", dirfd, name, mode)
|
||||
}
|
||||
|
||||
func (fs *UnixFS) Mkdirat(dirfd int, name string, mode FileMode) error {
|
||||
return fs.mkdirat("mkdirat", dirfd, name, mode)
|
||||
}
|
||||
|
||||
func (fs *UnixFS) mkdirat(op string, dirfd int, name string, mode FileMode) error {
|
||||
return ensurePathError(unix.Mkdirat(dirfd, name, uint32(mode)), op, name)
|
||||
}
|
||||
|
||||
// MkdirAll creates a directory named path, along with any necessary
|
||||
// parents, and returns nil, or else returns an error.
|
||||
//
|
||||
// The permission bits perm (before umask) are used for all
|
||||
// directories that MkdirAll creates.
|
||||
// If path is already a directory, MkdirAll does nothing
|
||||
// and returns nil.
|
||||
func (fs *UnixFS) MkdirAll(name string, mode FileMode) error {
|
||||
// Ensure name is somewhat clean before continuing.
|
||||
name, err := fs.unsafePath(name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return fs.mkdirAll(name, mode)
|
||||
}
|
||||
|
||||
// Open opens the named file for reading.
|
||||
//
|
||||
// If successful, methods on the returned file can be used for reading; the
|
||||
// associated file descriptor has mode O_RDONLY.
|
||||
//
|
||||
// If there is an error, it will be of type *PathError.
|
||||
func (fs *UnixFS) Open(name string) (File, error) {
|
||||
return fs.OpenFile(name, O_RDONLY, 0)
|
||||
}
|
||||
|
||||
// OpenFile is the generalized open call; most users will use Open
|
||||
// or Create instead. It opens the named file with specified flag
|
||||
// (O_RDONLY etc.).
|
||||
//
|
||||
// If the file does not exist, and the O_CREATE flag
|
||||
// is passed, it is created with mode perm (before umask). If successful,
|
||||
// methods on the returned File can be used for I/O.
|
||||
//
|
||||
// If there is an error, it will be of type *PathError.
|
||||
func (fs *UnixFS) OpenFile(name string, flag int, mode FileMode) (File, error) {
|
||||
fd, err := fs.openFile(name, flag, mode)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Do not close `fd` here, it is passed to a file that needs the fd, the
|
||||
// caller of this function is responsible for calling Close() on the File
|
||||
// to release the file descriptor.
|
||||
return os.NewFile(uintptr(fd), name), nil
|
||||
}
|
||||
|
||||
func (fs *UnixFS) openFile(name string, flag int, mode FileMode) (int, error) {
|
||||
dirfd, name, closeFd, err := fs.safePath(name)
|
||||
defer closeFd()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return fs.openat(dirfd, name, flag, mode)
|
||||
}
|
||||
|
||||
func (fs *UnixFS) OpenFileat(dirfd int, name string, flag int, mode FileMode) (File, error) {
|
||||
fd, err := fs.openat(dirfd, name, flag, mode)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Do not close `fd` here, it is passed to a file that needs the fd, the
|
||||
// caller of this function is responsible for calling Close() on the File
|
||||
// to release the file descriptor.
|
||||
return os.NewFile(uintptr(fd), name), nil
|
||||
}
|
||||
|
||||
// ReadDir reads the named directory,
|
||||
//
|
||||
// returning all its directory entries sorted by filename.
|
||||
// If an error occurs reading the directory, ReadDir returns the entries it
|
||||
// was able to read before the error, along with the error.
|
||||
func (fs *UnixFS) ReadDir(path string) ([]DirEntry, error) {
|
||||
dirfd, name, closeFd, err := fs.safePath(path)
|
||||
defer closeFd()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
fd, err := fs.openat(dirfd, name, O_DIRECTORY|O_RDONLY, 0)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() {
|
||||
_ = unix.Close(fd)
|
||||
}()
|
||||
return fs.readDir(fd, name, ".", nil)
|
||||
}
|
||||
|
||||
// RemoveStat is a combination of Stat and Remove, it is used to more
|
||||
// efficiently remove a file when the caller needs to stat it before
|
||||
// removing it.
|
||||
//
|
||||
// This optimized function exists for our QuotaFS abstraction, which needs
|
||||
// to track writes to a filesystem. When removing a file, the QuotaFS needs
|
||||
// to know if the entry is a file and if so, how large it is. Because we
|
||||
// need to Stat a file in order to get its mode and size, we will already
|
||||
// know if the entry needs to be removed by using Unlink or Rmdir. The
|
||||
// standard `Remove` method just tries both Unlink and Rmdir (in that order)
|
||||
// as it ends up usually being faster and more efficient than calling Stat +
|
||||
// the proper operation in the first place.
|
||||
func (fs *UnixFS) RemoveStat(name string) (FileInfo, error) {
|
||||
dirfd, name, closeFd, err := fs.safePath(name)
|
||||
defer closeFd()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Lstat name, we use Lstat as Unlink doesn't care about symlinks.
|
||||
s, err := fs.Lstatat(dirfd, name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if s.IsDir() {
|
||||
err = fs.unlinkat(dirfd, name, AT_REMOVEDIR) // Rmdir
|
||||
} else {
|
||||
err = fs.unlinkat(dirfd, name, 0)
|
||||
}
|
||||
if err != nil {
|
||||
return s, ensurePathError(err, "rename", name)
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// Remove removes the named file or (empty) directory.
|
||||
//
|
||||
// If there is an error, it will be of type *PathError.
|
||||
func (fs *UnixFS) Remove(name string) error {
|
||||
dirfd, name, closeFd, err := fs.safePath(name)
|
||||
defer closeFd()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Prevent trying to Remove the base directory.
|
||||
if name == "." {
|
||||
return &PathError{
|
||||
Op: "remove",
|
||||
Path: name,
|
||||
Err: ErrBadPathResolution,
|
||||
}
|
||||
}
|
||||
|
||||
// System call interface forces us to know
|
||||
// whether name is a file or directory.
|
||||
// Try both: it is cheaper on average than
|
||||
// doing a Stat plus the right one.
|
||||
err = fs.unlinkat(dirfd, name, 0)
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
err1 := fs.unlinkat(dirfd, name, AT_REMOVEDIR) // Rmdir
|
||||
if err1 == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Both failed: figure out which error to return.
|
||||
// OS X and Linux differ on whether unlink(dir)
|
||||
// returns EISDIR, so can't use that. However,
|
||||
// both agree that rmdir(file) returns ENOTDIR,
|
||||
// so we can use that to decide which error is real.
|
||||
// Rmdir might also return ENOTDIR if given a bad
|
||||
// file path, like /etc/passwd/foo, but in that case,
|
||||
// both errors will be ENOTDIR, so it's okay to
|
||||
// use the error from unlink.
|
||||
if err1 != unix.ENOTDIR {
|
||||
err = err1
|
||||
}
|
||||
return ensurePathError(err, "remove", name)
|
||||
}
|
||||
|
||||
// RemoveAll removes path and any children it contains.
|
||||
//
|
||||
// It removes everything it can but returns the first error
|
||||
// it encounters. If the path does not exist, RemoveAll
|
||||
// returns nil (no error).
|
||||
//
|
||||
// If there is an error, it will be of type *PathError.
|
||||
func (fs *UnixFS) RemoveAll(name string) error {
|
||||
name, err := fs.unsafePath(name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// While removeAll internally checks this, I want to make sure we check it
|
||||
// and return the proper error so our tests can ensure that this will never
|
||||
// be a possibility.
|
||||
if name == "." {
|
||||
return &PathError{
|
||||
Op: "removeall",
|
||||
Path: name,
|
||||
Err: ErrBadPathResolution,
|
||||
}
|
||||
}
|
||||
|
||||
return fs.removeAll(name)
|
||||
}
|
||||
|
||||
// RemoveContents recursively removes the contents of name.
|
||||
//
|
||||
// It removes everything it can but returns the first error
|
||||
// it encounters. If the path does not exist, RemoveContents
|
||||
// returns nil (no error).
|
||||
//
|
||||
// If there is an error, it will be of type [*PathError].
|
||||
func (fs *UnixFS) RemoveContents(name string) error {
|
||||
name, err := fs.unsafePath(name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Unlike RemoveAll, we don't remove `name` itself, only it's contents.
|
||||
// So there is no need to check for a name of `.` here.
|
||||
|
||||
return fs.removeContents(name)
|
||||
}
|
||||
|
||||
func (fs *UnixFS) unlinkat(dirfd int, name string, flags int) error {
|
||||
return ignoringEINTR(func() error {
|
||||
return unix.Unlinkat(dirfd, name, flags)
|
||||
})
|
||||
}
|
||||
|
||||
// Rename renames (moves) oldpath to newpath.
|
||||
//
|
||||
// If newpath already exists and is not a directory, Rename replaces it.
|
||||
// OS-specific restrictions may apply when oldpath and newpath are in different directories.
|
||||
// Even within the same directory, on non-Unix platforms Rename is not an atomic operation.
|
||||
//
|
||||
// If there is an error, it will be of type *LinkError.
|
||||
func (fs *UnixFS) Rename(oldpath, newpath string) error {
|
||||
// Simple case: both paths are the same.
|
||||
if oldpath == newpath {
|
||||
return nil
|
||||
}
|
||||
|
||||
olddirfd, oldname, closeFd, err := fs.safePath(oldpath)
|
||||
defer closeFd()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Ensure that we are not trying to rename the base directory itself.
|
||||
// While unix.Renameat ends up throwing a "device or resource busy" error,
|
||||
// that doesn't mean we are protecting the system properly.
|
||||
if oldname == "." {
|
||||
return &PathError{
|
||||
Op: "rename",
|
||||
Path: oldname,
|
||||
Err: ErrBadPathResolution,
|
||||
}
|
||||
}
|
||||
// Stat the old target to return proper errors.
|
||||
if _, err := fs.Lstatat(olddirfd, oldname); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
newdirfd, newname, closeFd2, err := fs.safePath(newpath)
|
||||
if err != nil {
|
||||
closeFd2()
|
||||
if !errors.Is(err, ErrNotExist) {
|
||||
return err
|
||||
}
|
||||
var pathErr *PathError
|
||||
if !errors.As(err, &pathErr) {
|
||||
return err
|
||||
}
|
||||
if err := fs.MkdirAll(pathErr.Path, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
newdirfd, newname, closeFd2, err = fs.safePath(newpath)
|
||||
defer closeFd2()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
defer closeFd2()
|
||||
}
|
||||
|
||||
// Ensure that we are not trying to rename the base directory itself.
|
||||
// While unix.Renameat ends up throwing a "device or resource busy" error,
|
||||
// that doesn't mean we are protecting the system properly.
|
||||
if newname == "." {
|
||||
return &PathError{
|
||||
Op: "rename",
|
||||
Path: newname,
|
||||
Err: ErrBadPathResolution,
|
||||
}
|
||||
}
|
||||
// Stat the new target to return proper errors.
|
||||
_, err = fs.Lstatat(newdirfd, newname)
|
||||
switch {
|
||||
case err == nil:
|
||||
return &PathError{
|
||||
Op: "rename",
|
||||
Path: newname,
|
||||
Err: ErrExist,
|
||||
}
|
||||
case !errors.Is(err, ErrNotExist):
|
||||
return err
|
||||
}
|
||||
if err := unix.Renameat(olddirfd, oldname, newdirfd, newname); err != nil {
|
||||
return &LinkError{Op: "rename", Old: oldpath, New: newpath, Err: err}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Stat returns a FileInfo describing the named file.
|
||||
//
|
||||
// If there is an error, it will be of type *PathError.
|
||||
func (fs *UnixFS) Stat(name string) (FileInfo, error) {
|
||||
return fs._fstat("stat", name, 0)
|
||||
}
|
||||
|
||||
// Statat is like Stat but allows passing an existing directory file
|
||||
// descriptor rather than needing to resolve one.
|
||||
func (fs *UnixFS) Statat(dirfd int, name string) (FileInfo, error) {
|
||||
return fs._fstatat("statat", dirfd, name, 0)
|
||||
}
|
||||
|
||||
// Lstat returns a FileInfo describing the named file.
|
||||
//
|
||||
// If the file is a symbolic link, the returned FileInfo
|
||||
// describes the symbolic link. Lstat makes no attempt to follow the link.
|
||||
//
|
||||
// If there is an error, it will be of type *PathError.
|
||||
func (fs *UnixFS) Lstat(name string) (FileInfo, error) {
|
||||
return fs._fstat("lstat", name, AT_SYMLINK_NOFOLLOW)
|
||||
}
|
||||
|
||||
// Lstatat is like Lstat but allows passing an existing directory file
|
||||
// descriptor rather than needing to resolve one.
|
||||
func (fs *UnixFS) Lstatat(dirfd int, name string) (FileInfo, error) {
|
||||
return fs._fstatat("lstatat", dirfd, name, AT_SYMLINK_NOFOLLOW)
|
||||
}
|
||||
|
||||
func (fs *UnixFS) fstat(name string, flags int) (FileInfo, error) {
|
||||
return fs._fstat("fstat", name, flags)
|
||||
}
|
||||
|
||||
func (fs *UnixFS) _fstat(op string, name string, flags int) (FileInfo, error) {
|
||||
dirfd, name, closeFd, err := fs.safePath(name)
|
||||
defer closeFd()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return fs._fstatat(op, dirfd, name, flags)
|
||||
}
|
||||
|
||||
func (fs *UnixFS) fstatat(dirfd int, name string, flags int) (FileInfo, error) {
|
||||
return fs._fstatat("fstatat", dirfd, name, flags)
|
||||
}
|
||||
|
||||
func (fs *UnixFS) _fstatat(op string, dirfd int, name string, flags int) (FileInfo, error) {
|
||||
var s fileStat
|
||||
if err := ignoringEINTR(func() error {
|
||||
return unix.Fstatat(dirfd, name, &s.sys, flags)
|
||||
}); err != nil {
|
||||
return nil, ensurePathError(err, op, name)
|
||||
}
|
||||
fillFileStatFromSys(&s, name)
|
||||
return &s, nil
|
||||
}
|
||||
|
||||
// Symlink creates newname as a symbolic link to oldname.
|
||||
//
|
||||
// On Windows, a symlink to a non-existent oldname creates a file symlink;
|
||||
// if oldname is later created as a directory the symlink will not work.
|
||||
//
|
||||
// If there is an error, it will be of type *LinkError.
|
||||
func (fs *UnixFS) Symlink(oldpath, newpath string) error {
|
||||
dirfd, newpath, closeFd, err := fs.safePath(newpath)
|
||||
defer closeFd()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ignoringEINTR(func() error {
|
||||
// We aren't concerned with oldpath here as a symlink can point anywhere
|
||||
// it wants.
|
||||
return unix.Symlinkat(oldpath, dirfd, newpath)
|
||||
}); err != nil {
|
||||
return &LinkError{Op: "symlink", Old: oldpath, New: newpath, Err: err}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Touch will attempt to open a file for reading and/or writing. If the file
|
||||
// does not exist it will be created, and any missing parent directories will
|
||||
// also be created. The opened file may be truncated, only if `flag` has
|
||||
// O_TRUNC set.
|
||||
func (fs *UnixFS) Touch(path string, flag int, mode FileMode) (File, error) {
|
||||
if flag&O_CREATE == 0 {
|
||||
flag |= O_CREATE
|
||||
}
|
||||
dirfd, name, closeFd, err, _ := fs.TouchPath(path)
|
||||
defer closeFd()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return fs.OpenFileat(dirfd, name, flag, mode)
|
||||
}
|
||||
|
||||
// TouchPath is like SafePath except that it will create any missing directories
|
||||
// in the path. Unlike SafePath, TouchPath returns an additional boolean which
|
||||
// indicates whether the parent directories already existed, this is intended to
|
||||
// be used as a way to know if the final destination could already exist.
|
||||
func (fs *UnixFS) TouchPath(path string) (int, string, func(), error, bool) {
|
||||
dirfd, name, closeFd, err := fs.safePath(path)
|
||||
switch {
|
||||
case err == nil:
|
||||
return dirfd, name, closeFd, nil, true
|
||||
case !errors.Is(err, ErrNotExist):
|
||||
return dirfd, name, closeFd, err, false
|
||||
}
|
||||
|
||||
var pathErr *PathError
|
||||
if !errors.As(err, &pathErr) {
|
||||
return dirfd, name, closeFd, err, false
|
||||
}
|
||||
if err := fs.MkdirAll(pathErr.Path, 0o755); err != nil {
|
||||
return dirfd, name, closeFd, err, false
|
||||
}
|
||||
|
||||
// Close the previous file descriptor since we are going to be opening
|
||||
// a new one.
|
||||
closeFd()
|
||||
|
||||
// Run safe path again now that the parent directories have been created.
|
||||
dirfd, name, closeFd, err = fs.safePath(path)
|
||||
return dirfd, name, closeFd, err, false
|
||||
}
|
||||
|
||||
// WalkDir walks the file tree rooted at root, calling fn for each file or
|
||||
// directory in the tree, including root.
|
||||
//
|
||||
// All errors that arise visiting files and directories are filtered by fn:
|
||||
// see the [WalkDirFunc] documentation for details.
|
||||
//
|
||||
// The files are walked in lexical order, which makes the output deterministic
|
||||
// but requires WalkDir to read an entire directory into memory before proceeding
|
||||
// to walk that directory.
|
||||
//
|
||||
// WalkDir does not follow symbolic links found in directories,
|
||||
// but if root itself is a symbolic link, its target will be walked.
|
||||
func (fs *UnixFS) WalkDir(root string, fn WalkDirFunc) error {
|
||||
return WalkDir(fs, root, fn)
|
||||
}
|
||||
|
||||
// openat is a wrapper around both unix.Openat and unix.Openat2. If the UnixFS
|
||||
// was configured to enable openat2 support, unix.Openat2 will be used instead
|
||||
// of unix.Openat due to having better security properties for our use-case.
|
||||
func (fs *UnixFS) openat(dirfd int, name string, flag int, mode FileMode) (int, error) {
|
||||
if flag&O_NOFOLLOW == 0 {
|
||||
flag |= O_NOFOLLOW
|
||||
}
|
||||
|
||||
var fd int
|
||||
for {
|
||||
var err error
|
||||
if fs.useOpenat2 {
|
||||
fd, err = fs._openat2(dirfd, name, uint64(flag), uint64(syscallMode(mode)))
|
||||
} else {
|
||||
fd, err = fs._openat(dirfd, name, flag, uint32(syscallMode(mode)))
|
||||
}
|
||||
if err == nil {
|
||||
break
|
||||
}
|
||||
// We have to check EINTR here, per issues https://go.dev/issue/11180 and https://go.dev/issue/39237.
|
||||
if err == unix.EINTR {
|
||||
continue
|
||||
}
|
||||
return 0, err
|
||||
}
|
||||
|
||||
// If we are using openat2, we don't need the additional security checks.
|
||||
if fs.useOpenat2 {
|
||||
return fd, nil
|
||||
}
|
||||
|
||||
// If we are not using openat2, do additional path checking. This assumes
|
||||
// that openat2 is using `RESOLVE_BENEATH` to avoid the same security
|
||||
// issue.
|
||||
var finalPath string
|
||||
finalPath, err := filepath.EvalSymlinks(filepath.Join("/proc/self/fd/", strconv.Itoa(fd)))
|
||||
if err != nil {
|
||||
if !errors.Is(err, ErrNotExist) {
|
||||
return fd, fmt.Errorf("failed to evaluate symlink: %w", convertErrorType(err))
|
||||
}
|
||||
|
||||
// The target of one of the symlinks (EvalSymlinks is recursive)
|
||||
// does not exist. So get the path that does not exist and use
|
||||
// that for further validation instead.
|
||||
var pErr *PathError
|
||||
if !errors.As(err, &pErr) {
|
||||
return fd, fmt.Errorf("failed to evaluate symlink: %w", convertErrorType(err))
|
||||
}
|
||||
|
||||
// Update the final path to whatever directory or path didn't exist while
|
||||
// recursing any symlinks.
|
||||
finalPath = pErr.Path
|
||||
// Ensure the error is wrapped correctly.
|
||||
err = convertErrorType(err)
|
||||
}
|
||||
|
||||
// Check if the path is within our root.
|
||||
if !fs.unsafeIsPathInsideOfBase(finalPath) {
|
||||
op := "openat"
|
||||
if fs.useOpenat2 {
|
||||
op = "openat2"
|
||||
}
|
||||
return fd, &PathError{
|
||||
Op: op,
|
||||
Path: name,
|
||||
Err: ErrBadPathResolution,
|
||||
}
|
||||
}
|
||||
|
||||
// Return the file descriptor and any potential error.
|
||||
return fd, err
|
||||
}
|
||||
|
||||
// _openat is a wrapper around unix.Openat. This method should never be directly
|
||||
// called, use `openat` instead.
|
||||
func (fs *UnixFS) _openat(dirfd int, name string, flag int, mode uint32) (int, error) {
|
||||
// Ensure the O_CLOEXEC flag is set.
|
||||
// Go sets this in the os package, but since we are directly using unix
|
||||
// we need to set it ourselves.
|
||||
if flag&O_CLOEXEC == 0 {
|
||||
flag |= O_CLOEXEC
|
||||
}
|
||||
// O_LARGEFILE is set by Openat for us automatically.
|
||||
fd, err := unix.Openat(dirfd, name, flag, mode)
|
||||
switch {
|
||||
case err == nil:
|
||||
return fd, nil
|
||||
case err == unix.EINTR:
|
||||
return fd, err
|
||||
case err == unix.EAGAIN:
|
||||
return fd, err
|
||||
default:
|
||||
return fd, ensurePathError(err, "openat", name)
|
||||
}
|
||||
}
|
||||
|
||||
// _openat2 is a wonderful syscall that supersedes the `openat` syscall. It has
|
||||
// improved validation and security characteristics that weren't available or
|
||||
// considered when `openat` was originally implemented. As such, it is only
|
||||
// present in Kernel 5.6 and above.
|
||||
//
|
||||
// This method should never be directly called, use `openat` instead.
|
||||
func (fs *UnixFS) _openat2(dirfd int, name string, flag, mode uint64) (int, error) {
|
||||
// Ensure the O_CLOEXEC flag is set.
|
||||
// Go sets this when using the os package, but since we are directly using
|
||||
// the unix package we need to set it ourselves.
|
||||
if flag&O_CLOEXEC == 0 {
|
||||
flag |= O_CLOEXEC
|
||||
}
|
||||
// Ensure the O_LARGEFILE flag is set.
|
||||
// Go sets this for unix.Open, unix.Openat, but not unix.Openat2.
|
||||
if flag&O_LARGEFILE == 0 {
|
||||
flag |= O_LARGEFILE
|
||||
}
|
||||
fd, err := unix.Openat2(dirfd, name, &unix.OpenHow{
|
||||
Flags: flag,
|
||||
Mode: mode,
|
||||
// This is the bread and butter of preventing a symlink escape, without
|
||||
// this option, we have to handle path validation fully on our own.
|
||||
//
|
||||
// This is why using Openat2 over Openat is preferred if available.
|
||||
Resolve: unix.RESOLVE_BENEATH,
|
||||
})
|
||||
switch {
|
||||
case err == nil:
|
||||
return fd, nil
|
||||
case err == unix.EINTR:
|
||||
return fd, err
|
||||
case err == unix.EAGAIN:
|
||||
return fd, err
|
||||
default:
|
||||
return fd, ensurePathError(err, "openat2", name)
|
||||
}
|
||||
}
|
||||
|
||||
func (fs *UnixFS) SafePath(path string) (int, string, func(), error) {
|
||||
return fs.safePath(path)
|
||||
}
|
||||
|
||||
func (fs *UnixFS) safePath(path string) (dirfd int, file string, closeFd func(), err error) {
|
||||
// Default closeFd to a NO-OP.
|
||||
closeFd = func() {}
|
||||
|
||||
// Use unsafePath to clean the path and strip BasePath if path is absolute.
|
||||
var name string
|
||||
name, err = fs.unsafePath(path)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Open the base path. We use this as the sandbox root for any further
|
||||
// operations.
|
||||
var fsDirfd int
|
||||
fsDirfd, err = fs._openat(AT_EMPTY_PATH, fs.basePath, O_DIRECTORY|O_RDONLY, 0)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Split the parent from the last element in the path, this gives us the
|
||||
// "file name" and the full path to its parent.
|
||||
var dir string
|
||||
dir, file = filepath.Split(name)
|
||||
// If dir is empty then name is not nested.
|
||||
if dir == "" {
|
||||
dirfd = fsDirfd
|
||||
closeFd = func() { _ = unix.Close(dirfd) }
|
||||
|
||||
// Return dirfd, name, an empty closeFd func, and no error
|
||||
return
|
||||
}
|
||||
|
||||
// Dir will usually contain a trailing slash as filepath.Split doesn't
|
||||
// trim slashes.
|
||||
dir = strings.TrimSuffix(dir, "/")
|
||||
dirfd, err = fs.openat(fsDirfd, dir, O_DIRECTORY|O_RDONLY, 0)
|
||||
if err != nil {
|
||||
// An error occurred while opening the directory, but we already opened
|
||||
// the filesystem root, so we still need to ensure it gets closed.
|
||||
closeFd = func() { _ = unix.Close(fsDirfd) }
|
||||
} else {
|
||||
// Set closeFd to close the newly opened directory file descriptor.
|
||||
closeFd = func() {
|
||||
_ = unix.Close(dirfd)
|
||||
_ = unix.Close(fsDirfd)
|
||||
}
|
||||
}
|
||||
|
||||
// Return dirfd, name, the closeFd func, and err
|
||||
return
|
||||
}
|
||||
|
||||
// unsafePath strips and joins the given path with the filesystem's base path,
|
||||
// cleaning the result. The cleaned path is then checked if it starts with the
|
||||
// filesystem's base path to obvious any obvious path traversal escapes. The
|
||||
// fully resolved path (if symlinks are followed) may not be within the
|
||||
// filesystem's base path, additional checks are required to safely use paths
|
||||
// returned by this function.
|
||||
func (fs *UnixFS) unsafePath(path string) (string, error) {
|
||||
// Calling filepath.Clean on the path will resolve it to it's absolute path,
|
||||
// removing any path traversal arguments (such as ..), leaving us with an
|
||||
// absolute path we can then use.
|
||||
//
|
||||
// This will also trim the filesystem's base path from the given path and
|
||||
// join the base path back on to ensure the path starts with the base path
|
||||
// without appending it twice.
|
||||
r := filepath.Clean(filepath.Join(fs.basePath, strings.TrimPrefix(path, fs.basePath)))
|
||||
|
||||
if fs.unsafeIsPathInsideOfBase(r) {
|
||||
// This is kinda ironic isn't it.
|
||||
// We do this as we are operating with dirfds and `*at` syscalls which
|
||||
// behave differently if given an absolute path.
|
||||
//
|
||||
// First trim the BasePath, then trim any leading slashes.
|
||||
r = strings.TrimPrefix(strings.TrimPrefix(r, fs.basePath), "/")
|
||||
// If the path is empty then return "." as the path is pointing to the
|
||||
// root.
|
||||
if r == "" {
|
||||
return ".", nil
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
return "", &PathError{
|
||||
Op: "safePath",
|
||||
Path: path,
|
||||
Err: ErrBadPathResolution,
|
||||
}
|
||||
}
|
||||
|
||||
// unsafeIsPathInsideOfBase checks if the given path is inside the filesystem's
|
||||
// base path.
|
||||
//
|
||||
// NOTE: this method doesn't clean the given path or attempt to join the
|
||||
// filesystem's base path. This is purely a basic prefix check against the
|
||||
// given path.
|
||||
func (fs *UnixFS) unsafeIsPathInsideOfBase(path string) bool {
|
||||
return strings.HasPrefix(
|
||||
strings.TrimSuffix(path, "/")+"/",
|
||||
fs.basePath+"/",
|
||||
)
|
||||
}
|
||||
768
internal/ufs/fs_unix_test.go
Normal file
768
internal/ufs/fs_unix_test.go
Normal file
@@ -0,0 +1,768 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// SPDX-FileCopyrightText: Copyright (c) 2024 Matthew Penner
|
||||
|
||||
//go:build unix
|
||||
|
||||
package ufs_test
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"slices"
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
"github.com/pterodactyl/wings/internal/ufs"
|
||||
)
|
||||
|
||||
type testUnixFS struct {
|
||||
*ufs.UnixFS
|
||||
|
||||
TmpDir string
|
||||
Root string
|
||||
}
|
||||
|
||||
func (fs *testUnixFS) Cleanup() {
|
||||
_ = fs.Close()
|
||||
_ = os.RemoveAll(fs.TmpDir)
|
||||
}
|
||||
|
||||
func newTestUnixFS() (*testUnixFS, error) {
|
||||
tmpDir, err := os.MkdirTemp(os.TempDir(), "ufs")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
root := filepath.Join(tmpDir, "root")
|
||||
if err := os.Mkdir(root, 0o755); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// fmt.Println(tmpDir)
|
||||
fs, err := ufs.NewUnixFS(root, true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tfs := &testUnixFS{
|
||||
UnixFS: fs,
|
||||
TmpDir: tmpDir,
|
||||
Root: root,
|
||||
}
|
||||
return tfs, nil
|
||||
}
|
||||
|
||||
func TestUnixFS(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fs, err := newTestUnixFS()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
return
|
||||
}
|
||||
defer fs.Cleanup()
|
||||
|
||||
// Test creating a file within the root.
|
||||
_, _, closeFd, err := fs.SafePath("/")
|
||||
closeFd()
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
f, err := fs.Touch("directory/file", ufs.O_RDWR, 0o644)
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
return
|
||||
}
|
||||
_ = f.Close()
|
||||
|
||||
// Test creating a file within the root.
|
||||
f, err = fs.Create("test")
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
return
|
||||
}
|
||||
_ = f.Close()
|
||||
|
||||
// Test stating a file within the root.
|
||||
if _, err := fs.Stat("test"); err != nil {
|
||||
t.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
// Test creating a directory within the root.
|
||||
if err := fs.Mkdir("ima_directory", 0o755); err != nil {
|
||||
t.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
// Test creating a nested directory within the root.
|
||||
if err := fs.Mkdir("ima_directory/ima_nother_directory", 0o755); err != nil {
|
||||
t.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
// Test creating a file inside a directory within the root.
|
||||
f, err = fs.Create("ima_directory/ima_file")
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
return
|
||||
}
|
||||
_ = f.Close()
|
||||
|
||||
// Test listing directory entries.
|
||||
if _, err := fs.ReadDir("ima_directory"); err != nil {
|
||||
t.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
// Test symlink pointing outside the root.
|
||||
if err := os.Symlink(fs.TmpDir, filepath.Join(fs.Root, "ima_bad_link")); err != nil {
|
||||
t.Error(err)
|
||||
return
|
||||
}
|
||||
f, err = fs.Create("ima_bad_link/ima_bad_file")
|
||||
if err == nil {
|
||||
_ = f.Close()
|
||||
t.Error("expected an error")
|
||||
return
|
||||
}
|
||||
if err := fs.Mkdir("ima_bad_link/ima_bad_directory", 0o755); err == nil {
|
||||
t.Error("expected an error")
|
||||
return
|
||||
}
|
||||
|
||||
// Test symlink pointing outside the root inside a parent directory.
|
||||
if err := fs.Symlink(fs.TmpDir, filepath.Join(fs.Root, "ima_directory/ima_bad_link")); err != nil {
|
||||
t.Error(err)
|
||||
return
|
||||
}
|
||||
if err := fs.Mkdir("ima_directory/ima_bad_link/ima_bad_directory", 0o755); err == nil {
|
||||
t.Error("expected an error")
|
||||
return
|
||||
}
|
||||
|
||||
// Test symlink pointing outside the root with a child directory.
|
||||
if err := os.Mkdir(filepath.Join(fs.TmpDir, "ima_directory"), 0o755); err != nil {
|
||||
t.Error(err)
|
||||
return
|
||||
}
|
||||
f, err = fs.Create("ima_bad_link/ima_directory/ima_bad_file")
|
||||
if err == nil {
|
||||
_ = f.Close()
|
||||
t.Error("expected an error")
|
||||
return
|
||||
}
|
||||
if err := fs.Mkdir("ima_bad_link/ima_directory/ima_bad_directory", 0o755); err == nil {
|
||||
t.Error("expected an error")
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := fs.ReadDir("ima_bad_link/ima_directory"); err == nil {
|
||||
t.Error("expected an error")
|
||||
return
|
||||
}
|
||||
|
||||
// Create multiple nested directories.
|
||||
if err := fs.MkdirAll("ima_directory/ima_directory/ima_directory/ima_directory", 0o755); err != nil {
|
||||
t.Error(err)
|
||||
return
|
||||
}
|
||||
if _, err := fs.ReadDir("ima_directory/ima_directory"); err != nil {
|
||||
t.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
// Test creating a directory under a symlink with a pre-existing directory.
|
||||
if err := fs.MkdirAll("ima_bad_link/ima_directory/ima_bad_directory/ima_bad_directory", 0o755); err == nil {
|
||||
t.Error("expected an error")
|
||||
return
|
||||
}
|
||||
|
||||
// Test deletion
|
||||
if err := fs.Remove("test"); err != nil {
|
||||
t.Error(err)
|
||||
return
|
||||
}
|
||||
if err := fs.Remove("ima_bad_link"); err != nil {
|
||||
t.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
// Test recursive deletion
|
||||
if err := fs.RemoveAll("ima_directory"); err != nil {
|
||||
t.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
// Test recursive deletion underneath a bad symlink
|
||||
if err := fs.Mkdir("ima_directory", 0o755); err != nil {
|
||||
t.Error(err)
|
||||
return
|
||||
}
|
||||
if err := fs.Symlink(fs.TmpDir, filepath.Join(fs.Root, "ima_directory/ima_bad_link")); err != nil {
|
||||
t.Error(err)
|
||||
return
|
||||
}
|
||||
if err := fs.RemoveAll("ima_directory/ima_bad_link/ima_bad_file"); err == nil {
|
||||
t.Error("expected an error")
|
||||
return
|
||||
}
|
||||
|
||||
// This should delete the symlink itself.
|
||||
if err := fs.RemoveAll("ima_directory/ima_bad_link"); err != nil {
|
||||
t.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
//for i := 0; i < 5; i++ {
|
||||
// dirName := "dir" + strconv.Itoa(i)
|
||||
// if err := fs.Mkdir(dirName, 0o755); err != nil {
|
||||
// t.Error(err)
|
||||
// return
|
||||
// }
|
||||
// for j := 0; j < 5; j++ {
|
||||
// f, err := fs.Create(filepath.Join(dirName, "file"+strconv.Itoa(j)))
|
||||
// if err != nil {
|
||||
// t.Error(err)
|
||||
// return
|
||||
// }
|
||||
// _ = f.Close()
|
||||
// }
|
||||
//}
|
||||
//
|
||||
//if err := fs.WalkDir2("", func(fd int, path string, info filesystem.DirEntry, err error) error {
|
||||
// if err != nil {
|
||||
// return err
|
||||
// }
|
||||
// fmt.Println(path)
|
||||
// return nil
|
||||
//}); err != nil {
|
||||
// t.Error(err)
|
||||
// return
|
||||
//}
|
||||
}
|
||||
|
||||
func TestUnixFS_Chmod(t *testing.T) {
|
||||
t.Parallel()
|
||||
fs, err := newTestUnixFS()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
return
|
||||
}
|
||||
defer fs.Cleanup()
|
||||
|
||||
// TODO: implement
|
||||
}
|
||||
|
||||
func TestUnixFS_Chown(t *testing.T) {
|
||||
t.Parallel()
|
||||
fs, err := newTestUnixFS()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
return
|
||||
}
|
||||
defer fs.Cleanup()
|
||||
|
||||
// TODO: implement
|
||||
}
|
||||
|
||||
func TestUnixFS_Lchown(t *testing.T) {
|
||||
t.Parallel()
|
||||
fs, err := newTestUnixFS()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
return
|
||||
}
|
||||
defer fs.Cleanup()
|
||||
|
||||
// TODO: implement
|
||||
}
|
||||
|
||||
func TestUnixFS_Chtimes(t *testing.T) {
|
||||
t.Parallel()
|
||||
fs, err := newTestUnixFS()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
return
|
||||
}
|
||||
defer fs.Cleanup()
|
||||
|
||||
// TODO: implement
|
||||
}
|
||||
|
||||
func TestUnixFS_Create(t *testing.T) {
|
||||
t.Parallel()
|
||||
fs, err := newTestUnixFS()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
return
|
||||
}
|
||||
defer fs.Cleanup()
|
||||
|
||||
// TODO: implement
|
||||
}
|
||||
|
||||
func TestUnixFS_Mkdir(t *testing.T) {
|
||||
t.Parallel()
|
||||
fs, err := newTestUnixFS()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
return
|
||||
}
|
||||
defer fs.Cleanup()
|
||||
|
||||
// TODO: implement
|
||||
}
|
||||
|
||||
func TestUnixFS_MkdirAll(t *testing.T) {
|
||||
t.Parallel()
|
||||
fs, err := newTestUnixFS()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
return
|
||||
}
|
||||
defer fs.Cleanup()
|
||||
|
||||
if err := fs.MkdirAll("/a/bunch/of/directories", 0o755); err != nil {
|
||||
t.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
// TODO: stat sanity check
|
||||
}
|
||||
|
||||
func TestUnixFS_Open(t *testing.T) {
|
||||
t.Parallel()
|
||||
fs, err := newTestUnixFS()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
return
|
||||
}
|
||||
defer fs.Cleanup()
|
||||
|
||||
// TODO: implement
|
||||
}
|
||||
|
||||
func TestUnixFS_OpenFile(t *testing.T) {
|
||||
t.Parallel()
|
||||
fs, err := newTestUnixFS()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
return
|
||||
}
|
||||
defer fs.Cleanup()
|
||||
|
||||
// TODO: implement
|
||||
}
|
||||
|
||||
func TestUnixFS_ReadDir(t *testing.T) {
|
||||
t.Parallel()
|
||||
fs, err := newTestUnixFS()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
return
|
||||
}
|
||||
defer fs.Cleanup()
|
||||
|
||||
// TODO: implement
|
||||
}
|
||||
|
||||
func TestUnixFS_Remove(t *testing.T) {
|
||||
t.Parallel()
|
||||
fs, err := newTestUnixFS()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
return
|
||||
}
|
||||
defer fs.Cleanup()
|
||||
|
||||
t.Run("base directory", func(t *testing.T) {
|
||||
// Try to remove the base directory.
|
||||
if err := fs.Remove(""); !errors.Is(err, ufs.ErrBadPathResolution) {
|
||||
t.Errorf("expected an a bad path resolution error, but got: %v", err)
|
||||
return
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("path traversal", func(t *testing.T) {
|
||||
// Try to remove the base directory.
|
||||
if err := fs.RemoveAll("../root"); !errors.Is(err, ufs.ErrBadPathResolution) {
|
||||
t.Errorf("expected an a bad path resolution error, but got: %v", err)
|
||||
return
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestUnixFS_RemoveAll(t *testing.T) {
|
||||
t.Parallel()
|
||||
fs, err := newTestUnixFS()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
return
|
||||
}
|
||||
defer fs.Cleanup()
|
||||
|
||||
t.Run("base directory", func(t *testing.T) {
|
||||
// Try to remove the base directory.
|
||||
if err := fs.RemoveAll(""); !errors.Is(err, ufs.ErrBadPathResolution) {
|
||||
t.Errorf("expected an a bad path resolution error, but got: %v", err)
|
||||
return
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("path traversal", func(t *testing.T) {
|
||||
// Try to remove the base directory.
|
||||
if err := fs.RemoveAll("../root"); !errors.Is(err, ufs.ErrBadPathResolution) {
|
||||
t.Errorf("expected an a bad path resolution error, but got: %v", err)
|
||||
return
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestUnixFS_Rename(t *testing.T) {
|
||||
t.Parallel()
|
||||
fs, err := newTestUnixFS()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
return
|
||||
}
|
||||
defer fs.Cleanup()
|
||||
|
||||
t.Run("rename base directory", func(t *testing.T) {
|
||||
// Try to rename the base directory.
|
||||
if err := fs.Rename("", "yeet"); !errors.Is(err, ufs.ErrBadPathResolution) {
|
||||
t.Errorf("expected an a bad path resolution error, but got: %v", err)
|
||||
return
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("rename over base directory", func(t *testing.T) {
|
||||
// Create a directory that we are going to try and move over top of the
|
||||
// existing base directory.
|
||||
if err := fs.Mkdir("overwrite_dir", 0o755); err != nil {
|
||||
t.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
// Try to rename over the base directory.
|
||||
if err := fs.Rename("overwrite_dir", ""); !errors.Is(err, ufs.ErrBadPathResolution) {
|
||||
t.Errorf("expected an a bad path resolution error, but got: %v", err)
|
||||
return
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("directory rename", func(t *testing.T) {
|
||||
// Create a directory to rename to something else.
|
||||
if err := fs.Mkdir("test_directory", 0o755); err != nil {
|
||||
t.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
// Try to rename "test_directory" to "directory".
|
||||
if err := fs.Rename("test_directory", "directory"); err != nil {
|
||||
t.Errorf("expected no error, but got: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Sanity check
|
||||
if _, err := os.Lstat(filepath.Join(fs.Root, "directory")); err != nil {
|
||||
t.Errorf("Lstat errored when performing sanity check: %v", err)
|
||||
return
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("file rename", func(t *testing.T) {
|
||||
// Create a directory to rename to something else.
|
||||
f, err := fs.Create("test_file")
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
return
|
||||
}
|
||||
_ = f.Close()
|
||||
|
||||
// Try to rename "test_file" to "file".
|
||||
if err := fs.Rename("test_file", "file"); err != nil {
|
||||
t.Errorf("expected no error, but got: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Sanity check
|
||||
if _, err := os.Lstat(filepath.Join(fs.Root, "file")); err != nil {
|
||||
t.Errorf("Lstat errored when performing sanity check: %v", err)
|
||||
return
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestUnixFS_Stat(t *testing.T) {
|
||||
t.Parallel()
|
||||
fs, err := newTestUnixFS()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
return
|
||||
}
|
||||
defer fs.Cleanup()
|
||||
|
||||
// TODO: implement
|
||||
}
|
||||
|
||||
func TestUnixFS_Lstat(t *testing.T) {
|
||||
t.Parallel()
|
||||
fs, err := newTestUnixFS()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
return
|
||||
}
|
||||
defer fs.Cleanup()
|
||||
|
||||
// TODO: implement
|
||||
}
|
||||
|
||||
func TestUnixFS_Symlink(t *testing.T) {
|
||||
t.Parallel()
|
||||
fs, err := newTestUnixFS()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
return
|
||||
}
|
||||
defer fs.Cleanup()
|
||||
|
||||
// TODO: implement
|
||||
}
|
||||
|
||||
func TestUnixFS_Touch(t *testing.T) {
|
||||
t.Parallel()
|
||||
fs, err := newTestUnixFS()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
return
|
||||
}
|
||||
defer fs.Cleanup()
|
||||
|
||||
t.Run("base directory", func(t *testing.T) {
|
||||
path := "i_touched_a_file"
|
||||
f, err := fs.Touch(path, ufs.O_RDWR, 0o644)
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
return
|
||||
}
|
||||
_ = f.Close()
|
||||
|
||||
// Sanity check
|
||||
if _, err := os.Lstat(filepath.Join(fs.Root, path)); err != nil {
|
||||
t.Errorf("Lstat errored when performing sanity check: %v", err)
|
||||
return
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("existing parent directory", func(t *testing.T) {
|
||||
dir := "some_parent_directory"
|
||||
if err := fs.Mkdir(dir, 0o755); err != nil {
|
||||
t.Errorf("error creating parent directory: %v", err)
|
||||
return
|
||||
}
|
||||
path := filepath.Join(dir, "i_touched_a_file")
|
||||
f, err := fs.Touch(path, ufs.O_RDWR, 0o644)
|
||||
if err != nil {
|
||||
t.Errorf("error touching file: %v", err)
|
||||
return
|
||||
}
|
||||
_ = f.Close()
|
||||
|
||||
// Sanity check
|
||||
if _, err := os.Lstat(filepath.Join(fs.Root, path)); err != nil {
|
||||
t.Errorf("Lstat errored when performing sanity check: %v", err)
|
||||
return
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("non-existent parent directory", func(t *testing.T) {
|
||||
path := "some_other_directory/i_touched_a_file"
|
||||
f, err := fs.Touch(path, ufs.O_RDWR, 0o644)
|
||||
if err != nil {
|
||||
t.Errorf("error touching file: %v", err)
|
||||
return
|
||||
}
|
||||
_ = f.Close()
|
||||
|
||||
// Sanity check
|
||||
if _, err := os.Lstat(filepath.Join(fs.Root, path)); err != nil {
|
||||
t.Errorf("Lstat errored when performing sanity check: %v", err)
|
||||
return
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("non-existent parent directories", func(t *testing.T) {
|
||||
path := "some_other_directory/some_directory/i_touched_a_file"
|
||||
f, err := fs.Touch(path, ufs.O_RDWR, 0o644)
|
||||
if err != nil {
|
||||
t.Errorf("error touching file: %v", err)
|
||||
return
|
||||
}
|
||||
_ = f.Close()
|
||||
|
||||
// Sanity check
|
||||
if _, err := os.Lstat(filepath.Join(fs.Root, path)); err != nil {
|
||||
t.Errorf("Lstat errored when performing sanity check: %v", err)
|
||||
return
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestUnixFS_WalkDir(t *testing.T) {
|
||||
t.Parallel()
|
||||
fs, err := newTestUnixFS()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
return
|
||||
}
|
||||
defer fs.Cleanup()
|
||||
|
||||
//for i := 0; i < 5; i++ {
|
||||
// dirName := "dir" + strconv.Itoa(i)
|
||||
// if err := fs.Mkdir(dirName, 0o755); err != nil {
|
||||
// t.Error(err)
|
||||
// return
|
||||
// }
|
||||
// for j := 0; j < 5; j++ {
|
||||
// f, err := fs.Create(filepath.Join(dirName, "file"+strconv.Itoa(j)))
|
||||
// if err != nil {
|
||||
// t.Error(err)
|
||||
// return
|
||||
// }
|
||||
// _ = f.Close()
|
||||
// }
|
||||
//}
|
||||
//
|
||||
//if err := fs.WalkDir(".", func(path string, info ufs.DirEntry, err error) error {
|
||||
// if err != nil {
|
||||
// return err
|
||||
// }
|
||||
// t.Log(path)
|
||||
// return nil
|
||||
//}); err != nil {
|
||||
// t.Error(err)
|
||||
// return
|
||||
//}
|
||||
}
|
||||
|
||||
func TestUnixFS_WalkDirat(t *testing.T) {
|
||||
t.Parallel()
|
||||
fs, err := newTestUnixFS()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
return
|
||||
}
|
||||
defer fs.Cleanup()
|
||||
|
||||
for i := 0; i < 2; i++ {
|
||||
dirName := "base" + strconv.Itoa(i)
|
||||
if err := fs.Mkdir(dirName, 0o755); err != nil {
|
||||
t.Error(err)
|
||||
return
|
||||
}
|
||||
for j := 0; j < 1; j++ {
|
||||
f, err := fs.Create(filepath.Join(dirName, "file"+strconv.Itoa(j)))
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
return
|
||||
}
|
||||
_ = f.Close()
|
||||
if err := fs.Mkdir(filepath.Join(dirName, "dir"+strconv.Itoa(j)), 0o755); err != nil {
|
||||
t.Error(err)
|
||||
return
|
||||
}
|
||||
f, err = fs.Create(filepath.Join(dirName, "dir"+strconv.Itoa(j), "file"+strconv.Itoa(j)))
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
return
|
||||
}
|
||||
_ = f.Close()
|
||||
}
|
||||
}
|
||||
|
||||
t.Run("walk starting at the filesystem root", func(t *testing.T) {
|
||||
pathsTraversed, err := fs.testWalkDirAt("")
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
return
|
||||
}
|
||||
expect := []Path{
|
||||
{Name: ".", Relative: "."},
|
||||
{Name: "base0", Relative: "base0"},
|
||||
{Name: "dir0", Relative: "base0/dir0"},
|
||||
{Name: "file0", Relative: "base0/dir0/file0"},
|
||||
{Name: "file0", Relative: "base0/file0"},
|
||||
{Name: "base1", Relative: "base1"},
|
||||
{Name: "dir0", Relative: "base1/dir0"},
|
||||
{Name: "file0", Relative: "base1/dir0/file0"},
|
||||
{Name: "file0", Relative: "base1/file0"},
|
||||
}
|
||||
if !reflect.DeepEqual(pathsTraversed, expect) {
|
||||
t.Log(pathsTraversed)
|
||||
t.Log(expect)
|
||||
t.Error("walk doesn't match")
|
||||
return
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("walk starting in a directory", func(t *testing.T) {
|
||||
pathsTraversed, err := fs.testWalkDirAt("base0")
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
return
|
||||
}
|
||||
expect := []Path{
|
||||
// TODO: what should relative actually be here?
|
||||
// The behaviour differs from walking the directory root vs a sub
|
||||
// directory. When walking from the root, dirfd is the directory we
|
||||
// are walking from and both name and relative are `.`. However,
|
||||
// when walking from a subdirectory, fd is the parent of the
|
||||
// subdirectory, and name is the subdirectory.
|
||||
{Name: "base0", Relative: "."},
|
||||
{Name: "dir0", Relative: "dir0"},
|
||||
{Name: "file0", Relative: "dir0/file0"},
|
||||
{Name: "file0", Relative: "file0"},
|
||||
}
|
||||
if !reflect.DeepEqual(pathsTraversed, expect) {
|
||||
t.Log(pathsTraversed)
|
||||
t.Log(expect)
|
||||
t.Error("walk doesn't match")
|
||||
return
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
type Path struct {
|
||||
Name string
|
||||
Relative string
|
||||
}
|
||||
|
||||
func (fs *testUnixFS) testWalkDirAt(path string) ([]Path, error) {
|
||||
dirfd, name, closeFd, err := fs.SafePath(path)
|
||||
defer closeFd()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var pathsTraversed []Path
|
||||
if err := fs.WalkDirat(dirfd, name, func(_ int, name, relative string, _ ufs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
pathsTraversed = append(pathsTraversed, Path{Name: name, Relative: relative})
|
||||
return nil
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
slices.SortStableFunc(pathsTraversed, func(a, b Path) int {
|
||||
if a.Relative > b.Relative {
|
||||
return 1
|
||||
}
|
||||
if a.Relative < b.Relative {
|
||||
return -1
|
||||
}
|
||||
return 0
|
||||
})
|
||||
return pathsTraversed, nil
|
||||
}
|
||||
27
internal/ufs/go.LICENSE
Normal file
27
internal/ufs/go.LICENSE
Normal file
@@ -0,0 +1,27 @@
|
||||
Copyright (c) 2009 The Go Authors. All rights reserved.
|
||||
|
||||
Redistribution and use in source and binary forms, with or without
|
||||
modification, are permitted provided that the following conditions are
|
||||
met:
|
||||
|
||||
* Redistributions of source code must retain the above copyright
|
||||
notice, this list of conditions and the following disclaimer.
|
||||
* Redistributions in binary form must reproduce the above
|
||||
copyright notice, this list of conditions and the following disclaimer
|
||||
in the documentation and/or other materials provided with the
|
||||
distribution.
|
||||
* Neither the name of Google Inc. nor the names of its
|
||||
contributors may be used to endorse or promote products derived from
|
||||
this software without specific prior written permission.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
|
||||
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
|
||||
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
|
||||
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
|
||||
OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
|
||||
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
|
||||
LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
|
||||
DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
|
||||
THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
|
||||
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
||||
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
63
internal/ufs/mkdir_unix.go
Normal file
63
internal/ufs/mkdir_unix.go
Normal file
@@ -0,0 +1,63 @@
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
// Code in this file was derived from `go/src/os/path.go`.
|
||||
|
||||
// Copyright 2009 The Go Authors. All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style
|
||||
// license that can be found in the `go.LICENSE` file.
|
||||
|
||||
//go:build unix
|
||||
|
||||
package ufs
|
||||
|
||||
// mkdirAll is a recursive Mkdir implementation that properly handles symlinks.
|
||||
func (fs *UnixFS) mkdirAll(name string, mode FileMode) error {
|
||||
// Fast path: if we can tell whether path is a directory or file, stop with success or error.
|
||||
dir, err := fs.Lstat(name)
|
||||
if err == nil {
|
||||
if dir.Mode()&ModeSymlink != 0 {
|
||||
// If the final path is a symlink, resolve its target and use that
|
||||
// to check instead.
|
||||
dir, err = fs.Stat(name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if dir.IsDir() {
|
||||
return nil
|
||||
}
|
||||
return &PathError{Op: "mkdir", Path: name, Err: ErrNotDirectory}
|
||||
}
|
||||
|
||||
// Slow path: make sure parent exists and then call Mkdir for path.
|
||||
i := len(name)
|
||||
for i > 0 && name[i-1] == '/' { // Skip trailing path separator.
|
||||
i--
|
||||
}
|
||||
|
||||
j := i
|
||||
for j > 0 && name[j-1] != '/' { // Scan backward over element.
|
||||
j--
|
||||
}
|
||||
|
||||
if j > 1 {
|
||||
// Create parent.
|
||||
err = fs.mkdirAll(name[:j-1], mode)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Parent now exists; invoke Mkdir and use its result.
|
||||
err = fs.Mkdir(name, mode)
|
||||
if err != nil {
|
||||
// Handle arguments like "foo/." by
|
||||
// double-checking that directory doesn't exist.
|
||||
dir, err1 := fs.Lstat(name)
|
||||
if err1 == nil && dir.IsDir() {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
80
internal/ufs/path_unix.go
Normal file
80
internal/ufs/path_unix.go
Normal file
@@ -0,0 +1,80 @@
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
// Code in this file was copied from `go/src/os/path.go`
|
||||
// and `go/src/os/path_unix.go`.
|
||||
|
||||
// Copyright 2009 The Go Authors. All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style
|
||||
// license that can be found in the `go.LICENSE` file.
|
||||
|
||||
//go:build unix
|
||||
|
||||
package ufs
|
||||
|
||||
import (
|
||||
"os"
|
||||
)
|
||||
|
||||
// basename removes trailing slashes and the leading directory name from path name.
|
||||
func basename(name string) string {
|
||||
i := len(name) - 1
|
||||
// Remove trailing slashes
|
||||
for ; i > 0 && name[i] == '/'; i-- {
|
||||
name = name[:i]
|
||||
}
|
||||
// Remove leading directory name
|
||||
for i--; i >= 0; i-- {
|
||||
if name[i] == '/' {
|
||||
name = name[i+1:]
|
||||
break
|
||||
}
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
// endsWithDot reports whether the final component of path is ".".
|
||||
func endsWithDot(path string) bool {
|
||||
if path == "." {
|
||||
return true
|
||||
}
|
||||
if len(path) >= 2 && path[len(path)-1] == '.' && os.IsPathSeparator(path[len(path)-2]) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// splitPath returns the base name and parent directory.
|
||||
func splitPath(path string) (string, string) {
|
||||
// if no better parent is found, the path is relative from "here"
|
||||
dirname := "."
|
||||
|
||||
// Remove all but one leading slash.
|
||||
for len(path) > 1 && path[0] == '/' && path[1] == '/' {
|
||||
path = path[1:]
|
||||
}
|
||||
|
||||
i := len(path) - 1
|
||||
|
||||
// Remove trailing slashes.
|
||||
for ; i > 0 && path[i] == '/'; i-- {
|
||||
path = path[:i]
|
||||
}
|
||||
|
||||
// if no slashes in path, base is path
|
||||
basename := path
|
||||
|
||||
// Remove leading directory path
|
||||
for i--; i >= 0; i-- {
|
||||
if path[i] == '/' {
|
||||
if i == 0 {
|
||||
dirname = path[:1]
|
||||
} else {
|
||||
dirname = path[:i]
|
||||
}
|
||||
basename = path[i+1:]
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return dirname, basename
|
||||
}
|
||||
@@ -1,26 +1,26 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// SPDX-FileCopyrightText: Copyright (c) 2024 Matthew Penner
|
||||
|
||||
package internal
|
||||
package ufs
|
||||
|
||||
import (
|
||||
"io"
|
||||
"os"
|
||||
"sync/atomic"
|
||||
)
|
||||
|
||||
// CountedWriter is a writer that counts the amount of data written to the
|
||||
// underlying writer.
|
||||
type CountedWriter struct {
|
||||
file *os.File
|
||||
File
|
||||
|
||||
counter atomic.Int64
|
||||
err error
|
||||
}
|
||||
|
||||
// NewCountedWriter returns a new countedWriter that counts the amount of bytes
|
||||
// written to the underlying writer.
|
||||
func NewCountedWriter(f *os.File) *CountedWriter {
|
||||
return &CountedWriter{file: f}
|
||||
func NewCountedWriter(f File) *CountedWriter {
|
||||
return &CountedWriter{File: f}
|
||||
}
|
||||
|
||||
// BytesWritten returns the amount of bytes that have been written to the
|
||||
@@ -46,7 +46,7 @@ func (w *CountedWriter) Write(p []byte) (int, error) {
|
||||
}
|
||||
|
||||
// Write is a very simple operation for us to handle.
|
||||
n, err := w.file.Write(p)
|
||||
n, err := w.File.Write(p)
|
||||
w.counter.Add(int64(n))
|
||||
w.err = err
|
||||
|
||||
@@ -59,7 +59,7 @@ func (w *CountedWriter) Write(p []byte) (int, error) {
|
||||
|
||||
func (w *CountedWriter) ReadFrom(r io.Reader) (n int64, err error) {
|
||||
cr := NewCountedReader(r)
|
||||
n, err = w.file.ReadFrom(cr)
|
||||
n, err = w.File.ReadFrom(cr)
|
||||
w.counter.Add(n)
|
||||
return
|
||||
}
|
||||
261
internal/ufs/removeall_unix.go
Normal file
261
internal/ufs/removeall_unix.go
Normal file
@@ -0,0 +1,261 @@
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
// Code in this file was derived from `go/src/os/removeall_at.go`.
|
||||
|
||||
// Copyright 2009 The Go Authors. All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style
|
||||
// license that can be found in the `go.LICENSE` file.
|
||||
|
||||
//go:build unix
|
||||
|
||||
package ufs
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
type unixFS interface {
|
||||
Open(name string) (File, error)
|
||||
Remove(name string) error
|
||||
unlinkat(dirfd int, path string, flags int) error
|
||||
}
|
||||
|
||||
func (fs *UnixFS) removeAll(path string) error {
|
||||
return removeAll(fs, path)
|
||||
}
|
||||
|
||||
func removeAll(fs unixFS, path string) error {
|
||||
if path == "" {
|
||||
// fail silently to retain compatibility with previous behavior
|
||||
// of RemoveAll. See issue https://go.dev/issue/28830.
|
||||
return nil
|
||||
}
|
||||
|
||||
// The rmdir system call does not permit removing ".",
|
||||
// so we don't permit it either.
|
||||
if endsWithDot(path) {
|
||||
return &PathError{Op: "removeall", Path: path, Err: unix.EINVAL}
|
||||
}
|
||||
|
||||
// Simple case: if Remove works, we're done.
|
||||
err := fs.Remove(path)
|
||||
if err == nil || errors.Is(err, ErrNotExist) {
|
||||
return nil
|
||||
}
|
||||
|
||||
// RemoveAll recurses by deleting the path base from
|
||||
// its parent directory
|
||||
parentDir, base := splitPath(path)
|
||||
|
||||
parent, err := fs.Open(parentDir)
|
||||
if err != nil {
|
||||
if !errors.Is(err, ErrNotExist) {
|
||||
return err
|
||||
}
|
||||
// If parent does not exist, base cannot exist. Fail silently
|
||||
return nil
|
||||
}
|
||||
defer parent.Close()
|
||||
|
||||
if err := removeAllFrom(fs, parent, base); err != nil {
|
||||
if pathErr, ok := err.(*PathError); ok {
|
||||
pathErr.Path = parentDir + string(os.PathSeparator) + pathErr.Path
|
||||
err = convertErrorType(pathErr)
|
||||
} else {
|
||||
err = ensurePathError(err, "removeallfrom", base)
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (fs *UnixFS) removeContents(path string) error {
|
||||
return removeContents(fs, path)
|
||||
}
|
||||
|
||||
func removeContents(fs unixFS, path string) error {
|
||||
if path == "" {
|
||||
// fail silently to retain compatibility with previous behavior
|
||||
// of RemoveAll. See issue https://go.dev/issue/28830.
|
||||
return nil
|
||||
}
|
||||
|
||||
// RemoveAll recurses by deleting the path base from
|
||||
// its parent directory
|
||||
parentDir, base := splitPath(path)
|
||||
|
||||
parent, err := fs.Open(parentDir)
|
||||
if err != nil {
|
||||
if !errors.Is(err, ErrNotExist) {
|
||||
return err
|
||||
}
|
||||
// If parent does not exist, base cannot exist. Fail silently
|
||||
return nil
|
||||
}
|
||||
defer parent.Close()
|
||||
|
||||
if err := removeContentsFrom(fs, parent, base); err != nil {
|
||||
if pathErr, ok := err.(*PathError); ok {
|
||||
pathErr.Path = parentDir + string(os.PathSeparator) + pathErr.Path
|
||||
err = convertErrorType(pathErr)
|
||||
} else {
|
||||
err = ensurePathError(err, "removecontentsfrom", base)
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// removeContentsFrom recursively removes all descendants of parent without
|
||||
// removing parent itself. Parent must be a directory.
|
||||
func removeContentsFrom(fs unixFS, parent File, base string) error {
|
||||
parentFd := int(parent.Fd())
|
||||
|
||||
var recurseErr error
|
||||
for {
|
||||
const reqSize = 1024
|
||||
var respSize int
|
||||
|
||||
// Open the directory to recurse into
|
||||
file, err := openFdAt(parentFd, base)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrNotExist) {
|
||||
return nil
|
||||
}
|
||||
recurseErr = &PathError{Op: "openfdat", Path: base, Err: err}
|
||||
break
|
||||
}
|
||||
|
||||
for {
|
||||
numErr := 0
|
||||
|
||||
names, readErr := file.Readdirnames(reqSize)
|
||||
// Errors other than EOF should stop us from continuing.
|
||||
if readErr != nil && readErr != io.EOF {
|
||||
_ = file.Close()
|
||||
if errors.Is(readErr, ErrNotExist) {
|
||||
return nil
|
||||
}
|
||||
return &PathError{Op: "readdirnames", Path: base, Err: readErr}
|
||||
}
|
||||
|
||||
respSize = len(names)
|
||||
for _, name := range names {
|
||||
err := removeAllFrom(fs, file, name)
|
||||
if err != nil {
|
||||
if pathErr, ok := err.(*PathError); ok {
|
||||
pathErr.Path = base + string(os.PathSeparator) + pathErr.Path
|
||||
}
|
||||
numErr++
|
||||
if recurseErr == nil {
|
||||
recurseErr = err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If we can delete any entry, break to start new iteration.
|
||||
// Otherwise, we discard current names, get next entries and try deleting them.
|
||||
if numErr != reqSize {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Removing files from the directory may have caused
|
||||
// the OS to reshuffle it. Simply calling Readdirnames
|
||||
// again may skip some entries. The only reliable way
|
||||
// to avoid this is to close and re-open the
|
||||
// directory. See issue https://go.dev/issue/20841.
|
||||
_ = file.Close()
|
||||
|
||||
// Finish when the end of the directory is reached
|
||||
if respSize < reqSize {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func removeAllFrom(fs unixFS, parent File, base string) error {
|
||||
parentFd := int(parent.Fd())
|
||||
|
||||
// Simple case: if Unlink (aka remove) works, we're done.
|
||||
err := fs.unlinkat(parentFd, base, 0)
|
||||
if err == nil || errors.Is(err, ErrNotExist) {
|
||||
return nil
|
||||
}
|
||||
|
||||
// EISDIR means that we have a directory, and we need to
|
||||
// remove its contents.
|
||||
// EPERM or EACCES means that we don't have write permission on
|
||||
// the parent directory, but this entry might still be a directory
|
||||
// whose contents need to be removed.
|
||||
// Otherwise, just return the error.
|
||||
if err != unix.EISDIR && err != unix.EPERM && err != unix.EACCES {
|
||||
return &PathError{Op: "unlinkat", Path: base, Err: err}
|
||||
}
|
||||
|
||||
// Is this a directory we need to recurse into?
|
||||
var statInfo unix.Stat_t
|
||||
statErr := ignoringEINTR(func() error {
|
||||
return unix.Fstatat(parentFd, base, &statInfo, AT_SYMLINK_NOFOLLOW)
|
||||
})
|
||||
if statErr != nil {
|
||||
if errors.Is(statErr, ErrNotExist) {
|
||||
return nil
|
||||
}
|
||||
return &PathError{Op: "fstatat", Path: base, Err: statErr}
|
||||
}
|
||||
if statInfo.Mode&unix.S_IFMT != unix.S_IFDIR {
|
||||
// Not a directory; return the error from the unix.Unlinkat.
|
||||
return &PathError{Op: "unlinkat", Path: base, Err: err}
|
||||
}
|
||||
|
||||
// Remove all contents will remove the contents of the directory.
|
||||
//
|
||||
// It was split out of this function to allow the deletion of the
|
||||
// contents of a directory, without deleting the directory itself.
|
||||
recurseErr := removeContentsFrom(fs, parent, base)
|
||||
|
||||
// Remove the directory itself.
|
||||
unlinkErr := fs.unlinkat(parentFd, base, AT_REMOVEDIR)
|
||||
if unlinkErr == nil || errors.Is(unlinkErr, ErrNotExist) {
|
||||
return nil
|
||||
}
|
||||
|
||||
if recurseErr != nil {
|
||||
return recurseErr
|
||||
}
|
||||
|
||||
return ensurePathError(err, "unlinkat", base)
|
||||
}
|
||||
|
||||
// openFdAt opens path relative to the directory in fd.
|
||||
// Other than that this should act like openFileNolog.
|
||||
// This acts like openFileNolog rather than OpenFile because
|
||||
// we are going to (try to) remove the file.
|
||||
// The contents of this file are not relevant for test caching.
|
||||
func openFdAt(dirfd int, name string) (File, error) {
|
||||
var fd int
|
||||
for {
|
||||
var err error
|
||||
fd, err = unix.Openat(dirfd, name, O_RDONLY|O_CLOEXEC|O_NOFOLLOW, 0)
|
||||
if err == nil {
|
||||
break
|
||||
}
|
||||
|
||||
// See comment in openFileNolog.
|
||||
if err == unix.EINTR {
|
||||
continue
|
||||
}
|
||||
|
||||
return nil, err
|
||||
}
|
||||
// This is stupid, os.NewFile immediately casts `fd` to an `int`, but wants
|
||||
// it to be passed as a `uintptr`.
|
||||
return os.NewFile(uintptr(fd), name), nil
|
||||
}
|
||||
67
internal/ufs/stat_unix.go
Normal file
67
internal/ufs/stat_unix.go
Normal file
@@ -0,0 +1,67 @@
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
// Code in this file was copied from `go/src/os/stat_linux.go`
|
||||
// and `go/src/os/types_unix.go`.
|
||||
|
||||
// Copyright 2009 The Go Authors. All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style
|
||||
// license that can be found in the `go.LICENSE` file.
|
||||
|
||||
//go:build unix
|
||||
|
||||
package ufs
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
type fileStat struct {
|
||||
name string
|
||||
size int64
|
||||
mode FileMode
|
||||
modTime time.Time
|
||||
sys unix.Stat_t
|
||||
}
|
||||
|
||||
var _ FileInfo = (*fileStat)(nil)
|
||||
|
||||
func (fs *fileStat) Size() int64 { return fs.size }
|
||||
func (fs *fileStat) Mode() FileMode { return fs.mode }
|
||||
func (fs *fileStat) ModTime() time.Time { return fs.modTime }
|
||||
func (fs *fileStat) Sys() any { return &fs.sys }
|
||||
func (fs *fileStat) Name() string { return fs.name }
|
||||
func (fs *fileStat) IsDir() bool { return fs.Mode().IsDir() }
|
||||
|
||||
func fillFileStatFromSys(fs *fileStat, name string) {
|
||||
fs.name = basename(name)
|
||||
fs.size = fs.sys.Size
|
||||
fs.modTime = time.Unix(fs.sys.Mtim.Unix())
|
||||
fs.mode = FileMode(fs.sys.Mode & 0o777)
|
||||
switch fs.sys.Mode & unix.S_IFMT {
|
||||
case unix.S_IFBLK:
|
||||
fs.mode |= ModeDevice
|
||||
case unix.S_IFCHR:
|
||||
fs.mode |= ModeDevice | ModeCharDevice
|
||||
case unix.S_IFDIR:
|
||||
fs.mode |= ModeDir
|
||||
case unix.S_IFIFO:
|
||||
fs.mode |= ModeNamedPipe
|
||||
case unix.S_IFLNK:
|
||||
fs.mode |= ModeSymlink
|
||||
case unix.S_IFREG:
|
||||
// nothing to do
|
||||
case unix.S_IFSOCK:
|
||||
fs.mode |= ModeSocket
|
||||
}
|
||||
if fs.sys.Mode&unix.S_ISGID != 0 {
|
||||
fs.mode |= ModeSetgid
|
||||
}
|
||||
if fs.sys.Mode&unix.S_ISUID != 0 {
|
||||
fs.mode |= ModeSetuid
|
||||
}
|
||||
if fs.sys.Mode&unix.S_ISVTX != 0 {
|
||||
fs.mode |= ModeSticky
|
||||
}
|
||||
}
|
||||
124
internal/ufs/walk.go
Normal file
124
internal/ufs/walk.go
Normal file
@@ -0,0 +1,124 @@
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
// Code in this file was derived from `go/src/io/fs/walk.go`.
|
||||
|
||||
// Copyright 2020 The Go Authors. All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style
|
||||
// license that can be found in the `go.LICENSE` file.
|
||||
|
||||
package ufs
|
||||
|
||||
import (
|
||||
iofs "io/fs"
|
||||
"path"
|
||||
)
|
||||
|
||||
// SkipDir is used as a return value from [WalkDirFunc] to indicate that
|
||||
// the directory named in the call is to be skipped. It is not returned
|
||||
// as an error by any function.
|
||||
var SkipDir = iofs.SkipDir
|
||||
|
||||
// SkipAll is used as a return value from [WalkDirFunc] to indicate that
|
||||
// all remaining files and directories are to be skipped. It is not returned
|
||||
// as an error by any function.
|
||||
var SkipAll = iofs.SkipAll
|
||||
|
||||
// WalkDirFunc is the type of the function called by [WalkDir] to visit
|
||||
// each file or directory.
|
||||
//
|
||||
// The path argument contains the argument to [WalkDir] as a prefix.
|
||||
// That is, if WalkDir is called with root argument "dir" and finds a file
|
||||
// named "a" in that directory, the walk function will be called with
|
||||
// argument "dir/a".
|
||||
//
|
||||
// The d argument is the [DirEntry] for the named path.
|
||||
//
|
||||
// The error result returned by the function controls how [WalkDir]
|
||||
// continues. If the function returns the special value [SkipDir], WalkDir
|
||||
// skips the current directory (path if d.IsDir() is true, otherwise
|
||||
// path's parent directory). If the function returns the special value
|
||||
// [SkipAll], WalkDir skips all remaining files and directories. Otherwise,
|
||||
// if the function returns a non-nil error, WalkDir stops entirely and
|
||||
// returns that error.
|
||||
//
|
||||
// The err argument reports an error related to path, signaling that
|
||||
// [WalkDir] will not walk into that directory. The function can decide how
|
||||
// to handle that error; as described earlier, returning the error will
|
||||
// cause WalkDir to stop walking the entire tree.
|
||||
//
|
||||
// [WalkDir] calls the function with a non-nil err argument in two cases.
|
||||
//
|
||||
// First, if the initial [Stat] on the root directory fails, WalkDir
|
||||
// calls the function with path set to root, d set to nil, and err set to
|
||||
// the error from [fs.Stat].
|
||||
//
|
||||
// Second, if a directory's ReadDir method (see [ReadDirFile]) fails, WalkDir calls the
|
||||
// function with path set to the directory's path, d set to an
|
||||
// [DirEntry] describing the directory, and err set to the error from
|
||||
// ReadDir. In this second case, the function is called twice with the
|
||||
// path of the directory: the first call is before the directory read is
|
||||
// attempted and has err set to nil, giving the function a chance to
|
||||
// return [SkipDir] or [SkipAll] and avoid the ReadDir entirely. The second call
|
||||
// is after a failed ReadDir and reports the error from ReadDir.
|
||||
// (If ReadDir succeeds, there is no second call.)
|
||||
type WalkDirFunc func(path string, d DirEntry, err error) error
|
||||
|
||||
// WalkDir walks the file tree rooted at root, calling fn for each file or
|
||||
// directory in the tree, including root.
|
||||
//
|
||||
// All errors that arise visiting files and directories are filtered by fn:
|
||||
// see the [WalkDirFunc] documentation for details.
|
||||
//
|
||||
// The files are walked in lexical order, which makes the output deterministic
|
||||
// but requires WalkDir to read an entire directory into memory before proceeding
|
||||
// to walk that directory.
|
||||
//
|
||||
// WalkDir does not follow symbolic links found in directories,
|
||||
// but if root itself is a symbolic link, its target will be walked.
|
||||
func WalkDir(fs Filesystem, root string, fn WalkDirFunc) error {
|
||||
info, err := fs.Stat(root)
|
||||
if err != nil {
|
||||
err = fn(root, nil, err)
|
||||
} else {
|
||||
err = walkDir(fs, root, iofs.FileInfoToDirEntry(info), fn)
|
||||
}
|
||||
if err == SkipDir || err == SkipAll {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// walkDir recursively descends path, calling walkDirFn.
|
||||
func walkDir(fs Filesystem, name string, d DirEntry, walkDirFn WalkDirFunc) error {
|
||||
if err := walkDirFn(name, d, nil); err != nil || !d.IsDir() {
|
||||
if err == SkipDir && d.IsDir() {
|
||||
// Successfully skipped directory.
|
||||
err = nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
dirs, err := fs.ReadDir(name)
|
||||
if err != nil {
|
||||
// Second call, to report ReadDir error.
|
||||
err = walkDirFn(name, d, err)
|
||||
if err != nil {
|
||||
if err == SkipDir && d.IsDir() {
|
||||
err = nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
for _, d1 := range dirs {
|
||||
name1 := path.Join(name, d1.Name())
|
||||
if err := walkDir(fs, name1, d1, walkDirFn); err != nil {
|
||||
if err == SkipDir {
|
||||
break
|
||||
}
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
314
internal/ufs/walk_unix.go
Normal file
314
internal/ufs/walk_unix.go
Normal file
@@ -0,0 +1,314 @@
|
||||
// SPDX-License-Identifier: BSD-2-Clause
|
||||
|
||||
// Some code in this file was derived from https://github.com/karrick/godirwalk.
|
||||
|
||||
//go:build unix
|
||||
|
||||
package ufs
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
iofs "io/fs"
|
||||
"os"
|
||||
"path"
|
||||
"reflect"
|
||||
"unsafe"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
type WalkDiratFunc func(dirfd int, name, relative string, d DirEntry, err error) error
|
||||
|
||||
func (fs *UnixFS) WalkDirat(dirfd int, name string, fn WalkDiratFunc) error {
|
||||
info, err := fs.Lstatat(dirfd, name)
|
||||
if err != nil {
|
||||
err = fn(dirfd, name, ".", nil, err)
|
||||
} else {
|
||||
b := newScratchBuffer()
|
||||
err = fs.walkDir(b, dirfd, name, ".", iofs.FileInfoToDirEntry(info), fn)
|
||||
}
|
||||
if err == SkipDir || err == SkipAll {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (fs *UnixFS) walkDir(b []byte, parentfd int, name, relative string, d DirEntry, walkDirFn WalkDiratFunc) error {
|
||||
if err := walkDirFn(parentfd, name, relative, d, nil); err != nil || !d.IsDir() {
|
||||
if err == SkipDir && d.IsDir() {
|
||||
// Successfully skipped directory.
|
||||
err = nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
dirfd, err := fs.openat(parentfd, name, O_DIRECTORY|O_RDONLY, 0)
|
||||
if dirfd != 0 {
|
||||
defer unix.Close(dirfd)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
dirs, err := fs.readDir(dirfd, name, relative, b)
|
||||
if err != nil {
|
||||
// Second call, to report ReadDir error.
|
||||
err = walkDirFn(dirfd, name, relative, d, err)
|
||||
if err != nil {
|
||||
if err == SkipDir && d.IsDir() {
|
||||
err = nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
for _, d1 := range dirs {
|
||||
name := d1.Name()
|
||||
// This fancy logic ensures that if we start walking from a subdirectory
|
||||
// that we don't make the path relative to the root of the filesystem.
|
||||
//
|
||||
// For example, if we walk from the root of a filesystem, relative would
|
||||
// be "." and path.Join would end up just returning name. But if relative
|
||||
// was a subdirectory, relative could be "dir" and path.Join would make
|
||||
// it "dir/child" even though we are walking starting at dir.
|
||||
var rel string
|
||||
if relative == "." {
|
||||
rel = name
|
||||
} else {
|
||||
rel = path.Join(relative, name)
|
||||
}
|
||||
if err := fs.walkDir(b, dirfd, name, rel, d1, walkDirFn); err != nil {
|
||||
if err == SkipDir {
|
||||
break
|
||||
}
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ReadDirMap .
|
||||
// TODO: document
|
||||
func ReadDirMap[T any](fs *UnixFS, path string, fn func(DirEntry) (T, error)) ([]T, error) {
|
||||
dirfd, name, closeFd, err := fs.safePath(path)
|
||||
defer closeFd()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
fd, err := fs.openat(dirfd, name, O_DIRECTORY|O_RDONLY, 0)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer unix.Close(fd)
|
||||
|
||||
entries, err := fs.readDir(fd, ".", path, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
out := make([]T, len(entries))
|
||||
for i, e := range entries {
|
||||
idx := i
|
||||
e := e
|
||||
v, err := fn(e)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out[idx] = v
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// nameOffset is a compile time constant
|
||||
const nameOffset = int(unsafe.Offsetof(unix.Dirent{}.Name))
|
||||
|
||||
func nameFromDirent(de *unix.Dirent) (name []byte) {
|
||||
// Because this GOOS' syscall.Dirent does not provide a field that specifies
|
||||
// the name length, this function must first calculate the max possible name
|
||||
// length, and then search for the NULL byte.
|
||||
ml := int(de.Reclen) - nameOffset
|
||||
|
||||
// Convert syscall.Dirent.Name, which is array of int8, to []byte, by
|
||||
// overwriting Cap, Len, and Data slice header fields to the max possible
|
||||
// name length computed above, and finding the terminating NULL byte.
|
||||
//
|
||||
// TODO: is there an alternative to the deprecated SliceHeader?
|
||||
// SliceHeader was mainly deprecated due to it being misused for avoiding
|
||||
// allocations when converting a byte slice to a string, ref;
|
||||
// https://go.dev/issue/53003
|
||||
sh := (*reflect.SliceHeader)(unsafe.Pointer(&name))
|
||||
sh.Cap = ml
|
||||
sh.Len = ml
|
||||
sh.Data = uintptr(unsafe.Pointer(&de.Name[0]))
|
||||
|
||||
if index := bytes.IndexByte(name, 0); index >= 0 {
|
||||
// Found NULL byte; set slice's cap and len accordingly.
|
||||
sh.Cap = index
|
||||
sh.Len = index
|
||||
return
|
||||
}
|
||||
|
||||
// NOTE: This branch is not expected, but included for defensive
|
||||
// programming, and provides a hard stop on the name based on the structure
|
||||
// field array size.
|
||||
sh.Cap = len(de.Name)
|
||||
sh.Len = sh.Cap
|
||||
return
|
||||
}
|
||||
|
||||
// modeTypeFromDirent converts a syscall defined constant, which is in purview
|
||||
// of OS, to a constant defined by Go, assumed by this project to be stable.
|
||||
//
|
||||
// When the syscall constant is not recognized, this function falls back to a
|
||||
// Stat on the file system.
|
||||
func (fs *UnixFS) modeTypeFromDirent(de *unix.Dirent, fd int, name string) (FileMode, error) {
|
||||
switch de.Type {
|
||||
case unix.DT_REG:
|
||||
return 0, nil
|
||||
case unix.DT_DIR:
|
||||
return ModeDir, nil
|
||||
case unix.DT_LNK:
|
||||
return ModeSymlink, nil
|
||||
case unix.DT_CHR:
|
||||
return ModeDevice | ModeCharDevice, nil
|
||||
case unix.DT_BLK:
|
||||
return ModeDevice, nil
|
||||
case unix.DT_FIFO:
|
||||
return ModeNamedPipe, nil
|
||||
case unix.DT_SOCK:
|
||||
return ModeSocket, nil
|
||||
default:
|
||||
// If syscall returned unknown type (e.g., DT_UNKNOWN, DT_WHT), then
|
||||
// resolve actual mode by reading file information.
|
||||
return fs.modeType(fd, name)
|
||||
}
|
||||
}
|
||||
|
||||
// modeType returns the mode type of the file system entry identified by
|
||||
// osPathname by calling os.LStat function, to intentionally not follow symbolic
|
||||
// links.
|
||||
//
|
||||
// Even though os.LStat provides all file mode bits, we want to ensure same
|
||||
// values returned to caller regardless of whether we obtained file mode bits
|
||||
// from syscall or stat call. Therefore, mask out the additional file mode bits
|
||||
// that are provided by stat but not by the syscall, so users can rely on their
|
||||
// values.
|
||||
func (fs *UnixFS) modeType(dirfd int, name string) (FileMode, error) {
|
||||
fi, err := fs.Lstatat(dirfd, name)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("ufs: error finding mode type for %s during readDir: %w", name, err)
|
||||
}
|
||||
return fi.Mode() & ModeType, nil
|
||||
}
|
||||
|
||||
var minimumScratchBufferSize = os.Getpagesize()
|
||||
|
||||
func newScratchBuffer() []byte {
|
||||
return make([]byte, minimumScratchBufferSize)
|
||||
}
|
||||
|
||||
func (fs *UnixFS) readDir(fd int, name, relative string, b []byte) ([]DirEntry, error) {
|
||||
scratchBuffer := b
|
||||
if scratchBuffer == nil || len(scratchBuffer) < minimumScratchBufferSize {
|
||||
scratchBuffer = newScratchBuffer()
|
||||
}
|
||||
|
||||
var entries []DirEntry
|
||||
var workBuffer []byte
|
||||
|
||||
var sde unix.Dirent
|
||||
for {
|
||||
if len(workBuffer) == 0 {
|
||||
n, err := unix.Getdents(fd, scratchBuffer)
|
||||
if err != nil {
|
||||
if err == unix.EINTR {
|
||||
continue
|
||||
}
|
||||
return nil, ensurePathError(err, "getdents", name)
|
||||
}
|
||||
if n <= 0 {
|
||||
// end of directory: normal exit
|
||||
return entries, nil
|
||||
}
|
||||
workBuffer = scratchBuffer[:n] // trim work buffer to number of bytes read
|
||||
}
|
||||
|
||||
// "Go is like C, except that you just put `unsafe` all over the place".
|
||||
copy((*[unsafe.Sizeof(unix.Dirent{})]byte)(unsafe.Pointer(&sde))[:], workBuffer)
|
||||
workBuffer = workBuffer[sde.Reclen:] // advance buffer for next iteration through loop
|
||||
|
||||
if sde.Ino == 0 {
|
||||
continue // inode set to 0 indicates an entry that was marked as deleted
|
||||
}
|
||||
|
||||
nameSlice := nameFromDirent(&sde)
|
||||
nameLength := len(nameSlice)
|
||||
|
||||
if nameLength == 0 || (nameSlice[0] == '.' && (nameLength == 1 || (nameLength == 2 && nameSlice[1] == '.'))) {
|
||||
continue
|
||||
}
|
||||
|
||||
childName := string(nameSlice)
|
||||
mt, err := fs.modeTypeFromDirent(&sde, fd, childName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var rel string
|
||||
if relative == "." {
|
||||
rel = name
|
||||
} else {
|
||||
rel = path.Join(relative, childName)
|
||||
}
|
||||
entries = append(entries, &dirent{dirfd: fd, name: childName, path: rel, modeType: mt, fs: fs})
|
||||
}
|
||||
}
|
||||
|
||||
// dirent stores the name and file system mode type of discovered file system
|
||||
// entries.
|
||||
type dirent struct {
|
||||
dirfd int
|
||||
name string
|
||||
path string
|
||||
modeType FileMode
|
||||
|
||||
fs *UnixFS
|
||||
}
|
||||
|
||||
func (de dirent) Name() string {
|
||||
return de.name
|
||||
}
|
||||
|
||||
func (de dirent) IsDir() bool {
|
||||
return de.modeType&ModeDir != 0
|
||||
}
|
||||
|
||||
func (de dirent) Type() FileMode {
|
||||
return de.modeType
|
||||
}
|
||||
|
||||
func (de dirent) Info() (FileInfo, error) {
|
||||
if de.fs == nil {
|
||||
return nil, nil
|
||||
}
|
||||
return de.fs.Lstatat(de.dirfd, de.name)
|
||||
// return de.fs.Lstat(de.path)
|
||||
}
|
||||
|
||||
func (de dirent) Open() (File, error) {
|
||||
if de.fs == nil {
|
||||
return nil, nil
|
||||
}
|
||||
return de.fs.OpenFileat(de.dirfd, de.name, O_RDONLY, 0)
|
||||
// return de.fs.OpenFile(de.path, O_RDONLY, 0)
|
||||
}
|
||||
|
||||
// reset releases memory held by entry err and name, and resets mode type to 0.
|
||||
func (de *dirent) reset() {
|
||||
de.name = ""
|
||||
de.path = ""
|
||||
de.modeType = 0
|
||||
de.dirfd = 0
|
||||
}
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
@@ -19,6 +18,7 @@ import (
|
||||
"gopkg.in/yaml.v3"
|
||||
|
||||
"github.com/pterodactyl/wings/config"
|
||||
"github.com/pterodactyl/wings/internal/ufs"
|
||||
)
|
||||
|
||||
// The file parsing options that are available for a server configuration file.
|
||||
@@ -161,14 +161,14 @@ func (cfr *ConfigurationFileReplacement) UnmarshalJSON(data []byte) error {
|
||||
iv, err := jsonparser.GetString(data, "if_value")
|
||||
// We only check keypath here since match & replace_with should be present on all of
|
||||
// them, however if_value is optional.
|
||||
if err != nil && !errors.Is(err, jsonparser.KeyPathNotFoundError) {
|
||||
if err != nil && err != jsonparser.KeyPathNotFoundError {
|
||||
return err
|
||||
}
|
||||
cfr.IfValue = iv
|
||||
|
||||
rw, dt, _, err := jsonparser.Get(data, "replace_with")
|
||||
if err != nil {
|
||||
if !errors.Is(err, jsonparser.KeyPathNotFoundError) {
|
||||
if err != jsonparser.KeyPathNotFoundError {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -190,7 +190,7 @@ func (cfr *ConfigurationFileReplacement) UnmarshalJSON(data []byte) error {
|
||||
|
||||
// Parse parses a given configuration file and updates all the values within
|
||||
// as defined in the API response from the Panel.
|
||||
func (f *ConfigurationFile) Parse(file *os.File) error {
|
||||
func (f *ConfigurationFile) Parse(file ufs.File) error {
|
||||
// log.WithField("path", path).WithField("parser", f.Parser.String()).Debug("parsing server configuration file")
|
||||
|
||||
// What the fuck is going on here?
|
||||
@@ -220,7 +220,7 @@ func (f *ConfigurationFile) Parse(file *os.File) error {
|
||||
}
|
||||
|
||||
// Parses an xml file.
|
||||
func (f *ConfigurationFile) parseXmlFile(file *os.File) error {
|
||||
func (f *ConfigurationFile) parseXmlFile(file ufs.File) error {
|
||||
doc := etree.NewDocument()
|
||||
if _, err := doc.ReadFrom(file); err != nil {
|
||||
return err
|
||||
@@ -299,7 +299,7 @@ func (f *ConfigurationFile) parseXmlFile(file *os.File) error {
|
||||
}
|
||||
|
||||
// Parses an ini file.
|
||||
func (f *ConfigurationFile) parseIniFile(file *os.File) error {
|
||||
func (f *ConfigurationFile) parseIniFile(file ufs.File) error {
|
||||
// Wrap the file in a NopCloser so the ini package doesn't close the file.
|
||||
cfg, err := ini.Load(io.NopCloser(file))
|
||||
if err != nil {
|
||||
@@ -380,7 +380,7 @@ func (f *ConfigurationFile) parseIniFile(file *os.File) error {
|
||||
// Parses a json file updating any matching key/value pairs. If a match is not found, the
|
||||
// value is set regardless in the file. See the commentary in parseYamlFile for more details
|
||||
// about what is happening during this process.
|
||||
func (f *ConfigurationFile) parseJsonFile(file *os.File) error {
|
||||
func (f *ConfigurationFile) parseJsonFile(file ufs.File) error {
|
||||
b, err := io.ReadAll(file)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -407,7 +407,7 @@ func (f *ConfigurationFile) parseJsonFile(file *os.File) error {
|
||||
|
||||
// Parses a yaml file and updates any matching key/value pairs before persisting
|
||||
// it back to the disk.
|
||||
func (f *ConfigurationFile) parseYamlFile(file *os.File) error {
|
||||
func (f *ConfigurationFile) parseYamlFile(file ufs.File) error {
|
||||
b, err := io.ReadAll(file)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -456,7 +456,7 @@ func (f *ConfigurationFile) parseYamlFile(file *os.File) error {
|
||||
// Parses a text file using basic find and replace. This is a highly inefficient method of
|
||||
// scanning a file and performing a replacement. You should attempt to use anything other
|
||||
// than this function where possible.
|
||||
func (f *ConfigurationFile) parseTextFile(file *os.File) error {
|
||||
func (f *ConfigurationFile) parseTextFile(file ufs.File) error {
|
||||
b := bytes.NewBuffer(nil)
|
||||
s := bufio.NewScanner(file)
|
||||
var replaced bool
|
||||
@@ -518,7 +518,7 @@ func (f *ConfigurationFile) parseTextFile(file *os.File) error {
|
||||
//
|
||||
// @see https://github.com/pterodactyl/panel/issues/2308 (original)
|
||||
// @see https://github.com/pterodactyl/panel/issues/3009 ("bug" introduced as result)
|
||||
func (f *ConfigurationFile) parsePropertiesFile(file *os.File) error {
|
||||
func (f *ConfigurationFile) parsePropertiesFile(file ufs.File) error {
|
||||
b, err := io.ReadAll(file)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -118,9 +118,9 @@ func (re *RequestError) asFilesystemError() (int, string) {
|
||||
return 0, ""
|
||||
}
|
||||
if filesystem.IsErrorCode(err, filesystem.ErrNotExist) ||
|
||||
filesystem.IsPathError(err) ||
|
||||
filesystem.IsLinkError(err) {
|
||||
return http.StatusNotFound, "The requested file or folder does not exist on the system."
|
||||
filesystem.IsErrorCode(err, filesystem.ErrCodePathResolution) ||
|
||||
strings.Contains(err.Error(), "resolves to a location outside the server root") {
|
||||
return http.StatusNotFound, "The requested resources was not found on the system."
|
||||
}
|
||||
if filesystem.IsErrorCode(err, filesystem.ErrCodeDenylistFile) || strings.Contains(err.Error(), "filesystem: file access prohibited") {
|
||||
return http.StatusForbidden, "This file cannot be modified: present in egg denylist."
|
||||
|
||||
@@ -56,8 +56,8 @@ func getDownloadBackup(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// The use of `os` here is safe as backups are not stored within server access
|
||||
// directories, and this path is program-controlled, not user input.
|
||||
// The use of `os` here is safe as backups are not stored within server
|
||||
// accessible directories.
|
||||
f, err := os.Open(b.Path())
|
||||
if err != nil {
|
||||
middleware.CaptureAndAbort(c, err)
|
||||
|
||||
@@ -225,26 +225,21 @@ func deleteServer(c *gin.Context) {
|
||||
// done in a separate process since failure is not the end of the world and can be
|
||||
// manually cleaned up after the fact.
|
||||
//
|
||||
// In addition, servers with large numbers of files can take some time to finish deleting,
|
||||
// In addition, servers with large amounts of files can take some time to finish deleting,
|
||||
// so we don't want to block the HTTP call while waiting on this.
|
||||
p := s.Filesystem().Path()
|
||||
go func(p string) {
|
||||
go func(s *server.Server) {
|
||||
fs := s.Filesystem()
|
||||
p := fs.Path()
|
||||
_ = fs.UnixFS().Close()
|
||||
if err := os.RemoveAll(p); err != nil {
|
||||
log.WithFields(log.Fields{"path": p, "error": err}).Warn("failed to remove server files during deletion process")
|
||||
}
|
||||
}(p)
|
||||
|
||||
if err := s.Filesystem().Close(); err != nil {
|
||||
log.WithFields(log.Fields{"server": s.ID(), "error": err}).Warn("failed to close filesystem root")
|
||||
}
|
||||
}(s)
|
||||
|
||||
middleware.ExtractManager(c).Remove(func(server *server.Server) bool {
|
||||
return server.ID() == s.ID()
|
||||
})
|
||||
|
||||
// Deallocate the reference to this server.
|
||||
s = nil
|
||||
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
|
||||
@@ -30,7 +30,8 @@ import (
|
||||
// getServerFileContents returns the contents of a file on the server.
|
||||
func getServerFileContents(c *gin.Context) {
|
||||
s := middleware.ExtractServer(c)
|
||||
f, st, err := s.Filesystem().File(c.Query("file"))
|
||||
p := strings.TrimLeft(c.Query("file"), "/")
|
||||
f, st, err := s.Filesystem().File(p)
|
||||
if err != nil {
|
||||
middleware.CaptureAndAbort(c, err)
|
||||
return
|
||||
@@ -128,7 +129,6 @@ func putServerRenameFiles(c *gin.Context) {
|
||||
}
|
||||
if err := fs.Rename(pf, pt); err != nil {
|
||||
// Return nil if the error is an is not exists.
|
||||
// NOTE: os.IsNotExist() does not work if the error is wrapped.
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
s.Log().WithField("error", err).
|
||||
WithField("from_path", pf).
|
||||
@@ -421,15 +421,9 @@ func postServerCompressFiles(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
f, err := s.Filesystem().CompressFiles(c.Request.Context(), data.RootPath, data.Files)
|
||||
f, err := s.Filesystem().CompressFiles(data.RootPath, data.Files)
|
||||
if err != nil {
|
||||
if errors.Is(err, filesystem.ErrNoSpaceAvailable) {
|
||||
c.AbortWithStatusJSON(http.StatusConflict, gin.H{
|
||||
"error": "This server does not have enough available disk space to generate a compressed archive.",
|
||||
})
|
||||
} else {
|
||||
middleware.CaptureAndAbort(c, err)
|
||||
}
|
||||
middleware.CaptureAndAbort(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -453,6 +447,17 @@ func postServerDecompressFiles(c *gin.Context) {
|
||||
|
||||
s := middleware.ExtractServer(c)
|
||||
lg := middleware.ExtractLogger(c).WithFields(log.Fields{"root_path": data.RootPath, "file": data.File})
|
||||
lg.Debug("checking if space is available for file decompression")
|
||||
err := s.Filesystem().SpaceAvailableForDecompression(context.Background(), data.RootPath, data.File)
|
||||
if err != nil {
|
||||
if filesystem.IsErrorCode(err, filesystem.ErrCodeUnknownArchive) {
|
||||
lg.WithField("error", err).Warn("failed to decompress file: unknown archive format")
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "The archive provided is in a format Wings does not understand."})
|
||||
return
|
||||
}
|
||||
middleware.CaptureAndAbort(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
lg.Info("starting file decompression")
|
||||
if err := s.Filesystem().DecompressFile(context.Background(), data.RootPath, data.File); err != nil {
|
||||
@@ -616,6 +621,7 @@ func handleFileUpload(p string, s *server.Server, header *multipart.FileHeader)
|
||||
if err := s.Filesystem().IsIgnored(p); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := s.Filesystem().Write(p, file, header.Size, 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -106,11 +106,9 @@ func postTransfers(c *gin.Context) {
|
||||
if !successful && err != nil {
|
||||
// Delete all extracted files.
|
||||
go func(trnsfr *transfer.Transfer) {
|
||||
_ = trnsfr.Server.Filesystem().Close()
|
||||
if err := os.RemoveAll(trnsfr.Server.Filesystem().Path()); err != nil {
|
||||
if !errors.Is(err, os.ErrNotExist) {
|
||||
trnsfr.Log().WithError(err).Warn("failed to delete local server files")
|
||||
}
|
||||
_ = trnsfr.Server.Filesystem().UnixFS().Close()
|
||||
if err := os.RemoveAll(trnsfr.Server.Filesystem().Path()); err != nil && !os.IsNotExist(err) {
|
||||
trnsfr.Log().WithError(err).Warn("failed to delete local server files")
|
||||
}
|
||||
}(trnsfr)
|
||||
}
|
||||
@@ -168,7 +166,7 @@ out:
|
||||
case "archive":
|
||||
trnsfr.Log().Debug("received archive")
|
||||
|
||||
if _, err := trnsfr.Server.EnsureDataDirectoryExists(); err != nil {
|
||||
if err := trnsfr.Server.EnsureDataDirectoryExists(); err != nil {
|
||||
middleware.CaptureAndAbort(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -67,7 +67,7 @@ func (s *Server) Backup(b backup.BackupInterface) error {
|
||||
}
|
||||
}
|
||||
|
||||
ad, err := b.Generate(s.Context(), s.Filesystem().Path(), ignored)
|
||||
ad, err := b.Generate(s.Context(), s.Filesystem(), ignored)
|
||||
if err != nil {
|
||||
if err := s.notifyPanelOfBackup(b.Identifier(), &backup.ArchiveDetails{}, false); err != nil {
|
||||
s.Log().WithFields(log.Fields{
|
||||
@@ -153,27 +153,13 @@ func (s *Server) RestoreBackup(b backup.BackupInterface, reader io.ReadCloser) (
|
||||
s.Log().Debug("starting file writing process for backup restoration")
|
||||
err = b.Restore(s.Context(), reader, func(file string, info fs.FileInfo, r io.ReadCloser) error {
|
||||
defer r.Close()
|
||||
if file == "." {
|
||||
return nil
|
||||
}
|
||||
|
||||
s.Events().Publish(DaemonMessageEvent, "(restoring): "+file)
|
||||
if info.IsDir() {
|
||||
if err := s.Filesystem().Mkdir(file, info.Mode().Perm()); err != nil {
|
||||
if !errors.Is(err, os.ErrExist) {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if !info.Mode().IsRegular() {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := s.Filesystem().Write(file, r, info.Size(), info.Mode().Perm()); err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
// TODO: since this will be called a lot, it may be worth adding an optimized
|
||||
// Write with Chtimes method to the UnixFS that is able to re-use the
|
||||
// same dirfd and file name.
|
||||
if err := s.Filesystem().Write(file, r, info.Size(), info.Mode()); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
atime := info.ModTime()
|
||||
return s.Filesystem().Chtimes(file, atime, atime)
|
||||
})
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
|
||||
"github.com/pterodactyl/wings/config"
|
||||
"github.com/pterodactyl/wings/remote"
|
||||
"github.com/pterodactyl/wings/server/filesystem"
|
||||
)
|
||||
|
||||
var format = archives.CompressedArchive{
|
||||
@@ -47,7 +48,7 @@ type BackupInterface interface {
|
||||
WithLogContext(map[string]interface{})
|
||||
// Generate creates a backup in whatever the configured source for the
|
||||
// specific implementation is.
|
||||
Generate(context.Context, string, string) (*ArchiveDetails, error)
|
||||
Generate(context.Context, *filesystem.Filesystem, string) (*ArchiveDetails, error)
|
||||
// Ignored returns the ignored files for this backup instance.
|
||||
Ignored() string
|
||||
// Checksum returns a SHA1 checksum for the generated backup.
|
||||
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"context"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"emperror.dev/errors"
|
||||
"github.com/juju/ratelimit"
|
||||
@@ -60,24 +59,14 @@ func (b *LocalBackup) WithLogContext(c map[string]interface{}) {
|
||||
|
||||
// Generate generates a backup of the selected files and pushes it to the
|
||||
// defined location for this instance.
|
||||
func (b *LocalBackup) Generate(ctx context.Context, basePath, ignore string) (*ArchiveDetails, error) {
|
||||
r, err := os.OpenRoot(basePath)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "server/backup: failed to open root directory")
|
||||
}
|
||||
defer r.Close()
|
||||
a, err := filesystem.NewArchive(r, "/", filesystem.WithIgnored(strings.Split(ignore, "\n")))
|
||||
if err != nil {
|
||||
return nil, errors.WrapIf(err, "server/backup: failed to create archive")
|
||||
func (b *LocalBackup) Generate(ctx context.Context, fsys *filesystem.Filesystem, ignore string) (*ArchiveDetails, error) {
|
||||
a := &filesystem.Archive{
|
||||
Filesystem: fsys,
|
||||
Ignore: ignore,
|
||||
}
|
||||
|
||||
b.log().WithField("path", b.Path()).Info("creating backup for server")
|
||||
f, err := os.OpenFile(b.Path(), os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o644)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "server/backup: failed to open file for writing")
|
||||
}
|
||||
defer f.Close()
|
||||
if err := a.Create(ctx, f); err != nil {
|
||||
if err := a.Create(ctx, b.Path()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
b.log().Info("created backup successfully")
|
||||
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"emperror.dev/errors"
|
||||
@@ -49,36 +48,27 @@ func (s *S3Backup) WithLogContext(c map[string]interface{}) {
|
||||
|
||||
// Generate creates a new backup on the disk, moves it into the S3 bucket via
|
||||
// the provided presigned URL, and then deletes the backup from the disk.
|
||||
func (s *S3Backup) Generate(ctx context.Context, basePath, ignore string) (*ArchiveDetails, error) {
|
||||
func (s *S3Backup) Generate(ctx context.Context, fsys *filesystem.Filesystem, ignore string) (*ArchiveDetails, error) {
|
||||
defer s.Remove()
|
||||
|
||||
r, err := os.OpenRoot(basePath)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "backup: failed to open root directory")
|
||||
}
|
||||
defer r.Close()
|
||||
a, err := filesystem.NewArchive(r, "/", filesystem.WithIgnored(strings.Split(ignore, "\n")))
|
||||
if err != nil {
|
||||
return nil, errors.WrapIf(err, "backup: failed to create archive")
|
||||
a := &filesystem.Archive{
|
||||
Filesystem: fsys,
|
||||
Ignore: ignore,
|
||||
}
|
||||
|
||||
s.log().WithField("path", s.Path()).Info("creating backup for server")
|
||||
f, err := os.OpenFile(s.Path(), os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o644)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "backup: failed to open file for writing")
|
||||
}
|
||||
defer f.Close()
|
||||
if err := a.Create(ctx, f); err != nil {
|
||||
if err := a.Create(ctx, s.Path()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.log().Info("created backup successfully")
|
||||
|
||||
_ = f.Sync()
|
||||
if _, err := f.Seek(0, io.SeekStart); err != nil {
|
||||
return nil, errors.Wrap(err, "backup: failed to seek on file")
|
||||
rc, err := os.Open(s.Path())
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "backup: could not read archive from disk")
|
||||
}
|
||||
defer rc.Close()
|
||||
|
||||
parts, err := s.generateRemoteRequest(ctx, f)
|
||||
parts, err := s.generateRemoteRequest(ctx, rc)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -1,31 +1,34 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"os"
|
||||
"runtime"
|
||||
|
||||
"github.com/gammazero/workerpool"
|
||||
|
||||
"github.com/pterodactyl/wings/internal/ufs"
|
||||
)
|
||||
|
||||
// UpdateConfigurationFiles updates all of the defined configuration files for
|
||||
// UpdateConfigurationFiles updates all the defined configuration files for
|
||||
// a server automatically to ensure that they always use the specified values.
|
||||
func (s *Server) UpdateConfigurationFiles() {
|
||||
pool := workerpool.New(runtime.NumCPU())
|
||||
files := s.ProcessConfiguration().ConfigurationFiles
|
||||
|
||||
s.Log().Debug("acquiring process configuration files...")
|
||||
files := s.ProcessConfiguration().ConfigurationFiles
|
||||
s.Log().Debug("acquired process configuration files")
|
||||
for _, cf := range files {
|
||||
f := cf
|
||||
|
||||
pool.Submit(func() {
|
||||
fd, err := s.Filesystem().Touch(f.FileName, os.O_RDWR|os.O_CREATE, 0o644)
|
||||
file, err := s.Filesystem().UnixFS().Touch(f.FileName, ufs.O_RDWR|ufs.O_CREATE, 0o644)
|
||||
if err != nil {
|
||||
s.Log().WithField("file_name", f.FileName).WithField("error", err).Error("failed to open configuration file")
|
||||
s.Log().WithField("file_name", f.FileName).WithField("error", err).Error("failed to open file for configuration")
|
||||
return
|
||||
}
|
||||
defer fd.Close()
|
||||
defer file.Close()
|
||||
|
||||
if err := f.Parse(fd); err != nil {
|
||||
s.Log().WithField("error", err).WithField("file_name", f.FileName).Error("failed to parse and update server configuration file")
|
||||
if err := f.Parse(file); err != nil {
|
||||
s.Log().WithField("error", err).Error("failed to parse and update server configuration file")
|
||||
}
|
||||
|
||||
s.Log().WithField("file_name", f.FileName).Debug("finished processing server configuration file")
|
||||
|
||||
@@ -11,18 +11,18 @@ import (
|
||||
"sync"
|
||||
|
||||
"emperror.dev/errors"
|
||||
"github.com/apex/log"
|
||||
"github.com/juju/ratelimit"
|
||||
"github.com/klauspost/pgzip"
|
||||
ignore "github.com/sabhiram/go-gitignore"
|
||||
|
||||
"github.com/pterodactyl/wings/config"
|
||||
"github.com/pterodactyl/wings/internal/progress"
|
||||
"github.com/pterodactyl/wings/internal/ufs"
|
||||
)
|
||||
|
||||
const memory = 4 * 1024
|
||||
|
||||
var ErrNoSpaceAvailable = errors.Sentinel("archive: no space available on disk")
|
||||
|
||||
var pool = sync.Pool{
|
||||
New: func() interface{} {
|
||||
b := make([]byte, memory)
|
||||
@@ -36,8 +36,7 @@ type TarProgress struct {
|
||||
p *progress.Progress
|
||||
}
|
||||
|
||||
// NewTarProgress returns a new progress writer for the tar file. This is a wrapper
|
||||
// around the standard writer with a progress instance embedded.
|
||||
// NewTarProgress .
|
||||
func NewTarProgress(w *tar.Writer, p *progress.Progress) *TarProgress {
|
||||
if p != nil {
|
||||
p.Writer = w
|
||||
@@ -56,79 +55,44 @@ func (p *TarProgress) Write(v []byte) (int, error) {
|
||||
return p.p.Write(v)
|
||||
}
|
||||
|
||||
type ArchiveOption func(a *Archive) error
|
||||
|
||||
type Archive struct {
|
||||
root *os.Root
|
||||
dir string
|
||||
pw *TarProgress
|
||||
ignored *ignore.GitIgnore
|
||||
matching *ignore.GitIgnore
|
||||
p *progress.Progress
|
||||
// Filesystem to create the archive with.
|
||||
Filesystem *Filesystem
|
||||
|
||||
// Ignore is a gitignore string (most likely read from a file) of files to ignore
|
||||
// from the archive.
|
||||
Ignore string
|
||||
|
||||
// BaseDirectory .
|
||||
BaseDirectory string
|
||||
|
||||
// Files specifies the files to archive, this takes priority over the Ignore
|
||||
// option, if unspecified, all files in the BaseDirectory will be archived
|
||||
// unless Ignore is set.
|
||||
Files []string
|
||||
|
||||
// Progress wraps the writer of the archive to pass through the progress tracker.
|
||||
Progress *progress.Progress
|
||||
|
||||
w *TarProgress
|
||||
}
|
||||
|
||||
// NewArchive returns a new archive instance that can be used for generating an
|
||||
// archive of files and folders within the provided os.Root. The "dir" value is
|
||||
// a child directory within the `os.Root` instance.
|
||||
func NewArchive(r *os.Root, dir string, opts ...ArchiveOption) (*Archive, error) {
|
||||
a := &Archive{root: r, dir: dir}
|
||||
for _, opt := range opts {
|
||||
if err := opt(a); err != nil {
|
||||
return nil, errors.Wrap(err, "server/filesystem: archive: failed to apply callback option")
|
||||
}
|
||||
// Create creates an archive at dst with all the files defined in the
|
||||
// included Files array.
|
||||
//
|
||||
// THIS IS UNSAFE TO USE IF `dst` IS PROVIDED BY A USER! ONLY USE THIS WITH
|
||||
// CONTROLLED PATHS!
|
||||
func (a *Archive) Create(ctx context.Context, dst string) error {
|
||||
// Using os.OpenFile here is expected, as long as `dst` is not a user
|
||||
// provided path.
|
||||
f, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o600)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return a, nil
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
func WithProgress(p *progress.Progress) ArchiveOption {
|
||||
return func(a *Archive) error {
|
||||
a.p = p
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func WithIgnored(files []string) ArchiveOption {
|
||||
return func(a *Archive) error {
|
||||
if a.matching != nil {
|
||||
return errors.NewPlain("cannot create an archive with both ignored and matching configurations")
|
||||
}
|
||||
|
||||
a.ignored = ignore.CompileIgnoreLines(files...)
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func WithMatching(files []string) ArchiveOption {
|
||||
return func(a *Archive) error {
|
||||
if a.ignored != nil {
|
||||
return errors.NewPlain("cannot create an archive with both ignored and matching configurations")
|
||||
}
|
||||
|
||||
lines := make([]string, len(files))
|
||||
for _, f := range files {
|
||||
// The old archiver logic just accepted an array of paths to include in the
|
||||
// archive and did rudimentary logic to determine if they should be included.
|
||||
// This newer logic makes use of the gitignore (flipped to make it an allowlist),
|
||||
// but to do that we need to make sure all the provided values here start with a
|
||||
// slash; otherwise files/folders nested deeply might be unintentionally included.
|
||||
lines = append(lines, "/"+strings.TrimPrefix(f, "/"))
|
||||
}
|
||||
|
||||
a.matching = ignore.CompileIgnoreLines(lines...)
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func (a *Archive) Progress() *progress.Progress {
|
||||
return a.p
|
||||
}
|
||||
|
||||
// Create .
|
||||
func (a *Archive) Create(ctx context.Context, f *os.File) error {
|
||||
// Select a writer based off of the WriteLimit configuration option. If there is no
|
||||
// write limit use the file as the writer.
|
||||
// write limit, use the file as the writer.
|
||||
var writer io.Writer
|
||||
if writeLimit := int64(config.Get().System.Backups.WriteLimit * 1024 * 1024); writeLimit > 0 {
|
||||
// Token bucket with a capacity of "writeLimit" MiB, adding "writeLimit" MiB/s
|
||||
@@ -141,9 +105,30 @@ func (a *Archive) Create(ctx context.Context, f *os.File) error {
|
||||
return a.Stream(ctx, writer)
|
||||
}
|
||||
|
||||
// Stream walks the given root directory and generates an archive from the
|
||||
// provided files.
|
||||
type walkFunc func(dirfd int, name, relative string, d ufs.DirEntry) error
|
||||
|
||||
// Stream streams the creation of the archive to the given writer.
|
||||
func (a *Archive) Stream(ctx context.Context, w io.Writer) error {
|
||||
if a.Filesystem == nil {
|
||||
return errors.New("filesystem: archive.Filesystem is unset")
|
||||
}
|
||||
|
||||
// The base directory may come with a prefixed `/`, strip it to prevent
|
||||
// problems.
|
||||
a.BaseDirectory = strings.TrimPrefix(a.BaseDirectory, "/")
|
||||
|
||||
if filesLen := len(a.Files); filesLen > 0 {
|
||||
files := make([]string, filesLen)
|
||||
for i, f := range a.Files {
|
||||
if !strings.HasPrefix(f, a.Filesystem.Path()) {
|
||||
files[i] = f
|
||||
continue
|
||||
}
|
||||
files[i] = strings.TrimPrefix(strings.TrimPrefix(f, a.Filesystem.Path()), "/")
|
||||
}
|
||||
a.Files = files
|
||||
}
|
||||
|
||||
// Choose which compression level to use based on the compression_level configuration option
|
||||
var compressionLevel int
|
||||
switch config.Get().System.Backups.CompressionLevel {
|
||||
@@ -164,55 +149,124 @@ func (a *Archive) Stream(ctx context.Context, w io.Writer) error {
|
||||
tw := tar.NewWriter(gw)
|
||||
defer tw.Close()
|
||||
|
||||
a.pw = NewTarProgress(tw, a.p)
|
||||
defer a.pw.Close()
|
||||
a.w = NewTarProgress(tw, a.Progress)
|
||||
|
||||
r, err := a.root.OpenRoot(normalize(a.dir))
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "server/filesystem: archive: failed to acquire root dir instance")
|
||||
fs := a.Filesystem.unixFS
|
||||
|
||||
// If we're specifically looking for only certain files, or have requested
|
||||
// that certain files be ignored we'll update the callback function to reflect
|
||||
// that request.
|
||||
var callback walkFunc
|
||||
if len(a.Files) == 0 && len(a.Ignore) > 0 {
|
||||
i := ignore.CompileIgnoreLines(strings.Split(a.Ignore, "\n")...)
|
||||
callback = a.callback(func(_ int, _, relative string, _ ufs.DirEntry) error {
|
||||
if i.MatchesPath(relative) {
|
||||
return SkipThis
|
||||
}
|
||||
return nil
|
||||
})
|
||||
} else if len(a.Files) > 0 {
|
||||
callback = a.withFilesCallback()
|
||||
} else {
|
||||
callback = a.callback()
|
||||
}
|
||||
defer r.Close()
|
||||
|
||||
base := strings.TrimRight(r.Name(), "./")
|
||||
return filepath.WalkDir(base, a.walker(ctx, base))
|
||||
// Open the base directory we were provided.
|
||||
dirfd, name, closeFd, err := fs.SafePath(a.BaseDirectory)
|
||||
defer closeFd()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Recursively walk the base directory.
|
||||
return fs.WalkDirat(dirfd, name, func(dirfd int, name, relative string, d ufs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
default:
|
||||
return callback(dirfd, name, relative, d)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Callback function used to determine if a given file should be included in the archive
|
||||
// being generated.
|
||||
func (a *Archive) walker(ctx context.Context, base string) fs.WalkDirFunc {
|
||||
return func(path string, de fs.DirEntry, err error) error {
|
||||
if ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return fs.SkipDir
|
||||
}
|
||||
|
||||
path = strings.TrimPrefix(path, base)
|
||||
if a.ignored != nil && a.ignored.MatchesPath(path) {
|
||||
func (a *Archive) callback(opts ...walkFunc) walkFunc {
|
||||
// Get the base directory we need to strip when walking.
|
||||
//
|
||||
// This is important as when we are walking, the last part of the base directory
|
||||
// is present on all the paths we walk.
|
||||
var base string
|
||||
if a.BaseDirectory != "" {
|
||||
base = filepath.Base(a.BaseDirectory) + "/"
|
||||
}
|
||||
return func(dirfd int, name, relative string, d ufs.DirEntry) error {
|
||||
// Skip directories because we are walking them recursively.
|
||||
if d.IsDir() {
|
||||
return nil
|
||||
}
|
||||
|
||||
if a.matching != nil && !a.matching.MatchesPath(path) {
|
||||
return nil
|
||||
// If base isn't empty, strip it from the relative path. This fixes an
|
||||
// issue when creating an archive starting from a nested directory.
|
||||
//
|
||||
// See https://github.com/pterodactyl/panel/issues/5030 for more details.
|
||||
if base != "" {
|
||||
relative = strings.TrimPrefix(relative, base)
|
||||
}
|
||||
|
||||
// Call the additional options passed to this callback function. If any of them return
|
||||
// a non-nil error we will exit immediately.
|
||||
for _, opt := range opts {
|
||||
if err := opt(dirfd, name, relative, d); err != nil {
|
||||
if err == SkipThis {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Add the file to the archive, if it is nested in a directory,
|
||||
// the directory will be automatically "created" in the archive.
|
||||
return a.addToArchive(path)
|
||||
return a.addToArchive(dirfd, name, relative, d)
|
||||
}
|
||||
}
|
||||
|
||||
// Adds a given file path to the final archive being created.
|
||||
func (a *Archive) addToArchive(p string) error {
|
||||
p = normalize(p)
|
||||
s, err := a.root.Lstat(p)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
var SkipThis = errors.New("skip this")
|
||||
|
||||
// Pushes only files defined in the Files key to the final archive.
|
||||
func (a *Archive) withFilesCallback() walkFunc {
|
||||
return a.callback(func(_ int, _, relative string, _ ufs.DirEntry) error {
|
||||
for _, f := range a.Files {
|
||||
// Allow exact file matches, otherwise check if file is within a parent directory.
|
||||
//
|
||||
// The slashes are added in the prefix checks to prevent partial name matches from being
|
||||
// included in the archive.
|
||||
if f != relative && !strings.HasPrefix(strings.TrimSuffix(relative, "/")+"/", strings.TrimSuffix(f, "/")+"/") {
|
||||
continue
|
||||
}
|
||||
|
||||
// Once we have a match return a nil value here so that the loop stops and the
|
||||
// call to this function will correctly include the file in the archive. If there
|
||||
// are no matches we'll never make it to this line, and the final error returned
|
||||
// will be the ufs.SkipDir error.
|
||||
return nil
|
||||
}
|
||||
return errors.Wrap(err, "server/filesystem: archive: failed to stat file")
|
||||
|
||||
return SkipThis
|
||||
})
|
||||
}
|
||||
|
||||
// Adds a given file path to the final archive being created.
|
||||
func (a *Archive) addToArchive(dirfd int, name, relative string, entry ufs.DirEntry) error {
|
||||
s, err := entry.Info()
|
||||
if err != nil {
|
||||
if errors.Is(err, ufs.ErrNotExist) {
|
||||
return nil
|
||||
}
|
||||
return errors.WrapIff(err, "failed executing os.Lstat on '%s'", name)
|
||||
}
|
||||
|
||||
// Skip socket files as they are unsupported by archive/tar.
|
||||
@@ -224,27 +278,34 @@ func (a *Archive) addToArchive(p string) error {
|
||||
// Resolve the symlink target if the file is a symlink.
|
||||
var target string
|
||||
if s.Mode()&fs.ModeSymlink != 0 {
|
||||
// This intentionally uses [os.Readlink] and not the [os.Root] instance. We need to
|
||||
// know the actual target for the symlink, even if outside the server directory, so
|
||||
// that we can restore it properly.
|
||||
//
|
||||
// This target is only used for the sake of keeping everything correct in the archive;
|
||||
// we never read the target file contents.
|
||||
target, err = os.Readlink(filepath.Join(a.root.Name(), p))
|
||||
// Read the target of the symlink. If there are any errors we will dump them out to
|
||||
// the logs, but we're not going to stop the backup. There are far too many cases of
|
||||
// symlinks causing all sorts of unnecessary pain in this process. Sucks to suck if
|
||||
// it doesn't work.
|
||||
target, err = os.Readlink(s.Name())
|
||||
if err != nil {
|
||||
target = ""
|
||||
// Ignore the not exist errors specifically, since there is nothing important about that.
|
||||
if !os.IsNotExist(err) {
|
||||
log.WithField("name", name).WithField("readlink_err", err.Error()).Warn("failed reading symlink for target path; skipping...")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// Get the tar FileInfoHeader to add the file to the archive.
|
||||
header, err := tar.FileInfoHeader(s, target)
|
||||
// Get the tar FileInfoHeader in order to add the file to the archive.
|
||||
header, err := tar.FileInfoHeader(s, filepath.ToSlash(target))
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "server/filesystem: archive: failed to get file info header")
|
||||
return errors.WrapIff(err, "failed to get tar#FileInfoHeader for '%s'", name)
|
||||
}
|
||||
|
||||
header.Name = p
|
||||
if err := a.pw.WriteHeader(header); err != nil {
|
||||
return errors.Wrap(err, "server/filesystem: archive: failed to write tar header")
|
||||
// Fix the header name if the file is not a symlink.
|
||||
if s.Mode()&fs.ModeSymlink == 0 {
|
||||
header.Name = relative
|
||||
}
|
||||
|
||||
// Write the tar FileInfoHeader to the archive.
|
||||
if err := a.w.WriteHeader(header); err != nil {
|
||||
return errors.WrapIff(err, "failed to write tar#FileInfoHeader for '%s'", name)
|
||||
}
|
||||
|
||||
// If the size of the file is less than 1 (most likely for symlinks), skip writing the file.
|
||||
@@ -265,18 +326,19 @@ func (a *Archive) addToArchive(p string) error {
|
||||
}()
|
||||
}
|
||||
|
||||
f, err := a.root.Open(p)
|
||||
// Open the file.
|
||||
f, err := a.Filesystem.unixFS.OpenFileat(dirfd, name, ufs.O_RDONLY, 0)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
return errors.Wrap(err, "server/filesystem: archive: failed to open file for copying")
|
||||
return errors.WrapIff(err, "failed to open '%s' for copying", header.Name)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
if _, err := io.CopyBuffer(a.pw, io.LimitReader(f, header.Size), buf); err != nil {
|
||||
return errors.Wrap(err, "server/filesystem: archive: failed to copy file to archive")
|
||||
// Copy the file's contents to the archive using our buffer.
|
||||
if _, err := io.CopyBuffer(a.w, io.LimitReader(f, header.Size), buf); err != nil {
|
||||
return errors.WrapIff(err, "failed to copy '%s' to archive", header.Name)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -15,39 +15,49 @@ import (
|
||||
|
||||
func TestArchive_Stream(t *testing.T) {
|
||||
g := Goblin(t)
|
||||
fs := NewFs()
|
||||
fs, rfs := NewFs()
|
||||
|
||||
g.Describe("Archive", func() {
|
||||
g.AfterEach(func() {
|
||||
fs.reset()
|
||||
// Reset the filesystem after each run.
|
||||
_ = fs.TruncateRootDirectory()
|
||||
})
|
||||
|
||||
g.It("creates archive with intended files", func() {
|
||||
g.Assert(fs.CreateDirectory("test", "/")).IsNil()
|
||||
g.Assert(fs.CreateDirectory("test2", "/")).IsNil()
|
||||
|
||||
err := fs.Writefile("test/file.txt", strings.NewReader("hello, world!\n"))
|
||||
r := strings.NewReader("hello, world!\n")
|
||||
err := fs.Write("test/file.txt", r, r.Size(), 0o644)
|
||||
g.Assert(err).IsNil()
|
||||
|
||||
err = fs.Writefile("test2/file.txt", strings.NewReader("hello, world!\n"))
|
||||
r = strings.NewReader("hello, world!\n")
|
||||
err = fs.Write("test2/file.txt", r, r.Size(), 0o644)
|
||||
g.Assert(err).IsNil()
|
||||
|
||||
err = fs.Writefile("test_file.txt", strings.NewReader("hello, world!\n"))
|
||||
r = strings.NewReader("hello, world!\n")
|
||||
err = fs.Write("test_file.txt", r, r.Size(), 0o644)
|
||||
g.Assert(err).IsNil()
|
||||
|
||||
err = fs.Writefile("test_file.txt.old", strings.NewReader("hello, world!\n"))
|
||||
r = strings.NewReader("hello, world!\n")
|
||||
err = fs.Write("test_file.txt.old", r, r.Size(), 0o644)
|
||||
g.Assert(err).IsNil()
|
||||
|
||||
archivePath := filepath.Join(fs.rootPath, "../archive.tar.gz")
|
||||
f, err := os.Create(archivePath)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
a := &Archive{
|
||||
Filesystem: fs,
|
||||
Files: []string{
|
||||
"test",
|
||||
"test_file.txt",
|
||||
},
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
a, err := NewArchive(fs.root, ".", WithMatching([]string{"test", "test_file.txt"}))
|
||||
// Create the archive.
|
||||
archivePath := filepath.Join(rfs.root, "archive.tar.gz")
|
||||
g.Assert(a.Create(context.Background(), archivePath)).IsNil()
|
||||
|
||||
g.Assert(a.Create(context.Background(), f)).IsNil()
|
||||
// Ensure the archive exists.
|
||||
_, err = os.Stat(archivePath)
|
||||
g.Assert(err).IsNil()
|
||||
|
||||
// Open the archive.
|
||||
genericFs, err := archives.FileSystem(context.Background(), archivePath, nil)
|
||||
@@ -74,55 +84,6 @@ func TestArchive_Stream(t *testing.T) {
|
||||
|
||||
g.Assert(files).Equal(expected)
|
||||
})
|
||||
|
||||
g.It("does not archive files outside of root", func() {
|
||||
if err := os.MkdirAll(filepath.Join(fs.rootPath, "../outer"), 0o755); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
fs.write("test.txt", []byte("test"))
|
||||
fs.write("../danger-1.txt", []byte("danger"))
|
||||
fs.write("../outer/danger-2.txt", []byte("danger"))
|
||||
|
||||
if err := os.Symlink("../danger-1.txt", filepath.Join(fs.rootPath, "symlink.txt")); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
if err := os.Symlink("../outer", filepath.Join(fs.rootPath, "danger-dir")); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
archivePath := filepath.Join(fs.rootPath, "../archive.tar.gz")
|
||||
f, err := os.Create(archivePath)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
a, err := NewArchive(fs.root, ".")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
err = a.Create(context.Background(), f)
|
||||
g.Assert(err).IsNil()
|
||||
|
||||
// Open the archive.
|
||||
genericFs, err := archives.FileSystem(context.Background(), archivePath, nil)
|
||||
g.Assert(err).IsNil()
|
||||
|
||||
// Assert that we are opening an archive.
|
||||
afs, ok := genericFs.(iofs.ReadDirFS)
|
||||
g.Assert(ok).IsTrue()
|
||||
|
||||
// Get the names of the files recursively from the archive.
|
||||
files, err := getFiles(afs, ".")
|
||||
g.Assert(err).IsNil()
|
||||
// We expect the actual symlinks themselves, but not the contents of the directory
|
||||
// or the file itself. We're storing the symlinked file in the archive so that
|
||||
// expanding it back is the same, but you won't have the inner contents.
|
||||
g.Assert(files).Equal([]string{"danger-dir", "symlink.txt", "test.txt"})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
100
server/filesystem/archiverext/compressed.go
Normal file
100
server/filesystem/archiverext/compressed.go
Normal file
@@ -0,0 +1,100 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// SPDX-FileCopyrightText: Copyright (c) 2016 Matthew Holt
|
||||
|
||||
// Code in this file was derived from
|
||||
// https://github.com/mholt/archiver/blob/v4.0.0-alpha.8/fs.go
|
||||
//
|
||||
// These modifications were necessary to allow us to use an already open file
|
||||
// with archiver.FileFS.
|
||||
|
||||
package archiverext
|
||||
|
||||
import (
|
||||
"io"
|
||||
"io/fs"
|
||||
|
||||
"github.com/mholt/archives"
|
||||
)
|
||||
|
||||
// FileFS allows accessing a file on disk using a consistent file system interface.
|
||||
// The value should be the path to a regular file, not a directory. This file will
|
||||
// be the only entry in the file system and will be at its root. It can be accessed
|
||||
// within the file system by the name of "." or the filename.
|
||||
//
|
||||
// If the file is compressed, set the Compression field so that reads from the
|
||||
// file will be transparently decompressed.
|
||||
type FileFS struct {
|
||||
// File is the compressed file backing the FileFS.
|
||||
File fs.File
|
||||
|
||||
// If file is compressed, setting this field will
|
||||
// transparently decompress reads.
|
||||
Compression archives.Decompressor
|
||||
}
|
||||
|
||||
// Open opens the named file, which must be the file used to create the file system.
|
||||
func (f FileFS) Open(name string) (fs.File, error) {
|
||||
if err := f.checkName(name, "open"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if f.Compression == nil {
|
||||
return f.File, nil
|
||||
}
|
||||
r, err := f.Compression.OpenReader(f.File)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return compressedFile{f.File, r}, nil
|
||||
}
|
||||
|
||||
// ReadDir returns a directory listing with the file as the singular entry.
|
||||
func (f FileFS) ReadDir(name string) ([]fs.DirEntry, error) {
|
||||
if err := f.checkName(name, "stat"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
info, err := f.Stat(name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return []fs.DirEntry{fs.FileInfoToDirEntry(info)}, nil
|
||||
}
|
||||
|
||||
// Stat stats the named file, which must be the file used to create the file system.
|
||||
func (f FileFS) Stat(name string) (fs.FileInfo, error) {
|
||||
if err := f.checkName(name, "stat"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return f.File.Stat()
|
||||
}
|
||||
|
||||
func (f FileFS) checkName(name, op string) error {
|
||||
if !fs.ValidPath(name) {
|
||||
return &fs.PathError{Op: "open", Path: name, Err: fs.ErrInvalid}
|
||||
}
|
||||
// TODO: we may need better name validation.
|
||||
if name != "." {
|
||||
return &fs.PathError{Op: op, Path: name, Err: fs.ErrNotExist}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// compressedFile is an fs.File that specially reads
|
||||
// from a decompression reader, and which closes both
|
||||
// that reader and the underlying file.
|
||||
type compressedFile struct {
|
||||
fs.File
|
||||
decomp io.ReadCloser
|
||||
}
|
||||
|
||||
func (cf compressedFile) Read(p []byte) (int, error) {
|
||||
return cf.decomp.Read(p)
|
||||
}
|
||||
|
||||
func (cf compressedFile) Close() error {
|
||||
err := cf.File.Close()
|
||||
err2 := cf.decomp.Close()
|
||||
if err2 != nil && err == nil {
|
||||
err = err2
|
||||
}
|
||||
return err
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
package filesystem
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
fs2 "io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"emperror.dev/errors"
|
||||
"github.com/pterodactyl/wings/config"
|
||||
)
|
||||
|
||||
func (fs *Filesystem) Chmod(path string, mode os.FileMode) error {
|
||||
path = strings.TrimLeft(filepath.Clean(path), "/")
|
||||
if path == "" {
|
||||
path = "."
|
||||
}
|
||||
if err := fs.root.Chmod(path, mode); err != nil {
|
||||
return errors.Wrap(err, "server/filesystem: chmod: failed to chmod path")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Chown recursively iterates over a file or directory and sets the permissions on all the
|
||||
// underlying files. Iterate over all the files and directories. If it is a file go ahead
|
||||
// and perform the chown operation. Otherwise dig deeper into the directory until we've run
|
||||
// out of directories to dig into.
|
||||
func (fs *Filesystem) Chown(p string) error {
|
||||
p = normalize(p)
|
||||
uid := config.Get().System.User.Uid
|
||||
gid := config.Get().System.User.Gid
|
||||
if err := fs.root.Chown(p, uid, gid); err != nil {
|
||||
return errors.WrapIf(err, "server/filesystem: chown: failed to chown path")
|
||||
}
|
||||
|
||||
// If this is not a directory, we can now return from the function; there is nothing
|
||||
// left that we need to do.
|
||||
if st, err := fs.root.Stat(p); err != nil || !st.IsDir() {
|
||||
if err == nil || errors.Is(err, os.ErrNotExist) {
|
||||
return nil
|
||||
}
|
||||
|
||||
return errors.WrapIf(err, "server/filesystem: chown: failed to stat path")
|
||||
}
|
||||
|
||||
rt := fs.rootPath
|
||||
if p == "." {
|
||||
r, err := fs.root.OpenRoot(p)
|
||||
if err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
defer r.Close()
|
||||
rt = r.Name()
|
||||
}
|
||||
|
||||
// If this was a directory, begin walking over its contents recursively and ensure that all
|
||||
// the subfiles and directories get their permissions updated as well.
|
||||
return filepath.WalkDir(rt, func(path string, _ fs2.DirEntry, err error) error {
|
||||
path = normalize(path)
|
||||
if path == "." {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := fs.root.Chown(path, uid, gid); err != nil {
|
||||
return errors.Wrap(err, fmt.Sprintf("server/filesystem: chown: failed to chown file"))
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (fs *Filesystem) Chtimes(path string, atime, mtime time.Time) error {
|
||||
path = strings.TrimLeft(filepath.Clean(path), "/")
|
||||
if err := fs.root.Chtimes(path, atime, mtime); err != nil {
|
||||
return errors.Wrap(err, "server/filesystem: chtimes: failed to chtimes path")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -4,182 +4,162 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
iofs "io/fs"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"emperror.dev/errors"
|
||||
"github.com/klauspost/compress/zip"
|
||||
"github.com/mholt/archives"
|
||||
"github.com/pterodactyl/wings/internal"
|
||||
)
|
||||
|
||||
type extractOptions struct {
|
||||
dir string
|
||||
file string
|
||||
format archives.Format
|
||||
r io.Reader
|
||||
}
|
||||
"github.com/pterodactyl/wings/internal/ufs"
|
||||
"github.com/pterodactyl/wings/server/filesystem/archiverext"
|
||||
)
|
||||
|
||||
// CompressFiles compresses all the files matching the given paths in the
|
||||
// specified directory. This function also supports passing nested paths to only
|
||||
// compress certain files and folders when working in a larger directory. This
|
||||
// effectively creates a local backup, but rather than ignoring specific files
|
||||
// and folders, it takes an allowlist of files and folders.
|
||||
// and folders, it takes an allow-list of files and folders.
|
||||
//
|
||||
// All paths are relative to the dir that is passed in as the first argument,
|
||||
// and the compressed file will be placed at that location named
|
||||
// `archive-{date}.tar.gz`.
|
||||
func (fs *Filesystem) CompressFiles(ctx context.Context, dir string, paths []string) (os.FileInfo, error) {
|
||||
a, err := NewArchive(fs.root, dir, WithMatching(paths))
|
||||
func (fs *Filesystem) CompressFiles(dir string, paths []string) (ufs.FileInfo, error) {
|
||||
a := &Archive{Filesystem: fs, BaseDirectory: dir, Files: paths}
|
||||
d := path.Join(
|
||||
dir,
|
||||
fmt.Sprintf("archive-%s.tar.gz", strings.ReplaceAll(time.Now().Format(time.RFC3339), ":", "")),
|
||||
)
|
||||
f, err := fs.unixFS.OpenFile(d, ufs.O_WRONLY|ufs.O_CREATE, 0o644)
|
||||
if err != nil {
|
||||
return nil, errors.WrapIf(err, "server/filesystem: compress: failed to create archive instance")
|
||||
}
|
||||
|
||||
n := fmt.Sprintf("archive-%s.tar.gz", strings.ReplaceAll(time.Now().Format(time.RFC3339), ":", ""))
|
||||
f, err := fs.root.OpenFile(normalize(filepath.Join(dir, n)), os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o644)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "server/filesystem: compress: failed to open file for writing")
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
cw := internal.NewCountedWriter(f)
|
||||
// todo: eventing on the counted writer so that we can slowly increase the disk
|
||||
// used value on the server as the file gets written?
|
||||
if err := a.Stream(ctx, cw); err != nil {
|
||||
return nil, errors.Wrap(err, "server/filesystem: compress: failed to write to disk")
|
||||
}
|
||||
if err := fs.HasSpaceFor(cw.BytesWritten()); err != nil {
|
||||
_ = fs.root.Remove(normalize(filepath.Join(dir, n)))
|
||||
return nil, err
|
||||
}
|
||||
fs.addDisk(cw.BytesWritten())
|
||||
|
||||
defer f.Close()
|
||||
cw := ufs.NewCountedWriter(f)
|
||||
if err := a.Stream(context.Background(), cw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !fs.unixFS.CanFit(cw.BytesWritten()) {
|
||||
_ = fs.unixFS.Remove(d)
|
||||
return nil, newFilesystemError(ErrCodeDiskSpace, nil)
|
||||
}
|
||||
fs.unixFS.Add(cw.BytesWritten())
|
||||
return f.Stat()
|
||||
}
|
||||
|
||||
func (fs *Filesystem) archiverFileSystem(ctx context.Context, p string) (iofs.FS, error) {
|
||||
f, err := fs.unixFS.Open(p)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Do not use defer to close `f`, it will likely be used later.
|
||||
|
||||
format, _, err := archives.Identify(ctx, filepath.Base(p), f)
|
||||
if err != nil && !errors.Is(err, archives.NoMatch) {
|
||||
_ = f.Close()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Reset the file reader.
|
||||
if _, err := f.Seek(0, io.SeekStart); err != nil {
|
||||
_ = f.Close()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
info, err := f.Stat()
|
||||
if err != nil {
|
||||
_ = f.Close()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if format != nil {
|
||||
switch ff := format.(type) {
|
||||
case archives.Zip:
|
||||
// zip.Reader is more performant than ArchiveFS, because zip.Reader caches content information
|
||||
// and zip.Reader can open several content files concurrently because of io.ReaderAt requirement
|
||||
// while ArchiveFS can't.
|
||||
// zip.Reader doesn't suffer from issue #330 and #310 according to local test (but they should be fixed anyway)
|
||||
return zip.NewReader(f, info.Size())
|
||||
case archives.Extraction:
|
||||
return &archives.ArchiveFS{Stream: io.NewSectionReader(f, 0, info.Size()), Format: ff, Context: ctx}, nil
|
||||
case archives.Compression:
|
||||
return archiverext.FileFS{File: f, Compression: ff}, nil
|
||||
}
|
||||
}
|
||||
_ = f.Close()
|
||||
return nil, archives.NoMatch
|
||||
}
|
||||
|
||||
// SpaceAvailableForDecompression looks through a given archive and determines
|
||||
// if decompressing it would put the server over its allocated disk space limit.
|
||||
func (fs *Filesystem) SpaceAvailableForDecompression(ctx context.Context, dir string, file string) error {
|
||||
// Don't waste time trying to determine this if we know the server will have the space for
|
||||
// it since there is no limit.
|
||||
if fs.MaxDisk() <= 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
fsys, err := fs.archiverFileSystem(ctx, filepath.Join(dir, file))
|
||||
if err != nil {
|
||||
if errors.Is(err, archives.NoMatch) {
|
||||
return newFilesystemError(ErrCodeUnknownArchive, err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
var size atomic.Int64
|
||||
return iofs.WalkDir(fsys, ".", func(path string, d iofs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
// Stop walking if the context is canceled.
|
||||
return ctx.Err()
|
||||
default:
|
||||
info, err := d.Info()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !fs.unixFS.CanFit(size.Add(info.Size())) {
|
||||
return newFilesystemError(ErrCodeDiskSpace, nil)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// DecompressFile will decompress a file in a given directory by using the
|
||||
// archiver tool to infer the file type and go from there. This will walk over
|
||||
// all the files within the given archive and ensure that there is not a
|
||||
// zip-slip attack being attempted by validating that the final path is within
|
||||
// the server data directory.
|
||||
func (fs *Filesystem) DecompressFile(ctx context.Context, dir string, file string) error {
|
||||
f, err := fs.root.Open(normalize(filepath.Join(dir, file)))
|
||||
f, err := fs.unixFS.Open(filepath.Join(dir, file))
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "server/filesystem: decompress: failed to open file")
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
// Identify the type of archive we are dealing with.
|
||||
format, input, err := archives.Identify(ctx, filepath.Base(file), f)
|
||||
if err != nil {
|
||||
if errors.Is(err, archives.NoMatch) {
|
||||
return newFilesystemError(ErrCodeUnknownArchive, err)
|
||||
}
|
||||
return errors.Wrap(err, "server/filesystem: decompress: failed to identify archive format")
|
||||
return err
|
||||
}
|
||||
|
||||
return fs.extractStream(ctx, extractOptions{dir: dir, file: file, format: format, r: input})
|
||||
}
|
||||
|
||||
func (fs *Filesystem) extractStream(ctx context.Context, opts extractOptions) error {
|
||||
// See if it's a compressed archive, such as TAR or a ZIP
|
||||
ex, ok := opts.format.(archives.Extractor)
|
||||
if !ok {
|
||||
// If not, check if it's a single-file compression, such as
|
||||
// .log.gz, .sql.gz, and so on
|
||||
de, ok := opts.format.(archives.Decompressor)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
p := filepath.Join(opts.dir, strings.TrimSuffix(opts.file, opts.format.Extension()))
|
||||
if err := fs.IsIgnored(p); err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
reader, err := de.OpenReader(opts.r)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "server/filesystem: decompress: failed to open reader")
|
||||
}
|
||||
defer reader.Close()
|
||||
|
||||
// Open the file for creation/writing
|
||||
f, err := fs.root.OpenFile(normalize(p), os.O_WRONLY|os.O_CREATE, 0o644)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "server/filesystem: decompress: failed to open file")
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
// Read in 4 KB chunks
|
||||
buf := make([]byte, 4096)
|
||||
for {
|
||||
n, err := reader.Read(buf)
|
||||
if n > 0 {
|
||||
if err := fs.HasSpaceFor(int64(n)); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := f.Write(buf[:n]); err != nil {
|
||||
return errors.Wrap(err, "server/filesystem: decompress: failed to write")
|
||||
}
|
||||
fs.addDisk(int64(n))
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
return errors.Wrap(err, "server/filesystem: decompress: failed to read")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Decompress and extract archive
|
||||
return ex.Extract(ctx, opts.r, func(ctx context.Context, f archives.FileInfo) error {
|
||||
if f.IsDir() {
|
||||
return nil
|
||||
}
|
||||
p := filepath.Join(opts.dir, f.NameInArchive)
|
||||
if err := fs.IsIgnored(p); err != nil {
|
||||
return nil
|
||||
}
|
||||
r, err := f.Open()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer r.Close()
|
||||
if f.Mode()&os.ModeSymlink != 0 {
|
||||
// Try to create the symlink if it is in the archive, but don't hold up the process
|
||||
// if the file cannot be created. In that case just skip over it entirely.
|
||||
if f.LinkTarget != "" {
|
||||
p2 := strings.TrimLeft(filepath.Clean(p), string(filepath.Separator))
|
||||
if p2 == "" {
|
||||
p2 = "."
|
||||
}
|
||||
// We don't use [fs.Symlink] here because that normalizes the source directory for
|
||||
// consistency with the codebase. In this case when decompressing we want to just
|
||||
// accept the source without any normalization.
|
||||
if err := fs.root.Symlink(f.LinkTarget, p2); err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) || IsPathError(err) || IsLinkError(err) {
|
||||
return nil
|
||||
}
|
||||
return errors.Wrap(err, "server/filesystem: decompress: failed to create symlink")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := fs.Write(p, r, f.Size(), f.Mode().Perm()); err != nil {
|
||||
return errors.Wrap(err, "server/filesystem: decompress: failed to write file")
|
||||
}
|
||||
|
||||
// Update the file modification time to the one set in the archive.
|
||||
if err := fs.Chtimes(p, f.ModTime(), f.ModTime()); err != nil {
|
||||
return errors.Wrap(err, "server/filesystem: decompress: failed to update file modification time")
|
||||
}
|
||||
|
||||
return nil
|
||||
return fs.extractStream(ctx, extractStreamOptions{
|
||||
FileName: file,
|
||||
Directory: dir,
|
||||
Format: format,
|
||||
Reader: input,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -192,9 +172,112 @@ func (fs *Filesystem) ExtractStreamUnsafe(ctx context.Context, dir string, r io.
|
||||
}
|
||||
return err
|
||||
}
|
||||
return fs.extractStream(ctx, extractOptions{
|
||||
dir: dir,
|
||||
format: format,
|
||||
r: input,
|
||||
return fs.extractStream(ctx, extractStreamOptions{
|
||||
Directory: dir,
|
||||
Format: format,
|
||||
Reader: input,
|
||||
})
|
||||
}
|
||||
|
||||
type extractStreamOptions struct {
|
||||
// The directory to extract the archive to.
|
||||
Directory string
|
||||
// File name of the archive.
|
||||
FileName string
|
||||
// Format of the archive.
|
||||
Format archives.Format
|
||||
// Reader for the archive.
|
||||
Reader io.Reader
|
||||
}
|
||||
|
||||
func (fs *Filesystem) extractStream(ctx context.Context, opts extractStreamOptions) error {
|
||||
// See if it's a compressed archive, such as TAR or a ZIP
|
||||
ex, ok := opts.Format.(archives.Extractor)
|
||||
if !ok {
|
||||
// If not, check if it's a single-file compression, such as
|
||||
// .log.gz, .sql.gz, and so on
|
||||
de, ok := opts.Format.(archives.Decompressor)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Strip the compression suffix
|
||||
p := filepath.Join(opts.Directory, strings.TrimSuffix(opts.FileName, opts.Format.Extension()))
|
||||
|
||||
// Make sure it's not ignored
|
||||
if err := fs.IsIgnored(p); err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
reader, err := de.OpenReader(opts.Reader)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer reader.Close()
|
||||
|
||||
// Open the file for creation/writing
|
||||
f, err := fs.unixFS.OpenFile(p, ufs.O_WRONLY|ufs.O_CREATE, 0o644)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
// Read in 4 KB chunks
|
||||
buf := make([]byte, 4096)
|
||||
for {
|
||||
n, err := reader.Read(buf)
|
||||
if n > 0 {
|
||||
|
||||
// Check quota before writing the chunk
|
||||
if quotaErr := fs.HasSpaceFor(int64(n)); quotaErr != nil {
|
||||
return quotaErr
|
||||
}
|
||||
|
||||
// Write the chunk
|
||||
if _, writeErr := f.Write(buf[:n]); writeErr != nil {
|
||||
return writeErr
|
||||
}
|
||||
|
||||
// Add to quota
|
||||
fs.addDisk(int64(n))
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
// EOF are expected
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
|
||||
// Return any other
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Decompress and extract archive
|
||||
return ex.Extract(ctx, opts.Reader, func(ctx context.Context, f archives.FileInfo) error {
|
||||
if f.IsDir() {
|
||||
return nil
|
||||
}
|
||||
p := filepath.Join(opts.Directory, f.NameInArchive)
|
||||
// If it is ignored, just don't do anything with the file and skip over it.
|
||||
if err := fs.IsIgnored(p); err != nil {
|
||||
return nil
|
||||
}
|
||||
r, err := f.Open()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer r.Close()
|
||||
if err := fs.Write(p, r, f.Size(), f.Mode()); err != nil {
|
||||
return wrapError(err, opts.FileName)
|
||||
}
|
||||
// Update the file modification time to the one set in the archive.
|
||||
if err := fs.Chtimes(p, f.ModTime(), f.ModTime()); err != nil {
|
||||
return wrapError(err, opts.FileName)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
54
server/filesystem/compress_test.go
Normal file
54
server/filesystem/compress_test.go
Normal file
@@ -0,0 +1,54 @@
|
||||
package filesystem
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
. "github.com/franela/goblin"
|
||||
)
|
||||
|
||||
// Given an archive named test.{ext}, with the following file structure:
|
||||
//
|
||||
// test/
|
||||
// |──inside/
|
||||
// |────finside.txt
|
||||
// |──outside.txt
|
||||
//
|
||||
// this test will ensure that it's being decompressed as expected
|
||||
func TestFilesystem_DecompressFile(t *testing.T) {
|
||||
g := Goblin(t)
|
||||
fs, rfs := NewFs()
|
||||
|
||||
g.Describe("Decompress", func() {
|
||||
for _, ext := range []string{"zip", "rar", "tar", "tar.gz"} {
|
||||
g.It("can decompress a "+ext, func() {
|
||||
// copy the file to the new FS
|
||||
c, err := os.ReadFile("./testdata/test." + ext)
|
||||
g.Assert(err).IsNil()
|
||||
err = rfs.CreateServerFile("./test."+ext, c)
|
||||
g.Assert(err).IsNil()
|
||||
|
||||
// decompress
|
||||
err = fs.DecompressFile(context.Background(), "/", "test."+ext)
|
||||
g.Assert(err).IsNil()
|
||||
|
||||
// make sure everything is where it is supposed to be
|
||||
_, err = rfs.StatServerFile("test/outside.txt")
|
||||
g.Assert(err).IsNil()
|
||||
|
||||
st, err := rfs.StatServerFile("test/inside")
|
||||
g.Assert(err).IsNil()
|
||||
g.Assert(st.IsDir()).IsTrue()
|
||||
|
||||
_, err = rfs.StatServerFile("test/inside/finside.txt")
|
||||
g.Assert(err).IsNil()
|
||||
g.Assert(st.IsDir()).IsTrue()
|
||||
})
|
||||
}
|
||||
|
||||
g.AfterEach(func() {
|
||||
_ = fs.TruncateRootDirectory()
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -1,17 +1,16 @@
|
||||
package filesystem
|
||||
|
||||
import (
|
||||
fs2 "io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"golang.org/x/sys/unix"
|
||||
"slices"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"emperror.dev/errors"
|
||||
"github.com/apex/log"
|
||||
|
||||
"github.com/pterodactyl/wings/internal/ufs"
|
||||
)
|
||||
|
||||
type SpaceCheckingOpts struct {
|
||||
@@ -24,7 +23,7 @@ type usageLookupTime struct {
|
||||
value time.Time
|
||||
}
|
||||
|
||||
// Update the last time that a disk space lookup was performed.
|
||||
// Set sets the last time that a disk space lookup was performed.
|
||||
func (ult *usageLookupTime) Set(t time.Time) {
|
||||
ult.Lock()
|
||||
ult.value = t
|
||||
@@ -42,12 +41,12 @@ func (ult *usageLookupTime) Get() time.Time {
|
||||
// MaxDisk returns the maximum amount of disk space that this Filesystem
|
||||
// instance is allowed to use.
|
||||
func (fs *Filesystem) MaxDisk() int64 {
|
||||
return atomic.LoadInt64(&fs.diskLimit)
|
||||
return fs.unixFS.Limit()
|
||||
}
|
||||
|
||||
// SetDiskLimit sets the disk space limit for this Filesystem instance.
|
||||
func (fs *Filesystem) SetDiskLimit(i int64) {
|
||||
atomic.SwapInt64(&fs.diskLimit, i)
|
||||
fs.unixFS.SetLimit(i)
|
||||
}
|
||||
|
||||
// The same concept as HasSpaceAvailable however this will return an error if there is
|
||||
@@ -59,13 +58,14 @@ func (fs *Filesystem) HasSpaceErr(allowStaleValue bool) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// HasSpaceAvailable checks if the directory a file is trying to be added to has enough
|
||||
// space available for the file to be written to. Because determining the amount of space
|
||||
// being used by a server is a taxing operation, we will load it all up into a cache and
|
||||
// pull from that as long as the key is not expired.
|
||||
// Determines if the directory a file is trying to be added to has enough space available
|
||||
// for the file to be written to.
|
||||
//
|
||||
// This operation will potentially be blocked unless allowStaleValue is set to true. See
|
||||
// the documentation on DiskUsage for how this affects the call.
|
||||
// Because determining the amount of space being used by a server is a taxing operation we
|
||||
// will load it all up into a cache and pull from that as long as the key is not expired.
|
||||
//
|
||||
// This operation will potentially block unless allowStaleValue is set to true. See the
|
||||
// documentation on DiskUsage for how this affects the call.
|
||||
func (fs *Filesystem) HasSpaceAvailable(allowStaleValue bool) bool {
|
||||
size, err := fs.DiskUsage(allowStaleValue)
|
||||
if err != nil {
|
||||
@@ -88,7 +88,7 @@ func (fs *Filesystem) HasSpaceAvailable(allowStaleValue bool) bool {
|
||||
// function for critical logical checks. It should only be used in areas where the actual disk usage
|
||||
// does not need to be perfect, e.g. API responses for server resource usage.
|
||||
func (fs *Filesystem) CachedUsage() int64 {
|
||||
return atomic.LoadInt64(&fs.diskUsed)
|
||||
return fs.unixFS.Usage()
|
||||
}
|
||||
|
||||
// Internal helper function to allow other parts of the codebase to check the total used disk space
|
||||
@@ -118,14 +118,14 @@ func (fs *Filesystem) DiskUsage(allowStaleValue bool) (int64, error) {
|
||||
// currently performing a lookup, just do the disk usage calculation in the background.
|
||||
go func(fs *Filesystem) {
|
||||
if _, err := fs.updateCachedDiskUsage(); err != nil {
|
||||
log.WithField("root", fs.rootPath).WithField("error", err).Warn("failed to update fs disk usage from within routine")
|
||||
log.WithField("root", fs.Path()).WithField("error", err).Warn("failed to update fs disk usage from within routine")
|
||||
}
|
||||
}(fs)
|
||||
}
|
||||
}
|
||||
|
||||
// Return the currently cached value back to the calling function.
|
||||
return atomic.LoadInt64(&fs.diskUsed), nil
|
||||
return fs.unixFS.Usage(), nil
|
||||
}
|
||||
|
||||
// Updates the currently used disk space for a server.
|
||||
@@ -153,77 +153,56 @@ func (fs *Filesystem) updateCachedDiskUsage() (int64, error) {
|
||||
// error encountered.
|
||||
fs.lastLookupTime.Set(time.Now())
|
||||
|
||||
atomic.StoreInt64(&fs.diskUsed, size)
|
||||
fs.unixFS.SetUsage(size)
|
||||
|
||||
return size, err
|
||||
}
|
||||
|
||||
// DirectorySize determines the directory size of a given location. Returns the size
|
||||
// in bytes. This can be a fairly taxing operation on locations with tons of files,
|
||||
// so it is recommended that you cache the output.
|
||||
func (fs *Filesystem) DirectorySize(dir string) (int64, error) {
|
||||
dir = normalize(dir)
|
||||
if dir != "." {
|
||||
if _, err := fs.root.Lstat(dir); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
// DirectorySize calculates the size of a directory and its descendants.
|
||||
func (fs *Filesystem) DirectorySize(root string) (int64, error) {
|
||||
dirfd, name, closeFd, err := fs.unixFS.SafePath(root)
|
||||
defer closeFd()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
rt := fs.root
|
||||
if dir != "." {
|
||||
r, err := fs.root.OpenRoot(dir)
|
||||
var hardLinks []uint64
|
||||
|
||||
var size atomic.Int64
|
||||
err = fs.unixFS.WalkDirat(dirfd, name, func(dirfd int, name, _ string, d ufs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return 0, errors.Wrap(err, "server/filesystem: directorysize: failed to open root directory")
|
||||
return errors.Wrap(err, "walkdirat err")
|
||||
}
|
||||
defer r.Close()
|
||||
rt = r
|
||||
}
|
||||
|
||||
var size int64
|
||||
var links []uint64
|
||||
|
||||
err := filepath.WalkDir(rt.Name(), func(path string, d fs2.DirEntry, err error) error {
|
||||
// Only calculate the size of regular files.
|
||||
if !d.Type().IsRegular() {
|
||||
return nil
|
||||
}
|
||||
|
||||
st, err := d.Info()
|
||||
info, err := fs.unixFS.Lstatat(dirfd, name)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
return errors.Wrap(err, "lstatat err")
|
||||
}
|
||||
|
||||
s := st.Sys().(*syscall.Stat_t)
|
||||
if s.Nlink > 1 {
|
||||
// Hard links have the same inode number, don't add them more than once.
|
||||
if slices.Contains(links, s.Ino) {
|
||||
var sysFileInfo = info.Sys().(*unix.Stat_t)
|
||||
if sysFileInfo.Nlink > 1 {
|
||||
// Hard links have the same inode number
|
||||
if slices.Contains(hardLinks, sysFileInfo.Ino) {
|
||||
// Don't add hard links size twice
|
||||
return nil
|
||||
} else {
|
||||
hardLinks = append(hardLinks, sysFileInfo.Ino)
|
||||
}
|
||||
links = append(links, s.Ino)
|
||||
}
|
||||
|
||||
size += st.Size()
|
||||
|
||||
size.Add(info.Size())
|
||||
return nil
|
||||
})
|
||||
|
||||
return size, errors.WrapIf(err, "server/filesystem: directorysize: failed to walk directory")
|
||||
return size.Load(), errors.WrapIf(err, "server/filesystem: directorysize: failed to walk directory")
|
||||
}
|
||||
|
||||
// Helper function to determine if a server has space available for a file of a given size.
|
||||
// If space is available, no error will be returned, otherwise an ErrNotEnoughSpace error
|
||||
// will be raised.
|
||||
func (fs *Filesystem) HasSpaceFor(size int64) error {
|
||||
if fs.MaxDisk() == 0 {
|
||||
return nil
|
||||
}
|
||||
s, err := fs.DiskUsage(true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if (s + size) > fs.MaxDisk() {
|
||||
if !fs.unixFS.CanFit(size) {
|
||||
return newFilesystemError(ErrCodeDiskSpace, nil)
|
||||
}
|
||||
return nil
|
||||
@@ -231,24 +210,5 @@ func (fs *Filesystem) HasSpaceFor(size int64) error {
|
||||
|
||||
// Updates the disk usage for the Filesystem instance.
|
||||
func (fs *Filesystem) addDisk(i int64) int64 {
|
||||
size := atomic.LoadInt64(&fs.diskUsed)
|
||||
|
||||
// Sorry go gods. This is ugly but the best approach I can come up with for right
|
||||
// now without completely re-evaluating the logic we use for determining disk space.
|
||||
//
|
||||
// Normally I would just be using the atomic load right below, but I'm not sure about
|
||||
// the scenarios where it is 0 because nothing has run that would trigger a disk size
|
||||
// calculation?
|
||||
//
|
||||
// Perhaps that isn't even a concern for the sake of this?
|
||||
if !fs.isTest {
|
||||
size, _ = fs.DiskUsage(true)
|
||||
}
|
||||
|
||||
// If we're dropping below 0 somehow just cap it to 0.
|
||||
if (size + i) < 0 {
|
||||
return atomic.SwapInt64(&fs.diskUsed, 0)
|
||||
}
|
||||
|
||||
return atomic.AddInt64(&fs.diskUsed, i)
|
||||
return fs.unixFS.Add(i)
|
||||
}
|
||||
|
||||
54
server/filesystem/errors.go
Executable file → Normal file
54
server/filesystem/errors.go
Executable file → Normal file
@@ -2,10 +2,12 @@ package filesystem
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"emperror.dev/errors"
|
||||
"github.com/apex/log"
|
||||
|
||||
"github.com/pterodactyl/wings/internal/ufs"
|
||||
)
|
||||
|
||||
type ErrorCode string
|
||||
@@ -14,6 +16,7 @@ const (
|
||||
ErrCodeIsDirectory ErrorCode = "E_ISDIR"
|
||||
ErrCodeDiskSpace ErrorCode = "E_NODISK"
|
||||
ErrCodeUnknownArchive ErrorCode = "E_UNKNFMT"
|
||||
ErrCodePathResolution ErrorCode = "E_BADPATH"
|
||||
ErrCodeDenylistFile ErrorCode = "E_DENYLIST"
|
||||
ErrCodeUnknownError ErrorCode = "E_UNKNOWN"
|
||||
ErrNotExist ErrorCode = "E_NOTEXIST"
|
||||
@@ -61,6 +64,12 @@ func (e *Error) Error() string {
|
||||
r = "<empty>"
|
||||
}
|
||||
return fmt.Sprintf("filesystem: file access prohibited: [%s] is on the denylist", r)
|
||||
case ErrCodePathResolution:
|
||||
r := e.resolved
|
||||
if r == "" {
|
||||
r = "<empty>"
|
||||
}
|
||||
return fmt.Sprintf("filesystem: server path [%s] resolves to a location outside the server root: %s", e.path, r)
|
||||
case ErrNotExist:
|
||||
return "filesystem: does not exist"
|
||||
case ErrCodeUnknownError:
|
||||
@@ -78,7 +87,31 @@ func (e *Error) Unwrap() error {
|
||||
|
||||
// Generates an error logger instance with some basic information.
|
||||
func (fs *Filesystem) error(err error) *log.Entry {
|
||||
return log.WithField("subsystem", "filesystem").WithField("root", fs.root).WithField("error", err)
|
||||
return log.WithField("subsystem", "filesystem").WithField("root", fs.Path()).WithField("error", err)
|
||||
}
|
||||
|
||||
// Handle errors encountered when walking through directories.
|
||||
//
|
||||
// If there is a path resolution error just skip the item entirely. Only return this for a
|
||||
// directory, otherwise return nil. Returning this error for a file will stop the walking
|
||||
// for the remainder of the directory. This is assuming an FileInfo struct was even returned.
|
||||
func (fs *Filesystem) handleWalkerError(err error, f ufs.FileInfo) error {
|
||||
if !IsErrorCode(err, ErrCodePathResolution) {
|
||||
return err
|
||||
}
|
||||
if f != nil && f.IsDir() {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsFilesystemError checks if the given error is one of the Filesystem errors.
|
||||
func IsFilesystemError(err error) bool {
|
||||
var fserr *Error
|
||||
if err != nil && errors.As(err, &fserr) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// IsErrorCode checks if "err" is a filesystem Error type. If so, it will then
|
||||
@@ -92,12 +125,17 @@ func IsErrorCode(err error, code ErrorCode) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func IsPathError(err error) bool {
|
||||
var pe *os.PathError
|
||||
return errors.As(err, &pe)
|
||||
// NewBadPathResolution returns a new BadPathResolution error.
|
||||
func NewBadPathResolution(path string, resolved string) error {
|
||||
return errors.WithStackDepth(&Error{code: ErrCodePathResolution, path: path, resolved: resolved}, 1)
|
||||
}
|
||||
|
||||
func IsLinkError(err error) bool {
|
||||
var le *os.LinkError
|
||||
return errors.As(err, &le)
|
||||
// wrapError wraps the provided error as a Filesystem error and attaches the
|
||||
// provided resolved source to it. If the error is already a Filesystem error
|
||||
// no action is taken.
|
||||
func wrapError(err error, resolved string) error {
|
||||
if err == nil || IsFilesystemError(err) {
|
||||
return err
|
||||
}
|
||||
return errors.WithStackDepth(&Error{code: ErrCodeUnknownError, err: err, resolved: resolved}, 1)
|
||||
}
|
||||
|
||||
15
server/filesystem/errors_test.go
Executable file → Normal file
15
server/filesystem/errors_test.go
Executable file → Normal file
@@ -39,4 +39,19 @@ func TestFilesystem_PathResolutionError(t *testing.T) {
|
||||
g.Assert(fserr.Unwrap()).Equal(underlying)
|
||||
})
|
||||
})
|
||||
|
||||
g.Describe("NewBadPathResolutionError", func() {
|
||||
g.It("is can detect itself as an error correctly", func() {
|
||||
err := NewBadPathResolution("foo", "bar")
|
||||
g.Assert(IsErrorCode(err, ErrCodePathResolution)).IsTrue()
|
||||
g.Assert(err.Error()).Equal("filesystem: server path [foo] resolves to a location outside the server root: bar")
|
||||
g.Assert(IsErrorCode(&Error{code: ErrCodeIsDirectory}, ErrCodePathResolution)).IsFalse()
|
||||
})
|
||||
|
||||
g.It("returns <empty> if no destination path is provided", func() {
|
||||
err := NewBadPathResolution("foo", "")
|
||||
g.Assert(err).IsNotNil()
|
||||
g.Assert(err.Error()).Equal("filesystem: server path [foo] resolves to a location outside the server root: <empty>")
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
764
server/filesystem/filesystem.go
Executable file → Normal file
764
server/filesystem/filesystem.go
Executable file → Normal file
@@ -1,16 +1,15 @@
|
||||
package filesystem
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"io"
|
||||
fs2 "io/fs"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"emperror.dev/errors"
|
||||
@@ -19,257 +18,248 @@ import (
|
||||
ignore "github.com/sabhiram/go-gitignore"
|
||||
|
||||
"github.com/pterodactyl/wings/config"
|
||||
"github.com/pterodactyl/wings/system"
|
||||
"github.com/pterodactyl/wings/internal/ufs"
|
||||
)
|
||||
|
||||
type Filesystem struct {
|
||||
unixFS *ufs.Quota
|
||||
|
||||
mu sync.RWMutex
|
||||
lastLookupTime *usageLookupTime
|
||||
lookupInProgress *system.AtomicBool
|
||||
diskUsed int64
|
||||
lookupInProgress atomic.Bool
|
||||
diskCheckInterval time.Duration
|
||||
denylist *ignore.GitIgnore
|
||||
|
||||
// The maximum amount of disk space (in bytes) that this Filesystem instance can use.
|
||||
diskLimit int64
|
||||
|
||||
// The root data directory path for this Filesystem instance.
|
||||
root *os.Root
|
||||
rootPath string
|
||||
|
||||
isTest bool
|
||||
}
|
||||
|
||||
// New creates a new Filesystem instance for a given server.
|
||||
func New(path string, size int64, denylist []string) (*Filesystem, error) {
|
||||
r, err := os.OpenRoot(path)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "server/filesystem: failed to open root")
|
||||
func New(root string, size int64, denylist []string) (*Filesystem, error) {
|
||||
if err := os.MkdirAll(root, 0o755); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
unixFS, err := ufs.NewUnixFS(root, config.UseOpenat2())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
quota := ufs.NewQuota(unixFS, size)
|
||||
|
||||
return &Filesystem{
|
||||
unixFS: quota,
|
||||
|
||||
fs := &Filesystem{
|
||||
root: r,
|
||||
rootPath: path,
|
||||
diskLimit: size,
|
||||
diskCheckInterval: time.Duration(config.Get().System.DiskCheckInterval),
|
||||
lastLookupTime: &usageLookupTime{},
|
||||
lookupInProgress: system.NewAtomicBool(false),
|
||||
denylist: ignore.CompileIgnoreLines(denylist...),
|
||||
}
|
||||
|
||||
return fs, nil
|
||||
}
|
||||
|
||||
// normalize takes the input path, runs it through filepath.Clean and trims any
|
||||
// leading forward slashes (since the os.Root method calls will fail otherwise).
|
||||
// If the resulting path is an empty string, "." is returned which os.Root will
|
||||
// understand as the base directory.
|
||||
func normalize(path string) string {
|
||||
c := strings.TrimLeft(filepath.Clean(path), string(filepath.Separator))
|
||||
if c == "" {
|
||||
return "."
|
||||
}
|
||||
return c
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Path returns the root path for the Filesystem instance.
|
||||
func (fs *Filesystem) Path() string {
|
||||
return fs.rootPath
|
||||
return fs.unixFS.BasePath()
|
||||
}
|
||||
|
||||
// Close closes the underlying os.Root instance for the server.
|
||||
func (fs *Filesystem) Close() error {
|
||||
if err := fs.root.Close(); err != nil {
|
||||
return errors.Wrap(err, "server/filesystem: failed to close root")
|
||||
}
|
||||
return nil
|
||||
// ReadDir reads directory entries.
|
||||
func (fs *Filesystem) ReadDir(path string) ([]ufs.DirEntry, error) {
|
||||
return fs.unixFS.ReadDir(path)
|
||||
}
|
||||
|
||||
// ReadDirStat is like ReadDir except that it returns FileInfo for each entry
|
||||
// instead of just a DirEntry.
|
||||
func (fs *Filesystem) ReadDirStat(path string) ([]ufs.FileInfo, error) {
|
||||
return ufs.ReadDirMap(fs.unixFS.UnixFS, path, func(e ufs.DirEntry) (ufs.FileInfo, error) {
|
||||
return e.Info()
|
||||
})
|
||||
}
|
||||
|
||||
// File returns a reader for a file instance as well as the stat information.
|
||||
func (fs *Filesystem) File(p string) (*os.File, Stat, error) {
|
||||
p = normalize(p)
|
||||
st, err := fs.Stat(p)
|
||||
func (fs *Filesystem) File(p string) (ufs.File, Stat, error) {
|
||||
f, err := fs.unixFS.Open(p)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil, Stat{}, newFilesystemError(ErrNotExist, err)
|
||||
}
|
||||
return nil, Stat{}, errors.WithStackIf(err)
|
||||
return nil, Stat{}, err
|
||||
}
|
||||
if st.IsDir() {
|
||||
return nil, Stat{}, newFilesystemError(ErrCodeIsDirectory, nil)
|
||||
}
|
||||
f, err := fs.root.Open(p)
|
||||
st, err := statFromFile(f)
|
||||
if err != nil {
|
||||
return nil, Stat{}, errors.WithStackIf(err)
|
||||
_ = f.Close()
|
||||
return nil, Stat{}, err
|
||||
}
|
||||
return f, st, nil
|
||||
}
|
||||
|
||||
func (fs *Filesystem) UnixFS() *ufs.UnixFS {
|
||||
return fs.unixFS.UnixFS
|
||||
}
|
||||
|
||||
// Touch acts by creating the given file and path on the disk if it is not present
|
||||
// already. If it is present, the file is opened using the defaults which will truncate
|
||||
// already. If it is present, the file is opened using the defaults which will truncate
|
||||
// the contents. The opened file is then returned to the caller.
|
||||
func (fs *Filesystem) Touch(p string, flag int, mode os.FileMode) (*os.File, error) {
|
||||
p = normalize(p)
|
||||
o := &fileOpener{root: fs.root}
|
||||
f, err := o.open(p, flag, mode)
|
||||
if err == nil {
|
||||
return f, nil
|
||||
}
|
||||
if f != nil {
|
||||
_ = f.Close()
|
||||
}
|
||||
// If the error is not because it doesn't exist then we just need to bail at this point.
|
||||
if !errors.Is(err, os.ErrNotExist) {
|
||||
return nil, errors.Wrap(err, "server/filesystem: touch: failed to open file handle")
|
||||
}
|
||||
// Only create and chown the directory if it doesn't exist.
|
||||
if _, err := fs.root.Stat(filepath.Dir(p)); errors.Is(err, os.ErrNotExist) {
|
||||
// Create the path leading up to the file we're trying to create, setting the final perms
|
||||
// on it as we go.
|
||||
if err := fs.root.MkdirAll(filepath.Dir(p), 0o755); err != nil {
|
||||
return nil, errors.WrapIf(err, "server/filesystem: touch: failed to create directory tree")
|
||||
}
|
||||
if err := fs.Chown(filepath.Dir(p)); err != nil {
|
||||
return nil, errors.WrapIf(err, "server/filesystem: touch: failed to chown directory tree")
|
||||
}
|
||||
}
|
||||
// Try to open the file now that we have created the pathing necessary for it, and then
|
||||
// Chown that file so that the permissions don't mess with things.
|
||||
f, err = o.open(p, flag, mode)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "server/filesystem: touch: failed to open file handle")
|
||||
}
|
||||
_ = fs.Chown(p)
|
||||
return f, nil
|
||||
func (fs *Filesystem) Touch(p string, flag int) (ufs.File, error) {
|
||||
return fs.unixFS.Touch(p, flag, 0o644)
|
||||
}
|
||||
|
||||
// Writefile writes a file to the system. If the file does not already exist one
|
||||
// will be created. This will also properly recalculate the disk space used by
|
||||
// the server when writing new files or modifying existing ones.
|
||||
//
|
||||
// deprecated 1.12.1 prefer the use of Filesystem.Write()
|
||||
// DEPRECATED: use `Write` instead.
|
||||
func (fs *Filesystem) Writefile(p string, r io.Reader) error {
|
||||
p = normalize(p)
|
||||
var currentSize int64
|
||||
// If the file does not exist on the system already go ahead and create the pathway
|
||||
// to it and an empty file. We'll then write to it later on after this completes.
|
||||
stat, err := fs.root.Stat(p)
|
||||
if err != nil && !os.IsNotExist(err) {
|
||||
st, err := fs.unixFS.Stat(p)
|
||||
if err != nil && !errors.Is(err, ufs.ErrNotExist) {
|
||||
return errors.Wrap(err, "server/filesystem: writefile: failed to stat file")
|
||||
} else if err == nil {
|
||||
if stat.IsDir() {
|
||||
return errors.WithStack(&Error{code: ErrCodeIsDirectory, resolved: stat.Name()})
|
||||
if st.IsDir() {
|
||||
// TODO: resolved
|
||||
return errors.WithStack(&Error{code: ErrCodeIsDirectory, resolved: ""})
|
||||
}
|
||||
currentSize = stat.Size()
|
||||
currentSize = st.Size()
|
||||
}
|
||||
|
||||
// Touch the file and return the handle to it at this point. This will
|
||||
// create or truncate the file, and create any necessary parent directories
|
||||
// if they are missing.
|
||||
file, err := fs.unixFS.Touch(p, ufs.O_RDWR|ufs.O_TRUNC, 0o644)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error touching file: %w", err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
// Do not use CopyBuffer here, it is wasteful as the file implements
|
||||
// io.ReaderFrom, which causes it to not use the buffer anyways.
|
||||
n, err := io.Copy(file, r)
|
||||
|
||||
// Adjust the disk usage to account for the old size and the new size of the file.
|
||||
fs.unixFS.Add(n - currentSize)
|
||||
|
||||
if err := fs.chownFile(p); err != nil {
|
||||
return fmt.Errorf("error chowning file: %w", err)
|
||||
}
|
||||
// Return the error from io.Copy.
|
||||
return err
|
||||
}
|
||||
|
||||
func (fs *Filesystem) Write(p string, r io.Reader, newSize int64, mode ufs.FileMode) error {
|
||||
var currentSize int64
|
||||
st, err := fs.unixFS.Stat(p)
|
||||
if err != nil && !errors.Is(err, ufs.ErrNotExist) {
|
||||
return errors.Wrap(err, "server/filesystem: writefile: failed to stat file")
|
||||
} else if err == nil {
|
||||
if st.IsDir() {
|
||||
// TODO: resolved
|
||||
return errors.WithStack(&Error{code: ErrCodeIsDirectory, resolved: ""})
|
||||
}
|
||||
currentSize = st.Size()
|
||||
}
|
||||
|
||||
br := bufio.NewReader(r)
|
||||
// Check that the new size we're writing to the disk can fit. If there is currently
|
||||
// a file we'll subtract that current file size from the size of the buffer to determine
|
||||
// the amount of new data we're writing (or amount we're removing if smaller).
|
||||
if err := fs.HasSpaceFor(int64(br.Size()) - currentSize); err != nil {
|
||||
if err := fs.HasSpaceFor(newSize - currentSize); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Touch the file and return the handle to it at this point. This will create the file,
|
||||
// any necessary directories, and set the proper owner of the file.
|
||||
file, err := fs.Touch(p, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0o644)
|
||||
// Touch the file and return the handle to it at this point. This will
|
||||
// create or truncate the file, and create any necessary parent directories
|
||||
// if they are missing.
|
||||
file, err := fs.unixFS.Touch(p, ufs.O_RDWR|ufs.O_TRUNC, mode)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
buf := make([]byte, 1024*4)
|
||||
sz, err := io.CopyBuffer(file, r, buf)
|
||||
if newSize == 0 {
|
||||
// Subtract the previous size of the file if the new size is 0.
|
||||
fs.unixFS.Add(-currentSize)
|
||||
} else {
|
||||
// Do not use CopyBuffer here, it is wasteful as the file implements
|
||||
// io.ReaderFrom, which causes it to not use the buffer anyways.
|
||||
var n int64
|
||||
n, err = io.Copy(file, io.LimitReader(r, newSize))
|
||||
|
||||
// Adjust the disk usage to account for the old size and the new size of the file.
|
||||
fs.addDisk(sz - currentSize)
|
||||
// Adjust the disk usage to account for the old size and the new size of the file.
|
||||
fs.unixFS.Add(n - currentSize)
|
||||
}
|
||||
|
||||
return fs.Chown(p)
|
||||
if err := fs.chownFile(p); err != nil {
|
||||
return err
|
||||
}
|
||||
// Return any remaining error.
|
||||
return err
|
||||
}
|
||||
|
||||
func (fs *Filesystem) Mkdir(p string, mode os.FileMode) error {
|
||||
if err := fs.root.Mkdir(normalize(p), mode); err != nil {
|
||||
return errors.Wrap(err, "server/filesystem: mkdir: failed to make directory")
|
||||
// CreateDirectory creates a new directory (name) at a specified path (p) for
|
||||
// the server.
|
||||
func (fs *Filesystem) CreateDirectory(name string, p string) error {
|
||||
return fs.unixFS.MkdirAll(filepath.Join(p, name), 0o755)
|
||||
}
|
||||
|
||||
func (fs *Filesystem) Rename(oldpath, newpath string) error {
|
||||
return fs.unixFS.Rename(oldpath, newpath)
|
||||
}
|
||||
|
||||
func (fs *Filesystem) Symlink(oldpath, newpath string) error {
|
||||
return fs.unixFS.Symlink(oldpath, newpath)
|
||||
}
|
||||
|
||||
func (fs *Filesystem) chownFile(name string) error {
|
||||
if fs.isTest {
|
||||
return nil
|
||||
}
|
||||
|
||||
uid := config.Get().System.User.Uid
|
||||
gid := config.Get().System.User.Gid
|
||||
return fs.unixFS.Lchown(name, uid, gid)
|
||||
}
|
||||
|
||||
// Chown recursively iterates over a file or directory and sets the permissions on all of the
|
||||
// underlying files. Iterate over all of the files and directories. If it is a file just
|
||||
// go ahead and perform the chown operation. Otherwise dig deeper into the directory until
|
||||
// we've run out of directories to dig into.
|
||||
func (fs *Filesystem) Chown(p string) error {
|
||||
if fs.isTest {
|
||||
return nil
|
||||
}
|
||||
|
||||
uid := config.Get().System.User.Uid
|
||||
gid := config.Get().System.User.Gid
|
||||
|
||||
dirfd, name, closeFd, err := fs.unixFS.SafePath(p)
|
||||
defer closeFd()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Start by just chowning the initial path that we received.
|
||||
if err := fs.unixFS.Lchownat(dirfd, name, uid, gid); err != nil {
|
||||
return errors.Wrap(err, "server/filesystem: chown: failed to chown path")
|
||||
}
|
||||
|
||||
// If this is not a directory we can now return from the function, there is nothing
|
||||
// left that we need to do.
|
||||
if st, err := fs.unixFS.Lstatat(dirfd, name); err != nil || !st.IsDir() {
|
||||
return nil
|
||||
}
|
||||
|
||||
// This walker is probably some of the most efficient code in Wings. It has
|
||||
// an internally re-used buffer for listing directory entries and doesn't
|
||||
// need to check if every individual path it touches is safe as the code
|
||||
// doesn't traverse symlinks, is immune to symlink timing attacks, and
|
||||
// gives us a dirfd and file name to make a direct syscall with.
|
||||
if err := fs.unixFS.WalkDirat(dirfd, name, func(dirfd int, name, _ string, info ufs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := fs.unixFS.Lchownat(dirfd, name, uid, gid); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
return fmt.Errorf("server/filesystem: chown: failed to chown during walk function: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Write writes a file to the disk.
|
||||
func (fs *Filesystem) Write(p string, r io.Reader, newSize int64, mode os.FileMode) error {
|
||||
st, err := fs.root.Stat(normalize(p))
|
||||
if err != nil {
|
||||
if !errors.Is(err, os.ErrNotExist) {
|
||||
return errors.Wrap(err, "server/filesystem: write: failed to stat file")
|
||||
}
|
||||
}
|
||||
|
||||
var c int64
|
||||
if err == nil {
|
||||
if st.IsDir() {
|
||||
return errors.WithStack(&Error{code: ErrCodeIsDirectory, resolved: normalize(p)})
|
||||
}
|
||||
c = st.Size()
|
||||
}
|
||||
|
||||
if err := fs.HasSpaceFor(newSize - c); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
f, err := fs.Touch(p, os.O_RDWR|os.O_CREATE|os.O_TRUNC, mode)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "server/filesystem: write: failed to touch file")
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
if newSize == 0 {
|
||||
fs.addDisk(-c)
|
||||
} else {
|
||||
// Do not use CopyBuffer here; it is wasteful as the file implements
|
||||
// io.ReaderFrom, which causes it to not use the buffer anyway.
|
||||
n, err := io.Copy(f, io.LimitReader(r, newSize))
|
||||
// Always adjust the disk to account for cases where a partial copy occurs
|
||||
// and there is some new content on the disk.
|
||||
fs.addDisk(n - c)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "server/filesystem: write: failed to write file")
|
||||
}
|
||||
}
|
||||
|
||||
// todo: might be unnecessary due to the `fs.Touch` call already doing this?
|
||||
return fs.Chown(p)
|
||||
}
|
||||
|
||||
// CreateDirectory creates a new directory ("name") at a specified path ("p") for the server.
|
||||
func (fs *Filesystem) CreateDirectory(name string, p string) error {
|
||||
return fs.root.MkdirAll(path.Join(normalize(p), name), 0o755)
|
||||
}
|
||||
|
||||
// Rename moves (or renames) a file or directory.
|
||||
func (fs *Filesystem) Rename(from string, to string) error {
|
||||
to = normalize(to)
|
||||
from = normalize(from)
|
||||
|
||||
if from == "." || to == "." {
|
||||
return os.ErrExist
|
||||
}
|
||||
|
||||
// If the target file or directory already exists the rename function will
|
||||
// fail, so just bail out now.
|
||||
if _, err := fs.root.Stat(to); err == nil {
|
||||
return os.ErrExist
|
||||
}
|
||||
|
||||
d := strings.TrimLeft(filepath.Dir(to), "/")
|
||||
// Ensure that the directory we're moving into exists correctly on the system. Only do this if
|
||||
// we're not at the root directory level.
|
||||
if d != "" {
|
||||
if err := fs.root.MkdirAll(d, 0o755); err != nil {
|
||||
return errors.Wrap(err, "server/filesystem: failed to create directory tree")
|
||||
}
|
||||
}
|
||||
|
||||
return fs.root.Rename(from, to)
|
||||
func (fs *Filesystem) Chmod(path string, mode ufs.FileMode) error {
|
||||
return fs.unixFS.Chmod(path, mode)
|
||||
}
|
||||
|
||||
// Begin looping up to 50 times to try and create a unique copy file name. This will take
|
||||
@@ -280,7 +270,7 @@ func (fs *Filesystem) Rename(from string, to string) error {
|
||||
// Could probably make this more efficient by checking if there are any files matching the copy
|
||||
// pattern, and trying to find the highest number and then incrementing it by one rather than
|
||||
// looping endlessly.
|
||||
func (fs *Filesystem) findCopySuffix(dir string, name string, extension string) (string, error) {
|
||||
func (fs *Filesystem) findCopySuffix(dirfd int, name, extension string) (string, error) {
|
||||
var i int
|
||||
suffix := " copy"
|
||||
|
||||
@@ -292,11 +282,10 @@ func (fs *Filesystem) findCopySuffix(dir string, name string, extension string)
|
||||
n := name + suffix + extension
|
||||
// If we stat the file and it does not exist that means we're good to create the copy. If it
|
||||
// does exist, we'll just continue to the next loop and try again.
|
||||
if _, err := fs.Stat(path.Join(dir, n)); err != nil {
|
||||
if !errors.Is(err, os.ErrNotExist) {
|
||||
if _, err := fs.unixFS.Lstatat(dirfd, n); err != nil {
|
||||
if !errors.Is(err, ufs.ErrNotExist) {
|
||||
return "", err
|
||||
}
|
||||
|
||||
break
|
||||
}
|
||||
|
||||
@@ -308,272 +297,201 @@ func (fs *Filesystem) findCopySuffix(dir string, name string, extension string)
|
||||
return name + suffix + extension, nil
|
||||
}
|
||||
|
||||
// Copies a given file to the same location and appends a suffix to the file to indicate that
|
||||
// it has been copied.
|
||||
// Copy copies a given file to the same location and appends a suffix to the
|
||||
// file to indicate that it has been copied.
|
||||
func (fs *Filesystem) Copy(p string) error {
|
||||
p = normalize(p)
|
||||
s, err := fs.root.Stat(p)
|
||||
dirfd, name, closeFd, err := fs.unixFS.SafePath(p)
|
||||
defer closeFd()
|
||||
if err != nil {
|
||||
return err
|
||||
} else if s.IsDir() || !s.Mode().IsRegular() {
|
||||
// If this is a directory or not a regular file, just throw a not-exist error
|
||||
// since anything calling this function should understand what that means.
|
||||
return os.ErrNotExist
|
||||
}
|
||||
|
||||
// Check that copying this file wouldn't put the server over its limit.
|
||||
if err := fs.HasSpaceFor(s.Size()); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
base := filepath.Base(p)
|
||||
relative := strings.TrimSuffix(strings.TrimPrefix(p, fs.Path()), base)
|
||||
extension := filepath.Ext(base)
|
||||
name := strings.TrimSuffix(base, extension)
|
||||
|
||||
// Ensure that ".tar" is also counted as apart of the file extension.
|
||||
// There might be a better way to handle this for other double file extensions,
|
||||
// but this is a good workaround for now.
|
||||
if strings.HasSuffix(name, ".tar") {
|
||||
extension = ".tar" + extension
|
||||
name = strings.TrimSuffix(name, ".tar")
|
||||
}
|
||||
|
||||
source, err := fs.root.Open(p)
|
||||
source, err := fs.unixFS.OpenFileat(dirfd, name, ufs.O_RDONLY, 0)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer source.Close()
|
||||
info, err := source.Stat()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if info.IsDir() || !info.Mode().IsRegular() {
|
||||
// If this is a directory or not a regular file, just throw a not-exist error
|
||||
// since anything calling this function should understand what that means.
|
||||
return ufs.ErrNotExist
|
||||
}
|
||||
currentSize := info.Size()
|
||||
|
||||
n, err := fs.findCopySuffix(relative, name, extension)
|
||||
// Check that copying this file wouldn't put the server over its limit.
|
||||
if err := fs.HasSpaceFor(currentSize); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
base := info.Name()
|
||||
extension := filepath.Ext(base)
|
||||
baseName := strings.TrimSuffix(base, extension)
|
||||
|
||||
// Ensure that ".tar" is also counted as apart of the file extension.
|
||||
// There might be a better way to handle this for other double file extensions,
|
||||
// but this is a good workaround for now.
|
||||
if strings.HasSuffix(baseName, ".tar") {
|
||||
extension = ".tar" + extension
|
||||
baseName = strings.TrimSuffix(baseName, ".tar")
|
||||
}
|
||||
|
||||
newName, err := fs.findCopySuffix(dirfd, baseName, extension)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
dst, err := fs.unixFS.OpenFileat(dirfd, newName, ufs.O_WRONLY|ufs.O_CREATE, info.Mode())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return fs.Writefile(path.Join(relative, n), source)
|
||||
}
|
||||
// Do not use CopyBuffer here, it is wasteful as the file implements
|
||||
// io.ReaderFrom, which causes it to not use the buffer anyways.
|
||||
n, err := io.Copy(dst, io.LimitReader(source, currentSize))
|
||||
fs.unixFS.Add(n)
|
||||
|
||||
// Symlink creates a symbolic link between the source and target paths. [os.Root].Symlink
|
||||
// allows for the creation of a symlink that targets a file outside the root directory.
|
||||
// This isn't the end of the world because the read is blocked through this system, and
|
||||
// within a container it would just point to something in the readonly filesystem.
|
||||
//
|
||||
// There are also valid use-cases where a symlink might need to point to a file outside
|
||||
// the server data directory for a server to operate correctly. Since everything in the
|
||||
// filesystem runs through os.Root though we're protected from accidentally reading a
|
||||
// sensitive file on the _host_ OS.
|
||||
func (fs *Filesystem) Symlink(source, target string) error {
|
||||
source = normalize(source)
|
||||
target = normalize(target)
|
||||
|
||||
// os.Root#Symlink allows for the creation of a symlink that targets a file outside
|
||||
// the root directory. This isn't the end of the world because the read is blocked
|
||||
// through this system, and within a container it would just point to something in the
|
||||
// readonly filesystem.
|
||||
//
|
||||
// However, just to avoid this propagating everywhere, *attempt* to block anything that
|
||||
// would be pointing to a location outside the root directory.
|
||||
if _, err := fs.root.Stat(source); err != nil {
|
||||
return errors.Wrap(err, "server/filesystem: symlink: failed to stat source")
|
||||
if !fs.isTest {
|
||||
if err := fs.unixFS.Lchownat(dirfd, newName, config.Get().System.User.Uid, config.Get().System.User.Gid); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Yes -- this gap between the stat and symlink allows a TOCTOU vulnerability to exist,
|
||||
// but again we're layering this with the remaining logic that prevents this filesystem
|
||||
// from reading any symlinks or acting on any file that points outside the root as defined
|
||||
// by os.Root. The check above is mostly to prevent stupid mistakes or basic attempts to
|
||||
// get around this. If someone *really* wants to make these symlinks, they can. They can
|
||||
// also just create them from the running server process, and we still need to rely on our
|
||||
// own internal FS logic to detect and block those reads, which it does. Therefore, I am
|
||||
// not deeply concerned with this.
|
||||
if err := fs.root.Symlink(source, target); err != nil {
|
||||
return errors.Wrap(err, "server/filesystem: symlink: failed to create symlink")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ReadDir returns all the contents of the given directory.
|
||||
func (fs *Filesystem) ReadDir(p string) ([]fs2.DirEntry, error) {
|
||||
d, ok := fs.root.FS().(fs2.ReadDirFS)
|
||||
if !ok {
|
||||
return []fs2.DirEntry{}, errors.New("server/filesystem: readdir: could not init root fs")
|
||||
}
|
||||
|
||||
e, err := d.ReadDir(normalize(p))
|
||||
if err != nil {
|
||||
return []fs2.DirEntry{}, errors.Wrap(err, "server/filesystem: readdir: failed to read directory")
|
||||
}
|
||||
|
||||
return e, nil
|
||||
// Return the error from io.Copy.
|
||||
return err
|
||||
}
|
||||
|
||||
// TruncateRootDirectory removes _all_ files and directories from a server's
|
||||
// data directory and resets the used disk space to zero.
|
||||
func (fs *Filesystem) TruncateRootDirectory() error {
|
||||
err := filepath.WalkDir(fs.rootPath, func(path string, d fs2.DirEntry, err error) error {
|
||||
p := normalize(strings.TrimPrefix(path, fs.rootPath))
|
||||
if p == "." {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := fs.root.RemoveAll(p); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if d.IsDir() {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
go func() {
|
||||
// If there was an error, re-calculate the disk usage right away to account
|
||||
// for any partially removed files.
|
||||
_, _ = fs.updateCachedDiskUsage()
|
||||
}()
|
||||
|
||||
return errors.Wrap(err, "server/filesystem: truncate: failed to walk root directory")
|
||||
if err := os.RemoveAll(fs.Path()); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Set the disk space back to zero.
|
||||
fs.addDisk(fs.diskUsed * -1)
|
||||
|
||||
if err := os.Mkdir(fs.Path(), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
_ = fs.unixFS.Close()
|
||||
unixFS, err := ufs.NewUnixFS(fs.Path(), config.UseOpenat2())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var limit int64
|
||||
if fs.isTest {
|
||||
limit = 0
|
||||
} else {
|
||||
limit = fs.unixFS.Limit()
|
||||
}
|
||||
fs.unixFS = ufs.NewQuota(unixFS, limit)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Delete removes a file or folder from the system. Prevents the user from
|
||||
// accidentally (or maliciously) removing their root server data directory.
|
||||
func (fs *Filesystem) Delete(p string) error {
|
||||
p = normalize(p)
|
||||
if p == "." {
|
||||
return errors.New("server/filesystem: delete: cannot delete root directory")
|
||||
}
|
||||
|
||||
st, err := fs.root.Lstat(p)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
return errors.Wrap(err, "server/filesystem: delete: failed to stat file")
|
||||
}
|
||||
|
||||
if st.IsDir() {
|
||||
if s, err := fs.DirectorySize(p); err == nil {
|
||||
fs.addDisk(-s)
|
||||
}
|
||||
} else {
|
||||
fs.addDisk(-st.Size())
|
||||
}
|
||||
|
||||
return fs.root.RemoveAll(p)
|
||||
return fs.unixFS.RemoveAll(p)
|
||||
}
|
||||
|
||||
type fileOpener struct {
|
||||
busy uint
|
||||
root *os.Root
|
||||
}
|
||||
//type fileOpener struct {
|
||||
// fs *Filesystem
|
||||
// busy uint
|
||||
//}
|
||||
//
|
||||
//// Attempts to open a given file up to "attempts" number of times, using a backoff. If the file
|
||||
//// cannot be opened because of a "text file busy" error, we will attempt until the number of attempts
|
||||
//// has been exhaused, at which point we will abort with an error.
|
||||
//func (fo *fileOpener) open(path string, flags int, perm ufs.FileMode) (ufs.File, error) {
|
||||
// for {
|
||||
// f, err := fo.fs.unixFS.OpenFile(path, flags, perm)
|
||||
//
|
||||
// // If there is an error because the text file is busy, go ahead and sleep for a few
|
||||
// // hundred milliseconds and then try again up to three times before just returning the
|
||||
// // error back to the caller.
|
||||
// //
|
||||
// // Based on code from: https://github.com/golang/go/issues/22220#issuecomment-336458122
|
||||
// if err != nil && fo.busy < 3 && strings.Contains(err.Error(), "text file busy") {
|
||||
// time.Sleep(100 * time.Millisecond << fo.busy)
|
||||
// fo.busy++
|
||||
// continue
|
||||
// }
|
||||
//
|
||||
// return f, err
|
||||
// }
|
||||
//}
|
||||
|
||||
// Attempts to open a given file up to "attempts" number of times, using a backoff. If the file
|
||||
// cannot be opened because of a "text file busy" error, we will attempt until the number of attempts
|
||||
// has been exhaused, at which point we will abort with an error.
|
||||
func (fo *fileOpener) open(path string, flags int, mode os.FileMode) (*os.File, error) {
|
||||
for {
|
||||
f, err := fo.root.OpenFile(path, flags, mode)
|
||||
|
||||
// If there is an error because the text file is busy, go ahead and sleep for a few
|
||||
// hundred milliseconds and then try again up to three times before just returning the
|
||||
// error back to the caller.
|
||||
//
|
||||
// Based on code from: https://github.com/golang/go/issues/22220#issuecomment-336458122
|
||||
if err != nil && fo.busy < 3 && strings.Contains(err.Error(), "text file busy") {
|
||||
time.Sleep(100 * time.Millisecond << fo.busy)
|
||||
fo.busy++
|
||||
continue
|
||||
}
|
||||
|
||||
return f, err
|
||||
}
|
||||
}
|
||||
|
||||
// ListDirectory lists the contents of a given directory and returns stat information
|
||||
// about each file and folder within it. If you only need to know the contents of the
|
||||
// directory and do not need mimetype information, call [Filesystem.ReadDir] directly
|
||||
// instead.
|
||||
// ListDirectory lists the contents of a given directory and returns stat
|
||||
// information about each file and folder within it.
|
||||
func (fs *Filesystem) ListDirectory(p string) ([]Stat, error) {
|
||||
files, err := fs.ReadDir(p)
|
||||
if err != nil {
|
||||
return []Stat{}, err
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
|
||||
// You must initialize the output of this directory as a non-nil value otherwise
|
||||
// when it is marshaled into a JSON object you'll just get 'null' back, which will
|
||||
// break the panel badly.
|
||||
out := make([]Stat, len(files))
|
||||
|
||||
// Iterate over all the files and directories returned and perform an async process
|
||||
// to get the mime-type for them all.
|
||||
for i, file := range files {
|
||||
wg.Add(1)
|
||||
|
||||
go func(idx int, d fs2.DirEntry) {
|
||||
defer wg.Done()
|
||||
|
||||
fi, err := d.Info()
|
||||
if err != nil {
|
||||
log.WithField("error", err).WithField("path", filepath.Join(p, d.Name())).Warn("failed to retrieve directory entry info")
|
||||
return
|
||||
}
|
||||
|
||||
if fi.IsDir() {
|
||||
out[idx] = Stat{FileInfo: fi, Mimetype: "inode/directory"}
|
||||
return
|
||||
}
|
||||
|
||||
st := Stat{FileInfo: fi, Mimetype: "application/octet-stream"}
|
||||
|
||||
// Don't try to detect the type on a pipe — this will just hang the application,
|
||||
// and you'll never get a response back.
|
||||
//
|
||||
// @see https://github.com/pterodactyl/panel/issues/4059
|
||||
if fi.Mode()&os.ModeNamedPipe == 0 {
|
||||
if f, err := fs.root.Open(normalize(filepath.Join(p, d.Name()))); err != nil {
|
||||
if !IsPathError(err) && !IsLinkError(err) {
|
||||
log.WithField("error", err).WithField("path", filepath.Join(p, d.Name())).Warn("error opening file for mimetype detection")
|
||||
}
|
||||
} else {
|
||||
if m, err := mimetype.DetectReader(f); err == nil {
|
||||
st.Mimetype = m.String()
|
||||
} else {
|
||||
log.WithField("error", err).WithField("path", filepath.Join(p, d.Name())).Warn("failed to detect mimetype for file")
|
||||
}
|
||||
_ = f.Close()
|
||||
}
|
||||
}
|
||||
|
||||
out[idx] = st
|
||||
}(i, file)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
// Sort the output alphabetically to begin with since we've run the output
|
||||
// through an asynchronous process and the order is gonna be very random.
|
||||
sort.SliceStable(out, func(i, j int) bool {
|
||||
if out[i].Name() == out[j].Name() || out[i].Name() > out[j].Name() {
|
||||
return true
|
||||
// Read entries from the path on the filesystem, using the mapped reader, so
|
||||
// we can map the DirEntry slice into a Stat slice with mimetype information.
|
||||
out, err := ufs.ReadDirMap(fs.unixFS.UnixFS, p, func(e ufs.DirEntry) (Stat, error) {
|
||||
info, err := e.Info()
|
||||
if err != nil {
|
||||
return Stat{}, err
|
||||
}
|
||||
|
||||
var d string
|
||||
if e.Type().IsDir() {
|
||||
d = "inode/directory"
|
||||
} else {
|
||||
d = "application/octet-stream"
|
||||
}
|
||||
var m *mimetype.MIME
|
||||
if e.Type().IsRegular() {
|
||||
// TODO: I should probably find a better way to do this.
|
||||
eO := e.(interface {
|
||||
Open() (ufs.File, error)
|
||||
})
|
||||
f, err := eO.Open()
|
||||
if err != nil {
|
||||
return Stat{}, err
|
||||
}
|
||||
m, err = mimetype.DetectReader(f)
|
||||
if err != nil {
|
||||
log.Error(err.Error())
|
||||
}
|
||||
_ = f.Close()
|
||||
}
|
||||
|
||||
st := Stat{FileInfo: info, Mimetype: d}
|
||||
if m != nil {
|
||||
st.Mimetype = m.String()
|
||||
}
|
||||
return st, nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Sort entries alphabetically.
|
||||
slices.SortStableFunc(out, func(a, b Stat) int {
|
||||
switch {
|
||||
case a.Name() == b.Name():
|
||||
return 0
|
||||
case a.Name() > b.Name():
|
||||
return 1
|
||||
default:
|
||||
return -1
|
||||
}
|
||||
return false
|
||||
})
|
||||
|
||||
// Then, sort it so that directories are listed first in the output. Everything
|
||||
// will continue to be alphabetized at this point.
|
||||
sort.SliceStable(out, func(i, j int) bool {
|
||||
return out[i].IsDir()
|
||||
// Sort folders before other file types.
|
||||
slices.SortStableFunc(out, func(a, b Stat) int {
|
||||
switch {
|
||||
case a.IsDir() && b.IsDir():
|
||||
return 0
|
||||
case a.IsDir():
|
||||
return -1
|
||||
default:
|
||||
return 1
|
||||
}
|
||||
})
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (fs *Filesystem) Chtimes(path string, atime, mtime time.Time) error {
|
||||
if fs.isTest {
|
||||
return nil
|
||||
}
|
||||
return fs.unixFS.Chtimes(path, atime, mtime)
|
||||
}
|
||||
|
||||
464
server/filesystem/filesystem_test.go
Executable file → Normal file
464
server/filesystem/filesystem_test.go
Executable file → Normal file
@@ -7,22 +7,17 @@ import (
|
||||
"math/rand"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"unicode/utf8"
|
||||
|
||||
. "github.com/franela/goblin"
|
||||
|
||||
"github.com/pterodactyl/wings/internal/ufs"
|
||||
|
||||
"github.com/pterodactyl/wings/config"
|
||||
)
|
||||
|
||||
type testFs struct {
|
||||
*Filesystem
|
||||
tmpDir string
|
||||
}
|
||||
|
||||
func NewFs() *testFs {
|
||||
func NewFs() (*Filesystem, *rootFs) {
|
||||
config.Set(&config.Configuration{
|
||||
AuthenticationToken: "abc",
|
||||
System: config.SystemConfiguration{
|
||||
@@ -34,21 +29,32 @@ func NewFs() *testFs {
|
||||
tmpDir, err := os.MkdirTemp(os.TempDir(), "pterodactyl")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
fs, err := New(tmpDir, 0, []string{})
|
||||
if err != nil {
|
||||
rfs := rootFs{root: tmpDir}
|
||||
|
||||
p := filepath.Join(tmpDir, "server")
|
||||
if err := os.Mkdir(p, 0o755); err != nil {
|
||||
panic(err)
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
fs, _ := New(p, 0, []string{})
|
||||
fs.isTest = true
|
||||
if err := fs.TruncateRootDirectory(); err != nil {
|
||||
panic(err)
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
tfs := &testFs{Filesystem: fs, tmpDir: tmpDir}
|
||||
tfs.reset()
|
||||
|
||||
return tfs
|
||||
return fs, &rfs
|
||||
}
|
||||
|
||||
func getFileContent(file *os.File) string {
|
||||
type rootFs struct {
|
||||
root string
|
||||
}
|
||||
|
||||
func getFileContent(file ufs.File) string {
|
||||
var w bytes.Buffer
|
||||
if _, err := bufio.NewReader(file).WriteTo(&w); err != nil {
|
||||
panic(err)
|
||||
@@ -56,64 +62,40 @@ func getFileContent(file *os.File) string {
|
||||
return w.String()
|
||||
}
|
||||
|
||||
func (tfs *testFs) reset() {
|
||||
if err := tfs.root.Close(); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
if !strings.HasPrefix(tfs.tmpDir, "/tmp/pterodactyl") {
|
||||
panic("filesystem_test: attempting to delete outside root directory: " + tfs.tmpDir)
|
||||
func (rfs *rootFs) CreateServerFile(p string, c []byte) error {
|
||||
f, err := os.Create(filepath.Join(rfs.root, "server", p))
|
||||
|
||||
if err == nil {
|
||||
_, _ = f.Write(c)
|
||||
_ = f.Close()
|
||||
}
|
||||
|
||||
if err := os.RemoveAll(tfs.tmpDir); err != nil {
|
||||
if !os.IsNotExist(err) {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(tfs.rootPath, tfs.tmpDir) {
|
||||
panic("filesystem_test: mismatch between root and tmp paths")
|
||||
}
|
||||
|
||||
tfs.rootPath = filepath.Join(tfs.tmpDir, "/server")
|
||||
if err := os.MkdirAll(tfs.rootPath, 0o755); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
r, err := os.OpenRoot(tfs.rootPath)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
tfs.root = r
|
||||
return err
|
||||
}
|
||||
|
||||
func (tfs *testFs) write(name string, data []byte) {
|
||||
p := filepath.Clean(filepath.Join(tfs.rootPath, name))
|
||||
// Ensure we're always writing into a location that would also be cleaned up
|
||||
// by the reset() function.
|
||||
if !strings.HasPrefix(p, filepath.Dir(tfs.rootPath)) {
|
||||
panic("filesystem_test: attempting to write outside of root directory: " + p)
|
||||
}
|
||||
func (rfs *rootFs) CreateServerFileFromString(p string, c string) error {
|
||||
return rfs.CreateServerFile(p, []byte(c))
|
||||
}
|
||||
|
||||
if err := os.WriteFile(filepath.Join(tfs.rootPath, name), data, 0o644); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
func (rfs *rootFs) StatServerFile(p string) (os.FileInfo, error) {
|
||||
return os.Stat(filepath.Join(rfs.root, "server", p))
|
||||
}
|
||||
|
||||
func TestFilesystem_Openfile(t *testing.T) {
|
||||
g := Goblin(t)
|
||||
fs := NewFs()
|
||||
fs, rfs := NewFs()
|
||||
|
||||
g.Describe("File", func() {
|
||||
g.It("returns custom error when file does not exist", func() {
|
||||
_, _, err := fs.File("foo/bar.txt")
|
||||
|
||||
g.Assert(err).IsNotNil()
|
||||
g.Assert(IsErrorCode(err, ErrNotExist)).IsTrue()
|
||||
// TODO
|
||||
//g.Assert(IsErrorCode(err, ErrNotExist)).IsTrue()
|
||||
})
|
||||
|
||||
g.It("returns file stat information", func() {
|
||||
fs.write("foo.txt", []byte("hello world"))
|
||||
_ = rfs.CreateServerFile("foo.txt", []byte("hello world"))
|
||||
|
||||
f, st, err := fs.File("foo.txt")
|
||||
g.Assert(err).IsNil()
|
||||
@@ -124,14 +106,14 @@ func TestFilesystem_Openfile(t *testing.T) {
|
||||
})
|
||||
|
||||
g.AfterEach(func() {
|
||||
fs.reset()
|
||||
_ = fs.TruncateRootDirectory()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func TestFilesystem_Writefile(t *testing.T) {
|
||||
g := Goblin(t)
|
||||
fs := NewFs()
|
||||
fs, _ := NewFs()
|
||||
|
||||
g.Describe("Open and WriteFile", func() {
|
||||
buf := &bytes.Buffer{}
|
||||
@@ -141,22 +123,22 @@ func TestFilesystem_Writefile(t *testing.T) {
|
||||
g.It("can create a new file", func() {
|
||||
r := bytes.NewReader([]byte("test file content"))
|
||||
|
||||
g.Assert(atomic.LoadInt64(&fs.diskUsed)).Equal(int64(0))
|
||||
g.Assert(fs.CachedUsage()).Equal(int64(0))
|
||||
|
||||
err := fs.Writefile("test.txt", r)
|
||||
err := fs.Write("test.txt", r, r.Size(), 0o644)
|
||||
g.Assert(err).IsNil()
|
||||
|
||||
f, _, err := fs.File("test.txt")
|
||||
g.Assert(err).IsNil()
|
||||
defer f.Close()
|
||||
g.Assert(getFileContent(f)).Equal("test file content")
|
||||
g.Assert(atomic.LoadInt64(&fs.diskUsed)).Equal(r.Size())
|
||||
g.Assert(fs.CachedUsage()).Equal(r.Size())
|
||||
})
|
||||
|
||||
g.It("can create a new file inside a nested directory with leading slash", func() {
|
||||
r := bytes.NewReader([]byte("test file content"))
|
||||
|
||||
err := fs.Writefile("/some/nested/test.txt", r)
|
||||
err := fs.Write("/some/nested/test.txt", r, r.Size(), 0o644)
|
||||
g.Assert(err).IsNil()
|
||||
|
||||
f, _, err := fs.File("/some/nested/test.txt")
|
||||
@@ -168,7 +150,7 @@ func TestFilesystem_Writefile(t *testing.T) {
|
||||
g.It("can create a new file inside a nested directory without a trailing slash", func() {
|
||||
r := bytes.NewReader([]byte("test file content"))
|
||||
|
||||
err := fs.Writefile("some/../foo/bar/test.txt", r)
|
||||
err := fs.Write("some/../foo/bar/test.txt", r, r.Size(), 0o644)
|
||||
g.Assert(err).IsNil()
|
||||
|
||||
f, _, err := fs.File("foo/bar/test.txt")
|
||||
@@ -180,17 +162,13 @@ func TestFilesystem_Writefile(t *testing.T) {
|
||||
g.It("cannot create a file outside the root directory", func() {
|
||||
r := bytes.NewReader([]byte("test file content"))
|
||||
|
||||
err := fs.Writefile("../../etc/test.txt", r)
|
||||
err := fs.Write("/some/../foo/../../test.txt", r, r.Size(), 0o644)
|
||||
g.Assert(err).IsNotNil()
|
||||
g.Assert(IsPathError(err)).IsTrue()
|
||||
|
||||
err = fs.Writefile("a/../../../test.txt", r)
|
||||
g.Assert(err).IsNotNil()
|
||||
g.Assert(IsPathError(err)).IsTrue()
|
||||
g.Assert(errors.Is(err, ufs.ErrBadPathResolution)).IsTrue("err is not ErrBadPathResolution")
|
||||
})
|
||||
|
||||
g.It("cannot write a file that exceeds the disk limits", func() {
|
||||
atomic.StoreInt64(&fs.diskLimit, 1024)
|
||||
fs.SetDiskLimit(1024)
|
||||
|
||||
b := make([]byte, 1025)
|
||||
_, err := rand.Read(b)
|
||||
@@ -198,18 +176,18 @@ func TestFilesystem_Writefile(t *testing.T) {
|
||||
g.Assert(len(b)).Equal(1025)
|
||||
|
||||
r := bytes.NewReader(b)
|
||||
err = fs.Writefile("test.txt", r)
|
||||
err = fs.Write("test.txt", r, int64(len(b)), 0o644)
|
||||
g.Assert(err).IsNotNil()
|
||||
g.Assert(IsErrorCode(err, ErrCodeDiskSpace)).IsTrue()
|
||||
})
|
||||
|
||||
g.It("truncates the file when writing new contents", func() {
|
||||
r := bytes.NewReader([]byte("original data"))
|
||||
err := fs.Writefile("test.txt", r)
|
||||
err := fs.Write("test.txt", r, r.Size(), 0o644)
|
||||
g.Assert(err).IsNil()
|
||||
|
||||
r = bytes.NewReader([]byte("new data"))
|
||||
err = fs.Writefile("test.txt", r)
|
||||
err = fs.Write("test.txt", r, r.Size(), 0o644)
|
||||
g.Assert(err).IsNil()
|
||||
|
||||
f, _, err := fs.File("test.txt")
|
||||
@@ -220,24 +198,21 @@ func TestFilesystem_Writefile(t *testing.T) {
|
||||
|
||||
g.AfterEach(func() {
|
||||
buf.Truncate(0)
|
||||
fs.reset()
|
||||
|
||||
atomic.StoreInt64(&fs.diskUsed, 0)
|
||||
atomic.StoreInt64(&fs.diskLimit, 0)
|
||||
_ = fs.TruncateRootDirectory()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func TestFilesystem_CreateDirectory(t *testing.T) {
|
||||
g := Goblin(t)
|
||||
fs := NewFs()
|
||||
fs, rfs := NewFs()
|
||||
|
||||
g.Describe("CreateDirectory", func() {
|
||||
g.It("should create missing directories automatically", func() {
|
||||
err := fs.CreateDirectory("test", "foo/bar/baz")
|
||||
g.Assert(err).IsNil()
|
||||
|
||||
st, err := os.Stat(filepath.Join(fs.rootPath, "foo/bar/baz/test"))
|
||||
st, err := rfs.StatServerFile("foo/bar/baz/test")
|
||||
g.Assert(err).IsNil()
|
||||
g.Assert(st.IsDir()).IsTrue()
|
||||
g.Assert(st.Name()).Equal("test")
|
||||
@@ -247,7 +222,7 @@ func TestFilesystem_CreateDirectory(t *testing.T) {
|
||||
err := fs.CreateDirectory("test", "/foozie/barzie/bazzy/")
|
||||
g.Assert(err).IsNil()
|
||||
|
||||
st, err := os.Stat(filepath.Join(fs.rootPath, "foozie/barzie/bazzy/test"))
|
||||
st, err := rfs.StatServerFile("foozie/barzie/bazzy/test")
|
||||
g.Assert(err).IsNil()
|
||||
g.Assert(st.IsDir()).IsTrue()
|
||||
g.Assert(st.Name()).Equal("test")
|
||||
@@ -256,89 +231,93 @@ func TestFilesystem_CreateDirectory(t *testing.T) {
|
||||
g.It("should not allow the creation of directories outside the root", func() {
|
||||
err := fs.CreateDirectory("test", "e/../../something")
|
||||
g.Assert(err).IsNotNil()
|
||||
g.Assert(IsPathError(err)).IsTrue()
|
||||
g.Assert(errors.Is(err, ufs.ErrBadPathResolution)).IsTrue("err is not ErrBadPathResolution")
|
||||
})
|
||||
|
||||
g.It("should not increment the disk usage", func() {
|
||||
err := fs.CreateDirectory("test", "/")
|
||||
g.Assert(err).IsNil()
|
||||
g.Assert(atomic.LoadInt64(&fs.diskUsed)).Equal(int64(0))
|
||||
g.Assert(fs.CachedUsage()).Equal(int64(0))
|
||||
})
|
||||
|
||||
g.AfterEach(func() {
|
||||
fs.reset()
|
||||
_ = fs.TruncateRootDirectory()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func TestFilesystem_Rename(t *testing.T) {
|
||||
g := Goblin(t)
|
||||
fs := NewFs()
|
||||
fs, rfs := NewFs()
|
||||
|
||||
g.Describe("Rename", func() {
|
||||
g.BeforeEach(func() {
|
||||
fs.write("source.txt", []byte("text content"))
|
||||
if err := rfs.CreateServerFileFromString("source.txt", "text content"); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
})
|
||||
|
||||
g.It("returns an error if the target already exists", func() {
|
||||
fs.write("target.txt", []byte("taget content"))
|
||||
err := rfs.CreateServerFileFromString("target.txt", "taget content")
|
||||
g.Assert(err).IsNil()
|
||||
|
||||
err := fs.Rename("source.txt", "target.txt")
|
||||
err = fs.Rename("source.txt", "target.txt")
|
||||
g.Assert(err).IsNotNil()
|
||||
g.Assert(errors.Is(err, os.ErrExist)).IsTrue()
|
||||
g.Assert(errors.Is(err, ufs.ErrExist)).IsTrue("err is not ErrExist")
|
||||
})
|
||||
|
||||
g.It("returns an error if the final destination is the root directory", func() {
|
||||
err := fs.Rename("source.txt", "/")
|
||||
g.Assert(err).IsNotNil()
|
||||
g.Assert(errors.Is(err, os.ErrExist)).IsTrue()
|
||||
g.Assert(errors.Is(err, ufs.ErrBadPathResolution)).IsTrue("err is not ErrBadPathResolution")
|
||||
})
|
||||
|
||||
g.It("returns an error if the source destination is the root directory", func() {
|
||||
err := fs.Rename("/", "destination.txt")
|
||||
err := fs.Rename("/", "target.txt")
|
||||
g.Assert(err).IsNotNil()
|
||||
g.Assert(errors.Is(err, os.ErrExist)).IsTrue()
|
||||
g.Assert(errors.Is(err, ufs.ErrBadPathResolution)).IsTrue("err is not ErrBadPathResolution")
|
||||
})
|
||||
|
||||
g.It("does not allow renaming to a location outside the root", func() {
|
||||
err := fs.Rename("source.txt", "../target.txt")
|
||||
g.Assert(err).IsNotNil()
|
||||
g.Assert(IsPathError(err)).IsTrue()
|
||||
g.Assert(errors.Is(err, ufs.ErrBadPathResolution)).IsTrue("err is not ErrBadPathResolution")
|
||||
})
|
||||
|
||||
g.It("does not allow renaming from a location outside the root", func() {
|
||||
err := fs.Rename("../ext-source.txt", "target.txt")
|
||||
err := rfs.CreateServerFileFromString("/../ext-source.txt", "taget content")
|
||||
|
||||
err = fs.Rename("/../ext-source.txt", "target.txt")
|
||||
g.Assert(err).IsNotNil()
|
||||
g.Assert(IsLinkError(err)).IsTrue()
|
||||
g.Assert(errors.Is(err, ufs.ErrBadPathResolution)).IsTrue("err is not ErrBadPathResolution")
|
||||
})
|
||||
|
||||
g.It("allows a file to be renamed", func() {
|
||||
err := fs.Rename("source.txt", "target.txt")
|
||||
g.Assert(err).IsNil()
|
||||
|
||||
_, err = os.Stat(filepath.Join(fs.rootPath, "source.txt"))
|
||||
_, err = rfs.StatServerFile("source.txt")
|
||||
g.Assert(err).IsNotNil()
|
||||
g.Assert(errors.Is(err, os.ErrNotExist)).IsTrue()
|
||||
g.Assert(errors.Is(err, ufs.ErrNotExist)).IsTrue("err is not ErrNotExist")
|
||||
|
||||
st, err := os.Stat(filepath.Join(fs.rootPath, "target.txt"))
|
||||
st, err := rfs.StatServerFile("target.txt")
|
||||
g.Assert(err).IsNil()
|
||||
g.Assert(st.Name()).Equal("target.txt")
|
||||
g.Assert(st.Size()).IsNotZero()
|
||||
})
|
||||
|
||||
g.It("allows a folder to be renamed", func() {
|
||||
if err := os.Mkdir(filepath.Join(fs.rootPath, "/source_dir"), 0o755); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
err := fs.Rename("source_dir", "target_dir")
|
||||
err := os.Mkdir(filepath.Join(rfs.root, "/server/source_dir"), 0o755)
|
||||
g.Assert(err).IsNil()
|
||||
|
||||
_, err = os.Stat(filepath.Join(fs.rootPath, "source_dir"))
|
||||
g.Assert(err).IsNotNil()
|
||||
g.Assert(errors.Is(err, os.ErrNotExist)).IsTrue()
|
||||
err = fs.Rename("source_dir", "target_dir")
|
||||
g.Assert(err).IsNil()
|
||||
|
||||
st, err := os.Stat(filepath.Join(fs.rootPath, "target_dir"))
|
||||
_, err = rfs.StatServerFile("source_dir")
|
||||
g.Assert(err).IsNotNil()
|
||||
g.Assert(errors.Is(err, ufs.ErrNotExist)).IsTrue("err is not ErrNotExist")
|
||||
|
||||
st, err := rfs.StatServerFile("target_dir")
|
||||
g.Assert(err).IsNil()
|
||||
g.Assert(st.IsDir()).IsTrue()
|
||||
})
|
||||
@@ -346,81 +325,92 @@ func TestFilesystem_Rename(t *testing.T) {
|
||||
g.It("returns an error if the source does not exist", func() {
|
||||
err := fs.Rename("missing.txt", "target.txt")
|
||||
g.Assert(err).IsNotNil()
|
||||
g.Assert(errors.Is(err, os.ErrNotExist)).IsTrue()
|
||||
g.Assert(errors.Is(err, ufs.ErrNotExist)).IsTrue("err is not ErrNotExist")
|
||||
})
|
||||
|
||||
g.It("creates directories if they are missing", func() {
|
||||
err := fs.Rename("source.txt", "nested/folder/target.txt")
|
||||
g.Assert(err).IsNil()
|
||||
|
||||
st, err := os.Stat(filepath.Join(fs.rootPath, "nested/folder/target.txt"))
|
||||
st, err := rfs.StatServerFile("nested/folder/target.txt")
|
||||
g.Assert(err).IsNil()
|
||||
g.Assert(st.Name()).Equal("target.txt")
|
||||
})
|
||||
|
||||
g.AfterEach(func() {
|
||||
fs.reset()
|
||||
_ = fs.TruncateRootDirectory()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func TestFilesystem_Copy(t *testing.T) {
|
||||
g := Goblin(t)
|
||||
fs := NewFs()
|
||||
fs, rfs := NewFs()
|
||||
|
||||
g.Describe("Copy", func() {
|
||||
g.BeforeEach(func() {
|
||||
fs.write("source.txt", []byte("text content"))
|
||||
atomic.StoreInt64(&fs.diskUsed, int64(utf8.RuneCountInString("test content")))
|
||||
if err := rfs.CreateServerFileFromString("source.txt", "text content"); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
fs.unixFS.SetUsage(int64(utf8.RuneCountInString("test content")))
|
||||
})
|
||||
|
||||
g.It("should return an error if the source does not exist", func() {
|
||||
err := fs.Copy("foo.txt")
|
||||
g.Assert(err).IsNotNil()
|
||||
g.Assert(errors.Is(err, os.ErrNotExist)).IsTrue()
|
||||
g.Assert(errors.Is(err, ufs.ErrNotExist)).IsTrue("err is not ErrNotExist")
|
||||
})
|
||||
|
||||
g.It("should return an error if the source is outside the root", func() {
|
||||
err := fs.Copy("../ext-source.txt")
|
||||
err := rfs.CreateServerFileFromString("/../ext-source.txt", "text content")
|
||||
|
||||
err = fs.Copy("../ext-source.txt")
|
||||
g.Assert(err).IsNotNil()
|
||||
g.Assert(IsPathError(err)).IsTrue()
|
||||
g.Assert(errors.Is(err, ufs.ErrBadPathResolution)).IsTrue("err is not ErrBadPathResolution")
|
||||
})
|
||||
|
||||
g.It("should return an error if the source directory is outside the root", func() {
|
||||
err := fs.Copy("../nested/in/dir/ext-source.txt")
|
||||
err := os.MkdirAll(filepath.Join(rfs.root, "nested/in/dir"), 0o755)
|
||||
g.Assert(err).IsNil()
|
||||
|
||||
err = rfs.CreateServerFileFromString("/../nested/in/dir/ext-source.txt", "external content")
|
||||
g.Assert(err).IsNil()
|
||||
|
||||
err = fs.Copy("../nested/in/dir/ext-source.txt")
|
||||
g.Assert(err).IsNotNil()
|
||||
g.Assert(IsPathError(err)).IsTrue()
|
||||
g.Assert(errors.Is(err, ufs.ErrBadPathResolution)).IsTrue("err is not ErrBadPathResolution")
|
||||
|
||||
err = fs.Copy("nested/in/../../../nested/in/dir/ext-source.txt")
|
||||
g.Assert(err).IsNotNil()
|
||||
g.Assert(IsPathError(err)).IsTrue()
|
||||
g.Assert(errors.Is(err, ufs.ErrBadPathResolution)).IsTrue("err is not ErrBadPathResolution")
|
||||
})
|
||||
|
||||
g.It("should return an error if the source is a directory", func() {
|
||||
err := os.Mkdir(filepath.Join(fs.rootPath, "/dir"), 0o755)
|
||||
err := os.Mkdir(filepath.Join(rfs.root, "server/dir"), 0o755)
|
||||
g.Assert(err).IsNil()
|
||||
|
||||
err = fs.Copy("dir")
|
||||
g.Assert(err).IsNotNil()
|
||||
g.Assert(errors.Is(err, os.ErrNotExist)).IsTrue()
|
||||
g.Assert(errors.Is(err, ufs.ErrNotExist)).IsTrue("err is not ErrNotExist")
|
||||
})
|
||||
|
||||
g.It("should return an error if there is not space to copy the file", func() {
|
||||
atomic.StoreInt64(&fs.diskLimit, 2)
|
||||
fs.SetDiskLimit(2)
|
||||
|
||||
err := fs.Copy("source.txt")
|
||||
g.Assert(err).IsNotNil()
|
||||
g.Assert(IsErrorCode(err, ErrCodeDiskSpace)).IsTrue()
|
||||
g.Assert(IsErrorCode(err, ErrCodeDiskSpace)).IsTrue("err is not ErrCodeDiskSpace")
|
||||
})
|
||||
|
||||
g.It("should create a copy of the file and increment the disk used", func() {
|
||||
err := fs.Copy("source.txt")
|
||||
g.Assert(err).IsNil()
|
||||
|
||||
_, err = os.Stat(filepath.Join(fs.rootPath, "source.txt"))
|
||||
_, err = rfs.StatServerFile("source.txt")
|
||||
g.Assert(err).IsNil()
|
||||
|
||||
_, err = os.Stat(filepath.Join(fs.rootPath, "source copy.txt"))
|
||||
_, err = rfs.StatServerFile("source copy.txt")
|
||||
g.Assert(err).IsNil()
|
||||
})
|
||||
|
||||
@@ -434,148 +424,68 @@ func TestFilesystem_Copy(t *testing.T) {
|
||||
r := []string{"source.txt", "source copy.txt", "source copy 1.txt"}
|
||||
|
||||
for _, name := range r {
|
||||
_, err = os.Stat(filepath.Join(fs.rootPath, name))
|
||||
_, err = rfs.StatServerFile(name)
|
||||
g.Assert(err).IsNil()
|
||||
}
|
||||
|
||||
g.Assert(atomic.LoadInt64(&fs.diskUsed)).Equal(int64(utf8.RuneCountInString("test content")) * 3)
|
||||
g.Assert(fs.CachedUsage()).Equal(int64(utf8.RuneCountInString("test content")) * 3)
|
||||
})
|
||||
|
||||
g.It("should create a copy inside of a directory", func() {
|
||||
if err := os.MkdirAll(filepath.Join(fs.rootPath, "/nested/in/dir"), 0o755); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
fs.write("nested/in/dir/source.txt", []byte("test content"))
|
||||
|
||||
err := fs.Copy("nested/in/dir/source.txt")
|
||||
err := os.MkdirAll(filepath.Join(rfs.root, "/server/nested/in/dir"), 0o755)
|
||||
g.Assert(err).IsNil()
|
||||
|
||||
_, err = os.Stat(filepath.Join(fs.rootPath, "nested/in/dir/source.txt"))
|
||||
err = rfs.CreateServerFileFromString("nested/in/dir/source.txt", "test content")
|
||||
g.Assert(err).IsNil()
|
||||
|
||||
_, err = os.Stat(filepath.Join(fs.rootPath, "nested/in/dir/source copy.txt"))
|
||||
err = fs.Copy("nested/in/dir/source.txt")
|
||||
g.Assert(err).IsNil()
|
||||
|
||||
_, err = rfs.StatServerFile("nested/in/dir/source.txt")
|
||||
g.Assert(err).IsNil()
|
||||
|
||||
_, err = rfs.StatServerFile("nested/in/dir/source copy.txt")
|
||||
g.Assert(err).IsNil()
|
||||
})
|
||||
|
||||
g.AfterEach(func() {
|
||||
fs.reset()
|
||||
|
||||
atomic.StoreInt64(&fs.diskUsed, 0)
|
||||
atomic.StoreInt64(&fs.diskLimit, 0)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func TestFilesystem_Symlink(t *testing.T) {
|
||||
g := Goblin(t)
|
||||
fs := NewFs()
|
||||
|
||||
g.Describe("Symlink", func() {
|
||||
g.It("should create a symlink", func() {
|
||||
fs.write("source.txt", []byte("text content"))
|
||||
|
||||
err := fs.Symlink("source.txt", "symlink.txt")
|
||||
g.Assert(err).IsNil()
|
||||
|
||||
st, err := os.Lstat(filepath.Join(fs.rootPath, "symlink.txt"))
|
||||
g.Assert(err).IsNil()
|
||||
g.Assert(st.Mode()&os.ModeSymlink != 0).IsTrue()
|
||||
})
|
||||
|
||||
g.It("should return an error if the source is outside the root", func() {
|
||||
err := fs.Symlink("../source.txt", "symlink.txt")
|
||||
g.Assert(err).IsNotNil()
|
||||
g.Assert(IsPathError(err)).IsTrue()
|
||||
})
|
||||
|
||||
g.It("should return an error if the dest is outside the root", func() {
|
||||
fs.write("source.txt", []byte("text content"))
|
||||
|
||||
err := fs.Symlink("source.txt", "../symlink.txt")
|
||||
g.Assert(err).IsNotNil()
|
||||
g.Assert(IsLinkError(err)).IsTrue()
|
||||
})
|
||||
|
||||
g.AfterEach(func() {
|
||||
fs.reset()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func TestFilesystem_ReadDir(t *testing.T) {
|
||||
g := Goblin(t)
|
||||
fs := NewFs()
|
||||
|
||||
g.Describe("ReadDir", func() {
|
||||
g.Before(func() {
|
||||
if err := os.Mkdir(filepath.Join(fs.rootPath, "child"), 0o755); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
fs.write("one.txt", []byte("one"))
|
||||
fs.write("two.txt", []byte("two"))
|
||||
fs.write("child/three.txt", []byte("two"))
|
||||
})
|
||||
|
||||
g.After(func() {
|
||||
fs.reset()
|
||||
})
|
||||
|
||||
g.It("should return the contents of the root directory", func() {
|
||||
d, err := fs.ReadDir("/")
|
||||
g.Assert(err).IsNil()
|
||||
g.Assert(len(d)).Equal(3)
|
||||
|
||||
// os.Root#ReadDir sorts them by name.
|
||||
g.Assert(d[0].Name()).Equal("child")
|
||||
g.Assert(d[0].IsDir()).IsTrue()
|
||||
g.Assert(d[1].Name()).Equal("one.txt")
|
||||
g.Assert(d[2].Name()).Equal("two.txt")
|
||||
})
|
||||
|
||||
g.It("should return the contents of a child directory", func() {
|
||||
d, err := fs.ReadDir("child")
|
||||
g.Assert(err).IsNil()
|
||||
g.Assert(len(d)).Equal(1)
|
||||
g.Assert(d[0].Name()).Equal("three.txt")
|
||||
})
|
||||
|
||||
g.It("should return an error if the directory is outside the root", func() {
|
||||
_, err := fs.ReadDir("../server")
|
||||
g.Assert(err).IsNotNil()
|
||||
g.Assert(IsPathError(err)).IsTrue()
|
||||
_ = fs.TruncateRootDirectory()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func TestFilesystem_Delete(t *testing.T) {
|
||||
g := Goblin(t)
|
||||
fs := NewFs()
|
||||
fs, rfs := NewFs()
|
||||
|
||||
g.Describe("Delete", func() {
|
||||
g.BeforeEach(func() {
|
||||
fs.write("source.txt", []byte("text content"))
|
||||
atomic.StoreInt64(&fs.diskUsed, int64(utf8.RuneCountInString("test content")))
|
||||
if err := rfs.CreateServerFileFromString("source.txt", "test content"); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
fs.unixFS.SetUsage(int64(utf8.RuneCountInString("test content")))
|
||||
})
|
||||
|
||||
g.It("does not delete files outside the root directory", func() {
|
||||
err := fs.Delete("../ext-source.txt")
|
||||
err := rfs.CreateServerFileFromString("/../ext-source.txt", "external content")
|
||||
|
||||
err = fs.Delete("../ext-source.txt")
|
||||
g.Assert(err).IsNotNil()
|
||||
g.Assert(IsPathError(err)).IsTrue()
|
||||
g.Assert(errors.Is(err, ufs.ErrBadPathResolution)).IsTrue("err is not ErrBadPathResolution")
|
||||
})
|
||||
|
||||
g.It("does not allow the deletion of the root directory", func() {
|
||||
err := fs.Delete("/")
|
||||
g.Assert(err).IsNotNil()
|
||||
g.Assert(err.Error()).Equal("server/filesystem: delete: cannot delete root directory")
|
||||
g.Assert(errors.Is(err, ufs.ErrBadPathResolution)).IsTrue("err is not ErrBadPathResolution")
|
||||
})
|
||||
|
||||
g.It("does not return an error if the target does not exist", func() {
|
||||
err := fs.Delete("missing.txt")
|
||||
g.Assert(err).IsNil()
|
||||
|
||||
st, err := os.Stat(filepath.Join(fs.rootPath, "source.txt"))
|
||||
st, err := rfs.StatServerFile("source.txt")
|
||||
g.Assert(err).IsNil()
|
||||
g.Assert(st.Name()).Equal("source.txt")
|
||||
})
|
||||
@@ -584,11 +494,11 @@ func TestFilesystem_Delete(t *testing.T) {
|
||||
err := fs.Delete("source.txt")
|
||||
g.Assert(err).IsNil()
|
||||
|
||||
_, err = os.Stat(filepath.Join(fs.rootPath, "source.txt"))
|
||||
_, err = rfs.StatServerFile("source.txt")
|
||||
g.Assert(err).IsNotNil()
|
||||
g.Assert(errors.Is(err, os.ErrNotExist)).IsTrue()
|
||||
g.Assert(errors.Is(err, ufs.ErrNotExist)).IsTrue("err is not ErrNotExist")
|
||||
|
||||
g.Assert(atomic.LoadInt64(&fs.diskUsed)).Equal(int64(0))
|
||||
g.Assert(fs.CachedUsage()).Equal(int64(0))
|
||||
})
|
||||
|
||||
g.It("deletes all items inside a directory if the directory is deleted", func() {
|
||||
@@ -598,109 +508,103 @@ func TestFilesystem_Delete(t *testing.T) {
|
||||
"foo/bar/baz/source.txt",
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(filepath.Join(fs.rootPath, "/foo/bar/baz"), 0o755); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
for _, s := range sources {
|
||||
fs.write(s, []byte("test content"))
|
||||
}
|
||||
|
||||
atomic.StoreInt64(&fs.diskUsed, int64(utf8.RuneCountInString("test content")*3))
|
||||
|
||||
err := fs.Delete("foo")
|
||||
err := os.MkdirAll(filepath.Join(rfs.root, "/server/foo/bar/baz"), 0o755)
|
||||
g.Assert(err).IsNil()
|
||||
g.Assert(atomic.LoadInt64(&fs.diskUsed)).Equal(int64(0))
|
||||
|
||||
for _, s := range sources {
|
||||
_, err = os.Stat(filepath.Join(fs.rootPath, s))
|
||||
err = rfs.CreateServerFileFromString(s, "test content")
|
||||
g.Assert(err).IsNil()
|
||||
}
|
||||
|
||||
fs.unixFS.SetUsage(int64(utf8.RuneCountInString("test content") * 3))
|
||||
|
||||
err = fs.Delete("foo")
|
||||
g.Assert(err).IsNil()
|
||||
g.Assert(fs.unixFS.Usage()).Equal(int64(0))
|
||||
|
||||
for _, s := range sources {
|
||||
_, err = rfs.StatServerFile(s)
|
||||
g.Assert(err).IsNotNil()
|
||||
g.Assert(errors.Is(err, os.ErrNotExist)).IsTrue()
|
||||
g.Assert(errors.Is(err, ufs.ErrNotExist)).IsTrue("err is not ErrNotExist")
|
||||
}
|
||||
})
|
||||
|
||||
g.It("deletes a symlink but not it's target within the root directory", func() {
|
||||
// Symlink to a file inside the root server data directory.
|
||||
if err := os.Symlink(filepath.Join(fs.rootPath, "source.txt"), filepath.Join(fs.rootPath, "symlink.txt")); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
// Symlink to a file inside the root directory.
|
||||
err := os.Symlink(filepath.Join(rfs.root, "server/source.txt"), filepath.Join(rfs.root, "server/symlink.txt"))
|
||||
g.Assert(err).IsNil()
|
||||
|
||||
// Delete the symlink itself.
|
||||
err := fs.Delete("symlink.txt")
|
||||
err = fs.Delete("symlink.txt")
|
||||
g.Assert(err).IsNil()
|
||||
|
||||
// Ensure the symlink was deleted.
|
||||
_, err = os.Lstat(filepath.Join(fs.rootPath, "symlink.txt"))
|
||||
_, err = os.Lstat(filepath.Join(rfs.root, "server/symlink.txt"))
|
||||
g.Assert(err).IsNotNil()
|
||||
|
||||
// Ensure the symlink target still exists.
|
||||
_, err = os.Lstat(filepath.Join(fs.rootPath, "source.txt"))
|
||||
_, err = os.Lstat(filepath.Join(rfs.root, "server/source.txt"))
|
||||
g.Assert(err).IsNil()
|
||||
})
|
||||
|
||||
g.It("does not delete files symlinked outside of the root directory", func() {
|
||||
// Create a file outside the root directory.
|
||||
fs.write("../external.txt", []byte("test content"))
|
||||
err := rfs.CreateServerFileFromString("/../source.txt", "test content")
|
||||
g.Assert(err).IsNil()
|
||||
|
||||
// Create a symlink to the file outside the root directory.
|
||||
if err := os.Symlink(filepath.Join(fs.rootPath, "../external.txt"), filepath.Join(fs.rootPath, "symlink.txt")); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
err = os.Symlink(filepath.Join(rfs.root, "source.txt"), filepath.Join(rfs.root, "/server/symlink.txt"))
|
||||
g.Assert(err).IsNil()
|
||||
|
||||
// Delete the symlink. (This should pass as we will delete the symlink itself, not the target)
|
||||
err := fs.Delete("symlink.txt")
|
||||
// Delete the symlink. (This should pass as we will delete the symlink itself, not it's target)
|
||||
err = fs.Delete("symlink.txt")
|
||||
g.Assert(err).IsNil()
|
||||
|
||||
// Ensure the file outside the root directory still exists.
|
||||
_, err = os.Lstat(filepath.Join(fs.rootPath, "../external.txt"))
|
||||
_, err = os.Lstat(filepath.Join(rfs.root, "source.txt"))
|
||||
g.Assert(err).IsNil()
|
||||
})
|
||||
|
||||
g.It("does not delete files symlinked through a directory outside of the root directory", func() {
|
||||
// Create a directory outside the root directory.
|
||||
if err := os.Mkdir(filepath.Join(fs.rootPath, "../external"), 0o755); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
err := os.Mkdir(filepath.Join(rfs.root, "foo"), 0o755)
|
||||
g.Assert(err).IsNil()
|
||||
|
||||
fs.write("../external/source.txt", []byte("test content"))
|
||||
// Create a file inside the directory that is outside the root.
|
||||
err = rfs.CreateServerFileFromString("/../foo/source.txt", "test content")
|
||||
g.Assert(err).IsNil()
|
||||
|
||||
// Symlink the directory that is outside the root to a file inside the root.
|
||||
if err := os.Symlink(filepath.Join(fs.rootPath, "../external"), filepath.Join(fs.rootPath, "/symlink")); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
err = os.Symlink(filepath.Join(rfs.root, "foo"), filepath.Join(rfs.root, "server/symlink"))
|
||||
g.Assert(err).IsNil()
|
||||
|
||||
// Delete a file inside the symlinked directory.
|
||||
err := fs.Delete("symlink/source.txt")
|
||||
err = fs.Delete("symlink/source.txt")
|
||||
g.Assert(err).IsNotNil()
|
||||
g.Assert(IsPathError(err)).IsTrue()
|
||||
g.Assert(errors.Is(err, ufs.ErrBadPathResolution)).IsTrue("err is not ErrBadPathResolution")
|
||||
|
||||
// Ensure the file outside the root directory still exists.
|
||||
_, err = os.Lstat(filepath.Join(fs.rootPath, "../external/source.txt"))
|
||||
_, err = os.Lstat(filepath.Join(rfs.root, "foo/source.txt"))
|
||||
g.Assert(err).IsNil()
|
||||
})
|
||||
|
||||
g.It("returns an error when trying to delete a non-existent file symlinked through a directory outside of the root directory", func() {
|
||||
// Create a directory outside the root directory.
|
||||
if err := os.Mkdir(filepath.Join(fs.rootPath, "../external"), 0o755); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
err := os.Mkdir(filepath.Join(rfs.root, "foo2"), 0o755)
|
||||
g.Assert(err).IsNil()
|
||||
|
||||
// Symlink the directory that is outside the root to a file inside the root.
|
||||
if err := os.Symlink(filepath.Join(fs.rootPath, "../external"), filepath.Join(fs.rootPath, "/symlink")); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
err = os.Symlink(filepath.Join(rfs.root, "foo2"), filepath.Join(rfs.root, "server/symlink"))
|
||||
g.Assert(err).IsNil()
|
||||
|
||||
// Delete a file inside the symlinked directory.
|
||||
err := fs.Delete("symlink/source.txt")
|
||||
err = fs.Delete("symlink/source.txt")
|
||||
g.Assert(err).IsNotNil()
|
||||
g.Assert(IsPathError(err)).IsTrue()
|
||||
g.Assert(errors.Is(err, ufs.ErrBadPathResolution)).IsTrue("err is not ErrBadPathResolution")
|
||||
})
|
||||
|
||||
g.AfterEach(func() {
|
||||
fs.reset()
|
||||
|
||||
atomic.StoreInt64(&fs.diskUsed, 0)
|
||||
atomic.StoreInt64(&fs.diskLimit, 0)
|
||||
_ = fs.TruncateRootDirectory()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,16 +1,43 @@
|
||||
package filesystem
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"emperror.dev/errors"
|
||||
)
|
||||
|
||||
// IsIgnored checks if the given file or path is in the server's file denylist. If so, an Error
|
||||
// Checks if the given file or path is in the server's file denylist. If so, an Error
|
||||
// is returned, otherwise nil is returned.
|
||||
func (fs *Filesystem) IsIgnored(paths ...string) error {
|
||||
for _, p := range paths {
|
||||
//sp, err := fs.SafePath(p)
|
||||
//if err != nil {
|
||||
// return err
|
||||
//}
|
||||
// TODO: update logic to use unixFS
|
||||
if fs.denylist.MatchesPath(p) {
|
||||
return errors.WithStack(&Error{code: ErrCodeDenylistFile, path: p})
|
||||
return errors.WithStack(&Error{code: ErrCodeDenylistFile, path: p, resolved: p})
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Generate a path to the file by cleaning it up and appending the root server path to it. This
|
||||
// DOES NOT guarantee that the file resolves within the server data directory. You'll want to use
|
||||
// the fs.unsafeIsInDataDirectory(p) function to confirm.
|
||||
func (fs *Filesystem) unsafeFilePath(p string) string {
|
||||
// Calling filepath.Clean on the joined directory will resolve it to the absolute path,
|
||||
// removing any ../ type of resolution arguments, and leaving us with a direct path link.
|
||||
//
|
||||
// This will also trim the existing root path off the beginning of the path passed to
|
||||
// the function since that can get a bit messy.
|
||||
return filepath.Clean(filepath.Join(fs.Path(), strings.TrimPrefix(p, fs.Path())))
|
||||
}
|
||||
|
||||
// Check that that path string starts with the server data directory path. This function DOES NOT
|
||||
// validate that the rest of the path does not end up resolving out of this directory, or that the
|
||||
// targeted file or folder is not a symlink doing the same thing.
|
||||
func (fs *Filesystem) unsafeIsInDataDirectory(p string) bool {
|
||||
return strings.HasPrefix(strings.TrimSuffix(p, "/")+"/", strings.TrimSuffix(fs.Path(), "/")+"/")
|
||||
}
|
||||
|
||||
@@ -8,15 +8,17 @@ import (
|
||||
|
||||
"emperror.dev/errors"
|
||||
. "github.com/franela/goblin"
|
||||
|
||||
"github.com/pterodactyl/wings/internal/ufs"
|
||||
)
|
||||
|
||||
func TestFilesystem_Path(t *testing.T) {
|
||||
g := Goblin(t)
|
||||
fs := NewFs()
|
||||
fs, rfs := NewFs()
|
||||
|
||||
g.Describe("Path", func() {
|
||||
g.It("returns the root path for the instance", func() {
|
||||
g.Assert(fs.Path()).Equal(fs.rootPath)
|
||||
g.Assert(fs.Path()).Equal(filepath.Join(rfs.root, "/server"))
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -27,24 +29,30 @@ func TestFilesystem_Path(t *testing.T) {
|
||||
// the calls and ensure they all fail with the same reason.
|
||||
func TestFilesystem_Blocks_Symlinks(t *testing.T) {
|
||||
g := Goblin(t)
|
||||
fs := NewFs()
|
||||
fs, rfs := NewFs()
|
||||
|
||||
fs.write("../malicious.txt", []byte("external content"))
|
||||
if err := os.Mkdir(filepath.Join(fs.rootPath, "../malicious_dir"), 0o777); err != nil {
|
||||
if err := rfs.CreateServerFileFromString("/../malicious.txt", "external content"); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
links := map[string]string{
|
||||
"../malicious.txt": "/symlinked.txt",
|
||||
"../malicious_does_not_exist.txt": "/symlinked_does_not_exist.txt",
|
||||
"/symlinked_does_not_exist.txt": "/symlinked_does_not_exist2.txt",
|
||||
"../malicious_dir": "/external_dir",
|
||||
if err := os.Mkdir(filepath.Join(rfs.root, "/malicious_dir"), 0o777); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
for src, dst := range links {
|
||||
if err := os.Symlink(filepath.Join(fs.rootPath, src), filepath.Join(fs.rootPath, dst)); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
if err := os.Symlink(filepath.Join(rfs.root, "malicious.txt"), filepath.Join(rfs.root, "/server/symlinked.txt")); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
if err := os.Symlink(filepath.Join(rfs.root, "malicious_does_not_exist.txt"), filepath.Join(rfs.root, "/server/symlinked_does_not_exist.txt")); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
if err := os.Symlink(filepath.Join(rfs.root, "/server/symlinked_does_not_exist.txt"), filepath.Join(rfs.root, "/server/symlinked_does_not_exist2.txt")); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
if err := os.Symlink(filepath.Join(rfs.root, "/malicious_dir"), filepath.Join(rfs.root, "/server/external_dir")); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
g.Describe("Writefile", func() {
|
||||
@@ -53,7 +61,7 @@ func TestFilesystem_Blocks_Symlinks(t *testing.T) {
|
||||
|
||||
err := fs.Writefile("symlinked.txt", r)
|
||||
g.Assert(err).IsNotNil()
|
||||
g.Assert(IsPathError(err)).IsTrue()
|
||||
g.Assert(errors.Is(err, ufs.ErrBadPathResolution)).IsTrue("err is not ErrBadPathResolution")
|
||||
})
|
||||
|
||||
g.It("cannot write to a non-existent file symlinked outside the root", func() {
|
||||
@@ -61,7 +69,7 @@ func TestFilesystem_Blocks_Symlinks(t *testing.T) {
|
||||
|
||||
err := fs.Writefile("symlinked_does_not_exist.txt", r)
|
||||
g.Assert(err).IsNotNil()
|
||||
g.Assert(IsPathError(err)).IsTrue()
|
||||
g.Assert(errors.Is(err, ufs.ErrBadPathResolution)).IsTrue("err is not ErrBadPathResolution")
|
||||
})
|
||||
|
||||
g.It("cannot write to chained symlinks with target that does not exist outside the root", func() {
|
||||
@@ -69,7 +77,7 @@ func TestFilesystem_Blocks_Symlinks(t *testing.T) {
|
||||
|
||||
err := fs.Writefile("symlinked_does_not_exist2.txt", r)
|
||||
g.Assert(err).IsNotNil()
|
||||
g.Assert(IsPathError(err)).IsTrue()
|
||||
g.Assert(errors.Is(err, ufs.ErrBadPathResolution)).IsTrue("err is not ErrBadPathResolution")
|
||||
})
|
||||
|
||||
g.It("cannot write a file to a directory symlinked outside the root", func() {
|
||||
@@ -77,7 +85,7 @@ func TestFilesystem_Blocks_Symlinks(t *testing.T) {
|
||||
|
||||
err := fs.Writefile("external_dir/foo.txt", r)
|
||||
g.Assert(err).IsNotNil()
|
||||
g.Assert(IsPathError(err)).IsTrue()
|
||||
g.Assert(errors.Is(err, ufs.ErrNotDirectory)).IsTrue("err is not ErrNotDirectory")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -85,75 +93,54 @@ func TestFilesystem_Blocks_Symlinks(t *testing.T) {
|
||||
g.It("cannot create a directory outside the root", func() {
|
||||
err := fs.CreateDirectory("my_dir", "external_dir")
|
||||
g.Assert(err).IsNotNil()
|
||||
g.Assert(IsPathError(err)).IsTrue()
|
||||
g.Assert(errors.Is(err, ufs.ErrNotDirectory)).IsTrue("err is not ErrNotDirectory")
|
||||
})
|
||||
|
||||
g.It("cannot create a nested directory outside the root", func() {
|
||||
err := fs.CreateDirectory("my/nested/dir", "../external_dir/foo/bar")
|
||||
err := fs.CreateDirectory("my/nested/dir", "external_dir/foo/bar")
|
||||
g.Assert(err).IsNotNil()
|
||||
g.Assert(IsPathError(err)).IsTrue()
|
||||
g.Assert(errors.Is(err, ufs.ErrNotDirectory)).IsTrue("err is not ErrNotDirectory")
|
||||
})
|
||||
|
||||
g.It("cannot create a nested directory outside the root", func() {
|
||||
err := fs.CreateDirectory("my/nested/dir", "../external_dir/server")
|
||||
err := fs.CreateDirectory("my/nested/dir", "external_dir/server")
|
||||
g.Assert(err).IsNotNil()
|
||||
g.Assert(IsPathError(err)).IsTrue()
|
||||
g.Assert(errors.Is(err, ufs.ErrNotDirectory)).IsTrue("err is not ErrNotDirectory")
|
||||
})
|
||||
})
|
||||
|
||||
g.Describe("Rename", func() {
|
||||
// You can rename the symlink file itself, which does not impact the
|
||||
// underlying symlinked target file outside the server directory.
|
||||
g.It("can rename a file symlinked outside the directory root", func() {
|
||||
err := fs.Rename("symlinked.txt", "foo.txt")
|
||||
_, err := os.Lstat(filepath.Join(rfs.root, "server", "symlinked.txt"))
|
||||
g.Assert(err).IsNil()
|
||||
|
||||
st, err := os.Lstat(filepath.Join(fs.rootPath, "foo.txt"))
|
||||
err = fs.Rename("symlinked.txt", "foo.txt")
|
||||
g.Assert(err).IsNil()
|
||||
g.Assert(st.Mode()&os.ModeSymlink != 0).IsTrue()
|
||||
|
||||
st, err = os.Lstat(filepath.Join(fs.rootPath, "../malicious.txt"))
|
||||
_, err = os.Lstat(filepath.Join(rfs.root, "server", "foo.txt"))
|
||||
g.Assert(err).IsNil()
|
||||
g.Assert(st.Mode()&os.ModeSymlink == 0).IsTrue()
|
||||
})
|
||||
|
||||
// The same as above, acts on the source directory and not the target directory,
|
||||
// therefore, this is allowed.
|
||||
g.It("can rename a directory symlinked outside the root", func() {
|
||||
err := fs.Rename("external_dir", "foo")
|
||||
g.It("can rename a symlinked directory outside the root", func() {
|
||||
_, err := os.Lstat(filepath.Join(rfs.root, "server", "external_dir"))
|
||||
g.Assert(err).IsNil()
|
||||
|
||||
st, err := os.Lstat(filepath.Join(fs.rootPath, "foo"))
|
||||
err = fs.Rename("external_dir", "foo")
|
||||
g.Assert(err).IsNil()
|
||||
g.Assert(st.IsDir()).IsTrue()
|
||||
g.Assert(st.Mode()&os.ModeSymlink != 0).IsTrue()
|
||||
|
||||
st, err = os.Lstat(filepath.Join(fs.rootPath, "../external_dir"))
|
||||
_, err = os.Lstat(filepath.Join(rfs.root, "server", "foo"))
|
||||
g.Assert(err).IsNil()
|
||||
g.Assert(st.IsDir()).IsTrue()
|
||||
g.Assert(st.Mode()&os.ModeSymlink == 0).IsTrue()
|
||||
})
|
||||
|
||||
g.It("cannot rename a file to a location outside the directory root", func() {
|
||||
fs.write("my_file.txt", []byte("internal content"))
|
||||
_ = rfs.CreateServerFileFromString("my_file.txt", "internal content")
|
||||
t.Log(rfs.root)
|
||||
|
||||
err := fs.Rename("my_file.txt", "../external_dir/my_file.txt")
|
||||
g.Assert(err).IsNotNil()
|
||||
g.Assert(IsPathError(err)).IsTrue()
|
||||
})
|
||||
})
|
||||
st, err := os.Lstat(filepath.Join(rfs.root, "server", "foo"))
|
||||
g.Assert(err).IsNil()
|
||||
g.Assert(st.Mode()&ufs.ModeSymlink != 0).IsTrue()
|
||||
|
||||
g.Describe("Chown", func() {
|
||||
g.It("cannot chown a file symlinked outside the directory root", func() {
|
||||
err := fs.Chown("symlinked.txt")
|
||||
g.Assert(err).IsNotNil()
|
||||
g.Assert(IsPathError(err)).IsTrue()
|
||||
})
|
||||
err = fs.Rename("my_file.txt", "foo/my_file.txt")
|
||||
g.Assert(errors.Is(err, ufs.ErrNotDirectory)).IsTrue()
|
||||
|
||||
g.It("cannot chown a directory symlinked outside the directory root", func() {
|
||||
err := fs.Chown("external_dir")
|
||||
g.Assert(err).IsNotNil()
|
||||
g.Assert(IsPathError(err)).IsTrue()
|
||||
st, err = os.Lstat(filepath.Join(rfs.root, "malicious_dir", "my_file.txt"))
|
||||
g.Assert(errors.Is(err, ufs.ErrNotExist)).IsTrue()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -161,7 +148,7 @@ func TestFilesystem_Blocks_Symlinks(t *testing.T) {
|
||||
g.It("cannot copy a file symlinked outside the directory root", func() {
|
||||
err := fs.Copy("symlinked.txt")
|
||||
g.Assert(err).IsNotNil()
|
||||
g.Assert(IsPathError(err)).IsTrue()
|
||||
g.Assert(errors.Is(err, ufs.ErrNotExist)).IsTrue("err is not ErrNotExist")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -170,14 +157,14 @@ func TestFilesystem_Blocks_Symlinks(t *testing.T) {
|
||||
err := fs.Delete("symlinked.txt")
|
||||
g.Assert(err).IsNil()
|
||||
|
||||
_, err = os.Stat(filepath.Join(fs.rootPath, "../malicious.txt"))
|
||||
_, err = os.Stat(filepath.Join(rfs.root, "malicious.txt"))
|
||||
g.Assert(err).IsNil()
|
||||
|
||||
_, err = os.Stat(filepath.Join(fs.rootPath, "symlinked.txt"))
|
||||
_, err = rfs.StatServerFile("symlinked.txt")
|
||||
g.Assert(err).IsNotNil()
|
||||
g.Assert(errors.Is(err, os.ErrNotExist)).IsTrue()
|
||||
g.Assert(errors.Is(err, ufs.ErrNotExist)).IsTrue("err is not ErrNotExist")
|
||||
})
|
||||
})
|
||||
|
||||
fs.reset()
|
||||
_ = fs.TruncateRootDirectory()
|
||||
}
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
package filesystem
|
||||
|
||||
import (
|
||||
"os"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"emperror.dev/errors"
|
||||
"github.com/gabriel-vasile/mimetype"
|
||||
"github.com/goccy/go-json"
|
||||
|
||||
"github.com/pterodactyl/wings/internal/ufs"
|
||||
)
|
||||
|
||||
type Stat struct {
|
||||
os.FileInfo
|
||||
ufs.FileInfo
|
||||
Mimetype string
|
||||
}
|
||||
|
||||
@@ -32,38 +33,31 @@ func (s *Stat) MarshalJSON() ([]byte, error) {
|
||||
Created: s.CTime().Format(time.RFC3339),
|
||||
Modified: s.ModTime().Format(time.RFC3339),
|
||||
Mode: s.Mode().String(),
|
||||
// Using `&os.ModePerm` on the file's mode will cause the mode to only have the permission values, and nothing else.
|
||||
ModeBits: strconv.FormatUint(uint64(s.Mode()&os.ModePerm), 8),
|
||||
// Using `&ModePerm` on the file's mode will cause the mode to only have the permission values, and nothing else.
|
||||
ModeBits: strconv.FormatUint(uint64(s.Mode()&ufs.ModePerm), 8),
|
||||
Size: s.Size(),
|
||||
Directory: s.IsDir(),
|
||||
File: !s.IsDir(),
|
||||
Symlink: s.Mode().Type()&os.ModeSymlink != 0,
|
||||
Symlink: s.Mode().Type()&ufs.ModeSymlink != 0,
|
||||
Mime: s.Mimetype,
|
||||
})
|
||||
}
|
||||
|
||||
// Stat stats a file or folder and returns the base stat object from go along
|
||||
// with the MIME data that can be used for editing files.
|
||||
func (fs *Filesystem) Stat(p string) (Stat, error) {
|
||||
p = normalize(p)
|
||||
s, err := fs.root.Stat(p)
|
||||
func statFromFile(f ufs.File) (Stat, error) {
|
||||
s, err := f.Stat()
|
||||
if err != nil {
|
||||
return Stat{}, errors.Wrap(err, "server/filesystem: stat: failed to stat file")
|
||||
return Stat{}, err
|
||||
}
|
||||
|
||||
var m *mimetype.MIME
|
||||
if !s.IsDir() {
|
||||
f, err := fs.root.Open(p)
|
||||
if err != nil {
|
||||
return Stat{}, errors.Wrap(err, "server/filesystem: stat: failed to open file")
|
||||
}
|
||||
defer f.Close()
|
||||
m, err = mimetype.DetectReader(f)
|
||||
if err != nil {
|
||||
return Stat{}, errors.Wrap(err, "server/filesystem: stat: failed to detect mimetype")
|
||||
return Stat{}, err
|
||||
}
|
||||
if _, err := f.Seek(0, io.SeekStart); err != nil {
|
||||
return Stat{}, err
|
||||
}
|
||||
}
|
||||
|
||||
st := Stat{
|
||||
FileInfo: s,
|
||||
Mimetype: "inode/directory",
|
||||
@@ -71,14 +65,20 @@ func (fs *Filesystem) Stat(p string) (Stat, error) {
|
||||
if m != nil {
|
||||
st.Mimetype = m.String()
|
||||
}
|
||||
|
||||
return st, nil
|
||||
}
|
||||
|
||||
func (fs *Filesystem) Stat2(p string) (os.FileInfo, error) {
|
||||
st, err := fs.root.Stat(normalize(p))
|
||||
// Stat stats a file or folder and returns the base stat object from go along
|
||||
// with the MIME data that can be used for editing files.
|
||||
func (fs *Filesystem) Stat(p string) (Stat, error) {
|
||||
f, err := fs.unixFS.Open(p)
|
||||
if err != nil {
|
||||
return st, errors.Wrap(err, "server/filesystem: stat2: failed to stat file")
|
||||
return Stat{}, err
|
||||
}
|
||||
defer f.Close()
|
||||
st, err := statFromFile(f)
|
||||
if err != nil {
|
||||
return Stat{}, err
|
||||
}
|
||||
return st, nil
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ package server
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"html/template"
|
||||
"io"
|
||||
@@ -218,30 +217,18 @@ func (ip *InstallationProcess) tempDir() string {
|
||||
// can be properly mounted into the installation container and then executed.
|
||||
func (ip *InstallationProcess) writeScriptToDisk() error {
|
||||
// Make sure the temp directory root exists before trying to make a directory within it. The
|
||||
// ioutil.TempDir call expects this base to exist, it won't create it for you.
|
||||
// os.TempDir call expects this base to exist, it won't create it for you.
|
||||
if err := os.MkdirAll(ip.tempDir(), 0o700); err != nil {
|
||||
return errors.WithMessage(err, "could not create temporary directory for install process")
|
||||
}
|
||||
|
||||
f, err := os.OpenFile(filepath.Join(ip.tempDir(), "install.sh"), os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0o644)
|
||||
if err != nil {
|
||||
return errors.WithMessage(err, "failed to write server installation script to disk before mount")
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
w := bufio.NewWriter(f)
|
||||
|
||||
scanner := bufio.NewScanner(bytes.NewReader([]byte(ip.Script.Script)))
|
||||
for scanner.Scan() {
|
||||
w.WriteString(scanner.Text() + "\n")
|
||||
}
|
||||
|
||||
if err := scanner.Err(); err != nil {
|
||||
if _, err := io.Copy(f, strings.NewReader(strings.ReplaceAll(ip.Script.Script, "\r\n", "\n"))); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
w.Flush()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -456,9 +443,7 @@ func (ip *InstallationProcess) Execute() (string, error) {
|
||||
// to trigger the reinstall of the server. It is possible the directory would
|
||||
// not exist when this runs if Wings boots with a missing directory and a user
|
||||
// triggers a reinstall before trying to start the server.
|
||||
// todo: verify, this might be wrong now that the FS requires the directory to
|
||||
// exist to boot it.
|
||||
if _, err := ip.Server.EnsureDataDirectoryExists(); err != nil {
|
||||
if err := ip.Server.EnsureDataDirectoryExists(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -195,18 +196,11 @@ func (m *Manager) InitServer(data remote.ServerConfigurationResponse) (*Server,
|
||||
return nil, errors.WithStackIf(err)
|
||||
}
|
||||
|
||||
d, err := s.EnsureDataDirectoryExists()
|
||||
s.fs, err = filesystem.New(filepath.Join(config.Get().System.Data, s.ID()), s.DiskSpace(), s.Config().Egg.FileDenylist)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, errors.WithStackIf(err)
|
||||
}
|
||||
|
||||
fs, err := filesystem.New(d, s.DiskSpace(), s.Config().Egg.FileDenylist)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s.fs = fs
|
||||
|
||||
// Right now we only support a Docker based environment, so I'm going to hard code
|
||||
// this logic in. When we're ready to support other environment we'll need to make
|
||||
// some modifications here, obviously.
|
||||
|
||||
@@ -29,34 +29,21 @@ func (s *Server) Mounts() []environment.Mount {
|
||||
},
|
||||
}
|
||||
|
||||
cfg := config.Get()
|
||||
|
||||
// Handle mounting a generated `/etc/passwd` if the feature is enabled.
|
||||
if cfg.System.Passwd.Enable {
|
||||
s.Log().WithFields(log.Fields{"source_path": cfg.System.Passwd.Directory}).Info("mouting generated /etc/{group,passwd} to workaround UID/GID issues")
|
||||
if passwd := config.Get().System.Passwd; passwd.Enable {
|
||||
s.Log().WithFields(log.Fields{"source_path": passwd.Directory}).Info("mouting generated /etc/{group,passwd} to workaround UID/GID issues")
|
||||
m = append(m, environment.Mount{
|
||||
Source: filepath.Join(cfg.System.Passwd.Directory, "group"),
|
||||
Source: filepath.Join(passwd.Directory, "group"),
|
||||
Target: "/etc/group",
|
||||
ReadOnly: true,
|
||||
})
|
||||
m = append(m, environment.Mount{
|
||||
Source: filepath.Join(cfg.System.Passwd.Directory, "passwd"),
|
||||
Source: filepath.Join(passwd.Directory, "passwd"),
|
||||
Target: "/etc/passwd",
|
||||
ReadOnly: true,
|
||||
})
|
||||
}
|
||||
|
||||
if cfg.System.MachineID.Enable {
|
||||
// Hytale wants a machine-id in order to encrypt tokens for the server.
|
||||
// So add a mount to `/etc/machine-id` to a source that contains the
|
||||
// server's UUID without any dashes.
|
||||
m = append(m, environment.Mount{
|
||||
Source: filepath.Join(cfg.System.MachineID.Directory, s.ID()),
|
||||
Target: "/etc/machine-id",
|
||||
ReadOnly: true,
|
||||
})
|
||||
}
|
||||
|
||||
// Also include any of this server's custom mounts when returning them.
|
||||
return append(m, s.customMounts()...)
|
||||
}
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
@@ -257,22 +255,10 @@ func (s *Server) ReadLogfile(len int) ([]string, error) {
|
||||
// for the server is setup, and that all of the necessary files are created.
|
||||
func (s *Server) CreateEnvironment() error {
|
||||
// Ensure the data directory exists before getting too far through this process.
|
||||
if _, err := s.EnsureDataDirectoryExists(); err != nil {
|
||||
if err := s.EnsureDataDirectoryExists(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cfg := *config.Get()
|
||||
if cfg.System.MachineID.Enable {
|
||||
// Hytale wants a machine-id in order to encrypt tokens for the server. So
|
||||
// write a machine-id file for the server that contains the server's UUID
|
||||
// without any dashes.
|
||||
p := filepath.Join(cfg.System.MachineID.Directory, s.ID())
|
||||
machineID := append(bytes.ReplaceAll([]byte(s.ID()), []byte{'-'}, []byte{}), '\n')
|
||||
if err := os.WriteFile(p, machineID, 0o644); err != nil {
|
||||
return errors.Wrap(err, "server: failed to write machine-id to disk")
|
||||
}
|
||||
}
|
||||
|
||||
return s.Environment.Create()
|
||||
}
|
||||
|
||||
@@ -295,25 +281,21 @@ func (s *Server) Filesystem() *filesystem.Filesystem {
|
||||
|
||||
// EnsureDataDirectoryExists ensures that the data directory for the server
|
||||
// instance exists.
|
||||
func (s *Server) EnsureDataDirectoryExists() (string, error) {
|
||||
c := *config.Get()
|
||||
path := filepath.Join(c.System.Data, s.ID())
|
||||
|
||||
if _, err := os.Lstat(path); err != nil {
|
||||
if !errors.Is(err, os.ErrNotExist) {
|
||||
return path, errors.Wrap(err, "server: failed to stat server root directory")
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(path, 0o700); err != nil {
|
||||
return path, errors.Wrap(err, "server: failed to create root directory")
|
||||
}
|
||||
|
||||
if err := os.Chown(path, c.System.User.Uid, c.System.User.Gid); err != nil {
|
||||
return path, errors.Wrap(err, "server: failed to chown newly created root directory")
|
||||
func (s *Server) EnsureDataDirectoryExists() error {
|
||||
if _, err := os.Lstat(s.fs.Path()); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
s.Log().Debug("server: creating root directory and setting permissions")
|
||||
if err := os.MkdirAll(s.fs.Path(), 0o700); err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
if err := s.fs.Chown("/"); err != nil {
|
||||
s.Log().WithField("error", err).Warn("server: failed to chown server data directory")
|
||||
}
|
||||
} else {
|
||||
return errors.WrapIf(err, "server: failed to stat server root directory")
|
||||
}
|
||||
}
|
||||
|
||||
return path, nil
|
||||
return nil
|
||||
}
|
||||
|
||||
// OnStateChange sets the state of the server internally. This function handles crash detection as
|
||||
|
||||
@@ -4,16 +4,14 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
|
||||
"emperror.dev/errors"
|
||||
"github.com/pterodactyl/wings/internal/progress"
|
||||
"github.com/pterodactyl/wings/server/filesystem"
|
||||
)
|
||||
|
||||
// Archive returns an archive that can be used to stream the contents of the
|
||||
// contents of a server.
|
||||
func (t *Transfer) Archive(r *os.Root) (*Archive, error) {
|
||||
func (t *Transfer) Archive() (*Archive, error) {
|
||||
if t.archive == nil {
|
||||
// Get the disk usage of the server (used to calculate the progress of the archive process)
|
||||
rawSize, err := t.Server.Filesystem().DiskUsage(true)
|
||||
@@ -21,12 +19,8 @@ func (t *Transfer) Archive(r *os.Root) (*Archive, error) {
|
||||
return nil, fmt.Errorf("transfer: failed to get server disk usage: %w", err)
|
||||
}
|
||||
|
||||
a, err := filesystem.NewArchive(r, "/", filesystem.WithProgress(progress.NewProgress(uint64(rawSize))))
|
||||
if err != nil {
|
||||
_ = r.Close()
|
||||
return nil, errors.WrapIf(err, "server/transfer: failed to create archive")
|
||||
}
|
||||
t.archive = &Archive{archive: a}
|
||||
// Create a new archive instance and assign it to the transfer.
|
||||
t.archive = NewArchive(t, uint64(rawSize))
|
||||
}
|
||||
|
||||
return t.archive, nil
|
||||
@@ -37,6 +31,16 @@ type Archive struct {
|
||||
archive *filesystem.Archive
|
||||
}
|
||||
|
||||
// NewArchive returns a new archive associated with the given transfer.
|
||||
func NewArchive(t *Transfer, size uint64) *Archive {
|
||||
return &Archive{
|
||||
archive: &filesystem.Archive{
|
||||
Filesystem: t.Server.Filesystem(),
|
||||
Progress: progress.NewProgress(size),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Stream returns a reader that can be used to stream the contents of the archive.
|
||||
func (a *Archive) Stream(ctx context.Context, w io.Writer) error {
|
||||
return a.archive.Stream(ctx, w)
|
||||
@@ -44,5 +48,5 @@ func (a *Archive) Stream(ctx context.Context, w io.Writer) error {
|
||||
|
||||
// Progress returns the current progress of the archive.
|
||||
func (a *Archive) Progress() *progress.Progress {
|
||||
return a.archive.Progress()
|
||||
return a.archive.Progress
|
||||
}
|
||||
|
||||
@@ -4,14 +4,13 @@ import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"emperror.dev/errors"
|
||||
"github.com/pterodactyl/wings/internal/progress"
|
||||
)
|
||||
|
||||
@@ -24,13 +23,7 @@ func (t *Transfer) PushArchiveToTarget(url, token string) ([]byte, error) {
|
||||
t.SendMessage("Preparing to stream server data to destination...")
|
||||
t.SetStatus(StatusProcessing)
|
||||
|
||||
r, err := os.OpenRoot(t.Server.Filesystem().Path())
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "server/transfer: failed to open root directory")
|
||||
}
|
||||
defer r.Close()
|
||||
|
||||
a, err := t.Archive(r)
|
||||
a, err := t.Archive()
|
||||
if err != nil {
|
||||
t.Error(err, "Failed to get archive for transfer.")
|
||||
return nil, errors.New("failed to get archive for transfer")
|
||||
|
||||
@@ -121,7 +121,7 @@ func (h *Handler) Filewrite(request *sftp.Request) (io.WriterAt, error) {
|
||||
if !h.can(permission) {
|
||||
return nil, sftp.ErrSSHFxPermissionDenied
|
||||
}
|
||||
f, err := h.fs.Touch(request.Filepath, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0o644)
|
||||
f, err := h.fs.Touch(request.Filepath, os.O_RDWR|os.O_TRUNC)
|
||||
if err != nil {
|
||||
l.WithField("flags", request.Flags).WithField("error", err).Error("failed to open existing file on system")
|
||||
return nil, sftp.ErrSSHFxFailure
|
||||
@@ -213,6 +213,8 @@ func (h *Handler) Filecmd(request *sftp.Request) error {
|
||||
}
|
||||
h.events.MustLog(server.ActivitySftpCreateDirectory, FileAction{Entity: request.Filepath})
|
||||
break
|
||||
// Support creating symlinks between files. The source and target must resolve within
|
||||
// the server home directory.
|
||||
case "Symlink":
|
||||
if !h.can(PermissionFileCreate) {
|
||||
return sftp.ErrSSHFxPermissionDenied
|
||||
@@ -263,23 +265,14 @@ func (h *Handler) Filelist(request *sftp.Request) (sftp.ListerAt, error) {
|
||||
|
||||
switch request.Method {
|
||||
case "List":
|
||||
d, err := h.fs.ReadDir(request.Filepath)
|
||||
entries, err := h.fs.ReadDirStat(request.Filepath)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil, sftp.ErrSSHFxNoSuchFile
|
||||
}
|
||||
h.logger.WithField("source", request.Filepath).WithField("error", err).Error("error while listing directory")
|
||||
return nil, sftp.ErrSSHFxFailure
|
||||
}
|
||||
files := make([]os.FileInfo, len(d))
|
||||
for _, entry := range d {
|
||||
if i, err := entry.Info(); err == nil {
|
||||
files = append(files, i)
|
||||
}
|
||||
}
|
||||
return ListerAt(files), nil
|
||||
return ListerAt(entries), nil
|
||||
case "Stat":
|
||||
st, err := h.fs.Stat2(request.Filepath)
|
||||
st, err := h.fs.Stat(request.Filepath)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil, sftp.ErrSSHFxNoSuchFile
|
||||
@@ -287,7 +280,7 @@ func (h *Handler) Filelist(request *sftp.Request) (sftp.ListerAt, error) {
|
||||
h.logger.WithField("source", request.Filepath).WithField("error", err).Error("error performing stat on file")
|
||||
return nil, sftp.ErrSSHFxFailure
|
||||
}
|
||||
return ListerAt([]os.FileInfo{st}), nil
|
||||
return ListerAt([]os.FileInfo{st.FileInfo}), nil
|
||||
default:
|
||||
return nil, sftp.ErrSSHFxOpUnsupported
|
||||
}
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
package system
|
||||
|
||||
var Version = "develop"
|
||||
var Version = "v1.12.0"
|
||||
|
||||
Reference in New Issue
Block a user