add tests for restoring to deleted drives, and fix up some other tests and code at the same time.
450 lines
13 KiB
Go
450 lines
13 KiB
Go
package graph
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"strings"
|
|
"syscall"
|
|
|
|
"github.com/alcionai/clues"
|
|
"github.com/microsoftgraph/msgraph-sdk-go/models"
|
|
"github.com/microsoftgraph/msgraph-sdk-go/models/odataerrors"
|
|
"github.com/pkg/errors"
|
|
|
|
"github.com/alcionai/corso/src/internal/common/ptr"
|
|
"github.com/alcionai/corso/src/pkg/fault"
|
|
"github.com/alcionai/corso/src/pkg/filters"
|
|
)
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Error Interpretation Helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
type errorCode string
|
|
|
|
const (
|
|
// this auth error is a catch-all used by graph in a variety of cases:
|
|
// users without licenses, bad jwts, missing account permissions, etc.
|
|
AuthenticationError errorCode = "AuthenticationError"
|
|
// cannotOpenFileAttachment happen when an attachment is
|
|
// inaccessible. The error message is usually "OLE conversion
|
|
// failed for an attachment."
|
|
cannotOpenFileAttachment errorCode = "ErrorCannotOpenFileAttachment"
|
|
emailFolderNotFound errorCode = "ErrorSyncFolderNotFound"
|
|
errorAccessDenied errorCode = "ErrorAccessDenied"
|
|
errorItemNotFound errorCode = "ErrorItemNotFound"
|
|
// This error occurs when an attempt is made to create a folder that has
|
|
// the same name as another folder in the same parent. Such duplicate folder
|
|
// names are not allowed by graph.
|
|
folderExists errorCode = "ErrorFolderExists"
|
|
// Some datacenters are returning this when we try to get the inbox of a user
|
|
// that doesn't exist.
|
|
invalidUser errorCode = "ErrorInvalidUser"
|
|
itemNotFound errorCode = "itemNotFound"
|
|
MailboxNotEnabledForRESTAPI errorCode = "MailboxNotEnabledForRESTAPI"
|
|
malwareDetected errorCode = "malwareDetected"
|
|
// nameAlreadyExists occurs when a request with
|
|
// @microsoft.graph.conflictBehavior=fail finds a conflicting file.
|
|
nameAlreadyExists errorCode = "nameAlreadyExists"
|
|
quotaExceeded errorCode = "ErrorQuotaExceeded"
|
|
RequestResourceNotFound errorCode = "Request_ResourceNotFound"
|
|
// Returned when we try to get the inbox of a user that doesn't exist.
|
|
ResourceNotFound errorCode = "ResourceNotFound"
|
|
resyncRequired errorCode = "ResyncRequired"
|
|
syncFolderNotFound errorCode = "ErrorSyncFolderNotFound"
|
|
syncStateInvalid errorCode = "SyncStateInvalid"
|
|
syncStateNotFound errorCode = "SyncStateNotFound"
|
|
)
|
|
|
|
type errorMessage string
|
|
|
|
const (
|
|
IOErrDuringRead errorMessage = "IO error during request payload read"
|
|
MysiteURLNotFound errorMessage = "unable to retrieve user's mysite url"
|
|
MysiteNotFound errorMessage = "user's mysite not found"
|
|
NoSPLicense errorMessage = "Tenant does not have a SPO license"
|
|
)
|
|
|
|
const (
|
|
LabelsMalware = "malware_detected"
|
|
LabelsMysiteNotFound = "mysite_not_found"
|
|
LabelsNoSharePointLicense = "no_sharepoint_license"
|
|
|
|
// LabelsSkippable is used to determine if an error is skippable
|
|
LabelsSkippable = "skippable_errors"
|
|
)
|
|
|
|
var (
|
|
// The folder or item was deleted between the time we identified
|
|
// it and when we tried to fetch data for it.
|
|
ErrDeletedInFlight = clues.New("deleted in flight")
|
|
|
|
// Delta tokens can be desycned or expired. In either case, the token
|
|
// becomes invalid, and cannot be used again.
|
|
// https://learn.microsoft.com/en-us/graph/errors#code-property
|
|
ErrInvalidDelta = clues.New("invalid delta token")
|
|
|
|
// ErrItemAlreadyExistsConflict denotes that a post or put attempted to create
|
|
// an item which already exists by some unique identifier. The identifier is
|
|
// not always the id. For example, in onedrive, this error can be produced
|
|
// when filenames collide in a @microsoft.graph.conflictBehavior=fail request.
|
|
ErrItemAlreadyExistsConflict = clues.New("item already exists")
|
|
|
|
// ErrServiceNotEnabled identifies that a resource owner does not have
|
|
// access to a given service.
|
|
ErrServiceNotEnabled = clues.New("service is not enabled for that resource owner")
|
|
|
|
// Timeout errors are identified for tracking the need to retry calls.
|
|
// Other delay errors, like throttling, are already handled by the
|
|
// graph client's built-in retries.
|
|
// https://github.com/microsoftgraph/msgraph-sdk-go/issues/302
|
|
ErrTimeout = clues.New("communication timeout")
|
|
|
|
ErrResourceOwnerNotFound = clues.New("resource owner not found in tenant")
|
|
)
|
|
|
|
func IsErrAuthenticationError(err error) bool {
|
|
return hasErrorCode(err, AuthenticationError)
|
|
}
|
|
|
|
func IsErrDeletedInFlight(err error) bool {
|
|
if errors.Is(err, ErrDeletedInFlight) {
|
|
return true
|
|
}
|
|
|
|
if hasErrorCode(
|
|
err,
|
|
errorItemNotFound,
|
|
itemNotFound,
|
|
syncFolderNotFound,
|
|
) {
|
|
return true
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
func IsErrItemNotFound(err error) bool {
|
|
return hasErrorCode(err, itemNotFound)
|
|
}
|
|
|
|
func IsErrInvalidDelta(err error) bool {
|
|
return hasErrorCode(err, syncStateNotFound, resyncRequired, syncStateInvalid) ||
|
|
errors.Is(err, ErrInvalidDelta)
|
|
}
|
|
|
|
func IsErrQuotaExceeded(err error) bool {
|
|
return hasErrorCode(err, quotaExceeded)
|
|
}
|
|
|
|
func IsErrExchangeMailFolderNotFound(err error) bool {
|
|
// Not sure if we can actually see a resourceNotFound error here. I've only
|
|
// seen the latter two.
|
|
return hasErrorCode(err, ResourceNotFound, errorItemNotFound, MailboxNotEnabledForRESTAPI)
|
|
}
|
|
|
|
func IsErrUserNotFound(err error) bool {
|
|
if hasErrorCode(err, RequestResourceNotFound, invalidUser) {
|
|
return true
|
|
}
|
|
|
|
if hasErrorCode(err, ResourceNotFound) {
|
|
var odErr odataerrors.ODataErrorable
|
|
if !errors.As(err, &odErr) {
|
|
return false
|
|
}
|
|
|
|
mainMsg, _, _ := errData(odErr)
|
|
|
|
return strings.Contains(strings.ToLower(mainMsg), "user")
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
func IsErrCannotOpenFileAttachment(err error) bool {
|
|
return hasErrorCode(err, cannotOpenFileAttachment)
|
|
}
|
|
|
|
func IsErrAccessDenied(err error) bool {
|
|
return hasErrorCode(err, errorAccessDenied) || clues.HasLabel(err, LabelStatus(http.StatusForbidden))
|
|
}
|
|
|
|
func IsErrTimeout(err error) bool {
|
|
switch err := err.(type) {
|
|
case *url.Error:
|
|
return err.Timeout()
|
|
}
|
|
|
|
return errors.Is(err, ErrTimeout) ||
|
|
errors.Is(err, context.Canceled) ||
|
|
errors.Is(err, context.DeadlineExceeded) ||
|
|
errors.Is(err, http.ErrHandlerTimeout) ||
|
|
os.IsTimeout(err)
|
|
}
|
|
|
|
func IsErrConnectionReset(err error) bool {
|
|
return errors.Is(err, syscall.ECONNRESET)
|
|
}
|
|
|
|
func IsErrUnauthorized(err error) bool {
|
|
// TODO: refine this investigation. We don't currently know if
|
|
// a specific item download url expired, or if the full connection
|
|
// auth expired.
|
|
return clues.HasLabel(err, LabelStatus(http.StatusUnauthorized))
|
|
}
|
|
|
|
func IsErrItemAlreadyExistsConflict(err error) bool {
|
|
return hasErrorCode(err, nameAlreadyExists) ||
|
|
errors.Is(err, ErrItemAlreadyExistsConflict)
|
|
}
|
|
|
|
// LabelStatus transforms the provided statusCode into
|
|
// a standard label that can be attached to a clues error
|
|
// and later reviewed when checking error statuses.
|
|
func LabelStatus(statusCode int) string {
|
|
return fmt.Sprintf("status_code_%d", statusCode)
|
|
}
|
|
|
|
// IsMalware is true if the graphAPI returns a "malware detected" error code.
|
|
func IsMalware(err error) bool {
|
|
return hasErrorCode(err, malwareDetected)
|
|
}
|
|
|
|
func IsMalwareResp(ctx context.Context, resp *http.Response) bool {
|
|
// https://learn.microsoft.com/en-us/openspecs/sharepoint_protocols/ms-wsshp/ba4ee7a8-704c-4e9c-ab14-fa44c574bdf4
|
|
// https://learn.microsoft.com/en-us/openspecs/sharepoint_protocols/ms-wdvmoduu/6fa6d4a9-ac18-4cd7-b696-8a3b14a98291
|
|
return resp != nil &&
|
|
len(resp.Header) > 0 &&
|
|
resp.Header.Get("X-Virus-Infected") == "true"
|
|
}
|
|
|
|
func IsErrFolderExists(err error) bool {
|
|
return hasErrorCode(err, folderExists)
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// error parsers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func hasErrorCode(err error, codes ...errorCode) bool {
|
|
if err == nil {
|
|
return false
|
|
}
|
|
|
|
var oDataError odataerrors.ODataErrorable
|
|
if !errors.As(err, &oDataError) {
|
|
return false
|
|
}
|
|
|
|
code, ok := ptr.ValOK(oDataError.GetError().GetCode())
|
|
if !ok {
|
|
return false
|
|
}
|
|
|
|
cs := make([]string, len(codes))
|
|
for i, c := range codes {
|
|
cs[i] = string(c)
|
|
}
|
|
|
|
return filters.Equal(cs).Compare(code)
|
|
}
|
|
|
|
// Wrap is a helper function that extracts ODataError metadata from
|
|
// the error. If the error is not an ODataError type, returns the error.
|
|
func Wrap(ctx context.Context, e error, msg string) *clues.Err {
|
|
if e == nil {
|
|
return nil
|
|
}
|
|
|
|
var oDataError odataerrors.ODataErrorable
|
|
if !errors.As(e, &oDataError) {
|
|
return clues.Wrap(e, msg).WithClues(ctx)
|
|
}
|
|
|
|
mainMsg, data, innerMsg := errData(oDataError)
|
|
|
|
if len(mainMsg) > 0 {
|
|
e = clues.Stack(e, clues.New(mainMsg))
|
|
}
|
|
|
|
ce := clues.Wrap(e, msg).WithClues(ctx).With(data...).WithTrace(1)
|
|
|
|
return setLabels(ce, innerMsg)
|
|
}
|
|
|
|
// Stack is a helper function that extracts ODataError metadata from
|
|
// the error. If the error is not an ODataError type, returns the error.
|
|
func Stack(ctx context.Context, e error) *clues.Err {
|
|
if e == nil {
|
|
return nil
|
|
}
|
|
|
|
var oDataError *odataerrors.ODataError
|
|
if !errors.As(e, &oDataError) {
|
|
return clues.Stack(e).WithClues(ctx)
|
|
}
|
|
|
|
mainMsg, data, innerMsg := errData(oDataError)
|
|
|
|
if len(mainMsg) > 0 {
|
|
e = clues.Stack(e, clues.New(mainMsg))
|
|
}
|
|
|
|
ce := clues.Stack(e).WithClues(ctx).With(data...).WithTrace(1)
|
|
|
|
return setLabels(ce, innerMsg)
|
|
}
|
|
|
|
// stackReq is a helper function that extracts ODataError metadata from
|
|
// the error, plus http req/resp data. If the error is not an ODataError
|
|
// type, returns the error with only the req/resp values.
|
|
func stackReq(
|
|
ctx context.Context,
|
|
req *http.Request,
|
|
resp *http.Response,
|
|
e error,
|
|
) *clues.Err {
|
|
if e == nil {
|
|
return nil
|
|
}
|
|
|
|
se := Stack(ctx, e).
|
|
WithMap(reqData(req)).
|
|
WithMap(respData(resp))
|
|
|
|
return se
|
|
}
|
|
|
|
// Checks for the following conditions and labels the error accordingly:
|
|
// * mysiteNotFound | mysiteURLNotFound
|
|
// * malware
|
|
func setLabels(err *clues.Err, msg string) *clues.Err {
|
|
if err == nil {
|
|
return nil
|
|
}
|
|
|
|
f := filters.Contains([]string{msg})
|
|
|
|
if f.Compare(string(MysiteNotFound)) ||
|
|
f.Compare(string(MysiteURLNotFound)) {
|
|
err = err.Label(LabelsMysiteNotFound)
|
|
}
|
|
|
|
if f.Compare(string(NoSPLicense)) {
|
|
err = err.Label(LabelsNoSharePointLicense)
|
|
}
|
|
|
|
if IsMalware(err) {
|
|
err = err.Label(LabelsMalware)
|
|
}
|
|
|
|
return err
|
|
}
|
|
|
|
func errData(err odataerrors.ODataErrorable) (string, []any, string) {
|
|
data := make([]any, 0)
|
|
|
|
// Get MainError
|
|
mainErr := err.GetError()
|
|
mainMsg := ptr.Val(mainErr.GetMessage())
|
|
|
|
data = appendIf(data, "odataerror_code", mainErr.GetCode())
|
|
data = appendIf(data, "odataerror_message", mainErr.GetMessage())
|
|
data = appendIf(data, "odataerror_target", mainErr.GetTarget())
|
|
msgConcat := ptr.Val(mainErr.GetMessage()) + ptr.Val(mainErr.GetCode())
|
|
|
|
for i, d := range mainErr.GetDetails() {
|
|
pfx := fmt.Sprintf("odataerror_details_%d_", i)
|
|
data = appendIf(data, pfx+"code", d.GetCode())
|
|
data = appendIf(data, pfx+"message", d.GetMessage())
|
|
data = appendIf(data, pfx+"target", d.GetTarget())
|
|
msgConcat += ptr.Val(d.GetMessage())
|
|
}
|
|
|
|
inner := mainErr.GetInnererror()
|
|
if inner != nil {
|
|
data = appendIf(data, "odataerror_inner_cli_req_id", inner.GetClientRequestId())
|
|
data = appendIf(data, "odataerror_inner_req_id", inner.GetRequestId())
|
|
}
|
|
|
|
return mainMsg, data, strings.ToLower(msgConcat)
|
|
}
|
|
|
|
func reqData(req *http.Request) map[string]any {
|
|
if req == nil {
|
|
return nil
|
|
}
|
|
|
|
r := map[string]any{}
|
|
r["req_method"] = req.Method
|
|
r["req_len"] = req.ContentLength
|
|
|
|
if req.URL != nil {
|
|
r["req_url"] = LoggableURL(req.URL.String())
|
|
}
|
|
|
|
return r
|
|
}
|
|
|
|
func respData(resp *http.Response) map[string]any {
|
|
if resp == nil {
|
|
return nil
|
|
}
|
|
|
|
r := map[string]any{}
|
|
r["resp_status"] = resp.Status
|
|
r["resp_len"] = resp.ContentLength
|
|
|
|
return r
|
|
}
|
|
|
|
func appendIf(a []any, k string, v *string) []any {
|
|
if v == nil {
|
|
return a
|
|
}
|
|
|
|
return append(a, k, *v)
|
|
}
|
|
|
|
// ItemInfo gathers potentially useful information about a drive item,
|
|
// and aggregates that data into a map.
|
|
func ItemInfo(item models.DriveItemable) map[string]any {
|
|
m := map[string]any{}
|
|
|
|
creator := item.GetCreatedByUser()
|
|
if creator != nil {
|
|
m[fault.AddtlCreatedBy] = ptr.Val(creator.GetId())
|
|
}
|
|
|
|
lastmodder := item.GetLastModifiedByUser()
|
|
if lastmodder != nil {
|
|
m[fault.AddtlLastModBy] = ptr.Val(lastmodder.GetId())
|
|
}
|
|
|
|
parent := item.GetParentReference()
|
|
if parent != nil {
|
|
m[fault.AddtlContainerID] = ptr.Val(parent.GetId())
|
|
m[fault.AddtlContainerName] = ptr.Val(parent.GetName())
|
|
containerPath := ""
|
|
|
|
// Remove the "/drives/b!vF-sdsdsds-sdsdsa-sdsd/root:" prefix
|
|
splitPath := strings.SplitN(ptr.Val(parent.GetPath()), ":", 2)
|
|
if len(splitPath) > 1 {
|
|
containerPath = splitPath[1]
|
|
}
|
|
|
|
m[fault.AddtlContainerPath] = containerPath
|
|
}
|
|
|
|
malware := item.GetMalware()
|
|
if malware != nil {
|
|
m[fault.AddtlMalwareDesc] = ptr.Val(malware.GetDescription())
|
|
}
|
|
|
|
return m
|
|
}
|