Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 63 additions & 20 deletions services/graph/pkg/middleware/path_lookup.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,15 +27,34 @@ import (
// and exposes the remainder via chi.RouteContext().RoutePath. parseColonPath
// therefore only needs to handle the part below the drive:
//
// root-anchored: /root:/<path>[:/<suffix>][:]
// item-anchored: /items/{itemID}:/<path>[:/<suffix>][:]
// root-anchored: /root:/<path>[:/<suffix>][:]
// item-anchored: /items/{itemID}:/<path>[:/<suffix>][:]
// special-anchored: /special/{specialName}:/<path>[:/<suffix>][:]

type contextKey string

// OriginalPathContextKey holds the pre-rewrite request path for downstream
// tracing/logging consumers.
const OriginalPathContextKey contextKey = "graph.original_path"

// specialPathContextKey holds the decoded path below a special folder for the
// special-anchored colon form. Special folders are not part of the item tree,
// so the path cannot be resolved to an item id here; the handler interprets it.
const specialPathContextKey contextKey = "graph.special_path"

// SpecialFolderPath returns the path below the special folder for a request
// that arrived in the special-anchored colon form, e.g. "/key/sub" for
// /special/recyclebin:/key/sub:/children. ok is false for plain requests.
func SpecialFolderPath(ctx context.Context) (string, bool) {
p, ok := ctx.Value(specialPathContextKey).(string)
return p, ok
}

// WithSpecialFolderPath stores the decoded path below a special folder, see SpecialFolderPath.
func WithSpecialFolderPath(ctx context.Context, p string) context.Context {
return context.WithValue(ctx, specialPathContextKey, p)
}

// Sentinels distinguishing the resolution outcomes that map to specific HTTP
// statuses. Anything else surfaces as 500.
//
Expand Down Expand Up @@ -64,12 +83,17 @@ var (
// descended into a sub-router, routeHTTP matches against rctx.RoutePath and
// ignores r.URL.Path.)
//
// Two URL shapes are recognized:
// Three URL shapes are recognized:
//
// /drives/{driveID}/root:/<path>[:/<suffix>][:]
// /drives/{driveID}/items/{itemID}:/<path>[:/<suffix>][:]
// /drives/{driveID}/special/{specialName}:/<path>[:/<suffix>][:]
//
// Path resolution runs as the request user via CS3 Stat. NOT_FOUND and
// The special-anchored form is rewritten to /special/{specialName}{suffix}
// without a lookup; the decoded path travels in the context (see
// SpecialFolderPath) because special folders live outside the item tree.
//
// Path resolution for the other two runs as the request user via CS3 Stat. NOT_FOUND and
// PERMISSION_DENIED collapse to 404 (no existence disclosure); operational
// failures (gateway selection, RPC transport, unexpected status) surface
// as 5xx so outages aren't masked.
Expand All @@ -89,9 +113,30 @@ func ResolveGraphPath(gws pool.Selectable[gateway.GatewayAPIClient], logger log.
return
}

match, ok := parseColonPath(rctx.RoutePath)
if !ok {
// No colon-syntax match - pass through untouched.
next.ServeHTTP(w, r)
return
}

driveID := chi.URLParam(r, "driveID")
original := r.URL.Path
rewritten, err := rewriteColonPath(r.Context(), gws, l, driveID, rctx.RoutePath)
var rewritten string
var err error
if match.specialName != "" {
var specialPath string
specialPath, err = url.PathUnescape(match.relPath)
if err != nil {
l.Debug().Err(err).Str("relPath", match.relPath).Msg("undecodable path in special colon path")
err = errInvalidRequest
} else {
r = r.WithContext(WithSpecialFolderPath(r.Context(), specialPath))
rewritten = "/special/" + match.specialName + match.suffix
}
} else {
rewritten, err = rewriteColonPath(r.Context(), gws, l, driveID, match)
}
switch {
case errors.Is(err, errPathNotFound):
l.Debug().Str("original", original).Msg("colon-path resolution: not found")
Expand All @@ -111,10 +156,6 @@ func ResolveGraphPath(gws pool.Selectable[gateway.GatewayAPIClient], logger log.
w, r, http.StatusInternalServerError, "internal error resolving path",
)
return
case rewritten == "":
// No colon-syntax match - pass through untouched.
next.ServeHTTP(w, r)
return
}

l.Debug().
Expand All @@ -141,32 +182,26 @@ func ResolveGraphPath(gws pool.Selectable[gateway.GatewayAPIClient], logger log.
type colonMatch struct {
isItemAnchored bool // item-anchored form: anchor is itemAnchorID, validate against driveID
itemAnchorID string // itemID from the path for the item-anchored form (empty for root-anchored)
specialName string // special-anchored form: the special folder name (empty otherwise)
relPath string // relative path with leading slash
suffix string // suffix with leading slash (e.g. "/children"); may be empty
}

// rewriteColonPath returns:
// - "" + nil - no colon-syntax pattern matched (passthrough)
// - rewritten + nil - matched and resolved to a canonical RoutePath
// rewriteColonPath resolves a root- or item-anchored match and returns:
// - rewritten + nil - resolved to a canonical RoutePath
// - "" + errPathNotFound - path doesn't exist or user lacks permission (404)
// - "" + errInvalidRequest - malformed input (400)
// - "" + errUnauthenticated - gateway said caller isn't authenticated (401)
// - "" + other error - operational / internal failure (5xx)
//
// driveIDParam is the {driveID} route param (raw chi.URLParam value); routePath
// is chi.RouteContext().RoutePath (the part below /drives/{driveID}).
// driveIDParam is the {driveID} route param (raw chi.URLParam value).
func rewriteColonPath(
ctx context.Context,
gws pool.Selectable[gateway.GatewayAPIClient],
logger zerolog.Logger,
driveIDParam string,
routePath string,
match colonMatch,
) (string, error) {
match, ok := parseColonPath(routePath)
if !ok {
return "", nil
}

// RoutePath follows chi's RawPath, i.e. the percent-encoded wire form
// (e.g. "/Documents/My%20File"). A single PathUnescape reproduces exactly
// what net/http put in r.URL.Path; it is NOT a double-decode (a crafted
Expand Down Expand Up @@ -233,6 +268,7 @@ func rewriteColonPath(
//
// /root:/<path>[:/<suffix>][:]
// /items/<itemID>:/<path>[:/<suffix>][:]
// /special/<specialName>:/<path>[:/<suffix>][:]
//
// The structural delimiter is ":/" (a colon immediately followed by the
// leading slash of the path or suffix); a trailing ":" is the no-suffix
Expand Down Expand Up @@ -279,6 +315,13 @@ func parseColonPath(routePath string) (colonMatch, bool) {
}
m.isItemAnchored = true
m.itemAnchorID = itemID
case strings.HasPrefix(anchor, "/special/"):
// Special-anchored: /special/{specialName} with a single-segment name.
name := strings.TrimPrefix(anchor, "/special/")
if name == "" || strings.Contains(name, "/") {
return m, false
}
m.specialName = name
default:
return m, false
}
Expand Down
103 changes: 103 additions & 0 deletions services/graph/pkg/middleware/path_lookup_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,10 @@ type leafCapture struct {
driveID string // chi.URLParam(driveID)
itemID string // resolved item id, decoded via PathUnescape
original any // OriginalPathContextKey value

specialName string // chi.URLParam(specialName) for the special leaves
specialPath string // middleware.SpecialFolderPath value
specialPathSet bool // whether SpecialFolderPath reported ok
}

// newGraphTestRouter wires ResolveGraphPath into a chi router that mirrors the
Expand Down Expand Up @@ -94,6 +98,8 @@ func newGraphTestRouter(t *testing.T, gw *cs3mocks.GatewayAPIClient) (http.Handl
// mirror that here so we assert on the recovered id.
cap.itemID, _ = url.PathUnescape(raw)
cap.original = r.Context().Value(middleware.OriginalPathContextKey)
cap.specialName = chi.URLParam(r, "specialName")
cap.specialPath, cap.specialPathSet = middleware.SpecialFolderPath(r.Context())
w.WriteHeader(http.StatusOK)
}
}
Expand Down Expand Up @@ -128,6 +134,10 @@ func newGraphTestRouter(t *testing.T, gw *cs3mocks.GatewayAPIClient) (http.Handl
r.Get("/", leaf("item"))
r.Get("/children", leaf("children"))
})
r.Route("/special/{specialName}", func(r chi.Router) {
r.Get("/", leaf("special"))
r.Get("/children", leaf("specialChildren"))
})
})
})
return m, cap
Expand Down Expand Up @@ -469,3 +479,96 @@ func TestResolveGraphPath_OriginalPathContext(t *testing.T) {
assert.Equal(t, original, cap.original, "original URL must be available via OriginalPathContextKey")
assert.Equal(t, original, cap.urlPath, "r.URL.Path must remain the original request path")
}

// TestResolveGraphPath_SpecialFolder pins the special-anchored colon form:
// no CS3 lookup (special folders live outside the item tree), the request is
// re-routed to /special/{specialName}{suffix} and the decoded path below the
// special folder reaches the handler through SpecialFolderPath.
func TestResolveGraphPath_SpecialFolder(t *testing.T) {
tests := []struct {
name string
urlPath string
expectStatus int
expectHit string
expectPath string
expectPathSet bool
expectSpecName string
}{
{
name: "plain special route passes through without a path",
urlPath: "/graph/v1.0/drives/" + testDriveID + "/special/recyclebin/children",
expectStatus: http.StatusOK,
expectHit: "specialChildren",
expectPathSet: false,
expectSpecName: "recyclebin",
},
{
name: "special-anchored with /children rewrites and carries the path",
urlPath: "/graph/v1.0/drives/" + testDriveID + "/special/recyclebin:/key/sub:/children",
expectStatus: http.StatusOK,
expectHit: "specialChildren",
expectPath: "/key/sub",
expectPathSet: true,
expectSpecName: "recyclebin",
},
{
name: "special-anchored without suffix rewrites to the bare special URL",
urlPath: "/graph/v1.0/drives/" + testDriveID + "/special/recyclebin:/key",
expectStatus: http.StatusOK,
expectHit: "special",
expectPath: "/key",
expectPathSet: true,
expectSpecName: "recyclebin",
},
{
name: "special-anchored with trailing colon rewrites to the bare special URL",
urlPath: "/graph/v1.0/drives/" + testDriveID + "/special/recyclebin:/key:",
expectStatus: http.StatusOK,
expectHit: "special",
expectPath: "/key",
expectPathSet: true,
expectSpecName: "recyclebin",
},
{
name: "percent-encoded path is decoded once",
urlPath: "/graph/v1.0/drives/" + testDriveID + "/special/recyclebin:/key/My%20File:/children",
expectStatus: http.StatusOK,
expectHit: "specialChildren",
expectPath: "/key/My File",
expectPathSet: true,
expectSpecName: "recyclebin",
},
{
// The name is a single segment; a slash inside means this is not the
// colon form and chi decides (here: no such route).
name: "multi-segment special name is not colon syntax",
urlPath: "/graph/v1.0/drives/" + testDriveID + "/special/recyclebin/x:/key:/children",
expectStatus: http.StatusNotFound,
expectHit: "",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
gw := &cs3mocks.GatewayAPIClient{}
router, cap := newGraphTestRouter(t, gw)
req := httptest.NewRequest(http.MethodGet, "http://localhost"+tt.urlPath, nil)
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)

assert.Equal(t, tt.expectStatus, rr.Code, "status code")
assert.Equal(t, tt.expectHit, cap.hit, "leaf handler reached")
gw.AssertNotCalled(t, "Stat", mock.Anything, mock.Anything)

if tt.expectHit != "" {
assert.Equal(t, testDriveID, cap.driveID, "driveID param")
assert.Equal(t, tt.expectSpecName, cap.specialName, "specialName param")
assert.Equal(t, tt.expectPathSet, cap.specialPathSet, "SpecialFolderPath ok")
assert.Equal(t, tt.expectPath, cap.specialPath, "SpecialFolderPath value")
// r.URL.Path is the decoded form; only chi's RoutePath is rewritten.
decoded, _ := url.PathUnescape(tt.urlPath)
assert.Equal(t, decoded, cap.urlPath, "r.URL.Path must remain the original request path")
}
})
}
}
Loading