bf9395e022
CI / license-header (push) Has been skipped
CI / e2e-dry-run (push) Has been skipped
CI / fast-gate (push) Failing after 0s
Test PR Label Logic / test-pr-labels (push) Failing after 1s
Skill Format Check / check-format (push) Failing after 2s
CI / security (push) Failing after 5s
CI / unit-test (push) Has been skipped
CI / lint (push) Has been skipped
CI / script-test (push) Has been skipped
CI / deterministic-gate (push) Has been skipped
CI / coverage (push) Has been skipped
CI / results (push) Has been cancelled
CI / deadcode (push) Has been cancelled
CI / e2e-live (push) Has been cancelled
984 lines
41 KiB
Go
984 lines
41 KiB
Go
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package drive
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"io/fs"
|
|
"net/http"
|
|
"path"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
|
|
|
"github.com/larksuite/cli/errs"
|
|
"github.com/larksuite/cli/internal/validate"
|
|
"github.com/larksuite/cli/shortcuts/common"
|
|
)
|
|
|
|
const (
|
|
drivePushIfExistsOverwrite = "overwrite"
|
|
drivePushIfExistsSmart = "smart"
|
|
drivePushIfExistsSkip = "skip"
|
|
)
|
|
|
|
type drivePushItem struct {
|
|
RelPath string `json:"rel_path"`
|
|
FileToken string `json:"file_token,omitempty"`
|
|
Action string `json:"action"`
|
|
Version string `json:"version,omitempty"`
|
|
SizeBytes int64 `json:"size_bytes,omitempty"`
|
|
Error string `json:"error,omitempty"`
|
|
Hint string `json:"hint,omitempty"`
|
|
Phase string `json:"phase,omitempty"`
|
|
ErrorClass string `json:"error_class,omitempty"`
|
|
Code int `json:"code,omitempty"`
|
|
Subtype string `json:"subtype,omitempty"`
|
|
Retryable *bool `json:"retryable,omitempty"`
|
|
}
|
|
|
|
type driveBatchFailureDecision struct {
|
|
Class string
|
|
Code int
|
|
Subtype string
|
|
Retryable bool
|
|
Terminal bool
|
|
Hint string
|
|
}
|
|
|
|
// DrivePush is a one-way, file-level mirror from a local directory onto a
|
|
// Drive folder: walks --local-dir, recursively lists --folder-token, and for
|
|
// each rel_path uploads (or overwrites) the corresponding Drive file. With
|
|
// --delete-remote --yes, any type=file entry on Drive that has no local
|
|
// counterpart is removed; online docs (docx/sheet/bitable/...), shortcuts
|
|
// and folders are never deleted, so this is "file-level" mirror — the
|
|
// command does not attempt to remove remote-only directories or close gaps
|
|
// in directory structure that exists on Drive but not locally.
|
|
//
|
|
// Only Drive entries with type=file participate in upload/overwrite/delete;
|
|
// online documents have no equivalent local binary. Sub-folders are created
|
|
// on Drive on demand via /open-apis/drive/v1/files/create_folder so the
|
|
// remote tree mirrors the local tree.
|
|
//
|
|
// The overwrite path passes the existing file_token as a form field on
|
|
// /open-apis/drive/v1/files/upload_all, mirroring the markdown +overwrite
|
|
// contract in shortcuts/markdown. The Drive backend exposing that field is
|
|
// being rolled out; until rollout completes, --if-exists defaults to "skip"
|
|
// so the safe path (do not touch existing remote files) is the default and
|
|
// callers must opt into "overwrite" explicitly.
|
|
var DrivePush = common.Shortcut{
|
|
Service: "drive",
|
|
Command: "+push",
|
|
Description: "File-level mirror of a local directory onto a Drive folder (local → Drive; remote-only directories are not removed)",
|
|
Risk: "write",
|
|
// Narrowed scopes follow the precedent set by drive +status / +pull:
|
|
// drive:drive is policy-disabled in some tenants, so this shortcut sticks
|
|
// to the smallest set the *core* path needs. space:folder:create is
|
|
// always declared because mirroring a non-flat tree calls
|
|
// /open-apis/drive/v1/files/create_folder on demand and we want the
|
|
// framework's pre-flight scope check to catch missing grants before any
|
|
// upload — otherwise a partial push could land top-level files and then
|
|
// trip on a missing folder grant for a sub-tree, leaving a half-synced
|
|
// state.
|
|
//
|
|
// space:document:delete is intentionally NOT in the default set even
|
|
// though --delete-remote needs it. The framework pre-check (runner.go
|
|
// checkShortcutScopes) runs unconditionally before Validate / dry-run,
|
|
// so declaring it here would make every plain push (and every
|
|
// --dry-run) fail for callers that only granted upload scopes.
|
|
//
|
|
// Instead, Validate runs a *conditional* pre-flight via
|
|
// runtime.EnsureScopes when both --delete-remote and --yes are on, so
|
|
// the missing grant fails the run upfront — before any upload —
|
|
// rather than landing files first and tripping on missing_scope when
|
|
// the cleanup pass tries to delete. That avoids the half-synced state
|
|
// (files uploaded, orphans never cleaned up) that the unconditional
|
|
// pre-check would otherwise prevent only by also blocking plain
|
|
// pushes.
|
|
Scopes: []string{"drive:drive.metadata:readonly", "drive:file:upload", "space:folder:create"},
|
|
AuthTypes: []string{"user", "bot"},
|
|
Flags: []common.Flag{
|
|
{Name: "local-dir", Desc: "local root directory (relative to cwd)", Required: true},
|
|
{Name: "folder-token", Desc: "target Drive folder token", Required: true},
|
|
{Name: "if-exists", Desc: "policy when a Drive file already exists at the same rel_path (skip = never touch existing remote files; smart = skip when remote modified_time already matches or is newer, otherwise fall through to overwrite semantics; overwrite = always replace)", Default: drivePushIfExistsSkip, Enum: []string{drivePushIfExistsOverwrite, drivePushIfExistsSmart, drivePushIfExistsSkip}},
|
|
{Name: "on-duplicate-remote", Desc: "policy when multiple remote Drive entries map to the same rel_path", Default: driveDuplicateRemoteFail, Enum: []string{driveDuplicateRemoteFail, driveDuplicateRemoteNewest, driveDuplicateRemoteOldest}},
|
|
{Name: "delete-remote", Type: "bool", Desc: "delete Drive files absent locally (file-level mirror; remote-only directories are not removed); requires --yes"},
|
|
{Name: "yes", Type: "bool", Desc: "confirm --delete-remote before deleting Drive files"},
|
|
},
|
|
Tips: []string{
|
|
"This is a file-level mirror: only type=file entries are uploaded, overwritten or deleted. Online docs (docx, sheet, bitable, mindnote, slides), shortcuts, and remote-only directories are never touched.",
|
|
"Local directory structure (including empty directories) is mirrored to Drive via create_folder; existing remote folders are reused.",
|
|
"For repeat syncs, --if-exists=smart is a best-effort incremental mode: it compares local mtime with Drive modified_time and skips uploads when the remote copy is already up to date; otherwise it falls through to the same overwrite path as --if-exists=overwrite.",
|
|
"Duplicate remote rel_path conflicts fail by default before upload, overwrite, or delete. Use --on-duplicate-remote=newest|oldest only when the conflict is duplicate files and you explicitly want to target one.",
|
|
"Default --if-exists=skip is the safe choice while the upload_all overwrite-version field is rolling out. Pass --if-exists=overwrite to replace remote bytes; on tenants without the field it surfaces a structured api_error and the run exits non-zero. The same caveat applies when --if-exists=smart decides the remote file is older and falls through to overwrite.",
|
|
"--delete-remote requires --yes; without --yes the command is rejected upfront so a stray flag never deletes anything.",
|
|
"--delete-remote --yes also requires the space:document:delete scope. Validate runs a dynamic pre-flight check when the flag is on, so a missing grant fails the run before any upload — preventing a half-synced state where files were uploaded but the cleanup pass cannot delete.",
|
|
"Item-level failures (upload, overwrite, folder, delete) bump summary.failed and the run exits non-zero. If any upload or folder step fails, the --delete-remote phase is skipped entirely so a partial upload never triggers remote deletion.",
|
|
},
|
|
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
|
localDir := strings.TrimSpace(runtime.Str("local-dir"))
|
|
folderToken := strings.TrimSpace(runtime.Str("folder-token"))
|
|
if localDir == "" {
|
|
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--local-dir is required").WithParam("--local-dir")
|
|
}
|
|
if folderToken == "" {
|
|
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--folder-token is required").WithParam("--folder-token")
|
|
}
|
|
if err := validate.ResourceName(folderToken, "--folder-token"); err != nil {
|
|
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--folder-token")
|
|
}
|
|
if _, err := validate.SafeLocalFlagPath("--local-dir", localDir); err != nil {
|
|
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--local-dir")
|
|
}
|
|
info, err := runtime.FileIO().Stat(localDir)
|
|
if err != nil {
|
|
return driveInputStatError(err)
|
|
}
|
|
if !info.IsDir() {
|
|
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--local-dir is not a directory: %s", localDir).WithParam("--local-dir")
|
|
}
|
|
if runtime.Bool("delete-remote") && !runtime.Bool("yes") {
|
|
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--delete-remote requires --yes (high-risk: deletes Drive files absent locally)").WithParam("--yes")
|
|
}
|
|
// Conditional scope pre-check: when --delete-remote --yes is set, the
|
|
// run will issue DELETE /open-apis/drive/v1/files/<token> after the
|
|
// upload phase. The default Scopes list intentionally omits
|
|
// space:document:delete so plain pushes don't get blocked on a grant
|
|
// they don't need (see the Scopes block above), but at this point we
|
|
// know the run will need it — pre-flight here so a missing grant
|
|
// fails before any upload, instead of after, which would otherwise
|
|
// leave the tenant in a half-synced state (files uploaded, remote
|
|
// orphans never cleaned up). EnsureScopes is a silent no-op when no
|
|
// token / scope metadata is available, so test envs and tenants
|
|
// where the resolver doesn't expose scopes still proceed and rely on
|
|
// the API-level missing_scope error.
|
|
if runtime.Bool("delete-remote") && runtime.Bool("yes") {
|
|
if err := runtime.EnsureScopes([]string{"space:document:delete"}); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
},
|
|
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
|
return common.NewDryRunAPI().
|
|
Desc("Walk --local-dir, recursively list --folder-token, then upload new files, skip existing, skip up-to-date files when --if-exists=smart, overwrite when --if-exists=overwrite, and (when --delete-remote --yes is set) delete Drive files absent locally.").
|
|
GET("/open-apis/drive/v1/files").
|
|
Set("folder_token", runtime.Str("folder-token"))
|
|
},
|
|
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
|
localDir := strings.TrimSpace(runtime.Str("local-dir"))
|
|
folderToken := strings.TrimSpace(runtime.Str("folder-token"))
|
|
ifExists := strings.TrimSpace(runtime.Str("if-exists"))
|
|
if ifExists == "" {
|
|
// Default to the safe "skip" policy: do not touch already-present
|
|
// remote files. Callers must pass --if-exists=overwrite to opt
|
|
// into the overwrite-with-version path that depends on the
|
|
// rolling-out upload_all `file_token`/`version` protocol field.
|
|
ifExists = drivePushIfExistsSkip
|
|
}
|
|
duplicateRemote := strings.TrimSpace(runtime.Str("on-duplicate-remote"))
|
|
if duplicateRemote == "" {
|
|
duplicateRemote = driveDuplicateRemoteFail
|
|
}
|
|
deleteRemote := runtime.Bool("delete-remote")
|
|
|
|
// Resolve --local-dir to its canonical absolute path before walking.
|
|
// SafeInputPath fully evaluates symlinks across the entire path,
|
|
// which closes the kernel-level escape route that filepath.Clean
|
|
// alone misses (e.g. "link/.." string-cleans to "." but the kernel
|
|
// resolves through link's target's parent). Walking the canonical
|
|
// root sidesteps that, and the matching cwd canonical lets each
|
|
// absolute walk hit be converted to a cwd-relative path that
|
|
// FileIO.Open's SafeInputPath check still accepts.
|
|
safeRoot, err := validate.SafeInputPath(localDir)
|
|
if err != nil {
|
|
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--local-dir: %s", err).WithParam("--local-dir")
|
|
}
|
|
cwdCanonical, err := validate.SafeInputPath(".")
|
|
if err != nil {
|
|
return errs.NewValidationError(errs.SubtypeInvalidArgument, "could not resolve cwd: %s", err)
|
|
}
|
|
|
|
fmt.Fprintf(runtime.IO().ErrOut, "Walking local: %s\n", localDir)
|
|
localFiles, localDirs, err := drivePushWalkLocal(safeRoot, cwdCanonical)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
fmt.Fprintf(runtime.IO().ErrOut, "Listing Drive folder: %s\n", common.MaskToken(folderToken))
|
|
entries, err := listRemoteFolderEntries(ctx, runtime, folderToken, "")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if duplicates := blockingRemotePathConflicts(entries, duplicateRemote); len(duplicates) > 0 {
|
|
return duplicateRemotePathError(duplicates)
|
|
}
|
|
// Two views over the same listing:
|
|
// - remoteFiles drives upload / overwrite / orphan-delete
|
|
// decisions (only type=file entries are upload candidates;
|
|
// online docs / shortcuts are intentionally never overwritten
|
|
// or deleted by --delete-remote).
|
|
// - remoteFolders is the create_folder cache: lets the upload
|
|
// path skip create_folder when an intermediate folder already
|
|
// exists, and keeps directory recreation idempotent across
|
|
// reruns.
|
|
remoteFiles, remoteFolders, remoteFileGroups, err := drivePushRemoteViews(entries, duplicateRemote)
|
|
if err != nil {
|
|
return errs.WrapInternal(err)
|
|
}
|
|
|
|
var uploaded, skipped, failed, deletedRemote int
|
|
items := make([]drivePushItem, 0)
|
|
// uploadFailed tracks whether any folder-creation, upload or
|
|
// overwrite step failed. The --delete-remote phase only runs when
|
|
// this stays false: a partial upload that then proceeds to delete
|
|
// remote orphans would leave the tenant half-synced (files missing
|
|
// locally and now on Drive too), which is the worst-of-both-worlds
|
|
// outcome the review flagged.
|
|
uploadFailed := false
|
|
aborted := false
|
|
|
|
// folderCache holds rel_path → folder_token. Seeded from the remote
|
|
// listing (so we don't recreate folders that already exist) and
|
|
// extended in-place as drivePushEnsureFolder mints new ones.
|
|
folderCache := map[string]string{"": folderToken}
|
|
for relDir, entry := range remoteFolders {
|
|
folderCache[relDir] = entry.FileToken
|
|
}
|
|
|
|
// Mirror local directory structure first, so empty directories
|
|
// are not silently dropped. Pre-creating also frees the upload
|
|
// loop from doing on-demand mkdir for every file's parent chain
|
|
// (the cache makes both paths idempotent, but pre-creation keeps
|
|
// items[] in a tidy "folders, then files" shape).
|
|
for _, relDir := range localDirs {
|
|
if _, alreadyRemote := folderCache[relDir]; alreadyRemote {
|
|
// Folder already exists on Drive — nothing to do; staying
|
|
// silent (no items[] entry) avoids noise on reruns.
|
|
continue
|
|
}
|
|
if _, ensureErr := drivePushEnsureFolder(ctx, runtime, folderToken, relDir, folderCache); ensureErr != nil {
|
|
item, terminal := drivePushFailedItem(relDir, "", "failed", "create_folder", 0, ensureErr)
|
|
items = append(items, item)
|
|
failed++
|
|
uploadFailed = true
|
|
if terminal {
|
|
aborted = true
|
|
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +push after terminal %s failure: %v\n", item.Phase, ensureErr)
|
|
break
|
|
}
|
|
continue
|
|
}
|
|
items = append(items, drivePushItem{RelPath: relDir, FileToken: folderCache[relDir], Action: "folder_created"})
|
|
}
|
|
|
|
// Upload local-only and overwrite/skip already-present files in a
|
|
// stable order so output is reproducible.
|
|
localPaths := make([]string, 0, len(localFiles))
|
|
for p := range localFiles {
|
|
localPaths = append(localPaths, p)
|
|
}
|
|
sort.Strings(localPaths)
|
|
|
|
for _, rel := range localPaths {
|
|
localFile := localFiles[rel]
|
|
if uploadFailed && aborted {
|
|
break
|
|
}
|
|
|
|
if entry, ok := remoteFiles[rel]; ok {
|
|
if drivePushShouldSkipExisting(localFile, entry, ifExists) {
|
|
items = append(items, drivePushItem{RelPath: rel, FileToken: entry.FileToken, Action: "skipped", SizeBytes: localFile.Size})
|
|
skipped++
|
|
continue
|
|
}
|
|
parentToken, parentErr := drivePushEnsureParentToken(ctx, runtime, folderToken, rel, folderCache)
|
|
if parentErr != nil {
|
|
item, terminal := drivePushFailedItem(rel, entry.FileToken, "failed", "create_folder", localFile.Size, parentErr)
|
|
items = append(items, item)
|
|
failed++
|
|
uploadFailed = true
|
|
if terminal {
|
|
aborted = true
|
|
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +push after terminal %s failure: %v\n", item.Phase, parentErr)
|
|
break
|
|
}
|
|
continue
|
|
}
|
|
token, version, upErr := drivePushUploadFile(ctx, runtime, localFile, entry.FileToken, parentToken)
|
|
if upErr != nil {
|
|
// Token contract on overwrite failure: an in-place
|
|
// overwrite preserves the file's token, so the
|
|
// existing entry.FileToken is normally still the
|
|
// authoritative pointer to the (possibly already
|
|
// rewritten) Drive file. But the protocol does not
|
|
// strictly forbid the backend from minting a new
|
|
// token, and a partial-success response can return a
|
|
// non-empty file_token alongside an error (the
|
|
// missing-version case below is the immediate
|
|
// concern: bytes hit the disk, version field
|
|
// missing, so we surface a structured error). Prefer
|
|
// the freshly returned token when one was produced,
|
|
// fall back to entry.FileToken otherwise — that way
|
|
// callers still have a usable handle to whatever
|
|
// state Drive ended up in.
|
|
failedToken := token
|
|
if failedToken == "" {
|
|
failedToken = entry.FileToken
|
|
}
|
|
item, terminal := drivePushFailedItem(rel, failedToken, "failed", "upload", localFile.Size, upErr)
|
|
items = append(items, item)
|
|
failed++
|
|
uploadFailed = true
|
|
if terminal {
|
|
aborted = true
|
|
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +push after terminal %s failure: %v\n", item.Phase, upErr)
|
|
break
|
|
}
|
|
continue
|
|
}
|
|
items = append(items, drivePushItem{RelPath: rel, FileToken: token, Action: "overwritten", Version: version, SizeBytes: localFile.Size})
|
|
uploaded++
|
|
continue
|
|
}
|
|
|
|
parentRel := drivePushParentRel(rel)
|
|
parentToken, ensureErr := drivePushEnsureFolder(ctx, runtime, folderToken, parentRel, folderCache)
|
|
if ensureErr != nil {
|
|
item, terminal := drivePushFailedItem(rel, "", "failed", "create_folder", localFile.Size, ensureErr)
|
|
items = append(items, item)
|
|
failed++
|
|
uploadFailed = true
|
|
if terminal {
|
|
aborted = true
|
|
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +push after terminal %s failure: %v\n", item.Phase, ensureErr)
|
|
break
|
|
}
|
|
continue
|
|
}
|
|
token, _, upErr := drivePushUploadFile(ctx, runtime, localFile, "", parentToken)
|
|
if upErr != nil {
|
|
item, terminal := drivePushFailedItem(rel, "", "failed", "upload", localFile.Size, upErr)
|
|
items = append(items, item)
|
|
failed++
|
|
uploadFailed = true
|
|
if terminal {
|
|
aborted = true
|
|
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +push after terminal %s failure: %v\n", item.Phase, upErr)
|
|
break
|
|
}
|
|
continue
|
|
}
|
|
items = append(items, drivePushItem{RelPath: rel, FileToken: token, Action: "uploaded", SizeBytes: localFile.Size})
|
|
uploaded++
|
|
}
|
|
|
|
// Skip the delete phase entirely on any upstream failure. The orphan
|
|
// loop deletes by remote token and is unrecoverable; running it
|
|
// after a failed upload risks deleting a file the partial upload
|
|
// would have replaced on a successful re-run, leaving the tenant
|
|
// in a worse state than where we started. Surface the skipped
|
|
// delete as a hint in stderr so operators know the cleanup pass
|
|
// is pending and can re-run after fixing the upload.
|
|
if deleteRemote && uploadFailed {
|
|
fmt.Fprintf(runtime.IO().ErrOut,
|
|
"Skipping --delete-remote: %d earlier failure(s) — re-run after resolving them.\n",
|
|
failed)
|
|
}
|
|
if deleteRemote && !uploadFailed {
|
|
// Stable iteration order so failures (and tests) are deterministic.
|
|
remoteRelPaths := make([]string, 0, len(remoteFileGroups))
|
|
for p := range remoteFileGroups {
|
|
remoteRelPaths = append(remoteRelPaths, p)
|
|
}
|
|
sort.Strings(remoteRelPaths)
|
|
|
|
abortDelete := false
|
|
for _, rel := range remoteRelPaths {
|
|
if abortDelete {
|
|
break
|
|
}
|
|
keepToken := ""
|
|
if _, ok := localFiles[rel]; ok {
|
|
if chosen, ok := remoteFiles[rel]; ok {
|
|
keepToken = chosen.FileToken
|
|
}
|
|
}
|
|
for _, entry := range remoteFileGroups[rel] {
|
|
if entry.FileToken == keepToken {
|
|
continue
|
|
}
|
|
if err := drivePushDeleteFile(ctx, runtime, entry.FileToken); err != nil {
|
|
if drivePushIsAlreadyDeleted(err) {
|
|
items = append(items, drivePushItem{RelPath: rel, FileToken: entry.FileToken, Action: "already_deleted"})
|
|
continue
|
|
}
|
|
item, terminal := drivePushFailedItem(rel, entry.FileToken, "delete_failed", "delete", 0, err)
|
|
items = append(items, item)
|
|
failed++
|
|
if terminal {
|
|
aborted = true
|
|
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +push after terminal %s failure: %v\n", item.Phase, err)
|
|
abortDelete = true
|
|
break
|
|
}
|
|
continue
|
|
}
|
|
items = append(items, drivePushItem{RelPath: rel, FileToken: entry.FileToken, Action: "deleted_remote"})
|
|
deletedRemote++
|
|
}
|
|
}
|
|
}
|
|
|
|
payload := map[string]interface{}{
|
|
"summary": map[string]interface{}{
|
|
"uploaded": uploaded,
|
|
"skipped": skipped,
|
|
"failed": failed,
|
|
"deleted_remote": deletedRemote,
|
|
"aborted": aborted,
|
|
},
|
|
"items": items,
|
|
}
|
|
// On any item-level failure (upload, overwrite, folder, or delete) the
|
|
// command reports a partial failure: the summary + per-item items[] stay
|
|
// machine-readable on stdout (ok:false) and the process exits non-zero,
|
|
// so callers / scripts / agents can react.
|
|
if failed > 0 {
|
|
return runtime.OutPartialFailure(payload, nil)
|
|
}
|
|
runtime.Out(payload, nil)
|
|
return nil
|
|
},
|
|
}
|
|
|
|
// drivePushLocalFile records what we need to upload a local regular file:
|
|
// a rel_path used for output and Drive layout, the cwd-relative path that
|
|
// FileIO.Open accepts, the file size (drives single/multipart selection),
|
|
// and the basename used as Drive's file_name.
|
|
type drivePushLocalFile struct {
|
|
RelPath string
|
|
OpenPath string
|
|
FileName string
|
|
Size int64
|
|
ModTime time.Time
|
|
}
|
|
|
|
// drivePushWalkLocal walks the canonical absolute root produced by
|
|
// SafeInputPath. Same threat model as +pull/+status: the validated root
|
|
// is not a symlink itself, and WalkDir's default policy (do not follow
|
|
// child symlinks) keeps the traversal inside that canonical subtree, so
|
|
// the OpenPath we hand to FileIO.Open stays inside cwd.
|
|
//
|
|
// Returns two views:
|
|
// - files: rel_path → file metadata; drives the upload/skip/overwrite loop.
|
|
// - dirs: every non-root directory rel_path encountered. Used to mirror
|
|
// empty directories (which would otherwise be silently dropped because
|
|
// the upload loop only iterates files); non-empty directories appear
|
|
// here too but are harmless because drivePushEnsureFolder is cached.
|
|
func drivePushWalkLocal(root, cwdCanonical string) (map[string]drivePushLocalFile, []string, error) {
|
|
files := make(map[string]drivePushLocalFile)
|
|
dirsSet := make(map[string]struct{})
|
|
// FileIO has no walker today and shortcuts can't import internal/vfs
|
|
// (depguard rule shortcuts-no-vfs). The walk root is the canonical
|
|
// absolute path returned by validate.SafeInputPath, so it is no
|
|
// longer a symlink itself, and WalkDir's default child-symlink
|
|
// policy keeps the traversal inside the validated subtree.
|
|
err := filepath.WalkDir(root, func(absPath string, d fs.DirEntry, walkErr error) error { //nolint:forbidigo // see comment above
|
|
if walkErr != nil {
|
|
return walkErr
|
|
}
|
|
rel, err := filepath.Rel(root, absPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
relSlash := filepath.ToSlash(rel)
|
|
if d.IsDir() {
|
|
// Skip the root itself ("."): that is --folder-token, already
|
|
// the parent we mirror into, not a sub-folder we need to
|
|
// create.
|
|
if relSlash != "." {
|
|
dirsSet[relSlash] = struct{}{}
|
|
}
|
|
return nil
|
|
}
|
|
if !d.Type().IsRegular() {
|
|
return nil
|
|
}
|
|
relToCwd, err := filepath.Rel(cwdCanonical, absPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
info, err := d.Info()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
files[relSlash] = drivePushLocalFile{
|
|
RelPath: relSlash,
|
|
OpenPath: relToCwd,
|
|
FileName: filepath.Base(rel),
|
|
Size: info.Size(),
|
|
ModTime: info.ModTime(),
|
|
}
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return nil, nil, errs.NewInternalError(errs.SubtypeFileIO, "walk %s: %s", root, err).WithCause(err)
|
|
}
|
|
dirs := make([]string, 0, len(dirsSet))
|
|
for d := range dirsSet {
|
|
dirs = append(dirs, d)
|
|
}
|
|
// Shallow-first ordering ensures parents are created before children;
|
|
// drivePushEnsureFolder also handles parent recursion on its own, but
|
|
// emitting items[] in shallow-first order matches what users expect.
|
|
sort.Slice(dirs, func(i, j int) bool {
|
|
di, dj := strings.Count(dirs[i], "/"), strings.Count(dirs[j], "/")
|
|
if di != dj {
|
|
return di < dj
|
|
}
|
|
return dirs[i] < dirs[j]
|
|
})
|
|
return files, dirs, nil
|
|
}
|
|
|
|
func drivePushShouldSkipExisting(localFile drivePushLocalFile, remoteFile driveRemoteEntry, ifExists string) bool {
|
|
switch ifExists {
|
|
case drivePushIfExistsSkip:
|
|
return true
|
|
case drivePushIfExistsSmart:
|
|
return drivePushShouldSkipSmart(localFile, remoteFile)
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func drivePushShouldSkipSmart(localFile drivePushLocalFile, remoteFile driveRemoteEntry) bool {
|
|
cmp, ok := compareDriveRemoteModifiedToLocal(remoteFile.ModifiedTime, localFile.ModTime)
|
|
if !ok {
|
|
// Smart mode is an optimization. If the timestamp is missing or
|
|
// malformed, fall back to the safe transfer path instead of silently
|
|
// skipping an update we could not compare.
|
|
return false
|
|
}
|
|
// Remote is already at least as new as the local file, so another
|
|
// upload would be redundant.
|
|
return cmp >= 0
|
|
}
|
|
|
|
func drivePushFailedItem(relPath, fileToken, action, phase string, sizeBytes int64, err error) (drivePushItem, bool) {
|
|
decision := driveClassifyBatchFailure(err)
|
|
item := drivePushItem{
|
|
RelPath: relPath,
|
|
FileToken: fileToken,
|
|
Action: action,
|
|
SizeBytes: sizeBytes,
|
|
Error: err.Error(),
|
|
Hint: decision.Hint,
|
|
Phase: phase,
|
|
ErrorClass: decision.Class,
|
|
Code: decision.Code,
|
|
Subtype: decision.Subtype,
|
|
Retryable: driveBoolPtr(decision.Retryable),
|
|
}
|
|
return item, decision.Terminal
|
|
}
|
|
|
|
func driveBoolPtr(v bool) *bool {
|
|
return &v
|
|
}
|
|
|
|
func driveClassifyBatchFailure(err error) driveBatchFailureDecision {
|
|
decision := driveBatchFailureDecision{Class: "unknown", Retryable: errs.IsRetryable(err)}
|
|
problem, ok := errs.ProblemOf(err)
|
|
if !ok {
|
|
return decision
|
|
}
|
|
decision.Code = problem.Code
|
|
decision.Subtype = string(problem.Subtype)
|
|
decision.Retryable = problem.Retryable
|
|
|
|
switch {
|
|
case problem.Category == errs.CategoryAuthorization && problem.Code == 99991672:
|
|
decision.Class = "app_scope_missing"
|
|
decision.Terminal = true
|
|
case problem.Category == errs.CategoryAuthorization && problem.Code == 99991679:
|
|
decision.Class = "user_scope_missing"
|
|
decision.Terminal = true
|
|
case problem.Category == errs.CategoryAuthorization && problem.Subtype == errs.SubtypePermissionDenied:
|
|
decision.Class = "permission_denied"
|
|
decision.Terminal = true
|
|
case problem.Category == errs.CategoryNetwork && problem.Code == http.StatusForbidden:
|
|
decision.Class = "permission_denied"
|
|
decision.Terminal = true
|
|
case problem.Subtype == errs.SubtypeInvalidParameters || problem.Code == 1061002:
|
|
decision.Class = "invalid_api_parameters"
|
|
decision.Terminal = true
|
|
case problem.Subtype == errs.SubtypeRateLimit || problem.Code == 99991400:
|
|
decision.Class = "rate_limited"
|
|
decision.Terminal = true
|
|
case problem.Code == 1062507:
|
|
decision.Class = "parent_sibling_limit"
|
|
decision.Terminal = true
|
|
decision.Hint = "The destination parent folder has reached its child-count limit. Clean up that folder, choose another --folder-token, or split the upload across subfolders before retrying."
|
|
case problem.Subtype == errs.SubtypeQuotaExceeded || problem.Code == 1061043:
|
|
decision.Class = "file_size_limit"
|
|
case problem.Code == 1062009:
|
|
decision.Class = "upload_size_mismatch"
|
|
case problem.Code == 1061044:
|
|
decision.Class = "parent_node_missing"
|
|
decision.Terminal = true
|
|
decision.Hint = "The destination parent folder no longer exists or is not visible. Verify --folder-token, folder permissions, and whether a parent directory was deleted during push before retrying."
|
|
case problem.Subtype == errs.SubtypeNotFound || problem.Code == 1061007:
|
|
decision.Class = "remote_not_found"
|
|
case problem.Subtype == errs.SubtypeServerError || problem.Code == 1061001 || problem.Code == 2200:
|
|
decision.Class = "server_error"
|
|
decision.Terminal = true
|
|
case problem.Subtype == errs.SubtypeFailedPrecondition:
|
|
decision.Class = "local_file_changed"
|
|
default:
|
|
decision.Class = string(problem.Subtype)
|
|
}
|
|
return decision
|
|
}
|
|
|
|
func drivePushIsAlreadyDeleted(err error) bool {
|
|
problem, ok := errs.ProblemOf(err)
|
|
return ok && problem.Code == 1061007
|
|
}
|
|
|
|
func drivePushRemoteViews(entries []driveRemoteEntry, duplicateRemote string) (map[string]driveRemoteEntry, map[string]driveRemoteEntry, map[string][]driveRemoteEntry, error) {
|
|
remoteFiles := make(map[string]driveRemoteEntry, len(entries))
|
|
remoteFolders := make(map[string]driveRemoteEntry, len(entries))
|
|
fileGroups := make(map[string][]driveRemoteEntry)
|
|
|
|
for _, entry := range entries {
|
|
switch entry.Type {
|
|
case driveTypeFile:
|
|
fileGroups[entry.RelPath] = append(fileGroups[entry.RelPath], entry)
|
|
case driveTypeFolder:
|
|
remoteFolders[entry.RelPath] = entry
|
|
}
|
|
}
|
|
|
|
relPaths := make([]string, 0, len(fileGroups))
|
|
for rel := range fileGroups {
|
|
relPaths = append(relPaths, rel)
|
|
}
|
|
sort.Strings(relPaths)
|
|
|
|
for _, rel := range relPaths {
|
|
files := fileGroups[rel]
|
|
if len(files) == 1 {
|
|
remoteFiles[rel] = files[0]
|
|
continue
|
|
}
|
|
switch duplicateRemote {
|
|
case driveDuplicateRemoteNewest, driveDuplicateRemoteOldest:
|
|
chosen, err := chooseRemoteFile(files, duplicateRemote)
|
|
if err != nil {
|
|
return nil, nil, nil, err
|
|
}
|
|
remoteFiles[rel] = chosen
|
|
default:
|
|
return nil, nil, nil, errs.NewInternalError(errs.SubtypeUnknown, "unsupported duplicate remote strategy %q", duplicateRemote)
|
|
}
|
|
}
|
|
return remoteFiles, remoteFolders, fileGroups, nil
|
|
}
|
|
|
|
// drivePushEnsureFolder ensures a folder chain (rel_dir relative to the root
|
|
// folder identified by rootFolderToken) exists on Drive, creating any
|
|
// missing segments via /open-apis/drive/v1/files/create_folder. Returns the
|
|
// token of the deepest folder, suitable as parent_node for the upload.
|
|
//
|
|
// folderCache is shared with the caller so each segment is only created
|
|
// once per push, and so subsequent uploads under the same sub-tree reuse
|
|
// the freshly minted folder token without an extra round trip.
|
|
func drivePushEnsureFolder(ctx context.Context, runtime *common.RuntimeContext, rootFolderToken, relDir string, folderCache map[string]string) (string, error) {
|
|
if token, ok := folderCache[relDir]; ok {
|
|
return token, nil
|
|
}
|
|
parentRel, name := drivePushSplitRel(relDir)
|
|
parentToken, err := drivePushEnsureFolder(ctx, runtime, rootFolderToken, parentRel, folderCache)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
data, err := runtime.CallAPITyped(
|
|
"POST",
|
|
"/open-apis/drive/v1/files/create_folder",
|
|
nil,
|
|
map[string]interface{}{
|
|
"name": name,
|
|
"folder_token": parentToken,
|
|
},
|
|
)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
token := common.GetString(data, "token")
|
|
if token == "" {
|
|
return "", errs.NewInternalError(errs.SubtypeInvalidResponse, "create_folder for %q returned no folder token", relDir)
|
|
}
|
|
folderCache[relDir] = token
|
|
return token, nil
|
|
}
|
|
|
|
func drivePushEnsureParentToken(ctx context.Context, runtime *common.RuntimeContext, rootFolderToken, relPath string, folderCache map[string]string) (string, error) {
|
|
return drivePushEnsureFolder(ctx, runtime, rootFolderToken, drivePushParentRel(relPath), folderCache)
|
|
}
|
|
|
|
// drivePushUploadFile uploads (or overwrites) a single local file. When
|
|
// existingToken is non-empty, the request adds the file_token form field to
|
|
// trigger overwrite-with-version semantics on the backend; the response is
|
|
// expected to carry a non-empty `version`, which is propagated to the
|
|
// caller for the items[].version field. When existingToken is empty, this
|
|
// is a fresh upload under parentToken.
|
|
//
|
|
// Files larger than common.MaxDriveMediaUploadSinglePartSize fall back to
|
|
// the three-step prepare/part/finish flow, which mirrors drive +upload's
|
|
// existing multipart logic.
|
|
func drivePushUploadFile(ctx context.Context, runtime *common.RuntimeContext, file drivePushLocalFile, existingToken, parentToken string) (string, string, error) {
|
|
if err := drivePushValidateUploadRequest(file, existingToken, parentToken); err != nil {
|
|
return "", "", err
|
|
}
|
|
if err := drivePushVerifyLocalSnapshot(runtime, file); err != nil {
|
|
return "", "", err
|
|
}
|
|
if file.Size > common.MaxDriveMediaUploadSinglePartSize {
|
|
token, err := drivePushUploadMultipart(ctx, runtime, file, existingToken, parentToken)
|
|
// Multipart finish does not return version on the existing
|
|
// /open-apis/drive/v1/files/upload_finish contract; surface an
|
|
// empty version in that case rather than fabricating one. The
|
|
// markdown +overwrite path has the same gap and is tracked for a
|
|
// follow-up once the multipart endpoint exposes the field.
|
|
return token, "", err
|
|
}
|
|
return drivePushUploadAll(ctx, runtime, file, existingToken, parentToken)
|
|
}
|
|
|
|
func drivePushValidateUploadRequest(file drivePushLocalFile, existingToken, parentToken string) error {
|
|
if strings.TrimSpace(file.FileName) == "" {
|
|
return errs.NewValidationError(errs.SubtypeInvalidArgument, "cannot upload %q: file name is empty", file.RelPath)
|
|
}
|
|
if file.Size < 0 {
|
|
return errs.NewValidationError(errs.SubtypeInvalidArgument, "cannot upload %q: file size is negative", file.RelPath)
|
|
}
|
|
if strings.TrimSpace(parentToken) == "" {
|
|
return errs.NewValidationError(errs.SubtypeInvalidArgument, "cannot upload %q: parent folder token is empty", file.RelPath)
|
|
}
|
|
if err := validate.ResourceName(parentToken, "parent_node"); err != nil {
|
|
return errs.NewValidationError(errs.SubtypeInvalidArgument, "cannot upload %q: %s", file.RelPath, err)
|
|
}
|
|
if existingToken != "" {
|
|
if err := validate.ResourceName(existingToken, "file_token"); err != nil {
|
|
return errs.NewValidationError(errs.SubtypeInvalidArgument, "cannot overwrite %q: %s", file.RelPath, err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func drivePushVerifyLocalSnapshot(runtime *common.RuntimeContext, file drivePushLocalFile) error {
|
|
info, err := runtime.FileIO().Stat(file.OpenPath)
|
|
if err != nil {
|
|
return errs.NewValidationError(errs.SubtypeFailedPrecondition, "local file changed during push: %s is no longer readable: %v", file.RelPath, err).WithCause(err)
|
|
}
|
|
if !info.Mode().IsRegular() {
|
|
return errs.NewValidationError(errs.SubtypeFailedPrecondition, "local file changed during push: %s is no longer a regular file", file.RelPath)
|
|
}
|
|
if info.Size() != file.Size {
|
|
return errs.NewValidationError(errs.SubtypeFailedPrecondition, "local file changed during push: %s snapshot size no longer matches", file.RelPath)
|
|
}
|
|
if modTimer, ok := info.(interface{ ModTime() time.Time }); ok && !modTimer.ModTime().Equal(file.ModTime) {
|
|
return errs.NewValidationError(errs.SubtypeFailedPrecondition, "local file changed during push: %s snapshot modtime no longer matches", file.RelPath)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func drivePushUploadAll(_ context.Context, runtime *common.RuntimeContext, file drivePushLocalFile, existingToken, parentToken string) (string, string, error) {
|
|
f, err := runtime.FileIO().Open(file.OpenPath)
|
|
if err != nil {
|
|
return "", "", driveInputStatError(err)
|
|
}
|
|
defer f.Close()
|
|
|
|
fd := larkcore.NewFormdata()
|
|
fd.AddField("file_name", file.FileName)
|
|
fd.AddField("parent_type", driveUploadParentTypeExplorer)
|
|
fd.AddField("parent_node", parentToken)
|
|
fd.AddField("size", fmt.Sprintf("%d", file.Size))
|
|
if existingToken != "" {
|
|
// Overwrite mode: the backend interprets a non-empty file_token on
|
|
// upload_all as "replace this file's content and bump its version",
|
|
// matching the markdown +overwrite contract.
|
|
fd.AddField("file_token", existingToken)
|
|
}
|
|
fd.AddFile("file", f)
|
|
|
|
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
|
|
HttpMethod: http.MethodPost,
|
|
ApiPath: "/open-apis/drive/v1/files/upload_all",
|
|
Body: fd,
|
|
}, larkcore.WithFileUpload())
|
|
if err != nil {
|
|
if errs.IsTyped(err) {
|
|
return "", "", err
|
|
}
|
|
return "", "", wrapDriveNetworkErr(err, "upload failed: %v", err)
|
|
}
|
|
|
|
// ClassifyAPIResponse returns the data even on a non-zero code, so the
|
|
// token is available on a partial-success response (code != 0 alongside a
|
|
// non-empty data.file_token) where bytes have already landed under that
|
|
// token. Returning "" would force the caller to fall back to
|
|
// entry.FileToken and silently lose the token Drive actually used,
|
|
// defeating the overwrite-error token-stability handling in Execute.
|
|
data, err := runtime.ClassifyAPIResponse(apiResp)
|
|
token := common.GetString(data, "file_token")
|
|
if err != nil {
|
|
return token, "", err
|
|
}
|
|
if token == "" {
|
|
return "", "", errs.NewInternalError(errs.SubtypeInvalidResponse, "upload failed: no file_token returned")
|
|
}
|
|
version := common.GetString(data, "version")
|
|
if version == "" {
|
|
// Some backends return the version under data_version; accept either
|
|
// per the markdown +overwrite contract.
|
|
version = common.GetString(data, "data_version")
|
|
}
|
|
if existingToken != "" && version == "" {
|
|
// The protocol guarantees a non-empty version on overwrite. If the
|
|
// deployed backend hasn't shipped the field yet we surface the gap
|
|
// rather than report a phantom success — callers can downgrade to
|
|
// --if-exists=skip in the meantime.
|
|
return token, "", errs.NewInternalError(errs.SubtypeInvalidResponse, "overwrite for %q succeeded but no version was returned by upload_all", file.RelPath)
|
|
}
|
|
return token, version, nil
|
|
}
|
|
|
|
func drivePushUploadMultipart(_ context.Context, runtime *common.RuntimeContext, file drivePushLocalFile, existingToken, parentToken string) (string, error) {
|
|
prepareBody := map[string]interface{}{
|
|
"file_name": file.FileName,
|
|
"parent_type": driveUploadParentTypeExplorer,
|
|
"parent_node": parentToken,
|
|
"size": file.Size,
|
|
}
|
|
if existingToken != "" {
|
|
prepareBody["file_token"] = existingToken
|
|
}
|
|
prepareResult, err := runtime.CallAPITyped("POST", "/open-apis/drive/v1/files/upload_prepare", nil, prepareBody)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
uploadID := common.GetString(prepareResult, "upload_id")
|
|
blockSize := int64(common.GetFloat(prepareResult, "block_size"))
|
|
blockNum := int(common.GetFloat(prepareResult, "block_num"))
|
|
if uploadID == "" || blockSize <= 0 || blockNum <= 0 {
|
|
return "", errs.NewInternalError(errs.SubtypeInvalidResponse,
|
|
"upload_prepare returned invalid data: upload_id=%q, block_size=%d, block_num=%d",
|
|
uploadID, blockSize, blockNum)
|
|
}
|
|
|
|
fmt.Fprintf(runtime.IO().ErrOut, "Multipart upload: %s, block size %s, %d block(s)\n",
|
|
common.FormatSize(file.Size), common.FormatSize(blockSize), blockNum)
|
|
|
|
// Open the local file ONCE for the whole multipart loop. fileio.File
|
|
// implements io.ReaderAt, so each block is a fresh
|
|
// io.NewSectionReader over a shared fd — no need to reopen N times
|
|
// (which is what drive +upload's existing multipart helper does and
|
|
// what the original drive_push copy inherited; that pattern wastes
|
|
// one Open + Close + path-validation per block).
|
|
partFile, err := runtime.FileIO().Open(file.OpenPath)
|
|
if err != nil {
|
|
return "", driveInputStatError(err)
|
|
}
|
|
defer partFile.Close()
|
|
|
|
for seq := 0; seq < blockNum; seq++ {
|
|
offset := int64(seq) * blockSize
|
|
partSize := blockSize
|
|
if remaining := file.Size - offset; partSize > remaining {
|
|
partSize = remaining
|
|
}
|
|
|
|
fd := larkcore.NewFormdata()
|
|
fd.AddField("upload_id", uploadID)
|
|
fd.AddField("seq", fmt.Sprintf("%d", seq))
|
|
fd.AddField("size", fmt.Sprintf("%d", partSize))
|
|
fd.AddFile("file", io.NewSectionReader(partFile, offset, partSize))
|
|
|
|
apiResp, doErr := runtime.DoAPI(&larkcore.ApiReq{
|
|
HttpMethod: http.MethodPost,
|
|
ApiPath: "/open-apis/drive/v1/files/upload_part",
|
|
Body: fd,
|
|
}, larkcore.WithFileUpload())
|
|
if doErr != nil {
|
|
if errs.IsTyped(doErr) {
|
|
return "", doErr
|
|
}
|
|
return "", wrapDriveNetworkErr(doErr, "upload part %d/%d failed: %v", seq+1, blockNum, doErr)
|
|
}
|
|
|
|
if _, err := runtime.ClassifyAPIResponse(apiResp); err != nil {
|
|
return "", err
|
|
}
|
|
fmt.Fprintf(runtime.IO().ErrOut, " Block %d/%d uploaded (%s)\n", seq+1, blockNum, common.FormatSize(partSize))
|
|
}
|
|
|
|
finishResult, err := runtime.CallAPITyped("POST", "/open-apis/drive/v1/files/upload_finish", nil, map[string]interface{}{
|
|
"upload_id": uploadID,
|
|
"block_num": blockNum,
|
|
})
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
token := common.GetString(finishResult, "file_token")
|
|
if token == "" {
|
|
return "", errs.NewInternalError(errs.SubtypeInvalidResponse, "upload_finish succeeded but no file_token returned")
|
|
}
|
|
return token, nil
|
|
}
|
|
|
|
// drivePushDeleteFile deletes a single Drive file (type=file). Folders are
|
|
// never reached here because --delete-remote only iterates the type=file
|
|
// subset of the remote listing.
|
|
func drivePushDeleteFile(_ context.Context, runtime *common.RuntimeContext, fileToken string) error {
|
|
_, err := runtime.CallAPITyped(
|
|
"DELETE",
|
|
fmt.Sprintf("/open-apis/drive/v1/files/%s", validate.EncodePathSegment(fileToken)),
|
|
map[string]interface{}{"type": driveTypeFile},
|
|
nil,
|
|
)
|
|
return err
|
|
}
|
|
|
|
// drivePushParentRel returns the parent rel_path of rel ("" when the file
|
|
// lives at the root). The local walker emits forward-slash rel_paths so
|
|
// path.Dir is the right primitive here, not filepath.Dir.
|
|
func drivePushParentRel(rel string) string {
|
|
dir := path.Dir(rel)
|
|
if dir == "." || dir == "/" {
|
|
return ""
|
|
}
|
|
return dir
|
|
}
|
|
|
|
// drivePushSplitRel splits a non-empty rel into (parent, basename), both
|
|
// using forward slashes.
|
|
func drivePushSplitRel(rel string) (string, string) {
|
|
idx := strings.LastIndex(rel, "/")
|
|
if idx < 0 {
|
|
return "", rel
|
|
}
|
|
return rel[:idx], rel[idx+1:]
|
|
}
|