posix: Support UNC paths on windows. (#1887)

This allows us to now use 32K paths names on windows.

Fixes #1620
master
Harshavardhana 8 years ago committed by Anand Babu (AB) Periasamy
parent 4ab57f7d60
commit ed4fe689b4
  1. 2
      config-migrate.go
  2. 3
      object-api-getobjectinfo_test.go
  3. 2
      object-api-listobjects_test.go
  4. 2
      object-api_test.go
  5. 8
      posix-list-dir-others.go
  6. 56
      posix-prepare-path.go
  7. 37
      posix-utils_common.go
  8. 61
      posix-utils_nix.go
  9. 177
      posix-utils_windows.go
  10. 65
      posix.go
  11. 31
      server_test.go
  12. 14
      server_xl_test.go
  13. 4
      test-utils_test.go

@ -49,7 +49,7 @@ func purgeV1() {
fatalIf(err, "Unable to retrieve config path.")
configFile := filepath.Join(configPath, "fsUsers.json")
os.RemoveAll(configFile)
removeAll(configFile)
}
fatalIf(errors.New(""), "Failed to migrate unrecognized config version ‘"+cv1.Version+"’.")
}

@ -21,7 +21,6 @@ import (
"crypto/md5"
"encoding/hex"
"io/ioutil"
"os"
"strconv"
"testing"
)
@ -116,7 +115,7 @@ func BenchmarkGetObjectFS(b *testing.B) {
if err != nil {
b.Fatal(err)
}
defer os.RemoveAll(directory)
defer removeAll(directory)
// Create the obj.
obj, err := newFSObjects(directory)

@ -577,7 +577,7 @@ func BenchmarkListObjects(b *testing.B) {
if err != nil {
b.Fatal(err)
}
defer os.RemoveAll(directory)
defer removeAll(directory)
// Create the obj.
obj, err := newFSObjects(directory)

@ -69,6 +69,6 @@ func (s *MySuite) TestXLAPISuite(c *C) {
func removeRootsC(c *C, roots []string) {
for _, root := range roots {
os.RemoveAll(root)
removeAll(root)
}
}

@ -19,6 +19,7 @@
package main
import (
"fmt"
"io"
"os"
"strings"
@ -26,10 +27,11 @@ import (
// Return all the entries at the directory dirPath.
func readDir(dirPath string) (entries []string, err error) {
d, err := os.Open(dirPath)
d, err := os.Open(preparePath(dirPath))
if err != nil {
// File is really not found.
if os.IsNotExist(err) {
fmt.Println(preparePath(dirPath), err)
return nil, errFileNotFound
}
@ -50,12 +52,12 @@ func readDir(dirPath string) (entries []string, err error) {
return nil, err
}
for _, fi := range fis {
// Skip special files.
// Skip special files, if found.
if hasPosixReservedPrefix(fi.Name()) {
continue
}
if fi.Mode().IsDir() {
// append "/" instead of "\" so that sorting is done as expected.
// Append "/" instead of "\" so that sorting is achieved as expected.
entries = append(entries, fi.Name()+slashSeparator)
} else if fi.Mode().IsRegular() {
entries = append(entries, fi.Name())

@ -0,0 +1,56 @@
/*
* Minio Cloud Storage, (C) 2016 Minio, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package main
import (
"path/filepath"
"runtime"
"strings"
)
// preparePath rewrites path to handle any OS specific details.
func preparePath(path string) string {
if runtime.GOOS == "windows" {
// Microsoft Windows supports long path names using
// uniform naming convention (UNC).
return UNCPath(path)
}
return path
}
// UNCPath converts a absolute windows path to a UNC long path.
func UNCPath(path string) string {
// Clean the path for any trailing "/".
path = filepath.Clean(path)
// UNC can NOT use "/", so convert all to "\".
path = filepath.FromSlash(path)
// If prefix is "\\", we already have a UNC path or server.
if strings.HasPrefix(path, `\\`) {
// If already long path, just keep it
if strings.HasPrefix(path, `\\?\`) {
return path
}
// Trim "\\" from path and add UNC prefix.
return `\\?\UNC\` + strings.TrimPrefix(path, `\\`)
}
path = `\\?\` + path
return path
}

@ -0,0 +1,37 @@
/*
* Minio Cloud Storage, (C) 2016 Minio, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package main
import "strings"
// List of reserved words for files, includes old and new ones.
var posixReservedPrefix = []string{
"$tmpfile",
// Add new reserved words if any used in future.
}
// hasPosixReservedPrefix - has reserved prefix.
func hasPosixReservedPrefix(name string) (isReserved bool) {
for _, reservedKey := range posixReservedPrefix {
if strings.HasPrefix(name, reservedKey) {
isReserved = true
break
}
isReserved = false
}
return isReserved
}

@ -1,3 +1,5 @@
// +build linux darwin dragonfly freebsd netbsd openbsd
/*
* Minio Cloud Storage, (C) 2016 Minio, Inc.
*
@ -17,33 +19,15 @@
package main
import (
"runtime"
"os"
"strings"
"unicode/utf8"
)
// isValidVolname verifies a volname name in accordance with object
// layer requirements.
func isValidVolname(volname string) bool {
if len(volname) < 3 || len(volname) > 63 {
return false
}
switch runtime.GOOS {
case "windows":
// Volname shouldn't have reserved characters on windows in it.
return !strings.ContainsAny(volname, "/\\:*?\"<>|")
default:
// Volname shouldn't have '/' in it.
return !strings.ContainsAny(volname, "/")
}
}
// Keeping this as lower bound value supporting Linux, Darwin and Windows operating systems.
const pathMax = 4096
const pathMax = 4096 // 4k limit on all unixes.
// isValidPath verifies if a path name is in accordance with FS limitations.
func isValidPath(path string) bool {
// TODO: Make this FSType or Operating system specific.
if len(path) > pathMax || len(path) == 0 {
return false
}
@ -53,20 +37,29 @@ func isValidPath(path string) bool {
return true
}
// List of reserved words for files, includes old and new ones.
var posixReservedPrefix = []string{
"$tmpfile",
// Add new reserved words if any used in future.
// isValidVolname verifies a volname name in accordance with object
// layer requirements.
func isValidVolname(volname string) bool {
if len(volname) < 3 || len(volname) > 63 {
return false
}
// Volname shouldn't have '/' in it.
return !strings.ContainsAny(volname, "/")
}
// hasPosixReservedPrefix - has reserved prefix.
func hasPosixReservedPrefix(name string) (isReserved bool) {
for _, reservedKey := range posixReservedPrefix {
if strings.HasPrefix(name, reservedKey) {
isReserved = true
break
}
isReserved = false
}
return isReserved
// mkdirAll creates a directory named path,
// along with any necessary parents, and returns nil,
// or else returns an error. The permission bits perm are used
// for all directories that mkdirAll creates. If path is already
// a directory, mkdirAll does nothing and returns nil.
func mkdirAll(path string, perm os.FileMode) error {
return os.MkdirAll(path, perm)
}
// 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).
func removeAll(path string) error {
return os.RemoveAll(path)
}

@ -0,0 +1,177 @@
// +build windows
/*
* Minio Cloud Storage, (C) 2016 Minio, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package main
import (
"io"
"os"
"path/filepath"
"strings"
"syscall"
"unicode/utf8"
)
const pathMax = 32 * 1024 // 32K is the maximum limit for UNC paths.
// isValidPath verifies if a path name is in accordance with FS limitations.
func isValidPath(path string) bool {
if len(path) > pathMax || len(path) == 0 {
return false
}
if !utf8.ValidString(path) {
return false
}
return true
}
// isValidVolname verifies a volname name in accordance with object
// layer requirements.
func isValidVolname(volname string) bool {
if len(volname) < 3 || len(volname) > 63 {
return false
}
// Volname shouldn't have reserved characters on windows in it.
return !strings.ContainsAny(volname, "/\\:*?\"<>|")
}
// mkdirAll creates a directory named path,
// along with any necessary parents, and returns nil,
// or else returns an error. The permission bits perm are used
// for all directories that mkdirAll creates. If path is already
// a directory, mkdirAll does nothing and returns nil.
func mkdirAll(path string, perm os.FileMode) error {
// Fast path: if we can tell whether path is a directory or file, stop with success or error.
dir, err := os.Stat(preparePath(path))
if err == nil {
if dir.IsDir() {
return nil
}
return &os.PathError{
Op: "mkdir",
Path: path,
Err: syscall.ENOTDIR,
}
}
// Slow path: make sure parent exists and then call Mkdir for path.
i := len(path)
for i > 0 && os.IsPathSeparator(path[i-1]) { // Skip trailing path separator.
i--
}
j := i
for j > 0 && !os.IsPathSeparator(path[j-1]) { // Scan backward over element.
j--
}
if j > 1 {
// Create parent
parent := path[0 : j-1]
if parent != filepath.VolumeName(parent) {
err = mkdirAll(parent, perm)
if err != nil {
return err
}
}
}
// Parent now exists; invoke Mkdir and use its result.
err = os.Mkdir(preparePath(path), perm)
if err != nil {
// Handle arguments like "foo/." by
// double-checking that directory doesn't exist.
dir, err1 := os.Lstat(preparePath(path))
if err1 == nil && dir.IsDir() {
return nil
}
return err
}
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).
func removeAll(path string) error {
// Simple case: if Remove works, we're done.
err := os.Remove(preparePath(path))
if err == nil || os.IsNotExist(err) {
return nil
}
// Otherwise, is this a directory we need to recurse into?
dir, serr := os.Lstat(preparePath(path))
if serr != nil {
if serr, ok := serr.(*os.PathError); ok && (os.IsNotExist(serr.Err) || serr.Err == syscall.ENOTDIR) {
return nil
}
return serr
}
if !dir.IsDir() {
// Not a directory; return the error from Remove.
return err
}
// Directory.
fd, err := os.Open(preparePath(path))
if err != nil {
if os.IsNotExist(err) {
// Race. It was deleted between the Lstat and Open.
// Return nil per RemoveAll's docs.
return nil
}
return err
}
// Remove contents & return first error.
err = nil
for {
names, err1 := fd.Readdirnames(4096) // Get 4k entries.
for _, name := range names {
err1 = removeAll(path + string(os.PathSeparator) + name)
if err == nil {
err = err1
}
}
if err1 == io.EOF {
break
}
// If Readdirnames returned an error, use it.
if err == nil {
err = err1
}
if len(names) == 0 {
break
}
}
// Close directory, because windows won't remove opened directory.
fd.Close()
// Remove directory.
err1 := os.Remove(preparePath(path))
if err1 == nil || os.IsNotExist(err1) {
return nil
}
if err == nil {
err = err1
}
return err
}

@ -23,7 +23,6 @@ import (
"os"
slashpath "path"
"path/filepath"
"runtime"
"strings"
"syscall"
@ -46,16 +45,7 @@ var errFaultyDisk = errors.New("Faulty disk")
// checkPathLength - returns error if given path name length more than 255
func checkPathLength(pathName string) error {
// For MS Windows, the maximum path length is 255
if runtime.GOOS == "windows" {
if len(pathName) > 255 {
return errFileNameTooLong
}
return nil
}
// For non-windows system, check each path segment length is > 255
// Check each path segment length is > 255
for len(pathName) > 0 && pathName != "." && pathName != "/" {
dir, file := slashpath.Dir(pathName), slashpath.Base(pathName)
@ -64,8 +54,7 @@ func checkPathLength(pathName string) error {
}
pathName = dir
}
} // Success.
return nil
}
@ -96,11 +85,17 @@ func newPosix(diskPath string) (StorageAPI, error) {
if diskPath == "" {
return nil, errInvalidArgument
}
var err error
// Disallow relative paths, figure out absolute paths.
diskPath, err = filepath.Abs(diskPath)
if err != nil {
return nil, err
}
fs := posix{
diskPath: diskPath,
minFreeDisk: fsMinSpacePercent, // Minimum 5% disk should be free.
}
st, err := os.Stat(diskPath)
st, err := os.Stat(preparePath(diskPath))
if err != nil {
if os.IsNotExist(err) {
return fs, errDiskNotFound
@ -153,7 +148,7 @@ func listVols(dirPath string) ([]VolInfo, error) {
continue
}
var fi os.FileInfo
fi, err = os.Stat(pathJoin(dirPath, entry))
fi, err = os.Stat(preparePath(pathJoin(dirPath, entry)))
if err != nil {
// If the file does not exist, skip the entry.
if os.IsNotExist(err) {
@ -208,7 +203,7 @@ func (s posix) MakeVol(volume string) (err error) {
return err
}
// Make a volume entry.
err = os.Mkdir(volumeDir, 0700)
err = os.Mkdir(preparePath(volumeDir), 0700)
if err != nil && os.IsExist(err) {
return errVolumeExists
}
@ -266,7 +261,7 @@ func (s posix) StatVol(volume string) (volInfo VolInfo, err error) {
}
// Stat a volume entry.
var st os.FileInfo
st, err = os.Stat(volumeDir)
st, err = os.Stat(preparePath(volumeDir))
if err != nil {
if os.IsNotExist(err) {
return VolInfo{}, errVolumeNotFound
@ -304,7 +299,7 @@ func (s posix) DeleteVol(volume string) (err error) {
if err != nil {
return err
}
err = os.Remove(volumeDir)
err = os.Remove(preparePath(volumeDir))
if err != nil {
if os.IsNotExist(err) {
return errVolumeNotFound
@ -345,7 +340,7 @@ func (s posix) ListDir(volume, dirPath string) (entries []string, err error) {
return nil, err
}
// Stat a volume entry.
_, err = os.Stat(volumeDir)
_, err = os.Stat(preparePath(volumeDir))
if err != nil {
if os.IsNotExist(err) {
return nil, errVolumeNotFound
@ -381,7 +376,7 @@ func (s posix) ReadFile(volume string, path string, offset int64, buf []byte) (n
return 0, err
}
// Stat a volume entry.
_, err = os.Stat(volumeDir)
_, err = os.Stat(preparePath(volumeDir))
if err != nil {
if os.IsNotExist(err) {
return 0, errVolumeNotFound
@ -393,7 +388,7 @@ func (s posix) ReadFile(volume string, path string, offset int64, buf []byte) (n
if err = checkPathLength(filePath); err != nil {
return 0, err
}
file, err := os.Open(filePath)
file, err := os.Open(preparePath(filePath))
if err != nil {
if os.IsNotExist(err) {
return 0, errFileNotFound
@ -456,7 +451,7 @@ func (s posix) AppendFile(volume, path string, buf []byte) (n int64, err error)
return 0, err
}
// Stat a volume entry.
_, err = os.Stat(volumeDir)
_, err = os.Stat(preparePath(volumeDir))
if err != nil {
if os.IsNotExist(err) {
return 0, errVolumeNotFound
@ -472,16 +467,16 @@ func (s posix) AppendFile(volume, path string, buf []byte) (n int64, err error)
}
// Verify if the file already exists and is not of regular type.
var st os.FileInfo
if st, err = os.Stat(filePath); err == nil {
if st, err = os.Stat(preparePath(filePath)); err == nil {
if st.IsDir() {
return 0, errIsNotRegular
}
}
// Create top level directories if they don't exist.
if err = os.MkdirAll(filepath.Dir(filePath), 0700); err != nil {
if err = mkdirAll(filepath.Dir(filePath), 0700); err != nil {
return 0, err
}
w, err := os.OpenFile(filePath, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0600)
w, err := os.OpenFile(preparePath(filePath), os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0600)
if err != nil {
// File path cannot be verified since one of the parents is a file.
if strings.Contains(err.Error(), "not a directory") {
@ -518,7 +513,7 @@ func (s posix) StatFile(volume, path string) (file FileInfo, err error) {
return FileInfo{}, err
}
// Stat a volume entry.
_, err = os.Stat(volumeDir)
_, err = os.Stat(preparePath(volumeDir))
if err != nil {
if os.IsNotExist(err) {
return FileInfo{}, errVolumeNotFound
@ -530,7 +525,7 @@ func (s posix) StatFile(volume, path string) (file FileInfo, err error) {
if err = checkPathLength(filePath); err != nil {
return FileInfo{}, err
}
st, err := os.Stat(filePath)
st, err := os.Stat(preparePath(filePath))
if err != nil {
// File is really not found.
if os.IsNotExist(err) {
@ -564,7 +559,7 @@ func deleteFile(basePath, deletePath string) error {
return nil
}
// Verify if the path exists.
pathSt, err := os.Stat(deletePath)
pathSt, err := os.Stat(preparePath(deletePath))
if err != nil {
if os.IsNotExist(err) {
return errFileNotFound
@ -578,7 +573,7 @@ func deleteFile(basePath, deletePath string) error {
return nil
}
// Attempt to remove path.
if err := os.Remove(deletePath); err != nil {
if err := os.Remove(preparePath(deletePath)); err != nil {
return err
}
// Recursively go down the next path and delete again.
@ -610,7 +605,7 @@ func (s posix) DeleteFile(volume, path string) (err error) {
return err
}
// Stat a volume entry.
_, err = os.Stat(volumeDir)
_, err = os.Stat(preparePath(volumeDir))
if err != nil {
if os.IsNotExist(err) {
return errVolumeNotFound
@ -655,14 +650,14 @@ func (s posix) RenameFile(srcVolume, srcPath, dstVolume, dstPath string) (err er
return err
}
// Stat a volume entry.
_, err = os.Stat(srcVolumeDir)
_, err = os.Stat(preparePath(srcVolumeDir))
if err != nil {
if os.IsNotExist(err) {
return errVolumeNotFound
}
return err
}
_, err = os.Stat(dstVolumeDir)
_, err = os.Stat(preparePath(dstVolumeDir))
if err != nil {
if os.IsNotExist(err) {
return errVolumeNotFound
@ -677,7 +672,7 @@ func (s posix) RenameFile(srcVolume, srcPath, dstVolume, dstPath string) (err er
}
if srcIsDir {
// If source is a directory we expect the destination to be non-existent always.
_, err = os.Stat(slashpath.Join(dstVolumeDir, dstPath))
_, err = os.Stat(preparePath(slashpath.Join(dstVolumeDir, dstPath)))
if err == nil {
return errFileAccessDenied
}
@ -686,14 +681,14 @@ func (s posix) RenameFile(srcVolume, srcPath, dstVolume, dstPath string) (err er
}
// Destination does not exist, hence proceed with the rename.
}
if err = os.MkdirAll(slashpath.Dir(slashpath.Join(dstVolumeDir, dstPath)), 0755); err != nil {
if err = mkdirAll(preparePath(slashpath.Dir(slashpath.Join(dstVolumeDir, dstPath))), 0755); err != nil {
// File path cannot be verified since one of the parents is a file.
if strings.Contains(err.Error(), "not a directory") {
return errFileAccessDenied
}
return err
}
err = os.Rename(slashpath.Join(srcVolumeDir, srcPath), slashpath.Join(dstVolumeDir, dstPath))
err = os.Rename(preparePath(slashpath.Join(srcVolumeDir, srcPath)), preparePath(slashpath.Join(dstVolumeDir, dstPath)))
if err != nil {
if os.IsNotExist(err) {
return errFileNotFound

@ -19,6 +19,7 @@ package main
import (
"bytes"
"crypto/md5"
"fmt"
"io"
"io/ioutil"
"os"
@ -86,7 +87,7 @@ func (s *MyAPISuite) SetUpSuite(c *C) {
}
func (s *MyAPISuite) TearDownSuite(c *C) {
os.RemoveAll(s.root)
removeAll(s.root)
testAPIFSCacheServer.Close()
}
@ -686,6 +687,34 @@ func (s *MyAPISuite) TestListBuckets(c *C) {
c.Assert(err, IsNil)
}
// Tests put object with long names.
func (s *MyAPISuite) TestPutObjectLongName(c *C) {
request, err := s.newRequest("PUT", testAPIFSCacheServer.URL+"/put-object-long-name", 0, nil)
c.Assert(err, IsNil)
client := http.Client{}
response, err := client.Do(request)
c.Assert(err, IsNil)
c.Assert(response.StatusCode, Equals, http.StatusOK)
buffer := bytes.NewReader([]byte("hello world"))
longObjName := fmt.Sprintf("%0255d/%0255d/%0255d", 1, 1, 1)
request, err = s.newRequest("PUT", testAPIFSCacheServer.URL+"/put-object-long-name/"+longObjName, int64(buffer.Len()), buffer)
c.Assert(err, IsNil)
response, err = client.Do(request)
c.Assert(err, IsNil)
c.Assert(response.StatusCode, Equals, http.StatusOK)
longObjName = fmt.Sprintf("%0256d", 1)
request, err = s.newRequest("PUT", testAPIFSCacheServer.URL+"/put-object-long-name/"+longObjName, int64(buffer.Len()), buffer)
c.Assert(err, IsNil)
response, err = client.Do(request)
c.Assert(err, IsNil)
c.Assert(response.StatusCode, Equals, http.StatusNotFound)
}
func (s *MyAPISuite) TestNotBeAbleToCreateObjectInNonexistentBucket(c *C) {
buffer1 := bytes.NewReader([]byte("hello world"))
request, err := s.newRequest("PUT", testAPIFSCacheServer.URL+"/innonexistentbucket/object", int64(buffer1.Len()), buffer1)

@ -94,9 +94,9 @@ func (s *MyAPIXLSuite) SetUpSuite(c *C) {
}
func (s *MyAPIXLSuite) TearDownSuite(c *C) {
os.RemoveAll(s.root)
removeAll(s.root)
for _, disk := range s.erasureDisks {
os.RemoveAll(disk)
removeAll(disk)
}
testAPIXLServer.Close()
}
@ -706,7 +706,15 @@ func (s *MyAPIXLSuite) TestPutObjectLongName(c *C) {
c.Assert(response.StatusCode, Equals, http.StatusOK)
buffer := bytes.NewReader([]byte("hello world"))
longObjName := fmt.Sprintf("%0256d", 1)
longObjName := fmt.Sprintf("%0255d/%0255d/%0255d", 1, 1, 1)
request, err = s.newRequest("PUT", testAPIXLServer.URL+"/put-object-long-name/"+longObjName, int64(buffer.Len()), buffer)
c.Assert(err, IsNil)
response, err = client.Do(request)
c.Assert(err, IsNil)
c.Assert(response.StatusCode, Equals, http.StatusOK)
longObjName = fmt.Sprintf("%0256d", 1)
request, err = s.newRequest("PUT", testAPIXLServer.URL+"/put-object-long-name/"+longObjName, int64(buffer.Len()), buffer)
c.Assert(err, IsNil)

@ -73,7 +73,7 @@ func getSingleNodeObjectLayer() (ObjectLayer, string, error) {
// removeRoots - Cleans up initialized directories during tests.
func removeRoots(roots []string) {
for _, root := range roots {
os.RemoveAll(root)
removeAll(root)
}
}
@ -81,7 +81,7 @@ func removeRoots(roots []string) {
func removeRandomDisk(disks []string, removeCount int) {
ints := randInts(len(disks))
for _, i := range ints[:removeCount] {
os.RemoveAll(disks[i-1])
removeAll(disks[i-1])
}
}

Loading…
Cancel
Save