-
Notifications
You must be signed in to change notification settings - Fork 590
feat(drive): add +status shortcut for content-hash diff #692
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
fangshuyu-768
wants to merge
5
commits into
main
Choose a base branch
from
feat/drive-status
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
5 commits
Select commit
Hold shift + click to select a range
b52baa1
feat(drive): add +status shortcut for content-hash diff
fangshuyu-768 9dbef29
docs(skills): document drive +status in lark-drive skill
fangshuyu-768 4e2cb21
test(drive): cover --folder-token validation and add +status dry-run E2E
fangshuyu-768 7e9bc85
fix(drive): walk +status on canonical absolute root to close symlink/…
fangshuyu-768 32eaf8a
test(drive): pin walker behavior on child / circular symlinks for +st…
fangshuyu-768 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,317 @@ | ||
| // Copyright (c) 2026 Lark Technologies Pte. Ltd. | ||
| // SPDX-License-Identifier: MIT | ||
|
|
||
| package drive | ||
|
|
||
| import ( | ||
| "context" | ||
| "crypto/sha256" | ||
| "encoding/hex" | ||
| "fmt" | ||
| "io" | ||
| "io/fs" | ||
| "path/filepath" | ||
| "sort" | ||
| "strings" | ||
|
|
||
| larkcore "github.com/larksuite/oapi-sdk-go/v3/core" | ||
|
|
||
| "github.com/larksuite/cli/internal/output" | ||
| "github.com/larksuite/cli/internal/validate" | ||
| "github.com/larksuite/cli/shortcuts/common" | ||
| ) | ||
|
|
||
| const ( | ||
| driveStatusListPageSize = 200 | ||
| driveStatusFileType = "file" | ||
| driveStatusFolderType = "folder" | ||
| ) | ||
|
|
||
| type driveStatusEntry struct { | ||
| RelPath string `json:"rel_path"` | ||
| FileToken string `json:"file_token,omitempty"` | ||
| } | ||
|
|
||
| // DriveStatus walks --local-dir, recursively lists --folder-token, and reports | ||
| // four buckets (new_local, new_remote, modified, unchanged) by SHA-256 hash. | ||
| // | ||
| // Only Drive entries with type=file are compared; online docs (docx, sheet, | ||
| // bitable, mindnote, slides) and shortcuts are skipped because there is no | ||
| // equivalent local binary to hash against. | ||
| // | ||
| // SafeInputPath (applied by runtime.FileIO()) rejects absolute paths and any | ||
| // path that resolves outside cwd, which keeps the local side bounded to the | ||
| // caller's working directory. | ||
| var DriveStatus = common.Shortcut{ | ||
| Service: "drive", | ||
| Command: "+status", | ||
| Description: "Compare a local directory with a Drive folder by content hash", | ||
| Risk: "read", | ||
| Scopes: []string{"drive:drive.metadata:readonly", "drive:file:download"}, | ||
| AuthTypes: []string{"user", "bot"}, | ||
| Flags: []common.Flag{ | ||
| {Name: "local-dir", Desc: "local root directory (relative to cwd)", Required: true}, | ||
| {Name: "folder-token", Desc: "Drive folder token", Required: true}, | ||
| }, | ||
| Tips: []string{ | ||
| "Only entries with type=file are compared; online docs (docx, sheet, bitable, mindnote, slides) and shortcuts are skipped.", | ||
| "Files present on both sides are downloaded and SHA-256 hashed in memory to decide modified vs unchanged; expect noticeable I/O on large folders.", | ||
| }, | ||
| 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 common.FlagErrorf("--local-dir is required") | ||
| } | ||
| if folderToken == "" { | ||
| return common.FlagErrorf("--folder-token is required") | ||
| } | ||
| if err := validate.ResourceName(folderToken, "--folder-token"); err != nil { | ||
| return output.ErrValidation("%s", err) | ||
| } | ||
| // Path safety (absolute paths, traversal, symlink escape) is enforced | ||
| // upfront by the framework helper so the error message references the | ||
| // correct flag name; FileIO().Stat below would do the same check, but | ||
| // surface --file in its hint. | ||
| if _, err := validate.SafeLocalFlagPath("--local-dir", localDir); err != nil { | ||
| return output.ErrValidation("%s", err) | ||
| } | ||
| info, err := runtime.FileIO().Stat(localDir) | ||
| if err != nil { | ||
| return common.WrapInputStatError(err) | ||
| } | ||
| if !info.IsDir() { | ||
| return output.ErrValidation("--local-dir is not a directory: %s", localDir) | ||
| } | ||
| return nil | ||
| }, | ||
| DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { | ||
| return common.NewDryRunAPI(). | ||
| Desc("Walk --local-dir, recursively list --folder-token, and download files present on both sides to compare SHA-256."). | ||
| 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")) | ||
|
|
||
| // 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, so a raw walk on the | ||
| // user-supplied string can land outside cwd. 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. | ||
| // | ||
| // Validate already ran SafeLocalFlagPath (with the proper flag | ||
| // name in the error message), so a failure here is unexpected and | ||
| // only possible under a Validate↔Execute race. | ||
| safeRoot, err := validate.SafeInputPath(localDir) | ||
| if err != nil { | ||
| return output.ErrValidation("--local-dir: %s", err) | ||
| } | ||
| cwdCanonical, err := validate.SafeInputPath(".") | ||
| if err != nil { | ||
| return output.ErrValidation("could not resolve cwd: %s", err) | ||
| } | ||
|
|
||
| fmt.Fprintf(runtime.IO().ErrOut, "Walking local: %s\n", localDir) | ||
| localHashes, err := walkLocalForStatus(runtime, safeRoot, cwdCanonical) | ||
| if err != nil { | ||
| return err | ||
| } | ||
|
|
||
| fmt.Fprintf(runtime.IO().ErrOut, "Listing Drive folder: %s\n", common.MaskToken(folderToken)) | ||
| remoteFiles, err := listRemoteForStatus(ctx, runtime, folderToken, "") | ||
| if err != nil { | ||
| return err | ||
| } | ||
|
|
||
| paths := mergeStatusPaths(localHashes, remoteFiles) | ||
|
|
||
| var newLocal, newRemote, modified, unchanged []driveStatusEntry | ||
| for _, relPath := range paths { | ||
| localHash, hasLocal := localHashes[relPath] | ||
| remoteToken, hasRemote := remoteFiles[relPath] | ||
| switch { | ||
| case hasLocal && !hasRemote: | ||
| newLocal = append(newLocal, driveStatusEntry{RelPath: relPath}) | ||
| case !hasLocal && hasRemote: | ||
| newRemote = append(newRemote, driveStatusEntry{RelPath: relPath, FileToken: remoteToken}) | ||
| default: | ||
| remoteHash, err := hashRemoteForStatus(ctx, runtime, remoteToken) | ||
| if err != nil { | ||
| return err | ||
| } | ||
| entry := driveStatusEntry{RelPath: relPath, FileToken: remoteToken} | ||
| if localHash == remoteHash { | ||
| unchanged = append(unchanged, entry) | ||
| } else { | ||
| modified = append(modified, entry) | ||
| } | ||
| } | ||
| } | ||
|
|
||
| runtime.Out(map[string]interface{}{ | ||
| "new_local": emptyIfNil(newLocal), | ||
| "new_remote": emptyIfNil(newRemote), | ||
| "modified": emptyIfNil(modified), | ||
| "unchanged": emptyIfNil(unchanged), | ||
| }, nil) | ||
| return nil | ||
| }, | ||
| } | ||
|
|
||
| // walkLocalForStatus walks the canonical absolute root produced by | ||
| // SafeInputPath. Using the canonical root keeps the kernel from | ||
| // following any symlink hidden inside the user-supplied --local-dir | ||
| // (e.g. "link/..", which filepath.Clean shrinks to "." but which OS | ||
| // path resolution would resolve through the symlink target). For each | ||
| // hit, we report rel_path relative to root for the JSON output, and | ||
| // convert the absolute path to a cwd-relative form so FileIO.Open's | ||
| // SafeInputPath check (which rejects absolute paths) still applies. | ||
| func walkLocalForStatus(runtime *common.RuntimeContext, root, cwdCanonical string) (map[string]string, error) { | ||
| files := make(map[string]string) | ||
| // FileIO has no walker today and shortcuts can't import internal/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 policy (do not follow child symlinks) keeps the | ||
| // traversal inside that canonical subtree. | ||
| err := filepath.WalkDir(root, func(absPath string, d fs.DirEntry, walkErr error) error { //nolint:forbidigo // see comment above | ||
| if walkErr != nil { | ||
| return walkErr | ||
| } | ||
| if d.IsDir() || !d.Type().IsRegular() { | ||
| return nil | ||
| } | ||
| rel, err := filepath.Rel(root, absPath) | ||
| if err != nil { | ||
| return err | ||
| } | ||
| relToCwd, err := filepath.Rel(cwdCanonical, absPath) | ||
| if err != nil { | ||
| return err | ||
| } | ||
| sum, err := hashLocalForStatus(runtime, relToCwd) | ||
| if err != nil { | ||
| return err | ||
| } | ||
| files[filepath.ToSlash(rel)] = sum | ||
| return nil | ||
| }) | ||
| if err != nil { | ||
| return nil, output.Errorf(output.ExitInternal, "io", "walk %s: %s", root, err) | ||
| } | ||
| return files, nil | ||
| } | ||
|
|
||
| func hashLocalForStatus(runtime *common.RuntimeContext, path string) (string, error) { | ||
| f, err := runtime.FileIO().Open(path) | ||
| if err != nil { | ||
| return "", common.WrapInputStatError(err) | ||
| } | ||
| defer f.Close() | ||
| h := sha256.New() | ||
| if _, err := io.Copy(h, f); err != nil { | ||
| return "", output.Errorf(output.ExitInternal, "io", "hash %s: %s", path, err) | ||
| } | ||
| return hex.EncodeToString(h.Sum(nil)), nil | ||
| } | ||
|
|
||
| func listRemoteForStatus(ctx context.Context, runtime *common.RuntimeContext, folderToken, relBase string) (map[string]string, error) { | ||
| files := make(map[string]string) | ||
| pageToken := "" | ||
| for { | ||
| params := map[string]interface{}{ | ||
| "folder_token": folderToken, | ||
| "page_size": fmt.Sprint(driveStatusListPageSize), | ||
| } | ||
| if pageToken != "" { | ||
| params["page_token"] = pageToken | ||
| } | ||
| result, err := runtime.CallAPI("GET", "/open-apis/drive/v1/files", params, nil) | ||
| if err != nil { | ||
| return nil, err | ||
| } | ||
| rawFiles, _ := result["files"].([]interface{}) | ||
| for _, item := range rawFiles { | ||
| f, ok := item.(map[string]interface{}) | ||
| if !ok { | ||
| continue | ||
| } | ||
| fType := common.GetString(f, "type") | ||
| fName := common.GetString(f, "name") | ||
| fToken := common.GetString(f, "token") | ||
| if fName == "" || fToken == "" { | ||
| continue | ||
| } | ||
| switch fType { | ||
| case driveStatusFileType: | ||
| files[joinRelStatus(relBase, fName)] = fToken | ||
| case driveStatusFolderType: | ||
| subFiles, err := listRemoteForStatus(ctx, runtime, fToken, joinRelStatus(relBase, fName)) | ||
| if err != nil { | ||
| return nil, err | ||
| } | ||
| for k, v := range subFiles { | ||
| files[k] = v | ||
| } | ||
| } | ||
| } | ||
| hasMore, _ := result["has_more"].(bool) | ||
| nextToken := common.GetString(result, "next_page_token") | ||
| if !hasMore || nextToken == "" { | ||
| break | ||
| } | ||
| pageToken = nextToken | ||
| } | ||
| return files, nil | ||
| } | ||
|
|
||
| func hashRemoteForStatus(ctx context.Context, runtime *common.RuntimeContext, fileToken string) (string, error) { | ||
| resp, err := runtime.DoAPIStream(ctx, &larkcore.ApiReq{ | ||
| HttpMethod: "GET", | ||
| ApiPath: fmt.Sprintf("/open-apis/drive/v1/files/%s/download", validate.EncodePathSegment(fileToken)), | ||
| }) | ||
| if err != nil { | ||
| return "", output.ErrNetwork("download %s: %s", common.MaskToken(fileToken), err) | ||
| } | ||
| defer resp.Body.Close() | ||
| h := sha256.New() | ||
| if _, err := io.Copy(h, resp.Body); err != nil { | ||
| return "", output.ErrNetwork("hash remote %s: %s", common.MaskToken(fileToken), err) | ||
| } | ||
| return hex.EncodeToString(h.Sum(nil)), nil | ||
| } | ||
|
|
||
| func joinRelStatus(base, name string) string { | ||
| if base == "" { | ||
| return name | ||
| } | ||
| return base + "/" + name | ||
| } | ||
|
|
||
| func mergeStatusPaths(local, remote map[string]string) []string { | ||
| seen := make(map[string]struct{}, len(local)+len(remote)) | ||
| for p := range local { | ||
| seen[p] = struct{}{} | ||
| } | ||
| for p := range remote { | ||
| seen[p] = struct{}{} | ||
| } | ||
| out := make([]string, 0, len(seen)) | ||
| for p := range seen { | ||
| out = append(out, p) | ||
| } | ||
| sort.Strings(out) | ||
| return out | ||
| } | ||
|
|
||
| func emptyIfNil(s []driveStatusEntry) []driveStatusEntry { | ||
| if s == nil { | ||
| return []driveStatusEntry{} | ||
| } | ||
| return s | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.