Skip to content

Commit d04ef12

Browse files
backup: accept canonicalized snapshot metadata uri
Signed-off-by: huanghaoyuanhhy <haoyuan.huang@zilliz.com>
1 parent 2309945 commit d04ef12

2 files changed

Lines changed: 159 additions & 3 deletions

File tree

core/backup/snapshot_target.go

Lines changed: 132 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ package backup
22

33
import (
44
"fmt"
5+
"net"
6+
"net/url"
57
"path"
68
"strings"
79

@@ -52,10 +54,137 @@ func newSnapshotTarget(milvusCfg, backupCfg storage.Config, backupDir string) (s
5254
// the backup directory, which is what the backup meta records: an absolute uri would pin
5355
// the backup to the bucket and prefix it was written to.
5456
func (t snapshotTarget) metadataPath(metadataURI string) (string, error) {
55-
root := strings.TrimSuffix(t.Path, "/") + "/"
56-
if !strings.HasPrefix(metadataURI, root) {
57+
target, err := parseSnapshotURI(t.Path)
58+
if err != nil {
59+
return "", fmt.Errorf("backup: parse snapshot target %s: %w", t.Path, err)
60+
}
61+
metadata, err := parseSnapshotURI(metadataURI)
62+
if err != nil {
63+
return "", fmt.Errorf("backup: parse exported metadata %s: %w", metadataURI, err)
64+
}
65+
66+
if !target.sameStorage(metadata) {
5767
return "", fmt.Errorf("backup: exported metadata %s is not under the target %s", metadataURI, t.Path)
5868
}
5969

60-
return path.Join(t.Dir, strings.TrimPrefix(metadataURI, root)), nil
70+
root := strings.TrimSuffix(target.key, "/") + "/"
71+
if !strings.HasPrefix(metadata.key, root) {
72+
return "", fmt.Errorf("backup: exported metadata %s is not under the target %s", metadataURI, t.Path)
73+
}
74+
75+
return path.Join(t.Dir, strings.TrimPrefix(metadata.key, root)), nil
76+
}
77+
78+
// snapshotURI is the storage identity and object key encoded by one of the URI
79+
// forms Milvus accepts. Endpoint-style forms put the bucket in the first path
80+
// segment, while provider-style forms put it in the host.
81+
type snapshotURI struct {
82+
scheme string
83+
endpoint string
84+
bucket string
85+
key string
86+
endpointStyle bool
87+
}
88+
89+
func parseSnapshotURI(raw string) (snapshotURI, error) {
90+
u, err := url.Parse(raw)
91+
if err != nil {
92+
return snapshotURI{}, err
93+
}
94+
if u.Scheme == "" || u.Host == "" {
95+
return snapshotURI{}, fmt.Errorf("uri needs a scheme and host")
96+
}
97+
if u.User != nil || u.RawQuery != "" || u.ForceQuery || u.Fragment != "" {
98+
return snapshotURI{}, fmt.Errorf("uri must not contain credentials, query parameters, or a fragment")
99+
}
100+
101+
objectPath, err := url.PathUnescape(u.EscapedPath())
102+
if err != nil {
103+
return snapshotURI{}, fmt.Errorf("unescape object path: %w", err)
104+
}
105+
objectPath, err = cleanSnapshotObjectKey(objectPath)
106+
if err != nil {
107+
return snapshotURI{}, err
108+
}
109+
110+
scheme := strings.ToLower(u.Scheme)
111+
switch scheme {
112+
case "minio", "http", "https", "az", "azure":
113+
parts := strings.SplitN(objectPath, "/", 2)
114+
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
115+
return snapshotURI{}, fmt.Errorf("endpoint-style uri needs a bucket and object key")
116+
}
117+
endpoint, err := snapshotEndpoint(u, scheme)
118+
if err != nil {
119+
return snapshotURI{}, err
120+
}
121+
return snapshotURI{
122+
scheme: scheme,
123+
endpoint: endpoint,
124+
bucket: parts[0],
125+
key: parts[1],
126+
endpointStyle: true,
127+
}, nil
128+
case "s3", "gs", "gcs":
129+
if u.Port() != "" {
130+
return snapshotURI{}, fmt.Errorf("provider-style uri bucket must not contain a port")
131+
}
132+
return snapshotURI{
133+
scheme: scheme,
134+
bucket: u.Host,
135+
key: objectPath,
136+
}, nil
137+
default:
138+
return snapshotURI{}, fmt.Errorf("unsupported snapshot uri scheme %q", scheme)
139+
}
140+
}
141+
142+
func cleanSnapshotObjectKey(objectPath string) (string, error) {
143+
objectPath = strings.Trim(objectPath, "/")
144+
if objectPath == "" {
145+
return "", fmt.Errorf("uri needs an object key")
146+
}
147+
for _, part := range strings.Split(objectPath, "/") {
148+
if part == "." || part == ".." {
149+
return "", fmt.Errorf("object key must not contain path traversal")
150+
}
151+
}
152+
return path.Clean(objectPath), nil
153+
}
154+
155+
func snapshotEndpoint(u *url.URL, scheme string) (string, error) {
156+
host := strings.ToLower(strings.TrimSuffix(u.Hostname(), "."))
157+
if host == "" {
158+
return "", fmt.Errorf("uri needs an endpoint host")
159+
}
160+
port := u.Port()
161+
if port == "" {
162+
switch scheme {
163+
case "https":
164+
port = "443"
165+
case "http":
166+
port = "80"
167+
}
168+
}
169+
if port == "" {
170+
return host, nil
171+
}
172+
return net.JoinHostPort(host, port), nil
173+
}
174+
175+
func (u snapshotURI) sameStorage(other snapshotURI) bool {
176+
if u.endpointStyle != other.endpointStyle || u.bucket != other.bucket {
177+
return false
178+
}
179+
if u.endpointStyle {
180+
return u.endpoint == other.endpoint
181+
}
182+
return canonicalSnapshotScheme(u.scheme) == canonicalSnapshotScheme(other.scheme)
183+
}
184+
185+
func canonicalSnapshotScheme(scheme string) string {
186+
if scheme == "gcs" {
187+
return "gs"
188+
}
189+
return scheme
61190
}

core/backup/snapshot_target_test.go

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,10 +53,37 @@ func TestSnapshotTarget_MetadataPath(t *testing.T) {
5353
assert.Equal(t, "bundle/snapshots/449577/metadata/449580.json", got)
5454
})
5555

56+
// Milvus may report an endpoint-style URI using its transport scheme and omit
57+
// the default port. That is the same storage location as the minio URI sent in
58+
// the export request and must not make an otherwise successful backup fail.
59+
t.Run("CanonicalizedEndpointURI", func(t *testing.T) {
60+
target := snapshotTarget{
61+
Path: "minio://s3.us-west-2.amazonaws.com:443/backup-bucket/backup/mybackup/bundle",
62+
Dir: "bundle",
63+
}
64+
got, err := target.metadataPath("https://s3.us-west-2.amazonaws.com/backup-bucket/backup/mybackup/bundle/snapshots/449577/metadata/449580.json")
65+
require.NoError(t, err)
66+
assert.Equal(t, "bundle/snapshots/449577/metadata/449580.json", got)
67+
})
68+
5669
// Anything outside the target is not ours to record, and a path relative to the
5770
// backup directory could not describe it anyway.
5871
t.Run("OutsideTarget", func(t *testing.T) {
5972
_, err := target.metadataPath("s3://other-bucket/snapshots/449577/metadata/449580.json")
6073
assert.Error(t, err)
6174
})
75+
76+
t.Run("SiblingPrefixIsOutsideTarget", func(t *testing.T) {
77+
_, err := target.metadataPath("s3://backup-bucket/backup/mybackup/bundle-other/snapshots/449577/metadata/449580.json")
78+
assert.Error(t, err)
79+
})
80+
81+
t.Run("DifferentEndpointIsOutsideTarget", func(t *testing.T) {
82+
target := snapshotTarget{
83+
Path: "minio://s3.us-west-2.amazonaws.com:443/backup-bucket/backup/mybackup/bundle",
84+
Dir: "bundle",
85+
}
86+
_, err := target.metadataPath("https://s3.us-east-1.amazonaws.com/backup-bucket/backup/mybackup/bundle/snapshots/449577/metadata/449580.json")
87+
assert.Error(t, err)
88+
})
6289
}

0 commit comments

Comments
 (0)