diff --git a/s3api/utils/name_validate.go b/s3api/utils/name_validate.go new file mode 100644 index 0000000..4f8250e --- /dev/null +++ b/s3api/utils/name_validate.go @@ -0,0 +1,24 @@ +// Copyright 2025 Versity Software +// This file is 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 utils + +func IsObjectNameValid(name string) bool { + switch clean(name) { + case "", ".", "..", "/": + return false + } + + return isObjectLocal(name) +} diff --git a/s3api/utils/path.go b/s3api/utils/path.go new file mode 100644 index 0000000..7b49c80 --- /dev/null +++ b/s3api/utils/path.go @@ -0,0 +1,171 @@ +// Copyright 2024 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +// code modified from golang std library src/internal/filepathlite/path.go +// to support path separator '/' for all platforms. +package utils + +import ( + "strings" +) + +const separator = '/' + +// isObjectLocal checks if the given path would result in an object +// that is local to the bucket. +func isObjectLocal(path string) bool { + if path == "" || path == "." { + return true + } + + path = strings.Join([]string{".", path}, string(separator)) + + hasDots := false + for p := path; p != ""; { + var part string + part, p, _ = strings.Cut(p, "/") + if part == "." || part == ".." { + hasDots = true + break + } + } + if hasDots { + path = clean(path) + } + if path == ".." || strings.HasPrefix(path, "../") { + return false + } + return true +} + +func clean(path string) string { + originalPath := path + if path == "" { + return originalPath + "." + } + rooted := isPathSeparator(path[0]) + + // Invariants: + // reading from path; r is index of next byte to process. + // writing to buf; w is index of next byte to write. + // dotdot is index in buf where .. must stop, either because + // it is the leading slash or it is a leading ../../.. prefix. + n := len(path) + out := lazybuf{path: path, volAndPath: originalPath, volLen: 0} + r, dotdot := 0, 0 + if rooted { + out.append(separator) + r, dotdot = 1, 1 + } + + for r < n { + switch { + case isPathSeparator(path[r]): + // empty path element + r++ + case path[r] == '.' && (r+1 == n || isPathSeparator(path[r+1])): + // . element + r++ + case path[r] == '.' && path[r+1] == '.' && (r+2 == n || isPathSeparator(path[r+2])): + // .. element: remove to last separator + r += 2 + switch { + case out.w > dotdot: + // can backtrack + out.w-- + for out.w > dotdot && !isPathSeparator(out.index(out.w)) { + out.w-- + } + case !rooted: + // cannot backtrack, but not rooted, so append .. element. + if out.w > 0 { + out.append(separator) + } + out.append('.') + out.append('.') + dotdot = out.w + } + default: + // real path element. + // add slash if needed + if rooted && out.w != 1 || !rooted && out.w != 0 { + out.append(separator) + } + // copy element + for ; r < n && !isPathSeparator(path[r]); r++ { + out.append(path[r]) + } + } + } + + // Turn empty string into "." + if out.w == 0 { + out.append('.') + } + + return FromSlash(out.string()) +} + +func isPathSeparator(c uint8) bool { + return c == '/' +} + +func FromSlash(path string) string { + if separator == '/' { + return path + } + return replaceStringByte(path, '/', separator) +} + +func replaceStringByte(s string, old, new byte) string { + if strings.IndexByte(s, old) == -1 { + return s + } + n := []byte(s) + for i := range n { + if n[i] == old { + n[i] = new + } + } + return string(n) +} + +// A lazybuf is a lazily constructed path buffer. +// It supports append, reading previously appended bytes, +// and retrieving the final string. It does not allocate a buffer +// to hold the output until that output diverges from s. +type lazybuf struct { + path string + buf []byte + w int + volAndPath string + volLen int +} + +func (b *lazybuf) index(i int) byte { + if b.buf != nil { + return b.buf[i] + } + return b.path[i] +} + +func (b *lazybuf) append(c byte) { + if b.buf == nil { + if b.w < len(b.path) && b.path[b.w] == c { + b.w++ + return + } + b.buf = make([]byte, len(b.path)) + copy(b.buf, b.path[:b.w]) + } + b.buf[b.w] = c + b.w++ +} + +func (b *lazybuf) string() string { + if b.buf == nil { + return b.volAndPath[:b.volLen+b.w] + } + return b.volAndPath[:b.volLen] + string(b.buf[:b.w]) +} diff --git a/s3api/utils/path_test.go b/s3api/utils/path_test.go new file mode 100644 index 0000000..68ee554 --- /dev/null +++ b/s3api/utils/path_test.go @@ -0,0 +1,64 @@ +// Copyright 2025 Versity Software +// This file is 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 utils_test + +import ( + "testing" + + "github.com/versity/versitygw/s3api/utils" +) + +func TestIsObjectNameValid(t *testing.T) { + tests := []struct { + name string + input string + want bool + }{ + // valid names + {"simple file", "file.txt", true}, + {"nested file", "dir/file.txt", true}, + {"absolute nested file", "/dir/file.txt", true}, + {"trailing slash", "dir/", true}, + {"slash prefix", "/file.txt", true}, // treated as local after joined with bucket + {"dot slash prefix", "./file.txt", true}, + + // invalid names + {"dot dot only", "..", false}, + {"dot only", ".", false}, + {"dot slash", "./", false}, + {"dot slash dot dot", "./..", false}, + {"cleans to dot", "./../.", false}, + {"empty", "", false}, + {"file escapes 1", "../file.txt", false}, + {"file escapes 2", "dir/../../file.txt", false}, + {"file escapes 3", "../../../file.txt", false}, + {"dir escapes 1", "../dir/", false}, + {"dir escapes 2", "dir/../../dir/", false}, + {"dir escapes 3", "../../../dir/", false}, + {"dot escapes 1", "../.", false}, + {"dot escapes 2", "dir/../../.", false}, + {"dot escapes 3", "../../../.", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := utils.IsObjectNameValid(tt.input) + if got != tt.want { + t.Errorf("%v: IsObjectNameValid(%q) = %v, want %v", + tt.name, tt.input, got, tt.want) + } + }) + } +}