mirror of
https://github.com/octoleo/restic.git
synced 2024-11-26 06:46:34 +00:00
Merge pull request #259 from klauspost/windows-support-rebased
Add Windows support
This commit is contained in:
commit
a820719c07
@ -8,6 +8,7 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"sync"
|
||||
|
||||
"github.com/restic/restic/backend"
|
||||
)
|
||||
@ -15,7 +16,9 @@ import (
|
||||
var ErrWrongData = errors.New("wrong data returned by backend, checksum does not match")
|
||||
|
||||
type Local struct {
|
||||
p string
|
||||
p string
|
||||
mu sync.Mutex
|
||||
open map[string][]*os.File // Contains open files. Guarded by 'mu'.
|
||||
}
|
||||
|
||||
// Open opens the local backend at dir.
|
||||
@ -37,7 +40,7 @@ func Open(dir string) (*Local, error) {
|
||||
}
|
||||
}
|
||||
|
||||
return &Local{p: dir}, nil
|
||||
return &Local{p: dir, open: make(map[string][]*os.File)}, nil
|
||||
}
|
||||
|
||||
// Create creates all the necessary files and directories for a new local
|
||||
@ -143,7 +146,7 @@ func (lb *localBlob) Finalize(t backend.Type, name string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
return os.Chmod(f, fi.Mode()&os.FileMode(^uint32(0222)))
|
||||
return setNewFileMode(f, fi)
|
||||
}
|
||||
|
||||
// Create creates a new Blob. The data is available only after Finalize()
|
||||
@ -162,6 +165,11 @@ func (b *Local) Create() (backend.Blob, error) {
|
||||
basedir: b.p,
|
||||
}
|
||||
|
||||
b.mu.Lock()
|
||||
open, _ := b.open["blobs"]
|
||||
b.open["blobs"] = append(open, file)
|
||||
b.mu.Unlock()
|
||||
|
||||
return &blob, nil
|
||||
}
|
||||
|
||||
@ -198,7 +206,15 @@ func dirname(base string, t backend.Type, name string) string {
|
||||
// Get returns a reader that yields the content stored under the given
|
||||
// name. The reader should be closed after draining it.
|
||||
func (b *Local) Get(t backend.Type, name string) (io.ReadCloser, error) {
|
||||
return os.Open(filename(b.p, t, name))
|
||||
file, err := os.Open(filename(b.p, t, name))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
b.mu.Lock()
|
||||
open, _ := b.open[filename(b.p, t, name)]
|
||||
b.open[filename(b.p, t, name)] = append(open, file)
|
||||
b.mu.Unlock()
|
||||
return file, nil
|
||||
}
|
||||
|
||||
// GetReader returns an io.ReadCloser for the Blob with the given name of
|
||||
@ -209,6 +225,11 @@ func (b *Local) GetReader(t backend.Type, name string, offset, length uint) (io.
|
||||
return nil, err
|
||||
}
|
||||
|
||||
b.mu.Lock()
|
||||
open, _ := b.open[filename(b.p, t, name)]
|
||||
b.open[filename(b.p, t, name)] = append(open, f)
|
||||
b.mu.Unlock()
|
||||
|
||||
_, err = f.Seek(int64(offset), 0)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@ -236,7 +257,17 @@ func (b *Local) Test(t backend.Type, name string) (bool, error) {
|
||||
|
||||
// Remove removes the blob with the given name and type.
|
||||
func (b *Local) Remove(t backend.Type, name string) error {
|
||||
return os.Remove(filename(b.p, t, name))
|
||||
// close all open files we may have.
|
||||
fn := filename(b.p, t, name)
|
||||
b.mu.Lock()
|
||||
open, _ := b.open[fn]
|
||||
for _, file := range open {
|
||||
file.Close()
|
||||
}
|
||||
b.open[fn] = nil
|
||||
b.mu.Unlock()
|
||||
|
||||
return os.Remove(fn)
|
||||
}
|
||||
|
||||
// List returns a channel that yields all names of blobs of type t. A
|
||||
@ -283,7 +314,22 @@ func (b *Local) List(t backend.Type, done <-chan struct{}) <-chan string {
|
||||
}
|
||||
|
||||
// Delete removes the repository and all files.
|
||||
func (b *Local) Delete() error { return os.RemoveAll(b.p) }
|
||||
func (b *Local) Delete() error {
|
||||
b.Close()
|
||||
return os.RemoveAll(b.p)
|
||||
}
|
||||
|
||||
// Close does nothing
|
||||
func (b *Local) Close() error { return nil }
|
||||
// Close closes all open files.
|
||||
// They may have been closed already,
|
||||
// so we ignore all errors.
|
||||
func (b *Local) Close() error {
|
||||
b.mu.Lock()
|
||||
for _, open := range b.open {
|
||||
for _, file := range open {
|
||||
file.Close()
|
||||
}
|
||||
}
|
||||
b.open = make(map[string][]*os.File)
|
||||
b.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
12
backend/local/local_unix.go
Normal file
12
backend/local/local_unix.go
Normal file
@ -0,0 +1,12 @@
|
||||
// +build !windows
|
||||
|
||||
package local
|
||||
|
||||
import (
|
||||
"os"
|
||||
)
|
||||
|
||||
// set file to readonly
|
||||
func setNewFileMode(f string, fi os.FileInfo) error {
|
||||
return os.Chmod(f, fi.Mode()&os.FileMode(^uint32(0222)))
|
||||
}
|
12
backend/local/local_windows.go
Normal file
12
backend/local/local_windows.go
Normal file
@ -0,0 +1,12 @@
|
||||
package local
|
||||
|
||||
import (
|
||||
"os"
|
||||
)
|
||||
|
||||
// We don't modify read-only on windows,
|
||||
// since it will make us unable to delete the file,
|
||||
// and this isn't common practice on this platform.
|
||||
func setNewFileMode(f string, fi os.FileInfo) error {
|
||||
return nil
|
||||
}
|
@ -10,7 +10,6 @@ import (
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"syscall"
|
||||
|
||||
"github.com/juju/errors"
|
||||
"github.com/pkg/sftp"
|
||||
@ -37,7 +36,7 @@ func startClient(program string, args ...string) (*SFTP, error) {
|
||||
cmd.Stderr = os.Stderr
|
||||
|
||||
// ignore signals sent to the parent (e.g. SIGINT)
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
|
||||
cmd.SysProcAttr = ignoreSigIntProcAttr()
|
||||
|
||||
// get stdin and stdout
|
||||
wr, err := cmd.StdinPipe()
|
||||
|
13
backend/sftp/sftp_unix.go
Normal file
13
backend/sftp/sftp_unix.go
Normal file
@ -0,0 +1,13 @@
|
||||
// +build !windows
|
||||
|
||||
package sftp
|
||||
|
||||
import (
|
||||
"syscall"
|
||||
)
|
||||
|
||||
// ignoreSigIntProcAttr returns a syscall.SysProcAttr that
|
||||
// disables SIGINT on parent.
|
||||
func ignoreSigIntProcAttr() *syscall.SysProcAttr {
|
||||
return &syscall.SysProcAttr{Setsid: true}
|
||||
}
|
11
backend/sftp/sftp_windows.go
Normal file
11
backend/sftp/sftp_windows.go
Normal file
@ -0,0 +1,11 @@
|
||||
package sftp
|
||||
|
||||
import (
|
||||
"syscall"
|
||||
)
|
||||
|
||||
// ignoreSigIntProcAttr returns a default syscall.SysProcAttr
|
||||
// on Windows.
|
||||
func ignoreSigIntProcAttr() *syscall.SysProcAttr {
|
||||
return &syscall.SysProcAttr{}
|
||||
}
|
9
build.go
9
build.go
@ -10,6 +10,7 @@ import (
|
||||
"os/exec"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
@ -258,11 +259,17 @@ func main() {
|
||||
|
||||
version := getVersion()
|
||||
compileTime := time.Now().Format(timeFormat)
|
||||
output := "restic"
|
||||
if runtime.GOOS == "windows" {
|
||||
output = "restic.exe"
|
||||
}
|
||||
|
||||
args := []string{
|
||||
"-tags", strings.Join(buildTags, " "),
|
||||
"-ldflags", fmt.Sprintf(`-s -X main.version %q -X main.compiledAt %q`, version, compileTime),
|
||||
"-o", "restic", "github.com/restic/restic/cmd/restic",
|
||||
"-o", output, "github.com/restic/restic/cmd/restic",
|
||||
}
|
||||
|
||||
err = build(gopath, args...)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "build failed: %v\n", err)
|
||||
|
31
cache.go
31
cache.go
@ -6,6 +6,7 @@ import (
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
"github.com/restic/restic/backend"
|
||||
@ -212,10 +213,40 @@ func getCacheDir() (string, error) {
|
||||
if dir := os.Getenv("RESTIC_CACHE"); dir != "" {
|
||||
return dir, nil
|
||||
}
|
||||
if runtime.GOOS == "windows" {
|
||||
return getWindowsCacheDir()
|
||||
}
|
||||
|
||||
return getXDGCacheDir()
|
||||
}
|
||||
|
||||
// getWindowsCacheDir will return %APPDATA%\restic or create
|
||||
// a folder in the temporary folder called "restic".
|
||||
func getWindowsCacheDir() (string, error) {
|
||||
cachedir := os.Getenv("APPDATA")
|
||||
if cachedir == "" {
|
||||
cachedir = os.TempDir()
|
||||
}
|
||||
cachedir = filepath.Join(cachedir, "restic")
|
||||
fi, err := os.Stat(cachedir)
|
||||
|
||||
if os.IsNotExist(err) {
|
||||
err = os.MkdirAll(cachedir, 0700)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if !fi.IsDir() {
|
||||
return "", fmt.Errorf("cache dir %v is not a directory", cachedir)
|
||||
}
|
||||
return cachedir, nil
|
||||
}
|
||||
|
||||
// getXDGCacheDir returns the cache directory according to XDG basedir spec, see
|
||||
// http://standards.freedesktop.org/basedir-spec/basedir-spec-latest.html
|
||||
func getXDGCacheDir() (string, error) {
|
||||
|
@ -1,4 +1,5 @@
|
||||
// +build !openbsd
|
||||
// +build !windows
|
||||
|
||||
package main
|
||||
|
||||
|
@ -132,7 +132,14 @@ func (o GlobalOptions) OpenRepository() (*repository.Repository, error) {
|
||||
// * s3://region/bucket -> amazon s3 bucket
|
||||
// * sftp://user@host/foo/bar -> remote sftp repository on host for user at path foo/bar
|
||||
// * sftp://host//tmp/backup -> remote sftp repository on host at path /tmp/backup
|
||||
// * c:\temp -> local repository at c:\temp - the path must exist
|
||||
func open(u string) (backend.Backend, error) {
|
||||
// check if the url is a directory that exists
|
||||
fi, err := os.Stat(u)
|
||||
if err == nil && fi.IsDir() {
|
||||
return local.Open(u)
|
||||
}
|
||||
|
||||
url, err := url.Parse(u)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@ -140,7 +147,13 @@ func open(u string) (backend.Backend, error) {
|
||||
|
||||
if url.Scheme == "" {
|
||||
return local.Open(url.Path)
|
||||
} else if url.Scheme == "s3" {
|
||||
}
|
||||
|
||||
if len(url.Path) < 1 {
|
||||
return nil, fmt.Errorf("unable to parse url %v", url)
|
||||
}
|
||||
|
||||
if url.Scheme == "s3" {
|
||||
return s3.Open(url.Host, url.Path[1:])
|
||||
}
|
||||
|
||||
@ -156,6 +169,12 @@ func open(u string) (backend.Backend, error) {
|
||||
|
||||
// Create the backend specified by URI.
|
||||
func create(u string) (backend.Backend, error) {
|
||||
// check if the url is a directory that exists
|
||||
fi, err := os.Stat(u)
|
||||
if err == nil && fi.IsDir() {
|
||||
return local.Create(u)
|
||||
}
|
||||
|
||||
url, err := url.Parse(u)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@ -163,7 +182,13 @@ func create(u string) (backend.Backend, error) {
|
||||
|
||||
if url.Scheme == "" {
|
||||
return local.Create(url.Path)
|
||||
} else if url.Scheme == "s3" {
|
||||
}
|
||||
|
||||
if len(url.Path) < 1 {
|
||||
return nil, fmt.Errorf("unable to parse url %v", url)
|
||||
}
|
||||
|
||||
if url.Scheme == "s3" {
|
||||
return s3.Open(url.Host, url.Path[1:])
|
||||
}
|
||||
|
||||
|
@ -1,4 +1,5 @@
|
||||
// +build !openbsd
|
||||
// +build !windows
|
||||
|
||||
package main
|
||||
|
||||
|
@ -6,7 +6,6 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"syscall"
|
||||
"testing"
|
||||
|
||||
. "github.com/restic/restic/test"
|
||||
@ -70,38 +69,6 @@ func sameModTime(fi1, fi2 os.FileInfo) bool {
|
||||
return fi1.ModTime() == fi2.ModTime()
|
||||
}
|
||||
|
||||
func (e *dirEntry) equals(other *dirEntry) bool {
|
||||
if e.path != other.path {
|
||||
fmt.Fprintf(os.Stderr, "%v: path does not match (%v != %v)\n", e.path, e.path, other.path)
|
||||
return false
|
||||
}
|
||||
|
||||
if e.fi.Mode() != other.fi.Mode() {
|
||||
fmt.Fprintf(os.Stderr, "%v: mode does not match (%v != %v)\n", e.path, e.fi.Mode(), other.fi.Mode())
|
||||
return false
|
||||
}
|
||||
|
||||
if !sameModTime(e.fi, other.fi) {
|
||||
fmt.Fprintf(os.Stderr, "%v: ModTime does not match (%v != %v)\n", e.path, e.fi.ModTime(), other.fi.ModTime())
|
||||
return false
|
||||
}
|
||||
|
||||
stat, _ := e.fi.Sys().(*syscall.Stat_t)
|
||||
stat2, _ := other.fi.Sys().(*syscall.Stat_t)
|
||||
|
||||
if stat.Uid != stat2.Uid {
|
||||
fmt.Fprintf(os.Stderr, "%v: UID does not match (%v != %v)\n", e.path, stat.Uid, stat2.Uid)
|
||||
return false
|
||||
}
|
||||
|
||||
if stat.Gid != stat2.Gid {
|
||||
fmt.Fprintf(os.Stderr, "%v: GID does not match (%v != %v)\n", e.path, stat.Gid, stat2.Gid)
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// directoriesEqualContents checks if both directories contain exactly the same
|
||||
// contents.
|
||||
func directoriesEqualContents(dir1, dir2 string) bool {
|
||||
@ -237,6 +204,8 @@ func withTestEnvironment(t testing.TB, f func(*testEnvironment, GlobalOptions))
|
||||
}
|
||||
|
||||
OK(t, os.MkdirAll(env.testdata, 0700))
|
||||
OK(t, os.MkdirAll(env.cache, 0700))
|
||||
OK(t, os.MkdirAll(env.repo, 0700))
|
||||
|
||||
f(&env, configureRestic(t, env.cache, env.repo))
|
||||
|
||||
|
41
cmd/restic/integration_helpers_unix_test.go
Normal file
41
cmd/restic/integration_helpers_unix_test.go
Normal file
@ -0,0 +1,41 @@
|
||||
//+build !windows
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
func (e *dirEntry) equals(other *dirEntry) bool {
|
||||
if e.path != other.path {
|
||||
fmt.Fprintf(os.Stderr, "%v: path does not match (%v != %v)\n", e.path, e.path, other.path)
|
||||
return false
|
||||
}
|
||||
|
||||
if e.fi.Mode() != other.fi.Mode() {
|
||||
fmt.Fprintf(os.Stderr, "%v: mode does not match (%v != %v)\n", e.path, e.fi.Mode(), other.fi.Mode())
|
||||
return false
|
||||
}
|
||||
|
||||
if !sameModTime(e.fi, other.fi) {
|
||||
fmt.Fprintf(os.Stderr, "%v: ModTime does not match (%v != %v)\n", e.path, e.fi.ModTime(), other.fi.ModTime())
|
||||
return false
|
||||
}
|
||||
|
||||
stat, _ := e.fi.Sys().(*syscall.Stat_t)
|
||||
stat2, _ := other.fi.Sys().(*syscall.Stat_t)
|
||||
|
||||
if stat.Uid != stat2.Uid {
|
||||
fmt.Fprintf(os.Stderr, "%v: UID does not match (%v != %v)\n", e.path, stat.Uid, stat2.Uid)
|
||||
return false
|
||||
}
|
||||
|
||||
if stat.Gid != stat2.Gid {
|
||||
fmt.Fprintf(os.Stderr, "%v: GID does not match (%v != %v)\n", e.path, stat.Gid, stat2.Gid)
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
27
cmd/restic/integration_helpers_windows_test.go
Normal file
27
cmd/restic/integration_helpers_windows_test.go
Normal file
@ -0,0 +1,27 @@
|
||||
//+build windows
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
)
|
||||
|
||||
func (e *dirEntry) equals(other *dirEntry) bool {
|
||||
if e.path != other.path {
|
||||
fmt.Fprintf(os.Stderr, "%v: path does not match (%v != %v)\n", e.path, e.path, other.path)
|
||||
return false
|
||||
}
|
||||
|
||||
if e.fi.Mode() != other.fi.Mode() {
|
||||
fmt.Fprintf(os.Stderr, "%v: mode does not match (%v != %v)\n", e.path, e.fi.Mode(), other.fi.Mode())
|
||||
return false
|
||||
}
|
||||
|
||||
if !sameModTime(e.fi, other.fi) {
|
||||
fmt.Fprintf(os.Stderr, "%v: ModTime does not match (%v != %v)\n", e.path, e.fi.ModTime(), other.fi.ModTime())
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
@ -25,8 +25,14 @@ func Match(pattern, str string) (matched bool, err error) {
|
||||
return false, ErrBadString
|
||||
}
|
||||
|
||||
patterns := strings.Split(pattern, string(filepath.Separator))
|
||||
strs := strings.Split(str, string(filepath.Separator))
|
||||
// convert file path separator to '/'
|
||||
if filepath.Separator != '/' {
|
||||
pattern = strings.Replace(pattern, string(filepath.Separator), "/", -1)
|
||||
str = strings.Replace(str, string(filepath.Separator), "/", -1)
|
||||
}
|
||||
|
||||
patterns := strings.Split(pattern, "/")
|
||||
strs := strings.Split(str, "/")
|
||||
|
||||
return match(patterns, strs)
|
||||
}
|
||||
|
@ -5,6 +5,8 @@ import (
|
||||
"compress/bzip2"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/restic/restic/filter"
|
||||
@ -71,20 +73,40 @@ var matchTests = []struct {
|
||||
{"foo/**/bar", "/home/user/foo/x/y/bar/main.go", true},
|
||||
{"user/**/important*", "/home/user/work/x/y/hidden/x", false},
|
||||
{"user/**/hidden*/**/c", "/home/user/work/x/y/hidden/z/a/b/c", true},
|
||||
{"c:/foo/*test.*", "c:/foo/bar/test.go", false},
|
||||
{"c:/foo/*/test.*", "c:/foo/bar/test.go", true},
|
||||
{"c:/foo/*/bar/test.*", "c:/foo/bar/test.go", false},
|
||||
}
|
||||
|
||||
func testpattern(t *testing.T, pattern, path string, shouldMatch bool) {
|
||||
match, err := filter.Match(pattern, path)
|
||||
if err != nil {
|
||||
t.Errorf("test pattern %q failed: expected no error for path %q, but error returned: %v",
|
||||
pattern, path, err)
|
||||
}
|
||||
|
||||
if match != shouldMatch {
|
||||
t.Errorf("test: filter.Match(%q, %q): expected %v, got %v",
|
||||
pattern, path, shouldMatch, match)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatch(t *testing.T) {
|
||||
for i, test := range matchTests {
|
||||
match, err := filter.Match(test.pattern, test.path)
|
||||
if err != nil {
|
||||
t.Errorf("test %d failed: expected no error for pattern %q, but error returned: %v",
|
||||
i, test.pattern, err)
|
||||
continue
|
||||
}
|
||||
for _, test := range matchTests {
|
||||
testpattern(t, test.pattern, test.path, test.match)
|
||||
|
||||
if match != test.match {
|
||||
t.Errorf("test %d: filter.Match(%q, %q): expected %v, got %v",
|
||||
i, test.pattern, test.path, test.match, match)
|
||||
// Test with native path separator
|
||||
if filepath.Separator != '/' {
|
||||
// Test with pattern as native
|
||||
pattern := strings.Replace(test.pattern, "/", string(filepath.Separator), -1)
|
||||
testpattern(t, pattern, test.path, test.match)
|
||||
|
||||
// Test with path as native
|
||||
path := strings.Replace(test.path, "/", string(filepath.Separator), -1)
|
||||
testpattern(t, test.pattern, path, test.match)
|
||||
|
||||
// Test with both pattern and path as native
|
||||
testpattern(t, pattern, path, test.match)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
31
lock.go
31
lock.go
@ -5,7 +5,6 @@ import (
|
||||
"os"
|
||||
"os/signal"
|
||||
"os/user"
|
||||
"strconv"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
@ -116,19 +115,8 @@ func (l *Lock) fillUserInfo() error {
|
||||
}
|
||||
l.Username = usr.Username
|
||||
|
||||
uid, err := strconv.ParseInt(usr.Uid, 10, 32)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
l.UID = uint32(uid)
|
||||
|
||||
gid, err := strconv.ParseInt(usr.Gid, 10, 32)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
l.GID = uint32(gid)
|
||||
|
||||
return nil
|
||||
l.UID, l.GID, err = uidGidInt(*usr)
|
||||
return err
|
||||
}
|
||||
|
||||
// checkForOtherLocks looks for other locks that currently exist in the repository.
|
||||
@ -206,17 +194,10 @@ func (l *Lock) Stale() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
proc, err := os.FindProcess(l.PID)
|
||||
defer proc.Release()
|
||||
if err != nil {
|
||||
debug.Log("Lock.Stale", "error searching for process %d: %v\n", l.PID, err)
|
||||
return true
|
||||
}
|
||||
|
||||
debug.Log("Lock.Stale", "sending SIGHUP to process %d\n", l.PID)
|
||||
err = proc.Signal(syscall.SIGHUP)
|
||||
if err != nil {
|
||||
debug.Log("Lock.Stale", "signal error: %v, lock is probably stale\n", err)
|
||||
// check if we can reach the process retaining the lock
|
||||
exists := l.processExists()
|
||||
if !exists {
|
||||
debug.Log("Lock.Stale", "could not reach process, %d, lock is probably stale\n", l.PID)
|
||||
return true
|
||||
}
|
||||
|
||||
|
@ -124,7 +124,7 @@ var staleLockTests = []struct {
|
||||
{
|
||||
timestamp: time.Now(),
|
||||
stale: true,
|
||||
pid: os.Getpid() + 500,
|
||||
pid: os.Getpid() + 500000,
|
||||
},
|
||||
}
|
||||
|
||||
@ -158,7 +158,7 @@ func TestLockWithStaleLock(t *testing.T) {
|
||||
id2, err := createFakeLock(repo, time.Now().Add(-time.Minute), os.Getpid())
|
||||
OK(t, err)
|
||||
|
||||
id3, err := createFakeLock(repo, time.Now().Add(-time.Minute), os.Getpid()+500)
|
||||
id3, err := createFakeLock(repo, time.Now().Add(-time.Minute), os.Getpid()+500000)
|
||||
OK(t, err)
|
||||
|
||||
OK(t, restic.RemoveStaleLocks(repo))
|
||||
|
48
lock_unix.go
Normal file
48
lock_unix.go
Normal file
@ -0,0 +1,48 @@
|
||||
// +build !windows
|
||||
|
||||
package restic
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/user"
|
||||
"strconv"
|
||||
"syscall"
|
||||
|
||||
"github.com/restic/restic/debug"
|
||||
)
|
||||
|
||||
// uidGidInt returns uid, gid of the user as a number.
|
||||
func uidGidInt(u user.User) (uid, gid uint32, err error) {
|
||||
var ui, gi int64
|
||||
ui, err = strconv.ParseInt(u.Uid, 10, 32)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
gi, err = strconv.ParseInt(u.Gid, 10, 32)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
uid = uint32(ui)
|
||||
gid = uint32(gi)
|
||||
return
|
||||
}
|
||||
|
||||
// checkProcess will check if the process retaining the lock
|
||||
// exists and responds to SIGHUP signal.
|
||||
// Returns true if the process exists and responds.
|
||||
func (l Lock) processExists() bool {
|
||||
proc, err := os.FindProcess(l.PID)
|
||||
if err != nil {
|
||||
debug.Log("Lock.Stale", "error searching for process %d: %v\n", l.PID, err)
|
||||
return false
|
||||
}
|
||||
defer proc.Release()
|
||||
|
||||
debug.Log("Lock.Stale", "sending SIGHUP to process %d\n", l.PID)
|
||||
err = proc.Signal(syscall.SIGHUP)
|
||||
if err != nil {
|
||||
debug.Log("Lock.Stale", "signal error: %v, lock is probably stale\n", err)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
25
lock_windows.go
Normal file
25
lock_windows.go
Normal file
@ -0,0 +1,25 @@
|
||||
package restic
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/user"
|
||||
|
||||
"github.com/restic/restic/debug"
|
||||
)
|
||||
|
||||
// uidGidInt always returns 0 on Windows, since uid isn't numbers
|
||||
func uidGidInt(u user.User) (uid, gid uint32, err error) {
|
||||
return 0, 0, nil
|
||||
}
|
||||
|
||||
// checkProcess will check if the process retaining the lock exists.
|
||||
// Returns true if the process exists.
|
||||
func (l Lock) processExists() bool {
|
||||
proc, err := os.FindProcess(l.PID)
|
||||
if err != nil {
|
||||
debug.Log("Lock.Stale", "error searching for process %d: %v\n", l.PID, err)
|
||||
return false
|
||||
}
|
||||
proc.Release()
|
||||
return true
|
||||
}
|
78
node.go
78
node.go
@ -15,6 +15,7 @@ import (
|
||||
"github.com/restic/restic/debug"
|
||||
"github.com/restic/restic/pack"
|
||||
"github.com/restic/restic/repository"
|
||||
"runtime"
|
||||
)
|
||||
|
||||
// Node is a file, directory or other item in a backup.
|
||||
@ -148,7 +149,7 @@ func (node *Node) CreateAt(path string, repo *repository.Repository) error {
|
||||
func (node Node) restoreMetadata(path string) error {
|
||||
var err error
|
||||
|
||||
err = os.Lchown(path, int(node.UID), int(node.GID))
|
||||
err = lchown(path, int(node.UID), int(node.GID))
|
||||
if err != nil {
|
||||
return errors.Annotate(err, "Lchown")
|
||||
}
|
||||
@ -236,6 +237,10 @@ func (node Node) createFileAt(path string, repo *repository.Repository) error {
|
||||
}
|
||||
|
||||
func (node Node) createSymlinkAt(path string) error {
|
||||
// Windows does not allow non-admins to create soft links.
|
||||
if runtime.GOOS == "windows" {
|
||||
return nil
|
||||
}
|
||||
err := os.Symlink(node.LinkTarget, path)
|
||||
if err != nil {
|
||||
return errors.Annotate(err, "Symlink")
|
||||
@ -245,15 +250,15 @@ func (node Node) createSymlinkAt(path string) error {
|
||||
}
|
||||
|
||||
func (node *Node) createDevAt(path string) error {
|
||||
return syscall.Mknod(path, syscall.S_IFBLK|0600, int(node.Device))
|
||||
return mknod(path, syscall.S_IFBLK|0600, int(node.Device))
|
||||
}
|
||||
|
||||
func (node *Node) createCharDevAt(path string) error {
|
||||
return syscall.Mknod(path, syscall.S_IFCHR|0600, int(node.Device))
|
||||
return mknod(path, syscall.S_IFCHR|0600, int(node.Device))
|
||||
}
|
||||
|
||||
func (node *Node) createFifoAt(path string) error {
|
||||
return syscall.Mkfifo(path, 0600)
|
||||
return mkfifo(path, 0600)
|
||||
}
|
||||
|
||||
func (node Node) MarshalJSON() ([]byte, error) {
|
||||
@ -381,9 +386,19 @@ func (node *Node) isNewer(path string, fi os.FileInfo) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
extendedStat := fi.Sys().(*syscall.Stat_t)
|
||||
inode := extendedStat.Ino
|
||||
size := uint64(extendedStat.Size)
|
||||
size := uint64(fi.Size())
|
||||
|
||||
extendedStat, ok := toStatT(fi.Sys())
|
||||
if !ok {
|
||||
if node.ModTime != fi.ModTime() ||
|
||||
node.Size != size {
|
||||
debug.Log("node.isNewer", "node %v is newer: timestamp or size changed", path)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
inode := extendedStat.ino()
|
||||
|
||||
if node.ModTime != fi.ModTime() ||
|
||||
node.ChangeTime != changeTime(extendedStat) ||
|
||||
@ -397,11 +412,11 @@ func (node *Node) isNewer(path string, fi os.FileInfo) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func (node *Node) fillUser(stat *syscall.Stat_t) error {
|
||||
node.UID = stat.Uid
|
||||
node.GID = stat.Gid
|
||||
func (node *Node) fillUser(stat statT) error {
|
||||
node.UID = stat.uid()
|
||||
node.GID = stat.gid()
|
||||
|
||||
username, err := lookupUsername(strconv.Itoa(int(stat.Uid)))
|
||||
username, err := lookupUsername(strconv.Itoa(int(stat.uid())))
|
||||
if err != nil {
|
||||
return errors.Annotate(err, "fillUser")
|
||||
}
|
||||
@ -439,12 +454,12 @@ func lookupUsername(uid string) (string, error) {
|
||||
}
|
||||
|
||||
func (node *Node) fillExtra(path string, fi os.FileInfo) error {
|
||||
stat, ok := fi.Sys().(*syscall.Stat_t)
|
||||
stat, ok := toStatT(fi.Sys())
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
node.Inode = uint64(stat.Ino)
|
||||
node.Inode = uint64(stat.ino())
|
||||
|
||||
node.fillTimes(stat)
|
||||
|
||||
@ -456,15 +471,15 @@ func (node *Node) fillExtra(path string, fi os.FileInfo) error {
|
||||
|
||||
switch node.Type {
|
||||
case "file":
|
||||
node.Size = uint64(stat.Size)
|
||||
node.Links = uint64(stat.Nlink)
|
||||
node.Size = uint64(stat.size())
|
||||
node.Links = uint64(stat.nlink())
|
||||
case "dir":
|
||||
case "symlink":
|
||||
node.LinkTarget, err = os.Readlink(path)
|
||||
case "dev":
|
||||
node.Device = uint64(stat.Rdev)
|
||||
node.Device = uint64(stat.rdev())
|
||||
case "chardev":
|
||||
node.Device = uint64(stat.Rdev)
|
||||
node.Device = uint64(stat.rdev())
|
||||
case "fifo":
|
||||
case "socket":
|
||||
default:
|
||||
@ -473,3 +488,32 @@ func (node *Node) fillExtra(path string, fi os.FileInfo) error {
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
type statT interface {
|
||||
dev() uint64
|
||||
ino() uint64
|
||||
nlink() uint64
|
||||
uid() uint32
|
||||
gid() uint32
|
||||
rdev() uint64
|
||||
size() int64
|
||||
atim() syscall.Timespec
|
||||
mtim() syscall.Timespec
|
||||
ctim() syscall.Timespec
|
||||
}
|
||||
|
||||
func mkfifo(path string, mode uint32) (err error) {
|
||||
return mknod(path, mode|syscall.S_IFIFO, 0)
|
||||
}
|
||||
|
||||
func (node *Node) fillTimes(stat statT) {
|
||||
ctim := stat.ctim()
|
||||
atim := stat.atim()
|
||||
node.ChangeTime = time.Unix(ctim.Unix())
|
||||
node.AccessTime = time.Unix(atim.Unix())
|
||||
}
|
||||
|
||||
func changeTime(stat statT) time.Time {
|
||||
ctim := stat.ctim()
|
||||
return time.Unix(ctim.Unix())
|
||||
}
|
||||
|
@ -3,22 +3,16 @@ package restic
|
||||
import (
|
||||
"os"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
func (node *Node) OpenForReading() (*os.File, error) {
|
||||
return os.Open(node.path)
|
||||
}
|
||||
|
||||
func changeTime(stat *syscall.Stat_t) time.Time {
|
||||
return time.Unix(stat.Ctimespec.Unix())
|
||||
}
|
||||
|
||||
func (node *Node) fillTimes(stat *syscall.Stat_t) {
|
||||
node.ChangeTime = time.Unix(stat.Ctimespec.Unix())
|
||||
node.AccessTime = time.Unix(stat.Atimespec.Unix())
|
||||
}
|
||||
|
||||
func (node Node) restoreSymlinkTimestamps(path string, utimes [2]syscall.Timespec) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s statUnix) atim() syscall.Timespec { return s.Atimespec }
|
||||
func (s statUnix) mtim() syscall.Timespec { return s.Mtimespec }
|
||||
func (s statUnix) ctim() syscall.Timespec { return s.Ctimespec }
|
||||
|
@ -3,22 +3,16 @@ package restic
|
||||
import (
|
||||
"os"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
func (node *Node) OpenForReading() (*os.File, error) {
|
||||
return os.OpenFile(node.path, os.O_RDONLY, 0)
|
||||
}
|
||||
|
||||
func (node *Node) fillTimes(stat *syscall.Stat_t) {
|
||||
node.ChangeTime = time.Unix(stat.Ctimespec.Unix())
|
||||
node.AccessTime = time.Unix(stat.Atimespec.Unix())
|
||||
}
|
||||
|
||||
func changeTime(stat *syscall.Stat_t) time.Time {
|
||||
return time.Unix(stat.Ctimespec.Unix())
|
||||
}
|
||||
|
||||
func (node Node) restoreSymlinkTimestamps(path string, utimes [2]syscall.Timespec) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s statUnix) atim() syscall.Timespec { return s.Atimespec }
|
||||
func (s statUnix) mtim() syscall.Timespec { return s.Mtimespec }
|
||||
func (s statUnix) ctim() syscall.Timespec { return s.Ctimespec }
|
||||
|
@ -4,7 +4,6 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"syscall"
|
||||
"time"
|
||||
"unsafe"
|
||||
|
||||
"github.com/juju/errors"
|
||||
@ -18,15 +17,6 @@ func (node *Node) OpenForReading() (*os.File, error) {
|
||||
return file, err
|
||||
}
|
||||
|
||||
func (node *Node) fillTimes(stat *syscall.Stat_t) {
|
||||
node.ChangeTime = time.Unix(stat.Ctim.Unix())
|
||||
node.AccessTime = time.Unix(stat.Atim.Unix())
|
||||
}
|
||||
|
||||
func changeTime(stat *syscall.Stat_t) time.Time {
|
||||
return time.Unix(stat.Ctim.Unix())
|
||||
}
|
||||
|
||||
func (node Node) restoreSymlinkTimestamps(path string, utimes [2]syscall.Timespec) error {
|
||||
dir, err := os.Open(filepath.Dir(path))
|
||||
defer dir.Close()
|
||||
@ -65,3 +55,7 @@ func utimensat(dirfd int, path string, times *[2]syscall.Timespec, flags int) (e
|
||||
func utimesNanoAt(dirfd int, path string, ts [2]syscall.Timespec, flags int) (err error) {
|
||||
return utimensat(dirfd, path, (*[2]syscall.Timespec)(unsafe.Pointer(&ts[0])), flags)
|
||||
}
|
||||
|
||||
func (s statUnix) atim() syscall.Timespec { return s.Atim }
|
||||
func (s statUnix) mtim() syscall.Timespec { return s.Mtim }
|
||||
func (s statUnix) ctim() syscall.Timespec { return s.Ctim }
|
||||
|
@ -3,7 +3,6 @@ package restic
|
||||
import (
|
||||
"os"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
func (node *Node) OpenForReading() (*os.File, error) {
|
||||
@ -14,15 +13,10 @@ func (node *Node) OpenForReading() (*os.File, error) {
|
||||
return file, err
|
||||
}
|
||||
|
||||
func (node *Node) fillTimes(stat *syscall.Stat_t) {
|
||||
node.ChangeTime = time.Unix(stat.Ctim.Unix())
|
||||
node.AccessTime = time.Unix(stat.Atim.Unix())
|
||||
}
|
||||
|
||||
func changeTime(stat *syscall.Stat_t) time.Time {
|
||||
return time.Unix(stat.Ctim.Unix())
|
||||
}
|
||||
|
||||
func (node Node) restoreSymlinkTimestamps(path string, utimes [2]syscall.Timespec) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s statUnix) atim() syscall.Timespec { return s.Atim }
|
||||
func (s statUnix) mtim() syscall.Timespec { return s.Mtim }
|
||||
func (s statUnix) ctim() syscall.Timespec { return s.Ctim }
|
||||
|
19
node_test.go
19
node_test.go
@ -119,6 +119,9 @@ func TestNodeRestoreAt(t *testing.T) {
|
||||
nodePath := filepath.Join(tempdir, test.Name)
|
||||
OK(t, test.CreateAt(nodePath, nil))
|
||||
|
||||
if test.Type == "symlink" && runtime.GOOS == "windows" {
|
||||
continue
|
||||
}
|
||||
if test.Type == "dir" {
|
||||
OK(t, test.RestoreTimestamps(nodePath))
|
||||
}
|
||||
@ -135,14 +138,16 @@ func TestNodeRestoreAt(t *testing.T) {
|
||||
"%v: type doesn't match (%v != %v)", test.Type, test.Type, n2.Type)
|
||||
Assert(t, test.Size == n2.Size,
|
||||
"%v: size doesn't match (%v != %v)", test.Size, test.Size, n2.Size)
|
||||
Assert(t, test.UID == n2.UID,
|
||||
"%v: UID doesn't match (%v != %v)", test.Type, test.UID, n2.UID)
|
||||
Assert(t, test.GID == n2.GID,
|
||||
"%v: GID doesn't match (%v != %v)", test.Type, test.GID, n2.GID)
|
||||
|
||||
if test.Type != "symlink" {
|
||||
Assert(t, test.Mode == n2.Mode,
|
||||
"%v: mode doesn't match (%v != %v)", test.Type, test.Mode, n2.Mode)
|
||||
if runtime.GOOS != "windows" {
|
||||
Assert(t, test.UID == n2.UID,
|
||||
"%v: UID doesn't match (%v != %v)", test.Type, test.UID, n2.UID)
|
||||
Assert(t, test.GID == n2.GID,
|
||||
"%v: GID doesn't match (%v != %v)", test.Type, test.GID, n2.GID)
|
||||
if test.Type != "symlink" {
|
||||
Assert(t, test.Mode == n2.Mode,
|
||||
"%v: mode doesn't match (%v != %v)", test.Type, test.Mode, n2.Mode)
|
||||
}
|
||||
}
|
||||
|
||||
AssertFsTimeEqual(t, "AccessTime", test.Type, test.AccessTime, n2.AccessTime)
|
||||
|
32
node_unix.go
Normal file
32
node_unix.go
Normal file
@ -0,0 +1,32 @@
|
||||
// +build dragonfly linux netbsd openbsd freebsd solaris darwin
|
||||
|
||||
package restic
|
||||
|
||||
import (
|
||||
"os"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
var mknod = syscall.Mknod
|
||||
var lchown = os.Lchown
|
||||
|
||||
type statUnix syscall.Stat_t
|
||||
|
||||
func toStatT(i interface{}) (statT, bool) {
|
||||
if i == nil {
|
||||
return nil, false
|
||||
}
|
||||
s, ok := i.(*syscall.Stat_t)
|
||||
if ok && s != nil {
|
||||
return statUnix(*s), true
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
func (s statUnix) dev() uint64 { return uint64(s.Dev) }
|
||||
func (s statUnix) ino() uint64 { return uint64(s.Ino) }
|
||||
func (s statUnix) nlink() uint64 { return uint64(s.Nlink) }
|
||||
func (s statUnix) uid() uint32 { return uint32(s.Uid) }
|
||||
func (s statUnix) gid() uint32 { return uint32(s.Gid) }
|
||||
func (s statUnix) rdev() uint64 { return uint64(s.Rdev) }
|
||||
func (s statUnix) size() int64 { return int64(s.Size) }
|
63
node_windows.go
Normal file
63
node_windows.go
Normal file
@ -0,0 +1,63 @@
|
||||
package restic
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
func (node *Node) OpenForReading() (*os.File, error) {
|
||||
return os.OpenFile(node.path, os.O_RDONLY, 0)
|
||||
}
|
||||
|
||||
// mknod() creates a filesystem node (file, device
|
||||
// special file, or named pipe) named pathname, with attributes
|
||||
// specified by mode and dev.
|
||||
var mknod = func(path string, mode uint32, dev int) (err error) {
|
||||
return errors.New("device nodes cannot be created on windows")
|
||||
}
|
||||
|
||||
// Windows doesn't need lchown
|
||||
var lchown = func(path string, uid int, gid int) (err error) {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (node Node) restoreSymlinkTimestamps(path string, utimes [2]syscall.Timespec) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
type statWin syscall.Win32FileAttributeData
|
||||
|
||||
func toStatT(i interface{}) (statT, bool) {
|
||||
if i == nil {
|
||||
return nil, false
|
||||
}
|
||||
s, ok := i.(*syscall.Win32FileAttributeData)
|
||||
if ok && s != nil {
|
||||
return statWin(*s), true
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
func (s statWin) dev() uint64 { return 0 }
|
||||
func (s statWin) ino() uint64 { return 0 }
|
||||
func (s statWin) nlink() uint64 { return 0 }
|
||||
func (s statWin) uid() uint32 { return 0 }
|
||||
func (s statWin) gid() uint32 { return 0 }
|
||||
func (s statWin) rdev() uint64 { return 0 }
|
||||
|
||||
func (s statWin) size() int64 {
|
||||
return int64(s.FileSizeLow) | (int64(s.FileSizeHigh) << 32)
|
||||
}
|
||||
|
||||
func (s statWin) atim() syscall.Timespec {
|
||||
return syscall.NsecToTimespec(s.LastAccessTime.Nanoseconds())
|
||||
}
|
||||
|
||||
func (s statWin) mtim() syscall.Timespec {
|
||||
return syscall.NsecToTimespec(s.LastWriteTime.Nanoseconds())
|
||||
}
|
||||
|
||||
func (s statWin) ctim() syscall.Timespec {
|
||||
return syscall.NsecToTimespec(s.CreationTime.Nanoseconds())
|
||||
}
|
16
restorer.go
16
restorer.go
@ -4,7 +4,6 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"syscall"
|
||||
|
||||
"github.com/restic/restic/backend"
|
||||
"github.com/restic/restic/debug"
|
||||
@ -96,16 +95,13 @@ func (res *Restorer) restoreNodeTo(node *Node, dir string, dst string) error {
|
||||
}
|
||||
|
||||
// Did it fail because of ENOENT?
|
||||
if pe, ok := errors.Cause(err).(*os.PathError); ok {
|
||||
errn, ok := pe.Err.(syscall.Errno)
|
||||
if ok && errn == syscall.ENOENT {
|
||||
debug.Log("Restorer.restoreNodeTo", "create intermediate paths")
|
||||
if err != nil && os.IsNotExist(errors.Cause(err)) {
|
||||
debug.Log("Restorer.restoreNodeTo", "create intermediate paths")
|
||||
|
||||
// Create parent directories and retry
|
||||
err = os.MkdirAll(filepath.Dir(dstPath), 0700)
|
||||
if err == nil || err == os.ErrExist {
|
||||
err = node.CreateAt(dstPath, res.repo)
|
||||
}
|
||||
// Create parent directories and retry
|
||||
err = os.MkdirAll(filepath.Dir(dstPath), 0700)
|
||||
if err == nil || err == os.ErrExist {
|
||||
err = node.CreateAt(dstPath, res.repo)
|
||||
}
|
||||
}
|
||||
|
||||
|
17
snapshot.go
17
snapshot.go
@ -5,7 +5,6 @@ import (
|
||||
"os"
|
||||
"os/user"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/restic/restic/backend"
|
||||
@ -76,19 +75,9 @@ func (sn *Snapshot) fillUserInfo() error {
|
||||
}
|
||||
sn.Username = usr.Username
|
||||
|
||||
uid, err := strconv.ParseInt(usr.Uid, 10, 32)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sn.UID = uint32(uid)
|
||||
|
||||
gid, err := strconv.ParseInt(usr.Gid, 10, 32)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sn.GID = uint32(gid)
|
||||
|
||||
return nil
|
||||
// set userid and groupid
|
||||
sn.UID, sn.GID, err = uidGidInt(*usr)
|
||||
return err
|
||||
}
|
||||
|
||||
// FindSnapshot takes a string and tries to find a snapshot whose ID matches
|
||||
|
Loading…
Reference in New Issue
Block a user