2017-10-20 14:52:55 +00:00
|
|
|
// Copyright (C) 2016 The Syncthing Authors.
|
|
|
|
//
|
|
|
|
// This Source Code Form is subject to the terms of the Mozilla Public
|
|
|
|
// License, v. 2.0. If a copy of the MPL was not distributed with this file,
|
|
|
|
// You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
|
|
|
|
|
|
// +build !solaris,!darwin solaris,cgo darwin,cgo
|
|
|
|
|
|
|
|
package fs
|
|
|
|
|
|
|
|
import (
|
|
|
|
"context"
|
2018-03-23 11:56:38 +00:00
|
|
|
"errors"
|
2017-10-20 14:52:55 +00:00
|
|
|
"fmt"
|
|
|
|
"os"
|
|
|
|
"path/filepath"
|
|
|
|
"runtime"
|
|
|
|
"strconv"
|
2018-04-16 18:07:00 +00:00
|
|
|
"strings"
|
2018-03-23 11:56:38 +00:00
|
|
|
"syscall"
|
2017-10-20 14:52:55 +00:00
|
|
|
"testing"
|
|
|
|
"time"
|
|
|
|
|
2018-03-14 13:48:22 +00:00
|
|
|
"github.com/syncthing/notify"
|
2017-10-20 14:52:55 +00:00
|
|
|
)
|
|
|
|
|
|
|
|
func TestMain(m *testing.M) {
|
|
|
|
if err := os.RemoveAll(testDir); err != nil {
|
|
|
|
panic(err)
|
|
|
|
}
|
|
|
|
|
|
|
|
dir, err := filepath.Abs(".")
|
|
|
|
if err != nil {
|
|
|
|
panic("Cannot get absolute path to working dir")
|
|
|
|
}
|
2018-08-11 20:24:36 +00:00
|
|
|
|
2018-09-26 18:28:20 +00:00
|
|
|
dir, err = evalSymlinks(dir)
|
2017-10-20 14:52:55 +00:00
|
|
|
if err != nil {
|
|
|
|
panic("Cannot get real path to working dir")
|
|
|
|
}
|
2018-08-11 20:24:36 +00:00
|
|
|
|
2017-10-20 14:52:55 +00:00
|
|
|
testDirAbs = filepath.Join(dir, testDir)
|
2018-08-11 20:24:36 +00:00
|
|
|
if runtime.GOOS == "windows" {
|
|
|
|
testDirAbs = longFilenameSupport(testDirAbs)
|
|
|
|
}
|
|
|
|
|
2018-01-27 12:52:48 +00:00
|
|
|
testFs = NewFilesystem(FilesystemTypeBasic, testDirAbs)
|
2017-10-20 14:52:55 +00:00
|
|
|
|
|
|
|
backendBuffer = 10
|
2018-05-14 07:47:23 +00:00
|
|
|
|
|
|
|
exitCode := m.Run()
|
|
|
|
|
|
|
|
backendBuffer = 500
|
|
|
|
os.RemoveAll(testDir)
|
|
|
|
|
|
|
|
os.Exit(exitCode)
|
2017-10-20 14:52:55 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
const (
|
2019-01-19 07:28:57 +00:00
|
|
|
testDir = "testdata"
|
|
|
|
failsOnOpenBSD = "Fails on OpenBSD. See https://github.com/rjeczalik/notify/issues/172"
|
2017-10-20 14:52:55 +00:00
|
|
|
)
|
|
|
|
|
|
|
|
var (
|
|
|
|
testDirAbs string
|
|
|
|
testFs Filesystem
|
|
|
|
)
|
|
|
|
|
|
|
|
func TestWatchIgnore(t *testing.T) {
|
2019-01-19 07:28:57 +00:00
|
|
|
if runtime.GOOS == "openbsd" {
|
|
|
|
t.Skip(failsOnOpenBSD)
|
|
|
|
}
|
2017-10-20 14:52:55 +00:00
|
|
|
name := "ignore"
|
|
|
|
|
|
|
|
file := "file"
|
|
|
|
ignored := "ignored"
|
|
|
|
|
|
|
|
testCase := func() {
|
|
|
|
createTestFile(name, file)
|
|
|
|
createTestFile(name, ignored)
|
|
|
|
}
|
|
|
|
|
|
|
|
expectedEvents := []Event{
|
|
|
|
{file, NonRemove},
|
|
|
|
}
|
2018-02-04 21:25:59 +00:00
|
|
|
allowedEvents := []Event{
|
|
|
|
{name, NonRemove},
|
|
|
|
}
|
2017-10-20 14:52:55 +00:00
|
|
|
|
2018-05-14 07:47:23 +00:00
|
|
|
testScenario(t, name, testCase, expectedEvents, allowedEvents, fakeMatcher{ignore: filepath.Join(name, ignored), skipIgnoredDirs: true})
|
|
|
|
}
|
|
|
|
|
|
|
|
func TestWatchInclude(t *testing.T) {
|
2019-01-19 07:28:57 +00:00
|
|
|
if runtime.GOOS == "openbsd" {
|
|
|
|
t.Skip(failsOnOpenBSD)
|
|
|
|
}
|
2018-05-14 07:47:23 +00:00
|
|
|
name := "include"
|
|
|
|
|
|
|
|
file := "file"
|
|
|
|
ignored := "ignored"
|
2019-02-02 11:16:27 +00:00
|
|
|
testFs.MkdirAll(filepath.Join(name, ignored), 0777)
|
2018-05-14 07:47:23 +00:00
|
|
|
included := filepath.Join(ignored, "included")
|
|
|
|
|
|
|
|
testCase := func() {
|
|
|
|
createTestFile(name, file)
|
|
|
|
createTestFile(name, included)
|
|
|
|
}
|
|
|
|
|
|
|
|
expectedEvents := []Event{
|
|
|
|
{file, NonRemove},
|
|
|
|
{included, NonRemove},
|
|
|
|
}
|
|
|
|
allowedEvents := []Event{
|
|
|
|
{name, NonRemove},
|
|
|
|
}
|
|
|
|
|
|
|
|
testScenario(t, name, testCase, expectedEvents, allowedEvents, fakeMatcher{ignore: filepath.Join(name, ignored), include: filepath.Join(name, included)})
|
2017-10-20 14:52:55 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
func TestWatchRename(t *testing.T) {
|
2019-01-19 07:28:57 +00:00
|
|
|
if runtime.GOOS == "openbsd" {
|
|
|
|
t.Skip(failsOnOpenBSD)
|
|
|
|
}
|
2017-10-20 14:52:55 +00:00
|
|
|
name := "rename"
|
|
|
|
|
|
|
|
old := createTestFile(name, "oldfile")
|
|
|
|
new := "newfile"
|
|
|
|
|
|
|
|
testCase := func() {
|
|
|
|
renameTestFile(name, old, new)
|
|
|
|
}
|
|
|
|
|
|
|
|
destEvent := Event{new, Remove}
|
|
|
|
// Only on these platforms the removed file can be differentiated from
|
|
|
|
// the created file during renaming
|
|
|
|
if runtime.GOOS == "windows" || runtime.GOOS == "linux" || runtime.GOOS == "solaris" {
|
|
|
|
destEvent = Event{new, NonRemove}
|
|
|
|
}
|
|
|
|
expectedEvents := []Event{
|
|
|
|
{old, Remove},
|
|
|
|
destEvent,
|
|
|
|
}
|
2018-02-04 21:25:59 +00:00
|
|
|
allowedEvents := []Event{
|
|
|
|
{name, NonRemove},
|
|
|
|
}
|
2017-10-20 14:52:55 +00:00
|
|
|
|
2018-01-28 10:44:43 +00:00
|
|
|
// set the "allow others" flag because we might get the create of
|
|
|
|
// "oldfile" initially
|
2018-05-14 07:47:23 +00:00
|
|
|
testScenario(t, name, testCase, expectedEvents, allowedEvents, fakeMatcher{})
|
2017-10-20 14:52:55 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
// TestWatchOutside checks that no changes from outside the folder make it in
|
|
|
|
func TestWatchOutside(t *testing.T) {
|
|
|
|
outChan := make(chan Event)
|
|
|
|
backendChan := make(chan notify.EventInfo, backendBuffer)
|
|
|
|
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
|
|
|
|
|
|
// testFs is Filesystem, but we need BasicFilesystem here
|
|
|
|
fs := newBasicFilesystem(testDirAbs)
|
|
|
|
|
|
|
|
go func() {
|
|
|
|
defer func() {
|
|
|
|
if recover() == nil {
|
2018-05-14 07:47:23 +00:00
|
|
|
select {
|
|
|
|
case <-ctx.Done(): // timed out
|
|
|
|
default:
|
|
|
|
t.Fatalf("Watch did not panic on receiving event outside of folder")
|
|
|
|
}
|
2017-10-20 14:52:55 +00:00
|
|
|
}
|
|
|
|
cancel()
|
|
|
|
}()
|
2018-08-11 20:24:36 +00:00
|
|
|
fs.watchLoop(".", testDirAbs, backendChan, outChan, fakeMatcher{}, ctx)
|
2017-10-20 14:52:55 +00:00
|
|
|
}()
|
|
|
|
|
|
|
|
backendChan <- fakeEventInfo(filepath.Join(filepath.Dir(testDirAbs), "outside"))
|
2018-05-14 07:47:23 +00:00
|
|
|
|
|
|
|
select {
|
|
|
|
case <-time.After(10 * time.Second):
|
|
|
|
cancel()
|
|
|
|
t.Errorf("Timed out before panicing")
|
|
|
|
case <-ctx.Done():
|
|
|
|
}
|
2017-10-20 14:52:55 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
func TestWatchSubpath(t *testing.T) {
|
|
|
|
outChan := make(chan Event)
|
|
|
|
backendChan := make(chan notify.EventInfo, backendBuffer)
|
|
|
|
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
|
|
|
|
|
|
// testFs is Filesystem, but we need BasicFilesystem here
|
|
|
|
fs := newBasicFilesystem(testDirAbs)
|
|
|
|
|
|
|
|
abs, _ := fs.rooted("sub")
|
2018-08-11 20:24:36 +00:00
|
|
|
go fs.watchLoop("sub", testDirAbs, backendChan, outChan, fakeMatcher{}, ctx)
|
2017-10-20 14:52:55 +00:00
|
|
|
|
|
|
|
backendChan <- fakeEventInfo(filepath.Join(abs, "file"))
|
|
|
|
|
|
|
|
timeout := time.NewTimer(2 * time.Second)
|
|
|
|
select {
|
|
|
|
case <-timeout.C:
|
|
|
|
t.Errorf("Timed out before receiving an event")
|
|
|
|
cancel()
|
|
|
|
case ev := <-outChan:
|
|
|
|
if ev.Name != filepath.Join("sub", "file") {
|
|
|
|
t.Errorf("While watching a subfolder, received an event with unexpected path %v", ev.Name)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
cancel()
|
|
|
|
}
|
|
|
|
|
|
|
|
// TestWatchOverflow checks that an event at the root is sent when maxFiles is reached
|
|
|
|
func TestWatchOverflow(t *testing.T) {
|
2019-01-19 07:28:57 +00:00
|
|
|
if runtime.GOOS == "openbsd" {
|
|
|
|
t.Skip(failsOnOpenBSD)
|
|
|
|
}
|
2017-10-20 14:52:55 +00:00
|
|
|
name := "overflow"
|
|
|
|
|
2018-02-04 21:25:59 +00:00
|
|
|
expectedEvents := []Event{
|
|
|
|
{".", NonRemove},
|
|
|
|
}
|
|
|
|
|
|
|
|
allowedEvents := []Event{
|
|
|
|
{name, NonRemove},
|
|
|
|
}
|
|
|
|
|
2017-10-20 14:52:55 +00:00
|
|
|
testCase := func() {
|
|
|
|
for i := 0; i < 5*backendBuffer; i++ {
|
2018-02-04 21:25:59 +00:00
|
|
|
file := "file" + strconv.Itoa(i)
|
|
|
|
createTestFile(name, file)
|
|
|
|
allowedEvents = append(allowedEvents, Event{file, NonRemove})
|
2017-10-20 14:52:55 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2018-05-14 07:47:23 +00:00
|
|
|
testScenario(t, name, testCase, expectedEvents, allowedEvents, fakeMatcher{})
|
2017-10-20 14:52:55 +00:00
|
|
|
}
|
|
|
|
|
2018-03-23 11:56:38 +00:00
|
|
|
func TestWatchErrorLinuxInterpretation(t *testing.T) {
|
|
|
|
if runtime.GOOS != "linux" {
|
|
|
|
t.Skip("testing of linux specific error codes")
|
|
|
|
}
|
|
|
|
|
|
|
|
var errTooManyFiles = &os.PathError{
|
|
|
|
Op: "error while traversing",
|
|
|
|
Path: "foo",
|
|
|
|
Err: syscall.Errno(24),
|
|
|
|
}
|
|
|
|
var errNoSpace = &os.PathError{
|
|
|
|
Op: "error while traversing",
|
|
|
|
Path: "bar",
|
|
|
|
Err: syscall.Errno(28),
|
|
|
|
}
|
|
|
|
|
|
|
|
if !reachedMaxUserWatches(errTooManyFiles) {
|
|
|
|
t.Error("Underlying error syscall.Errno(24) should be recognised to be about inotify limits.")
|
|
|
|
}
|
|
|
|
if !reachedMaxUserWatches(errNoSpace) {
|
|
|
|
t.Error("Underlying error syscall.Errno(28) should be recognised to be about inotify limits.")
|
|
|
|
}
|
|
|
|
err := errors.New("Another error")
|
|
|
|
if reachedMaxUserWatches(err) {
|
|
|
|
t.Errorf("This error does not concern inotify limits: %#v", err)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2018-03-28 21:01:25 +00:00
|
|
|
func TestWatchSymlinkedRoot(t *testing.T) {
|
|
|
|
if runtime.GOOS == "windows" {
|
|
|
|
t.Skip("Involves symlinks")
|
|
|
|
}
|
|
|
|
|
|
|
|
name := "symlinkedRoot"
|
|
|
|
if err := testFs.MkdirAll(name, 0755); err != nil {
|
|
|
|
panic(fmt.Sprintf("Failed to create directory %s: %s", name, err))
|
|
|
|
}
|
2019-02-02 11:16:27 +00:00
|
|
|
defer testFs.RemoveAll(name)
|
2018-03-28 21:01:25 +00:00
|
|
|
|
|
|
|
root := filepath.Join(name, "root")
|
|
|
|
if err := testFs.MkdirAll(root, 0777); err != nil {
|
|
|
|
panic(err)
|
|
|
|
}
|
|
|
|
link := filepath.Join(name, "link")
|
|
|
|
|
|
|
|
if err := testFs.CreateSymlink(filepath.Join(testFs.URI(), root), link); err != nil {
|
|
|
|
panic(err)
|
|
|
|
}
|
|
|
|
|
|
|
|
linkedFs := NewFilesystem(FilesystemTypeBasic, filepath.Join(testFs.URI(), link))
|
|
|
|
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
|
|
defer cancel()
|
|
|
|
if _, err := linkedFs.Watch(".", fakeMatcher{}, ctx, false); err != nil {
|
|
|
|
panic(err)
|
|
|
|
}
|
|
|
|
|
|
|
|
if err := linkedFs.MkdirAll("foo", 0777); err != nil {
|
|
|
|
panic(err)
|
|
|
|
}
|
|
|
|
|
|
|
|
// Give the panic some time to happen
|
|
|
|
sleepMs(100)
|
|
|
|
}
|
|
|
|
|
|
|
|
func TestUnrootedChecked(t *testing.T) {
|
|
|
|
var unrooted string
|
|
|
|
defer func() {
|
|
|
|
if recover() == nil {
|
|
|
|
t.Fatal("unrootedChecked did not panic on outside path, but returned", unrooted)
|
|
|
|
}
|
|
|
|
}()
|
|
|
|
fs := newBasicFilesystem(testDirAbs)
|
2018-08-11 20:24:36 +00:00
|
|
|
unrooted = fs.unrootedChecked("/random/other/path", testDirAbs)
|
2018-03-28 21:01:25 +00:00
|
|
|
}
|
|
|
|
|
2018-04-16 18:07:00 +00:00
|
|
|
func TestWatchIssue4877(t *testing.T) {
|
|
|
|
if runtime.GOOS != "windows" {
|
|
|
|
t.Skip("Windows specific test")
|
|
|
|
}
|
|
|
|
|
|
|
|
name := "Issue4877"
|
|
|
|
|
|
|
|
file := "file"
|
|
|
|
|
|
|
|
testCase := func() {
|
|
|
|
createTestFile(name, file)
|
|
|
|
}
|
|
|
|
|
|
|
|
expectedEvents := []Event{
|
|
|
|
{file, NonRemove},
|
|
|
|
}
|
|
|
|
allowedEvents := []Event{
|
|
|
|
{name, NonRemove},
|
|
|
|
}
|
|
|
|
|
2018-09-11 20:30:32 +00:00
|
|
|
volName := filepath.VolumeName(testDirAbs)
|
|
|
|
if volName == "" {
|
|
|
|
t.Fatalf("Failed to get volume name for path %v", testDirAbs)
|
|
|
|
}
|
2018-04-16 18:07:00 +00:00
|
|
|
origTestFs := testFs
|
2018-09-11 20:30:32 +00:00
|
|
|
testFs = NewFilesystem(FilesystemTypeBasic, strings.ToLower(volName)+strings.ToUpper(testDirAbs[len(volName):]))
|
2018-04-16 18:07:00 +00:00
|
|
|
defer func() {
|
|
|
|
testFs = origTestFs
|
|
|
|
}()
|
|
|
|
|
2018-05-14 07:47:23 +00:00
|
|
|
testScenario(t, name, testCase, expectedEvents, allowedEvents, fakeMatcher{})
|
2018-04-16 18:07:00 +00:00
|
|
|
}
|
|
|
|
|
2017-10-20 14:52:55 +00:00
|
|
|
// path relative to folder root, also creates parent dirs if necessary
|
|
|
|
func createTestFile(name string, file string) string {
|
|
|
|
joined := filepath.Join(name, file)
|
|
|
|
if err := testFs.MkdirAll(filepath.Dir(joined), 0755); err != nil {
|
|
|
|
panic(fmt.Sprintf("Failed to create parent directory for %s: %s", joined, err))
|
|
|
|
}
|
|
|
|
handle, err := testFs.Create(joined)
|
|
|
|
if err != nil {
|
|
|
|
panic(fmt.Sprintf("Failed to create test file %s: %s", joined, err))
|
|
|
|
}
|
|
|
|
handle.Close()
|
|
|
|
return file
|
|
|
|
}
|
|
|
|
|
|
|
|
func renameTestFile(name string, old string, new string) {
|
|
|
|
old = filepath.Join(name, old)
|
|
|
|
new = filepath.Join(name, new)
|
|
|
|
if err := testFs.Rename(old, new); err != nil {
|
|
|
|
panic(fmt.Sprintf("Failed to rename %s to %s: %s", old, new, err))
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
func sleepMs(ms int) {
|
|
|
|
time.Sleep(time.Duration(ms) * time.Millisecond)
|
|
|
|
}
|
|
|
|
|
2018-05-14 07:47:23 +00:00
|
|
|
func testScenario(t *testing.T, name string, testCase func(), expectedEvents, allowedEvents []Event, fm fakeMatcher) {
|
2017-10-20 14:52:55 +00:00
|
|
|
if err := testFs.MkdirAll(name, 0755); err != nil {
|
|
|
|
panic(fmt.Sprintf("Failed to create directory %s: %s", name, err))
|
|
|
|
}
|
2019-02-02 11:16:27 +00:00
|
|
|
defer testFs.RemoveAll(name)
|
2017-10-20 14:52:55 +00:00
|
|
|
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
2018-01-28 10:44:43 +00:00
|
|
|
defer cancel()
|
2017-10-20 14:52:55 +00:00
|
|
|
|
2018-05-14 07:47:23 +00:00
|
|
|
eventChan, err := testFs.Watch(name, fm, ctx, false)
|
2017-10-20 14:52:55 +00:00
|
|
|
if err != nil {
|
|
|
|
panic(err)
|
|
|
|
}
|
|
|
|
|
2018-02-04 21:25:59 +00:00
|
|
|
go testWatchOutput(t, name, eventChan, expectedEvents, allowedEvents, ctx, cancel)
|
2017-10-20 14:52:55 +00:00
|
|
|
|
|
|
|
testCase()
|
|
|
|
|
|
|
|
select {
|
2018-05-14 07:47:23 +00:00
|
|
|
case <-time.After(time.Minute):
|
2017-10-20 14:52:55 +00:00
|
|
|
t.Errorf("Timed out before receiving all expected events")
|
|
|
|
|
2018-01-28 10:44:43 +00:00
|
|
|
case <-ctx.Done():
|
2017-10-20 14:52:55 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2018-02-04 21:25:59 +00:00
|
|
|
func testWatchOutput(t *testing.T, name string, in <-chan Event, expectedEvents, allowedEvents []Event, ctx context.Context, cancel context.CancelFunc) {
|
2017-10-20 14:52:55 +00:00
|
|
|
var expected = make(map[Event]struct{})
|
|
|
|
for _, ev := range expectedEvents {
|
|
|
|
ev.Name = filepath.Join(name, ev.Name)
|
|
|
|
expected[ev] = struct{}{}
|
|
|
|
}
|
|
|
|
|
|
|
|
var received Event
|
|
|
|
var last Event
|
|
|
|
for {
|
|
|
|
if len(expected) == 0 {
|
|
|
|
cancel()
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
select {
|
|
|
|
case <-ctx.Done():
|
|
|
|
return
|
|
|
|
case received = <-in:
|
|
|
|
}
|
|
|
|
|
|
|
|
// apparently the backend sometimes sends repeat events
|
|
|
|
if last == received {
|
|
|
|
continue
|
|
|
|
}
|
|
|
|
|
|
|
|
if _, ok := expected[received]; !ok {
|
2018-02-04 21:25:59 +00:00
|
|
|
if len(allowedEvents) > 0 {
|
2017-10-20 14:52:55 +00:00
|
|
|
sleepMs(100) // To facilitate overflow
|
|
|
|
continue
|
|
|
|
}
|
|
|
|
t.Errorf("Received unexpected event %v expected one of %v", received, expected)
|
|
|
|
cancel()
|
|
|
|
return
|
|
|
|
}
|
|
|
|
delete(expected, received)
|
|
|
|
last = received
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2018-05-14 07:47:23 +00:00
|
|
|
// Matches are done via direct comparison against both ignore and include
|
|
|
|
type fakeMatcher struct {
|
|
|
|
ignore string
|
|
|
|
include string
|
|
|
|
skipIgnoredDirs bool
|
|
|
|
}
|
2017-10-20 14:52:55 +00:00
|
|
|
|
|
|
|
func (fm fakeMatcher) ShouldIgnore(name string) bool {
|
2018-05-14 07:47:23 +00:00
|
|
|
return name != fm.include && name == fm.ignore
|
|
|
|
}
|
|
|
|
|
|
|
|
func (fm fakeMatcher) SkipIgnoredDirs() bool {
|
|
|
|
return fm.skipIgnoredDirs
|
2017-10-20 14:52:55 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
type fakeEventInfo string
|
|
|
|
|
|
|
|
func (e fakeEventInfo) Path() string {
|
|
|
|
return string(e)
|
|
|
|
}
|
|
|
|
|
|
|
|
func (e fakeEventInfo) Event() notify.Event {
|
|
|
|
return notify.Write
|
|
|
|
}
|
|
|
|
|
|
|
|
func (e fakeEventInfo) Sys() interface{} {
|
|
|
|
return nil
|
|
|
|
}
|